В заметке "Идиоматическое внедрение зависимостей в ZIO 2" Пьер Рикадат описывает типичную структуру Scala-приложения на основе ZIO. Элементами этой структуры являются:
использование интерфейсов,
классы с зависимостями в конструкторе
и dependency injection для построения runtime-дерева приложения.
Известен также подход, описанный в заметке "От внедрения зависимостей к отказу от зависимостей" Марка Симана. Автор прежде был апологетом внедрения зависимостей и написал об этом книжку. Но в функциональном подходе можно строить системы без зависимостей, что требует отказа сразу от интерфейсов, классов с зависимостями и от понятия dependency injection.
Хотелось бы посмотреть, как те же примеры, что приводит Пьер Рикадат, будут выглядеть без классов и интерфейсов. А также понять, можно ли использовать ZLayer
'ы при разработке программ в функциональном стиле.
В чём проблема "сервисов"?
Одной из проблем, связанных с разбиением программы на "сервисы", является произвольность такого разбиения. Программист создаёт набор "абстракций", не связанных с какими-либо ограничениями реального мира, а затем испытывает затруднения с рефакторингом такой системы. Например, создаются "сервисы" типа DAO для доступа к отдельным табличкам, а затем вместо использования join'ов на стороне БД, сущности загружаются целиком в память и join делается вручную. Т.е. искусственные ограничения приводят к менее эффективным программам.
Сервис из одной функции
В примере используется сервис, состоящий из единственной функции - maskChat
. Такой "сервис" в определённом смысле эквивалентен обыкновенной функции:
type WordFilter = (msg: String, language: Option[Language]) => Task[String]
Нужна ли нам эта сигнатура?
Если бы мы следовали ООП-подходу, то эта сигнатура могла бы служить своего рода "интерфейсом" и мы бы исходили из того, что в точке использования можно одну реализацию заменить на другую.
Эта идея кажется выигрышной и дающей определённую гибкость. Однако на практике есть нюансы. Даниэль Спивак привёл развёрнутую аргументацию, в чём проблема обобщённого типа коллекций Seq[T]
. В частности, он говорит о том, что использование обобщённого типа приводит к крайне неэффективным программам.
В примере приводится "live"-реализация, требующая настроек и зависимостей, и тестовая реализация, свободная от зависимостей. Являются ли эти реализации взаимозаменяемыми? Как продемонстрировано там же далее - нет. Замена одной реализации на другую требует также внесения изменений в дерево зависимостей.
Как могла бы выглядеть простая live-функция maskChat
?
def maskChatLive1(config: WordFilterConfig, sttp: SttpClient)(msg: String, language: Option[Language]): Task[String] =
???
(Причём, вместо ???
можно подставить точный текст реализации из примера.)
Использование ZLayer
Наша функция в явном виде требует аргументы непосредственно при вызове. Это может оказаться не вполне удобно, если зависимости требуются не в этой функции, а в каких-то вложенных, вызываемых функциях.
Здесь на помощь приходит т.н. Reader-монада. И ZIO ZEnvironment
в частности.
Статичные аргументы функции мы переносим в ZLayer
:
def maskChatLive2(msg: String, language: Option[Language]): RIO[WordFilterConfig & SttpClient, String] =
for
config <- ZIO.serivce[WordFilterConfig]
sttp <- ZIO.service[SttpClient]
yeild
msg
Здесь, наверное, стоит напомнить сигнатуры ZIO
, Task
и RIO
для справки:
type ZIO[-R, E, +A] // R - окружение, E - ошибка, A - результат
type Task[+A] = ZIO[Any, Throwable, A]
type RIO[-R, +A] = ZIO[R, Throwable, A]
Тестовая функция:
def maskChatTest(msg: String, language: Option[Language]): Task[String] =
ZIO.succeed(msg)
Эти функции имеют несовместимые сигнатуры. Однако, за счёт ZIO ZEnvironment
'а, всё-таки можно построить сигнатуру, совместимую сразу с двумя реализациями:
type WordFilter[-R] = (msg: String, language: Option[Language]) => RIO[R, String]
Остаётся лишь вопрос, надо ли так делать.
Антипаттерн ли это?
Пьер Рикадат сообщает, что такое использование ZLayer'ов является антипаттерном, и приводит такие аргументы:
Вы “передаете” свою зависимость остальному коду и делаете его менее читаемым, нарушая принцип разделения ответственности (separation of concerns).
Эти зависимости могут измениться в будущем (например, если мы заменим sttp на что-то другое), и это вызовет множество изменений в разных частях кода.
А также даёт рекомендации:
ZIO
ZEnvironment
следует использовать только для передачи контекста, который удаляется на каком-то этапе.Если ваша зависимость всегда имеет одно и то же значение в течение работы приложения, переместите ее в свой класс, реализующий сервис.
Про изменение зависимостей в будущем, следует отметить, что такое изменение сложнее всего будет выполнить в точке, где зависимость используется. А во всех случаях, где зависимость лишь передаётся, проблема решается элементарно - поиск и замена. Так что этот аргумент выглядит несостоятельным.
Что касается передачи "низкоуровневых зависимостей" остальному коду, уменьшения читабельности и нарушения принципа разделения ответственности.
Являются ли довольно случайные объединения ряда функций в интерфейс/класс полноценной абстракцией? Или это просто привычный способ структурирования программы? Глядя на название сервиса "WordFilter", можем ли мы предполагать, что под капотом этот сервис умеет ходить в другие сервисы и падать с сетевыми ошибками? Является ли эта неожиданная способность (capability) такой уж "незначительной деталью реализации"?
В то же время, сигнатура функции maskChatLive
с исчерпывающей искренностью описывает все возможности (capability), которые она умеет использовать, и все ошибки (Throwable), которые могут быть выброшены в процессе. Является ли эта сигнатура сложной? Пожалуй. Но это уже вопрос отдельный, который может быть решён разного рода рефакторингом и выделением абстракций. При этом новые абстракции, которые могут появиться, не ограничены произвольным интерфейсом WordFilter.
Уменьшается ли читабельнось кода? С одной стороны, если смотреть на сигнатуру, и сравнивать её с сигнатурой maskChat
, кажется, что явно усложнилось. С другой стороны, исчезли зависимости, функция стала полностью автономной, не требуется отдельный класс. Т.е., если сравнивать единственную сигнатуру новой функции с наличием интерфейса, класса, и метода в нём, то, как будто количество привлекаемых абстракций уменьшилось. При некоторой привычке, передача окружения будет восприниматься как фон, и не будет влиять на восприятие кода. Удобно использовать короткие type-alias'ы для окружений.
type R = WordFilterConfig & SttpClient
def maskChatLive3(msg: String, language: Option[Language]): RIO[R, String] =
???
Принцип разделения ответственности (Separation of Concerns or Single Responsibility Principle) заключается в том, что человеку для внесения изменений в программу необходимо сфокусироваться на каком-то одном аспекте. Поэтому в одном модуле должна быть ровно одна причина внесения изменений, то есть, желательно весь модуль посвятить этому аспекту в изоляции от других аспектов.
Изменилось ли что-то в нашем случае с точки зрения этого принципа? Кажется, нет. А вот если бы в нашем сервисе было несколько методов, то здесь уже разбиение на отдельные методы позволило бы лучше этот принцип соблюдать, т.к. разнородные функции, отнесённые к одному интерфейсу, вполне могут меняться в интересах разных заказчиков. Сведение точки изменений к отдельной функции позволило бы вносить изменения, не затрагивая другие функции.
Далее автор даёт рекомендации о том, что если зависимость не меняется, то её надо поместить в свой класс. Мы же даём другую рекомендацию: если зависимость не меняется, то её надо поместить в ZEnvironment
. В чём преимущество? Как раз, в отсутствии необходимости создавать отдельный класс и раздувать ZEnvironment
большим числом "сервисов", которые перевязаны между собой.
(Замечание про контекстную информацию, кажется, опровергается автором заметки, т.к. буквально в следующем разделе автор помещает свои сервисы в ZEnvironment
.)
Конструирование сервиса
Т.к. мы не создавали WordFilter
, то и конструировать ничего не надо. Функция maskChat*
доступна непосредственно.
Тем не менее, нам для работы всё равно необходимо предоставить реальные зависимости - WordFilterConfig
и SttpClient
.
Использование ~~сервиса~~ функции
Т.к. наша функция доступна непосредственно, то мы её и вызываем. При этом нам необходимо предоставить зависимости.
def chat(message: String): RIO[WordFilterConfig & SttpClient, Unit] =
for
filtered <- maskChatLive(message, None)
yield
()
Благодаря использованию Reader-монады ZEnvironment
, наши зависимости протекают сквозь код без необходимости что-то дополнительно указывать. Если у нас будут использоваться в коде другие функции с другими зависимостями, то множества зависимостей будут объединены ( ZIO[A & B, ..., ...]
и ZIO[B & C, ..., ...]
дадут ZIO[A & B & C, ..., ...]
).
Будет ли этот список зависимостей чрезмерно длинным? Возможно. Здесь на помощь придут type-alias'ы - type ABC = A & B & C
. При этом (1) наши функции по-прежнему являются обыкновенными функциями, которые мы можем непосредственно вызвать; (2) список зависимостей "плоский" и легко конструируется из других type-alias'ов (например, type ABC = AB & BC
); (3) мы точно видим исчерпывающий список возможностей (capability), которые необходимы для работы функции.
Запуск приложения
Также как и раньше, мы запускаем приложение, предоставляя зависимости через provide
. Отличие заключается лишь в том, что предоставляются лишь базовые зависимости, перечень которых не очень сильно меняется при разработке приложения.
val run =
startServer
.provide(
HttpClientZioBackend.layer()
)
Заключение
Являются ли "сервисы" необходимым условием для работы с ZIO и для использования ZLayer
'ов? По-видимому, нет. Можно разрабатывать программы без традиционной структуры, пользуясь простыми функциями, и передавая только действительно содержательные зависимости.
В каких случаях всё-таки стоит создавать свой "сервис"? Мне видятся такие случаи, в которых паттерн "сервис" может пригодится:
Способности (capability). Например, способность удалённого вызова внешних Rest API, способность работы с файловой системой.
Разделяемые ресурсы, например, соединения.
Общее состояние, используемое несколькими функциями. Например, кэш. Если именно его поместить в
ZEnvironment
, то не потребуется создавать ещё один класс-обёртку над кэшем, в котором надо будет добавлять все функции, которым он требуется.
Использование ZLayer
'ов вполне возможно и полезно даже без создания лишней структуры программы. Наоборот, избавление от промежуточных классов зачастую открывает новые возможности оптимизации и повторного использования кода.