[Перевод] Идиоматическое внедрение зависимостей в ZIO 2

Перевод заметки Пьера Рикадата о механизме ZLayer в ZIO2 («Idiomatic dependency injection for ZIO applications in Scala», Pierre Ricadat).

Я (автор оригинальной заметки) часто слышу в Интернете в Scala-обсуждениях, что ZLayer «слишком сложный» или «лишний». Эти совершенно противоречит моему опыту: я считаю, что ZLayer — невероятно крутая технология! В предыдущих версиях ZIO действительно были проблемы (те, кто помнит тип данных Has[_], знают, о чем я говорю!), но с тех пор всё поменялось. В этой статье я покажу идиоматическое использование ZLayer для DI (dependency injection, внедрение зависимостей) и надеюсь продемонстрировать, как это позволяет делать сложные вещи очень простым способом.

Примечание: Я много лет работал с приложениями ZIO (ещё даже до выхода версии 1.0!), и в настоящее время я работаю над серверной частью большой многопользовательской онлайн-игры, полностью написанной на Scala и использующей ZIO. Примеры в этой статье основаны на этой кодовой базе.

Типичное объявление сервиса

Рассмотрим простой сервис. Игра, над которой я работаю, позволяет пользователям общаться между собой. Чтобы они не использовали обсценную лексику, каждое сообщение в чате отправляется во внешнюю службу (называемую WordFilter), которая осуществляет цензуру.

Реализация сервиса начинается с объявления интерфейса. Программирование на основе интерфейсов — это хорошая практика, которая имеет много преимуществ. Разделение задач упрощает понимание и поддержку вашего кода: вы не смешиваете свою бизнес-логику с несвязанными задачами, такими как доступ к базе данных или вызовы API. Изменение серверной службы или поставщика повлияет только на данную реализацию интерфейса, но не на весь остальной код. Еще одним очевидным преимуществом является возможность тестирования, поскольку вы можете использовать другую реализацию интерфейса в тестах.

trait WordFilter {
  def maskChat(msg: String, language: Option[Language]): Task[String]
}

В нашем интерфейсе WordFilter есть единственный метод maskChat, который принимает сообщение и необязательный язык и возвращает новое сообщение. Тип возвращаемого значения Task подразумевает, что метод может иметь побочные эффекты или завершаться ошибкой в виде исключения.

Теперь давайте реализуем этот интерфейс. Мы хотим вызвать нашу внешнюю службу, которая представляет собой простой REST API. Нам нужны две вещи: URL-адрес этой службы и HTTP-клиент. Для этого мы собираемся использовать библиотеку sttp. У нашей службы есть две «зависимости», которые мы можем определить следующим образом:

type SttpClient = sttp.client3.SttpBackend[Task, Any]

case class WordFilterConfig(url: String)

Идиоматический способ создания нашей сервисной реализации заключается в создании класса, реализующего интерфейс, со всеми нашими зависимостями в качестве параметров.

class WordFilterLive(config: WordFilterConfig, sttp: SttpClient)
    extends WordFilter {
  def maskChat(msg: String, language: Option[Language]): Task[String] =
    ??? // the implementation itself is not important here
}

Важно отметить, что использование ZIO ZEnvironment для зависимостей в возвращаемом значении:

  def maskChat(msg: String, language: Option[Language]): RIO[WordFilterConfig & SttpClient, String] =
    ??? // the implementation itself is not important here

является антипаттерном, и такого использования следует избегать. Делая это, вы «передаете» свою зависимость остальному коду и делаете его менее читаемым, нарушая принцип разделения ответственности. Более того, эти зависимости могут измениться в будущем (например, если мы заменим sttp на что-то другое), и это вызовет множество изменений в разных частях кода. ZIO ZEnvironment следует использовать только для передачи контекста, который удаляется на каком-то этапе (пользовательский контекст, контекст транзакции и т.д.). Если ваша зависимость всегда имеет одно и то же значение в течение работы приложения, переместите ее в свой класс, реализующий сервис.

При тестировании мы не хотим затрагивать наш реальный сервис; мы просто хотим вернуть исходное сообщение. Для этого мы можем создать другую реализацию сервиса, которая не требует какой-либо зависимости.

class WordFilterTest extends WordFilter {
  def maskChat(msg: String, language: Option[Language]): Task[String] =
    ZIO.succeed(msg)
}

Если бы мы следовали антипаттерну, приведённому выше, нам бы пришлось предоставить конфигурационное значение и sttp-клиента даже для тестовой реализации. Зависимости привязаны к реализации, а не к интерфейсу.

До сих пор мы пользовались обычными правилами Scala. Зачем же нам тогда ZLayer? Он оказывается полезным при конструировании сервисов и связывании их между собой.

Конструирование сервиса

Для создания нашего WordFilterLive, нам потребуется sttp backend и конфигурационное значение. Вначале посмотрим на sttp backend. Мы могли бы создать новый экземпляр в процессе создания WordFilterLive; однако, у нас могуть быть другие сервисы, которым он потребуется, и рекомендуется иметь единственный экземпляр бэкенда на всё приложение. Поэтому мы получим его, используяZIO.service. Эту операцию мы выполним внутри ZLayer, так что она будет выполнена ровно 1 раз при создании нашего сервиса.

Что касается WordFilterConfig, то здесь нам вообще не требуется использовать ZEnvironment: мы можем просто использовать ZIO.config, который получит значение через имплисит given Config[WordFilterConfig] в области видимости. Тайпкласс Config описывает, какие ключи из конфига надо прочитать, и как их распарсить. Можно определить вручную, но также есть автоматическая деривация через deriveConfig. В нашем случае мы будем использовать переменные окружения, поэтому воспользуемся аннотацией @name и автоматической деривацией:

import zio.config.derivation.name
import zio.config.magnolia.deriveConfig

case class WordFilterConfig(
  @name("WORDFILTER_URL") url: String
)

object WordFilterConfig {
  given Config[WordFilterConfig] = deriveConfig[WordFilterConfig]
}

Вышеприведённый фрагмент кода означает, что при поиске Config[WordFilterConfig], приложение прочитает переменную WORDFILTER_URL в из конфигурации, распарсит её в строку и создаст экземпляр WordFilterConfig.

Итого, вот объявление нашего «слоя» ZLayer:

val live: ZLayer[SttpClient, Nothing, WordFilter] =
  ZLayer {
    for {
      config <- ZIO.config[WordFilterConfig]
      sttp   <- ZIO.service[SttpClient] // requires a SttpClient
    } yield WordFilterLive(config, sttp)
  }

Внутренний for возвращает ZIO[SttpClient, Nothing, WordFilter], но мы заворачиваем его в ZLayer. Ниже посмотрим, с чем это связано.

А что, если нашему сервису требуется дюжина других сервисов и конфигурационных настроек? Не будет ли чрезмерно утомительно писать такой код? Будет, но к счастью, нам не требуется такой код писать. Мы можем использовать ZLayer.derive как показано ниже (включая аналогичный код для тестовой реализации):

val live: ZLayer[SttpClient, Nothing, WordFilter] =
  ZLayer.derive[WordFilterLive]

val test: ZLayer[Any, Nothing, WordFilter] =
  ZLayer.derive[WordFilterTest]

Этот код сделает то же самое, что и код выше. Он использует макрос, который составит список значений, необходимых для создания WordFilterLive. Затем для каждого типа проверяется, есть ли для него заданная конфигурация в области видимости. Если конфигурация найдена, макрос вызовет ZIO.config, чтобы получить это значение; в противном случае макрос вызовет ZIO.service, и в результате в зависимостях ZLayer появится этот сервис. В конце концов, макрос сгенерирует код, аналогичный созданному вручную.

А что, если для построения нашего сервиса потребуются дополнительные значения, такие как Queue или Hub? ZLayer.derive может справиться и с этим (он будет использовать unbounded конструкторы). Что, если бы нам понадобился тип, который не обрабатывается «из коробки», например, RateLimiter или Cache? Для этого вы можете определить данный экземпляр ZLayer.Derive.Default, которое описывает, как создать значение вашего типа, и оно будет использоваться макросом ZLayer.derive.

case class MyService(
  config: MyConfig,
  anotherService: AnotherService,
  rateLimiter: RateLimiter
)

given ZLayer.Derive.Default.WithContext[Any, Nothing, RateLimiter] =
  ZLayer.Derive.Default.fromZIO(RateLimiter.make)

val live: ZLayer[AnotherService, Nothing, MyService] =
  ZLayer.derive[MyService] // will run RateLimiter.make

Конечно, можно написать код создания слоя вручную, особенно если требуется выполнять какие-то преобразования. Но в большинстве случаев derive справляется.

Использование сервиса

Мы описали ZLayer для нашего сервиса WordFilter. Как его использовать? Чтобы разобраться, давайте добавим в наше приложение два других сервиса: во-первых, ChatService, который предоставляет API чата и в своей реализации будет зависеть от WordFilter. Во-вторых, GrpcServer, который делает доступными все наши сервисы на заданном порту. У него будет метод start, который запустит сервер и будет работать вечно, пока приложение не будет прервано (поэтому тип возвращаемого значения Nothing).

class ChatService(wordFilter: WordFilter) {
  def chat(message: String): Task[Unit] = ???
}

object ChatService {
  val live: ZLayer[WordFilter, Nothing, ChatService] =
    ZLayer.derive[ChatService]
}

class GrpcServer(chatService: ChatService) {
  def start: Task[Nothing] = ???
}

object GrpcServer {
  val live: ZLayer[ChatService, Nothing, GrpcServer] =
    ZLayer.derive[GrpcServer]
}

Пока ничего особого, мы повторно использовали тот же шаблон, что и раньше (здесь мы не использовали trait'ы для упрощения; обычно trait'ы используются для всех сервисов, кроме самого верхнего уровня).

Теперь давайте рассмотрим нашу основную функцию (функцию run в ZIOApp). Главное, что мы хотим сделать, это запустить GrpcServer. Мы будем использовать ZIO.serviceWithZIO для получения GrpcServer из окружения ZIO и вызова функции start.

object App extends ZIOAppDefault {
  val run = ZIO.serviceWithZIO[GrpcServer](_.start)
}

К сожалению, этот код не скомпилируется:

Ошибка отсутствия ZLayer'а

Ошибка отсутствия ZLayer’а 

Эта ошибка вызвана тем, что мы не описали, как конструировать GrpcServer. В ZIO это делается с помощью метода .provide, которому передаётся необходимый ZLayer:

val run = 
  ZIO
    .serviceWithZIO[GrpcServer](_.start)
    .provide(GrpcServer.live)

Ошибка изменилась:

Ошибка отсутствия определения ChatService

Ошибка отсутствия определения ChatService

Ошибка достаточно понятная: мы предоставили слой для конструирования GrpcServer, но у него есть зависимость от ChatService, так что надо его тоже предоставить. И т.д., до тех пор, пока мы не предоставим все необходимые зависимости:

  val run =
    ZIO
      .serviceWithZIO[GrpcServer](_.start)
      .provide(
        GrpcServer.live,
        ChatService.live,
        WordFilter.live,
        HttpClientZioBackend.layer() // creates a SttpClient
      )

Компилируется! Теперь мы сможем запустить наше приложение.

Предположим, что мы хотим протестировать приложение с тестовой версией WordFilter API, для чего заменим WordFilter.live на WordFilter.test. Программа скомпилируется, но будет выведено предупреждение:

Предупреждение о том, что можно убрать лишний слой

Предупреждение о том, что можно убрать лишний слой

Т.к. мы больше не используем рабочую реализацию API, нам больше не нужен настоящий HTTP клиент.

Что будет, если мы запровайдим и live и test?

Ошибка, когда один сервис провайдится дважды

Ошибка, когда один сервис провайдится дважды

Опять таки, сообщение об ошибке очень понятное.

Прелесть ZLayer'а в том, что нам не надо собирать кусочки вручную. Макрос provide глядя на типы и слои сконструирует дерево зависимостей на этапе компиляции. Любая аномалия приведёт к ошибке компиляции: отсутствующая зависимость, лишняя зависимость, цикл… Сообщения об ошибках сделаны очень качественно, как можно было видеть в примерах, и точно описывают, что именно не так.

Если программа скомпилировалась, то в рантайме не будет никаких сюрпризов. Дерево будет собрано поэтапно, причём независимые сервисы могут быть собраны параллельно. Любой сервис, который используется в нескольких местах, будет создан ровно один раз (либо надо пометить слой с помощью метода fresh, чтобы всякий раз создавался новый экземпляр).

Заключение

Как можно видеть, ничего сверхсложного в ZLayer'ах нет, особенно, если уже есть какой-то опыт использования ZIO. Используется один паттерн: объявляйте сервисы как классы и подключайте используемые сервисы через конструктор. После этого можно использовать ZLayer.derive, чтобы создать соответствующие ZLayer'ы (в большинстве случаев — одна строка). В конце, все слои надо записать в длинный плоский список и компилятор соберёт и проверит дерево зависимостей. И всё!

Я применял и применяю этот паттерн во множестве приложений, некоторые из которых содержат сотни сервисов и всё работает прекрасно.

Полностью код для этого примера имеется в Gist-е и его можно запустить с помощью такой команды: export WORDFILTER_URL="???" && scala run ..

© Habrahabr.ru