Представляю вашему вниманию новую статью Mark Seemann. Похоже, с таким количеством переводов он скоро станет топовым хаброавтором, даже не имея здесь аккаунта!
Чем интересна функциональная архитектура? Она имеет тенденцию попадать в так называемую «яму успеха» («Pit of Success»), в условиях которой разработчики оказываются в ситуации, вынуждающей писать хороший код.
Обсуждая объектно-ориентированную архитектуру, мы часто сталкиваемся с идеей архитектуры портов и адаптеров, хотя часто называем ее как-либо иначе: многоуровневой, луковой или гексагональной архитектурой. Смысл состоит в том, чтобы отделить бизнес-логику от деталей технической реализации, чтобы мы могли варьировать их независимо друг от друга. Это позволяет нам маневрировать, реагируя на изменения в бизнесе или в технологиях.
Идея архитектуры портов и адаптеров заключается в том, что порты представляют собой границы приложения. Порт — это то, что взаимодействует с внешним миром: пользовательскими интерфейсами, очередью сообщений, базой данных, файлами, приглашениями командной строки и так далее. В то время как порты являются интерфейсом приложения для остального мира, адаптеры обеспечивают трансляцию между портами и моделью приложения.
Термин «адаптер» выбран удачно, поскольку роль адаптера (как шаблона проектирования) заключается в обеспечении связи между двумя разными интерфейсами.
Как я объяснял ранее, вы должны прибегнуть к каким-либо вариантам портов и адаптеров, если применяете Injection Dependency.
Однако проблема с этой архитектурой заключается в том, что, похоже, для ее реализации требуется много объяснений:
По моему опыту, реализация архитектуры портов и адаптеров — сизифов труд. Она требует много усердия, но если отвлечься на мгновение, валун снова покатится вниз.
Реализовать архитектуру портов и адаптеров в объектно-ориентированным программировании вполне возможно, но это требует больших усилий. Должно ли это быть так сложно?
Имея неподдельный интерес к функциональному программированию, я решил изучить Haskell. Не то, чтобы Haskell был единственным функциональным языком, но он обеспечивает чистоту на уровне, не достижимом ни F#, ни Clojure, ни Scala. В Haskell функция является чистой, если ее тип не указывает иного. Это заставляет вас быть осторожным в дизайне и отделять чистые функции от функций с побочными эффектами.
Если вы не знаете Haskell, код с побочными эффектами может появиться только внутри определенного «контекста», называемого IO (ввод-вывод). Это монадический тип, однако это не главное. Главное заключается в том, что по типу функции вы можете сказать, чистая она или нет. Функция с типом
является чистой, поскольку
не чистая, потому что возвращаемый ею тип —
Существует фундаментальное различие между функциями, возвращающими
В процессе написании программ на Haskell вы должны стремиться максимизировать количество чистых функций, сдвигая нечистый код к границам системы. Хорошая программа на Haskell имеет большое ядро чистых функций и оболочку кода ввода-вывода. Выглядит знакомо, не правда ли?
В целом это означает, что система типов в Haskell обеспечивает использование архитектуры портов и адаптеров. Порты — это ваш код ввода-вывода. Ядро приложения — это все ваши чистые функции. Система типов автоматически сталкивает вас в «яму успеха».
Haskell — отличный помощник в обучении, потому что заставляет вас четко различать чистые и нечистые функции. Вы даже можете использовать его в качестве инструмента проверки того, является ли ваш код F# «достаточно функциональным».
F# — в первую очередь функциональный язык, но он также позволяет писать объектно-ориентированный или императивный код. Если вы напишете свой код на F# «функциональным» способом, его легко перевести на Haskell. Если ваш код F# трудно перевести на Haskell, вероятно, он не является функциональным.
Ниже для вас живой пример.
В моем Pluralsight-курсе Test-Driven Development with F# (доступна сокращенная бесплатная версия: http://www.infoq.com/presentations/mock-fsharp-tdd) я демонстрирую, как реализовать HTTP API для онлайн-системы бронирования ресторанов, который принимает заявки на резервирование. Один из шагов при обработке запроса на резервирование — проверить, достаточна ли свободных мест в ресторане для приема брони. Функция выглядит так:
Как следует из комментария, второй аргумент
В ходе юнит-тестирования вы можете заменить чистую функцию заглушкой, например:
А во время итоговой сборки приложения вместо использования чистой функции с жестко фиксированным возвращаемым значением вы можете составить нечистую, которая запрашивает базу данных для получения требуемой информации:
Здесь
Все это хорошо работает в F#, где от вас зависит, будет ли конкретная функция чистой или нечистой. Поскольку
Это выглядит функциональным, но так ли это на самом деле?
Чтобы ответить на этот вопрос, я решил переделать основную часть приложения на Haskell. Моя первая попытка проверить свободные места была напрямую переведена следующим образом:
Это компилируется и на первый взгляд кажется многообещающим. Тип функции
С другой стороны, когда вам нужно реализовать функцию для извлечения количества зарезервированных мест из базы данных, она по своей природе должна будет стать нечистой, поскольку возвращаемое значение может меняться. Чтобы включить это в Haskell, функция должна иметь такой тип:
Хотя вы можете частично применить первый аргумент
Функция типа
С другой стороны, вы можете вызвать нечистую функцию внутри IO-контекста и извлечь
Если вы внимательно посмотрите на приведенную выше функцию
Так намного проще. На границе системы приложение выполняется в IO-контексте, позволяя создавать чистые и нечистые функции:
(полный исходный код доступен здесь: https://gist.github.com/ploeh/c999e2ae2248bd44d775)
Не беспокойтесь, если вы не понимаете всех деталей этой композиции. Основные моменты я описал ниже:
Функция
Кроме того, обратите внимание, что функция
Также возникает вопрос, как можно чередовать чистые и нечистые функции. Если вы присмотритесь, увидите, как данные передаются из чистой функции
Значение из этого выражения получается с помощью встроенной функции
Вся функция
Вы можете рассматривать функции
Большая часть дизайна моей первой попытки на F# сохранилась, кроме функции
Требуемые изменения малы, так что урок, полученный от Haskell, легко применить к коду на базе F#. Главным виновником была функция
Это не только упрощает реализацию, но и делает композицию немного более привлекательной:
Это выглядит чуть более сложным, чем функция Haskell. Преимущество Haskell заключается в том, что вы можете автоматически использовать любой тип, реализующий класс
Вы можете сделать нечто подобное в F#, но тогда вам придется реализовать собственный конструктор вычислительных выражений для типа Result. Я описал это в своем блоге.
Хороший функциональный дизайн эквивалентен архитектуре «портов и адаптеров». Если вы используете Haskell в качестве критерия «идеальной» функциональной архитектуры, вы увидите, как ее явное различие между чистыми и нечистыми функциями создает так называемую «яму успеха». Если вы не напишете все свое приложение внутри IO-монады, Haskell автоматически отразит различие и вытолкнет всю связь с внешним миром на границы системы.
Некоторые функциональные языки, такие как F#, не используют это различие явно. Тем не менее, в F# легко неофициально реализовать его и строить приложения с нечистыми функциями, размещенными у границ системы. Хотя это различие не навязывается системой типов, оно по-прежнему кажется естественным.
Если тема функционального программирования для вас актуальна как никогда, наверняка вас заинтересуют вот эти доклады с нашей двухдневной ноябрьской конференции DotNext 2017 Moscow:
Чем интересна функциональная архитектура? Она имеет тенденцию попадать в так называемую «яму успеха» («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-тестировании».