Help us understand the problem. What is going on with this article?

継続モナドを使ってWebアプリケーションのコントローラーを自由自在に組み立てる

More than 5 years have passed since last update.

継続モナドを使ってPlay FrameworkのActionを作るという話をします。
Play FrameworkはScalaのWebアプリケーションフレームワークであり、Actionはそのコントローラー部分になります。

この記事を読むにあたって継続モナドの知識は前提としませんが、

  • ある程度のモナドの知識(Scalaのfor構文の使い方、ScalaのモナドがflatMapメソッドで合成できることなど)
  • Play Frameworkの使い方(PlayのActionがどのようなものであるかなど)

などの知識は前提とし、説明を省略させていただきます。

話の流れとしては以下のようになります。

この説明で使われたサンプルコードは action-cont/action-cont-simple にあります。
また、より実践的な例として hexx/action-cont ではScalazの継続モナドを使って t2v/play2-auth の一部を再実装したライブラリを作成してみました。

コントローラーで継続モナドを使いたい動機

この話には二つの大きい動機があります。

  • 継続モナドを使ってPlayのActionをよりコンポーザブルにする
  • 継続モナドを使ってPlayのコントローラー層のエラー処理を整理する

自分が業務で関わっているWebアプリケーションには既に30個以上ものコントローラーに対するフィルタ処理があり、それを正常系と異常系で使い分けなければならない状態でした。
さらにエラー時のレスポンス処理やリダイレクト処理の整理も課題としてあり、今回新規機能を追加するにあたって、継続モナドを使った新しい仕組みを導入し、それらの問題を解決したいと考えました。

個人的には以下の動機もあります。

  • 継承を使ったフレームワークから離れることで、各々のコントローラに処理の主導権を与える
  • 継続モナドを使ってWebアプリケーションのコントローラーの一般的な構造を考える

継続モナドとは?

今回の話の主役となる継続モナドですが、「継続」と「モナド」とあると、いかにも難しいように思われるかもしれませんが、実際にコードを見てみるとそれほど難しいものではありません。
Scalaで書くと4行ほどのコードで書けます。

Cont.scala
case class Cont[R, A](run: (A => R) => R) {
  def map[B](f: A => B): Cont[R, B] = Cont(k => run(a => k(f(a))))
  def flatMap[B](f: A => Cont[R, B]): Cont[R, B] = Cont(k => run(a => f(a).run(k)))
}

ここで重要なのは Cont のコンストラクターに渡される run 関数です。
この run 関数を作り Cont のコンストラクターに渡すことで、継続モナドを作ることができます。

それで run 関数の型を見てみますと (A => R) => R とあります。
A => R という型の関数を受け取り、おそらく受け取った関数を実行し R という結果を返すという動作が期待できます。

ここで A を全体の中の途中の計算結果、 R を最終的な計算結果と考えるとわかりやすくなります。
そう考えると A => R は途中から最後までの計算を表している関数ということになります。
となると (A => R) => R という型の run 関数は、「途中から最後までの計算を表している関数」を受け取り、それを実行し、最終的な結果を得る関数ということになります。

実際にはこの run 関数は、受け取った A => R 型の関数をただ実行するのではなく途中結果の A の計算をおこない、そして A => R の関数に A を渡して実行し(あるいは渡さずにまったく別の計算をし)、そしてその作られた結果の R を場合によって加工するようなことが考えられます。

そして例によって継続モナドはモナドなので map メソッドで途中結果の A を加工できたり、 flatMap メソッドで継続モナド同士を合成できたりします。

これが継続モナドの動作ということになります。

なぜコントローラーで継続モナドを使うと便利なのか?

このように継続モナドの動作は、受け取ったコールバック関数を実行する前後に何か処理を入れることができるというものでした。
この動作がコントローラを構成する部品を作る上で便利な性質になります。

Webアプリケーションのコントローラのフレームワークで、これに似たような動作をするものがないでしょうか?
有名なものではJava EEのServlet Filterあたりがそうでしょう。
Servlet Filterは doFilter メソッドでリクエストを加工し、フィルタのチェーンの次の doFilter メソッドを呼び出し、そのメソッド呼び出しが終了し、処理が戻ってきたらレスポンスを加工したりします。
これは継続モナドの run 関数の動作とよく似ていますね。

Servlet Filterと継続モナドの対応を考えると、個々のフィルタの動作は run 関数で表現することができ、Servlet Filterを連鎖させる全体の仕組みは、Scalaでは4行程度で作られた継続モナドの合成で表現することができてしまいます。
関数型プログラミングの力を感じますね。

そう考えると継続モナドの使用用途も見えてきます。
たとえば以下のようなものが考えられるでしょう。

  • ユーザの認証、認可のチェック
  • CSRFトークンのチェック
  • 言語、文字エンコードの変更
  • システムがメンテナンス状態なら他のサーバにリダイレクトするような処理
  • リクエストヘッダから値を取り出す
  • レスポンスのステータスコードを変更する
  • レスポンスに集計用などのヘッダを追加する

もちろん上記の動作はリクエストとレスポンスで対応させることができます。
たとえばリクエストヘッダを見て特定の条件のときにだけレスポンスにCORS用のヘッダを付けたりすることができます。

以上の説明で継続モナドを何に使えばいいのか見えてきたでしょうか?

継続モナドとFutureを組み合わせることでエラー処理を整理する

そして、今回の話にはもう一つ重要なモチベーションがありました。
エラー処理の整理です。
ここでは実際に継続モナドをPlay Frameworkの中でどのような型として使うかということを見ていきながらエラー処理の解説もしていきたいと思います。

まず今回の継続モナドでは Cont[R, A]R の部分を Future[Result] とします。 Result はPlayのActionでレスポンスとなる型の Result です。
そして、できあがった Cont[Future[Result], A] という型を ActionCont[A] と名付けましょう。

package.scala
import play.api.mvc.Result
import scala.concurrent.Future

package object cont {
  type ActionCont[A] = Cont[Future[Result], A]
}

さらにいくつかFutureからActionContを作る便利メソッドもいくつか追加します。

ActionCont.scala
object ActionCont {
  def apply[A](f: (A => Future[Result]) => Future[Result]): ActionCont[A] =
    Cont(f)

  // FutureからActionContを作る
  // 後続処理にFutureの中身の値を渡す
  def fromFuture[A](future: => Future[A])(implicit ec: ExecutionContext): ActionCont[A] =
    Cont(future.flatMap)

  def successful[A](a: A)(implicit ec: ExecutionContext): ActionCont[A] =
    fromFuture(Future.successful(a))

  def failed[A](throwable: Throwable)(implicit ec: ExecutionContext): ActionCont[A] =
    fromFuture(Future.failed(throwable))
}

Result ではなく Future[Result] を使う理由は、まず Future を付けたほうがより一般的であるという点があります。
ScalaのWebアプリケーションでは、ドメイン層の処理が Future で返されることが多いと思います。
そして Future を使わない場合も Future.successful を付ければいいだけです。
というわけで Future にしておいたほうが柔軟性が上がるわけです。

そして結果型を Future にすることにはエラー処理の面でも意味があります。

Future は非同期計算をするモナドですが、非同期プログラミングでは例外を使うことができないために Future は正常時の型とエラー時の Throwable の直和型のような動作をします。
継続モナドの結果の型が Future[Result] ではなく Result の場合は、処理の途中で何らかのエラーが発生した場合は例外を投げるか、その場で BadRequest などの Result を作らなければならなくなりますが、
Future[Result] の場合は Future.failed を使って値をエラーにすることができます。
つまり Future では EitherTry などと同じように エラー処理を例外ではなく、値として扱うことができるわけです。

例外の使用に関して、特に関数型界隈では賛否両論あると思いますが、コントローラー層に限って言えば例外は使わないほうがよいと考えます。
ドメイン層では処理できない例外的なことがあるかもしれませんが、
コントローラー層(アプリケーション層)ではよほど致命的なエラーでない限り、何らかの結果を生成してユーザーや関連システムにレスポンスを返さなければなりません。
必ず自分のところで処理しなければならないとすれば、例外は制御フローをわかりづらくするという問題しか残りません。

継続モナドを Cont[Future[Result], A] の形で使うことで、処理の途中でエラーが起きたとしても例外を使わずにエラーの値を扱うことができます。
これがコントローラーでFutureと継続モナドを組み合わせて使う理由です。

継続モナドを使ったActionの書き方

それでは継続モナドを使ったActionの書き方の解説をしてきたいと思います。
ここではためしに簡単な継続モナドを作ってみましょう。

Formを使ってリクエストから値を取り出す

Formを使ってリクエストから値を取り出す例を考えてみます。Formは以下のようなものを考えます。

case class AuthParam(name: String, password: String)

val authParamForm = Form(
  mapping(
    "name" -> text,
    "password" -> text
  )(AuthParam.apply)(_ => None)
)

Requestから AuthParam を取り出すFormです。

それに対して継続モナドを作るメソッドは以下のようになります。

def authParamCont(request: Request[AnyContent]): ActionCont[AuthParam] =
  Cont((f: AuthParam => Future[Result]) =>
    authParamForm.bindFromRequest()(request).fold(
      // 値が正常に取得できなかった場合はBadRequestにする
      error => Future.successful(BadRequest),
      // 正常に値が取れば場合は後続処理にAuthParamを渡す
      authParam => f(authParam)
    )
  )

まず全体の authParamCont メソッドを見てみますと、引数は Request[AnyContent] で返り値は ActionCont[AuthParam] です。
Requestから AuthParam を取り出す継続モナドというわけです。

次に authParamCont メソッドの中身を見ますと Cont がいきなりあるだけです。
継続モナドの説明で Cont コンストラクタに与える run 関数を作ることで継続モナドを作ると説明しました。
つまり、Cont コンストラクタにどんな関数が渡されるかが重要なわけです。

それで関数の中身を見ますとまず f: AuthParam => Future[Result] という後続の計算を受け取っています。
最初のほうに説明した case class Cont[R, A](run: (A => R) => R)A => R に対応しています。

その後は普通に FormbindFromRequest メソッドを使って Request から AuthParam を取り出しています。
エラーの場合は Future.successful(BadRequest) でHTTPステータスコード400のレスポンスを作ります。

そして注目していただきたいのが、正しく AuthParam を取り出せた場合です。最初に受け取った後続の計算を表す fAuthParam を渡しています。
fAuthParam => Future[Result] だったので AuthParam を渡すことでこのモナドの計算が最後までおこなわれることになります。
これでRequestからFormを使って AuthParam を取り出す継続モナドが完成しました。

基本的にはこのようなやり方で継続モナドを作成しますが、細かいところを変化させることで色々な動作が可能になります。
たとえば、上記コードの Future.successful(BadRequest)Future.failed するとActionCont全体がエラーになり、
あとでActionContを実行したときにエラーの値に応じてどんなレスポンスにするか選択することができるようになります。

他には、レスポンスヘッダを追加したいなど場合は上記コードの f(authParam)f(authParam).flatMap(_.withHeaders(???)) のような形に変えるとレスポンスを加工することができます。

ちなみに上記の authParamCont メソッドを短く書くと、

def authParamCont(request: Request[AnyContent]): ActionCont[AuthParam] =
  Cont(authParamForm.bindFromRequest()(request).fold(_ => Future.successful(BadRequest), _))

となります。
サンプルコードの hexx/action-cont ではこんなコードばかりなのでちょっと読みづらいかもしれません…。

継続モナドの合成

上記のような継続モナドが既にいくつかあるとしましょう。

  • 上記の authParamCont: Request[AnyContent] => ActionCont[AuthParam]
  • CORS用のヘッダを付ける corsCont: Request[AnyContent] => ActionCont[Unit]
  • ログイン処理をおこないレスポンスにセッション情報を付ける loginCont: AuthParam => ActionCont[User]

の三つのActionContがあるとします。
そして、ユーザのデータ型の User もあるとします。

case class User(id: Int, name: String)

これらの継続モナドを合成してログイン処理をおこなう継続モナドを作成してみましょう。
と言っても、継続モナドはモナドなので、いつものようにfor構文を使うだけです。

def combinedCont(request: Request[AnyContent]): ActionCont[User] =
  for {
    _ <- corsCont(request)
    authParam <- authParamCont(request)
    user <- loginCont(authParam)
  } yield user

これでCORS用の処理をおこない、リクエストからFormを使ってパラメータを取り出し、ログイン処理をおこない User を返す継続モナドを合成することができました。

いつ見てもモナド合成処理は楽しいですね!

Actionの作り方

それではいよいよこの ActionCont からPlayのActionを作ります。
と言っても最後も簡単です。コードを見てみましょう。

def login = Action.async { request =>

  val cont: ActionCont[Result] = for {
    user <- loginCont(request)
  } yield Ok(Json.obj("id" -> user.id))

  cont.run(Future.successful)
}

継続モナドの仕上げに Ok のレスポンスを作成しています。
そして、最後にできあがった run 関数に Future.successful を渡しています。
計算が Result まで進んでいるので、あとは Future にするだけなのです。
最後に全体を Action.async に渡せばActionの完成になります。

これで継続モナドを使ったActionの書き方を一通り説明しました。
慣れると簡単に継続モナドを作り、合成し、Actionを作れるようになります。

エラー処理も含めた処理の流れを継続モナドで記述する

ここまで継続モナドによる基本的なActionの作り方を見てきましたが、エラー処理については述べられませんでした。
とりあえず上記のコードの最後で run 関数を実行して得られた Future[Result] に対してFutureの recover メソッドを使えばエラー時のレスポンスを作ることができます。
これだけでもエラー処理を一箇所にまとめられるという利点はあります。

しかし、これではエラー時のレスポンスは継続モナドの外側になってしまいます。
たとえば、せっかく入れたCORS用の処理はエラーのレスポンスには含まれないことになってしまいます。
このようにコントローラーの処理では、正常系でも異常系でも共通しておこないたい処理もあるのではないでしょうか。
そこでエラー処理を組み込んだ継続モナドを作成してみます。

まず ActionContrecover メソッドを追加します。

object ActionCont {
  def recover[A](actionCont: ActionCont[A])(pf: PartialFunction[Throwable, Future[Result]])(implicit executor: ExecutionContext): ActionCont[A] =
    ActionCont(f => actionCont.run(f).recoverWith(pf))
}

このメソッドは継続モナドの中で FuturerecoverWith をおこなう処理です。
これで ActionCont の結果がエラーだった場合にも継続モナドの中でエラー用のレスポンスを作成できるようになりました。

次に正常系と異常系で共通に適用される処理、正常系だけの処理、異常系だけの処理を組み合わせて、全体の処理の流れを記述したテンプレートになる継続モナドを作成してみます。

FlowCont.scala
// コントローラーの処理の流れを記述した継続モナド
object FlowCont {
  def apply[WholeRequestContext, NormalRequestContext](
    request: Request[AnyContent],
    wholeCont: Request[AnyContent] => ActionCont[WholeRequestContext],
    normalCont: WholeRequestContext => ActionCont[NormalRequestContext],
    handlerCont: NormalRequestContext => ActionCont[Result],
    errorCont: WholeRequestContext => Throwable => ActionCont[Result])
    (implicit executionContext: ExecutionContext): ActionCont[Result] = {

    for {
      // 正常系と異常系共通で適用される処理
      wholeRequestContext <- wholeCont(request)
      wholeResult <- ActionCont.recover(
        for {
          // 正常系だけで適用される処理
          normalRequestContext <- normalCont(wholeRequestContext)
          // コントローラーの処理本体
          result <- handlerCont(normalRequestContext)
        } yield result) {
          // 異常系の処理
          case e => errorCont(wholeRequestContext)(e).run(Future.successful)
        }
    } yield wholeResult
  }
}

WholeRequestContextNormalRequestContext を型変数として受け取ることで、色々な継続モナドを受け取れるようにします。
そして以下のような継続モナドを引数として受け取り、処理の流れを組み立てます。

  • 正常系と異常系共通で適用される継続モナド wholeCont
  • 正常系だけに適用される継続モナド normalCont
  • コントローラーの処理本体の継続モナド handlerCont
  • 異常系の処理を記述した継続モナド errorCont

あとはこれらの継続モナドと、先程作成した ActionCont.recover を組み合わせれば処理の流れを記述できます。
ここで重要なのはエラー処理がfor文の中に含まれていることです。
これにより wholeCont の処理が errorCont の結果にも適用されるようになります。

この FlowCont を使って先ほどのログイン処理を記述すると、以下のようになります。

def login = Action.async { request =>
  FlowCont(
    request     = request,
    wholeCont   = corsCont,
    normalCont  = (_: Unit) => authParamCont(request),
    handlerCont = loginCont(_: AuthParam).map(user => Ok(Json.obj("id" -> user.id))),
    errorCont   = (_: Unit) => ((_: Throwable) match {
      case e: FormErrorException     => BadRequest
      case e: UserNotFoundException  => NotFound
      case _                         => InternalServerError
    }).andThen(ActionCont.successful)
  ).run(Future.successful)
}

corsContwholeCont に渡していますので、先ほどと違い、エラー処理の場合でもCORSの処理が適用されるようになりました。

ScalazのContTを使う

継続モナドを使ってPlayのコントローラーを書くというアイデアのプロトタイプ実装の時点では、上記のように簡単な自作の継続モナドを使っていたのですが、
例によって、あの方に社内チャットで「それScalazにあるよ」といつものScalazハラスメントを受けまして、Scalazの継続モナドを使うことになりました。

と言ってもScalazのContTはおそらくScalazの中では簡単なほうでしょう。
使い方もほとんど自作のものと変わりません。

ActionCont は 普通にContTとScalaのFutureを組み合わせるだけです。
それに加えてfor文の中でのパターンマッチを使うためにimplicit classで withFilter メソッドも追加しておくと便利でしょう。

package.scala
import play.api.mvc.Result
import scala.concurrent.Future
import scalaz.ContT

package object cont {
  type ActionCont[A] = ContT[Future, Result, A]

  implicit class ActionContWithFilter[A](val actionCont: ActionCont[A]) extends AnyVal {
    def withFilter(f: A => Boolean): ActionCont[A] =
      ActionCont(k =>
        actionCont.run(a =>
          if (f(a)) {
            k(a)
          } else {
            throw new NoSuchElementException("ActionCont must not fail to filter.")
          }
        )
      )
}

objectのActionContのほうはScalazに既にある IndexedContsTInstancesIndexedContsTFunctions を継承しておきます。
Scalaz 7.1以降にはScalaのFutureの型クラスインスタンス群が定義されているので、これでほとんど動作します。
(Scalaz 7.0向けにはscalaz-contribにFutureの型クラスインスタンスがあるらしいです。
scalaz-contrib/Future.scala at v0.1.5 · typelevel/scalaz-contrib
吉田さんに教えていただきました)

ActionCont.scala
object ActionCont extends IndexedContsTInstances with IndexedContsTFunctions {
  // 中身は自作版のContと同じなので省略
}

また ScalazのContTには run_ という便利メソッドが追加されています。

IndexedContsT.scala
def run_(implicit W: Applicative[W], M: Applicative[M], ev: A =:= O): M[R] = 
  run(W.point(x => M.point(x)))

中のモナドのpointを適用して実行してくれるだけですが、たとえば
actionCont.run(Future.successful)actionCont.run_ と書けるようになります。
継続モナドでは最後に id 関数を与えてrunするのがよくある動作なので、こういう便利メソッドが定義されているのでしょう。

それと継続と言えばcallCCですが、このActionContでも全体をfailedにするのではなく、途中だけ処理を脱出したい場合に使えると思います。
しかし、Scalaの継続モナドのcallCCは型推論ができないらしく、常に型指定しなければならない点がわずらわしいです。
今回は継続モナドの中でFutureをrecoverできるようにしたので、途中脱出でもその方法を使うことができるでしょう。

まとめ

以上、継続モナドを使ってPlay FrameworkのActionを組み立てるという解説をしました。
最後のFlowContの話とScalazの話はちょっと難易度が高めだったかもしれませんが、全体としてはActionContの作り方と合成の便利さを理解していただければ十分です。

継続モナドを使う手法で、個人的に重要だと考えている点は、個々の部品の書きやすさ、組み立てやすさ、エラー処理の流れのわかりやすさもありますが、継続モナドを使う側のコントローラーのカスタマイズ性の高さがあると思います。
コントローラー共通のスーパークラスを持つようなやり方に比べて、モナドを組み立てる手法はフレームワークに縛られるようなところが少なく、
個々のコントローラーでリクエストやレスポンスをちょっといじりたい場合も、継承を使う方法に比べて全体への影響が少なくできます。

また上記でFlowContというのフレームワークのようなものも導入しましたが、個々の部品が継続モナドでできているので拡張性が高いです。
また継続モナドは記述力が高いのでFlowCont全体は30行弱ほどでできています。
このFlowCont自体を変更したい場合でも、この程度のコード量なら類似品を作るにしても大してコストがかからないでしょう。

このように関数型を用いる手法は、従来の継承によるフレームワークなどに比べて、コード量が小さくなり、部品化され、カスタマイズ性が高くなり、処理の主導権が個々の実行者に委ねられるようになります。
このことは開発スピードの向上、多用な仕様への追従する柔軟性、デグレードに対する堅牢さに大きく貢献すると考えています。

Scalaの普及のおかげで関数型の手法を実用的な実装に活用できる機会が飛躍的に増えました。
とは言え、まだまだ関数型の手法に面喰らう人も多いと思います。
このドキュメントが関数型を実戦で使うための一助になればと思います。

pab_tech
dwango
Born in the net, Connected by the net.
https://dwango.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
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  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
ユーザーは見つかりませんでした