"Functional Programming Patterns in Scala and Clojure"(以下、FPPSC)(PDF)の第3章に、関数型言語のイディオムでオブジェクト指向のパターンを置き換える「パターン」集が載っていて、その中で OO 的な DI の代替技法として Cake パターンが紹介されている。
11個紹介されている「置き換えパターン」の中でも、この Cake は特に、OOP vs FPというより、Scalaならこういう書き方もできるという言語特有イディオムの紹介だけど、これをもう少し関数型っぽく発展させてみたい。以下 Cake Pattern、Minimal Cake Pattern、Reader、Free、Reader + Free、ZIO を順に試してみる。
※『DI と後出しモナド:Tagless Final / Free Monad』に続く
準備等
使ったライブラリ等は以下。
- Scala: 3.1.3
- cats: 2.8.0
- cats effect: 3.3.12
- ZIO: 2.0.0
書籍のサンプルコードを出発点にする。ただし後の説明のため以下の点を少し改変する。
- エンティティと DAO のペアが
Movie
用とVideo
用の二組あるが、これをMovie
用の1個にした。 - 「Dao」から返される
Movie
型をIO[Option[_]]
に入れた。 - Dao → Repo、ServiceComponentImpl → ServiceComponentなど若干ネーミングを変更した。
- テストコードなどは適当に変更した。
Movie
エンティティは以下のような 'case class'で定義し、RDBを模したMap
にサンプルデータを一つ入れておく。
case class Movie(id: Int, title: String)
val db = Map[Int, Movie](42 -> Movie(42, "A Movie"))
パターンいろいろ
Cake Pattern
有名なオリジナルの Cake パターンで、FPPSCではこれが紹介されている。Java 由来のDIフレームワークより特に優れているというものでもないが、実行時DIとコンパイル時DIという点で本質的に違う。
以下がオリジナルの Cake 特有の構成で、各trait
がパラレルな入れ子になっている。
trait MovieRepoComponent:
trait MovieRepo:
def getMovie(id: Int): IO[Option[Movie]]
trait MovieServiceComponent { this: MovieRepoComponent =>
val movieRepo: MovieRepo
class MovieService:
def getMovie(id: Int): IO[Option[Movie]] = movieRepo.getMovie(id)
}
MovieRepoComponentの実装クラスは、たとえばプロダクト環境用として以下のように実装される。これも入れ子になっている。
trait MovieRepoComponentImpl extends MovieRepoComponent:
class MovieRepoImpl extends MovieRepo:
def getMovie(id: Int): IO[Option[Movie]] = IO(db.get(id))
これらのコンポーネントを用途に応じて"wiring"してから実行することになるが、ここではサンプル通りにobject Registry
で実装した。なんとなく不思議で面白い。
object Registry extends MovieServiceComponent with MovieRepoComponentImpl:
val movieRepo = new MovieRepoImpl
val movieService = new MovieService
val program = Registry.movieService.getMovie(42)
// rumtime -------------------------------------------------
program.unsafeRunSync() // Some(Movie(42,A Movie))
テストのときも同様にして、別の*Impl
と*Registry
を定義する。
trait MovieRepoComponentTestImpl extends MovieRepoComponent:
class MovieRepoTestImpl extends MovieRepo:
def getMovie(id: Int): IO[Option[Movie]] = IO(Movie(-1, "Test").some)
object TestRegistry
extends MovieServiceComponent with MovieRepoComponentTestImpl:
val movieRepo = new MovieRepoTestImpl
val movieService = new MovieService
val testProgram = TestRegistry.movieService.getMovie(42)
testProgram.unsafeRunSync()
// Some(Movie(-1,Test))
ソース: original_cake.worksheet.sc
Minimal Cake Pattern
Minimal Cake Pattern では、オリジナル Cake パターンのパラレル入れ子構造はなくなる。まずインジェクト対象のコンポーネントの trait、及びそれとペアになるUses*
を定義する。
trait MovieRepo:
def getMovie(id: Int): IO[Option[Movie]]
trait UsesMovieRepo:
val movieRepo: MovieRepo
さらに使用したいコンポーネントの実装と、それを使用側で関連づけるためのMixIn*
も定義する。
object MovieRepoImpl extends MovieRepo:
def getMovie(id: Int): IO[Option[Movie]] = IO(dB.get(id))
trait MixInMovieRepo:
val movieRepo: MovieRepo = MovieRepoImpl
コンポーネント-Uses*
のペアと、実装-MixIn*
のペアの間に見られる「I/F と実装の分離」は、コンポーネントを使用する側にも反映される。
trait MovieService extends UsesMovieRepo:
def getMovie(id: Int): IO[Option[Movie]] = movieRepo.getMovie(id)
object MovieService extends MovieService with MixInMovieRepo
val program = MovieService.getMovie(42)
テスト用のコンポーネントも以下のように簡単にインジェクトできる。ここではやらなかったがモックテストもスムーズに書ける。
val testMovieService: MovieService = new:
val movieRepo: MovieRepo = new:
def getMovie(id: Int): IO[Option[Movie]] = IO(Movie(-1, "Test").some)
val testProgram = testMovieService.getMovie(42)
testProgram.unsafeRunSync()
ちなみに Scala初心者の頃に入ったチームでこのパターンを知ったとき、「ふーんScalaはこうやるんだー」くらいの感じで軽く流してしまってたけど、初心者でも迷わず自然に使い始められるほどシンプルなのに、DIでやりたいことの8〜9割くらいできてしまうのが今思えばすごい。
ReaderT Monad
関数型プログラミング界隈ではReader
モナドを使ったイディオムも昔からよく紹介されている。これもいろんな実装方法があるが、ここでは上の Minimum Cake Pattern のコードをベースにしてUses*
を踏襲しつつ、MixIn*
をReaderT
の文脈上のEnv
に替えてみた。
trait MovieServiceEnv extends UsesMovieRepo
使用する側ではDIするコンポーネントを*Env
の実装で wiring して、ReaderT#run
に渡す。
object MovieServiceEnv extends MovieServiceEnv:
val movieRepo = MovieRepoImpl
object MovieService:
def getMovie(id: Int): ReaderT[IO, MovieServiceEnv, Option[Movie]] =
ReaderT(_.movieRepo.getMovie(id))
val task1 = MovieService.getMovie(42).run(MovieServiceEnv)
テストも同様。
object TestEnvironment extends MovieServiceEnv:
val movieRepo: MovieRepo = new:
def getMovie(id: Int): IO[Option[Movie]] = IO(Movie(-1, "Test").some)
val task2 = MovieService.getMovie(42).run(TestEnvironment)
task2.unsafeRunSync() //res1: Option[Movie] = Some(Movie(-1,Test))
(やってみて気づいたが、結局 Minimum Cake Patternと同じというか、むしろ一手間増えているだけかもしれない。)
ソース: DI_reader.worksheet.sc
Free Monad
ここまで書籍のサンプルコードに付け加えたIO
に触れなかったが、IO
がドメイン層まで入ってきていると、モデルの表現と実行という2つの異なる関心事が癒着してしまっていて、実行の「文脈」を与えるモナドへの依存関係が生じている。この「依存」も純粋なドメインモデルと切り離し、癒着を解いてシンプルにしたい。このために Free
モナドが使える。
Free モナドの場合 ADT とインタープリターを定義するが、まず ADT は以下のようにしてみた(実際はどのレイヤーで定義するかとか、どのくくりでまとめるとか設計ポイントがあるが、ここでは適当にした)。
case class Movie(id: Int, title: String)
enum Query[A]:
case GetMovie(id: Int) extends Query[Option[Movie]]
Free
モナドの定義は以下のようになる。ADT をFree
にリフトする定型的なブリッジも必要になる1。
type QueryF[T] = Free[Query, T]
object MovieRepoOps:
def getMovie(id: Int): QueryF[Option[Movie]] = liftF(GetMovie(id))
これらを使ってドメインロジック(ここでは簡単のためgetMovie
呼び出しのみ)を組み立てるコードは以下のようになる。
object MovieService:
def getMovie(id: Int): QueryF[Option[Movie]] = MovieRepoOps.getMovie(id)
ADTからTask
への「自然変換2」(cats.arrow.FunctionK
)は以下のように書く。模擬DBへの依存もここに置いた。
def taskInterpreter: Query ~> IO = new:
def apply[A](fa: Query[A]): IO[A] = fa match
case GetMovie(id) => IO(db.get(id))
実行はFree#foldMap
にインタープリターを渡す。
val program = MovieService.getMovie(42).foldMap(taskInterpreter)
program.unsafeRunSync() // Some(Movie(42, A Movie))
テストも同じように書ける。ここではインタープリターの変換先をId
としてみたが、呼び出しを記録したければWriter
モナドが使えるし、インタラクションを含めたシナリオを定義したければ State
などを使えばいい。
def testInterpreter: Query ~> Id = new:
def apply[A](fa: Query[A]): Id[A] = fa match
case GetMovie(_: Int) => Option(Movie(-1, "Test"))
val id1 = MovieService.getMovie(42).foldMap(testInterpreter)
// Some(Movie(-1,Test))
ソース: DI_free.worksheet.sc
Free + ReaderT Monad
モナドが提供する実行の文脈を差し替えるFree
モナドと、コンポーネントの wiring を差し替えるReader
モナドを併用することもできる。
trait MovieRepo:
def getMovie(id: Int): QueryF[Option[Movie]]
trait Env:
def movieRepo: MovieRepo
type QueryF[T] = Free[Query, T]
type EnvReader[T] = ReaderT[QueryF, Env, T]
object MovieRepoOps:
def getMovie(id: Int): QueryF[Option[Movie]] = Free.liftF(GetMovie(id))
コンポーネントを使う側のMovieService
では、Reader
の文脈上のEnv.movieRepo
をリポジトリ実装として使う。
object MovieService:
def getMovie(id: Int): EnvReader[Option[Movie]] =
ReaderT (_.movieRepo.getMovie(id))
Query~>Task
のインタープリターは上で書いたもの同じ。
Reader
のときと同様にEnv
を定義するが、ここではMovieRepo
の実装をEnv
の中で書いた。
val productEnv: Env = new:
def movieRepo: MovieRepo = id => MovieRepoOps.getMovie(id)
実行は以下のようになる。コンポーネントの wiring を定めたproductEnv
と、Taskへの自然変換を定めたtaskInterpreter
を個別に与えている。
val program = MovieService.getMovie(42)
.run(productEnv)
.foldMap(taskInterpreter)
テストでは固定値を返すダミーのMovieRepo
をEnv
上でコンポーネント実装に指定して、ADT
を記録するWriter
をモナドに指定した。
type TestWriter[T] = Writer[Vector[Any], T]
def testInterpreter2: Query ~> TestWriter = new:
def apply[A](q: Query[A]): TestWriter[A] = q match
case GetMovie(id) => Option(Movie(1, "A Movie")).writer(Vector(q))
val testEnv: Env = new:
def movieRepo: MovieRepo = _ => MovieRepoOps.getMovie(-1)
val (l, v) = MovieService.getMovie(-1)
.run(testEnv)
.foldMap(testInterpreter2)
.run
// l: Vector[Any] = Vector(GetMovie(-1))
// v: Option[Movie] = Some(Movie(1,A Movie))
実際には、FunctionalテストではEnv
上のコンポーネントを本物に wiring したまま自然変換のみテスト用に替えて、Unitテストでは隣接するコンポーネントをモックに差し替えるような使い分けになると思う。
ソース: DI_freeT2.worksheet.sc
ZIO
「記述と実行の分離」から一旦離れてみる。
以前 ZIO の紹介記事を書いたが、ZIO[R, E, A]
とは簡単に言えば Cats Effect の IO[A]
に環境 R
とエラー E
を加えた、謂わば trifunctor とでも言えそうな形式の型だった。ReaderT
同様、この環境 R
に Env
を与えることで DIっぽく使うことができる。
リポジトリアクセスで結果が得られなかった場合をエラー扱いとし AppError
型で表現すれば、最終的な 型は ZIO[Env, AppError, ?]
のようになる。ただしこのサンプルのリポジトリでは、それ自体は環境が要らないので、ZIO[Any, E, A]
のエイリアス IO[+E, +A]
を使う。
enum AppError:
case NoValue extends AppError
trait MovieRepo:
def getMovie(id: Int): IO[AppError, Movie] // = ZIO[Any, AppError, Movie]
object MovieRepoImpl extends MovieRepo:
def getMovie(id: Int): IO[AppError, Movie] =
ZIO.fromEither(db.get(id).toRight(NoValue))
環境 Env
は以下のようにインターフェイスと実装を分離して、あとで差し替えられるようにしておく。
trait Env:
val movieRepo: MovieRepo
object Env extends Env: // 製品コードの実装
val movieRepo = MovieRepoImpl
ZIO の文脈内で環境にアクセスするには ZIO.environment
を、環境を与えるには Runtime#withEnvironment
が使える。まとめると以下のように実行できる。
object MovieService:
def getMovie(id: Int): ZIO[Env, AppError, Movie] =
ZIO.environment[Env].flatMap(_.get[Env].movieRepo.getMovie(id))
val program: ZIO[Env, AppError, Movie] = MovieService.getMovie(42)
val runtime = Runtime.default.withEnvironment(ZEnvironment(Env))
Unsafe.unsafe(runtime.unsafe.run(program))
// Success(Movie(42,A Movie))
テストは例えば以下のように書ける。
val testEnv: Env = new:
val movieRepo = _ => ZIO.fromEither(NoValue.asLeft)
val testRuntime = Runtime.default.withEnvironment(ZEnvironment(testEnv))
Unsafe.unsafe(testRuntime.unsafe.run(program))
// Failure(Fail(NoValue))
実行のためのモナドが ZIO に固定されてしまう事3について妥協すれば、外観上はかなりシンプルに DI(≒オブジェクトのグラフの生成) が書ける。
所感
-
IO
やTask
などプログラムの「実行」に強く関わるモナドがあるとき、Free
とモナドトランスフォーマーを用いると「関心事の分離」がやりやすい。 - 逆に、
IO
やTask
など「実行系」のモナドがなかったり、あるいはそもそも「関心事の分離」に興味がない場合などは、Free
のコストが割高になるかもしれない。 - 最後、Free + ReaderTで終わったが、今回は試さなかった Minimum Cake + Free の組み合わせの方がいいかもしれない。→ 書いた(2018/04/02)
-
Free
の他にTagless final
などの選択肢も要検討。→ 書いた(2018/04/02) - 2019/04/25 に ZIO を追記した。simple と easy を二項対立と見ると、記述と実行を分離した Free+ReaderT などと比べて、どちらかというと ZIO は easy 寄りで、その分微妙に complect4 が生じている気がする。なので、すでに
Future
、IO
、Task
をベタ書きしているようなプログラムで関数型DIを導入するなら ZIO が良いかもしれないが、逆に実行の文脈を抽象化して関心事の分離と simplicity を徹底したいコード(特にドメイン層など)では、Free Monad や Tagless Final を使うと、純粋にドメインの意図が表現できるかもしれない。 - Scala 3 とその他最新のライブラリに合わせて修正した(2022-08-09)
参考
- 実戦での Scala: Cake パターンを用いた Dependency Injection (DI)
- Scalaにおける最適なDependency Injectionの方法を考察する 〜なぜドワンゴアカウントシステムの生産性は高いのか〜