2012年3月31日土曜日

Validator と MessageSource


前回の「ModelAttribute と SessionAttributes」で、単純なフォーム処理の仕掛けを作ったので、今回はバリデーションの機能を追加してみます。

Validator の実装
リファレンス「6.2 Validation using Spring's Validator interface」の例に従って ValidationUtils クラスの rejectIfEmpty メソッドや rejectIfEmptyOrWhitespace メソッドを使えば、未入力フィールドとエラーコードの設定が簡単に行えます。

ただ今回は、未入力だけでなく、文字列のフォーマットまでチェックしたいので、StringUtils クラスの hasLength メソッドと java.util.regex パッケージが提供する Pattern クラス及び Matcher クラスを使うことにします

エラーコード
エラーコードは DefaultMessageCodesResolver に記されたルールに従って作ります。

例えば、今回の場合
  • 未入力によるフィールドエラー : error.field.empty
  • 指定外フォーマットによるフィールドエラー : error.field.illigal
  • フォームレベルで拾ったグローバルエラー : error.form.invalid
のような感じにしてみました。

因みに validation 後の BindingResult の内容を見ると Validator 実装クラス(IdCardValidator)の中で errors.rejectValue(..) を使って設定したエラー情報は、上記のルールに則っていることがわかります。

IdCardValidator.java
package wrider.validator;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.springframework.validation.Validator;
import org.springframework.validation.Errors;
import org.springframework.util.StringUtils;

import wrider.model.IdCard;

public class IdCardValidator implements Validator {
 
 private static final String EMAIL_PATTERN = "^[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)*@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)*(\\.[a-zA-Z]{2,})$";
 private static final String PASSWORD_PATTERN = "^[a-zA-Z0-9]{8,32}$";
 
 private static final String ERROR_FIELD_EMPTY = "error.field.empty";
 private static final String ERROR_FIELD_ILLIGAL = "error.field.illigal";
 private static final String ERROR_FORM_INVALID = "error.form.invalid";
 
 public boolean supports(Class<?> clazz) {
  return IdCard.class.isAssignableFrom(clazz);
 }
 
 public void validate(Object target, Errors errors) {
  
  IdCard idCard = (IdCard)target;
  
  if (!StringUtils.hasLength(idCard.getEmail())) {
   errors.rejectValue("email", ERROR_FIELD_EMPTY);
  }
  else if (!patternMatching(EMAIL_PATTERN, idCard.getEmail())) {
   errors.rejectValue("email", ERROR_FIELD_ILLIGAL);
  }
  
  if (!StringUtils.hasLength(idCard.getPassword())) {
   errors.rejectValue("password", ERROR_FIELD_EMPTY);
  }
  else if (!patternMatching(PASSWORD_PATTERN, idCard.getPassword())) {
   errors.rejectValue("password", ERROR_FIELD_ILLIGAL);
  }
  
  if (errors.hasErrors()) {
   errors.reject(ERROR_FORM_INVALID);
  }
  
 }

 private boolean patternMatching(String patternStr, String targetStr) {
  Pattern pattern = Pattern.compile(patternStr);
  Matcher matcher = pattern.matcher(targetStr);
  return matcher.matches();
 }
}

Validator と messageSource の登録
上記 Validator の実装クラス(idCardValidator)を applicationContext.xml に登録して、コントローラークラスに DI できるようにします。また、メッセージコード(含むエラーコード)を実際のメッセージに解決する messageSource ですが、今回は ReloadableResourceBundleMessageSource を指定しています。尚、classpath: プレフィックスでクラスパス上の場所を指定することもできます。また、プロパティファイルが一つだけのときは、以下のように basenames 要素(複数形)の代わりに basename 要素(単数形)が使えます。

<property name="basename" value="classpath:messages"/>

applicationContext.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:p="http://www.springframework.org/schema/p"
xmlns:tx="http://www.springframework.org/schema/tx"
xmlns:jee="http://www.springframework.org/schema/jee"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:aop="http://www.springframework.org/schema/aop"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx-3.0.xsd
http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-2.5.xsd
http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop-2.5.xsd">

<!-- Validator -->
<bean id="idCardValidator"
class="wrider.validator.IdCardValidator"/>

<!-- Message Source -->
<bean id="messageSource"
class="org.springframework.context.support.ReloadableResourceBundleMessageSource">
<property name="basenames">
<list>
<value>/WEB-INF/messages/errors</value>
</list>
</property>
<property name="defaultEncoding" value="utf-8"/>
<property name="fileEncodings" value="utf-8"/>
<property name="cacheSeconds" value="0"/>
</bean>

</beans>

プロパティファイル
プロパティファイルは、http://java.sun.com/dtd/properties.dtdに従って作った XML 形式のファイル(error.xml)を上記 applicationContext.xml で指示した /WEB-INF/messages/ に置いています。

errors.xml
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
<comment>field level error messages</comment>
<entry key="error.field.empty.idCard.email">必須です</entry>
<entry key="error.field.illigal.idCard.email">内容をお確かめ下さい</entry>
<entry key="error.field.empty.idCard.password">必須です</entry>
<entry key="error.field.illigal.idCard.password">内容をお確かめ下さい</entry>
<entry key="error.form.invalid.idCard">入力ミスがあります</entry>
</properties>

コントローラーへのDI
前回作ったコントローラー(AccountController.java)に、上記 applicationContext.xml で定義した idCardValidator ビーンを @Autowired アノテーションで DI(Dependency Injection)し、validation メソッドで POST されたデータを検証するコードを追加しています(赤字の箇所)。

AccountController.java
package wrider.controller;

import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.validation.Validator;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.SessionAttributes;
import org.springframework.web.servlet.ModelAndView;

import wrider.model.IdCard;

@Controller
@SessionAttributes("idCard")
public class AccountController {

@Autowired
private Validator idCardValidator;

private int testCounter = 0;

@ModelAttribute("idCard")
public IdCard makeIdCard() {
printOrder("makeIdCard");
return new IdCard();
}

@RequestMapping(value="/account/login.html", method=RequestMethod.GET)
public String login() {
printOrder("login[GET]");
return "account/login";
}

@RequestMapping(value="/account/login.html", method=RequestMethod.POST)
public ModelAndView login(IdCard idCard, BindingResult br) {

this.idCardValidator.validate(idCard, br);

printOrder("login[POST]");

System.out.println(idCard.getEmail());

System.out.println("BindingResult has ..");
for (Map.Entry<String, Object> entry : br.getModel().entrySet()) {
System.out.println(" key: " + entry.getKey());
System.out.println(" value: " + entry.getValue().toString());
System.out.println(" -----------------------------------");
}

ModelAndView mav = new ModelAndView();
mav.getModel().putAll(br.getModel());
mav.setViewName("account/login");
return mav;
}

private void printOrder(String methodName) {
this.testCounter++;
System.out.println(this.testCounter + ": " + methodName + " has been invoked!");
}

}

メッセージの表示
Spring Framework の <form:errors> タグの path 属性で、Validator が上げてきたエラーオブジェクトへのパスを指定しています(赤字の箇所)。

login.jsp(抜粋)
<body>
<style>
.red {
color: #ff0000;
}
</style>
<h1>WriDer's Demo Site</h1>
<form:form action="login.html" method="POST" modelAttribute="idCard">
<label for="email">メールアドレス</label>
<input type="text" id="email" name="email" value="${idCard.email}"/>
<span class="red"><form:errors path="email"/></span>
<br />
<label for="password">パスワード</label>
<input type="password" id="password" name="password" value="${idCard.password}"/>
<span class="red"><form:errors path="password"/></span>
<br />
<input type="submit" value="決定"/>
<h3>POSTed email: ${idCard.email}</h3>
<h3>POSTed password: ${idCard.password}</h3>
</form:form>
</body>

動作確認
何も入力せずに submit すると図のようにフィールドの横に赤字で「必須です」のメッセージが表示されます。これらは errors.xml の error.field.empty.idCard.email 及び passwod で定義したメッセージです。

次にメールアドレスに「wrider」という文字列を入力してみます。メールアドレス欄のエラーメッセージが「内容をお確かめ下さい」となりました。これはエラーコード error.field.illigal.idCard.email に対応したメッセージです。

errors.rejectValue と reject が吐き出す中身
IdCardValidator 内で rejectValue/reject されたエラー情報をコンソールで確認してみると、以下のようになっています。これは未入力時の状態ですが、青字で示した部分に、DefaultMessageCodesResolver の仕様どおりの順番でエラーコードが格納されているのがわかります。

BindingResult has ..
 key: idCard
 value: wrider.model.IdCard@471b39
 -----------------------------------
 key: org.springframework.validation.BindingResult.idCard
 value: org.springframework.validation.BeanPropertyBindingResult: 3 errors
Field error in object 'idCard' on field 'email':
 rejected value [];
 codes [
  error.field.empty.idCard.email,
  error.field.empty.email,
  error.field.empty.java.lang.String,
  error.field.empty
 ];
 arguments [];
 default message [null]
Field error in object 'idCard' on field 'password':
 :
Error in object 'idCard':
 codes [
  error.form.invalid.idCard,
  error.form.invalid
 ];
 arguments [];
 default message [null]

リローダブル!
今回最も興味があったのが ReloadableResourceBundleMessageSource の Reloadable の部分です。アプリケーションが動作している状態で errors.xml を以下のように変更し、メールアドレスに先ほどエラーとなった「wrider」を入力してみます。

 <entry key="error.field.illigal.idCard.email">内容をお確かめ下さい</entry>
   ↓
 <entry key="error.field.illigal.idCard.email">あなたのメルアドです</entry>

すると、エラーメッセージが下図のように変わりました。

Spring の API ドキュメントによれば、ReloadableResourceBundleMessageSource の cacheSeconds プロパティを“0(ゼロ)”に設定すると、メッセージへのアクセスが発生する度に、ファイルのタイプスタンプをチェックするそうです。0設定については“本番環境では使うな”と書いてありますが、適度な間隔を設定すれば、再起動することなくファイルの変更が反映されるので、とても便利だと思います。

ResourceBundleMessageSource にまつわる話
Spring が提供している MessageSource のもう一つの実装 ResourceBundleMessageSourceに関しては、「文字化け」とか「native2ascii が必要」とか properties ファイルにまつわる面倒くさそうな話を散見します。Aleksa Vukotic 氏の投稿“UTF-8 encoding and Spring message sources”によると、同クラスで使われている java.util.Properties が ISO 8859-1 しかサポートしていないことに起因するようです。

ただ J2SE 5.0 の java.util.Properties から XML 形式がサポートされ、デフォルトの UTF-8 以外も指定できるみたいなので、Spring の方でも対応してもよさそうですが、おそらくそうした使い方をしたいときは ReloadableResourceBundleMessageSource を使え、ということでなのしょうね。

0 件のコメント:

コメントを投稿