среда, 15 июля 2009 г.

Hexagonal/Onion Architecture - слоим приложения

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

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

Архитектура по-умолчанию

Итак, мы начинаем новый проект. Чтобы легче было работать, пусть это будет какой-нибудь интернет-магазин со вполне себе ординарной функциональностью: какая-нибудь витрина, корзина и система заказов. Открыв любимую IDE, через несколько пассов у нас получится приложение с примерно такой структурой:

Default Architecture

Стрелочками обозначены ссылки между проектами.

Наверняка, сначала будет разработана база данных. Затем через какой-нибудь DAL сущности из проекта BusinessLogic будут этими самыми данными манипулировать. Ну и UI-проект все это будет отображать, собирать какой-то пользовательский ввод и дергать за ниточки бизнес-логику.

Все бы хорошо, но данная схема подразумевает первичность базы данных. Ее разработка влияет на все остальные уровни. И в случае, если мы хотим смоделировать бизнес-логику, используя Domain Driven Design, это влияние будет столь значительным, что рано или поздно все попытки разработать полноценную модель скатятся в использование Active Record или Table Module, похоронив все дальнейшие попытки использования преимуществ возможностей богатой модели.

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

А что если бы появилась возможность сконцентрироваться на разработке модели домена, как на первоочередном модуле?

Ресурсами наружу

Решение проблемы с первоочередностью разработки модели домена (как, в общем случае, и любой другой логики, связанной с разрабатываемым приложением) была хорошо сформулирована Алистером Кокберном в виде паттерна Hexagonal Architecture.

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

Перерисовав структуру нашего приложения согласно вышеописанному паттерну, мы получим примерно следующее:

Hexagonal Architecture

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

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

Центр управляет всем

В качестве решения этой проблемы предлагается использование так называемой «луковой архитектуры» (onion architecture). У этого паттерна такая же мотивация, что и у гексагональной архитектуры, но решение строится не просто на отделении абстракций от реализации, а на управлении доступом между различными уровнями архитектуры.

Если мы перерисуем структуру нашего проекта, согласно предложенной схеме, то у нас получится такой рисунок:

Onion Atchitecture

Смысл данного подхода заключается в следующем:

  1. В ядре системы находится модуль, не связанный ни с чем. Чистая абстракция предметной области;
  2. Другие модули выстраиваются вокруг ядра таким образом, чтобы их зависимости были направлены вовнутрь к ядру;
  3. Зависимости в противоположную торону от ядра недопустимы;
  4. Оболочка системы состоит из модулей конкретных реализаций внутренних асбтракций.

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

Заключение

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

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

Кроме того, не стоит забывать об использовании всевозможных Dependency Injection-фреймворкам, которые помогут избежать трудностей при управлении временем жизни объектов в такой вот «вывернутой» архитектуре.

Отпуск продолжается :)

Опубликовать

13 комментариев:

  1. Спасибо, интересно.

    Вообще говоря не согласен с постанвкой вопроса

    Все бы хорошо, но данная схема подразумевает первичность базы данных. Ее разработка влияет на все остальные уровни. И в случае, если мы хотим смоделировать бизнес-логику, используя Domain Driven Design, это влияние будет столь значительным, что рано или поздно все попытки разработать полноценную модель скатятся в использование Active Record или Table Module, похоронив все дальнейшие попытки использования преимуществ возможностей богатой модели.


    Как насчёт Data Mapper?

    ОтветитьУдалить
  2. DataMapper как бы ортогонален к рассматриваемой проблеме. Например, на втором рисунке он может использоваться (а может и нет) в классах SqlServerRepository. Точно так же в классах WebFormsController паттерн DataMapper может использоваться для преобразования данных из формата модели домена в формат, удобный для отображения.

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

    Но. Использование этого паттерна, условно говоря, там не обязательно. И его наличие либо отсутствие сути описанной проблемы не меняет.

    ОтветитьУдалить
  3. все эти мысли можно было резюмировать в low coupling, high cohesion, не? :-)

    ОтветитьУдалить
  4. Можно. Но так было бы скучно резюмировать каждый второй пост :)

    ОтветитьУдалить
  5. Игорь, спасибо за пост. Есть пара мыслей по этому поводу.

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

    Так вот, к чему это я. UI зависит от состояния модели и вляет на него - это понятно. Сюда можно положить MVC или что-то другое. Контроллеры знают, что есть модель, UI не знает - все замечательно. Но как насчет инфраструктуры? Управляет ли модель своим сохранением или нет? Согласно Onion architecture - нет, потому что зависимости направлены внутрь ядра. В то же время очень часто ситуация обратная - как раз модель знает, что нужно пойти и сохранить свое состояние куда-нибудь, или пойти и получить его, материализовав объекты в памяти.

    Логика подсказывает что здесь тоже может быть какой-нибудь "контроллер", который позволит отделить данные от модели, так чтобы они ничего не знали друг о друге. Или, возможно, нужно реализовать какую-нибудь "логику приложения", которая будет связывать модель с инфраструктурой. Кстати, здесь очень часто используются все те же контроллеры UI, но не уверен, что это правильно. Так все же как это лучше реализовать?

    ОтветитьУдалить
  6. Саша, привет.

    Модель это набор сущностей, это не набор оперций над сущностями. Поэтому не может модель управлять сохранением/чтением.

    Модель позволяет коду твоего приложения моделировать ситуации. Так например есть продукт и есть склад. Код приложения должен взять обьекты модели(тока приложение знает где их взять) и добавить продукт в склад. И после этого их сохранить.


    В зависимости от потребностей код приложения может быть в отдельном слое(слой приложения) или в простых случая контроллер МВЦ.

    По идее так.

    ОтветитьУдалить
  7. Миш, поправка принимается. В некотгорых местах, де я писал про модель, я понимал слойбизнес логики в целом. Надо быть точнее с терминами, но судя по твоему ответу мы поняли друг друга :)

    Согласно твоим словам, команду прочитать/сохранить данные отдает какой-то код отдельного слоя или контроллера MVC. По сути, это почти то же самое, что я писал, хотя и напутал, где модель, где BL/ядро :)

    "Логика подсказывает что здесь тоже может быть какой-нибудь "контроллер", который позволит отделить данные от модели, так чтобы они ничего не знали друг о друге. Или, возможно, нужно реализовать какую-нибудь "логику приложения", которая будет связывать модель с инфраструктурой. Кстати, здесь очень часто используются все те же контроллеры UI (MVC), но не уверен, что это правильно."

    С контроллерами MVC все понятно, за исключением того, почему они вдруг что-то знают про сохранение модели. По идее, это не их дело, что там BL (ядро) делает с целью сохранения своего состояния. Мне кажется, что инфраструктурные вопросы ядра должно решать само ядро, которое должно быть сконфигурировано для этого соответствующим образом. Так вот интересно, как правильно реализовать вот это взаимодействие между BL/ядром и слоем сохранения. В терминах Onion, Hexagonal, DDD или чего угодно еще :) Спасибо!

    ОтветитьУдалить
  8. Что такое инфраструктура сохранения? Вызов методов Add(...)/Get(...) у репозитория - это BL или инфраструктура в заданном вопросе?

    ОтветитьУдалить
  9. Убрать слой сохранения. И все упрощаеться.

    Смотри по Игоря схеме есть модель(Domain Logic - шо за фигня?). В нее же входят и репозитраии. Точнее контракты репозитариев.

    Далее есть инфраструктура в ней есть реалзиации этих репозитариев.

    Далее есть слой приложения (IMembershipService, IProductPublisher, IStoreManager). В него инжектяться репозитраии.

    Тоесть фактически получаеться что нема слоев. Есть один большой модуль бизнеслогики (и небольшой хак ввиде заинжекченых реальных репозитареив да внешних сервисов).

    Как то плутано получилось ;(. Это проще кодом расказать.

    ОтветитьУдалить
  10. Игорь: Что такое инфраструктура сохранения - не знаю, я вроде бы такое не писал... Слой сохранения можно понимать как обычный DAL. В терминах DDD он же является инфраструктурой. В принципе, Миша внизу правильно расписал: реализацию репозитория можно отнести в эту самую инфраструктуру, контракты - выше, в BL/ядре. А вот места вызовов Add()/Get() у репозитория - это как раз то, о чем я спрашиваю.

    Когда BL использует DAL (обычная многослойная архитектура) - все понятно. Классы BL (те же Мишины MembershipService, ProductPublisher, StoreManager) вызывают через контракты заинжекченных репозиториев методы Add/Get у конкретных реализаций. Но как в случае с Onion? Согласно приведенной диаграмме и вашему описанию "Другие модули выстраиваются вокруг ядра таким образом, чтобы их зависимости были направлены вовнутрь к ядру" и "Зависимости в противоположную торону от ядра недопустимы". Вот я и пытаюсь понять, каким образом мои репозитории вдруг будут зависеть от моего ядра? Скорее всего, подразумевается то, что написал Миша: "Далее есть слой приложения (IMembershipService, IProductPublisher, IStoreManager). В него инжектяться репозитраии". Но тогда диаграмма с Database и UI явно либо неверна, либо символична, потому что объективно UI (хотя бы в виде его контроллера) имеет зависимость на ядро, а вот Database, изображенный аналогично - нет.

    Миш, спасибо за описание. В принципе, я тоже это все именно так и понимаю, но тогда где-то в диаграмме и ее описание есть ошибка... Или, возможно, я просто чего-то недопонял :)

    ОтветитьУдалить
  11. Перечитал статьи по onion. Ок, теперь я понял смысл и разобрался в том, что мне не очень нравилось. Автор говорит о физических зависоимостях, а не о логических.

    Key tenets of Onion Architecture:

    * The application is built around an independent object model
    * Inner layers define interfaces. Outer layers implement interfaces
    * Direction of coupling is toward the center
    * All application core code can be compiled and run separate from infrastructure


    Ну, и стрелочек наружу в его диаграммах тоже нет, это сбивало с толку.

    Уже пробовали реализовать такую архитектуру в реальности?

    ОтветитьУдалить
  12. Угу, текущий проект и использует эту архитектуру. Ессно что иземенненное. Но суть именно такая.

    ОтветитьУдалить
  13. Тема интересная, спасибо.
    Картинки битые :(

    ОтветитьУдалить