Play 2.x の QueryStringBindable, PathBindable について

よしださんのこんなツイートを見たので、
https://twitter.com/xuwei_k/status/302445064976732160


Play 2.x ではリクエストの処理をどのコントローラーのどのアクションに任せるか、 conf/routes というファイルで定義します。そのとき、パスやクエリストリングに含まれる文字列を引数としてアクションに渡すことができます。


アクションに渡すときはパスやクエリストリングはアクションの引数の形に変換する必要がありますが、その変換ルールを定めているのが PathBindable[T], QueryStringBindable[T] といった implicit object です。conf/routes では例えば Int などはではデフォルトで変換してくれます。これは QueryStringBindable[Int] を Play 側が定義してくれているからです。

例: 日付

パスやクエリストリングの文字列から日付を判別する、なんてことはよくあると思います。

これを普通にやると

routes は

GET     /bar                        controllers.Application.bar(date: String)


アクションは

// import joda-time

  def bar(dateString: String) = Action {
    try {
      val format = "yyyyMMdd"
      val date = DateTimeFormat.forPattern(format).parseLocalDate(dateString)
      Ok((date + 7.days).toString)
    } catch {
      case e: IllegalArgumentException => BadRequest("invalid parameters")
    }
  }

のようになります。
やっていることは単純に 7 日後の日付の表示ですが、処理のほとんどが渡された文字列のパースでちょっと残念です。


これを QueryStringBindableを使って書き直してみます。


joda-time の LocalDate のために、QueryStringBindable[LocalDate] を定義したいところですが、ライブラリが提供する既存の型についてはどこに implicit object を定義すれば良いのかわからない、っていうかできなそうなので、QueryStringLocalDate という LocalDate をラップする型を準備しました。

追記
追加できないと思ってたんですが、できました。See コメント欄。

object Implicits {

  implicit def queryStringLocalDateBinder = new QueryStringBindable[LocalDate] {

    val format = "yyyyMMdd"

    override def bind(key: String, params: Map[String, Seq[String]]): Option[Either[String, LocalDate]] = {
      params(key).headOption map { dateString =>
        try {
          Right(DateTimeFormat.forPattern(format).parseLocalDate(dateString))
        } catch {
          case e: IllegalArgumentException => Left("Failed to parse query string as LocalDate.")
        }
      }
    }
    override def unbind(key: String, localDate: LocalDate): String = {
      import java.net.URLEncoder
      URLEncoder.encode(key, "utf-8") + "&" + URLEncoder.encode(localDate.toString(format), "utf-8")
    }
  }
}

Implicits オブジェクト内に QueryStringBindable[LocaDate] を定義しました。
そしてその中で QueryStringBindable#bind と QueryStringBindable#unbind を定義しています。
ちょっと難しそうですが、心を落ち着かせて見ましょう。#bind がリクエストルーティングの際のクエリストリングから LocalDate への変換ルールで、unbind がリバースルーティングで使うための LocalDate からクエリストリングへの変換ルールです。

次に Build.scala ちょっといじります。
routesImport というのは Play が routes をコンパイルScala のコードに変換する際に使用します。

  val main = play.Project(appName, appVersion, appDependencies).settings(
    routesImport += "controllers.Implicits._"
  )

なお、この設定は自分で定義したケースクラスなどのの場合は implicit object をそのコンパニオンオブジェクトに定義して使うことができるので必要ありません。今回は org.joda.time.LocalDate という既存の型に対し定義した QueryStringBindable を routes がコンパイルされて生成されたコードの中で利用できるようにするためにこのような設定が必要になっています。*1
ここらへんの仕組みはややこしいので、ちゃんと理解したい人は RoutesCompiler のコードや、target/../src_managed ディレクトリにある routes がコンパイルされて生成された Scala のコードを見ると良いです。


QueryStringBindable[LocalDate] を定義したので LocalDate が conf/routes で使用できるようになりました。

GET     /foo                        controllers.Application.foo(date: controllers.LocalDate)

おかげでアクションはここまですっきりします。

  def foo(date: LocalDate) = Action {
    Ok((date + 7.days).toString)
  }


リバースルーティングも使えます。

scala> controllers.routes.Application.foo(new org.joda.time.LocalDate(2013, 2, 16))
res7: play.api.mvc.Call = /foo?date&20130216


PathBindable を使った例については割愛しますが、だいたい同じように使います。

使いどころ

日付に限らず、「クエリストリングからオブジェクトへの変換処理」が必要なところでは QueryStringBindable, PathBindable が便利に使えると思います。
こういうの Play に限らずフレームワークでは一般的によくある要求ですが、Play だと implicit object を使った拡張という方法をとるっていうのがミソでしょうか。


他の例を探すと、例えば文字列からなにか列挙型のようなものに変換するとか。
以下のコードはテキトーですが、「なんだかQueryStringBindableを使って書き直したくなるコード」

def change(statusString: String) = Action {
   statusString match {
       case "start" => RUNNING
       case "restart" => RUNNING
       case "pending" => PENDING
       case _ => throw new Exception()
   }
   ...
}


Play の scaladoc だとページング処理のための Pager オブジェクト

case class Pager(index: Int, size: Int)

に対して、QueryStringBindable[Pager] を定義する例が載っています。


使う場所が1カ所ならむしろコード量増えてるし使わなくてもいいかとも思いますが、何カ所もクエリストリングからオブジェクトへの変換処理を書いていることに気づいたら使ってみると良いかと思います。関心事が分離されて可読性もあがりますね。

*1:ダサいですね