Play2.0でパラメータを型安全に受け取る
アクションに渡すときはパスやクエリストリングはアクションの引数の形に変換する必要がありますが、その変換ルールを定めているのが PathBindable[T], QueryStringBindable[T] といった implicit object です。conf/routes では例えば Int などはではデフォルトで変換してくれます。これは QueryStringBindable[Int] を Play 側が定義してくれているからです。
Play 2.x の QueryStringBindable, PathBindable について - tototoshi の日記
これはいいですね。
とはいえ個人的にはパラメータのIDをIntやLongで受け取るだけでは不十分で、折角なんでもうちょっと変換してしまいたい。
例えば、computer-databaseのサンプルで、コンピュータの一覧から一つを選択して編集するところは次のようになっている。
ルーティング部分[ソース]:
# Edit existing computer GET /computers/:id controllers.Application.edit(id:Long)
HTMLテンプレートでリンクを表示する箇所[ソース]:
@computers.map { case (computer, company) => { <tr> <td><a href="@routes.Application.edit(computer.id.get)">@computer.name</a></td> <td> ...
/** * Display the 'edit form' of a existing Computer. * * @param id Id of the computer to edit */ def edit(id: Long) = Action { Computer.findById(id).map { computer => Ok(html.editForm(id, computerForm.fill(computer))) }.getOrElse(NotFound) }
このアクションはComputerのidしか取らないのに、パラメータをLongで受けてComputer.findByIdで自分で変換している。また、テンプレートの方ではcomputer.id.getでidをLongで取得して使用している。折角単一のルールからルーティング/リバースルーティングしているのに、二カ所ともLongを経由することで誤った型のデータを渡してしまう危険性がある。
id: Longの代わりにモデルのデータ型で受け取る
そこで、PathBindableを使ってComputer型で受け取れるようにするよ。*1
まず、参照先を真似してBuild.scalaにroutesImportを追加
val main = PlayProject(appName, appVersion, appDependencies, mainLang = SCALA).settings( routesImport += "controllers.Implicits._" )
Implicitsに、Computer用のPathBindableを定義
package controllers import models.Computer import play.api.mvc.PathBindable object Implicits { implicit object bindableComputer extends PathBindable[Computer] { def bind(key: String, value: String): Either[String, Computer] = { try { Computer.findById(value.toLong).toRight("Computer not found for parameter %s: %s".format(key, value)) } catch { case e: Exception => Left("Cannot parse parameter %s as Computer: %s".format(key, e.getMessage)) } } def unbind(key: String, value: Computer): String = value.id.toString() } }
すると、routes以下は次のように書ける
ルーティング(routes):
... # Edit existing computer GET /computers/:computer controllers.Application.edit(computer: models.Computer) ...
HTMLテンプレート(list.scala.html):
... @computers.map { case (computer, company) => { <tr> <td><a href="@routes.Application.edit(computer)">@computer.name</a></td> <td> ...
アクション(Application.scala):
def edit(computer: Computer) = Action {
Ok(html.editForm(computer.id.get, computerForm.fill(computer)))
}
それぞれがかなりすっきりした上に、ルーティング/リバースルーティングが同一の型に対しておこなわれることが保証されるようになった!
但し、存在しないIDを指定した場合に、以前はNot Foundを返していたところがBad requestになってしまうので少し仕様は変わってしまっている。BindがEither[String, A]じゃなくてEither[play.api.mvc.Result, A]とか返せると良いかも。
id: Longとモデルの型の中間的な値で受け取る
アクションは型安全にしたいけど、DBアクセスのタイミングは自分で管理したい、というなら、型パラメータでモデルの型を取れるように中間的な型を作ってラップしてやれば良い。
仮にResourceId[A]型とするよ。
package models trait ResourceId[A] { def encode(key: String): String def decode: Option[A] }
で、ResourceId型とエンコード文字列やモデルの型をマッピングするものを用意する
package models trait ResourceMapper[A] { def fromValue(value: A): ResourceId[A] def fromCode(key: String, code: String): ResourceId[A] } object ResourceMapper { def toId[A: ResourceMapper](value: A) = implicitly[ResourceMapper[A]].fromValue(value) }
Implicitには、モデルの型ごとにResourceMapperを用意してやれば良い
package controllers import models.Computer import play.api.mvc.PathBindable import models.ResourceMapper import models.ResourceId object Implicits { // ResourceId[A]から/に変換するPathBindable // implicitにResourceMapper[A]が必要 implicit def bindableResource[A: ResourceMapper] = new PathBindable[ResourceId[A]] { def bind(key: String, value: String): Either[String, ResourceId[A]] = Right(implicitly[ResourceMapper[A]].fromCode(key, value)) def unbind(key: String, id: ResourceId[A]): String = id.encode(key) } // implicitにResourceMapper[Computer]を用意 implicit object computerResourceMapper extends ResourceMapper[Computer] { def fromValue(value: Computer): ResourceId[Computer] = new ResourceId[Computer] { def encode(key: String): String = value.id.toString() def decode: Option[Computer] = Some(value) } def fromCode(key: String, code: String): ResourceId[Computer] = new ResourceId[Computer] { def encode(key: String): String = code lazy val value_ = { try { Computer.findById(code.toLong) } catch { case e: Exception => None } } def decode = value_ } } }
これを使うとroutes以下各ファイルは次のようになる
ルーティング(routes):
... # Edit existing computer GET /computers/:id controllers.Application.edit(id: models.ResourceId[models.Computer]) ...
HTMLテンプレート(list.scala.html):
@import models.ResourceMapper.toId @import controllers.Implicits._ ... @computers.map { case (computer, company) => { <tr> <td><a href="@routes.Application.edit(toId(computer))">@computer.name</a></td> <td> ...
アクション(Application.scala):
def edit(id: ResourceId[Computer]) = Action {
id.decode.map{ computer =>
Ok(html.editForm(computer.id.get, computerForm.fill(computer)))
}.getOrElse(NotFound)
}
まあ回りくどいけど、ResourceMapperをDBのメタデータから自動で定義出来たりするなら良いかも。
あと、データのコード表現を扱うという点で、フォームの中身をレンダリング/パースする際のMappingやFormatterととても近いと思うので、うまく統合出来ないかと思う。
感想
PathBindableやQueryStringBindableを使ってパラメータを型安全に扱えるのはとても良いと思う。
が、型安全が目的というより、データのコード表現を扱う部分をなるべく無駄なくというのが個人的にはやりたい。過去のエントリで言うとこの辺。
- Play2.0でセレクトボックスにEnumを貼れるようにする - terazzoの日記
- コード値をEnumを使って安全に扱う - terazzoの日記
- コード値の表示をtaglibでおこなう - terazzoの日記
- 折角なので今考えていること - terazzoの日記
例えばデータのリストをハイパーリンクの一覧としてレンダリングし、その一つをユーザがクリックすることでデータを選ぶという場合、大事なのはデータのリストからデータを一つ選ぶということであって、ハイパーリンクのURLの書式・コード表現は主な関心事ではないはずなんだよね。単にたまたまHTMLというメディアの上でデータを識別するのに文字列の形で表現されているにすぎない。
サーバから送信する際にデータを文字列としてエンコードし、選択した文字列が送り返されて来てサーバで受信されるというのは、丁度データのシリアライズ/デシリアライズ(マーシャリング /アンマーシャリング)と考えることが出来る。そう考えると、データのシリアライズの度に毎回シリアライズの形式を考えたりしないのと同じで、URLのパラメータやフォームのコード値やフォーマットを毎回考えなくても良いようにしたい。
あと、今回はサーバ上のデータとWebの関係だったわけだけど、同じことをRDBとサーバ上のプログラムの間でも言いたい。具体的には(自然キーではない)PKやFKをアプリケーションプログラム上から排除したい。DBのIDキーっていうのは、テーブル上でレコードを参照し特定するものなのだが、プログラム上でデータへの参照はフィールドなどでおこなわれているし、データの同一性は比較演算子などで実現されるのでIDをアプリケーションのコード上で直接扱うことは不要だと思う*2。クエリを組み立てる時にDSLを使って例えば「emp1.dep_id eq dep1.id」とかいうのは回りくどいので「emp1.dep eq dep1」みたいに書けるようにしたい。これはそのうちに。
と、ここ数年そういう問題意識でやってきたので「衝撃的なデータベース理論・関手的データモデル 入門 - 檜山正幸のキマイラ飼育記 (はてなBlog)」には大注目ですよ。理解出来ると良いのだけど。
*1:書き終わってから見たらAPIリファレンスにそのものずばりなサンプルが載ってるけど…… cf: http://www.playframework.com/documentation/api/2.1.0/scala/index.html#play.api.mvc.PathBindable