ZIO2への移行

こんにちは、岡村です。
私はアドプラットフォーム事業の開発を行っている部署でヴァイスジェネラルマネージャとして、主に部署内のテクノロジーマネジメントをしています。
前回はがくぞ (@gakuzzzz)さんにScalaコードをレビューしていただいた話をご紹介させていただきましたが、今回はZIOの話です!

ZIO2が2022年6月に正式にリリースされて、そろそろ1年が経ちます。

ZIO2では新しいスケジューラの導入によるパフォーマンスの向上だけでなく、使い勝手の面でも多くの改善が実施されました。

2.0.0のリリースから2023/05/01現在の2.0.13までの間でも、Loggingの導入やConfigurationを統一的に扱うためのインターフェースの導入など、さまざまな改善・機能追加が実施されています。

今回の記事ではまだZIO2に移行されていない方へ向けて、ZIO1からZIO2への移行について簡単に紹介したいと思います。

内容としては、

  • ZIO
  • Clockなどのデフォルトサービス
  • ZIOのテスト

に関する変更点、そして自動マイグレーションに関する内容をまとめています。その他の詳細については公式を見ていただければと思います。

これらの内容は公式ドキュメントの一部の要約です。

zio.dev

自動マイグレーション

ZIO2へのアップグレードは、scalafixのルールが用意されているのである程度の範囲を自動で書き換えることができます。

1.あなたのコードが依存しているZIO関連ライブラリが、ZIO2に対応したバージョンをリリースしていることを確認します。
ZIO Ecosystem Toolなどを参考にしてください。
  まだこの時点では、ZIO2に対応したバージョンに更新してはいけません。

2.Scalafix Sbt pluginをインストールします

// project/plugins.sbt
addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "<version>")

3.scalafixのZio2Upgradeルールを実行します。

sbt "scalafixEnable; scalafixAll github:zio/zio/Zio2Upgrade?sha=series/2.x"

4.scalafixの実行後、ZIOの依存関係を更新します。

libraryDependencies += "dev.zio" %% "zio"         % "2.0.13"
libraryDependencies += "dev.zio" %% "zio-test"    % "2.0.13"

上記以外でも、ZIOに依存したライブラリなどは全てZIO2に対応したバージョンに更新します。

5.以上で移行の大半は完了しています。プロジェクトを開き、残ったコンパイルエラーなどを修正してください。
このドキュメントの以下のセクションがその助けになるはずです。

重要な変更点

型エイリアスのコンパニオンオブジェクトの削除

ZIO1では、IO.failUIO.succeedなどZIOの型エイリアスのコンパニオンオブジェクト経由でZIOの値を作成できましたが、混乱や一貫性の欠如を生み出していたため、削除されました。

Has型の削除

ZLayerなどと関連して複数のサービスを結合するのに使われていたHas型は削除されました。

ZIO1では以下のようにZLayerを定義していましたが、

val userRepo: ZLayer[Has[Logging] with Has[Random] with Has[Database], Throwable, Has[UserRepo]] = ???

ZIO2ではよりシンプルになります。

// scala2
val userRepo: ZLayer[Logging with Random with Database, Throwable, UserRepo] = ???

// scala3ではwithの代わりに&が使用できます
val userRepo: ZLayer[Logging & Random & Database, Throwable, UserRepo] = ???

ZIO型についての変更

削除されたメソッド

アロー結合子と呼ばれる関数群は削除されました。
+++, |||, onSecond, onFirst, second, first, onRight, onLeft, andThen, >>>, compose, <<<, identity, swap, join

ZIO2ではこれらの関数ではなく、flatMap, provide, zipなどを使ってプログラムを構築してください。

ZIO2における名前付けの規約

ZIO2では、ZIOの値を作成するためのメソッドや操作の名称がより直感的でシンプルに統一されました。

名称の統一

ZIO1ではZIO.succeedZIO.effectTotalという全く同じ意味を持つメソッドがありましたが、ZIO2ではZIO.succeedに統一されました。
>>=(bind operator)というflatMapと同じことをするメソッドは削除されました。もうHaskellに親しみのないプログラマーが>>=というメソッドを見て驚くことはありません。
ZIO#getZIO#someに統一されました。

ZIO.attempt

ZIO.effect*という副作用を含む処理からZIOの値を作るメソッド群はZIO.attempt*に変更されました。

xxxZIO

xxxMなどと、ZIOの値を返す関数を渡されるメソッドにはM(monad)という接尾辞がついていましたが、xxxZIOに変更されます。
例えば今までifMだったメソッドは、ZIO2ではifZIOとなります。

xxxDiscard

ZIO1では、_ という接尾辞を持つメソッドがありましたが、これはDiscardに変更されました。
この接尾辞が意味するのは「結果を破棄する」つまりUnit型を返すメソッドであることですが、_という名前はHaskellの世界から持ち込まれたものでした。
例えばcollectAll_メソッドはcollectAllDiscardと変更されます。

パラメータの遅延評価

ZIO2では、全ての作用を作成するメソッドのパラメータが名前渡しになっています。また、ライブラリ作者にも同じようにすることを強く推奨しています。

これは、ZIOユーザーのよくある誤りを防ぐための手段です。
以下のZIO1におけるアンチパターンを見てみましょう。

ZIO.bracket({
  val random = scala.util.Random.nextInt()
  ZIO.succeed(random)
})(_ => ZIO.unit)(x => console.putStrLn(x.toString)).repeatN(2)

慣れていないScalaユーザは、このプログラムが3つの異なった乱数を表示してくれることを期待しているかもしれません。
しかし実際に表示されるのは同じ数字です。

1085597917
1085597917
1085597917

これは、副作用を伴う処理をbracketメソッドのacquireに渡しているために発生します。この引数は遅延評価になっていないため、引数が評価された時点でnextIntが呼ばれ、その結果がZIO.succeedによってZIOの値として作成されています。

もしacquireが名前渡しパラメータになっていれば、このような失敗を防ぐことができます。

- def bracket[R, E, A](acquire: ZIO[R, E, A]): ZIO.BracketAcquire[R, E, A]
+ def bracket[R, E, A](acquire: => ZIO[R, E, A]): ZIO.BracketAcquire[R, E, A]

また以下のコード例の様に、ZIO2では bracketメソッドはacquireReleaseWithとリネームされているので注意してください。

ZIO.acquireReleaseWith {
  val random = scala.util.Random.nextInt()
  ZIO.succeed(random)
}(_ => ZIO.unit)(x => Console.printLine(x.toString)).repeatN(2)

上記の出力は、例えば以下の様になります。

355191016
2046799548
333146616

zipによる合成

ZIO2では、zipを使って複数の作用を合成した場合、タプルがネスト「しません」

val x1: UIO[Int]     = ZIO.succeed(???)
val x2: UIO[Unit]    = ZIO.succeed(???)
val x3: UIO[String]  = ZIO.succeed(???)
val x4: UIO[Boolean] = ZIO.succeed(???)

In ZIO 1.x:

// <*> == zip
val zipped: UIO[(((Int, Unit), String), Boolean)] = x1 <*> x2 <*> x3 <*> x4

In ZIO 2.x: タプルがネストしないだけでなく、意味を持たないUnit型も結果から除かれます

val zipped: UIO[(Int, String, Boolean)] = x1 <*> x2 <*> x3 <*> x4

ZIO2ではzipが非常に便利になったため、ZIO1のmapNなどのメソッドは不要となり削除されました。

非同期処理

非同期な作用の並列実行を制御するためのメソッドが追加されました。

  • ZIO#withParallelism
  • ZIO#withParallelismUnbounded

これらのメソッドは、並列処理におけるfiberの最大個数を設定するためのものです。
これらの導入により、collectAllParNなどのNで終わる並列処理メソッドは削除されました。

In ZIO1:

ZIO.foreachParN(8)(urls)(download)

In ZIO2:

ZIO.foreachPar(urls)(download).withParallelism(8)

Either

ZIO1.xでは、 ZIO#left ZIO#rightは元々のEither型が持っていた反対側の情報を失ってしまう処理でした。

例を見てみましょう。ここではZIO[Any, Throwable, Left[Int, String]]の値を持っているとします。

val effect         = Task.effect(Left[Int, String](5))
// effect: ZIO[Any, Throwable, Left[Int, String]]
val leftProjection = effect.left
// leftProjection: ZIO[Any, Option[Throwable], Int]

leftProjectionのエラー型はOption[Throwable]となっていて、元のEitherのRight側の情報「String」は失われてしまいます。
つまり、一度leftを呼び出したら、元の状態に戻すことはできません。

ZIO2では、ZIO#left ZIO#rightは元の情報を保持し、unleft unrightという元の状態に戻すためのメソッドを追加します。

val effect         = ZIO.attempt(Left[Int, String](5))
val leftProjection = effect.left
val unlefted       = leftProjection.map(_ * 2).unleft

このため、left rightを呼んだ後のエラー型はOptionからEitherに変更されました。

final def left[B, C](implicit ev: A IsSubtypeOfOutput Either[B, C], trace: Trace): ZIO[R, Either[E, C], B]

より詳細なエラー

ZIOの型システムでは、implicitパラメータを使って型安全性を確保しています。
ZIO2では、サブタイプの関連性を保証するためのimplicitパラメータとして scala本体が提供する<:<ではなく、独自の型を使ってより詳細なエラーの表示を可能にしています。

  • IsSubtypeOfOutput: O1 IsSubtypeOfOutput O2 結果型O1が、O2のサブタイプであることの確認
  • IsSubtypeOfErrorE1 IsSubtypeOfError E2 エラー型E1が、E2のサブタイプであることの確認

例を見てみましょう。まずZIO1では、<:<を使っているため以下のようなコンパイルエラーとなります。

ZIO.fail("Boom!").orDie
// error: Cannot prove that String <:< Throwable.
// ZIO.fail("Boom!").orDie
// ^^^^^^^^^^^^^^^^^^^^^^^

ZIO.succeed(Set(3,4)).head
// error: Cannot prove that scala.collection.immutable.Set[Int] <:< List[B].
// ZIO.succeed(Set(3, 4)).head
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^

ZIO2では、以下のようなコンパイルエラーが出るようになります。

ZIO.fail("Boom!").orDie
// error: This operator requires that the error type be a subtype of Throwable but the actual type was String.
// ZIO.fail("Boom!").orDie
// ^^^^^^^^^^^^^^^^^^^^^^^

ZIO.succeed(Set(3, 4, 3)).head
// error: This operator requires that the output type be a subtype of List[B] but the actual type was scala.collection.immutable.Set[Int].
// ZIO.succeed(Set(3, 4, 3)).head
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Clockなどのデフォルトサービス

ZIOの環境から、Clockなどのデフォルトサービスが除外されました。

ZIO1.xでは、ClockやConsole、Random、SystemなどもZIOのサービスとして扱われていました。
そのため、これらのサービスを使用するたびにR型は大きくなり、複雑になっていました。

一例を見てみましょう。ZIO1.xで、毎秒ランダムな数値をコンソールに出力するコードを書くと以下のようになります。

import zio._
import zio.clock.Clock
import zio.duration.durationInt
import zio.random.Random
import java.io.IOException
import zio.console._

object MainApp extends App {
  val myApp: ZIO[Clock with Console with Random, IOException, Unit] =
    for {
      rnd <- random.nextIntBounded(100)
      _   <- console.putStrLn(s"Random number: $rnd")
      _   <- clock.sleep(1.second)
    } yield ()
  override def run(args: List[String]): URIO[zio.ZEnv, ExitCode] =
    myApp.forever.exitCode
    // or we can provide our own implementation
    // myApp.forever.provideLayer(Console.live ++ Clock.live ++ Random.live).exitCode
}

これらのサービスは、ZIOのサービスパターンにマッチしていません。なぜならこれらは非常に低レベルで頻繁に使われ、テストでもない限りデフォルト以外の実装が使われる可能性は非常に低いからです。
それがサービスとして組み込まれていることで、ユーザの環境型(R型)を容易に汚染してしまいます。

この問題を解決するため、ZIO2ではこれらのデフォルトサービスは環境型からは排除されました。その代わり、ZIOのRuntimeに組み込むことで変更やテストが可能になっています。
ZIO2では、R型を使ったサービスパターンはより高レベルなサービスのために使うことになります。

上記のコードをZIO2で書き直してみましょう。

import zio._

import java.io.IOException

object MainApp extends App {
  val myApp: ZIO[Any, IOException, Unit] =
    for {
      rnd <- Random.nextIntBounded(100)
      _   <- Console.printLine(s"Random number: $rnd")
      _   <- Clock.sleep(1.second)
    } yield ()

  def run = myApp.forever
}

変更点のまとめ

ZIO1.xからZIO2.xへの移行ではデフォルトサービスに関して以下のような変更点があります。

1.デフォルトサービスにサービスパターンでアクセスする必要はありません。それらはZIOのRuntimeに組み込まれています。
ZIO.service[Console]などとする代わりに、ZIO.consoleZIO.consoleWithを使ってください。

for {
-   random <- ZIO.service[Random]
+   random <- ZIO.random
} yield ()

2.デフォルトサービスが環境型から排除されたことで、ZEnv Console Clock Random Systemなどの環境型は削除されました。

- val myApp: ZIO[Clock with Console with Random with UserRepo with Logging, IOException, Unit] = ???
+ val myApp: ZIO[UserRepo with Logging, IOException, Unit] = ???

3.もしテストの中でこれらのデフォルトサービスのLiveバージョンを使用したい場合、レイヤーの代わりにテストアスペクトを使って指定することができます。

  • withLiveClick
  • withLiveConsole
  • withLiveRandom
  • withLiveSystem
  • withLiveEnvironment

- testM("TestLiveClock") { ... }.provideLayer(Clock.live)
+ test("TestLiveClock") { ... } @@ TestAspect.withLiveClock

4.ZIO1.xでは、デフォルトサービスの独自の実装を使いたい場合、ZIO#provide*を使っていました。
ZIO2.xでは、それぞれ専用に用意されたメソッドを使ってデフォルトサービスを上書きすることができます。

  • ZIO.withConsole/ZIO.withConsoleScoped
  • ZIO.withClock/ZIO.withClockScoped
  • ZIO.withRandom/ZIO.withRandomScoped
  • ZIO.withSystem/ZIO.withSystemScoped

import zio._

object MyClick extends Click {
   ...
}

ZIO.withClock(MyClock)(someEffect)

5.環境型からデフォルトサービスが削除されたため、それらのZLayerも削除されました

  • Console.live, Console.any
  • Clock.live, Clock.javaClock, Clock.any
  • Random.live, Random.scalaRandom, Random.any
  • System.live, System.any

6.デフォルトサービスにはデフォルトの実装とは異なる実装も存在します。ZIO1.xではデフォルトの実装は環境により自動的に提供されていましたが、別の実装を使う際にはレイヤーとして渡す必要がありました。

import zio._
import zio.clock.Clock

object MainApp extends App {
  def run(args: List[String]) =
    clock.localDateTime
      .debug("local date time")
      .provideCustomLayer(
        ZLayer.succeed(
          java.time.Clock.systemDefaultZone()
        ) >>> Clock.javaClock
      )
      .orDie
      .exitCode
}

ZIO2では、呼び出すメソッド自体を変更することで対応します。

import zio._

object MainApp extends ZIOAppDefault {
  def run =
    Clock.ClockJava(java.time.Clock.systemDefaultZone())
      .currentDateTime
      .debug("current date time")
}

同様のアプローチは、ClockだけでなくRandomサービスでも使われます。

ZIO1.x(ZLayer)ZIO2.x
Clock.javaClockClock.ClockJava
Random.scalaRandomRandom.RandomScala

7.ZIO1.xでは、ZEnvが全てのデフォルトサービスのエイリアスとして使われていましたが、削除されました。

ZIO test

ZSpec

ZSpecは、Specにリネームされました。

レイヤーのスペック間での共有

ZIO2.xでは、生成にコストがかかるようなレイヤーを複数のスペックで共有するのが容易になりました。ZIOSpec[SharedType]を使用し、bootstrapフィールドに渡すだけです。

import zio.test._

class SharedService()

object Layers {
  val sharedLayer =
    ZLayer.succeed(new SharedService())
}

object UseSharedLayerA extends ZIOSpec[SharedService]{
  override def spec =
    test("use the shared layer in test A") {
      assertCompletes
    }

  override def bootstrap= Layers.sharedLayer
}

object UseSharedLayerB extends ZIOSpec[SharedService]{
  override def spec =
    test("use the shared layer in test B") {
      assertCompletes
    }

  override def bootstrap = Layers.sharedLayer
}

賢いテストコンストラクタ

ZIO1.xでは、テスト処理の返り値の型に応じたtesttestMという二つのメソッドがありましたが、testに統一されました。

ZIO 1.x:

suite("Ref") {
  testM("updateAndGet") {
    val result = Ref.make(0).flatMap(_.updateAndGet(_ + 1))
    assertM(result)(Assertion.equalTo(1))
  }
}

ZIO 2.x:

suite("Ref") {
  test("updateAndGet") {
    val result = Ref.make(0).flatMap(_.updateAndGet(_ + 1))
    assertZIO(result)(Assertion.equalTo(1))
  }

AssertionAssertionMの統一

ZIO2では、AssertionAssertionMAssertionに統一されました。

ZIO 1.x:

testM("Effectful Assertion ZIO 1.x") {
  def myEffectfulAssertion[Int](reference: Int): AssertionM[Int] = ???

  val sut = ZIO.effect(???)
  assertM(sut)(effectfulAssertion(5))
}

ZIO 2.x:

test("Effectful Assertion ZIO 2.x") {
  def myAssertion[Int](reference: Int): Assertion[Int] = ???

  val res = for {
    sut <- ZIO.effect(???)
    res <- extractedOperations(sut)
  } yield assert(res)(myAssertion(5))
}

スマートアサーション

ZIO2でスマートアサーション assertTrue が導入されました。
このアサーションには、Booleanを返すScalaの式を渡します。

val list   = List(1, 2, 3, 4, 5)
val number = 3
val option = Option.empty[Int]

ZIO 1.x:

suite("ZIO 1.x Test Assertions")(
  test("contains")(assert(list)(Assertion.contains(5))),
  test("forall")(assert(list)(Assertion.forall(Assertion.assertion("even")(actual => actual % 2 == 0)))),
  test("less than")(assert(number)(Assertion.isLessThan(0))),
  test("isSome")(assert(option)(Assertion.equalTo(Some(3))))
)

ZIO 2.x:

suite("ZIO 2.x SmartAssertions")(
  test("contains")(assertTrue(list.contains(5))),
  test("forall")(assertTrue(list.forall(_ % 2 == 0))),
  test("less than")(assertTrue(number < 0)),
  test("isSome")(assertTrue(option.get == 3))
)

スマートアサーションは非常に強力で、アサーションが失敗した際には失敗した箇所をハイライトし、期待と何が違ったのかの差分など、詳細な失敗に関する説明を表示してくれます。

Specの合成

ZIO1.xでは、スペックは直接合成できず、親suiteの子供としてまとめることしかできませんでした。

val fooSuite = suite("Foo")(fooSpec)
val barSuite = suite("Bar")(barSpec)
val bazSuite = suite("Baz")(bazSpec)

val bigSuite = suite("big suite")(fooSuite, barSuite, bazSuite)

ZIO2では、親を用意することなく直接合成することができます。

val bigSuite = fooSuite + barSuite + bazSuite

まとめ

ここまでZIO2のさまざまな変更点をまとめてきました。
ZIO2.0.0以降のリリースで追加されたLoggingやConfigurationについてなど、書きたい内容はまだまだあるのですが次回以降に回したいと思います。

ZIO2もリリースから1年近く経ち、周辺ライブラリなどのエコシステムもしっかり整ってきています。
もし今までZIO2に移行していない方がいましたら、ぜひこの機会にどうぞ!