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

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
Help us understand the problem. What is going on with this article?

Dependency InjectionとDSL

More than 5 years have passed since last update.

Dead-Simple Dependency Injection in Scalaという発表で、Dependency Injection(依存の注入)をReaderモナドなどを用いて行うという技術があった。下記がその発表である。

この記事ではまず、Dependency Injectionについての説明と、Readerモナドについての説明を行い、次にReaderモナドを使ったDependency Injectionについて述べて、Freeモナドを用いて小さなDSLを作るアプローチを紹介する。
この記事はモナドやDependency Injectionなどに関する前提知識がなくてもある程度読めるように意図しているが、Scalaの文法的な知識を前提としている。また、もし説明が不足している点や文章の意図が分かりにくい部分があれば、気軽にコメントなどで指摘して欲しい。

注意:
記事の中にあるコードは読みやすさのためにimportなどを省略しているので、このままでは動かない。動かしたい方はGithubのリポジトリを使うとよい。

ReaderモナドとDependency Injection

例えば次のようにTwitterから情報を取ってきたり、ツイートを投稿する関数があるとする。

TwitterRepository.scala
object TwitterRepository {
  val config = new NingAsyncHttpClientConfigBuilder(DefaultWSClientConfig()).build()
  val builder = new AsyncHttpClientConfig.Builder(config)
  val client = new NingWSClient(builder.build)

  val key   = ConsumerKey(
    "key",
    "secret"
  )
  val token = RequestToken(
    "token",
    "secret"
  )

  def fetchUserByScreenName(screenName: String): Future[WSResponse] =
    client.url("https://api.twitter.com/1.1/users/show.json")
      .withQueryString("screen_name" -> screenName)
      .sign(OAuthCalculator(key, token))
      .get()

  def updateStatus(status: String): Future[WSResponse] =
    client.url("https://api.twitter.com/1.1/statuses/update.json")
      .sign(OAuthCalculator(key, token))
      .post(Map("status" -> Seq(status)))
}

これで動きはするが、外部と通信する部分(client)やTwitterの鍵(key)やトークン(token)がハードコードされているので、別のアカウントに差し換えたり、テストする際に不便なことになる。
そこでReaderモナドを使って外から依存を注入しようというのがDead-Simple Dependency Injection in Scalaなどで紹介されている手法である。

Readerモナド

まず、Readerモナド Readerを次のように定義する1

Reader.scala
case class Reader[E, A](g: E => A) {
  def apply(e: E) = g(e)
  def run: E => A = apply
  def map[B](f: A => B): Reader[E, B] = Reader(e => f(g(e)))
  def flatMap[B](f: A => Reader[E, B]): Reader[E, B] = Reader(e => f(g(e))(e))
}

object Reader {
  def pure[E, A](a: A): Reader[E, A] = Reader(e => a)
  def ask[E]: Reader[E, E] = Reader(identity)
  def local[E, A](f: E => E, c: Reader[E, A]): Reader[E, A] = Reader(e => c(f(e)))
  def reader[E, A](f: E => A): Reader[E, A] = Reader(f)
}

Readerについて全てを説明するのは大変なので、ここでは直感的なことだけを説明する。まず、ReadermapflatMapに注目すると、今のReaderが持っている関数geを与えて実行し、それを使ってfを実行するという操作をする関数を持つ新しいReaderを生成している。ただし、mapflatMapの際にはfgを組合せるだけで、実際に実行するのはapplyもしくはrun2を用いて引数eに値を投入した時に初めて全ての計算が実行されることになる。
次にコンパニオンオブジェクトReaderで定義しているものについて説明する。

pure
任意の値をReaderにする
ask
環境eを取得する
local
環境eを書き換える
reader
関数をReaderにする

これらの説明は今はよく分からないかもしれないが、後で実際に使う際に具体的な例として表われるので心配ない。

Readerモナド vs 関数

一見するとReaderモナドは関数(ラムダ式)とほとんど同じように思える。しかし、大きな違いとして、Readerモナドは自身が持つ関数に共通の環境というグローバル変数でもなくローカル変数でもない第三の場所を提供する3。関数の中から何か情報を参照したい場合、通常は次の二択になる。

  • 引数で渡す
  • グローバル変数から読み出す

グローバル変数を用いることが不味いというのはよく知られているが、かといって引数を使うアプローチも、次のように関数がいくつも連なった状況を考えると問題が浮き彫りになる。

def main(args: Array[String]) = {
  ???
  level1(args[0])
}

def level1(d: String) = {
  ???  // ここでは d を使わない
  level2(d)
}

def level2(d: String) = {
  ???  // ここでは d を使わない
  need_arg(d)
}

def need_arg(d: String) =
  ??? // d を必要とする

このようにある関数が依存してる関数の依存をわざわざ明示的に引数で渡す必要があるので、引数が増えて混乱したり、コードの見通しが悪くなったりする。また、依存が増えた際に関係する関数の引数を全て増やす必要がある。
一方で、Readerモナドは共通に使う情報を引数でもグローバル変数でもない第三の場所(環境)に入れることで、グローバル変数と引数で一長一短だと思われていた問題をスマートに解決する。

Dependency Injection

具体的な例で、 Readerモナドを用いたDependency Injectionがどのように行われるのだろうか。

まず、依存を持つことを表すトレイトを用意する。

UseWSClient.scala
trait UseWSClient {
  val client: WSClient
}
UseOAuthCred.scala
trait UseOAuthCred {
  val cred: OAuthCalculator
}

TwitterRepositoryを改造して、Readerモナドを返すようにする。また、環境として先程定義したトレイトUseWSClientUseOAuthCredwithで結合したものを用いる。

TwitterRepositoryDI.scala
object TwitterRepositoryDI {
  def fetchUserByScreenName(screenName: String): Reader[UseWSClient with UseOAuthCred, Future[WSResponse]] =
    reader(env =>
      env.client.url("https://api.twitter.com/1.1/users/show.json")
        .withQueryString("screen_name" -> screenName)
        .sign(env.cred)
        .get())

  def updateStatus(status: String): Reader[UseWSClient with UseOAuthCred, Future[WSResponse]] =
    reader(env =>
      env.client.url("https://api.twitter.com/1.1/statuses/update.json")
        .sign(env.cred)
        .post(Map("status" -> Seq(status))))

}

そして、依存を保存しておく場所を作る。

DefaultEnvironment.scala
object DefaultEnvironment {
  val config  = new NingAsyncHttpClientConfigBuilder(DefaultWSClientConfig()).build()
  val builder = new AsyncHttpClientConfig.Builder(config)
  val c       = new NingWSClient(builder.build)

  val defaultEnvironment = new UseWSClient with UseOAuthCred {
    val client = c
    val cred = OAuthCalculator(
      ConsumerKey(
        "key",
        "secret"
      ),
      RequestToken(
        "token",
        "secret"
      )
    )
  }
}

最終的には次のように実行する。

fetchUserByScreenName("_yyu_").run(DefaultEnvironment.defaultEnvironment)

このように、Readerモナドの環境として依存を注入できるうえ、これらのReaderを合成することもできる4

(for {
  _ <- fetchUserByScreenName("_yyu_")
  _ <- updateStatus("good")
} yield () ).run(DefaultEnvironment.defaultEnvironment)

依存の選択

例えば次のようFuture[Boolean]を返すような例と、その結果に応じてどの依存を使うのかを選択して注入する例を考えてみることにする。
まずは次のような関数を用意する。

TwitterRepositoryDI.scala
def existUserWithScreenName(screenName: String): Reader[UseWSClient with UseOAuthCred, Future[Boolean]] =
  reader(env =>
    for {
      res <- env.client.url("https://api.twitter.com/1.1/users/show.json")
               .withQueryString("screen_name" -> screenName)
               .sign(env.cred)
               .get()
    } yield res.status == 200
  )

この関数はscreenNameを持つユーザーが存在するかどうかを判定する関数である。

次にdefaultEnvironmentとは別の依存を用意する。

DefaultEnvironment.scala
val adminEnvironment = new UseWSClient with UseOAuthCred {
  val client = c
  val cred = OAuthCalculator(
    ConsumerKey(
      "key",
      "secret"
    ),
    RequestToken(
      "token",
      "secret"
    )
  )
}

そして、環境を変更してReaderモナドを実行するlocalを使って次のようにする。

(for {
   fb <- existUserWithScreenName("_yyu_")
   _  <- local(
           (e: UseWSClient with UseOAuthCred) =>
             if (Await.result(fb, Duration.Inf))
               DefaultEnvironment.adminEnvironment
             else
               e,
           updateStatus("test")
         )
} yield () ).run(DefaultEnvironment.defaultEnvironment)

このコードでは、“_yyu_”というユーザーが存在すれば環境をadminEnvironmentへ変更してからupdateStatusを実行し、そうでなけば通常の環境で実行する。
このように、この方法では依存を実行時の値によって切り換えるといった柔軟な処理ができる。

DSLとFreeモナド

計算を合成したりしつつ、依存を注入できるようになった。これを使ってTwitterを操作するためのミニプログラム言語(DSL)を作ろうというのが、Dead-Simple Dependency Injection in Scalaの後半パートになる。

小さなDSL

このTwitterの例では次のように、「次の計算」を持てるようなケースクラスとトレイトを用意する。

Twitter.scala
sealed trait Twitter[A]

case class Fetch[A](screenName: String, next: WSResponse => A) extends Twitter[A]
case class Update[A](status: String, next: A) extends Twitter[A]

次の計算は型Anextである。例えばユーザー情報を取得して、取得できた場合はツイートするという処理をこのように書きたい。

Fetch(
  "_yyu_",
  (fws: Future[WSResponse]) => {
    val ws = Await.result(fws, Duration.Inf)
    if (ws.status == 200)
      Update("exist", ())
    else
      Update("not exist", ())
  }
)

あとは各ケースクラスに対応する処理を書けばよいように思える。

def twitter_interpreter[A](a: Twitter[A]) = a match {
  case Fetch(user, next) =>
    for {
      res <- fetchUserByScreenName(user)
    } yield twitter_interpreter(next(res))

  case Update(status, next) =>
    for {
      _ <- updateStatus(status)
    } yield twitter_interpreter(next)
}

しかし、実はこれは上手くいかない。なぜならFetchUpdateの持つnextの型はAであってTwitter[A]ではない。ではATwitter[A]にすれば動くかというと、そうでもない。もしnextTwitter[A]だとすると、Fetchは次のようになる。

case class Fetch[A](screenName: String, next: WSResponse => Twitter[A]) extends Twitter[Twitter[A]]

このようにFetchの型がTwitter[Twitter[A]]となり、Twitterが二重になってしまって大変扱いづらい。
そこで、Dead-Simple Dependency Injection in ScalaではFreeモナドを使ってこの問題を解決する。

ファンクターとFreeモナドとインタープリター

FreeモナドはTwitter[Twitter[A]]のような構造をFree[Twitter, A]というFreeモナドへ落すデータ構造の一つである。これは、例えばTwitter[Twitter[Twitter[Twitter[A]]]]のようにどれだけネストしていたとしても全てがFree[Twitter, A]になる5
このように便利なFreeモナドだが、この効能を得るためにFreeモナドは「Twitterファンクターである」という性質を要求する。

ファンクター

ある型Fがファンクターであるとは、Twitterは次のような型を持つ関数mapを定義できるということである。

Functor.scala
trait Functor[F[_]] {
  def map[A, B](a: F[A])(f: A => B): F[B]
}

さらに、関数mapは次のファンクター則に則っていなければならない。

  1. mapfx => xを入れて生成されたものが、元の値と等しい
    • assert( t.map(x => x) == t )
  2. 適当な関数ghについて、ghの合成関数(x => g(h(x)))でmapした値と、hmapした値をgmapした値が等しい
    • assert( t.map(x => g(h(x))) == t.map(h).map(g) )

このような制約を持つmapFetchUpdateに対してどのように定義すればいいだろうか。少々天下り的だが、次のようにすればよい。

implicit val twitterFunctor = new Functor[Twitter] {
  def map[A, B](a: Twitter[A])(f: A => B) = a match {
    case Fetch(screenName, next) => Fetch(screenName, x => f(next(x)))
    case Update(status, next)    => Update(status, f(next))
  }
}

Freeモナド

FreeモナドFreeを次のように定義する。

Free.scala
case class Done[F[_]: Functor, A](a: A) extends Free[F, A]
case class More[F[_]: Functor, A](k: F[Free[F, A]]) extends Free[F, A]

class Free[F[_], A](implicit F: Functor[F]) {
  def flatMap[B](f: A => Free[F, B]): Free[F, B] = this match {
    case Done(a) => f(a)
    case More(k) => More[F, B](F.map(k)(_ flatMap f))
  }

  def map[B](f: A => B): Free[F, B] =
    flatMap(x => Done(f(x)))
}

そして、DSLを次のように修正する。

def fetch[A](screenName: String, f: WSResponse => Free[Twitter, A]): Free[Twitter, A] =
  More(Fetch(screenName, f))

def update(status: String): Free[Twitter, Unit] =
  More(Update(status, Done()))

そして、例えば“_yyu_”というユーザーの情報を取得して、取得できた場合はツイートするという処理を次のように書ける。

fetch(
  "_yyu_",
  res =>
    if (res.status == 200)
      update("exist")
    else
      update("not exist")
)

DSLの組み立てが完了したので、次はこれを実行するインタープリターを作成する。

インタープリター

Freeモナドを使ったとしても、普通のインタープリターと特に違いはない。

TwitterInterpreter.scala
def runTwitter[A](dsl: Free[Twitter, A], env: UseWSClient with UseOAuthCred): Unit = dsl match {
  case Done(a) => ()
  case More(Fetch(screenName, f)) =>
    for {
      fws <- fetchUserByScreenName(screenName).run(env)
    } yield runTwitter(f(fws), env)
  case More(Update(status, next)) =>
    for {
      _ <- updateStatus(status).run(env)
    } yield runTwitter(next, env)
}

さきほど作ったDSLを次のように実行する。

val dsl = fetch(
  "_yyu_",
  res =>
    if (res.status == 200)
      update("exist")
    else
      update("not exist")
)

runTwitter(dsl, DefaultEnvironment.defaultEnvironment)

まとめ

ReaderモナドとFreeモナドを使って依存を注入するDSLを作ることができたが、これにはExpression Problemという解決しなければならない課題が残っている。次の機会にはExpression Problemの解決法として、InjectTagless Finalの二つを紹介したい。

8/5 追記:
次回作を書きました。
FreeモナドとTagless FinalによるDependency InjectionのためのDSL


  1. このReaderは単純化のため共変や反変のパラメータを省略している。 

  2. このrunapplyと全く同じだが、Readerモナドに環境を入れて実行する際にはrunというような名前の関数が用いられることが多いので、今回は慣習を引き継いでこちらのメソッドも用意した。runapplyも同じ意味である。 

  3. 通常「環境」という言葉はローカル変数もグローバル変数も含んだものを指すと思うが、この記事ではReaderモナドが提供する環境という意味でのみこの言葉を使うことにする。 

  4. 今回の例では合成する意味は全くないが……。 

  5. The Perfect Insider 

yyu
暗号やプログラム言語の記事をよく書きます。 最近ではZenn.devにも投稿してます。 https://zenn.dev/yyu
https://twitter.com/_yyu_
recruitmp
結婚・カーライフ・進学の情報サイトや『スタディサプリ』などの学びを支援するサービスなど、ライフイベント領域に関わるサービスを提供するリクルートグループの中核企業
http://www.recruit-mp.co.jp/
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