LoginSignup
21

More than 1 year has passed since last update.

Scala で書く関数型な DI: Cake 〜 Free+ReaderT、ZIO

Last updated at Posted at 2018-01-01

"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割くらいできてしまうのが今思えばすごい。

ソース: min_cake.worksheet.sc

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)

テストでは固定値を返すダミーのMovieRepoEnv上でコンポーネント実装に指定して、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 同様、この環境 REnv を与えることで 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(≒オブジェクトのグラフの生成) が書ける。

ソース: di_with_zio.worksheet.sc

所感

  • IOTaskなどプログラムの「実行」に強く関わるモナドがあるとき、Freeとモナドトランスフォーマーを用いると「関心事の分離」がやりやすい。
  • 逆に、IOTaskなど「実行系」のモナドがなかったり、あるいはそもそも「関心事の分離」に興味がない場合などは、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 が生じている気がする。なので、すでに FutureIOTask をベタ書きしているようなプログラムで関数型DIを導入するなら ZIO が良いかもしれないが、逆に実行の文脈を抽象化して関心事の分離と simplicity を徹底したいコード(特にドメイン層など)では、Free Monad や Tagless Final を使うと、純粋にドメインの意図が表現できるかもしれない。
  • Scala 3 とその他最新のライブラリに合わせて修正した(2022-08-09)

参考

  1. Freestyle などを使ってこのボイラープレートを減らす方法もあるがここでは割愛する。

  2. 慣習上「自然変換」と言われることが多いが厳密には圏論の自然変換ではない。

  3. Cats Effect IO や Monix Task ではなく ZIO に固定される。

  4. 編み合わせる、織り合わせるといった意味で、Simple made Easy では、プログラムの要素を解きほぐしにくい形で癒着させて、simple さを損ねるものとされている。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21