SE(たぶん)の雑感記

一応SEやっている筆者の思ったことを書き連ねます。会計学もやってたので、両方を生かした記事を書きたいと考えています。 でもテーマが定まってない感がすごい。

PlayFrameworkのFormクラス周りの作りを読んだ

PlayFrameworkには、デフォルトでフォームを作る機能があります。

フォームについて、html部分とそれ以外について、説明してみます。

前説:PlayFrameworkのHTML描画について

まずは前提で、PlayFrameworkではどうやってHTMLを描画するのか、触れておきます。

一般的なMVCパターンと同様、Controllerがリクエストを受け取って、必要なModelを構築し、それをViewに渡す、という方式です。

Modelはなんでもよいですが、PythonDjangoのように既定の扱いやすいModelがあるわけではないです。なのでそこの詳細は今回は割愛します。

Viewのテンプレートエンジンは、既定ではTwirlと呼ばれるものを使っています。ASP.NET Razorからインスパイアされたそうです*1。基本はcontrollerから描画用のクラス(Model)を受け取って、それをテンプレート上で参照しながら描画します。

フォームについて

HTMLのFormがどういう形式で送信されるのかは、MDNに説明があります。

developer.mozilla.org

要するに、単なる文字列です。型付の言語上のプログラムでフォームデータをよしなに扱うには、この単なる文字列を型付のクラスに変換する必要があります。フォームを作る側としては、定義を書いたら変換してくれるのが望ましいです。

生成

フォームの生成は、こういう書き方ができます。データ型は適当です。

import play.api.data._
import play.api.data.Forms._

case class InquiryData(
  name: String,
  tel: String,
  email: String,
  reason: Int,
)

object InquiryData {

  val inquiryForm = Form(
    mapping(
      "name"   -> nonEmptyText,
      "tel"    -> nonEmptyText,
      "email"  -> email,
      "reason" -> number
    )(InquiryData.apply)(InquiryData.unapply)
  )
}

これだけ書いておけば、あとはController側で変換メソッドなどを呼び出せばおおむねそのまま使えます。

フォームデータの取り出し

POSTなりGETリクエストなりでフォーム入力データをControllerで受け取ったものとすると、form.bindFromRequestメソッドを使うと、フォームデータを変換した後の結果を取得できます。

フォーム関連のクラスについて

どういうクラスが使われているか書きます。

フォームと紐づくクラス

自分で定義したクラスと、フォームを紐づけられます。この定義はいろいろありますが、上記の例では単なるcase classにしています。case classが良い理由は後述します。

case class InquiryData(
  name: String,
  tel: String,
  email: String,
  reason: Int,
)

フォームで入力してほしいデータと一対一で紐づくのが、最も楽な定義です。

Form[T]

フォームは、オブジェクトとして定義します。そのフォームをControllerからViewに渡して、テンプレートで描画できます。それらを楽にできるように、PlayFrameworkにはFormクラスが用意されています。play.api.data.Formにあります。

Tは、フォームのデータと紐づけたいクラスを指定します。

case class Form[T](mapping: Mapping[T], data: Map[String, String], errors: Seq[FormError], value: Option[T]) {
  //
}

上はインスタンス生成のコンストラクタで、コンパニオンオブジェクトに用意されている生成はもっと簡単です。

   *
   * @param mapping the form mapping
   * @return a form definition
   */
  def apply[T](mapping: Mapping[T]): Form[T] = Form(mapping, Map.empty, Nil, None)

第一引数のmappingでフォームの入力要素と、実際どのクラスにマッピングされるのか定義します。

前者のcase classは、POSTリクエストなどで受け付けたbodyを解釈した結果を、PlayFramework側で解釈した結果を詰めるとき*2に、ライブラリが使用します。後者は定義だけ決めたフォームを生成するのに使います。普段は後者だけ気にしておけばよいです。

Mapping[T]

Mapping[T]というのは、フォームの各入力項目の定義です。入力項目ごとに名前と制約を指定します。生成は、play.api.data.Formsにあるmappingメソッドを使います。22個ほど定義があります。引数の数が22個までという制約なので、対応メソッドが一つ一つ書かれています。

実体はObjectMapping1~22というクラスになっています。

playframework/ObjectMappings.scala at master · playframework/playframework · GitHub

圧巻です。ObjectMappingの各数値すべてMapping[T]をextendsしています。これで画一的に扱えるようになっています。

Mapping[T]の定義の面白いところは、Mapping自体をネストさせられる点だと思います。Formを生成する際の

  val inquiryForm = Form(
    mapping(
      "name"   -> nonEmptyText,
      "tel"    -> nonEmptyText,
      "email"  -> email,
      "reason" -> number
    )(InquiryData.apply)(InquiryData.unapply)
  )

というようなソースのMapping[T]生成部分

    mapping(
      "name"   -> nonEmptyText,
      // 以下省略

ですが、nameの右辺nonEmptyText自体もMapping[T]です。正確にはこの場合Mapping[String]となっています。インスタンス生成後の型イメージを書くと、

Form[InquiryData]
  - Mapping[InquiryData]
    - "name"   -> Mapping[String]
    - "tel"    -> Mapping[String]
    - "email"  -> Mapping[String]
    - "reason" -> Mapping[Int]

となっています。

もうちょっと追ってみます。Mapping[String]とはなんでしょうか?まずはnonEmptyTextの定義から。

val nonEmptyText: Mapping[String] = text.verifying(Constraints.nonEmpty)

先ほど書いた通り、Mapping[String]を返します。text

val text: Mapping[String] = of[String]

であり、of[String]

def of[T](implicit binder: Formatter[T]): FieldMapping[T] = FieldMapping[T]()(binder)

です。Mapping[T]は、具象クラスとしてObjectMappingFieldMappingがある、ということが分かります。要するにCompositeパターンです。

Formatter[T]

of[T]のimplicit parameterであるbinder: Formatter[T]ですが、見るからにフィールド型とMapping[T]の変換用クラスを挿入しろ、という感じです。お手本のように綺麗なStrategyパターンですね*3

もちろん、デフォルト変換は定義されています。

playframework/Format.scala at master · playframework/playframework · GitHub

ここに無いもの、例えば独自定義Enumのようなものを変換したい場合は、フォームから参照できる場所にimplicitな関数を定義すれば挿入されます。また、デフォルトのimplicitを別のものに差し替えたい場合も同様に、独自定義すればよいです。

Form[T]やMapping[T]のT

型パラメータのTですが、これはcase classを使っておくのが楽ですし、サンプルも大抵そうなっています。これはmappingの最も簡単な定義が

def mapping[R, A1](a1: (String, Mapping[A1]))(apply: Function1[A1, R])(unapply: Function1[R, Option[(A1)]]): Mapping[R] = {
  new ObjectMapping1(apply, unapply, a1)
}

となっており、case classで自動的に付与されるapplyunapplyをそのまま渡せるためです。

フォームで入力するデータとControllerで使う型のフィールド定義をどうしても別にしたかったら、applyunapplyを独自定義して渡すこともできます。そしてメソッド名は何でもよいです。型が一致していればよく、case classである必要もありません。

Function1[A1, R]:型A1を引数で受け取って型Rを返す関数

Function1[R, Option[(A1)]]:型Rを引数で受け取って型Option[(A1)]を返す関数(Optionの中身はTuple)

applyは、入力データ(Map)を結果クラスに変換(クラスに束縛する、のほうがしっくりくる)する際に使用されます。unapplyは、クラスからMapへ変換(解放)する際に使用されます。

余談ですが、bindの戻り値はEither[Seq[FormError], R]です。束縛に失敗した場合、エラー原因を返すための定義です。unbindの戻り値はMap[String, String]で、unapplyNoneを返した場合は空のMapを返します。

おわりに

急に終わる感じですが…

本当は、テンプレート側でFormFieldを描画する部分も書きたかったのですが、分量が多くなりすぎるので止めました。フォームデータ変換部分、単純に書けるようによく考えられていました。すごい。

次回

この続きで、テンプレート側でFormFieldをどうやって描画しているのか書きたいと思っています。

*1:モデルは何でもよい、テンプレート上でプログラミング言語に基づいた記述ができる点は一緒

*2:主に、Form.bindFromRequestの結果を追うとわかる

*3:implicit parameterはstrategyパターンで使う場合に綺麗にはまる、とScalaMatsuriでも話があった