17 августа 2023

Шина данных на Golang

Что такое шина данных

Шина данных — это решение, которое становится посредником при передаче информации. Она применяется, если в сервисе большой объем различной бизнес-логики или функциональности, которую необходимо разнести, избавившись от их зависимости друг от друга.

Шина позволяет обмениваться любыми данными между программными слоями приложения, избегая проблемы связности (импорта, в том числе циклического). Можно сказать, что это один из классических инструментов или решений, исповедующих подход чистой архитектуры.

67.png

Если бы шина данных существовала в реальной жизни, то она бы выглядела как арбитр между конфликтующими людьми, которые по каким-то причинам не могут общаться между собой напрямую, причем не только двумя, как в обычном споре, их может быть и десять, и больше. При этом при попадании информации в шину ее отправитель остается анонимным для получателя.

В процессе разработки приложения на Golang программная часть организуется в пакеты (слои). Слой — это пакет или набор пакетов, решающих какую-либо логически законченную функциональность либо бизнес-задачу.

Различным слоям между собой в процессе работы приложения необходимо обмениваться данными при выполнении своих задач, для этого приходится импортировать пакеты одного слоя в другой, и, если пакетов очень много, возникают проблемы очень большой связанности между ними. Эту проблему решает шина данных.

Какие проблемы решает шина данных на Golang

Сильная связность и зависимость слоев между собой может привести к следующим проблемам:

1) Импорт одним пакетом другого.

Это указание в пакете на физическое расположение другого пакета (локального в файловой системе или в сети Интернет), который он импортирует, т.е., пути к нему.

2) При изменении слоя надо учитывать все зависимости.

Это означает, что у пакета меняется API. Соответственно, все явно связанные с ним пакеты надо будет проверять и, скорее всего, тоже изменять. Это может приводить к целым цепочкам изменений или даже невозможности что-то поменять без кардинальной переделки приложения.

3) Перенос слоя в другое место или вынос в отдельный сервис вызывает проблемы.

Это означает, что физически меняется место расположения пакета. Следовательно, нужно менять все пути импорта.

4) Циклическая зависимость пакетов.

Это случай, когда два пакета импортируют друг друга. Golang запрещает такие операции.

5) Архитектура приложения цементируется, изменения чреваты затратами.

Здесь объединены первый и второй пункты в бОльшем масштабе, т.е., когда принимается решение о принципиальном изменении структуры приложения.

Как работает шина данных

Идея нашего решения взята из gRPC. Для начала выделяется отдельный пакет с описанием типов, значения которых будут использоваться для обмена между слоями. Помимо самих значений эти типы одновременно будут идентификаторами подписки (общения между слоями).

При старте приложения инициализируется шина данных. С одной стороны, пакет-отправитель посылает в шину данные. С другой стороны, принимающий пакет-подписчик получает эти данные из шины.

Принимающий пакет, в котором нужно получать значения какого-либо типа подписывается, передав тип в качестве параметра при вызове функции подписки. В ответ возвращается канал, в который будут приходить значения указанного типа при появлении их в шине — когда какой-то слой будет направлять в шину значения данного типа, «подписчик» будет их получать, читая канал. Технически подписчики — это каналы, привязанные к определенному типу (индексу массива) через вызов функции подписки.

С другой стороны, из любого места в программе можно отправить в шину данных какое-то значение, и, у типа передаваемого значения существует хотя бы один подписчик, то он его получит через канал. При это надо учитывать следующие факты:

  • подписчиков может быть много;
  • если нет ни одного подписчика, данные никуда не идут, поскольку никому не нужны;
  • данные передаются асинхронно. Отправляющая сторона не блокируется при отправке данных в шину;
  • отписка происходит через закрытие канала, как со стороны подписчика, так и со стороны шины данных, при полном завершении работы.

Таким образом, обе стороны — пакет-отправитель и пакет-получатель — никак не связаны между собой напрямую, что и решает проблему связанности пакетов.

Что происходит «под капотом»

При старте шины данных происходит инициализация буферизированного канала и массива подписчиков по типам.

Также запускается отдельная программа — сердце шины данных. Эта программа в цикле читает канал, в который попадают отправляемые данные.

В цикле определяется тип каждого получаемого значения.

По этому типу определяются его подписчики (из массива), и им отправляется полученное значение.

При вызове функции завершения работы шина данных блокирует дальнейший прием данных, затем посылает подписчикам значение «nil» — сигнал к завершению работы.

Далее шина данных ждет завершения работы всех подписчиков, закрывает все каналы и завершает свою работу.

Если шина используется для монолитных решений, которые хотят перейти на микросервисную архитектуру, то на первом этапе в монолите можно разделить связанные программные блоки с помощью шины данных, затем, убедившись, что все работает корректно, можно безопасно выносить разделенные таким образом части монолита на отдельные сервисы.

Отличие будет только в том, что вместо внутренней шины данных будет либо асинхронный брокер сообщений (RabbitMQ, Kafka), либо синхронная работа по gRPC.

В среднем на передачу данных по шине уходит 650 наносекунд на единицу данных. Ознакомиться с нашей реализацией шины на Golang и посмотреть производительность можно в тесте в репозитории GitHub.

1