LiftでWebアプリ(4): フォーム用共通処理を作る 1
フォームの書き方は複数ある。
http://stable.simply.liftweb.net/#toc-Chapter-4
LiftScreenを使ったフォームの自動生成は、HTMLを編集しにくいので却下。
手軽そうなStatefulSnippetを使ってみる。
サンプルに上がっているようなprocess関数でのエラーチェックは使いにくいのでListScreenのように変数自体にバリデーターを割り当てる方式でいく。
まずはWicketのようにrawInputと指定した型の変数を保持するtraitを作る。
trait FilterValidator extends StatefulSnippet { trait ValueWithFilterValidator[T] { /* * 型に応じて変換メソッドをサブクラスで実装する */ protected def convertToString: T => String protected def convertFromString: String => T /** * Internal Value */ protected var _value: Box[T] = Empty /** * SHtmlなどに渡すゲッター */ def get: String = rawInput /** * SHtmlなどに渡すセッター */ protected def set(a: String) { rawInput = a ... // ここでフィルタやバリデータやコンバータなどを使って… ... _value = a2 } /** * フィルタを設定する */ def |(fv: String => String): ValueWithFilterValidator[T] = { this } def filter(fv: String => String): ValueWithFilterValidator[T] = |(fv) /** * バリデータを設定する */ def <<(f: T => Box[String]): ValueWithFilterValidator[T] = { this } def validator(f: T => Box[String]): ValueWithFilterValidator[T] = <<(f) } implicit def fromValue(a: String) = new ValueWithFilterValidator[String] { override protected def convertToString: (String) => String = a => a override protected def convertFromString: (String) => String = a => a } implicit def fromValue(a: Int) = new ValueWithFilterValidator[Int] { override protected def convertToString: (Int) => String = (in: Int) => in.toString override protected def convertFromString: String => Int = (in:String) => in.toInt } // template method override def dispatch = { case "render" => { hasErrors = false render } case _ => (a: NodeSeq) => a } def render: NodeSeq => NodeSeq def onSubmit() { if (hasErrors) onError() else onSuccess() } def onSuccess() def onError() = () }
StatefulSnippetを継承して、エラーがなければonSuccessを呼び出す。
フォームの値は型に応じてimplicitで変換する。
利用するクラスでは次のように書きたい。
class MyForm extends FilterValidator { //def dispatch: DispatchIt = { case "render" => render } はtraitで定義されている private val name1 = "" | trim private val name2 = "" << { case "bad" => Full("not accept [bad]") case _ => Empty } | (_.replace("a", "b")) private val num1 = 1 << min(2, "num1 min error.") << max(5, "num1 max error.") | (trim) required override def render = { ... } override def onSuccess() = { ... }
演算子の記号は考える余地があるが、LiftScreen.scalaに定義されている<*>や^/よりかは見やすいかと。
ブラウザでアクセスした時の処理の流れは以下のようになっている。
- 初回アクセス
- dispatch が呼び出される("render" 以外は渡ってこない?以下renderとする)
- renderで設定したget系関数が呼び出される
- ブラウザ描写
- submitボタンをクリックする
- renderで設定したset系関数が呼び出される
- onSubmit(SHtml.onSubmitUnitで登録した関数)が呼び出される
- renderが呼び出される
- get系関数が呼び出される
- ブラウザ描写
これをふまえて、StringとIntのフィールドが動くまで出来た。
ここから一気にtraitやimplicitなどが出てくる。まだ理解が及ばないので雑なコードだけど取りあえず貼る。
trait FormUtil { trait FilterValidator extends StatefulSnippet { protected def invalidFormat(label: String, input: String):String = label + " is invalid format [" + input + "]." protected def emptyError(label: String):String = label + " is empty." var hasErrors: Boolean = false trait ValueWithFilterValidator[T] { protected var rawInput: String = "" var hasError = false protected val isRequired = false protected var fieldLabel: String = "Field" protected var errorId: Box[String] = Empty protected val stringFilters: List[String => String] = Nil protected val validators: List[T => Box[String]] = Nil //protected val invalidFunc: ((String, String) => String) = invalidFormat // TODO: 型別に対応する /* * 型に応じて変換メソッドをサブクラスで実装する */ protected def convertToString: T => String protected def convertFromString: String => T /** * Internal Value */ protected var _value: Box[T] = Empty protected def htmlError(){ hasError = true hasErrors = true } protected def htmlError(msg: String){ htmlError() errorId match { case Full(a) => S.error(a, msg) case _ => S.error(msg) } } /** * 入力された値があればFull(入力値)を返却する */ def valueBox = _value protected def value_=(a: String) { rawInput = a val a1 = (a /: stringFilters) { (v, f) => f(v) } if (!isRequired && a1.length() == 0){ }else{ if (isRequired && a1.length() == 0){ htmlError(emptyError(fieldLabel)) }else{ convertAndSet() } } def convertAndSet(){ val a2 = tryo{convertFromString(a1)} def checkError(v: T, list: List[T => Box[String]]):Boolean = list match { case Nil => false case f :: fs => f(v) match { case Full(a) => htmlError(a); true case _ => checkError(v, fs) } } a2 match { case Full(a3) => hasError = checkError(a3, validators) case _ => htmlError(invalidFormat(fieldLabel, rawInput)) } _value = a2 } } /** * SHtmlなどに渡すゲッター */ def get: String = rawInput /** * SHtmlなどに渡すセッター */ def set = (in:String) => value_=(in) class CopySelf[T](old: ValueWithFilterValidator[T]) extends ValueWithFilterValidator[T] { errorId = old.errorId fieldLabel = old.fieldLabel hasError = old.hasError rawInput = old.rawInput _value = old._value override protected def convertToString = old.convertToString override protected def convertFromString = old.convertFromString override protected val stringFilters = old.stringFilters override protected val validators = old.validators } /** * idとnameの文字列を指定してSHtml.textを生成する */ def #>(idName: String): CssBindFunc = ("name=" + idName) #> SHtml.text(get, set) & <--(idName) /** * id、nameの文字列とElem生成関数を指定してElem生成する */ def #>(idName: String, f: (String, String => Any, ElemAttr*)=> Elem, attr: ElemAttr*): CssBindFunc = ("name=" + idName) #> f(get, set, attr:_*) & <--(idName) /** * エラーメッセージのIDを設定し、ラベルをHTMLから取得して設定する */ def <--(id: String): CssBind = { errorId = Full(id) ("for="+id) #> {in: NodeSeq => :@(in.text); in} } /** * エラーメッセージで利用するラベルを設定する */ def :@(name: String): ValueWithFilterValidator[T] = { fieldLabel = name this } def labelWith(name: String): ValueWithFilterValidator[T] = :@(name) /** * エラーメッセージのIDを設定する */ def :#(errId: String): ValueWithFilterValidator[T] = { errorId = Full(errId) this } def errorIdWith(errId: String): ValueWithFilterValidator[T] = :#(errId) /** * フィルタを設定する */ def |(fv: String => String): ValueWithFilterValidator[T] = { val old = this fv match { case f: (String => String) => new CopySelf[T](old) { override protected val stringFilters: List[String => String] = f :: old.stringFilters } case _ => htmlError("internal error"); old } } def filter(fv: String => String): ValueWithFilterValidator[T] = |(fv) /** * バリデータを設定する */ def <<(f: T => Box[String]): ValueWithFilterValidator[T] = { val old = this new CopySelf[T](old) { override protected val validators = f :: old.validators } } def validator(f: T => Box[String]): ValueWithFilterValidator[T] = <<(f) def as(w: With) = w match { case Required => mkRequired() } trait WithRequired { self: ValueWithFilterValidator[T] => /** * 入力された値を返却する。 * * onErrorでは私用不可。 * onSuccessでNullPointerExceptionが発生したらバグ */ def value:T = _value.open_! } def required = mkRequired() private def mkRequired() = new CopySelf[T](this) with WithRequired { protected override val isRequired = true } /** * フィールドに変換する */ def asField: ValueWithFilterValidator[T] = this } private sealed trait With private[FilterValidator] case class RequiredCase() extends With val Required = RequiredCase() /** * type=submitのボタンにsubmit関数を登録する */ def #!(): CssBind = "type=submit" #> SHtml.onSubmitUnit(onSubmit) implicit def fromValue(a: String) = new ValueWithFilterValidator[String] { override protected def convertToString: (String) => String = a => a override protected def convertFromString: (String) => String = a => a } implicit def fromValue(a: Int) = new ValueWithFilterValidator[Int] { override protected def convertToString: (Int) => String = (in: Int) => in.toString override protected def convertFromString: String => Int = (in:String) => in.toInt } // Filters def trim: String => String = { case null => null case s => s.trim() } // Validators def min(min: Int, msg: String): Int => Box[String] = { case a if a < min => Full(msg) case _ => Empty } def max(max: Int, msg: String): Int => Box[String] = { case a if a > max => Full(msg) case _ => Empty } // template method override def dispatch = { case "render" => { println("*** render ***: " + hasErrors) hasErrors = false render } case _ => (a: NodeSeq) => a } def render: NodeSeq => NodeSeq def onSubmit() { println("*** onSubmit ***: " + hasErrors) if (hasErrors) onError() else onSuccess() } def onSuccess() def onError() = () } }
やたらHelper的な関数が増えた。varとvalが混ざっていたりCopySelfのようなクラスがあったりRequiredのcase classがイマイチだったりするが、その辺は後で考える。
Requiredがなければ入力値はBoxでしか受け取れないが、Requiredを付けると型変換後の値を直接受け取れるようになる。
受け取りがStringで良いなら失敗は無いので、これも後で考える。
本当は独自演算子は全部 | にしてパイプのように繋げたかったんだけど、そうすると型とmatchの省略ができなくなって利用側で型を書く必要が出てくるので諦めた。
演算子の優先順位があるので、バリデータ、フィルタ、as Requiredの順に書かないといけない。右から入力値が来るイメージで。
利用するページでは FormUtil._をimportして
trait CustomFilterValidator { override def invalidFormat(label: String, input: String) = label + "に入力された値 " + input + " が数値ではありません。" override def emptyError(label: String) = label + " は入力必須です。" } class MyForm extends FilterValidator with CustomFilterValidator { private val name = "" :# "errorFieldId" | trim private val name1 = "".errorIdWith("errorFieldId1").filter(trim).validator{ case "b" => Full("err") // all error } private val name2 = "" :# "name2" << { case "b" => Full("not accept b") case _ => Empty } | (_.replace("a", "b")) private val num2 = 1 :# "num2" << min(2, "num2 min error ") << max(5, "num2 max error ") | trim private val num3 = 1 :# "num3" << min(2, "num3 min error ") << max(5, "num3 max error ") | trim as Required override def render = num2 <-- "num2" & num3 <-- "num3" & "name=name" #> SHtml.text(name.get, name.set) & "name=name2" #> SHtml.text(name2.get, name2.set) & "name=num2" #> SHtml.text(num2.get, num2.set) & "name=num3" #> SHtml.text(num3.get, num3.set) & "type=submit" #> SHtml.onSubmitUnit(onSubmit) override def onSuccess() = { S.notice("ON SUCCESS!!") println("hasErrors: " + hasErrors) println(num3.value) } override def onError() = { S.error("ON ERROR!!") println("ON ERROR!! num2 error:" + num2.hasError) } }
このように書ける。
途中まで書いていて、エラーメッセージで利用するラベルは誰が決める?という疑問がふと出てきた。
そこで <-- メソッドを使ってHTMLからラベルを取得するようにした。ここまできたら、もう #> メソッドも独自に実装したらいいんじゃないか?ということで #> メソッドも追加した。これを使って最終的には
class LoginForm extends FilterValidator { private val mail = "" << { case a if (a.matches("[a-zA-Z0-9._+-]+@([a-zA-Z0-9]+\\.)+[a-zA-Z]{2,6}")) => Empty case _ => Full("メールアドレスが正しくありません。") } | (trim) as Required private val password = "" as Required override def render = mail #> "mail" & password #> ("password", SHtml.password) & #! override def onSuccess(){ println(mail.value) println(password.value) } }
<html> <body> <form class="lift:LoginForm?form=post"> <label for="mail">メールアドレス</label>: <input type="text" name="mail" id="mail" value=""> <span class="lift:msg?id=mail"></span> <br> <label for="password">パスワード</label>: <input name="password" type="password" id="password" value=""> <span class="lift:msg?id=password"></span> <br> <input type="submit"> </form> </body> </html>
こんな感じに。エラーメッセージ用のidやラベルの取得は、名前を揃えると自動化される。
ここまで作ると、ステートフルの意味はあまり無い気がしてきた。逆に余計なデータをセッションに溜め込むので非効率になる気がする。
リファクタリングも兼ねて、onSubmit方式のフォームをベースにもう一度作ろうか…。
その他メモ。
require を後置演算子っぽくしようとしたらエディタ警告が出た。
http://twitter.com/#!/odersky/status/49882758968905728
それで一時的にcase classに。
JRebelを使わずに SBTで ~jetty-run を使って何度もリロードすると
java.lang.OutOfMemoryError: PermGen space
のエラーが出るが、解決方法はよくわからない。
オプションで XX:+CMSClassUnloadingEnabled -XX:+CMSPermGenSweepingEnabled を指定してもダメみたい。
もしかしたらJRebelでも長時間起動していたら同じ現象になるのかもしれない。