こんにちは、岡村です。
私はアドプラットフォーム事業の開発を行っている部署でヴァイスジェネラルマネージャとして、主に部署内のテクノロジーマネジメントをしています。
前回はがくぞ (@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のテスト
に関する変更点、そして自動マイグレーションに関する内容をまとめています。その他の詳細については公式を見ていただければと思います。
これらの内容は公式ドキュメントの一部の要約です。
自動マイグレーション
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.fail
やUIO.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.succeed
とZIO.effectTotal
という全く同じ意味を持つメソッドがありましたが、ZIO2ではZIO.succeed
に統一されました。
>>=
(bind operator)というflatMap
と同じことをするメソッドは削除されました。もうHaskellに親しみのないプログラマーが>>=
というメソッドを見て驚くことはありません。
ZIO#get
はZIO#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のサブタイプであることの確認IsSubtypeOfError
— E1 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.console
やZIO.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.javaClock | Clock.ClockJava |
Random.scalaRandom | Random.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では、テスト処理の返り値の型に応じたtest
とtestM
という二つのメソッドがありましたが、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)) }
Assertion
とAssertionM
の統一
ZIO2では、Assertion
とAssertionM
はAssertion
に統一されました。
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に移行していない方がいましたら、ぜひこの機会にどうぞ!