P.S. Пример выполнен в виде Scala, но язык не имеет особого значения, меня интересует функциональный подход в целом.
Обычно я видел такую картину
outer world -> controller -> serviceA -> serviceB -> DB Accessor
Итак, мы получили слои, в которых функция внешнего слоя вызывает функцию внутреннего слоя. (Для простоты я опускаю упаковку монад IO)
class Controller(service: ServiceA) {
def call(req): res {
val req1 = someWorkBefore(req)
val res1 = service.call(req1)
someWorkAfter(res1)
}
private someWorkBefore(req): req1
private someWorkAfter(res1): res
}
class ServiceA(service: ServiceB) {
def call(req1): res1 {
val req2 = someWorkBefore(req1)
val res2 = service.call(req2)
someWorkAfter(res2)
}
private someWorkBefore(req1): req2
private someWorkAfter(res2): res1
}
class ServiceB(db: DBAccessor) {
def call(req2): res2 {
val req3 = someWorkBefore(req2)
val res3 = service.call(req3)
someWorkAfter(res3)
}
private someWorkBefore(req2): req3
private someWorkAfter(res3): res2
}
Проблема, которую я здесь вижу, заключается в том, что все функции «не чистые», и чтобы написать тест какого-то компонента, нужно издеваться над его внутренним компонентом, что, на мой взгляд, нехорошо.
Другой вариант — как-то забыть о разделении задач и собрать все в одном месте. (Для простоты я опускаю упаковку монад IO)
class Controller(serviceA: ServiceA, serviceB: ServiceB, db: DBAccessor) {
def call(req): res = {
val req1 = someWorkBefore(req)
val req2 = serviceA.someWorkBefore(req1)
val req3 = serviceB.someWorkBefore(req2)
val res3 = db.call(req3)
val res2 = serviceB.someWorkAfter(res3)
val res1 = serviceA.someWorkAfter(res2)
someWorkAfter(res1)
}
private someWorkBefore(req): req1
private someWorkAfter(res1): res
}
Это выглядит лучше, потому что каждая функция в сервисах в некотором роде чиста и не зависит от других вещей, которые следует высмеивать, но функция Controller
теперь представляет собой беспорядок.
Какие еще варианты архитектуры можно рассмотреть?
Да, я знаю, что мой вопрос связан не с IO (даже из примера убрал), а со структурой кода. Причина, по которой я упомянул FP, заключается в том, чтобы привлечь людей, у которых, по моему мнению, больше шансов дать хороший совет. И потому что я использую Scala.
Хорошо, тогда возникает вопрос: зачем вам нужно имитировать частные методы, чтобы протестировать Controller
? Все должно быть протестировано Controller
— это имитация ServiceA
, если вы хотите следовать традиционному модульному тестированию. Или вы можете использовать другую стратегию тестирования.
Меня беспокоила цепочка вызовов от Controller
до Db
, и чтобы протестировать любой компонент из этой цепочки, нужно издеваться над его зависимым компонентом. И мне кажется, что это издевательство над зависимым компонентом. Вот и захотелось поискать альтернативные конструкции
Почему вы считаете это «неоптимальным»? Как бы вы это сделали в кодовой базе, отличной от FP? Вот что я имею в виду, когда говорю, что вопрос не связан с ФП. - В любом случае, да, проверьте пост в блоге о тестировании сценариев, которым я поделился, у меня такое ощущение, что это больше соответствует тому, что вы хотите.
Что делать вместо этого, зависит от того, какой именно язык вы используете, и от используемых в нем идиом. В Haskell, например, свободные монады могут быть одним из способов решения таких проблем, но хотя свободные монады технически возможны и в F#, я бы не считал их идиоматическими в этом контексте.
Поскольку веб-приложения являются своего рода интерактивным программным обеспечением , я обычно нахожу их хорошо подходящими для архитектуры Functional Core, Imperative Shell . Другая метафора — рассматривать такую архитектуру как сэндвич. Выполните нечистые действия, вызовите чистую функцию, выполните еще несколько нечистых действий и выйдите.
На протяжении многих лет я довольно подробно писал на эту тему. В качестве примера другие читатели нашли полезным следующий пример: Рефакторинг потока регистрации в функциональную архитектуру.
Короче говоря, в функциональном программировании (по крайней мере, во всех примерах, которые я видел) точка входа всегда нечиста (ср. действия Haskell main
), поэтому вы помещаете все нечистое как можно ближе к точке входа, а затем вызовите чистые функции со значениями, полученными в результате нечистых действий.
Идея перенести нечистые функции в точку входа звучит круто и просто. Я думаю, это кажется странным, когда кто-то приходит из какого-то опыта ООП, когда нечистая база данных, например, находится на самом низком уровне вложенных вызовов.
Взгляните на этот пример с ZIO — blog.pierre-ricadat.com/… — аналогичный подход можно использовать для Cats Effect: функции домена работают на case class
/sealed
/Scala 3 enum
, ошибки обрабатываются с помощью Either
s, некоторые репозитории работают с монадой ввода-вывода, контроллеры затем выполняют преобразование между представлениями API/DTO и моделями предметной области, делегируя операции предметной области чистым функциям и сохраняемость нечистым (монадам ввода-вывода).
Идея функционального веб-приложения заключается в простом наличии функции «Запрос -> Ответ».
Проблема здесь не в ФП и
IO
ее не решит (насколько я это люблю и использую). Проблема просто в том, что ваш код смоделирован неправильно. Если вам нужно смоделировать методprivate
вашего класса для его тестирования, то это проблема. - Кстати, вы также можете рассмотреть возможность сценарного тестирования как альтернативы модульному тестированию, что может помочь вам избежать некоторого рефакторинга: jducoeur.medium.com/…