継続モナドが分からなくてもActionContの嬉しさなら何とか分かる気がした。

某会社の某アカウントシステムで使われているという継続モナドコントローラーですが、 今までいまいちよくわかっていなかったのですが友達に教えてもらってなるほど!と思ったので書き記します。

ActionCont

継続モナドコントローラー(ActionCont)については以下の記事で解説されています。

本記事では「継続 = 何か残りの処理が呼べるやつ」という雑かつ不正確な認識でもActionCont便利じゃん!使ってみよ!と感じられることを目指すので、なんとなくの概要が頭に入っていればよい(はず)です。(なので、ActionContのメリットなどがもうわかってる人にとっては当たり前じゃん?みたいな内容です)

ScalaMatsuri 2016

さてScalaMatsuri 2016 でもActionContについての発表がされました。 (この時の資料はこちら ScalaMatsuri 2016 ドワンゴアカウントシステムを支えるScala技術 / hexx さん - ニコナレ )

この際、Q:「Futureでも良いのでは?」A:「実際、8割位の処理はFutureでよい。残り二割の処理で継続が使えると便利」という質疑応答がなされました。

これを聞いて、残り二割ってどれだ・・?という気持ちになったのでその手の事に詳しい友人に聞いた所以下の事を説明されたのでコードベースで説明します。 従って、この記事では継続モナドとはという話はせずに、何かよくわからないけどFutureよりどう良くなるの?(´・_・`)という点だけ書きます。

鍵となるのはこの図(http://niconare.nicovideo.jp/watch/kn1052 のp.9から許可を得て掲載)です。

f:id:matsu_chara:20160131215036p:plain

この図は典型的なWebアプリケーションの構成図を示しています。 ブラウザ -> サーバー -> FilterA -> ... と進んだ後、逆方向に戻っていっているのがポイントです。 「典型的なWebアプリケーションでは処理全体が大きなUターンを描くという」点を覚えつつコードを見ていきましょう。

サンプルコード

今回はGithubこのへんの例を改造して考えてみます。

def authLoginCont(request: Request[AnyContent]): ActionCont[Result] =
  for {
    authParam <- authParamCont(request)
    user <- loginCont(authParam)
  } yield Ok(Json.obj("id" -> user.id))


// actionでは以下のようにすればFuture[Result]が取得できる
def login = Action.async { request =>
  authLoginCont(request).run(Future.successful)
}

login アクションは authLoginCont という継続にrequestを渡してresponseを得ています。

ここまでの処理はFutureでも簡単に記述可能だと思います。 実際に書いてみると以下のようになります。

def authLoginFuture(request: Request[AnyContent]): Future[Result] = 
  for {
    authParam <- authParamFuture(request)
    user <- loginFuture(authParam)
  } yield Ok(Json.obj("id" -> user.id))

このような書き換えの簡単さが「Futureでも8割行ける」ということを指すのだと思います。

残りの二割を考えるために、authParam関連の処理が実は、 「authParamを取得しつつ、authParamが何かの条件を満たしていたらresponseに特定のheaderを追加する」という処理だったと考えてみます。

まずはFutureから見てみましょう。

def authParamFuture(request: Request[AnyContent]): Future[AuthParam] = Future {
  // DBから取得するなど何か適当な処理
  repository.fetchAuthParam()
}
def addAuthParamResponseHeader(authParam: AuthParam, response: Result): Future[Result] = Future {
  if(authParam.isSpecial) response.withHeaders("Auth-Special-Mode" -> "Mofu") else response
}

def authLoginFuture(request: Request[AnyContent]): Future[Result] =  {
  for {
    authParam <- authParamFuture(request)
    user <- loginFuture(authParam)
    result <- Future.successful(Ok(Json.obj("id" -> user.id)))
    resultWithHeader <- addAuthParamResponseHeader(authParam, result)
  } yield resultWithHeader
}

以上のような感じで書けました。しかしAuthParam関連の処理が離れてしまっています。 AuthParamを関心とするところがちょうど2つあってそれがresponseによって隔てられています。 こんな処理が他にも幾つもあると

authParam1 <- authParamFuture1(request)
authParam2 <- authParamFuture2(request)
authParam3 <- authParamFuture3(request)
user <- loginFuture(authParam)
result <- Future.successful(Ok(Json.obj("id" -> user.id)))
resultWithHeader1 <- addAuthParamResponseHeader1(authParam1, result)
resultWithHeader2 <- addAuthParamResponseHeader2(authParam2, resultWithHeader1)
resultWithHeader3 <- addAuthParamResponseHeader3(authParam3, resultWithHeader2)

のようにresultが障壁になって関連するコード同士がどんどん離れていってしまいそうです。

ここで コードをよく見ると request処理1 -> request処理2 ->request処理3 -> ... -> response処理1 -> response処理2 -> response処理3 という流れが出てきました。

ここで、response処理の順番を少し調整して request処理1 -> request処理2 ->request処理3 -> ... -> response処理3 -> response処理2 -> response処理1 とすれば上の図にあった大きなUターンが存在することが分かります。

今度は、この処理をActionContで表現してみます

def authParamWithHeaderCont(request: Request[AnyContent]): ActionCont[AuthParam] = ActionCont((f: AuthParam => Future[Result]) =>
  for {
    // DBから取得するなど何か適当な処理
    authParam <- repository.fetchAuthParam()
    result <- f(authParam)
  } yield if(authParam.isSpecial) result.withHeaders("Auth-Special-Mode" -> "Mofu") else result
)

def authLoginCont(request: Request[AnyContent]): Future[Result] =  {
  for {
    authParam <- authParamWithHeaderCont(request)
    user <- loginCont(authParam)
  } yield Ok(Json.obj("id" -> user.id))
}

上記のように簡潔に表現することが出来ました。さきほどのようなresponseを隔てた処理の散らばりがなくなっています。 これは f() にauthParamを渡すことで継続が実行されて Future[Result] が返ってくることを利用しています。

下の図(再掲)でいうと、FilterAでの処理中にf()を呼び出すとFilterB, Main Processing, (折り返して) Filter B が実行されて、 その結果がFilterAf()の返り値として取得できるイメージです。(感覚的には残りのUターンを実行させて、もう一度自分の所に来たらそこで止めるかんじでしょうか。)

f:id:matsu_chara:20160131215036p:plain

もちろん処理が増えても継続を実行すれば残りの処理は全て実行されるので、 以下のように各レイヤーの関心事に集中して処理(パラメータの取得&レスポンスの変更)を柔軟に行うことが出来るような凝集度の高いコードが書けそうです。

authParam1 <- authParamWithHeaderCont1(request)
authParam2 <- authParamWithHeaderCont2(request)
authParam3 <- authParamWithHeaderCont3(request)
user <- loginCont(authParam)

感想

今回は継続モナドを利用することで、レスポンスを改変するようなコードがシンプルに書けそうだということを中心に見ていきました。

こういった処理が2割もあるのかというと微妙そうですが、アカウントシステムというドメインでは認証のためにHTTPヘッダをごりごり使っていたり、色々なデバイス(3DSVita)などと通信してたりしていそうなので(よく知らないので完全に想像ですが)そのような面倒な処理がたくさん出てきてしまうというのは何となく納得がいくように思いました。 (また実際にこのユースケースが継続モナドが活きる全てのユースケースかどうかというとだいぶ怪しそうなので2割のうちの一部分なんだと思います。)

以下まとめ(と、書き残したこと)です。

  • Futureで済むならFutureで良いようですが、responseを変更したいときなどで継続モナドを使うと簡潔に書けるケースがあるようです。そのような簡潔さはWebアプリケーションの典型的なアーキテクチャと継続(継続渡しスタイル)の間にある(処理がUターンを描くという)類似性から得られているようです。
  • 継続というと色々なことができすぎてコードを読むのが難しくなるという印象がありました。しかし、ActionContは継続の用途をはっきりさせることで、継続の複雑さを隠した上でシンプルな処理の記述が可能になっているようです。
  • 資料でも述べられていますが、PlayのAction合成もおおまかにはこのような考え方のようです。違いは継続モナドモナドなので合成もなにかと柔軟にできますし、モナドトランスフォーマーなどのモナドであることの恩恵を受けられるけどAction合成は関数合成なのでやや柔軟性に欠けるという点のようです。

継続モナドコントローラーの嬉しさが70分の1くらいは理解できた気がします₍₍ (ง´・_・`)ว ⁾⁾

追記

とのことです。