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

Автор оригинала: https://timpigden.github.io/
  • Перевод
В июле 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.



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


OTUS. Онлайн-образование
Цифровые навыки от ведущих экспертов

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

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Самое читаемое