Чем интересна функциональная архитектура? Она имеет тенденцию попадать в так называемую «яму успеха» («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
Это компилируется и на первый взгляд кажется многообещающим. Тип функции
getReservedSeats
— ZonedTime -> 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:
- легкий, но затягивающий рассказ Николая Гусева о функциональном программировании для C#
- интересный доклад для практиков от специалиста по паттернам Mark Seeman «From dependency injection to dependency rejection»
- практично-полезный рассказ Романа Неволина о провайдерах типов: как их использовать, какие проблемы они решают и как их написать
- и кейноут Андрея Акиньшинина о «performance-тестировании».
Комментарии (10)
marshinov
01.11.2017 21:40Для ясного и наглядного введения в монады типа Either смотрите отличную статью Скотта Улашина (Scott Wlaschin) «Railway oriented programming» (EN)
Перевод есть на Хабре.
redyuf
01.11.2017 23:33Я правда ни разу не ФП-шник, но понял из статьи, что в check была зависимость от getReservedSeats, выносим ее выше и check зависит уже от результата getReservedSeats. Просто перенесли сложность из одного места в другое.
Похоже на то, как в js, асинхронщину в виде стримов или async/await выносят выше, что б в функции оставалась чистая бизнес логика.
Я так понимаю, это делается что б было проще переиспользовать и тестировать чистые функции.
Но нечистые все-равно же никуда не денутся, они просто выше окажутся, их тестировать и мочить тоже надо, смысл тогда какой в переносе, только в упрощении слоя с чистой логикой?
В статье вроде подразумевается, что этот подход лучше, чем DI и SOLID, а чем лучше — не понятно. Как здесь решить проблему с контекстами, когда детали верхнего уровня не знают о деталях нижних уровней. В DI можно определить реализацию интерфейса выше и не прокидывать его вниз, а просто прописать зависимость в конструкторе.
Как простому фронтендщику, мне было бы гораздо понятнее, если б хотя бы два todomvc запилили на dependency rejection и на dependency injection, и сравнили плюсы и минусы.
wlbm_onizuka
02.11.2017 07:37Что такое «Прием брони»?
WarKnife
02.11.2017 11:51Жаргонизм — «Прием заказа на бронирование»
wlbm_onizuka
03.11.2017 06:31Прошу прощения за глупость, но я все равно не понял))
WarKnife
03.11.2017 13:46Разберем выражение «Прием брони»
Прием — обработка, в том или ном виде, заявки от клиента (опр. по контексту статьи). Например, прием звонков, прием заявлений, прием обращений и т.д.
«Бронь» — жаргонизм от бронировать. Например, сделать бронь в ресторане.
В тексте комбинация из этих понятий используется в заголовках. Под первым из них
«Прием брони на F#, попытка первая»
излагается пояснение
«В моем Pluralsight-курсе Test-Driven Development with F# (доступна сокращенная бесплатная версия: www.infoq.com/presentations/mock-fsharp-tdd) я демонстрирую, как реализовать HTTP API для онлайн-системы бронирования ресторанов, который принимает заявки на резервирование. Один из шагов при обработке запроса на резервирование — проверить, достаточна (заставляет задуматься о грамотности перевода, прим.) ли свободных мест в ресторане для приема брони.»
В переводе присутствует такая же жаргонная фраза — «бронирования ресторанов» — при прочтении текста становится очевидно, что бронируются не рестораны, а места в них.
Оригинальный текст не читал, поэтому не могу сказать, является ли сам текс безграмотным или таков лишь перевод.
roman_kashitsyn
02.11.2017 17:25обратите внимание, что функция liftIO вызывается дважды. Вам не нужно в деталях понимать, что она делает, но она необходима, чтобы «вытащить» значение из IO-типа
Это совершенно неправильно.
liftIO
нужен для выполненияIO
внутри стека монадических трансформеров. В коде автора никаких трансформеров нет, всё выполняется в IO, трансфомеры есть только в коде на github. «Вытаскивает» значение из IO-типа бинд (>>=
) илиx <- m
вdo
-сахаре.
shishmakov
03.11.2017 11:25"Похоже, с таким количеством переводов он скоро станет топовым хаброавтором, даже не имея здесь аккаунта!"
Вы переводы от своего лица имеете ввиду?
Tiendil
Это случаем не то, что нынче называется гексагональной архитектурой и от парадигмы особо не зависит?
olegchir Автор
Вопрос с акцентом на то, почему это привязано к F# и Haskell? Потому что автор на них пишет, конечно же! :)
Если плясать от идеи того, что нам нужно сделать что-то гексагональное, неважно на чем — то наверное да, берем и натягиваем птиц любых видов на глобусы любых размеров, не маленькие уже.
А если плясать от идеи того, что нужно писать чистый код (насколько это можно на C#, F# и Scala, и имея в виду Haskell как пример хороших практик), то можно писать по-разному. Возникает вопрос — какой функциональный дизайн можно считать действительно хорошим. И что немаловажно, как именно записать это в синтаксисе языка. Например, для большинства людей совершенно неочевидно, что если мы откажемся от dependency injection, это не повлечет за собой замусоривание кода упоминанием зависимостей.