Hexagonal Architecture
References:
Hexagonal Architecture помогает нам ориентироваться в сложностях масштабируемости, создавая дизайн, который слабо связан, но при этом целостен. Это не просто архитектурный подход, это прагматичное решение для современных проблем проектирования программного обеспечения.
Традиционная архитектура
Традиционный способ проектирования приложения использует то, что мы называем 3-tier architecture (трёхуровневой архитектурой). Ваше приложение разделено на три уровня.
Presentation Layer
Первый — это Presentation Layer (уровень представления). Это слой, с которым взаимодействуют ваши пользователи. Это может быть интерфейс вашего приложения или в некоторых случаях API-контракт, который вы предоставляете своим пользователям.
Logic Layer
Второй уровень — это Logic Layer (логический уровень) или Business Logic Layer (уровень бизнес логики). Как следует из названия, это основная часть вашего приложения, где находится вся логика вашей системы.
Data Layer
Наконец, у нас есть Data Layer (уровень данных) или Data Access Layer (уровень доступа к данным), который управляет тем, как данные сохраняются в вашем приложении.
Это обычно хороший старт при попытке спроектировать приложение. Однако этот многоуровневый подход не говорит о том, как эти уровни взаимодействуют. Если не быть осторожным, очень легко сделать так, чтобы каждый уровень был сильно связан с другим.
Порты и адаптеры
Если вы не слышали о Hexagonal Architecture (шестиугольная архитектура), то, возможно, слышали о паттерне Ports & Adapters (порты и адаптеры).
Ports
Для каждого ввода и вывода в приложение у нас есть Port (порт). Это просто abstraction (абстракция). Приложению не важно, сохраняем ли мы данные в базу данных, файловую систему или в очередь событий. Ему нужно знать только то, что существует способ чтения и записи данных.
Adapters
Весь код, отвечающий за фактическое общение с базой данных, находится внутри Adapter (адаптера). Таким образом, приложению не нужно понимать что-либо о технологиях, которые используются, — все это делается внутри адаптера. Если вы когда-либо писали repository (репозиторий) для базы данных, то, вероятно, знакомы с этой концепцией.
Driving (Primary) vs Driven (Secondary)
Входные данные заставляют наше приложение что-то делать, а выходные данные создаются самим приложением.
Почему это называется Hexagonal Architecture
Шестиугольник — это просто форма, помогающая визуализировать архитектуру, и не имеет реальных оснований в реальном мире.
Тем не менее, что вы думаете, когда видите шестиугольник?
Приложение может иметь множество входов и портов, и один из них может быть API. Когда мы сохраняем данные, мы обычно думаем о сохранении их в базу данных или файловую систему. Если у вас есть особенно большое приложение, можно начать делить его на разные domains (домены). Это концепция domain-driven design (предметно-ориентированного проектирования), где каждое приложение отвечает только за один домен. Один домен может быть управлением пользователями, другой — поиском или сохранением данных. Каждая из этих частей вашего приложения может стать собственным шестиугольником.
Когда использовать Hexagonal Architecture
Hexagonal Architecture отлично подходит для больших и непрерывных приложений, у которых много разных входов и выходов, но если вы работаете над небольшим приложением, возможно, не стоит добавлять всю дополнительную сложность.
Концепция Hexagonal Architecture
Hexagonal представляет business logic (бизнес-логику). Ему не важны ваш фреймворк разработки, ваш технологический стек и язык программирования. Это помогает вам определить decoupled (зацепленную) структуру кода, isolated (изолированную) от внешних компонентов, и easy-to-run tests (облегчает выполнение тестов).
Архитектура включает несколько важных компонентов. Скажем, у меня уже есть GUI (веб-фронтенд), и моя задача — разработать бэкенд-сервис. Рассмотрим следующую диаграмму последовательности:
На диаграмме четыре компонента: пользователь, веб-интерфейс, User service и база данных. Пользовательский сервис предоставляет HTTP POST API /signup для веб-интерфейса. Внутренний пользовательский сервис включает три компонента:
- REST Controller: получает запросы от веб-интерфейса через API и передает их обработчику для выполнения бизнес-логики.
- Handler: валидирует запросы и выполняет логику. Если бизнес-данные нужно сохранить в базу данных, передает данные в репозиторий. В этом примере он сохраняет нового пользователя при регистрации.
- Repository: преобразует бизнес-данные в объекты данных и отправляет их в базу данных через протокол базы данных.
В этом примере он отправляет оператор
INSERT
в базу данных при создании нового пользователя.
Теперь я преобразую вышеуказанную диаграмму в Hexagonal Architecture.
Компоненты User service располагаются по краям шестиугольника. Легко увидеть, что они разделены. Поскольку у них разные роли, они не должны зависеть друг от друга. Когда компонент изменяется, другие компоненты не должны быть затронуты. Как это сделать?
Продолжим преобразование вышеуказанной Hexagonal Architecture для User service в стандартную Hexagonal Architecture.
Ports
Application (Приложение) — это ваша бизнес-логика как основная архитектура. Оно определяет Ports (интерфейсы) для взаимодействия с внешними компонентами (специфическими технологиями / внешними компонентами), такими как веб-интерфейс, база данных и т.д. Бизнес-логика знает только эти порты, которые представляют конкретные случаи использования логики. Существует два типа портов:
- Primary Port / Driver Port (Первичный порт / ведущий порт): предоставляет функции приложения внешнему компоненту. Мы можем назвать их границами использования приложения или API приложения. Например, он предоставляет функцию регистрации под названием sign-up handler (обработчик регистрации).
- Secondary Port / Driven Port (Вторичный порт / ведомый порт): предоставляет функции внешних компонентов для приложения, которое реализует бизнес-логику. Например, он предоставляет функцию создания записи в базе данных под названием sign-up repository (репозиторий регистрации).
Adapters
Компонент для соединения портов с внешними компонентами называется adapter (адаптером).
Существует два типа адаптеров:
- Primary adapter / Driver adapter (Первичный адаптер / ведущий адаптер)
- Secondary adapter / Driven adapter (Вторичный адаптер / ведомый адаптер)
Primary adapter / Driver adapter
Primary adapter / Driver adapter (Первичный адаптер / ведущий адаптер): использует интерфейс порта или запускает функцию порта для выполнения бизнес-логики. Он также преобразует запросы от внешних компонентов в запросы application (приложения).
В вышеуказанном примере REST-контроллер является первичным адаптером. Он определяет API POST /signup для вызова веб-интерфейса. Когда веб-интерфейс отправляет POST-запрос к API /signup, REST-контроллер преобразует запрос и отправляет его в бизнес-логику через sign-up handler (первичный порт).
Secondary adapter / Driven adapter
Secondary adapter / Driven adapter (Вторичный адаптер / ведомый адаптер): реализует вторичный порт. Application (приложение) использует его для связи с внешними компонентами. Он преобразует запросы бизнес-логики в запросы внешних технологических компонентов.
В вышеуказанном примере логика регистрации требует создания новой записи в базе данных.
Она вызывает репозиторий (вторичный порт) для создания новой записи,
и репозиторий MySQL преобразует этот запрос в SQL-запрос
INSERT INTO user (id, username, displayname, created_at) VALUES (1, 'yes', 'red', 123456789)
.
Таким образом, приложение заботится только о port interface (интерфейсе для порта), и port использует любой adapter для достижения бизнес-цели. Это показывает гибкость и разделение между application (приложением) и технологическими компонентами.
Actor
Существует два типа actors (акторов):
- Primary actor / Driver actor (Первичный актор / ведущий актор): использует функции application (приложения) или взаимодействует с приложением для достижения бизнес-цели, такие, как веб-интерфейс, мобильное приложение, человек (клиент), внешние приложения и т.д.
- Secondary actor / Driven actor (Вторичный актор / ведомый актор):
предоставляет функции для использования application (приложения)
или запускается application (приложения) для реализации бизнес-логики.
Существует два типа ведомых акторов:
- Repository (Репозиторий): предоставляет функции чтения и записи / отправки для приложения, такие как база данных, кэш, очередь и т.д.
- Recipient (Получатель): предоставляет функции отправки для приложения, такие как почтовый сервер, SMTP-сервер, очередь и т.д.
Как создать структуру кода на основе Hexagonal Architecture
Dependency pattern
Упомянуты два шаблона, которые используются в Hexagonal Architecture:
- Adapter Pattern (шаблон адаптера)
- Dependency Injection (внедрение зависимости)
Они помогают сделать архитектуру flexible (гибкой ) and decoupled (разъединенной) между компонентами. Как они используются в архитектуре? Начнем с ведущей стороны. На этой стороне есть четыре связанных компонента: actor, adapter, port и application (бизнес-логика). Важно, чтобы внешние компоненты взаимодействовали с application только через ports и adapters.
Класс SignUpController
использует интерфейс SignUpHandler
для выполнения функции регистрации.
SignUpService
является реализацией SignUpHandler
.
Он реализует функцию регистрации, определенную в интерфейсе SignUpHandler
.
Между классом SignUpController
и интерфейсом SignUpHandler
появляется зависимость кода.
Это способ реализовать primary adapter и primary port для application.
На ведомой стороне также есть четыре связанных компонента: actor, adapter, port и application (бизнес-логика), но роли инвертированы. Приложение взаимодействует с внешними компонентами через порты и адаптеры.
Класс SignUpService
использует интерфейс Repository
для создания новой записи.
MySQLRepository
является экземпляром или реализацией Repository
.
Он определяет способ создания записей в базе данных MySQL.
Между классомSignUpService
и интерфейсом Repository
появляется зависимость кода.
В общем, можно заметить, что в Hexagonal Architecture есть dependency inversion (инверсия зависимости).
Рассмотрим приведенную ниже модель:
Передача данных между компонентами
Благодаря dependency pattern, Hexagonal Architecture разделяет компоненты. Как эти компоненты взаимодействуют между собой? Язык является средством общения между людьми. Компоненты в структуре кода взаимодействуют друг с другом посредством data objects (объектов данных).
Адаптер является communication bridge (коммуникационным мостом) между внешними компонентами и application (приложением). Поэтому он выполняет роль преобразования типа данных объекта, который могут понять компоненты.
Например, на driver side SignUpController
(primary adapter) получает запрос от веб-интерфейса,
он должен преобразовать запрос в пользовательский ввод перед отправкой его в SignUpHandler
.
При получении результата отSignUpHandler
он также преобразует пользовательский вывод в ответ,
который может понять веб-интерфейс.
Эту же идею мы используем на driven side.
MySQLRepository
(adapter) преобразует data objects между SignUpService
и базой данных MySQL.
Затраты
Преобразование data objects достаточно затратно с точки зрения использования памяти и производительности сервиса. Но это помогает сохранять decoupled компонентов в сервисе. Если ваш сервис не меняет технологию часто, вы можете повторно использовать data objects между adapter и компонентом port.
Например, SignUpController
(adapter) и SignUpHandler
могут использовать один и тот же SignUpRequest
и SignUpResponse
.
Это означает, что SignUpController
отправляет SignUpRequest
в SignUpHandler
,
затемSignUpHandler
возвращает SignUpResponse
в SignUpController
.
Как написать тест с использованием Hexagonal Architecture
Тестирование — важный этап в цикле разработки проекта. Благодаря изоляции, которую предоставляет Hexagonal Architecture, структура кода проекта очень легко тестируется. Application управляет бизнес-логикой, что является важной ролью проекта. Поэтому мы сначала пишем тесты для этого компонента.
Тестирование компонента application
Application не заботится о внешних компонентах (веб-интерфейс, база данных и т.д.), поэтому мы применяем test double (тестовый дублер) для внешних компонентов. Это означает, что нам нужно:
- Написать unit tests (модульные тесты) для запуска application с primary port.
- Реализовать mock instance для secondary adapter.
При написании модульных тестов для бизнес-логики, вы должны знать expected result (ожидаемый результат) этих тестов и сравнивать их с actual result (фактическим результатом) при запуске этих тестов. Нам нужно имитировать подходящее состояние для каждого теста (т.е. задавать разное поведение для mocks).
Например, вы хотите написать unit test для случая успешной регистрации в SignUpService
.
Вам нужно настроить так, чтобы MockRepository
возвращал успешный результат,
когда SignUpService
вызывает Repository
для создания нового пользователя.
Затем вы сравниваете этот результат с ожидаемым результатом.
В противном случае вы также возвращаете duplicate user error (ошибку дублирования пользователя)
для неудачного test case.
Тестирование компонента adapter
Каждый компонент изолирован друг от друга, поэтому мы также пишем тесты для компонентов адаптера, которые изолированы от бизнес-логики. Поскольку адаптеры взаимодействуют с внешними компонентами, нам нужно использовать сторонние библиотеки для запуска тестов.
Тестирование primary adapter
Используйте инструменты для end-to-end HTTP и REST API testing для запуска API первичного адаптера,
в случае когда он представляет собой REST Controller.
Например для Go можно применить httpexpect
(применяйте HTTP REST контроллер)
Для primary adapters другого типа используйте другой подходящий инструментарий.
Существует два способа выполнить тестирование primary adapter:
- Оставьте
MockRepository
как в приведенном выше примере и сравните результат на уровне primary adapter. - Реализуйте
MockSignUpService
и настройте подходящее состояние для каждого теста.
Затем настройте unit tests для secondary adapter по следующей модели:
Преимущества и недостатки
При чтении вышеуказанных разделов вы также осознаете преимущества и недостатки Hexagonal Architecture для программных проектов, особенно для бэкенд-проектов.
Преимущества
Isolation (Изоляция)
Упомянута изоляционная функция. Это особенность, которая отличает Hexagonal Architecture. Она разделяет компоненты в структуре кода: бизнес-логику, внешний компонент и технологический стек. Изоляция также помогает снизить риск для вашего проекта при смене технологического стека. Если смена технологического стека вызывает проблему, вы просто переключаете использование старого адаптера.
Flexibility (Гибкость)
Технологический движок является незаменимым компонентом. С этой архитектурой вы можете легко обновить технологию без обновления ядра (компонента порта, бизнес-логики) проекта. Вам нужно реализовать новый адаптер и переключиться на его использование. В противном случае, при изменении бизнес-логики (приложения), вы не обновляете код адаптеров или интерфейса порта.
Testability (Тестируемость)
Написание тестов для приложения очень легко выполнить. Компоненты изолированы друг от друга, поэтому мы можем писать изолированные unit tests для каждого компонента. Кроме того, мы можем использовать механизм test double для поддержки тестирования.
Development and maintainability (Разработка и поддержка)
Мы можем реализовать основные компоненты бизнес-логики до выбора технологического стека. Поэтому мы можем улучшить скорость реализации кода.
Каждый компонент в структуре кода может быть назначен разным членам команды, и члены могут параллельно развивать компоненты. Поддерживающий разработчик также легко вносит изменения и добавляет новую логику.
Недостатки
Complexity (Сложность)
Если ваш проект небольшой или имеет простую бизнес-логику, вам потребуется довольно много времени на создание компонентов и организацию структуры кода. В этом случае вам следует выбрать другую архитектуру, такую как layer architecture (многоуровневая архитектура) для вашей структуры кода.
Mapping (Преобразование)
Затраты на преобразование объектов данных должны учитываться при создании изоляции между компонентами, это trade-off (компромисс) для этой задачи.
Hexagonal Architecture действительно принесла много преимуществ для процесса разработки программного обеспечения. Она меняет подход к организации структуры нашего кода. Применяет шаблон зависимости и шаблон адаптера для создания изоляции и гибкости архитектуры. Эти проекты очень легко расширять, изменять, обновлять и тестировать благодаря разделению компонентов.