Hexagon — гексогональная архитектура для Kotlin Backend
В математике идеальной фигурой является шар. В мире микросервисов близкой к идеальной можно считать шестиугольник. Сегодня мы поговорим о преимуществах и недостатках гексагональной архитектуры и относительно новой, но перспективной библиотеке Hexagon для Kotlin, предоставляющей базовую функциональность для создания веб-приложений и API, разрабатываемых с использованием гексагональной архитектуры. В конечном итоге мы спроектируем общую структуру взаимодействия микросервисов и разработаем несколько компонентов некоторого идеального приложения для ведения домашней бухгалтерии и автоматизации оплаты счетов и налогов.
В качестве примера мы будем рассматривать относительно простую систему для домашней бухгалтерии с элементами автоматизации (извлечение операций по счету, оплата поступающих счетов и налогов, отправка информации о доходах в налоговую). Сегодня мы будем говорить только о backend, но обязательно обозначим роль frontend-части в проектировании гексогональной архитектуры.
Определим основные подсистемы и требования к их взаимодействию. Из описания задачи видно, что будет необходимо обеспечить интеграцию с неизвестным заранее количеством банков (для извлечения операций по банковским счетам), предусмотреть возможность получения информации о выставленных на оплату счетах, подготовить налоговую декларацию (и подписать ее с применением ЭЦП) и отправить декларацию в налоговый орган. Кроме того, хотя это неочевидно из описания, но для пользователя важно предусмотреть возможность регистрировать операции вручную (например, при оплате наличными), а также выполнять корректировку категории операции. Также в будущем будет полезна подсистема автоматической классификации операций (по сумме, комментарию, содержанию чека и иным метаданным, полученным от банка).
Один из вариантов решения задачи - создать единое приложение, которое будет работать с одной базой данных и выступать в роли монолита, который экспортирует во внешний мир некоторый API (или сразу пользовательский веб-интерфейс) со всеми доступными функциями. Основная проблема такого подхода состоит в том, что внесение любого (даже незначительного) исправления приведет к необходимости пересборки всего приложения. Кроме того, добавление нового компонента (например, подключение дополнительной интеграции к банку) может потребовать множественных изменений в различных подсистемах, синхронного изменения конфигураций, изменения схемы базы данных и возможной доработки клиентского приложения. Также, при необходимости масштабирования системы (например, по мере увеличения количества пользователей) будет необходимо создавать дополнительные экземпляры всего монолита, что не всегда возможно, например если внутри запущенного процесса (не в базе данных или внешнем хранилище) сохраняется текущее состояние для пользователя. А что делать, если основная нагрузка приходится на подсистему отчетов, как масштабировать или изменить доступные ресурсы только для нее? В монолите, к сожалению, никак, это единое приложение.
Как альтернатива монолитам была предложена архитектура, основанная на сервисах (SOA для всей организации, микросервисов для компонентов одной или нескольких взаимосвязанных систем) - небольших функционально завершенных элементах системы, каждый из которых работает с собственным источником данных. Для взаимодействия между сервисами используется обмен данными через сеть, при этом существует десяток протоколов для реализации удаленного вызова процедур (Remote Procedure Call - RPC), работающие как поверх HTTP (например SOAP, как основа Web-сервисов в сервис-ориентированной архитектуре; XML RPC), так и двоичных протоколов сериализации (например, SunRPC, DCE/RPC, Java RMI, а также gRPC, о которым мы говорили в этой статье). В конечном итоге все реализации создают дополнительную абстракцию (stub, skeleton, proxy, ...) для скрытия процессов обмена аргументами и результатом выполнения функции (marshalling/unmarshalling) и предоставляют общий механизм вызова удаленной функции (метода), аналогичный вызовам локальных функций (методов) в выбранной технологии разработки.
Но все же остается открытым вопрос - как оптимально разделить приложение на независимые фрагменты, чтобы уменьшить неизбежные расходы по производительности при передаче данных через сеть и при этом сохранить гибкость в масштабировании и замене компонентов.
Среди множества подходов к построению систем на основе микросервисов мы рассмотрим архитектурный шаблон разделения системы на независимые компоненты (например, ядро, интеграции с другими системами, пользовательский интерфейс), которые взаимодействуют между собой с использованием “портов” и “адаптеров”. Порт предоставляет единообразный интерфейс, как для предоставления сервиса другим частям системы, так и для подключения к другим существующим микросервисам и подсистемам (например, хранилищу данных). Адаптер реализует шаблон проектирования "Adapter" и выполняет согласование интерфейса (API) реализации и согласованного интерфейса порта. Такой подход позволяет интегрировать существующие legacy-системы и использовать разнородные решения для хранения данных, а также бесшовно вносить изменения в систему и заменять отдельные компоненты без необходимости внесения изменений в код других компонентов. Еще один важный аспект этого подхода в том, что изначально подразумевается более сложное взаимодействие между компонентами, чем в привычной многослойной архитектуре, и это значительно ближе к современному пониманию микросервисов (и заодно помогает избежать создание избыточной прокси-методов для проброски запросов "сквозь слой" и уменьшить взаимозависимость между компонентами).
Идею такого описания архитектуры предложил в 2005 году Алистер Кокберн (который также является одним из соавторов Agile Manifesto) и она получила название “гексогональной архитектуры”. Изображение компонента как гексагона (шестиугольника), конечно, условно и предоставляет возможность отобразить наличие нескольких взаимосвязей с другими частями системы (конечно же стороны можно разделять дополнительно, если необходимо показать большее количество портов). Компонент часто представляется двумя вложенными шестиугольниками, где внутренний - обозначение основной функции компонента, а во внешнем представлены порты (точки подключения к компоненту) и адаптеры (преобразователи интерфейса к конкретной реализации), но в действительности нет единого соглашения об изображении компонента и могут использоваться также элементы UML-нотации (соединение интерфейса и реализации), а также несколько уровней вложенности шестиугольников (для обозначения направления инъекции зависимостей между абстракциями системы).
Поскольку все компоненты логически разделены и при этом могут взаимодействовать посредством “портов и адаптеров”, гексагональная архитектура подходит одинаково хорошо как для описания структуры и коммуникации компонентов в монолитном приложении (в этом случае "портами" становятся контракты технологии разработки по взаимодействию с объектами и получению результатов, а в роли адаптеров могут быть классы-обертки для согласования конкретных реализаций и ожидаемых интерфейсов), так и для микросервисной архитектуры (где компоненты могут быть запущены как самостоятельные контейнеры, роль портов выполняет сервер API-шлюза и механизмы обнаружения сервисов, а адаптерами могут быть как встроенные механизмы трансформации запросов, интегрированные в API-шлюз, промежуточные преобразователи сервиса, выступающего в роли посредника (ETL, Kafka Connect Transformations и др.), или фрагменты кода для взаимодействия со сторонней системой (например, интернет-банком) или существующим драйвером (например, реализации действий средствами реляционной базы данных). С точки зрения кода взаимодействие с другим компонентом описывается через методы интерфейса, которые реализуются либо самостоятельно (например, при получении информации об операциях над банковским счетом), либо описаны в соответствующей библиотеке (например, выполнение запросов в базе данных) или являются результатом кодогенерации на основе описания интерфейса (proto-файл, IDL, WSDL и др.).
Визуально компоненты приложения отображаются в виде касающихся или взаимодействующих иных образом шестиугольников, каждый из которых является независимым компонентом системы (например, микросервисом, базой данных, внешней системой, мобильным приложением), при этом вблизи соприкасающихся граней приводится описание порта (например, это может быть протокол или название порта в описании сервиса в Kubernetes, а также название интерфейса, который должен быть реализован сервисом), а также адаптера (описание согласования интерфейса для порта и способа реализации в конкретной системе). При этом порты могут быть как исходящими (например, подключение к базе данных или банку), так и входящими (публикация внешнего API, запрос истории транзакций по счету).
Одними из наиболее важных критериев в выделении компонента как самостоятельной единицы станут ответы на вопросы: “Будет ли необходимость заменять эту часть или выбирать одну из нескольких реализаций”? и “Будет ли эта часть использоваться более чем одним компонентом”? Например, подсистема логирования является хорошим кандидатом для выделения в самостоятельный компонент. Также как и код для интеграции с банковскими системами (поскольку мы обговаривали, что предполагается взаимодействие с разными банками). В то же время, выделять в отдельный код логику, происходящую внутри подсистемы классификации транзакции, не очень рационально, поскольку она тесно связана с моделью данных и, при необходимости замены, будет заменяться вместе с компонентом классификации.
Вернемся к нашему приложению и попробуем спроектировать его с использованием гексогональной архитектуры. Из описания функциональности можно выделить предварительный список компонентов:
Мобильное приложение;
Ядро системы (API);
Операции со счетами;
База данных состояния счетов;
Подсистема авторизации;
База данных пользователей;
Подсистема классификации транзакций;
Интеграция с банком (получение транзакций, выполнение операций);
Интеграция с налоговой службой (получение информации о налогах, отправка налоговой декларации);
Цифровая подпись документов;
Подсистема отчетов;
Подсистема протоколирования действий.
Почти уверен, что вы предложите другой вариант разделения и это совершенно нормально, более того - с большой вероятностью и мой и ваш вариант разделения будет изменяться в процессе разработке по мере проработке функциональных требований.
Перейдем к изображению архитектуры системы и начнем с объединения компонентов. Пока ограничимся изображением связей между подсистемами без уточнения направления вызовов, портов и адаптеров.
Давайте дополним диаграмму направлением взаимодействия компонентов и заодно детализируем информацию о портах и адаптерах. Порт указывается с принимающей запрос стороны, адаптер - со стороны вызывающего компонента.
Можно заметить, что диаграмма для гексагональной архитектуры подразумевает возможность сложных видов взаимодействия между компонентами и из нее можно получить информацию, необходимую для настройки шлюза или механизма обнаружения сервисов и определить какие компоненты могут быть заменены (например, для целей тестирования). При этом контракт описывается в терминах “интерфейс”-“реализация”, где реализация зачастую будет представлять из себя прокси-адаптер вокруг существующего сервиса или протокола (например, для подключения к базе данных).
Перейдем к разработке каркаса нашего приложения. Как упоминалось в начале статьи, мы будем использовать молодую, но активно развивающуюся библиотеку Hexagon. Библиотека предлагает базовые интерфейсы для реализации типовых портов (создание веб-сервера и клиента, сериализация, применение шаблонов для веб-ресурсов), встроенное решение для инъекции зависимостей и адаптеры (клиент и сервер на основе встраиваемой Jetty, сериализации в форматы csv, json, xml и yaml и применения шаблонов freemarker и pebble).
Библиотека представляет базовые порты для следующих операций:
логирование (LoggingPort, LoggingManager);
сериализация (SerializationManager);
конверторы (ConverterManager);
http-клиент и сервер (HttpClientPort, HttpServerPort): com.hexagonkt:http_server_jetty:2.0.2, com.hexagonkt:http_client_jetty:2.0.2;
работа с шаблонами (TemplatePort, TemplateManager): com.hexagonkt:templates:2.0.2;
хранилище и реализация в Mongo (Store): com.hexagonkt:store_mongodb:2.0.2.
Особенность библиотеки в том, что каждый порт является полностью самостоятельным и не подразумевает каких-либо явных зависимостей между ними, при этом могут использоваться разные реализации адаптеров (при этом с точки зрения компонента замена адаптера не потребует внесения никаких изменений в код).
Рассмотрим архитектуру системы на примере использования шаблонов. Для хранения доступных адаптеров (реализаций порта) используется менеджер - синглтон-объект, проксирующий запрос метода render к одному из существующих адаптеров (выбор подходящего адаптера выполняется по проверке URL на соответствие регулярному выражению). Список адаптеров может быть переопределен в менеджере и в него могут быть добавлены собственные реализации интерфейса TemplatePort.
Сейчас библиотека находится в процессе миграции на версию 2.0, некоторые модули (например, тестирование) еще не работают, но основное ядро и базовая функциональность уже доступны. Начнем с установки библиотеки Hexagon, для этого добавим в зависимости build.gradle:
implementation("com.hexagonkt:core:2.0.2")
Рассмотрим вначале реализацию портов и адаптеров, как частей единого приложения (монолита) на примере компонента извлечения истории транзакций из внешнего банковского API. Поскольку предполагается использование нескольких банков, то добавим единую схему идентификации номеров счетов, содержащую префикс банка, например: “greenbank:идентификатор_клиента”. Идентификатор будет использоваться менеджером для выбора подходящего адаптера.
Начнем с описания порта (интерфейса) и вспомогательных классов:
import java.time.LocalDateTime
data class TransactionInfo(val amount:Int, val datetime:LocalDateTime, val comment:String)
interface BankTransactionsPort {
fun getTransaction(account: String, since:LocalDateTime): List<TransactionInfo>
}
Дальше будет необходимо создать менеджер:
import java.time.LocalDateTime
object BankTransactionsManager {
var adapters = mutableMapOf<Regex, BankTransactionsPort>()
fun register(regex: Regex, bankTransactionsPort: BankTransactionsPort) {
adapters[regex] = bankTransactionsPort
}
fun getTransaction(account: String, since: LocalDateTime): List<TransactionInfo>? {
return adapters.filter { it.key.matches(account) }.firstNotNullOfOrNull { it.value }
?.getTransaction(account, since)
}
}
И простой адаптер с реализацией (пока с подстановкой тестовых данных)
import java.time.LocalDateTime
class BankTransactionsGreenBankAdapter : BankTransactionsPort {
override fun getTransaction(account: String, since: LocalDateTime): List<TransactionInfo> {
return listOf(
TransactionInfo(100000, LocalDateTime.now(), "Sample transaction for 1000 rub")
)
}
}
Запустим простой тест:
fun main() {
BankTransactionsManager.register("greenbank:.*".toRegex(), BankTransactionsGreenBankAdapter());
assert(BankTransactionsManager.getTransaction("greenbank:123", java.time.LocalDateTime.MIN)?.isNotEmpty() != null)
}
Теперь используем компоненты Hexagon для преобразования ответа в JSON, используем протоколирование и веб-сервер для публикации реализации порта.
Добавим в getTransactions вывод сообщения в журнал:
import com.hexagonkt.core.logging.logger
import java.time.LocalDateTime
class BankTransactionsGreenBankAdapter : BankTransactionsPort {
override fun getTransaction(account: String, since: LocalDateTime): List<TransactionInfo> {
logger.info { "Get transaction for green bank since $since" }
return listOf(
TransactionInfo(100000, LocalDateTime.now(), "Sample transaction for 1000 rub")
)
}
}
Используем реализации портов сериализации и веб-сервера (Jetty) для обеспечения доступа к сервису через веб (дополнительно свяжем все шаблоны с адаптером FreeMarkerAdapter):
fun main() {
BankTransactionsManager.register("greenbank:.*".toRegex(), BankTransactionsGreenBankAdapter())
TemplateManager.adapters = mapOf(".*".toRegex() to FreeMarkerAdapter)
serve(HttpServerSettings(bindPort = 8080)){
get("/transactions/{bank}/{id}") {
val bank = pathParameters["bank"]
val id = pathParameters["id"]
ok(
TemplateManager.render(
URL("classpath:transactions.json.ftl"),
mapOf("txs" to BankTransactionsManager.getTransaction("$bank:$id", java.time.LocalDateTime.MIN))
)
)
}
}
}
И разместим шаблон FreeMarker в resources:
<transactions>
<#list txs as tx>
<transaction comment="${tx.comment}" amount="${tx.amount}" datetime="${tx.datetime}"/>
</#list>
</transactions>
Также добавим зависимости в build.gradle:
implementation("com.hexagonkt:http_server_jetty:2.0.2")
implementation("com.hexagonkt:templates_freemarker:2.0.2")
И проверим корректность работы веб-сервиса:
curl http://localhost:2010/transactions/greenbank/123
<transactions>
<transaction comment="Sample transaction for 1000 rub" amount="100,000" datetime="2022-02-05T22:58:47.504926300"/>
</transactions>
Теперь используем механизм сериализации Hexagon.
Добавим зависимость
implementation("com.hexagonkt:serialization_jackson_json:2.0.2")
и заменим возвращаемое значение на результат сериализации
import com.hexagonkt.core.media.ApplicationMedia
import com.hexagonkt.http.model.HttpStatus
import com.hexagonkt.http.server.jetty.serve
import com.hexagonkt.serialization.SerializationManager
import com.hexagonkt.serialization.jackson.json.JsonFormat
import com.hexagonkt.serialization.serialize
import com.hexagonkt.templates.TemplateManager
import com.hexagonkt.templates.freemarker.FreeMarkerAdapter
import java.time.LocalDateTime
fun main() {
BankTransactionsManager.register("greenbank:.*".toRegex(), BankTransactionsGreenBankAdapter())
TemplateManager.adapters = mapOf(".*".toRegex() to FreeMarkerAdapter)
SerializationManager.formats = setOf(JsonFormat())
serve(HttpServerSettings(bindPort = 8080)) {
get("/transactions/{bank}/{id}") {
val bank = pathParameters["bank"]
val id = pathParameters["id"]
val result = BankTransactionsManager.getTransaction("$bank:$id", LocalDateTime.MIN)
if (result == null) {
send(HttpStatus[500]!!)
} else {
ok(result.serialize(ApplicationMedia.JSON))
}
}
}
}
Теперь нам предстоит организовать взаимодействие двух микросервисов через веб-протокол с использованием модели порт-адаптер.
Организуем взаимодействие сервиса AccountsSubsystem и разработанного сервиса TransactionService. Согласно модели, портом является интерфейс (контракт) для подключения к сервису, поэтому необходимо только реализовать метод для подключения из внешнего сервиса.
Сначала создадим порт и адаптер для получения данных из микросервиса.
interface GetTransactionsPort {
fun getTransactions(bank: String, account: String, since: LocalDateTime): List<TransactionInfo>
}
class GetTransactionsAdapter : GetTransactionsPort {
override fun getTransactions(bank: String, account: String, since: LocalDateTime): List<TransactionInfo> {
val client = HttpClient(JettyClientAdapter(), baseUrl = URL("http://localhost:8080"))
client.start()
val result = client.get("/transactions/$bank/$account").body.toString()
@Suppress("UNCHECKED_CAST")
return SerializationManager.formatOf(ApplicationMedia.JSON).parse(result) as List<TransactionInfo>
}
}
Здесь мы используем возможности адаптера порта HttpClientPort (JettyClientAdapter) для отправки запроса между микросервисами и интегрированные механизмы сериализации для разбора ответа. Для создания экземпляра адаптера будем использовать синглтон-объект менеджера.
object GetTransactionsManager {
var adapters = mapOf<String, GetTransactionsAdapter>()
fun getTransactions(bank: String, account: String, since: LocalDateTime): List<TransactionInfo>? =
adapters[bank]?.getTransactions(bank, account, since)
}
Проверим правильность взаимодействия между микросервисами:
import com.hexagonkt.core.media.ApplicationMedia
import com.hexagonkt.http.client.HttpClient
import com.hexagonkt.http.client.jetty.JettyClientAdapter
import com.hexagonkt.serialization.SerializationManager
import com.hexagonkt.serialization.jackson.json.JsonFormat
import java.net.URL
import java.time.LocalDateTime
fun main() {
GetTransactionsManager.adapters = mapOf("greenbank" to GetTransactionsAdapter())
SerializationManager.formats = setOf(JsonFormat())
assert(GetTransactionsManager.getTransactions("greenbank", "123", LocalDateTime.MIN)?.size==1)
}
Аналогичным образом может быть организованы порты и адаптеры для всех остальных связей разрабатываемой системы. В исходных текстах на Github также приведены примеры для передачи сложных структур данных в POST-запросах, но выше мы рассмотрели все основные моменты, важные для создания распределенного приложения с гексогональной архитектурой с использованием библиотеки Hexagon.
Исходные тексты приведены в Github: https://github.com/dzolotov/kotlin-hexagon-sample
Также хочу пригласить всех на бесплатный демоурок курса Kotlin Backend Developer, который пройдет уже 9 февраля на платформе OTUS. Регистрация доступна по ссылке.