ZIOのZLayerについて

この記事はただの集団 Advent Calendar 2020の23日目の記事です。

adventar.org

本記事では、ZIOのDI機能であるZLayerの使い方を説明します。 対象読者はZIOをある程度知っている方を想定しています。 ZIOについて詳しく知らない方はまず公式ページのドキュメントを読むことをお勧めします。

zio.dev

ZIOとは

ZIOは純粋な関数型プログラミングを促進する非同期・並行プログラミング用ライブラリです。 モナドなどを知らなくても、ZIOを使うことで関数型プログラミングを始めることができます。

ZIOには便利なデータ型が複数存在しますが、中心となるのはeffectを表すデータ型であるZIO[-R, +E, +A]です。 このデータ型はの3つの型パラメータを持ちます。

R: Environment Type => 実行に必要な環境の型, Anyの場合は環境を必要としない
E: Failure Type  => 失敗した時の型, Nothingの場合は失敗しない
A: Success Type => 成功した時の型

E,Aに関しては説明不用だと思うので、今回はこのRについて詳しくみていきます。

環境型Rの使い方

題材としてZIOで書かれた以下のプログラムを考えます。 このコードは、公式ドキュメントのものを参考にしています。 https://zio.dev/docs/howto/howto_use_layers#our-first-zio-module

val user2: User = User(UserId(123), "Tommy")
val makeUser: ZIO[Logging with UserRepo, DBError, Unit] = for {
  _ <- ZIO.accessM[Logging](_.get.info(s"inserting user"))  
  _ <- ZIO.accessM[UserRepo](_.get..createUser(user2))
  _ <- ZIO.accessM[Logging](_.get.info(s"user inserted"))
} yield ()

このプログラムは、最初に処理をはじめることをログに出力し、 つぎに、ユーザをRepositoryに登録します。 最後にログに処理が完了したことを再度出力します。

Logging with UserRepomakeUserを実行するために必要な環境の型です。 ここではLoggingUserRepoは以下のようなメソッドを持つ型だと考えてください。

trait Logging {
  def info(s: String): UIO[Unit]
}

trait UserRepo {
  def createUser(user: User): IO[DBError, Unit]
}

ZIO.accessMを使うことでeffectの環境にアクセスすることができます。 このプログラムmakeUserを実行するためには、 以下のようにLoggingUserRepoの実装を提供する必要があります。

val runnable: ZIO[Any, DBError, Unit] = 
    makeUser.provide(new Logging with UserRepo { // なんらかの実装 }) 

runtime.unsafeRun(runnable)

ZIO.provideによってeffectに環境を提供することで、Rから必要な環境が削除され、環境型がAnyのeffectが生成されます。 こうして作成されたeffectはruntime.unsafeRunで実行できるようになります。 ZIOのバージョンv1.0.0-RC17までは実際にこのようにして、依存性を注入していました。

しかしながら、このアプローチにはいくつかの欠点があります。 まず、環境の一部だけを提供することができません。必要な環境を全て揃えた上でprovideする必要があります。 また、環境の一部を動的に更新することができず、アプリケーションの一部でサービスをカスタマイズしようとするときに苦労します。

これらの問題の解決のために、v1.0.0-RC18ではHasとZLayerという2つの新しいデータ型が導入されました。

ZIO module

HasZLayerの詳細に入る前に、ZIOで環境を定義する際の典型的なパターンを紹介します。

type UserRepo = Has[UserRepo.Service]

object UserRepo {
  // インタフェース
  trait Service {
    def getUser(userId: UserId): IO[DBError, Option[User]]
    def createUser(user: User): IO[DBError, Unit]
  }

  // 実装
  val testRepo: ZLayer[Any, Nothing, UserRepo] = ZLayer.succeed(???)


  // ZIO.accessMを毎回書くのは大変なので、ここでアクセサメソッドを定義しておく
  def getUser(userId: UserId): ZIO[UserRepo, DBError, Option[User]] =
    ZIO.accessM(_.get.getUser(userId))

  def createUser(user: User): ZIO[UserRepo, DBError, Unit] =
    ZIO.accessM(_.get.createUser(user))
}

この書き方のパターンをモジュールパターンと呼びます。 モジュールとは、1つの問題のみを扱う機能のグループです。 モジュールの範囲を限定することで、頭の中であまりにも多くの概念を一緒に扱うことなく、 一度に一つのトピックだけに集中することができます。

何かしらの環境を定義する必要がある場合は、まずこのパターンにのっとってインタフェースを定義します。 このパターンさえ覚えておけば、どのようなものでも同じようにアプリケーションの環境として扱えます。

Has

先ほどのコードでHas[UserRepo.Service]というものがありました。 Has[A] は、型 A のサービスに対するeffectの依存性を表現するための型です。 たとえば、RIO[Has[Console.Service], Unit] はこのeffectがConsole.Serviceを必要とすることを表しています。

Has++演算子を使用して組み合わせることができます。

val repo: Has[UserRepo.Service] = Has(new UserRepo.Service{})
val logger: Has[Logger.Service] = Has(new Logger.Service{})
val mix: Has[UserRepo.Service] with Has[Logger.Service] = repo ++ logger

先ほどは UserRepo with Loggingとmixinで2つの環境を組み合わせていました。 これでも環境の組み合わせはできていたのに、 なぜtype UserRepo = Has[UserRepo.Service]のような型エイリアスを用いてまでHasを使っているのでしょうか?

Hasが優れているところは、以下のように複数のHasを結合したあとでも、 それぞれの環境を分離して取り出すことができる点です。

// get back the logger service from the mixed value:
val log = mix.get[Logger.Service].log("Hello modules!")

これが可能であるということは、型安全に環境の一部を動的に更新することができるということです。

Hasでなぜこのようなことが可能なのかというと、TypeTagを用いているためです。 Hasの定義は以下のようになっています。 https://github.com/zio/zio/blob/master/core/shared/src/main/scala/zio/Has.scala

 final class Has[A] private (
  private val map: Map[LightTypeTag, scala.Any],
  private var cache: Map[LightTypeTag, scala.Any] = Map()
 ) extends Serializable ..

ここでmap: Map[LightTypeTag, scala.Any]でインタフェースと実装のマッピングを保持しています。
他の JVM言語同様に、Scala の型はコンパイル時に消去されますが、 TypeTagを用いることで実行時でも型情報を保持することができます。
Has++で結合するということは、単にmapに新しい項目を追加しているということです。 またmapのキーに同じ型を指定することで実装を後から入れ替えることもできます。

余談ですが、ZIOではscala-reflectのTypeTagの代わりに独自実装のizumi-reflectを使用しているようです。 github.com

ZLayer

さて、データ型Hasを見てきましたが、つぎにZLayerについて説明します。 Zlayerは環境Rを作るためのレシピを表すデータ型です。ZLayer[-RIn, +E, +ROut <: Has[_]]という3つの型パラメータを持ちます。

RIn - 構築するために必要な依存関係 (依存関係がない場合は Any になります)
E - 作成時に発生する可能性があるエラー(失敗する可能性がない場合はNothing)
ROut - 作成される環境の型

つまり、RIn型からRout型を生成する方法を表すデータ型です。 これはJavaScalaアプリケーションのコンストラクタに似ていて、依存するサービスを受け取り、構築したサービスを返します(コンストラクタベースの依存性注入)。 しかし、コンストラクタとは異なり、ZLayers はいくつかの方法で型安全に構成されたファーストクラスの値であり、1つのサービスだけではなく、多くのサービスを構築することができます。 さらに、データベースに接続し、データベースのスレッドプールを作成し、サービスが不要になったら、スレッドプールを解放し、データベースから切断するといった、リソースの管理も行うことができます。

では、ZLayerの作り方を見ていきます。

まず、RIntがAnyかつ失敗しない場合は、ZLayer.succeedを使うことでZLayerを作成できます。

type UserRepo = Has[UserRepo.Service]

object UserRepo {
  trait Service {
    def getUser(userId: UserId): IO[DBError, Option[User]]
    def createUser(user: User): IO[DBError, Unit]
  }

  val inMemory: ZLayer[Any, Nothing, UserRepo] = ZLayer.succeed(
    new Service {
      def getUser(userId: UserId): IO[DBError, Option[User]] = UIO(???)
      def createUser(user: User): IO[DBError, Unit] = UIO(???)
    }
  )

  //accessor methods
  def getUser(userId: UserId): ZIO[UserRepo, DBError, Option[User]] =
    ZIO.accessM(_.get.getUser(userId))

  def createUser(user: User): ZIO[UserRepo, DBError, Unit] =
    ZIO.accessM(_.get.createUser(user))
}

つぎに、構築時に他のコンポーネントが必要な場合を見てみます。

type Logging = Has[Logging.Service]

object Logging {
  trait Service {
    def info(s: String): UIO[Unit]
    def error(s: String): UIO[Unit]
  }

  import zio.console.Console
  val consoleLogger: ZLayer[Console, Nothing, Logging] = ZLayer.fromFunction( console =>
    new Service {
      def info(s: String): UIO[Unit]  = console.get.putStrLn(s"info - $s")
      def error(s: String): UIO[Unit] = console.get.putStrLn(s"error - $s")
    }
  )

  //accessor methods
  def info(s: String): URIO[Logging, Unit] =
    ZIO.accessM(_.get.info(s))

  def error(s: String): URIO[Logging, Unit] =
    ZIO.accessM(_.get.error(s))
}

ZLayerの構築方法は以下のように様々な種類があります。 一つ一つの説明は行いませんが、どのメソッドを使うべきかの判断するには各メソッドの実装を見て型から考えていくのが早いと思います。

ZLayer.succeed  // or ZIO#asService to create a layer from an existing service
ZLayer.succeedMany  // to create a layer from a value that's one or more services
ZLayer.fromFunction   // to create a layer from a function from the requirement to the service
ZLayer.fromEffect       // to lift a ZIO effect to a layer requiring the effect environment
ZLayer.fromAcquireRelease // for a layer based on resource acquisition/release. The idea is the same as ZManaged
ZLayer.fromService    // to build a layer from a service
ZLayer.fromServices // to build a layer from a number of required services
ZLayer.identity          // to express the requirement for a layer
ZIO#toLayer              // or ZManaged#toLayer to construct a layer from an effect

最後に、ZLayerの使い方をみていきます。

ZLayerは++メソッドで水平に合成することができます。

val inMemory: ZLayer[Any, Nothing, UserRepo] = UserRepo.inMemory
val consoleLogger: ZLayer[Console, Nothing, Logging]  = Logging.consoleLogger
val horizontal: ZLayer[Console, Nothing, Logging with UserRepo] = Logging.consoleLogger ++ inMemory

これはHasのところで見た形と似ていると思います。 実際にZLayerの++メソッドは内部でHasのunionメソッド、すなわち++メソッドを呼び出しています。

また、ZLayerを縦に合成することもできます。 つまり、1 つのレイヤーの出力を後続のレイヤーの入力として使用して次のレイヤーを構築することができます。

val horizontal: ZLayer[Console, Nothing, Logging with UserRepo] = Logging.consoleLogger ++ inMemory
val console: ZLayer[Any, Nothing, Console] = Console.live
val fullLayer: ZLayer[Any, Nothing, Logging with UserRepo] = console >>> horizontal
makeUser.provideLayer(fullLayer)

このようにZLayerを使うことで、柔軟に環境の構築を行うことができます。 継承ベースの環境組み合わせから、ZLayerを用いて値ベースで環境を組み合わせになったことで柔軟に環境の構築・変更ができるようになりました。

まとめ

ZIOのデータ型であるZLayerを紹介しました。

参考