[ZIO] ZLayerを使ったモジュール分割と依存性注入

ZIOのドキュメントを参考にZLayerを使って簡単なプログラム(FizzBuzz)を作ってみました。
2021.04.28

はじめに

ZIO のドキュメント How to Use Modules and Layers? を 参考にZLayerを使って簡単なプログラム(FizzBuzz)を作ってみました。

ZLayerとは

ZLayerの定義は以下の通りです。ZLayerはZIOで構成されるプログラムのレイヤーを表します。レイヤーは何らかのService(RIn)を入力として別のサービス(ROut)を出力します。

レイヤーの生成は副作用をもたらす場合があります(e.g. ファイルディスクリプタのオープンやDBコネクション)。生成中のエラーはEで表されます。また生成されたリソースは安全に破棄される必要があります(ZManagedからレイヤーを生成できる)。

sealed abstract class ZLayer[-RIn, +E, +ROut] {}

ServiceとLayerの例

FizzzBuzzの入力値を生成するサービス FizzBuzzSeedGenとそれを提供するレイヤーの例を示します。

コンポーネントのインタフェースをobject内にServiceとして定義して、パッケージオブジェクトにサービスへの依存性をHas[A]でエイリアス定義するのが推奨されているパターンです。

サービスを提供するレイヤーとして具象クラスを定義しています。このレイヤーでは乱数の生成元としてzio.random.Randomを入力としてFizzBuzzSeedGen.Serivceを出力します。

package examples

import zio.macros.accessible
import zio.random.Random
import zio.{Has, UIO, ZLayer}

package object layer_example {

  final case class FizzBuzzInput(num: Long)

	sealed trait FizzBuzzAnswer

  object FizzBuzzAnswer {
    final case class Just(num: Long) extends FizzBuzzAnswer
    final case class Fizz(num: Long) extends FizzBuzzAnswer
    final case class Buzz(num: Long) extends FizzBuzzAnswer
    final case class FizzBuzz(num: Long) extends FizzBuzzAnswer
  }

  type FizzBuzzSeedGen = Has[FizzBuzzSeedGen.Service]

  @accessible
  object FizzBuzzSeedGen {

    val live: ZLayer[Random, Nothing, FizzBuzzSeedGen] = ZLayer.fromFunction { rng =>
      new Service {
        override def randomOne: UIO[FizzBuzzInput] = rng.get.nextLongBetween(0, 100).map(FizzBuzzInput)
      }
    }

    trait Service {
      def randomOne: UIO[FizzBuzzInput]
    }

  }
}

レイヤーの合成と環境としての使用例

次のこのレイヤーの使用例を示します。#runの中でレイヤーを合成してprintFizzBuzzおよびfizzbuzzが必要とするサービスを提供するレイヤーを生成します。ZIOへの環境の注入は#providerLayerまたは#provideCustomLayerで行えます。providerCustomLayerはzio.ZEnv以外に依存するものがないレイヤーを生成します。

依存性の注入は >>> で行います。以下の例ではrandompartialClientLayer に注入してZLayer[Any, Nothing, FizzBuzzSeedGen] を得ています。ZLayerには他にもいくつかのオペレーターが定義されていて様々なレイヤーの合成が行えます。

package examples.layer_example

import zio.{ExitCode, URIO, ZIO, ZLayer}

object FizzBuzzGame extends zio.App {

  import FizzBuzzAnswer._

  override def run(args: List[String]): URIO[zio.ZEnv, ExitCode] = {
    val partialGen: ZLayer[zio.random.Random, Nothing, FizzBuzzSeedGen] = FizzBuzzSeedGen.live
    val random: ZLayer[Any, Nothing, zio.random.Random] = zio.random.Random.live
    val client: ZLayer[Any, Nothing, FizzBuzzSeedGen] = random >>> partialGen
    for {
      _ <- printFizzBuzz.provideCustomLayer(client)
    } yield ExitCode.success
  }

  def printFizzBuzz: ZIO[FizzBuzzSeedGen with zio.console.Console, Nothing, Unit] = for {
    ans <- fizzbuzz
    _ <- ZIO.accessM[zio.console.Console](_.get.putStrLn(ans.toString))
  } yield ()

  def fizzbuzz: ZIO[FizzBuzzSeedGen, Nothing, FizzBuzzAnswer] = for {
    r <- ZIO.accessM[FizzBuzzSeedGen](_.get.randomOne)
    ans = r.num match {
      case _ if r.num % 3 == 0 && r.num % 5 == 0 => FizzBuzz(r.num)
      case _ if r.num % 3 == 0 => Fizz(r.num)
      case _ if r.num % 5 == 0 => Buzz(r.num)
      case _ => Just(r.num)
    }
  } yield ans
}

具象クラスの差し替え

ここまでみてきたようにあるサービスのインスタンスは依存するサービスを提供するレイヤーの合成によって得られます。レイヤーは任意に生成できるので、ユニットテスト時に依存するサービスをモックに差し替えることもできます。以下の例では乱数ではなくて固定の値を生成するFizzBuzzSeedGenを与えてfizzbuzzメソッドをテストしています。

package examples.layer_example

import munit.FunSuite
import zio.{UIO, ZLayer}

class FizzBuzzGameTest extends FunSuite {

  import FizzBuzzAnswer._

  def fizzbuzz(num: Long): FizzBuzzAnswer =
    zio.Runtime.default.unsafeRun(
      FizzBuzzGame.fizzbuzz.provideLayer(
        ZLayer.succeed(
          new FizzBuzzSeedGen.Service {
            override def randomOne: UIO[FizzBuzzInput] = UIO(FizzBuzzInput(num))
          })
      ))

  test("fizzbuzz") {
    assertEquals(fizzbuzz(1), Just(1))
    assertEquals(fizzbuzz(2), Just(2))
    assertEquals(fizzbuzz(3), Fizz(3))
    assertEquals(fizzbuzz(4), Just(4))
    assertEquals(fizzbuzz(5), Buzz(5))
    assertEquals(fizzbuzz(15), FizzBuzz(15))
  }

}

まとめ

ZLayerを使ったモジュール分割とレイヤーの合成による依存性の注入をしてみました。tagless finalほどコンパイルエラーに悩まされずにエフェクトコンテナとロジックを分離できそう・・・な気がしました。