Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
19
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

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

"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: 2.12.8
  • cats: 1.6.0
  • monix: 3.0.0-RC2
  • ZIO: 1.0-RC3

書籍のサンプルコードを出発点にする。ただし後の説明のため以下の点を少し改変する。

  • エンティティと DAO のペアが Movie用とVideo用の二組あるが、これをMovie用の1個にした。
  • 「Dao」から返されるMovie型をTask[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): Task[Option[Movie]]
  }
}
trait MovieServiceComponent { this: MovieRepoComponent  =>
  val movieRepo: MovieRepo

  class MovieService {
    def getMovie(id: Int): Task[Option[Movie]] = movieRepo.getMovie(id)
  }
}

MovieRepoComponentの実装クラスは、たとえばプロダクト環境用として以下のように実装される。これも入れ子になっている。

trait MovieRepoComponentImpl extends MovieRepoComponent {
  class MovieRepoImpl extends MovieRepo {
    def getMovie(id: Int): Task[Option[Movie]] = Task(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 -------------------------------------------------
import scala.concurrent.Await
import monix.execution.Scheduler.Implicits.global
import scala.concurrent.duration._

Await.result(program.runToFuture, 1.second)
// res0: Option[Movie] = Some(Movie(42,A Movie))

テストのときも同様にして、別の*Impl*Registryを定義する。

trait MovieRepoComponentTestImpl extends MovieRepoComponent {
  class MovieRepoTestImpl extends MovieRepo {
    def getMovie(id: Int): Task[Option[Movie]] =
      Task(Movie(-1, "Test").some)
  }
}
object TestRegistry
    extends MovieServiceComponent with MovieRepoComponentTestImpl {

  val movieRepo    = new MovieRepoTestImpl
  val movieService = new MovieService
}
val testProgram = TestRegistry.movieService.getMovie(42)
Await.result(testProgram.runToFuture, 1.second)
// res1: Option[Movie] = Some(Movie(-1,Test))

gist: oop to fp - original cake

Minimal Cake Pattern

Minimal Cake Pattern では、オリジナル Cake パターンのパラレル入れ子構造はなくなる。まずインジェクト対象のコンポーネントの trait と、それとペアになるUses*を定義する。

trait MovieRepo {
  def getMovie(id: Int): Task[Option[Movie]]
}
trait UsesMovieRepo {
  val movieRepo: MovieRepo
}

さらに使用したいコンポーネントの実装と、それを使用側で関連づけるためのMixIn*も定義する。

object MovieRepoImpl extends MovieRepo {
  def getMovie(id: Int): Task[Option[Movie]] = Task(dB.get(id))
}
trait MixInMovieRepo {
  val movieRepo: MovieRepo = MovieRepoImpl
}

コンポーネント-Uses*のペアと、実装-MixIn*のペアの間に見られる「I/F と実装の分離」は、コンポーネントを使用する側にも反映される。

trait MovieService extends UsesMovieRepo {
  def getMovie(id: Int): Task[Option[Movie]] = movieRepo.getMovie(id)
}
object MovieService extends MovieService with MixInMovieRepo
val program = MovieService.getMovie(42)

テスト用のコンポーネントも以下のように簡単にインジェクトできる。ここではやらなかったがMockテストもスムーズに書ける。

val testMovieService = new MovieService {
  val movieRepo = new MovieRepo {
    def getMovie(id: Int): Task[Option[Movie]] = Task(Movie(-1, "Test").some)
  }
}
val testProgram = testMovieService.getMovie(42)
Await.result(testProgram.runToFuture, 1.second)

ちなみに Scala初心者の頃に入ったチームでこのパターンを知ったとき、「ふーんScalaはこうやるんだー」くらいの感じで一瞬で流してしまってたけど、初心者でも迷わず自然に使い始められるほどシンプルなのに、DIでやりたいことの8〜9割くらいできてしまうのが今思えばすごい。

gist: oop to fp - minimum cake

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[Task, MovieServiceEnv, Option[Movie]] =
    ReaderT(_.movieRepo.getMovie(id))
}
val program = MovieService.getMovie(42).run(MovieServiceEnv)

テストも同様。

object TestEnvironment extends MovieServiceEnv {
  val movieRepo = new MovieRepo {
    def getMovie(id: Int): Task[Option[Movie]] = Task(Movie(-1, "Test").some)
  }
}
val testProgram = MovieService.getMovie(42).run(TestEnvironment)
Await.result(testProgram.runToFuture, 1.second)
//res1: Option[Movie] = Some(Movie(-1,Test))

(やってみて気づいたが、結局 Minimum Cake Patternと同じというか、むしろ一手間増えているだけかもしれない。)
gist: oop to fp - reader monad

Free Monad

ここまで書籍のサンプルコードに付け加えたTaskに触れなかったが、Taskがドメイン層まで入ってきていると、モデルの表現実行という2つの異なる関心事が癒着してしまっていて、実行の「文脈」を与えるモナドへの依存関係が生じている。この「依存」も純粋なドメインモデルと切り離し、癒着を解いてシンプルにしたい。このために Free モナドが使える。

Free モナドの場合 ADT とインタープリターを定義するが、まず ADT は以下のようにしてみた(実際はどのレイヤーで定義するかとか、どのくくりでまとめるとか設計ポイントがあるが、ここでは適当にした)。

sealed trait Query[A]
case class 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]] = for {
    movie <- MovieRepoOps.getMovie(id)
  } yield movie
}

ADTからTaskへの「自然変換」(cats.arrow.FunctionK)は以下のように書く。模擬DBへの依存もここに置いた。

def taskInterpreter: Query ~> Task = new (Query ~> Task) {
  def apply[A](fa: Query[A]): Task[A] = fa match {
    case GetMovie(id) => Task(db.get(id))
  }
}

実行はFree#foldMapにインタープリターを渡す。

val program = MovieService.getMovie(42).foldMap(taskInterpreter)

// 宇宙のおわりに
Await.result(program.runToFuture, 1.second)
// Some(Movie(42, A Movie))

テストも同じように書ける。ここではインタープリターの変換先をIdとしてみたが、呼び出しを記録したければWriterモナドが使えるし、インタラクションを含めた振る舞い定義したければ State などを使えばいい。

def testInterpreter: Query ~> Id = new (Query ~> Id) {
  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))

gist: oop to fp - free monad

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 = new Env {
  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 (Query ~> TestWriter) {
   def apply[A](q: Query[A]): TestWriter[A] =
    q match {
      case GetMovie(id) => Option(Movie(1, "A Movie")).writer(Vector(q))
    }
 }
val testEnv = new Env {
  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テストでは隣接するコンポーネントをモックに差し替えるような使い分けになると思う。
gist: oop to fp - free & reader monads

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] を使う。

sealed trait AppError
case object 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] =
    IO.fromEither(db.get(id).toRight(NoValue))
}

環境 Env は以下のようにインターフェイスと実装を分離して、あとで差し替えられるようにしておく。

trait Env {
  val movieRepo: MovieRepo
}
object Env extends Env { // 製品コードの実装
  val movieRepo = MovieRepoImpl
}

ZIO の文脈内で環境にアクセスするには ZIO.environment を、環境を与えるには ZIOインスタンスの ZIO#provideを使う。まとめると以下のようにしてプログラムを実行できる。

object MovieService {
  def getMovie(id: Int): ZIO[Env, AppError, Movie] =
    ZIO.environment[Env] >>= (_.movieRepo.getMovie(id)) // flatMap 構文
}
val program: ZIO[Env, AppError, Movie] = MovieService.getMovie(42)

new DefaultRuntime {}.unsafeRunSync(program.provide(Env))
// res0: scalaz.zio.Exit[AppError,Movie] = Success(Movie(42,A Movie))

テストは例えば以下のように書ける。

new DefaultRuntime {}.unsafeRunSync(program.provide(new Env {
  val movieRepo = _ => IO.fromEither(NoValue.asLeft) // 値が無かった場合をエミュレートする固定値
}))
// res1: scalaz.zio.Exit[AppError,Movie] = Failure(Fail(NoValue))

実行のためのモナドが ZIO に固定されてしまう事2について妥協すれば、外観上はかなりシンプルに DI(≒オブジェクトのグラフの生成) が書ける。

ソース Gist

所感

  • 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 寄りで、その分微妙に complect3 が生じている気がする。なので、すでに FutureIOTask をベタ書きしているようなプログラムで関数型DIを導入するなら ZIO が良いかもしれないが、逆に実行の文脈を抽象化して関心事の分離と simplicity を徹底したいコード(特にドメイン層など)では、Free Monad や Tagless Final を使うと、純粋にドメインの意図が表現できるかもしれない。

参考


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

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

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

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
19
Help us understand the problem. What are the problem?