Применение ZIO ZLayer

—

от автора

В июле OTUS запускает новый курс «Scala-разработчик», в связи с чем мы подготовили для вас перевод полезного материала.


Новая функция ZLayer в ZIO 1.0.0-RC18+ является значительным улучшением старого паттерна модулей, что делает добавление новых сервисов намного быстрее и легче. Однако при использовании на практике я обнаружил, что может потребоваться какое-то время, чтобы освоить эту идиому.

Ниже приведен аннотированный пример финальной версии моего тестового кода, в котором я рассматриваю ряд вариантов использования. Большое спасибо Адаму Фрейзеру за помощь в оптимизации и облагораживании моей работы. Сервисы преднамеренно упрощены, так что, надеюсь, они будут достаточно понятны для быстрого чтения.

Я предполагаю, что у вас есть базовое понимание ZIO тестов, а также вы ознакомились с основной информацией касательно модулей.

Весь код запускается в zio тестах и представляет собой один файл.
Вот его верхушка:

import zio._ import zio.test._ import zio.random.Random import Assertion._  object LayerTests extends DefaultRunnableSpec {    type Names = Has[Names.Service]   type Teams = Has[Teams.Service]   type History = Has[History.Service]    val firstNames = Vector( "Ed", "Jane", "Joe", "Linda", "Sue", "Tim", "Tom") 

Names

Итак, мы добрались до нашего первого сервиса — Names (Имена)

 type Names = Has[Names.Service]    object Names {     trait Service {       def randomName: UIO[String]     }      case class NamesImpl(random: Random.Service) extends Names.Service {       println(s"created namesImpl")       def randomName =          random.nextInt(firstNames.size).map(firstNames(_))     }      val live: ZLayer[Random, Nothing, Names] =       ZLayer.fromService(NamesImpl)   }    package object names {     def randomName = ZIO.accessM[Names](_.get.randomName)   } 

Тут все в рамках типичного модульного паттерна.

  • Объявите Names как псевдоним типа для Has
  • В объекте, определите Service как трейт
  • Создайте реализацию (конечно, вы можете создать несколько),
  • Создайте ZLayer внутри объекта для данной реализации. Конвенция ZIO имеет тенденцию вызывать их в реальном времени.
  • Добавляется объект пакета, который обеспечивает удобное для доступа сокращение.

В live используется ZLayer.fromService, который определяется как:

def fromService[A: Tagged, B: Tagged](f: A => B): ZLayer[Has[A], Nothing, Has[B]

Игнорируя Tagged (это необходимо для работы всего Has/Layers), вы можете видеть, что здесь используется функция f: A => B — которая в данном случае является просто конструктором кейс класса для NamesImpl.

Как вы можете видеть, для работы Names требуется Random из среды zio.

Вот тест:

def namesTest = testM("names test") {     for {       name <- names.randomName     }  yield {       assert(firstNames.contains(name))(equalTo(true))     }   }

Он использует ZIO.accessM для извлечения Names из среды. _.get извлекает сервис.

Мы предоставляем Names для теста следующим образом:

 suite("needs Names")(        namesTest     ).provideCustomLayer(Names.live), 

provideCustomLayer добавляет слой Names в существующую среду.

Teams

Суть Teams (Команды) заключается в тестировании зависимостей между модулями, которые мы создали.

 object Teams {     trait Service {       def pickTeam(size: Int): UIO[Set[String]]     }      case class TeamsImpl(names: Names.Service) extends Service {       def pickTeam(size: Int) =          ZIO.collectAll(0.until(size).map { _ => names.randomName}).map(_.toSet ) // да, я знаю, что команда может иметь < размер!        }      val live: ZLayer[Names, Nothing, Teams] =       ZLayer.fromService(TeamsImpl)    }

Teams выберут команду из доступных names, сделав выбор по размеру.
Следуя паттернам использования модулей, хотя для работы pickTeam нужны Names, мы не помещаем его в ZIO[Names, Nothing, Set[String]] — вместо этого мы держим на него ссылку в TeamsImpl.

Наш первый тест прост.

 def justTeamsTest = testM("small team test") {     for {       team <- teams.pickTeam(1)     }  yield {       assert(team.size)(equalTo(1))     }   }

Чтобы запустить его, нам нужно предоставить ему слой Teams:

 suite("needs just Team")(       justTeamsTest     ).provideCustomLayer(Names.live >>> Teams.live),

Что такое «>>>»?

Это вертикальная композиция. Она указывает, что нам нужен слой Names, которому нужен слой Teams.

Тем не менее, при запуске этого, есть небольшая проблема.

created namesImpl created namesImpl [32m+[0m individually   [32m+[0m needs just Team     [32m+[0m small team test [36mRan 1 test in 225 ms: 1 succeeded, 0 ignored, 0 failed[0m

Возвращаясь к определению NamesImpl

case class NamesImpl(random: Random.Service) extends Names.Service {       println(s"created namesImpl")       def randomName =          random.nextInt(firstNames.size).map(firstNames(_))     }

Таким образом, наш NamesImpl создается дважды. Чем это чревато, если наш сервис содержит какой-нибудь уникальный системный ресурс приложения? На самом деле, оказывается, что проблема вовсе не в механизме Layers — слои запоминаются и не создаются несколько раз в графе зависимостей. На самом деле это артефакт тестовой среды.

Изменим наш набор тестов на:

suite("needs just Team")(       justTeamsTest     ).provideCustomLayerShared(Names.live >>> Teams.live),

Это исправляет проблему, что означает, что слой создается в тесте только один раз

JustTeamsTest требует только teams. Но что, если я хотел получить доступ к Teams и Names?

 def inMyTeam = testM("combines names and teams") {     for {       name <- names.randomName       team <- teams.pickTeam(5)       _ = if (team.contains(name)) println("one of mine")         else println("not mine")     } yield assertCompletes   }

Чтобы это работало, нам нужно предоставить и то, и другое:

 suite("needs Names and Teams")(        inMyTeam     ).provideCustomLayer(Names.live ++ (Names.live >>> Teams.live)),

Здесь мы используем комбинатор ++ для создания слоя Names с Teams. Обратите внимание на приоритет оператора и дополнительные скобки

(Names.live >>> Teams.live)

Вначале, я сам на это попался — в противном случае компилятор будет делать не правильно.

History

History (История) немного сложнее.

object History {          trait Service {       def wonLastYear(team: Set[String]): Boolean     }      case class HistoryImpl(lastYearsWinners: Set[String]) extends Service {       def wonLastYear(team: Set[String]) = lastYearsWinners == team     }          val live: ZLayer[Teams, Nothing, History] = ZLayer.fromServiceM { teams =>        teams.pickTeam(5).map(nt => HistoryImpl(nt))     }        }

Конструктор HistoryImpl требует множество Names. Но единственный способ получить такое — извлечь его из Teams. И для этого требуется ZIO — поэтому мы используем ZLayer.fromServiceM, чтобы он дал нам то, что нам нужно.
Тест проводится по той же схеме, что и раньше:

 def wonLastYear = testM("won last year") {     for {       team <- teams.pickTeams(5)       ly <- history.wonLastYear(team)     } yield assertCompletes   }      suite("needs History and Teams")(       wonLastYear     ).provideCustomLayerShared((Names.live >>> Teams.live) ++ (Names.live >>> Teams.live >>> History.live))

И все.

Throwable ошибки

В приведенном выше коде предполагается, что вы возвращаете ZLayer[R, Nothing, T] — другими словами, конструкция службы среды имеет тип Nothing. Но если он выполняет что-то вроде чтения из файла или базы данных, то, скорее всего, это будет ZLayer[R, Throwable, T] — потому что такого рода вещи часто включают именно тот внешний фактор, который вызывает исключение. Так что представьте себе, что в конструкции Names произошла ошибка. Для ваших тестов есть способ обойти это:

val live: ZLayer[Random, Throwable, Names] = ???

затем в конце теста

.provideCustomLayer(Names.live).mapError(TestFailure.test)

mapError превращает объект throwable в сбой теста — это то, что вам нужно — он может сказать, что тестовый файл не существует или что-то вроде этого.

Больше ZEnv кейсов

В «стандартные» элементы среды входят Clock (часы) и Random. В наших Names мы уже использовали Random. Но что, если мы также хотим, чтобы один из этих элементов еще больше «понизил» наши зависимости? Для этого я создал вторую версию History — History2 — и здесь для создания экземпляра нужен Clock.

 object History2 {          trait Service {       def wonLastYear(team: Set[String]): Boolean     }      case class History2Impl(lastYearsWinners: Set[String], lastYear: Long) extends Service {       def wonLastYear(team: Set[String]) = lastYearsWinners == team     }          val live: ZLayer[Clock with Teams, Nothing, History2] = ZLayer.fromEffect {        for {         someTime <- ZIO.accessM[Clock](_.get.nanoTime)                 team <- teams.pickTeam(5)       } yield History2Impl(team, someTime)     }        } 

Это не очень полезный пример, но важной частью является то, что строка

 someTime <- ZIO.accessM[Clock](_.get.nanoTime)

заставляет нас предоставлять часы в нужном месте.

Теперь .provideCustomLayer может добавить наш слой в стек слоев, и он волшебным образом выталкивает Random в Names. Но этого не будет происходить для часов, которые требуются ниже, в History2. Поэтому следующий код НЕ компилируется:

def wonLastYear2 = testM("won last year") {     for {       team <- teams.pickTeam(5)       _ <- history2.wonLastYear(team)     } yield assertCompletes   }  // ...     suite("needs History2 and Teams")(       wonLastYear2     ).provideCustomLayerShared((Names.live >>> Teams.live) ++ (Names.live >>> Teams.live >>> History2.live)),

Вместо этого вам нужно предоставить History2.live часы в явном виде, что делается следующим образом:

 suite("needs History2 and Teams")(       wonLastYear2     ).provideCustomLayerShared((Names.live >>> Teams.live) ++ (((Names.live >>> Teams.live) ++ Clock.any) >>> History2.live))

Clock.any — это функция, которая получает любые часы, доступные сверху. В этом случае это будут тестовые часы, потому что мы не пытались использовать Clock.live.

Исходный код

Полный исходный код (за исключением throwable) приведен ниже:

import zio._ import zio.test._ import zio.random.Random import Assertion._  import zio._ import zio.test._ import zio.random.Random import zio.clock.Clock import Assertion._  object LayerTests extends DefaultRunnableSpec {    type Names = Has[Names.Service]   type Teams = Has[Teams.Service]   type History = Has[History.Service]   type History2 = Has[History2.Service]    val firstNames = Vector( "Ed", "Jane", "Joe", "Linda", "Sue", "Tim", "Tom")    object Names {     trait Service {       def randomName: UIO[String]     }      case class NamesImpl(random: Random.Service) extends Names.Service {       println(s"created namesImpl")       def randomName =          random.nextInt(firstNames.size).map(firstNames(_))     }      val live: ZLayer[Random, Nothing, Names] =       ZLayer.fromService(NamesImpl)   }      object Teams {     trait Service {       def pickTeam(size: Int): UIO[Set[String]]     }      case class TeamsImpl(names: Names.Service) extends Service {       def pickTeam(size: Int) =          ZIO.collectAll(0.until(size).map { _ => names.randomName}).map(_.toSet )  // да, я знаю, что команда может иметь < размер!        }      val live: ZLayer[Names, Nothing, Teams] =       ZLayer.fromService(TeamsImpl)    }     object History {          trait Service {       def wonLastYear(team: Set[String]): Boolean     }      case class HistoryImpl(lastYearsWinners: Set[String]) extends Service {       def wonLastYear(team: Set[String]) = lastYearsWinners == team     }          val live: ZLayer[Teams, Nothing, History] = ZLayer.fromServiceM { teams =>        teams.pickTeam(5).map(nt => HistoryImpl(nt))     }        }      object History2 {          trait Service {       def wonLastYear(team: Set[String]): Boolean     }      case class History2Impl(lastYearsWinners: Set[String], lastYear: Long) extends Service {       def wonLastYear(team: Set[String]) = lastYearsWinners == team     }          val live: ZLayer[Clock with Teams, Nothing, History2] = ZLayer.fromEffect {        for {         someTime <- ZIO.accessM[Clock](_.get.nanoTime)                 team <- teams.pickTeam(5)       } yield History2Impl(team, someTime)     }        }       def namesTest = testM("names test") {     for {       name <- names.randomName     }  yield {       assert(firstNames.contains(name))(equalTo(true))     }   }    def justTeamsTest = testM("small team test") {     for {       team <- teams.pickTeam(1)     }  yield {       assert(team.size)(equalTo(1))     }   }      def inMyTeam = testM("combines names and teams") {     for {       name <- names.randomName       team <- teams.pickTeam(5)       _ = if (team.contains(name)) println("one of mine")         else println("not mine")     } yield assertCompletes   }         def wonLastYear = testM("won last year") {     for {       team <- teams.pickTeam(5)       _ <- history.wonLastYear(team)     } yield assertCompletes   }      def wonLastYear2 = testM("won last year") {     for {       team <- teams.pickTeam(5)       _ <- history2.wonLastYear(team)     } yield assertCompletes   }     val individually = suite("individually")(     suite("needs Names")(        namesTest     ).provideCustomLayer(Names.live),     suite("needs just Team")(       justTeamsTest     ).provideCustomLayer(Names.live >>> Teams.live),      suite("needs Names and Teams")(        inMyTeam     ).provideCustomLayer(Names.live ++ (Names.live >>> Teams.live)),     suite("needs History and Teams")(       wonLastYear     ).provideCustomLayerShared((Names.live >>> Teams.live) ++ (Names.live >>> Teams.live >>> History.live)),     suite("needs History2 and Teams")(       wonLastYear2     ).provideCustomLayerShared((Names.live >>> Teams.live) ++ (((Names.live >>> Teams.live) ++ Clock.any) >>> History2.live))   )      val altogether = suite("all together")(       suite("needs Names")(        namesTest     ),     suite("needs just Team")(       justTeamsTest     ),      suite("needs Names and Teams")(        inMyTeam     ),     suite("needs History and Teams")(       wonLastYear     ),   ).provideCustomLayerShared(Names.live ++ (Names.live >>> Teams.live) ++ (Names.live >>> Teams.live >>> History.live))    override def spec = (     individually   ) }  import LayerTests._  package object names {   def randomName = ZIO.accessM[Names](_.get.randomName) }  package object teams {   def pickTeam(nPicks: Int) = ZIO.accessM[Teams](_.get.pickTeam(nPicks)) }    package object history {   def wonLastYear(team: Set[String]) = ZIO.access[History](_.get.wonLastYear(team)) }  package object history2 {   def wonLastYear(team: Set[String]) = ZIO.access[History2](_.get.wonLastYear(team)) } 

Если у вас есть более сложные вопросы, обращайтесь в Discord #zio-users или посетите сайт и документацию zio.


Узнать о курсе подробнее.


ссылка на оригинал статьи https://habr.com/ru/company/otus/blog/509438/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *