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

в 12:03, , рубрики: .net, C#, dotnext, dotnext2017moscow, F#, Блог компании JUG.ru Group, функциональное программирование

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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 обеспечивает использование архитектуры портов и адаптеров. Порты — это ваш код ввода-вывода. Ядро приложения — это все ваши чистые функции. Система типов автоматически сталкивает вас в «яму успеха».

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

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. Я намеренно помещаю две функции для работы с базой данных внизу диаграммы ниже, чтобы она казалась более знакомой читателям, привыкшим к многоуровневым архитектурным диаграммам. Вы можете себе представить, что под кругами есть цилиндрические объекты, представляющие базы данных.

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

Вы можете рассматривать функции 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:

Автор: olegchir

Источник

Поделиться

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