FLINTERS Engineer's Blog

FLINTERSのエンジニアによる技術ブログ

ZIOを使ったプログラムの魅力とは?Scopeの活用方法を紹介します

こんにちは河内です。

この記事は10周年記念として133日間ブログを書き続けるチャレンジの120日目の記事となります。 そして本日2024年1月6日がFLINTERS創立10周年当日です! 10年を無事に迎えられたのは、取引先のお客様、応援してくださる皆様、そして社員の皆のおかげです。ありがとうございます!

さて、最近は ZIO を使ってプログラムを書くことが多いのですが、先日会社の人と ZIO のいいところ、いろいろあるよね、という話をしていまして、今回は Scope について書こうと思います。 (Scala 2.13.4, ZIO 2.0.13 です。)

zio.dev

Scope はリソース管理のための機能です。 リソース管理というのは Java でいうところの try-with-resources文がやっているようなものです。確保したものを使い終わったら解放するための仕組みです。

ZIO は Scala のライブラリなので、Scala で例を書きます。 Scala では try-with-resources の代わりに scala.util.Using が使えます。

Using.resource(new FileInputStream("foo.txt")) { is =>
  ... // is を使う
} // is はここで close される

そんなに話すことのない機能のように思えますが、実行タイミングが異なる要素が入ると少し難しくなります。 例えば前述のコードの中で is を使う部分が ZIO.attempt() になったとします。 ZIO.attempt() はその場で実行されるのではなく、ワークフロー(ZIO型のインスタンス)の構築のみを行い、実行は後で行われます。

val io = Using.resource(new FileInputStream("foo.txt")) { is =>
  // is を使う
  ZIO.attempt {
    // is を使う
  }
} // is はここで close される

// io の実行

この場合、io の実行時には is は close されてしまっているので、実行時にエラーになってしまいます。 この例ですと Using.resource()ZIO.attempt() の中に入れてしまえばいいのですが、複数の ZIO.attempt() から is を使いたい場合などはそうもいきません。

これが ZIO のやり方ではこうなります。

ZIO.scoped {
  for {
    is <- ZIO.fromAutoCloseable(ZIO.attempt(new FileInputStream("foo")))
    _ <-  ZIO.attempt {
      ... // is を使う
    }
  } yield ()
} // is はここで close される

ZIO.fromAutoCloseable()ZIO[R with Scope, E, A] 型の値を返します。 Scope はリソースの解放方法を知っている値です。 また ZIO.scoped はスコープを閉じる関数で、 ZIO[R with Scope, E, A]ZIO[R, E, A] に変換します。

ZIO.fromAutoCloseable(ZIO.attempt(new FileInputStream("foo"))) を説明のために分解すると、次のようになります。

  // "foo" を開く方法を記述したワークフロー
  val howToOpenFoo: ZIO[Any, Throwable, FileInputStream] =
    ZIO.attempt(new FileInputStream("foo"))

  // "foo" を開き、 Scope が閉じられる際に close する方法を記述したワークフロー
  val howToOpenAndCloseFoo: ZIO[Scope, Throwable, FileInputStream] =
    ZIO.fromAutoCloseable(howToOpenFoo)

Java の try-with-resources や Using.resource() がコードブロックでリソースの利用範囲を表現するのに対して、ZIO では ScopeR に含まれていることによってリソースの利用範囲を表現しています。

FileInputStream のように java.lang.AutoCloseable を継承している値には ZIO.fromAutoCloseable() を使えます。 それ以外には ZIO.acquireRelease()() が使えるので、AutoCloseable でないリソースも扱えます。

// ZIO.fromAutoCloseable(ZIO.attempt(new FileInputStream("foo"))) を書き換えたもの
ZIO.acquireRelease(ZIO.attempt(new FileInputStream("foo")))(is => ZIO.succeed(is.close()))

Scope は複数のリソースを扱える

Scope は複数のリソースを扱うことができます。 例を示します。

ZIO.scoped {
  for {
    is1 <- ZIO.fromAutoCloseable(ZIO.attempt(new FileInputStream("foo")))
    is2 <- ZIO.fromAutoCloseable(ZIO.attempt(new FileInputStream("bar")))
    _ <- ZIO.attempt(???)
  } yield ()
} // is2 → is1 の順で、ここで close される

リソースは確保した順と逆の順でシリアルに解放されます。

bracket と比べて何が嬉しいの?

確保、利用、解放を表現するときに使われるイディオムとして、それぞれを関数として引数に取る方法があります。 bracket pattern と呼ばれるものです。 該当するものとして ZIO 2.x には ZIO.acquireReleaseWith()()() という関数があるので、比べてみましょう。

一時ファイルを作ってそれを利用する例を考えます。

まずは Scope を使って書いたものです。

import zio.{Scope, ZIO, ZIOAppArgs, ZIOAppDefault}

import java.nio.file.{Files, Path}

object ScopeExample extends ZIOAppDefault {

  def createTmpFile: ZIO[Scope, Nothing, Path] =
    ZIO.acquireRelease(
      ZIO.succeed(Files.createTempFile("foo", ".tmp"))
    )(f => ZIO.succeed(Files.deleteIfExists(f)))

  override def run: ZIO[ZIOAppArgs with Scope, Any, Any] =
    for {
      tmp1 <- createTmpFile
      tmp2 <- createTmpFile
      _ <- ZIO.logInfo(s"tmp1: $tmp1, tmp2: $tmp2")
    } yield ()
}

次に bracket を使って書いたものです。

import zio.{Scope, ZIO, ZIOAppArgs, ZIOAppDefault}

import java.nio.file.{Files, Path}

object BracketExample extends ZIOAppDefault {

  def createTmpFile[R, E, A](use: Path => ZIO[R, E, A]): ZIO[R, E, A] =
    ZIO.acquireReleaseWith(
      ZIO.succeed(Files.createTempFile("foo", ".tmp"))
    )(f => ZIO.succeed(Files.deleteIfExists(f)))(use)

  override def run: ZIO[ZIOAppArgs with Scope, Any, Any] =
    for {
      _ <- createTmpFile { tmp1 =>
        createTmpFile { tmp2 =>
          ZIO.logInfo(s"tmp1: $tmp1, tmp2: $tmp2")
        }
      }
    } yield ()
}

ポイントは for 式の中です。 Scope は flatMap で合成できるので、平坦に for 式がかけますが、 bracket の場合は利用部を関数として渡すことになるため、ネストが深くなります。

一部の Scope を延長する

Scope はリソースの閉じ方を知っている値で、複数のリソースを扱えることは説明しました。 ZIO.scoped はそれらすべてを閉じる関数です。

一方で、一部のリソースを延長したい場合もあります。 そんなときは ZIO.scoped の外で ZIO.service[Scope] で Scope を取得し、 extend() でスコープを延長します。

// tmp1 はワークフロー内で close され、tmp2 は Scope が閉じられるときに close される
def workflowWithHowToCloseTmp2: ZIO[Scope, Nothing, Unit] =
  for {
    s <- ZIO.service[Scope]
    _ <- ZIO.scoped {
      for {
        tmp1 <- createTmpFile
        tmp2 <- s.extend(createTmpFile)
        _ <- ZIO.logInfo(s"tmp1: $tmp1, tmp2: $tmp2")
      } yield ()
    }
  } yield ()

まれにあるケース

Scope はリソースの管理を確実にするためのものですが、たまにはわざと管理をすり抜けたいこともあります。

そんなときは Scope.global で extend します。

def workflow: ZIO[Any, Nothing, Unit] =
  ZIO.scoped {
    for {
      tmp1 <- createTmpFile
      tmp2 <- Scope.global.extend(createTmpFile) // 注意! close されない!
      _ <- ZIO.logInfo(s"tmp1: $tmp1, tmp2: $tmp2")
    } yield ()
  }

危ないのでよく考えて使いましょう。

まとめ

ZIO の Scope はリソースの管理を確実にするための機能です。 for 式で平坦にかけ、複数のリソースを扱えるのが特徴です。 いざというときの抜け道もありますが、危険なのでよく考えて使いましょう。