- PVSM.RU - https://www.pvsm.ru -
Гексагональная архитектура — это архитектурный паттерн, представленный Алистером Кокберном и описанный у него в блоге [1] в 2005 году. Основная идея заключается в том, чтобы структурировать приложение таким образом, чтобы это приложение можно было разрабатывать и тестировать в изоляции, не завися от внешних инструментов и технологий.
Вот как сам Кокберн описывает эту архитектуру одним тезисом:
Добиться, чтобы приложение в равной степени могло управляться пользователями, программами, автоматизированными тестовыми или пакетными сценариями, а также разрабатываться и тестироваться в изоляции от устройств и баз данных, на которых оно впоследствии будет выполняться. — Алистер Кокберн, 2005 г.
В этой статье мы рассмотрим некоторые задачи, как правило, решаемые в типичных программных проектах. Затем мы поговорим о гексагональной архитектуре и о том, как она призвана решить эти задачи. Мы также рассмотрим некоторые детали реализации такой архитектуры и варианты тестирования.
Прежде чем подробно остановиться на гексагональной архитектуре, рассмотрим некоторые типичные задачи, с которыми мы можем столкнуться при работе с крупномасштабными приложениями.
На фронтенде бизнес-логика приложения просачивается в пользовательский интерфейс. В результате эту логику трудно тестировать, поскольку она связана с пользовательским интерфейсом. Кроме того, логика плохо поддаётся использованию в других сценариях, и трудно перейти от человеко-управляемых к программно-управляемым сценариям.
На бэкенде бизнес-логика оказывается связанной с базой данных или со внешними библиотеками и сервисами. Это опять же затрудняет тестирование логики из-за сильной связи компонентов. Также становится сложнее переходить от одной технологии к другой или обновлять технологический стек.
Чтобы решить проблему смешения бизнес-логики и технологических деталей, часто используют многоуровневую архитектуру. Предполагается, что, распределив разнородные проблемы по отдельным слоям, удастся надёжно разделить их.
Многоуровневая архитектура:
Компонентам любого уровня мы разрешаем обращаться только к таким другим компонентам, которые расположены на том же уровне или ниже. Теоретически это должно защитить нас от смешения различных зон ответственности. Проблема заключается в том, что не существует четкого механизма, который позволял бы выявлять нарушения этого требования, и со временем мы обычно оказываемся в той самой ситуации, которой пытались избежать.
Когда уровень доступа к данным находится внизу, вся система проектируется на основе того, как устроена база данных. Когда мы разрабатываем сценарии использования, мы в первую очередь должны моделировать поведение, а персистентность – это, прежде всего, хранение состояния. Не лучше ли начать с бизнес-логики приложения?
Эти сущности легко просачиваются на вышестоящие уровни, из-за чего приходится менять бизнес-логику при реорганизации персистентности. Если приходится менять способ сохранения данных, то почему это должно привести к изменению бизнес-логики?
Вышеизложенное представление является предельно упрощённым, и в реальности редко бывает таковым. В реальности нам необходимо взаимодействовать с внешними службами или библиотеками, и не всегда понятно, где их место.
Многоуровневая архитектура с большим количеством компонентов:
При необходимости добавления новых компонентов требуется обновлять архитектурные уровни. Это чревато попытками срезать углы и утечкой технических деталей в бизнес-логику, например, при прямом обращении к сторонним API.
Всё изложенное должно побудить нас к поиску альтернатив. Может быть, есть какие-то более качественные варианты оформления архитектуры?
Как уже отмечалось выше, основная идея гексагональной архитектуры заключается в том, чтобы отделить бизнес-логику от внешнего мира. Вся бизнес-логика заключена внутри приложения, а все внешние сущности — за его пределами. Внутренняя часть приложения не должна знать о внешней.
Мы стремимся, чтобы приложение в равной степени поддавалось управлению и со стороны пользователей, и других программ, и даже тестов. Должна быть возможность разрабатывать и тестировать бизнес-логику безотносительно фреймворков, баз данных или внешних сервисов.
Чтобы отделить бизнес-логику от внешнего мира, сделаем так, чтобы приложение взаимодействовало с внешним миром только через порты. Эти порты описывают суть коммуникации между двумя сторонами. Для приложения не имеет значения, каковы технические детали реализации портов.
Адаптеры обеспечивают связь приложения с внешним миром. Они преобразуют внешние сигналы в форму, понятную приложению. Адаптеры взаимодействуют с приложением только через порты.
Разделение бизнес-логики и инфраструктуры в гексагональной архитектуре:
Любой порт может иметь несколько адаптеров. Адаптеры могут быть взаимозаменяемыми с обеих сторон, не затрагивая бизнес-логику. Это позволяет легко масштабировать решение для использования новых интерфейсов или технологий.
Например, в приложении для кофейни может быть пользовательский интерфейс кассы, который обрабатывает прием заказов на кофе. Когда бариста отправляет заказ, REST-адаптер принимает HTTP-запрос POST и преобразует его в форму, понятную порту. При вызове порта запускается бизнес-логика, связанная с оформлением заказа внутри приложения. Само приложение не знает, что оно работает через REST API.
Адаптеры преобразуют поступающие извне сигналы, передавая их приложению:
С другой стороны, приложение взаимодействует с портом, который позволяет сохранять заказы. Если бы мы хотели использовать в качестве хранилища информации реляционную базу данных, то подключение к ней было бы реализовано через адаптер базы данных. Адаптер принимает информацию, поступающую из порта, и преобразует её в SQL-запрос для хранения заказа в базе данных. Само приложение не знает, как реализован данный функционал, и какие технологии при этом используются.
Во многих статьях, рассказывающих о гексагональной архитектуре, упоминаются уровни (layers). Однако в оригинальной статье об уровнях ничего не говорится. Есть только внутренняя и внешняя части приложения. Также ничего не говорится о том, как реализуется внутренняя часть. Определим ли мы свои собственные уровни, организуем компоненты по признакам или применим паттерны DDD (предметно-ориентированного проектирования) — всё зависит от нас.
Как мы убедились, некоторые адаптеры вызывают сценарии использования приложения, а другие реагируют на действия, выполняемые приложением. Адаптеры, которые управляют приложением, называются первичными или управляющими адаптерами, они обычно изображаются в левой части схемы. Адаптеры, управляемые приложением, называются вторичными или управляемыми адаптерами, которые обычно располагаются в правой части схемы.
Первичный и вторичный адаптеры с вариантами использования на границе приложения:
Различие между первичными и вторичными участниками основано на том, кто инициирует взаимодействие. Такая метафора проистекает из примеров, в которых выделяются главные и второстепенные действующие лица.
Первичный агент — это агент, выполняющий одну из функций приложения. Поэтому порты приложения естественным образом подходят для описания вариантов использования приложения. Вторичный агент — это участник, от кого приложение получает отклики или которого оно уведомляет. Таким образом, вторичные порты можно грубо подразделить на две категории: репозитории и получатели.
Варианты использования должны быть описаны на границе приложения. Вариант использования не должен содержать подробной информации о технологиях, не присутствующих в пределах приложения. Гексагональная архитектура может повышать качество описания примеров использования.
Типичная ошибка заключается в том, что мы пишем сценарии использования, держа в уме конкретные технологии. Такие сценарии использования не излагаются на языке предметной области, тесно увязываются с используемыми технологиями, и поэтому их сложнее поддерживать.
До сих пор мы говорили только о том, что технические детали должны оставаться за пределами приложения. Связь между адаптерами и приложением должна идти только через порты. Давайте посмотрим, как это воплощается на практике.
Когда мы реализуем первичный адаптер на стороне ведущего, адаптер должен сообщить приложению, что нужно сделать. Поток управления идет от адаптера к приложению через порты. Зависимость между адаптером и приложением направлена вовнутрь, поэтому приложение не знает, «кто» вызывает сценарии его использования.
Реализация первичных адаптеров:
Flow of control // Поток управления
Dependencies // Зависимости
В нашем примере кофейни OrderController – это адаптер, который вызывает сценарий использования, определенный портом PlacingOrders. Внутри приложения CoffeeShop — это класс, реализующий функционал, описанный портом. Приложение не знает, «кто» вызывает его сценарии использования.
Когда мы реализуем вторичный адаптер на управляемой стороне, поток управления выходит за пределы приложения, поскольку мы должны приказать адаптеру базы данных сохранять заказ. Однако наш архитектурный принцип гласит, что приложение не должно быть осведомлено о деталях внешнего мира.
Для этого необходимо применить принцип инверсии зависимостей.
Высокоуровневые модули не должны зависеть от низкоуровневых. И те, и другие должны зависеть от абстракций (например, интерфейсов). Абстракции не должны зависеть от деталей реализации. Детали (конкретные реализации) должны зависеть от абстракций. — Robert C. Martin, 2003
В нашем случае это означает, что приложение не должно напрямую зависеть от адаптера базы данных. Вместо этого приложение должно использовать порт, а адаптер должен реализовать этот порт.
Реализация вторичных адаптеров:
Flow of control // Поток управления
Dependencies // Зависимости
Реализация CoffeeShop не должна напрямую зависеть от реализации OrdersDatabaseAdapter, а должна использовать интерфейс Orders и позволить OrdersDatabaseAdapter реализовать этот интерфейс. Так инвертируется зависимость и, фактически, отношения диаметрально меняются.
Можно также сказать, что CoffeeShop имеет настраиваемую зависимость от интерфейса Orders, который реализуется OrdersDatabaseAdapter. Аналогично, OrderController имеет настраиваемую зависимость от интерфейса Orders, реализуемого CoffeeShop. Для настройки этих зависимостей мы можем использовать инъекцию зависимостей в качестве паттерна реализации.
Адаптеры должны переводить сигналы внешнего мира в вид, понятный приложению, и наоборот. Практически это означает, что адаптеры должны отображать любые модели приложения на модель адаптера и наоборот.
В нашем примере для разграничения внешней и внутренней модели можно ввести модель OrderRequest, которая представляет данные, поступающие в адаптер в виде REST-запроса. Контроллер OrderController теперь отвечает за отображение OrderRequest в модель Order, понятную приложению.
Отображение моделей в первичных адаптерах:
Аналогичным образом, когда адаптеру необходимо ответить вызывающему его агенту, мы можем ввести модель OrderResponse и позволить адаптеру отобразить модель Order из приложения в модель ответа.
Может показаться, что это лишняя работа. Мы могли бы просто возвращать модели из приложения напрямую, но тогда создаётся несколько проблем.
Во-первых, если нам нужно, например, отформатировать данные, то мы должны записать в приложение знания, специфичные для конкретной технологии. Это нарушает архитектурный принцип, согласно которому приложение не должно знать о деталях внешнего мира. Если другому адаптеру потребуется использовать те же данные, переиспользование модели может оказаться невозможным.
Во-вторых, мы усложняем рефакторинг внутри приложения, поскольку наша модель теперь открыта внешнему миру. Если кто-то будет полагаться на предоставляемый нами API, то мы будем вносить разрушающие изменения при каждом заходе на рефакторинг нашей модели.
С другой стороны, в описываемом здесь приложении мы могли бы ввести модель OrderEntity для описания деталей, необходимых для сохранения данных. Теперь специфичный для данной технологии OrdersDatabaseAdapter отвечает за преобразование модели Order из приложения в сущность, способную работать с уровнем персистентности.
Отображение моделей во вторичных адаптерах:
Опять же, использование единой модели для сущностей базы данных и приложения может быть заманчивым, но такой подход сопряжен с определенными издержками. Нам придется учесть в модели приложения детали, специфичные для конкретной технологии. В зависимости от используемого технологического стека, это может означать, что теперь вам придется учитывать в рамках бизнес-логики и такие детали, как транзакции и ленивая загрузка.
При создании гексагональной архитектуры одной из целей постулировалось возможность тестирования бизнес-логики безотносительно внешних инструментов и технологий. Это естественным образом вытекает из принципа разделения ответственности, реализованного с помощью портов и адаптеров. Без такого разделения возможности тестирования значительно сужаются, и возникает тенденция к расширению тестов.
Первым шагом в реализации практического сценария будет тест, описывающий его. Мы начинаем с приложения, работающего по принципу «чёрного ящика» и разрешаем тесту вызывать приложение только через его порты. Также следует заменить все вторичные адаптеры на имитационные.
Модульное тестирование бизнес-логики:
Хотя здесь можно использовать фреймворк mocking, написание собственных mocks или stubs окажется полезным в дальнейшем. Для любых адаптеров репозиториев эти имитаторы могут быть простыми картами значений.
Следующий шаг — подключение к приложению нескольких адаптеров. Обычно мы начинаем со стороны основного адаптера. Так мы обеспечим, чтобы приложением могли управлять реальные пользователи.
Для вторичных адаптеров можно и далее использовать имитационные варианты, созданные на предыдущем этапе. Затем наши узкие интеграционные тесты будут вызывать первичный адаптер для тестирования. Фактически, мы можем поставлять первую версию нашего решения с вторичными адаптерами, реализованными в виде заглушек.
Тестирование первичных адаптеров:
Например, интеграционный тест может выполнять некоторые HTTP-запросы к REST-контроллеру и утверждать, что ответ соответствует нашим ожиданиям. Хотя REST-контроллер вызывает приложение, приложение не является тестируемым объектом.
Если в этих тестах мы будем использовать тестовый двойник приложения, то нам придется тщательнее проверять взаимодействия между адаптером и приложением. Если мы имитируем только адаптеры, изображённые справа, то можем сосредоточиться на проверке состояния после прохождения модульного теста (state-based testing).
В этих тестах мы должны проверить только обязанности контроллера. Каждый сценарий использования приложения можно протестировать в отдельности.
Когда приходит время реализовать адаптеры изображённые справа, требуется удостовериться, что интеграция со сторонней технологией прошла корректно. Можно не подключаться к удаленной базе данных или сервису, а контейнеризировать базу данных или сервис и настроить тестируемый объект так, чтобы он к ним подключался.
Тестирование вторичных адаптеров:
Например, в Java для замены реальной удаленной базы данных или сервиса можно использовать что-то вроде Testcontainers или MockWebServer. Так мы сможем использовать базовую технологию локально, не завися от доступности внешних сервисов.
Хотя и можно покрыть различные части системы модульными и интеграционными тестами, для искоренения всех проблем этого недостаточно. Для комплексного тестирования нам пригодятся сквозные тесты (также именуемые широкими интеграционными или системными).
Сквозное тестирование системы:
Мы по-прежнему можем изолировать систему от внешних сервисов, но при этом тестировать ее как единое целое. Эти сквозные тесты охватывают целые фрагменты системы — от первичных адаптеров до приложения и вторичных адаптеров.
В этих тестах мы стремимся проверить основные пути, по которым выполняется приложение. Цель состоит не в проверке функциональных сценариев использования, а в том, правильно ли скомпоновано приложение и работает ли оно как надо.
Очевидно, что такой подход приведет к появлению взаимно дублирующих тестов. Чтобы избежать многократного тестирования одних и тех же сущностей, на разных уровнях, важно продумать зону ответственности тестируемого субъекта.
Хорошая архитектура позволяет постоянно менять программное обеспечение с минимальными усилиями со стороны разработчика. Цель – минимизировать эксплуатационные издержки системы и максимизировать её производительность.
Гексагональная архитектура обладает рядом преимуществ, способствующих достижению этих целей:
Как и у любого решения, у гексагональной архитектуры есть свои недостатки.
В конечном итоге решение об использовании гексагональной архитектуры зависит от того, насколько сложна создаваемая система. Всегда можно начать с относительно простого подхода и развивать архитектуру по мере необходимости.
Основная идея гексагональной архитектуры заключается в том, чтобы отделить бизнес-логику от деталей реализации. Это достигается путем разграничения проблем с помощью интерфейсов.
С одной стороны приложения мы создаём адаптеры, использующие предоставляемые приложением интерфейсы. Это могут быть, например, контроллеры, управляющие работой приложения. С другой стороны приложения создаются адаптеры, реализующие интерфейсы приложения. Это могут быть, например, хранилища, из которых приложение получает отклики.
Автор: Александр
Источник [2]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/logika/388047
Ссылки в тексте:
[1] блоге: https://alistair.cockburn.us/hexagonal-architecture/
[2] Источник: https://habr.com/ru/companies/timeweb/articles/771338/?utm_source=habrahabr&utm_medium=rss&utm_campaign=771338
Нажмите здесь для печати.