Scalaの非同期処理フレームワークであるCats Effectには、非同期処理のためのバラエティ豊かな部品が沢山揃っていて、これらを組み合わせることで簡単に柔軟な非同期処理を書けるようになっている。
中でもよく登場する部品がResourceだ。Resourceを利用することで、以下のような処理を簡単に・柔軟にハンドリングできるようになる:
- 取得と解放を必ず行わなければならないような操作
- 例: ファイルへのロック
- ある処理のバックグラウンドで別の処理を行い、フォアグラウンド側の処理が終わったら終了させる
- 例: 処理中インジケータ
- 上記のような操作を合成した操作
- 例: 2つのファイルを順に開き、開いたときとは逆順に閉じなければならない
この記事ではResourceとその操作方法、どんなに便利かについて解説する。
環境
この記事ではScala 3.3.3、Cats Effect 3を利用する。
Scalaの実行環境としてScala CLIを利用する。
Resourceとは
Resource(cats.effect.kernel.Resource)とは、取得処理と解放処理のペアのことである(取得したリソースのことではない)。
Resourceは、取得と解放、入口と出口など、強制的に対にして扱う必要があるもののためにCats Effectが用意している部品である。Resourceを取得中にキャンセルが行われたりエラーが発生しても、Cats Effectは自動的に安全にResourceのシャットダウン処理を順に遂行してくれる。
Resourceの型シグネチャはResource[エフェクト型, 中身の型]だ。例えばIOの中でIntをラップしたリソースの型はResource[IO, Int]になる。
Cats Effectが標準で提供する部品のうち、以下のようなものがResourceとして提供される:
SemaphoreやMutexなどロック系部品のlock- ロックを取得し、あとで必ず解放する必要があるため
- ファイバを閉じることを保証する
Supervisor- どの範囲を抜けたらファイバを閉じるか規定しなければならないため、その境界線を
Resourceとして定める
- どの範囲を抜けたらファイバを閉じるか規定しなければならないため、その境界線を
Resourceは作ることができる
Resourceを作るにはいくつかの方法があるが、最も素朴なものはResource.makeだ。makeはリソースを作成する処理とそれを解放する処理とを引数に取り、Resourceを作成する:
import cats.effect.IO import cats.effect.kernel.Resource val r: Resource[IO, Int] = Resource.make(IO.println("Acquiring resource") >> IO(42))(n => IO.println(s"Releasing resource [$n]"))
Resourceはリソース概念の抽象化であり、まだこの時点では実際のリソース確保は行なわれない。
ちなみに、ただのIOを解放処理なしでそのままResourceに持ち上げるResource.evalも存在する。後述するがResource同士は合成できるため、型を合わせるのに便利そうだ:
import cats.effect.IO import cats.effect.kernel.Resource val r: Resource[IO, Int] = Resource.eval(IO(42))
Resourceは使うことができる
Resource.useを呼び出すと、そこで初めてリソースの確保処理が走り、ブロックの中ではそれが使えるようになる。リソースを確保する副作用があるため、useの型はuse[B](f: A => IO[B]): IO[B]であり、IOを返す:
//> using scala 3.3.3 //> using dep "org.typelevel::cats-effect:3.5.4" //> using options "-Wnonunit-statement" import cats.effect.{IOApp, IO} import cats.effect.kernel.Resource object Main extends IOApp.Simple { val r = Resource.make(IO.println("Acquiring resource") >> IO(42))(_ => IO.println("Releasing resource") ) def run = for { _ <- r.use { n => // リソース確保処理が起動し、中身がもらえる IO.println(s"Using resource: $n") } // ここでリソースの利用区間は終わり。解放処理が走る } yield () }
実用的には、上掲のコードにあるように、リソース自体の定義は別に置いて、実際にそれが欲しいときにuseを呼び出すのがわかりやすい。
また、Resource自体はリソースの取得方法を表現しているだけなので、状態を持たないし、何度でも使っていい。
Resourceは合成できる
エンジニアの嬉しい嬉しいポイント、合成可能性だ。Resourceは他のResourceと合成して1つのResourceとして扱うことができる。
例えば、次のような3つのResourceがあるとしよう:
val mug = Resource.make(IO.println("Acquiring mug") >> IO("mug"))(_ => IO.println("Returning mug")) val pot = Resource.make(IO.println("Acquiring pot") >> IO("pot"))(_ => IO.println("Returning pot")) val teabag = Resource.make(IO.println("Acquiring teabag") >> IO("teabag"))(_ => IO.println("Returning teabag"))
おいしい紅茶を淹れるには、3つのリソースを全て確保しなければならない。
//> using scala 3.3.3 //> using dep "org.typelevel::cats-effect:3.5.4" //> using options "-Wnonunit-statement" import cats.effect.{IOApp, IO} import cats.effect.kernel.Resource object Main extends IOApp.Simple { val mug = Resource.make(IO.println("Acquiring mug") >> IO("mug"))(_ => IO.println("Returning mug")) val pot = Resource.make(IO.println("Acquiring pot") >> IO("pot"))(_ => IO.println("Returning pot")) val teabag = Resource.make(IO.println("Acquiring teabag") >> IO("teabag"))(_ => IO.println("Returning teabag")) def run = for { _ <- mug.use { m => pot.use { p => teabag.use { t => IO.println(s"Making tea with $m, $p, and $t 🍵") } } } } yield () }
これを実行すると以下のような結果になる(ちゃんと内側から解放されている!):
Acquiring mug Acquiring pot Acquiring teabag Making tea with mug, pot, and teabag 🍵 Returning teabag Returning pot Returning mug
さきほどの例では、useして、useして、useしてから使っている。こうする代わりに、先にResourceを合成してからuseすることで手数を減らせる。
Resourceは、forや>>、tupledなどで合成できる(flatMapが実装されているため)。
//> using scala 3.3.3 //> using dep "org.typelevel::cats-effect:3.5.4" //> using options "-Wnonunit-statement" import cats.effect.{IOApp, IO} import cats.effect.kernel.Resource object Main extends IOApp.Simple { val mug = Resource.make(IO.println("Acquiring mug") >> IO("mug"))(_ => IO.println("Returning mug")) val pot = Resource.make(IO.println("Acquiring pot") >> IO("pot"))(_ => IO.println("Returning pot")) val teabag = Resource.make(IO.println("Acquiring teabag") >> IO("teabag"))(_ => IO.println("Returning teabag")) val triplet: Resource[IO, (String, String, String)] = for { m <- mug p <- pot t <- teabag } yield (m, p, t) def run = for { _ <- triplet.use { (m, p, t) => IO.println(s"Making tea with $m, $p, and $t 🍵") } } yield () }
上掲の例ではfor式でResourceを1つのResourceに合成している。以下の例では同様のことをtupledを使ってよりシンプルに表現している(リソース同士に依存関係がなければparTupledを使うと同時並行でリソースを確保・解放する):
//> using scala 3.3.3 //> using dep "org.typelevel::cats-effect:3.5.4" //> using options "-Wnonunit-statement" import cats.effect.{IOApp, IO} import cats.effect.kernel.Resource // tupledを呼ぶために必要 import cats.syntax.all.{*, given} object Main extends IOApp.Simple { val mug = Resource.make(IO.println("Acquiring mug") >> IO("mug"))(_ => IO.println("Returning mug")) val pot = Resource.make(IO.println("Acquiring pot") >> IO("pot"))(_ => IO.println("Returning pot")) val teabag = Resource.make(IO.println("Acquiring teabag") >> IO("teabag"))(_ => IO.println("Returning teabag")) val triplet: Resource[IO, (String, String, String)] = (mug, pot, teabag).tupled def run = for { _ <- triplet.use { (m, p, t) => IO.println(s"Making tea with $m, $p, and $t 🍵") } } yield () }
まとめ
Resourceを使って取得・解放が必要な概念をうまく扱えることを示した。実際にはファイルを開いたり閉じたりする処理が入ってくるが、骨格となる部分は同じだ。Resourceがあることで、「いつかちゃんと終了して返さなければならないリソース」の扱いに悩むことを減らせる。また、Resourceの合成可能性によって、非常に簡潔・柔軟にリソースハンドリングを行えることがわかった。