company_banner

Функциональная архитектура — это порты и адаптеры

Автор оригинала: Mark Seemann
  • Перевод
Представляю вашему вниманию новую статью Mark Seemann. Похоже, с таким количеством переводов он скоро станет топовым хаброавтором, даже не имея здесь аккаунта!

Чем интересна функциональная архитектура? Она имеет тенденцию попадать в так называемую «яму успеха» («Pit of Success»), в условиях которой разработчики оказываются в ситуации, вынуждающей писать хороший код.


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


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


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


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

Как я объяснял ранее, вы должны прибегнуть к каким-либо вариантам портов и адаптеров, если применяете Injection Dependency.

Однако проблема с этой архитектурой заключается в том, что, похоже, для ее реализации требуется много объяснений:

  • моя книга о Dependency Injection имеет объем 500 страниц;
  • книга Роберта Мартина о SOLID-принципах, дизайне пакетов, компонент и т.п. также занимает 700 страниц;
  • Проблемно-ориентированное программирование — 500 страниц;
  • и так далее…

По моему опыту, реализация архитектуры портов и адаптеров — сизифов труд. Она требует много усердия, но если отвлечься на мгновение, валун снова покатится вниз.


Реализовать архитектуру портов и адаптеров в объектно-ориентированным программировании вполне возможно, но это требует больших усилий. Должно ли это быть так сложно?

Haskell как учебное пособие


Имея неподдельный интерес к функциональному программированию, я решил изучить Haskell. Не то, чтобы Haskell был единственным функциональным языком, но он обеспечивает чистоту на уровне, не достижимом ни F#, ни Clojure, ни Scala. В Haskell функция является чистой, если ее тип не указывает иного. Это заставляет вас быть осторожным в дизайне и отделять чистые функции от функций с побочными эффектами.

Если вы не знаете Haskell, код с побочными эффектами может появиться только внутри определенного «контекста», называемого IO (ввод-вывод). Это монадический тип, однако это не главное. Главное заключается в том, что по типу функции вы можете сказать, чистая она или нет. Функция с типом

ReservationRendition -> Either Error Reservation

является чистой, поскольку IO в типе отсутствует. С другой стороны, функция с типом:

ConnectionString -> ZonedTime -> IO Int 

не чистая, потому что возвращаемый ею тип — IO Int. Это означает, что возвращаемое значение является целым числом, но это целое происходит из контекста, в котором оно может меняться между вызовами функции.

Существует фундаментальное различие между функциями, возвращающими Int и IO Int. В Haskell любая функция, возвращающая Int, ссылочно прозрачная en.wikipedia.org/wiki/Referential_transparency. Это означает, что функция гарантированно будет возвращать одно и то же значение при одном и том же вводе. С другой стороны, функция, возвращающая IO Int, не дает такой гарантии.

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

В целом это означает, что система типов в Haskell обеспечивает использование архитектуры портов и адаптеров. Порты — это ваш код ввода-вывода. Ядро приложения — это все ваши чистые функции. Система типов автоматически сталкивает вас в «яму успеха».


Haskell — отличный помощник в обучении, потому что заставляет вас четко различать чистые и нечистые функции. Вы даже можете использовать его в качестве инструмента проверки того, является ли ваш код F# «достаточно функциональным».

F# — в первую очередь функциональный язык, но он также позволяет писать объектно-ориентированный или императивный код. Если вы напишете свой код на F# «функциональным» способом, его легко перевести на Haskell. Если ваш код F# трудно перевести на Haskell, вероятно, он не является функциональным.

Ниже для вас живой пример.

Прием брони на F#, попытка первая


В моем Pluralsight-курсе Test-Driven Development with F# (доступна сокращенная бесплатная версия: http://www.infoq.com/presentations/mock-fsharp-tdd) я демонстрирую, как реализовать HTTP API для онлайн-системы бронирования ресторанов, который принимает заявки на резервирование. Один из шагов при обработке запроса на резервирование — проверить, достаточна ли свободных мест в ресторане для приема брони. Функция выглядит так:

// int
// -> (DateTimeOffset -> int)
// -> Reservation
// -> Result<Reservation,Error>
let check capacity getReservedSeats reservation =
    let reservedSeats = getReservedSeats reservation.Date
    if capacity < reservation.Quantity + reservedSeats
    then Failure CapacityExceeded
    else Success reservation

Как следует из комментария, второй аргумент getReservedSeats — это функция типа DateTimeOffset -> int. Функция check вызывает ее, чтобы получить количество уже зарезервированных мест на запрошенную дату.

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

let getReservedSeats _ = 0
let actual = Capacity.check capacity getReservedSeats reservation

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

let imp =
    Validate.reservation
    >> bind (Capacity.check 10 (SqlGateway.getReservedSeats connectionString))
    >> map (SqlGateway.saveReservation connectionString)

Здесь SqlGateway.getReservedSeats connectionString — частично применяемая функция, тип которой — DateTimeOffset -> int. В F# вы не можете сказать по типу, что она нечистая, но я знаю, что это так, потому что я написал эту функцию. Функция запрашивает базу данных, поэтому не является ссылочно чистой.

Все это хорошо работает в F#, где от вас зависит, будет ли конкретная функция чистой или нечистой. Поскольку imp состоит из Composition root этого приложения, нечистые функции SqlGateway.getReservedSeats и SqlGateway.saveReservation появляются только на границе системы. Остальная часть системы хорошо защищена от побочных эффектов.

Это выглядит функциональным, но так ли это на самом деле?

Фидбэк от Haskell


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

checkCapacity :: Int
              -> (ZonedTime -> Int)
              -> Reservation
              -> Either Error Reservation
checkCapacity capacity getReservedSeats reservation =
  let reservedSeats = getReservedSeats $ date reservation
  in if capacity < quantity reservation + reservedSeats
      then Left CapacityExceeded
      else Right reservation

Это компилируется и на первый взгляд кажется многообещающим. Тип функции getReservedSeatsZonedTime -> Int. Поскольку IO нигде в этом типе не появляется, Haskell гарантирует, что он чистый.

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

getReservedSeatsFromDB :: ConnectionString -> ZonedTime -> IO Int


Хотя вы можете частично применить первый аргумент ConnectionString, возвращаемое значение будет IO Int, а не Int.

Функция типа ZonedTime -> IO Int — это не то же самое, что ZonedTime -> Int. Даже при выполнении внутри IO-контекста вы не можете преобразовать ZonedTime -> IO Int в ZonedTime -> Int.

С другой стороны, вы можете вызвать нечистую функцию внутри IO-контекста и извлечь Int из IO Int. Это не совсем соответствует приведенной выше функции checkCapacity, поэтому нужно будет пересмотреть ее дизайн. Хотя код на F# выглядел «достаточно функционально», оказывается, этот дизайн не является действительно функциональным.

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

checkCapacity :: Int -> Int -> Reservation -> Either Error Reservation
checkCapacity capacity reservedSeats reservation =
    if capacity < quantity reservation + reservedSeats
    then Left CapacityExceeded
    else Right reservation

Так намного проще. На границе системы приложение выполняется в IO-контексте, позволяя создавать чистые и нечистые функции:

import Control.Monad.Trans (liftIO)
import Control.Monad.Trans.Either (EitherT(..), hoistEither)

postReservation :: ReservationRendition -> IO (HttpResult ())
postReservation candidate = fmap toHttpResult $ runEitherT $ do
  r <- hoistEither $ validateReservation candidate
  i <- liftIO $ getReservedSeatsFromDB connStr $ date r
  hoistEither $ checkCapacity 10 i r
  >>= liftIO . saveReservation connStr

(полный исходный код доступен здесь: https://gist.github.com/ploeh/c999e2ae2248bd44d775)

Не беспокойтесь, если вы не понимаете всех деталей этой композиции. Основные моменты я описал ниже:

Функция postReservation получает на вход ReservationRendition (считайте это документом JSON) и возвращает IO (HttpResult ()). IO информирует вас о том, что вся эта функция выполняется в IO-монаде. Другими словами, функция нечистая. Это не удивительно, поскольку речь идет о границе системы.

Кроме того, обратите внимание, что функция liftIO вызывается дважды. Вам не нужно в деталях понимать, что она делает, но она необходима, чтобы «вытащить» значение из IO-типа; т.е., например, вытащить Int из IO Int. Таким образом, становится ясно, где чистый код, а где — нет: функция liftIO применяется к getReservedSeatsFromDB и saveReservation. Это говорит о том, что эти две функции нечистые. Методом исключения остальные функции (validateReservation, checkCapacity и toHttpResult) являются чистыми.

Также возникает вопрос, как можно чередовать чистые и нечистые функции. Если вы присмотритесь, увидите, как данные передаются из чистой функции validateReservation, в нечистую функцию getReservedSeatsFromDB, а затем оба возвращаемых значения (r и i) передаются в чистую функцию checkCapacity и, наконец, в нечистую функцию сохранения saveReservation. Все это происходит в блоке (EitherT Error IO) () do, поэтому, если какая-либо из этих функций возвращает Left, функция замыкается и выдает итоговую ошибку. Для ясного и наглядного введения в монады типа Either смотрите отличную статью Скотта Улашина (Scott Wlaschin) «Railway oriented programming» (EN).
Значение из этого выражения получается с помощью встроенной функции runEitherT; и снова с этой чистой функцией:

toHttpResult :: Either Error () -> HttpResult ()
toHttpResult (Left (ValidationError msg)) = BadRequest msg
toHttpResult (Left CapacityExceeded) = StatusCode Forbidden
toHttpResult (Right ()) = OK ()

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


Вы можете рассматривать функции validateReservation и toHttpResult как принадлежащие модели приложения. Они являются чистыми и осуществляют перевод между внешним и внутренним представлением данных. Наконец, если хотите, функция checkCapacity является частью доменной модели приложения.

Большая часть дизайна моей первой попытки на F# сохранилась, кроме функции Capacity.check. Повторная реализация дизайна в Haskell преподала мне важный урок, который я могу теперь применить к своему коду на F#.

Прием брони на F#, еще более функционально


Требуемые изменения малы, так что урок, полученный от Haskell, легко применить к коду на базе F#. Главным виновником была функция Capacity.check, которая должна быть реализована следующим образом:

let check capacity reservedSeats reservation =
    if capacity < reservation.Quantity + reservedSeats
    then Failure CapacityExceeded
    else Success reservation

Это не только упрощает реализацию, но и делает композицию немного более привлекательной:

let imp =
    Validate.reservation
    >> map (fun r ->
        SqlGateway.getReservedSeats connectionString r.Date, r)
    >> bind (fun (i, r) -> Capacity.check 10 i r)
    >> map (SqlGateway.saveReservation connectionString)

Это выглядит чуть более сложным, чем функция Haskell. Преимущество Haskell заключается в том, что вы можете автоматически использовать любой тип, реализующий класс Monad внутри блока do, и поскольку (EitherT Error IO) () является экземпляром Monad, синтаксис do бесплатен.

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

Резюме


Хороший функциональный дизайн эквивалентен архитектуре «портов и адаптеров». Если вы используете Haskell в качестве критерия «идеальной» функциональной архитектуры, вы увидите, как ее явное различие между чистыми и нечистыми функциями создает так называемую «яму успеха». Если вы не напишете все свое приложение внутри IO-монады, Haskell автоматически отразит различие и вытолкнет всю связь с внешним миром на границы системы.

Некоторые функциональные языки, такие как F#, не используют это различие явно. Тем не менее, в F# легко неофициально реализовать его и строить приложения с нечистыми функциями, размещенными у границ системы. Хотя это различие не навязывается системой типов, оно по-прежнему кажется естественным.



Если тема функционального программирования для вас актуальна как никогда, наверняка вас заинтересуют вот эти доклады с нашей двухдневной ноябрьской конференции DotNext 2017 Moscow:

JUG Ru Group
Конференции для программистов и сочувствующих. 18+

Комментарии 14

    +4
    Это случаем не то, что нынче называется гексагональной архитектурой и от парадигмы особо не зависит?
      +2
      Вопрос с акцентом на то, почему это привязано к F# и Haskell? Потому что автор на них пишет, конечно же! :)

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

      А если плясать от идеи того, что нужно писать чистый код (насколько это можно на C#, F# и Scala, и имея в виду Haskell как пример хороших практик), то можно писать по-разному. Возникает вопрос — какой функциональный дизайн можно считать действительно хорошим. И что немаловажно, как именно записать это в синтаксисе языка. Например, для большинства людей совершенно неочевидно, что если мы откажемся от dependency injection, это не повлечет за собой замусоривание кода упоминанием зависимостей.
      +2
      Для ясного и наглядного введения в монады типа Either смотрите отличную статью Скотта Улашина (Scott Wlaschin) «Railway oriented programming» (EN)

      Перевод есть на Хабре.
        +1
        Я правда ни разу не ФП-шник, но понял из статьи, что в check была зависимость от getReservedSeats, выносим ее выше и check зависит уже от результата getReservedSeats. Просто перенесли сложность из одного места в другое.

        Похоже на то, как в js, асинхронщину в виде стримов или async/await выносят выше, что б в функции оставалась чистая бизнес логика.

        Я так понимаю, это делается что б было проще переиспользовать и тестировать чистые функции.

        Но нечистые все-равно же никуда не денутся, они просто выше окажутся, их тестировать и мочить тоже надо, смысл тогда какой в переносе, только в упрощении слоя с чистой логикой?

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

        Как простому фронтендщику, мне было бы гораздо понятнее, если б хотя бы два todomvc запилили на dependency rejection и на dependency injection, и сравнили плюсы и минусы.
          0

          Насколько я понимаю, автор не совсем это хотел сказать.Вроде бы он сразу обозначил, что говорит про некие границы (порт/адаптер) и что этому способствуют и SOLID и чистота ФП.


          По-моему он намекал не на проблемы SOLID в-прицнипе, а на проблемы его поддержки. Среда разработки не дает контроля за выполением этого самого SOLID. А вот в случае с чистыми функциями это контролируется. Чтобы что-то испортить тебе нужно явно это указать. А это очень быстро бросится в глаза на код-ревью.


          Ну примерно как кто-то где-то изменил тип возвращаемого значения с int на object. Понижение уровня контроля сразу же бросается в глаза и ты спешишь проверить, а действительно ли оно требовалось… Действительно ли в данном конкретном месте и т.д.

            0
            Среда разработки не дает контроля за выполением этого самого SOLID
            Что значит не дает контроля за выполнением? Проверка типов есть, интерфейсы есть. Испортил реализацию — приложение не соберется.

            Почему быстро бросится в глаза, а в SOLID значит не быстро? Это разве не от строгости языка зависит?

            Как без DI быть с абстракциями, когда где-то наверху надо сказать, замени на 10м уровне вложенности класс X на Y, не влезая в иерархию деталей нижних уровней.
              0
              Что значит не дает контроля за выполнением? Проверка типов есть, интерфейсы есть. Испортил реализацию — приложение не соберется.

              Соблюдение SRP не проверить даже статическим анализом.
              Соблюдение OCP в теории частично можно автоматизировать, но не встречал.
              Соблюдение LSP проверяемо.
              Соблюдение ISP только частично. В теории конечно есть контроль на уровне статического анализа. Но анализатор не в состоянии подсказать, что ты используешь абстракцию слишком широко, что есть возможность использовать другой метод и уточнить абстракцию.
              DIP — окей.
              Итого только две состовляющие, но все равно с некой натяжкой. Какого-то 100% контроля не видно. К тому же из коробки этого нет. Нужно цеплять статический анализ.


              Почему быстро бросится в глаза, а в SOLID значит не быстро? Это разве не от строгости языка зависит?

              Как скоро на ревью тебе бросится в глаза разрыв SRP? При первом обновлении не заметил. Второе обновление ревьювил другой. Усталость, загруженность, ну и т.д.
              Смену ключевого слова сразу заметишь и задумаешься. С остальным прицнипами лучше. Но опять же, глаза чаще будут закрываться на нарушение того или иного прицнипа. Потому что в данный конкретный момент это может выглядеть небольшим минорным изменением, которое не порушит идею в целом. Но по мелочам потом скопится тот еще огород. А вот делать функцию нечистой — это очень пахучий ход.


              Мое понимание статьи примерно такое.

          +2
          Что такое «Прием брони»?
            0
            Жаргонизм — «Прием заказа на бронирование»
              0
              Прошу прощения за глупость, но я все равно не понял))
                0
                Разберем выражение «Прием брони»

                Прием — обработка, в том или ном виде, заявки от клиента (опр. по контексту статьи). Например, прием звонков, прием заявлений, прием обращений и т.д.

                «Бронь» — жаргонизм от бронировать. Например, сделать бронь в ресторане.

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

                «Прием брони на F#, попытка первая»

                излагается пояснение

                «В моем Pluralsight-курсе Test-Driven Development with F# (доступна сокращенная бесплатная версия: www.infoq.com/presentations/mock-fsharp-tdd) я демонстрирую, как реализовать HTTP API для онлайн-системы бронирования ресторанов, который принимает заявки на резервирование. Один из шагов при обработке запроса на резервирование — проверить, достаточна (заставляет задуматься о грамотности перевода, прим.) ли свободных мест в ресторане для приема брони.»

                В переводе присутствует такая же жаргонная фраза — «бронирования ресторанов» — при прочтении текста становится очевидно, что бронируются не рестораны, а места в них.

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

            Это совершенно неправильно. liftIO нужен для выполнения IO внутри стека монадических трансформеров. В коде автора никаких трансформеров нет, всё выполняется в IO, трансфомеры есть только в коде на github. «Вытаскивает» значение из IO-типа бинд (>>=) или x <- m в do-сахаре.

              0

              "Похоже, с таким количеством переводов он скоро станет топовым хаброавтором, даже не имея здесь аккаунта!"
              Вы переводы от своего лица имеете ввиду?

                0
                так ведь куча переводов и не от моего лица, поищите. e_fedorov очередную стаью цикла про монады буквально на днях выпустил

                тут вообще случилось смешное — мы переводили вдвоем одну статью одновременно с mitutee (одновременно, Карл!). Я выпустил на два дня раньше, что привело mitutee в некоторое уныние

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

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

              Самое читаемое