В заметке "Идиоматическое внедрение зависимостей в 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'ов является антипаттерном, и приводит такие аргументы:

  1. Вы “передаете” свою зависимость остальному коду и делаете его менее читаемым, нарушая принцип разделения ответственности (separation of concerns).

  2. Эти зависимости могут измениться в будущем (например, если мы заменим sttp на что-то другое), и это вызовет множество изменений в разных частях кода.

А также даёт рекомендации:

  1. ZIO ZEnvironment следует использовать только для передачи контекста, который удаляется на каком-то этапе.

  2. Если ваша зависимость всегда имеет одно и то же значение в течение работы приложения, переместите ее в свой класс, реализующий сервис.

Про изменение зависимостей в будущем, следует отметить, что такое изменение сложнее всего будет выполнить в точке, где зависимость используется. А во всех случаях, где зависимость лишь передаётся, проблема решается элементарно - поиск и замена. Так что этот аргумент выглядит несостоятельным.

Что касается передачи "низкоуровневых зависимостей" остальному коду, уменьшения читабельности и нарушения принципа разделения ответственности.

Являются ли довольно случайные объединения ряда функций в интерфейс/класс полноценной абстракцией? Или это просто привычный способ структурирования программы? Глядя на название сервиса "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'ов? По-видимому, нет. Можно разрабатывать программы без традиционной структуры, пользуясь простыми функциями, и передавая только действительно содержательные зависимости.

В каких случаях всё-таки стоит создавать свой "сервис"? Мне видятся такие случаи, в которых паттерн "сервис" может пригодится:

  1. Способности (capability). Например, способность удалённого вызова внешних Rest API, способность работы с файловой системой.

  2. Разделяемые ресурсы, например, соединения.

  3. Общее состояние, используемое несколькими функциями. Например, кэш. Если именно его поместить в ZEnvironment, то не потребуется создавать ещё один класс-обёртку над кэшем, в котором надо будет добавлять все функции, которым он требуется.

Использование ZLayer'ов вполне возможно и полезно даже без создания лишней структуры программы. Наоборот, избавление от промежуточных классов зачастую открывает новые возможности оптимизации и повторного использования кода.

Комментарии (0)