1. 概要

ZIO is a zero-dependency library for asynchronous and concurrent programming in Scala. It’s a functional effect system in Scala.

Scalaコミュニティの関数型プログラミングには、ZIO、Cats Effect、Monixなどの関数型エフェクトシステムがいくつかあります。 In this tutorial, we’ll look at ZIO and its competitive features in the world of functional programming in Scala.

2. 機能的効果とは何ですか?

機能的な効果は、計算をファーストクラスの値に変換することです。 すべての効果は操作自体と考えることができますが、機能効果はその操作の説明です

たとえば、Scalaでは、コード println( “Hello、Scala!”)は、 “Hello、Scala!”メッセージをコンソールに出力するエフェクトです。 println関数のタイプはAny=>Unitです。 Unit を返すので、ステートメントです。

ただし、ZIOでは、 Console.printLine( “Hello、ZIO!”)は、コンソールに “Hello、ZIO!”を印刷する機能的な効果です。 そのような操作の説明です。  Console.printLine は、タイプ Any => ZIO [Any、IOException、Unit]の関数です。 ZIO データ型を返します。これは、コンソールへのメッセージの出力の説明です。

つまり、一言で言えば:

  • an effect is about doing something, such as println(“Hello, Scala!”)
  • a functional effect is about the description of doing something, as in zio.Console.printLine(“Hello, ZIO!”)

3. インストール

We need to add two lines to our build.sbt file to use this library:

libraryDependencies += "dev.zio" %% "zio" % "2.0.2"
libraryDependencies += "dev.zio" %% "zio-streams" % "2.0.2"

4. ZIOデータ型

ZIO [R、E、A] は、ZIOライブラリのコアデータ型です。 ZIO データ型は、RからEither[E、A]までの関数のような概念モデルと考えることができます。

R => Either[E, A]

Rを必要とするこの関数は、失敗を表すE、または成功を表すAのいずれかを生成する可能性があります。 ZIOエフェクトは、非同期エフェクトや同時エフェクトなどの複雑なエフェクトをモデル化しているため、実際には何もしていません。

Let’s write a “hello world” application using ZIO:

import zio._
import java.io.IOException

object Main extends ZIOAppDefault {
  val myApp: ZIO[Any, IOException, Unit] =
    Console.printLine("Hello, World!")

  def run = myApp
}

ZIOアプリケーションを開発するときは、 ZIO の値を組み合わせて、アプリケーションロジック全体を作成します。 ZIOランタイムを使用して実行する必要がある単一のZIO値があります。 run メソッドは、アプリケーションのエントリポイントです。 ZIOランタイムは、その関数を呼び出してアプリケーションを実行します

ZIO [R、E、A] データ型は、効果に関する3つの異なるものをエンコードします。

  • R —効果の環境/依存性
  • E —エフェクトがスローする可能性のあるエラーのタイプ
  • A —エフェクトのリターンタイプ

In the above example, the type of myApp is ZIO[Any, IOException, Unit]. The environment of this effect is Any. This means that the ZIO Runtime doesn’t need any particular layer to run the effect. This is because the Console is already part of the runtime, so we don’t need to provide it.The E type parameter is IOException, and the A parameter is Unit. これは、このエフェクトを実行すると Unit 値が返されるか、IOExceptionがスローされる可能性があることを意味します。

5. ZIO値の作成

ZIOは、エフェクトを作成および構築するためのさまざまなコンビネーターを提供します。 たとえば、 flatMapを使用すると、エフェクトを順番に作成し、あるエフェクトの出力を別のエフェクトにフィードできます。

import zio._
import zio.Console

for {
  _ <- Console.printLine("Hello! What is your name?")
  n <- Console.readLine
  _ <- Console.printLine("Hello, " + n + ", good to meet you!")
} yield ()

この例では、3つのエフェクトを一緒に構成しました。 まず、ユーザーに名前を挿入するようにメッセージを出力し、次にコンソールからそれを読み取り、ユーザーに別のメッセージを出力する別のエフェクトをフィードします。

zip は、エフェクトを作成するためのもう1つのコンビネーターです。 エフェクトをzip一緒に作成し、その結果からタプルを作成できます。

for {
  _ <- Console.printLine("Enter a new user name")
  (uuid, username) <- Random.nextUUID zip Console.readLine
} yield () 

6. リソースの安全性

ZIO のもう1つの興味深い機能は、リソースに安全な優れた構造 ZIO.acquireReleaseWith を備えていることです。これにより、誤ってリソースをリークするアプリケーションを作成できなくなります。

ZIO.acquireReleaseWith(acquireEffect)(releaseEffect) {
  usageEffect
}

たとえば、ファイルから読み取るリソースセーフな方法を見てみましょう。

ZIO.acquireReleaseWith(
  ZIO.attemptBlocking(Source.fromFile("file.txt"))
)(file => ZIO.attempt(file.close()).orDie) { file =>
  ZIO.attemptBlocking(file.getLines().mkString("\n"))
}

The way resource management is done in ZIO is through Scope data type. The data type represents the lifetime of one or more resources. For example, if we use acquireReleaseWith, Scope will be added to R environment of the ZIO Effect. This means that given effect requires Scope to be executed and all resources acquired will be released once Scope is closed.

We can provide Scope by using ZIO.scoped as shown below:

ZIO.scoped {
  ZIO.acquireReleaseWith(
    ZIO.attemptBlocking(Source.fromFile("file.txt"))
  )(file => ZIO.attempt(file.close()).orDie) { file => ZIO.attemptBlocking(file.getLines().mkString("\n")) }
}

Additionally, ZioApp provides default Scope, representing lifetime of the whole application, so if we won’t provide any Scope the default will be used and the resources will be released when application is closed.

7. ZIOモジュールと依存性注入

ZLayer is the main contextual data type in ZIO. The ZLayer data type is used to construct a service from its dependencies. So, the ZLayer[Logging & Database, Throwable, UserService] is the recipe of building UserService from Logging and Database services. ZLayer は、LoggingおよびDatabaseサービスをUserServiceにマップする関数と考えることができます。

7.1. ライティングサービス

ZIOは、モジュールパターン2.0を使用してZIOサービスを作成することをお勧めします。 Module Pattern 2.0では、特性を使ってサービスを定義し、Scalaクラスを使用してそれを実装できます。 また、クラスコンストラクターを使用してサービスの依存関係を定義できます。

詳細に立ち入ることなく、ZIOでサービスを定義する方法を見てみましょう。

// Service Definition
trait Logging {
  def log(line: String): UIO[Unit]
}

// Companion object containing accessor methods
object Logging {
  def log(line: String): URIO[Logging, Unit] =
    ZIO.serviceWith[Logging](_.log(line))
}

// Live implementation of Logging service
class LoggingLive extends Logging {
  override def log(line: String): UIO[Unit] =
    for {
      current <- Clock.currentDateTime
      _ <- Console.printLine(s"$current--$line").orDie
    } yield ()
}

// Companion object of LoggingLive containing the service implementation into the ZLayer
object LoggingLive {
  val layer: URLayer[Any, Logging] =
    ZLayer.succeed(new LoggingLive)
}

In this way, we can write the whole application with interfaces, and at the end of the day, we provide layers containing all the implementations.

8. 繊維

ZIOの同時実行モデルは、ファイバーに基づいています。 ファイバーは軽量のユーザースペーススレッドと考えることができます。 プリエンプティブスケジューリングは使用しません。 むしろ、協調マルチタスクを使用します。

ZIOファイバーの重要な機能のいくつかを見てみましょう。

  • Asynchronous— Fibers aren’t blocking like JVM threads, they are always asynchronous.
  • Lightweight— Fibers don’t use preemptive scheduling, and they yield their execution to each other using cooperative multitasking. したがって、JVMスレッドよりもはるかに軽量です。
  • Scalable — Unlike JVM threads, the number of fibers isn’t limited to the number of kernel threads. 十分なメモリがある限り、必要な数のファイバーを使用できます。
  • Resource Safe — ZIO fibers have a structured concurrency model, so they don’t leak resources. 親ファイバーが中断されるか、そのジョブが終了すると、すべての子ファイバーが中断されます。 The child fibers are scoped to their parents.

2つの異なるファイバーで2つの長時間実行ジョブを実行してから、それらを結合してみましょう。

for {
  fiber1 <- longRunningJob.fork
  fiber2 <- anotherLongRunningJob.fork
  _ <- Console.printLine("Execution of two job started")
  result <- (fiber1 <*> fiber2).join
} yield result

9. 並行性プリミティブ

ZIOには、他の同時実行データ構造の基礎となる2つの基本的な同時実行データ構造RefPromiseがあります。

  1. Ref ステートフルアプリケーションの作成に役立つ機能的な可変リファレンス。 2つの基本的な操作は、setgetです。 Ref は、可変参照を更新するアトミックな方法を提供します。
  2. Promise 複数のファイバーの同期に役立つ同期データ型Promiseは1回だけ設定できるプレースホルダーと考えることができます。 たとえば、何かが起こるのを待ちたいときは、Promiseを使用できます。 約束を待つことにより、約束が満たされるまでファイバーはブロックされます。

Refsがコンカレント環境でカウンターを作成するのにどのように役立つかを見てみましょう。

for {
  counter <- Ref.make(0)
  _ <- ZIO.foreachPar((1 to 100).toList) { _ =>
    counter.updateAndGet(_ + 1)
      .flatMap(reqNumber => Console.printLine(s"request number: $reqNumber"))
  }
  reqCounts <- counter.get
  _ <- Console.printLine(s"total requests performed: $reqCounts")
} yield ()

Queue Hub Semaphore などの他の同時実行データ構造は、RefおよびPromise[の上に構築されます。 X145X]。

10. 並列演算子

ZIOには、次のようなさまざまな並列演算子もあります。 ZIO.foreachPar ZIO.collectPar 、 と ZIO#zipPar 、エフェクトを並行して実行するのに役立ちます 。 試してみましょう foreachPar オペレーター:

ZIO.foreachPar(pages) { page =>
  fetchUrl(page)
}

11. 非同期プログラミング

一部のI/Oまたはブロッキング操作に依存する長時間実行ジョブがある場合は非同期プログラミングが不可欠です。 たとえば、イベントが発生するのを待っているとき、スレッドを待って消費する代わりに、コールバックを登録して次の操作を実行し続けることができます。 このスタイルのコールバックベースの非同期プログラミングは、スレッドをブロックしないようにし、アプリケーションの応答性を向上させますが、いくつかの欠点があります

  1. Callback Hell — Callbacks are great for simple cases, but they add some level of nesting and complexity to our programs, especially when we have lots of nested callbacks.
  2. Tedious Error Handling — When we use callbacks, we should be very careful where we use try/catch to catch exceptions. そして、これはエラー処理を面倒にします。
  3. 構成可能性の欠如—コールバックは構成されません。 したがって、関数型プログラミングの世界では、コールバックを使用して構成可能なコンポーネントを作成することは非常に困難です。

ZIO効果を使用することで、コールバックベースのAPIを使用した非同期プログラミングに別れを告げています

まず、ZIOには、非同期コールバックをZIOエフェクトに変換するための優れた構造がいくつかあります。 これらの構成の1つは、ZIO.asyncです。

object legacy {
  def login(
    onSuccess: User => Unit,
    onFailure: AuthError => Unit): Unit = ???
}

val login: ZIO[Any, AuthError, User] =
  ZIO.async[Any, AuthError, User] { callback =>
    legacy.login(
      user => callback(IO.succeed(user)),
      err  => callback(IO.fail(err))
    )
  }

次に、エラー処理のための機能演算子がたくさんあります。たとえば、エラーをキャッチし、失敗した場合に別の効果にフォールバックして、再試行するための演算子があります。

12. STM

ある口座から別の口座に送金するという古典的な問題を考えると、次のようになります。

def transfer(from: Ref[Int], to: Ref[Int], amount: Int) = for {
  _ <- withdraw(from, amount)
  _ <- deposit(to, amount)
} yield ()

withdraw操作とdeposit操作の間に、同じアカウントのwithdrawまたはdepositに別のファイバーが到達した場合はどうなりますか? このスニペットコードには、同時環境にバグがあります。 マイナスのバランスに達する可能性があります。 したがって、1つのアトミック操作で引き出し操作と預金操作の両方を実行する必要があります。

コンカレント環境では、ブロック全体をトランザクションで実行する必要があります。 ZIO STM(ソフトウェアトランザクショナルメモリ)を使用すると、transferロジック全体をSTMデータ型に記述し、それをトランザクションで実行できます。

def transfer(from: TRef[Int], to: TRef[Int], amount: Int): ZIO[Any, String, Unit] =
  STM.atomically {
    for {
      _ <- withdraw(from, amount)
      _ <- deposit(to, amount)
    } yield ()
  }

ZIO STMは、ロックフリーアルゴリズムを使用してすべての操作が非ブロッキングである宣言型の構成可能なトランザクションを提供します。 すべての条件が満たされたときに、トランザクションをコミットします。

13. 結論

この記事では、関数型プログラミングパラダイムを使用して実際のアプリケーションを作成するのに役立つZIO効果のいくつかの重要な機能を学びました。 その過程で、モジュラーZIOアプリケーションの作成方法の基本を学びました。 また、ZIOとの並行プログラミングの機能のいくつかを発見しました。

いつものように、例の完全なソースコードは、GitHubから入手できます。