Что такое гексагональная архитектура. Разделение бизнес-логики и инфраструктуры с помощью портов и адаптеров

в 8:01, , рубрики: http, rest, sql, timeweb_статьи_перевод, адаптеры, бизнес, бэкенд, гексагональная архитектура, инфраструктура, логика, паттерны, персистентность, порты, приложение
image

Гексагональная архитектура — это архитектурный паттерн, представленный Алистером Кокберном и описанный у него в блоге в 2005 году. Основная идея заключается в том, чтобы структурировать приложение таким образом, чтобы это приложение можно было разрабатывать и тестировать в изоляции, не завися от внешних инструментов и технологий.

Вот как сам Кокберн описывает эту архитектуру одним тезисом:

Добиться, чтобы приложение в равной степени могло управляться пользователями, программами, автоматизированными тестовыми или пакетными сценариями, а также разрабатываться и тестироваться в изоляции от устройств и баз данных, на которых оно впоследствии будет выполняться. — Алистер Кокберн, 2005 г.

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

Какие сложности могут возникать при традиционном подходе

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

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

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

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

Многоуровневая архитектура:

image

Описание

Presentation // Представление
Business logic // Бизнес-логика
Data Access // Доступ к данным

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

Когда уровень доступа к данным находится внизу, вся система проектируется на основе того, как устроена база данных. Когда мы разрабатываем сценарии использования, мы в первую очередь должны моделировать поведение, а персистентность – это, прежде всего, хранение состояния. Не лучше ли начать с бизнес-логики приложения?

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

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

Многоуровневая архитектура с большим количеством компонентов:

image

Описание

GUI controller // Контроллер GUI
REST controller // REST-контроллер
Message queue // Очередь сообщений
Business logic // Бизнес-логика
ORM lib // Библиотека ORM
Email lib // Библиотека электронной почты
HTTP lib // Бибилиотека HTTP

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

Всё изложенное должно побудить нас к поиску альтернатив. Может быть, есть какие-то более качественные варианты оформления архитектуры?

Что такое гексагональная архитектура?

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

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

Порты и адаптеры

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

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

Разделение бизнес-логики и инфраструктуры в гексагональной архитектуре:

image

Описание

Adapters // Адаптеры
Application // Приложение
Port // Порт
External app // Внешнее приложение
RabbitMQ queue // Очередь RabbitMQ
Windows app // Приложение для Windows
Web app // Веб-приложение
PostgreSQL server // Сервер PostgreSQL
MongoDB server // Сервер MongoDB
Email server // Сервер электронной почты
SMS gateway // SMS-шлюз
REST controller // REST-контроллер
Message queue adapter // Адаптер очереди сообщений
GUI controller // Контроллер GUI
Web app view controller // Контроллер представления веб-приложения
Postgres adapter // Адаптер Postgres
MongoDB adapter // Адаптер MongoDB
Email adapter // Адаптер электронной почты
SMS adapter // Адаптер SMS

Любой порт может иметь несколько адаптеров. Адаптеры могут быть взаимозаменяемыми с обеих сторон, не затрагивая бизнес-логику. Это позволяет легко масштабировать решение для использования новых интерфейсов или технологий.

Например, в приложении для кофейни может быть пользовательский интерфейс кассы, который обрабатывает прием заказов на кофе. Когда бариста отправляет заказ, REST-адаптер принимает HTTP-запрос POST и преобразует его в форму, понятную порту. При вызове порта запускается бизнес-логика, связанная с оформлением заказа внутри приложения. Само приложение не знает, что оно работает через REST API.

Адаптеры преобразуют поступающие извне сигналы, передавая их приложению:

image

Описание

Place order // Размещение заказа
Save order // Сохранение заказа
Web app // Веб-приложение
REST adapter // REST-адаптер
Port // Порт
Application // Приложение
Flow of control // Поток управления
Database adapter // Адаптер базы данных
Database // База данных

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

Во многих статьях, рассказывающих о гексагональной архитектуре, упоминаются уровни (layers). Однако в оригинальной статье об уровнях ничего не говорится. Есть только внутренняя и внешняя части приложения. Также ничего не говорится о том, как реализуется внутренняя часть. Определим ли мы свои собственные уровни, организуем компоненты по признакам или применим паттерны DDD (предметно-ориентированного проектирования) — всё зависит от нас.

Первичные и вторичные адаптеры

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

Первичный и вторичный адаптеры с вариантами использования на границе приложения:

image

Описание

Primary (driving) adapters // Первичные (управляющие) адаптеры
Secondary (driven) adapters // Вторичные (управляемые) адаптеры
REST controller // REST-контроллер
Test framework // Фреймворк тестирования
GUI controller // GUI-контроллер
Placing orders // Размещение заказов
Managing products // Управление продуктами
Application // Приложение
Orders (repository) // Заказы (репозиторий)
Notifications (recipient) // Уведомления (получатель)
Postgres adapter // Адаптер Postgres
Mock database // Фиктивная база данных
Email adapter // Адаптер электронной почты
Mock notifier // Фиктивный уведомитель
Use case boundary // Границы вариантов использования

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

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

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

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

Реализация

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

Инверсия зависимостей

Когда мы реализуем первичный адаптер на стороне ведущего, адаптер должен сообщить приложению, что нужно сделать. Поток управления идет от адаптера к приложению через порты. Зависимость между адаптером и приложением направлена вовнутрь, поэтому приложение не знает, «кто» вызывает сценарии его использования.

Реализация первичных адаптеров:

image

Flow of control // Поток управления
Dependencies // Зависимости

В нашем примере кофейни OrderController – это адаптер, который вызывает сценарий использования, определенный портом PlacingOrders. Внутри приложения CoffeeShop — это класс, реализующий функционал, описанный портом. Приложение не знает, «кто» вызывает его сценарии использования.

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

Для этого необходимо применить принцип инверсии зависимостей.

Высокоуровневые модули не должны зависеть от низкоуровневых. И те, и другие должны зависеть от абстракций (например, интерфейсов). Абстракции не должны зависеть от деталей реализации. Детали (конкретные реализации) должны зависеть от абстракций. — Robert C. Martin, 2003

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

Реализация вторичных адаптеров:

image

Flow of control // Поток управления
Dependencies // Зависимости

Реализация CoffeeShop не должна напрямую зависеть от реализации OrdersDatabaseAdapter, а должна использовать интерфейс Orders и позволить OrdersDatabaseAdapter реализовать этот интерфейс. Так инвертируется зависимость и, фактически, отношения диаметрально меняются.
Можно также сказать, что CoffeeShop имеет настраиваемую зависимость от интерфейса Orders, который реализуется OrdersDatabaseAdapter. Аналогично, OrderController имеет настраиваемую зависимость от интерфейса Orders, реализуемого CoffeeShop. Для настройки этих зависимостей мы можем использовать инъекцию зависимостей в качестве паттерна реализации.

Отображение в адаптерах

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

В нашем примере для разграничения внешней и внутренней модели можно ввести модель OrderRequest, которая представляет данные, поступающие в адаптер в виде REST-запроса. Контроллер OrderController теперь отвечает за отображение OrderRequest в модель Order, понятную приложению.

Отображение моделей в первичных адаптерах:

image

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

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

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

Во-вторых, мы усложняем рефакторинг внутри приложения, поскольку наша модель теперь открыта внешнему миру. Если кто-то будет полагаться на предоставляемый нами API, то мы будем вносить разрушающие изменения при каждом заходе на рефакторинг нашей модели.
С другой стороны, в описываемом здесь приложении мы могли бы ввести модель OrderEntity для описания деталей, необходимых для сохранения данных. Теперь специфичный для данной технологии OrdersDatabaseAdapter отвечает за преобразование модели Order из приложения в сущность, способную работать с уровнем персистентности.

Отображение моделей во вторичных адаптерах:

image

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

Тестирование

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

Тестирование бизнес-логики

Первым шагом в реализации практического сценария будет тест, описывающий его. Мы начинаем с приложения, работающего по принципу «чёрного ящика» и разрешаем тесту вызывать приложение только через его порты. Также следует заменить все вторичные адаптеры на имитационные.

Модульное тестирование бизнес-логики:

image

Описание

Set up test doubles // Установка тестовых двойников
Call a use case // Вызов практического сценария
Assert results // Утверждение результатов
Unit test // Модульный тест
Mock database // Фиктивная база данных
Mock notifier // Фиктивный уведомитель
Port // Порт
Application (subject under test) // Приложение (то самое, которое мы тестируем)

Хотя здесь можно использовать фреймворк mocking, написание собственных mocks или stubs окажется полезным в дальнейшем. Для любых адаптеров репозиториев эти имитаторы могут быть простыми картами значений.

Tестирование первичных адаптеров

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

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

Тестирование первичных адаптеров:

image

Описание

Set up test doubles // Установка тестовых двойников
Make an HTTP request // Выполнение HTTP-запроса
Integration test // Интеграционный тест
REST controller (subject under test) // REST-контроллер (тестируемый объект)
Assert HTTP response // Утверждение HTTP-отклика
Mock database // Фиктивная база данных
Mock notifier // Фиктивный уведомитель
Port // Порт
Application // Приложение

Например, интеграционный тест может выполнять некоторые HTTP-запросы к REST-контроллеру и утверждать, что ответ соответствует нашим ожиданиям. Хотя REST-контроллер вызывает приложение, приложение не является тестируемым объектом.

Если в этих тестах мы будем использовать тестовый двойник приложения, то нам придется тщательнее проверять взаимодействия между адаптером и приложением. Если мы имитируем только адаптеры, изображённые справа, то можем сосредоточиться на проверке состояния после прохождения модульного теста (state-based testing).

В этих тестах мы должны проверить только обязанности контроллера. Каждый сценарий использования приложения можно протестировать в отдельности.

Тестирование вторичных адаптеров

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

Тестирование вторичных адаптеров:

image

Описание

Set up a test database // Настройка тестовой базы данных
Call an adapter // Вызов адаптера
Assert results // Утверждение результатов
Port // Порт
Integration test // Интеграционный тест
Postgres adapter (subject under test) // Адаптер Postgres (тестируемый объект)
Database test container // Тестовый контейнер базы данных

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

Сквозные тесты

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

Сквозное тестирование системы:

image

Описание

Set up a test database and a mock server // Создание тестовой базы данных и фиктивного сервера
Make an HTTP request //Выполнение HTTP-запроса
Assert HTTP response // Утверждение HTTP-отклика
End-to-end test // Сквозной тест
Rest controller // REST-контроллер
Port // Порт
Application // Приложение
Postgres adapter // Адаптер Postgres
Email adapter // Адаптер электронной почты
Database test container // Тестовый контейнер базы данных
Mock email server // Фиктивный почтовый сервер
(subject under test) // (тестируемый объект)

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

В этих тестах мы стремимся проверить основные пути, по которым выполняется приложение. Цель состоит не в проверке функциональных сценариев использования, а в том, правильно ли скомпоновано приложение и работает ли оно как надо.

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

Преимущества и недостатки

Хорошая архитектура позволяет постоянно менять программное обеспечение с минимальными усилиями со стороны разработчика. Цель – минимизировать эксплуатационные издержки системы и максимизировать её производительность.

Гексагональная архитектура обладает рядом преимуществ, способствующих достижению этих целей:

  • Мы можем начать работу, а о конкретных деталях определиться позже (например, о том, какой фреймворк или базу данных использовать).
  • Мы можем менять бизнес-логику, не трогая адаптеры.
  • Мы можем заменять или обновлять инфраструктурный код, не затрагивая бизнес-логику.
  • Мы можем подробно описывать сценарии использования, не вдаваясь в технические детали.
  • Информативно именуя порты и адаптеры, можно качественнее разграничить зоны ответственности и снизить риск просачивания технических деталей в бизнес-логику.
  • Мы получаем возможность тестировать части системы как в отдельности друг от друга, так и группируя их.

Как и у любого решения, у гексагональной архитектуры есть свои недостатки.

  • Для простых решений (например, CRUD-приложений или технических микросервисов) она может оказаться переусложнённой (оверинжиниринг).
  • Требуется прилагать усилия, создавая отдельные модели и отображения между ними.

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

Итоги

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


Автор: Александр

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js