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から情報を取ってきたり、ツイートを投稿する関数があるとする。
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。
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
について全てを説明するのは大変なので、ここでは直感的なことだけを説明する。まず、Reader
のmap
とflatMap
に注目すると、今のReader
が持っている関数g
にe
を与えて実行し、それを使ってf
を実行するという操作をする関数を持つ新しいReader
を生成している。ただし、map
やflatMap
の際にはf
とg
を組合せるだけで、実際に実行するのはapply
もしくはrun
2を用いて引数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がどのように行われるのだろうか。
まず、依存を持つことを表すトレイトを用意する。
trait UseWSClient {
val client: WSClient
}
trait UseOAuthCred {
val cred: OAuthCalculator
}
TwitterRepository
を改造して、Readerモナドを返すようにする。また、環境として先程定義したトレイトUseWSClient
とUseOAuthCred
をwith
で結合したものを用いる。
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))))
}
そして、依存を保存しておく場所を作る。
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]
を返すような例と、その結果に応じてどの依存を使うのかを選択して注入する例を考えてみることにする。
まずは次のような関数を用意する。
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
とは別の依存を用意する。
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の例では次のように、「次の計算」を持てるようなケースクラスとトレイトを用意する。
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]
次の計算は型A
のnext
である。例えばユーザー情報を取得して、取得できた場合はツイートするという処理をこのように書きたい。
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)
}
しかし、実はこれは上手くいかない。なぜならFetch
やUpdate
の持つnext
の型はA
であってTwitter[A]
ではない。ではA
をTwitter[A]
にすれば動くかというと、そうでもない。もしnext
がTwitter[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
を定義できるということである。
trait Functor[F[_]] {
def map[A, B](a: F[A])(f: A => B): F[B]
}
さらに、関数map
は次のファンクター則に則っていなければならない。
-
map
のf
にx => x
を入れて生成されたものが、元の値と等しいassert( t.map(x => x) == t )
- 適当な関数
g
とh
について、g
とh
の合成関数(x => g(h(x))
)でmap
した値と、h
でmap
した値をg
でmap
した値が等しいassert( t.map(x => g(h(x))) == t.map(h).map(g) )
このような制約を持つmap
をFetch
やUpdate
に対してどのように定義すればいいだろうか。少々天下り的だが、次のようにすればよい。
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
を次のように定義する。
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モナドを使ったとしても、普通のインタープリターと特に違いはない。
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の解決法として、InjectとTagless Finalの二つを紹介したい。
8/5 追記:
次回作を書きました。
→ FreeモナドとTagless FinalによるDependency InjectionのためのDSL