В данной статье мы коротко пройдемся по теории и на практике разберемся как перевести любое Legacy приложение на гексагональную архитектуру. Повествование будет в контексте фреймворка Symfony и PHP 7.4, но синтаксис приведенных примеров настолько прост что вы без труда поймете как сделать так же на вашем языке программирования (если он поддерживает ООП).
За свою карьеру я работал над многими проектами Symfony, и одна из самых частых проблем, с которыми клиенты звонят в нашу компанию, заключается в том, что их программное обеспечение «заблокировано» старой версией фреймворка или оно стало необслуживаемым, потому что поиск и исправление ошибок обходится слишком дорого.
Обычно, я стараюсь хорошо разобраться, почему эти устаревшие проекты находятся в таком состоянии. И часто я обнаруживал общую закономерность: команде в начале проекта нужно быстро создать приложение с нуля, потому что строгие сроки поджимают.
Процесс разработки у них начинается примерно так:
- установить проект Symfony skeleton с помощью composer
- удалить demo код
- авто-генерация моделей
- авто-генерация контроллеров
- теперь все готово для разработки (бизнес-логики) приложения
Такие шаги для меня не лучшая практика, все-же сначала лучше разобраться в предметной области и её характеристиках вместо того, чтобы немедленно начинать что-то разрабатывать.
Я думаю, что в описанной ранее ситуации они руководствовались типичным flow разработки на фреймворке.
На мой взгляд, лучше сосредоточить свои усилия на предметной области, и вам нужно рассматривать Symfony (или фреймворк в целом) как инструмент, а не основное ядро ??программного обеспечения, потому что реальная ценность вашего программного обеспечения — это «domain», логика которую вы разработаете для решения проблем.
Ориентация на фреймворк имеет множество побочных эффектов, и одним из самых опасных является соединение домена и фреймворка, которое может создать множество проблем, например:
- невозможно обновить фреймворк и сторонние библиотеки
- стоимость обслуживания, потому что каждая ошибка или новая фича требует много времени для решения
- разработчики не мотивированы, потому что стек очень старый по причине первого пункта
- необслуживаемое приложение
- много технического долга
Но не пугайтесь, существует архитектура, которая поможет вам избежать этих проблем: гексагональная архитектура.
История гексагональной архитектуры
Гексагональная архитектура была изобретена Алистером Коберном в попытке избежать известных структурных ошибок в объектно-ориентированном проектировании программного обеспечения, таких как нежелательные зависимости между уровнями и загрязнение кода пользовательского интерфейса бизнес-логикой, и опубликована в 2005 году.
Гексагональная архитектура делит систему на несколько слабо связанных взаимозаменяемых компонентов, таких как ядро ??приложения, база данных, пользовательский интерфейс, тестовые сценарии и интерфейсы с другими системами. Такой подход является альтернативой традиционной многослойной архитектуре. (Википедия)
Когда я много раз читаю и объясняю это определение, разработчики спрашивают меня: не приведет ли это к оверинжинирингу проекта?
Что ж, у вас есть больше классов, больше концепций и больше моментов, когда вам нужно много подумать о правильном расположении класса, названии класса или лучшем имени для переменной. Однако, это зависит только от вас, я могу лишь рекомендовать попробовать применить эту стратегию и улучшить свои навыки с ее помощью.
Реальные проблемы
Проект, написанный 10 лет назад, заблокирован старой версией PHP, и вы хотите перейти на новую версию.
Обновление PHP означает, что вам необходимо обновить структуру и composer-пакеты, затрагивающие бизнес-логику, потому что все взаимосвязано.
Вы не можете выполнить безопасное обновление, потому что код не полностью покрыт тестами.
В этом случае ваше приложение — необслуживаемое (not maintainable).
Все эти проблемы являются типичными для проектов где домен и фреймворк связаны.
В гексагональной архитектуре вы можете разделить фреймворк и домен, чтобы вы могли обновлять фреймворк и сторонние пакеты, затрагивая лишь небольшую конкретную часть вашего кода, а не бизнес-логику.
Чтобы разделить фреймворк и домен, на практике я имею в виду разделение их по разным директориям. Я поясню это через минуту.
Еще один хороший пример «связанного кода» — это когда у вас есть внешние сервисы, и они могут на что-то влиять в вашем приложении.
Предположим, у вас есть поставщик платежных шлюзов, который выпускает новую версию, а ваша текущая версия, используемая в вашем приложении, уже перестала поддерживаться.
Можете переключиться на новую версию или заменить ее другим поставщиком шлюза, но вы знаете, что вам придется переписать множество мест по всему проекту, потому что ваш домен сильно связан с библиотекой или службой.
Таким образом, вам нужно приложить много усилий, чтобы переписать множество мест в коде, где вдобавок вы можете допустить ошибки.
В гексагональной архитектуре вы можете изменять/менять только адаптеры, не затрагивая логику домена, поскольку она отделена от фреймворка.
Пример связанного кода:
class Payment
{
public function pay(Request $request): void
{
$gateway = new YourBankGateway();
$gateway->pay($request->get('amount'));
}
}
На мой взгляд, в этом классе есть несколько проблем:
- Вызывается метод pay с объектом Request, который представляет веб-запрос HTTP. Это означает, что вы не можете вызвать этот метод из командной строки, если вам это нужно, вы должны продублировать этот код или что-то изменить.
- Создание экземпляра службы YourBankGateway внутри метода означает, что, если вы хотите заменить эту службу другой, вам необходимо изменить ее во всем коде, во всех таких строках.
Попробуем расцепить этот код:
interface GatewayProvider {
public function pay(Money $amount): void;
}
class YourBankGateway implements GatewayProvider {
public function pay(Money $amount): void
{
//do stuff..
}
}
class Payment {
private GatewayProvider $gateway;
public function __construct(GatewayProvider $gateway)
{
$this->gateway = $gateway;
}
public function payThroughGateway(Money $amount): void
{
$this->gateway->pay($amount);
}
}
В этом и многих других случаях использование интерфейсов и шаблона внедрения зависимостей позволяет разработчикам разъединять код, потому что в любой момент вы можете заменить реализацию на новую, реализующую этот интерфейс.
Еще одно преимущество примера развязки: теперь вы можете вызвать класс Payment из веб-запроса HTTP или командной строки, потому что вам нужно передать объект Money (обычно я пытаюсь передать типизированный объект или DTO) вместо объекта Request.
Связывание домена и фреймворка имеет темный побочный эффект, заключающийся в создании не обслуживаемого приложения.
Поддерживаемое приложение
Под «поддерживаемостью» я имею в виду отсутствие (уменьшение) технического долга.
Технический долг — это долг, который мы платим за наши (плохие) решения, и возвращается он нашим разочарованием и временем.
Поддерживаемое приложение — это приложение, которое увеличивает технический долг настолько медленными темпами, насколько это можно реально достичь.
Каковы характеристики легко обслуживаемого приложения?
- Изменения в одной части приложения должны затронуть как можно меньше других мест
- Добавление новых возможностей(фич) не должно требовать изменения какой-либо другой части кода
- Добавление новых способов взаимодействия с приложением должно требовать как можно меньше манипуляций
- Отладка должна производиться с как можно меньшим количеством обходных путей
- Тестирование должно быть относительно простым
Чтобы меньше трогать код для нового или существующего функционала, важно соблюдать принцип единственной ответственности для всех классов.
Единственная ответственность
Хорошей концепцией является единственная ответственность за код, но она существует также и для архитектуры: какие изменения по той же причине следует сгруппировать, например:
- Все, что связано с фреймворком
- Все, что связано с логикой домена
- Все, что связано с вызовом API
Таким образом, мы можем создать самое важное различие в нашем проекте: домен, приложение и инфраструктуру.
Для домена я имею в виду:
- сущности: модели, объекты значений и агрегаты…
- интерфейсы для граничных объектов
Для приложения это:
- сценарии использования (сервисы приложения)
Для инфраструктуры подразумевается:
- фреймворк
- реализации для граничных объектов
- контроллеры, команды CLI
Почему шестиугольник?
На самом деле сторон может быть множество. И количество сторон говорит о том сколько “портов ввода-вывода” имеет наше приложение.
Каждый порт может использоваться адаптерами, чтобы наша система работала нормально.
Давайте подробно объясним, что означают порты и адаптеры.
Порты
Порты похожи на контракты, поэтому они не будут представлены в кодовой базе.
Существует порт для каждого способа вызова сценария использования приложения (через UI, API и т.д.), а также для всех способов выхода данных из приложения (персистентность, уведомления в другие системы и т. д.). Алистер Коберн называет эти порты первичным и вторичным или обычно разработчики называют их портами ввода и вывода.
Первичный и вторичный — это различие между намерением общения и поддерживающей реализацией.
Пример порта:
interface ProductRepositoryInterface
{
public function find(ProductId $id): ?Product;
}
Порты — это всего лишь определение того, что мы хотим делать. Они не говорят, как их достичь.
Адаптеры
Адаптеры — это реализация портов, потому что для каждого из этих абстрактных портов нам нужен код, чтобы соединение работало.
Они очень конкретны и содержат низкоуровневый код и по определению не связаны со своими портами.
Пример адаптера:
class MysqlProductRepository implements ProductRepositoryInterface
{
private $repository;
public function __construct(ProductRepository $repository)
{
$this->repository = $repository;
}
public function find(ProductId $id): ?Product
{
return $this->repository->find(id);
}
}
Попробуем представить наши порты и адаптеры внутри реальной системы.
Как видите, у нас есть команда CLI или HTTP-запрос, которые вызывают наши входные адаптеры внутри уровня инфраструктуры. Адаптеры реализуют наши входные порты внутри уровня домена.
С другой стороны, у нас есть наши выходные адаптеры внутри уровня инфраструктуры, которые реализуют наши выходные порты внутри домена и могут взаимодействовать с внешней системой, такой как база данных.
Итак, в нашем приложении PHP у нас может быть такая структура директорий:
В этом примере у вас есть два разных контекста: Payment и Cart.
Здесь под каждым контекстом существует различие между доменом, приложением и инфраструктурой. Не обязательно иметь все эти каталоги, иногда может отсутствовать уровень приложения или уровень инфраструктуры.
В вашем домене у вас есть логика домена без зависимостей от каких-либо поставщиков (не всегда верно, например я обычно в своем домене использую генератор UUID ramsey/uuid).
Внутри этой директории у вас также есть все порты для указания того, как использовать эти данные с помощью объектов.
В папке вашего приложения вы можете иметь службы и сценарии использования.
В вашей инфраструктурной папке вы можете иметь код фреймворка и адаптеры, поэтому реализовать доменные порты можно с использованием любых библиотек и технологий.
Принцип инверсии зависимостей
Теперь, если объединить гексагональную архитектуру с принципом инверсии зависимостей, тогда вы еще больше улучшите свои проекты.
Принцип инверсии зависимостей означает, что модули высокого уровня не должны зависеть от модулей низкого уровня. Оба должны зависеть от абстракций.
Таким образом, класс инфраструктуры может зависеть от класса приложения и класса домена.
Класс приложения может зависеть от класса предметной области, но не может зависеть от класса инфраструктуры.
Доменный класс не может зависеть от класса инфраструктуры или приложения.
Преимущества использования гексагональной архитектуры
Как по мне, использование гексагональной архитектуры дает множество преимуществ:
- Отцепление домена от инфраструктуры повышает тестируемость, поскольку многие части кода не требуют подключения к базе данных, подключения к Интернету или файловой системы. Вы можете создать множество unit-тестов.
- Вы можете заменить адаптер, не затрагивая порты, вы можете поменять базу данных, и код домена исправлять не потребуется.
- Вы можете отложить выбор composer-пакетов, БД, серверов и т. д., потому что важнее смоделировать свой домен. Таким образом у вас будет больше знаний, когда потребуется сделать этот выбор.
- Вы можете обновлять фреймворк и сторонние пакеты, не касаясь кода своего домена.
Когда это использовать
На данный момент я стараюсь использовать эту архитектуру всегда, потому что, если начинаешь думать с таким мышлением, тогда очень трудно вернуться к старым практикам.
Как насчет устаревшего кода без гексагональной архитектуры?
Обычно с устаревшим приложением, которое не следует этой архитектуре, я предлагаю команде начать пробовать новые вещи, чтобы сделать домен и код лучше и понятнее.
Это начинается с создания новых директорий, таких как Infrastructure и Domain.
После этого новые замыслы и фичи могут быть разработаны в этих директориях.
Со старым функционалом, если это возможно и не так сложно, я стараюсь создавать pull-реквесты переносящие небольшие части на новую архитектуру.
Когда я переношу старый legacy фрагмент кода я стараюсь следовать золотому правилу, которое я люблю, это правило бойскаута:
Оставьте код более чистым чем он был до того, как вы его нашли.
Let’s Make Our Projects Great Again
Для улучшения вашего домена и вашего кода я могу подсказать использовать:
- DDD (Domain-driven design)
- Шаблон CQRS (разделение ответственности командного запроса)
- Event sourcing
- TDD
- BDD
Все эти концепции, методологии и подходы могут снова сделать ваши проекты еще более лучшими.
telhin
Есть очень интересный пост (и видео доклада) от Mark Seemann:
https://blog.ploeh.dk/2016/03/18/functional-architecture-is-ports-and-adapters/
https://www.youtube.com/watch?v=US8QG9I1XW0&%3Bfeature=youtu.be
В посте рассматривается архитектура портов и адаптеров (та же гексогональная) со стороны ООП и со стороны ФП. В частности в Haskell, который имеет такую систему типов какую имеет, оказывается архитектура портов и адаптеров является путем наименьшего сопротивления. Что связано в большей мере с механизмом ограничения эффектов.
Таким образом в Haskell воспроизводство некорректной архитектуры в коде требует активных деструктивных действий программиста.
С другой в ООП нужно принимать активные архитектурные решения и знать паттерны, чтобы поддерживать архитектуру и добиться тех же преимуществ.
P.s. не слишком опытный кодер на Haskell