Использовать классы типов для реализации инверсии зависимостей в приложении Haskell?

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

Правило зависимостей гласит, что зависимости должны указывать только внутрь. Например.; постоянство может зависеть от функций и типов из вариантов использования, а варианты использования могут зависеть от функций и типов из предметной области. Но домен не может зависеть от внешних колец. Как мне реализовать такую ​​архитектуру в Haskell? Чтобы конкретизировать: как я могу реализовать модуль варианта использования, который не зависит (= импортирует) функции и типы от модуля постоянства, даже если ему необходимо извлекать и хранить данные?

Скажем, я хочу реализовать вариант использования размещение заказа с помощью функции U.placeOrder :: D.Customer -> [D.LineItem] -> IO U.OrderPlacementResult, которая создает заказ из позиций и пытается сохранить заказ. Здесь U указывает на модуль вариантов использования, а D — на модуль домена. Функция возвращает действие ввода-вывода, потому что ей каким-то образом нужно сохранить заказ. Однако само постоянство находится в самом внешнем архитектурном кольце — реализовано в каком-то модуле P; поэтому указанная выше функция не должна зависеть ни от чего, экспортируемого из P.

Я могу представить два общих решения:

  1. Функции высшего порядка: функция U.placeOrder принимает дополнительный аргумент функции, скажем, U.OrderDto -> U.PersistenceResult. Эта функция реализована в модуле persistence (P), но она зависит от типов модуля U, тогда как для модуля U не нужно объявлять зависимость от P.
  2. Классы типов: Модуль U определяет класс типов Persistence, который объявляет указанную выше функцию. Модуль P зависит от этого класса типов и предоставляет для него экземпляр.

Вариант 1 довольно явный, но не очень общий. Потенциально это приводит к функциям со многими аргументами. Вариант 2 менее подробный (см., например, здесь). Однако вариант 2 приводит ко множеству беспринципных классов типов, что считается плохой практикой в ​​большинстве современных учебников и руководств по Haskell.

Итак, у меня осталось два вопроса:

  • Я пропустил другие альтернативы?
  • Какой подход обычно рекомендуется, если таковой имеется?

person Ulrich Schuster    schedule 14.05.2020    source источник


Ответы (1)


На самом деле есть и другие альтернативы (см. ниже).

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

В вашем текущем примере это, кажется, не имеет большого значения, потому что U.placeOrder уже нечисто, но в целом вы хотите, чтобы ваш код Haskell состоял из как можно большего количества ссылочно-прозрачного кода.

Иногда вы видите предложение, связанное с монадой Reader, где «зависимости» передаются функции как контекст чтения, а не как прямые аргументы функции, но, насколько я могу судить, это просто (изоморфные?) вариации одного и того же идея, с теми же проблемами.

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

Функциональное ядро, императивная оболочка

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

U.placeOrder :: D.Customer -> [D.LineItem] -> U.OrderPlacementDecision

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

Это ваше функциональное ядро. Затем вы должны составить свою императивную оболочку (например, функцию main) в нечистый бутерброд:

main :: IO ()
main = do
  stuffFromDb <- -- call the persistence module code here
  customer -- initialised from persistence module, or some other place
  lineItems -- ditto
  let decision = U.placeOrder customer lineItems
  _ <- persist decision
  return ()

(Я, очевидно, не пытался проверить этот код, но надеюсь, что он достаточно правильный, чтобы понять суть.)

Бесплатные монады

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

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

Я написал серию статей о том, как думать о внедрении зависимостей в F# и Haskell. Я также недавно опубликовал статью, в которой ( среди прочего) демонстрирует эту технику. Большинство моих статей сопровождаются репозиториями GitHub.

person Mark Seemann    schedule 14.05.2020
comment
Марк, спасибо за отличный ответ и очень полезные сообщения в блоге. Я хотел бы копнуть немного глубже, чтобы добраться до того места, где я застрял. В моем упрощенном примере я говорю о средней оболочке чистой архитектуры, а не о домене, а о вариантах использования. В большом приложении их будет много. Простое добавление логики варианта использования в функцию main не поможет. Таким образом, варианты использования, вероятно, должны быть нечистыми (возврат действий ввода-вывода). Тем не менее, я хочу избежать того, чтобы модули вариантов использования зависели от деталей постоянства или пользовательского интерфейса. Бутерброд, кажется, не отвечает на этот вопрос. - person Ulrich Schuster; 14.05.2020
comment
Я читал о бесплатных монадах, но для новичка в Haskell их сложно понять, поэтому я решил запустить несколько существенных программ, прежде чем я взгляну на . Но из того, что я пока получаю, кажется странным писать собственный интерпретатор для базовых задач. Кажется, это означает изобретать собственный DSL для каждой проблемы, что для большинства корпоративных задач будет означать много дублирования и, наконец, кого-то, написавшего книги о функциональных шаблонах предприятия. Но требование шаблонов, похоже, подразумевает некоторый недостаток языка, поэтому я хочу в первую очередь перейти от ООП к ФП. - person Ulrich Schuster; 14.05.2020
comment
@UlrichSchuster Независимо от того, можете ли вы использовать нечистую сэндвич-архитектуру, это не имеет ничего общего с различием между моделью предметной области и вариантами использования. Чаще всего это соответствует типу приложения. Например, если вы внедряете REST API, я не вижу причин для нечистых вариантов использования. Каждый HTTP-запрос уже является нечистым (например, main), поэтому вы можете угодить каждому нечистому бутерброду. - person Mark Seemann; 14.05.2020
comment
@UlrichSchuster Написание интерпретатора бесплатной монады похоже на реализацию интерфейса в ООП. Почему это может привести к дублированию? - person Mark Seemann; 14.05.2020
comment
В вашем примере сервера ReSTful не будет ли main довольно маленькой функцией, которая содержит (нечистый) цикл основного события, возможно, предоставленный какой-то библиотекой, такой как Scotty или Servant? В зависимости от маршрута эта функция main вызывает разные функции для каждого варианта использования, реализованные в одном или нескольких модулях вариантов использования. Согласно чистой архитектуре цель состоит в том, чтобы эти модули не зависели ни от библиотеки HTTP, ни от какой-либо библиотеки постоянства. Но функциональность постоянства или HTTP должна быть где-то реализована — я не представляю все SQL-запросы в Main.hs. - person Ulrich Schuster; 14.05.2020
comment
@UlrichSchuster Действительно. Рассмотрим эту реализацию Servant API, который не зависит от модуля сохранения. Он смешивает модели HTTP и модели предметной области, потому что моей мотивацией для написания этого примера кода была не демонстрация того, как реализовать это разделение, но вы можете это сделать. Обратите внимание, что все в этом модуле чистое. - person Mark Seemann; 14.05.2020