2012年4月10日火曜日

Spring AOP で流れを追う!


アプリケーションを開発していると作りこんだクラスやメソッドに関して「入出力はどうなっているか」とか、「そもそも呼ばれているのか」といったことが気になることがあります。そんなとき Spring Framework の Spring AOP(Aspect Oriented Programming)が重宝します。今回は、Spring AOP を使い、前回の『Bean Validation』で作ったバリデーターの動きを追跡してみたいと思います。

Pointcut, Join point, Advice
Spring AOP の目玉は、Pointcut(ポイントカット) の記述言語として AspectJ を採用していることです。Spring AOP の詳しい用語解説は、リファレンスの“8.1.1 AOP concepts”に書かれていますが、Pointcutとは要するに、プログラムの中で共通の特徴を持つ(いくつかの)場所(Join Point)に、何らかの処理(Advice)を差し込むための条件です。Spring AOP ではそうした条件の記述に AspectJ が利用できるということです。

準備 - AspectJ
というわけで AspectJ を準備します。The AspectJ Project サイトから aspectj-[version].jar をダウンロードし、同サイトの FAQ ページにある“2 Quick Start”に従って、適当な場所にインストールします。因みに次のように打ち込めばインストーラーが起動します。

java -jar aspectj-[version].jar

すると [インストール先フォルダー]/lib に必要な jar が入っているので、これらを /WEB-INF/lib にコピーし、ビルドパスに追加します。

準備 - SLF4J
これは次回のための準備としていれておきます。SLF4J(Simple Logging Facade for Java) サイトから slf4j-[version] の圧縮ファイルを持ってきて解凍後、slf4j-api-[version].jar と slf4j-simple-[version].jar を上記と同様、ビルドパスに登録します。


その他
aopalliance.jar
古いファイルですが、これが無いと AOP を有効にして立ち上げようとした時に怒られます。AOP Alliance サイトからリンクを辿って AOP Alliance フォルダーに行き、aopalliance.zip をダウンロードします。解凍後 /WEB-INF/lib にコピーします。

cglib-2.2.2.jar
8.1.3 AOP Proxies に書いてありますが、Spring AOP はインターフェースを実装していないクラスに対しては CGLIB(Code Generation Library) Proxy を使うそうです。将来的に必要となるかもしれないので、これも CGLIB サイトから持ってきて /WEB-INF/lib にコピーしておきます。


AOP Proxy の有効化
applicationContext.xml [Servlet-name]-servlet.xml に以下の一行を追加して AOP Proxy を有効化します。

<aop:aspectj-autoproxy/>


Pointcut の定義
一通りの準備が整ったところで、Pointcut の定義に取り掛かります。まずは Join Point にしたいメソッドの特徴の見極めです。以下のコードは、パスワードのバリデーションを行う CheckPasswordValidator クラスです。

CheckPasswordValidator
前回のコードに少し手を加えています。正規表現でチェックする部分を wrider.utils.AbstractRegexpUtils という抽象クラスに持たせ、CheckPasswordValidator はこれを extends しています。また、有効文字数をアノテーションの引数 min と max で指定できるようにしています。後者に伴い CheckPassword.java も少し変わりましたが、コードは AbstractRegexpUtils.java と共に割愛させていただきます。
package wrider.validator;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

import org.springframework.util.StringUtils;

import wrider.annotation.CheckPassword;
import wrider.utils.AbstractRegexpUtils;

public class CheckPasswordValidator extends AbstractRegexpUtils
  implements ConstraintValidator {

  private static final String BASE_PATTERN = "^[a-zA-Z0-9]";
  private int max;
  private int min;
  
  public void initialize(CheckPassword constraintAnnotation) {
    max = constraintAnnotation.max();
    min = constraintAnnotation.min();
  }
  
  public boolean isValid(String object, ConstraintValidatorContext constraintContext) {
    if (!StringUtils.hasLength(object)) {
      return true;
    }
    else {
      final String PASSWORD_PATTERN = BASE_PATTERN + "{" + min + "," + max + "}$";
      return super.patternMatching(PASSWORD_PATTERN, object);
    }
  }
  
}

上記クラスは javax.validation.ConstraintValidator インターフェースの実装クラスで、wrider.validator パッケージにあり、boolean 型の値を返す isValid メソッドを持っています。同パッケージには、メールアドレスのバリデーションを行う CheckEmail クラスもあり、同様の特徴を持っています。

そこで、これらのクラスの isValid メソッドを Join point とするよう Pointcut を定義したのが次のコードです。

WebPointcuts.java
package wrider.aop.pointcut;

import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class WebPointcuts {
  
  @Pointcut("within(wrider..*)")
  public void inWriderPackage() {}
  
  @Pointcut("execution(public boolean isValid(..))")
  public void doValidate() {}
  
  @Pointcut("inWriderPackage() && doValidate()")
  public void fieldValidation() {}
  
}

クラス定義を @Aspect と @Component でアノテートしています。これにより「component-scan と Stereotypeアノテーション」で書いたように[servlet-name]-servlet.xml で <context:component-scan /> が有効になっていれば、Spring Framework が自動検出してくれます。

クラス定義の中にはいくつかの空のメソッドがあり、それぞれに @Pointcut アノテーションが付いています。最初のメソッドは「wrider パッケージ内のすべての型におけるメソッドの実行」と定義した inWriderPackage、次が「public で、boolean 型の返り値と任意の引数を持つ isValid メソッドの実行」を対象とした doValidate です。そして最後の fieldValidation は、上記二つを同時に満たす Pointcut の定義です。

このようにプログラムの「aspect(相、特徴)」に着目した記述ができるのが AspectJ です。

Advice の定義
Advice には大別して Join Point の直前で実行する Before Advice、Join Point 終了後に実行する After Advice、Join Point が呼び出された辺りで実行する Around Advice があります。

WebAdvices.java
以下のコードでは 3 つの Advice が定義しています。いずれも Pointcut に“fieldValidation”を指定し、文字列を連結して作ったメッセージを System.out.println() でコンソールに出力している点は共通していますが、@Before, @AfterReturning, @Around の違いに応じて、返り値や引数の扱いを変えています。

@Before - Before Advice
実行直前にインターセプトしたメソッドの最初の引数を、final Object 型の引数(param)として受け取るよう指定しています。jp.getTarget().getClass().getSimpleName() でクラス名、jp.getSignature().getName() でそのクラスのメソッド名を取得しています。そしてメッセージには「実行前」ということで“will be invoked!”の文字列を含めています。
@Before("wrider.aop.pointcut.WebPointcuts.fieldValidation() && args(param,*)")
  public void logFieldValidationOccured(final JoinPoint jp, final Object param) {
    
    Signature sig = jp.getSignature();
    String cn = jp.getTarget().getClass().getSimpleName();
    String buf = cn + "." + sig.getName() + " will be invoked! [" + param.toString() + "] ";
    
    System.out.println(buf);
    
  }

@AfterReturning - AfterReturning Advice
メソッド実行後の返り値を final Object 型の引数(retVal)で受け取り、それをそのまま return しています。インターセプトしたクラス名、メソッド名の取得は上記と同じです。
@AfterReturning(
      pointcut="wrider.aop.pointcut.WebPointcuts.fieldValidation()", 
      returning="retVal")
  public Object logFieldValidationFinished(final JoinPoint jp, final Object retVal) {
  :
    return retVal;
  }

@Around - Around Advice
ProceedingJoinPoint インターフェースの proceed() メソッドを使って、進行中の状態を return しています。クラス名、メソッド名の取得は上の 2 つと異なり、Around Advice で使用できる ProceedingJoinPoint から取得しています。
@Around("wrider.aop.pointcut.WebPointcuts.fieldValidation()")
  public Object logFieldValidationOnGoing(final ProceedingJoinPoint pjp)  throws Throwable {
  :
    return pjp.proceed();
  }

全体のコードです。
package wrider.aop.advice;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.AfterReturning;

import org.springframework.stereotype.Component;

@Aspect
@Component
public class WebAdvices {
  
  @Before("wrider.aop.pointcut.WebPointcuts.fieldValidation() && args(param,*)")
  public void logFieldValidationOccured(final JoinPoint jp, final Object param) {
    
    Signature sig = jp.getSignature();
    String cn = jp.getTarget().getClass().getSimpleName();
    String buf = cn + "." + sig.getName() + " will be invoked! [" + param.toString() + "] ";
    
    System.out.println(buf);
    
  }
  
  @AfterReturning(
      pointcut="wrider.aop.pointcut.WebPointcuts.fieldValidation()", 
      returning="retVal")
  public Object logFieldValidationFinished(final JoinPoint jp, final Object retVal) {
    
    Signature sig = jp.getSignature();
    String cn = jp.getTarget().getClass().getSimpleName();
    String buf = cn + "." + sig.getName() + " completed! result is " + retVal.toString();
    
    System.out.println(buf);
    
    return retVal;
  }
  
  @Around("wrider.aop.pointcut.WebPointcuts.fieldValidation()")
  public Object logFieldValidationOnGoing(final ProceedingJoinPoint pjp)  throws Throwable {
    
    Signature sig = pjp.getSignature();
    String cn = pjp.getTarget().getClass().getSimpleName();
    String buf = cn + "." + sig.getName() + " is on going!";
    
    System.out.println(buf);
    
    return pjp.proceed();
  }
}

実行!
では試して見ましょう。今まで再三使ってきた login.html にアクセスし、エラーとなる文字列を入力した結果が以下のコンソール画面です。青字が各 Advice の出力です。
1: makeIdCard has been invoked!
2: makeUserProfile has been invoked!
3: login[GET] has been invoked!
CheckEmailValidator.isValid will be invoked! [wrider] 
CheckEmailValidator.isValid is on going!
CheckEmailValidator.isValid completed! result is false
CheckPasswordValidator.isValid will be invoked! [123] 
CheckPasswordValidator.isValid is on going!
CheckPasswordValidator.isValid completed! result is false
4: login[POST] has been invoked!
got email: wrider

CheckEmailValidator に着目すると

 ~ will be invoked [wrider]
  ↓
 ~ is on going!
  ↓
 ~ completed! result is false

というメッセージの流れから“Before”→“Around”→“AfterReturning”という順番で Advice が呼び出されていることがわかります。また、CheckEmailValidator が「wrider」という文字列の検証で「false」を返している様子もわかります。

次回の予告(かも?)
最後に、SLF4J を使って各 Advice を書き換えた場合のコンソール出力を載せておきます。

SLF4Jによるコンソール出力
10969 [http-bio-8080-exec-3] INFO wrider.aop.advice.WebAdvices - CheckEmailValidator.isValid will be invoked! [wrider] 
10969 [http-bio-8080-exec-3] INFO wrider.aop.advice.WebAdvices - CheckEmailValidator.isValid is on going!
10969 [http-bio-8080-exec-3] INFO wrider.aop.advice.WebAdvices - CheckEmailValidator.isValid completed! result is false

2012年4月5日木曜日

Bean Validation


Spring Frameworkリファレンスの“6.7 Spring 3 Validation”に、Spring 3 は Bean Validation API(JSR-303)を完全サポートし、デフォルトのリファレンス実装として Hibernate Validator を採用している と記されています。

今回は、この JSR-303 Validator を使って「Validator と MessageSource」で作成した login フォームのバリデーションを作り変えます。また、簡単な機能ですが、独自の制約条件(custom constraint)を定義した、カスタムメイドのアノテーションも作ってみます。

JSR と Bean Validation
JSR(Java Specification Requests) とは、Java 標準として JCP(Java Community Process)にポストされたリクエストで、JCP メンバーのレビューを経て認可されたリクエストは仕様化ステージに入ります。JSR 303: Bean Validation もそうしたリクエストの一つで、JCPサイトから仕様の最終リリースをダウンロードできます。

事前準備
Hibernate Validator サイトからリンクを辿ると SourceForge.net の /hibernate-validator フォルダーに飛べるので、そこから必要な Distribution bundle をダウンロードします。私は以下のバージョンをダウンロードしました。

hibernate-validator-4.2.0.Final-dist.zip

JAR の登録
今回の作業で必要なのは次の 4 つです。

解凍先フォルダー
hibernate-validator-4.2.0.Final.jar
hibernate-validator-annotation-processor-4.2.0.Final.jar

解凍先フォルダー/lib/required
slf4j-api-1.6.1.jar
validation-api-1.0.0.GA.jar

これらを図のように /WEB-INF/lib にコピーし、それをビルドパスに追加します。因みに、私は Eclipse(Helios SR2) + JDK6 + Java EE6 SDK + Tomcat 7.0.25 という環境です。


<mvc:annotation-driven/>
リファレンスの 6.7.4.3 Configuring a JSR-303 Validator for use by Spring MVC に従って [servlet-name]-servlet.xml に <mvc:annotation-driven/>を追加します。

[servlet-name]-servlet.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:context="http://www.springframework.org/schema/context"
     xmlns:aop="http://www.springframework.org/schema/aop"
     xmlns:tx="http://www.springframework.org/schema/tx"
     xmlns:mvc="http://www.springframework.org/schema/mvc"
     xsi:schemaLocation="
       http://www.springframework.org/schema/beans 
       http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
       http://www.springframework.org/schema/context 
       http://www.springframework.org/schema/context/spring-context-3.0.xsd
       http://www.springframework.org/schema/aop 
       http://www.springframework.org/schema/aop/spring-aop-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/mvc 
       http://www.springframework.org/schema/mvc/spring-mvc-3.0.xsd">
  
  <mvc:annotation-driven/>
  
  <context:component-scan base-package="wrider"/>
    :
    
</beans>

制約条件をアノテート
以上で準備は完了です。では早速、login フォームのデータを格納する IdCard クラスの各プロパティにビルトインのアノテーションで制約条件を定義してみます。

IdCard.java
まず「メールアドレス(email)」には@NotEmpty と @Email アノテーションで“必須”、“E-Mailのフォーマット”という制約条件を付けます。一方「パスワード(password)」については、@NotEmpty と @Pattern を使って“必須”、“8 文字以上 32 文字以下の半角英数”という制約条件を付加します。
package wrider.model;

import javax.validation.constraints.Pattern;

import org.hibernate.validator.constraints.Email;
import org.hibernate.validator.constraints.NotEmpty;

public class IdCard {
  
  @NotEmpty
  @Email
  private String email;
  
  public String getEmail() {
    return this.email;
  }
  
  public void setEmail(String email) {
    this.email = email;
  }
  
  @NotEmpty
  @Pattern(regexp = "^[a-zA-Z0-9]{8,32}$")
  private String password;
  
  public String getPassword() {
    return this.password;
  }
  
  public void setPassword(String password) {
    this.password = password;
  }
}

コントローラークラスの修正
独自に作成した IdCard 用のバリデーター(IdCardValidator)の代わりにアノテーションを使用することに伴う変更を施します。

AccountController.java(抜粋)
@RequestMapping で POST リクエストに紐付けられた login メソッドに渡す IdCard 型引数に @Valid アノテーションを付け、idCardValidator を呼び出す部分をコメントアウトしています。
package wrider.controller;

import java.util.Map;

import javax.validation.Valid;

import org.springframework.beans.factory.annotation.Autowired;
  :
import wrider.model.IdCard;
import wrider.model.UserProfile;

@Controller
@SessionAttributes({"idCard", "userProfile"})
public class AccountController {
  /*
  @Autowired
  private Validator idCardValidator;
  */
  @Autowired
  private Validator userProfileValidator;
  
  @ModelAttribute("idCard")
  public IdCard makeIdCard() {
    return new IdCard();
  }
  
  @ModelAttribute("userProfile")
  public UserProfile makeUserProfile() {
    return new UserProfile();
  }
  
  @RequestMapping(value="/account/login.html", method=RequestMethod.GET)
  public String login() {
    return "account/login";
  }
  
  @RequestMapping(value="/account/login.html", method=RequestMethod.POST)
  public ModelAndView login(@Valid IdCard idCard, BindingResult br) {
    
//    this.idCardValidator.validate(idCard, br);
    
    ModelAndView mav = new ModelAndView();
    mav.getModel().putAll(br.getModel());
    mav.setViewName("account/login");
    return mav;
  }
  :
}

エラーメッセージの登録
メッセージソースは「Validator と MessageSource」で行った ReloadableResourceBundleMessageSource の設定をそのまま使います。なので、Bean Validator が吐き出したエラーコードと、それらに対応するメッセージを errors.xml 追加するだけです。

バリデーションの結果、検出されたエラー情報は次のような感じで BindingResult に格納されます。
:
codes [Pattern.idCard.password,Pattern.password,Pattern.java.lang.String,Pattern];
    :
codes [NotEmpty.idCard.password,NotEmpty.password,NotEmpty.java.lang.String,NotEmpty];
    :
要は、各エラーにおいて上記コードのどれかに対応したメッセージが定義されていればいいわけです。従って、今回は次のように定義しました。

errors.xml(抜粋)
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
  <comment>field and form level error messages</comment>
    :
  <entry key="Email.email">指定外の文字列</entry>
  <entry key="NotEmpty.email">必須です</entry>
  <entry key="Pattern.password">8-32文字の半角英数</entry>
  <entry key="NotEmpty.password">必須です</entry>
</properties>

実行!
図は、以上の作業を経てできた login フォームに、メールアドレス「wrider」、パスワード「未入力」として submit した後の画面です。

メールアドレス欄には“指定外の文字列”と表示されています。ここを「未入力」にすると“必須です”と表示されます。

一方、パスワード欄は @NotEmpty で「未入力」が検出されたことを示す“必須です”と、@Patter の引数で指示した正規表現にマッチしていないことを示す「8-32文字の半角英数」の両方が表示されています。これではちょっとかっこ悪いです。

GrepCode を使って、@Email アノテーションで呼び出される EmailValidator のソースを調べて見ると、未入力(null または length() == 0)の場合は true を返すようになっています。つまり、「未入力チェックは @Email の仕事ではありません」ということなのでしょう。

カスタムアノテーションの実装
そこでパスワード(password)について、JSR-303: Bean Validator と Hibernate Validator リファレンスの「Chapter 3. Creating custom constraints」を参考に、@CheckPassword という独自の制約条件(Custom Constraint)を実装してみます。

CheckPassword.java
まず、アノテーション CheckPassword を定義します。@Target でアノテーションの付与対象を指定し、@Constraint の引数 validatedBy で、このアノテーションが呼び出すバリデーターを指示します。
package wrider.annotation;

import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.RetentionPolicy.*;

import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import javax.validation.Constraint;
import javax.validation.Payload;

import wrider.validator.CheckPasswordValidator;

@Target({FIELD, ANNOTATION_TYPE})
@Retention(RUNTIME)
@Constraint(validatedBy = CheckPasswordValidator.class)
@Documented
public @interface CheckPassword {
  
  String message() default "{wrider.annotation.CheckPassword.message}";
  
  Class<?>[] groups() default {};
  
  Class<? extends Payload>[] payload() default {};
  
}

CheckPaswordValidator.java
実際のバリデーション処理を行うクラスです。未入力チェックは他のアノテーションに譲り、正規表現によるフォーマットチェックの結果を返すようにしています。
package wrider.validator;

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

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

import org.springframework.util.StringUtils;

import wrider.annotation.CheckPassword;

public class CheckPasswordValidator implements ConstraintValidator<CheckPassword, String> {

  private static final String PASSWORD_PATTERN = "^[a-zA-Z0-9]{8,32}$";
  
  public void initialize(CheckPassword constraintAnnotation) {
    
  }
  
  public boolean isValid(String object, ConstraintValidatorContext constraintContext) {
    if (!StringUtils.hasLength(object)) {
      return true;
    }
    else {
      return this.patternMatching(PASSWORD_PATTERN, object);
    }
  }

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

IdCard.java(抜粋)
これで @CheckPassword の作成は完了です。これを password に対して @Pattern の変わりに付与します。
:
  @NotEmpty
  @CheckPassword
  private String password;
    :

errors.xml(抜粋)
後は、@CheckPassword に対応したエラーメッセージを errors.xml に追加します。
:
  <entry key="CheckPassword.password">8-32文字の半角英数</entry>
    :

再び実行!
今度は、未入力時には“必須です”、入力ルールに従っていない文字列の時には“8-32文字の半角英数”のメッセージだけがパスワード欄に表示されるようになりました。

様々な制約条件に柔軟に対応できる Bean Validation は、慣れてしまえば重宝する仕掛けだと感じました。今回は取り上げませんでしたがバリデーションの順序を指定できる @GroupSequence など、他にも色々と面白そうな機能があり、これからやみつきになりそうです。

2012年4月2日月曜日

DI による Validator の再利用


今回の課題は「Validator と MessageSource」で作成したパーツを再利用した登録フォームにします。

データオブジェクトの再利用
とりあえず登録フォームの入力項目は以下のように考えました。
  • メールアドレス: email
  • パスワード: password
  • 名字: firstName
  • 名前: lastName
この中で「メールアドレス」と「パスワード」は前に作った IdCard と重複しているので、登録フォーム用のデータオブジェクト(UserProfile)には、これを再利用することにします。

UserProfile.java
このクラスは IdCard クラスを extends しています。名字(firstName)と名前(lastName)は、プロパティとそれらに対応する setter/getter を定義していますが、メールアドレス(email)とパスワード(password)については、スーパークラスの setter/getter を呼び出しています。
package wrider.model;

public class UserProfile extends IdCard {
  
  private String lastName;
  
  public String getLastName() {
    return this.lastName;
  }
  
  public void setLastName(String lastName) {
    this.lastName = lastName;
  }
  
  private String firstName;
  
  public String getFirstName() {
    return this.firstName;
  }
  
  public void setFirstName(String firstName) {
    this.firstName = firstName;
  }
  
  public String getEmail() {
    return super.getEmail();
  }
  
  public void setEmail(String email) {
    super.setEmail(email);
  }
  
  public String getPassword() {
    return super.getPassword();
  }
  
  public void setPassword(String password) {
    super.setPassword(password);
  }

}

Validator の再利用
Validator は、IdCard 用の IdCardValidator を DI(Dependency injection)します。

UserProfileValidator.java
リファレンスの“6.2 Validation using Spring's Validator interface”に習い、コンストラクター引数として IdCardValidator を DI しています。この方法を Constructor-based DI というらしいです。

上記 UserProfile クラスと同じように登録フォーム独自の項目である「名字」と「名前」の未入力チャックとエラー情報の reject を ValidationUtilsrejectIfEmptyOrWhitespace(..) メソッドで行い、「メールアドレス」と「パスワード」に関しては、DI した IdCardValidator をinvokeValidator(..) メソッドで呼び出して検証させます。
package wrider.validator;

import org.springframework.validation.ValidationUtils;
import org.springframework.validation.Validator;
import org.springframework.validation.Errors;

import wrider.model.UserProfile;

public class UserProfileValidator implements Validator {
  
  private final Validator idCardValidator;
  
  private static final String ERROR_FIELD_EMPTY = "error.field.empty";
  
  public UserProfileValidator(Validator idCardValidator) {
    this.idCardValidator = idCardValidator;
  }
  
  public boolean supports(Class<?> clazz) {
    return UserProfile.class.isAssignableFrom(clazz);
  }
  
  public void validate(Object target, Errors errors) {
    
    ValidationUtils.rejectIfEmptyOrWhitespace(errors, "firstName", ERROR_FIELD_EMPTY);
    ValidationUtils.rejectIfEmptyOrWhitespace(errors, "lastName", ERROR_FIELD_EMPTY);
    
    UserProfile userProfile = (UserProfile)target;
    
    ValidationUtils.invokeValidator(idCardValidator, userProfile, errors);
    
  }
}

Validator の登録
新しく作成した UserProfileValidator を applicationContext.xml に登録します。

applicationContext.xml(抜粋)
リファレンスの“4.4.1.1 Constructor-based dependency injection”に習い <constructor-arg> 要素で userProfileValidator ビーンのコンストラクター引数として idCardValidator を DI するよう指示しています。
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
    :
  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"/>
  
  <bean id="userProfileValidator"
    class="wrider.validator.UserProfileValidator">
    <constructor-arg ref="idCardValidator"/>
  </bean>
    :
</beans>

エラーコードの修正
今回は IdCard と UserProfile のバリデーションエラーに共通のエラーコードで対応したいので、以下のように errors.xml を修正しました。

修正前 [エラーコード].[オブジェクト名].[フィールド名]
 ↓
修正後 [エラーコード].[フィールド名]

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

registrationForm.jsp(抜粋)
登録フォームの jsp です。とりあえず先の login.jsp に名字と名前を追加して、<form:form> タグの modelAttribute と、POST リクエストを投げる先を変えただけです。
<body>
  <h1>WriDer's Demo Site</h1>
  <form:form action="register.html" method="POST" modelAttribute="userProfile">
    <label for="email">メールアドレス</label>
    <input type="text" id="email" name="email" value="${userProfile.email}"/>
    <span class="red"><form:errors path="email"/></span>
    <br />
    <label for="password">パスワード</label>
    <input type="password" id="password" name="password" value="${userProfile.password}"/>
    <span class="red"><form:errors path="password"/></span>
    <br />
    <label for="firstName">名字</label>
    <input type="text" id="firstName" name="firstName" value="${userProfile.firstName}"/>
    <span class="red"><form:errors path="firstName"/></span>
    <br />
    <label for="lastName">名前</label>
    <input type="text" id="lastName" name="lastName" value="${userProfile.lastName}"/>
    <span class="red"><form:errors path="lastName"/></span>
    <br />
    <input type="submit" value="決定"/>
  </form:form>
</body>

コントローラーの変更
今回の登録フォームも AccountController クラスで対応します。@RequestMapping アノテーションで /account/register.html に対する GET/POST リクエストを振り分けています。POST で実行される register メソッドで userProfileValidator を呼び出し、バリデーションを行っています。

また、@SessionAttributes, @Autowired, @ModelAttribute アノテーション、および import に UserProfile に関する記述を追加しています。

AccountController.java(抜粋)
package wrider.controller;
    :
import wrider.model.UserProfile;

@Controller
@SessionAttributes({"idCard", "userProfile"})
public class AccountController {
  
  @Autowired
  private Validator idCardValidator;
  
  @Autowired
  private Validator userProfileValidator;

    :
  @ModelAttribute("userProfile")
  public UserProfile makeUserProfile() {
    return new UserProfile();
  }
    :

  @RequestMapping(value="/account/register.html", method=RequestMethod.GET)
  public String register() {
    return "account/registrationForm";
  }
  
  @RequestMapping(value="/account/register.html", method=RequestMethod.POST)
  public ModelAndView register(UserProfile userProfile, BindingResult br) {
    
    this.userProfileValidator.validate(userProfile, br);
    
    ModelAndView mav = new ModelAndView();
    mav.getModel().putAll(br.getModel());
    mav.setViewName("account/registrationForm");
    return mav;
  }
    :
}

実行
図はブラウザーで /account/register.html にアクセスし、「名前」を未入力にして submit した後の画面です。

POST リクエストを受けた Spring Framework は IdCard インスタンスに「メールアドレス」と「パスワード」、UserProfile インスタンスに「名字」「名前」をセットし、AccountController の register(..)メソッドを呼び出します。同メソッドは UserProfileValidator のバリデーション結果を BindingResult から取り出して ModelAndView にセットします。

Java は元々、コンポーネントを再利用する仕掛けを持っていますが、Spring Framework が提供する DI(または IoC)の仕掛けを使えば、依存関係を記した XML(やアノテーション)に従って、インスタンス化や注入を行ってくれます。使いたいコンポーネントを直に import して new する頻度を減らせるので、うまく使いこなせば大規模アプリケーションの改修なども楽になるかも、と感じました。

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 を使え、ということでなのしょうね。

2012年3月30日金曜日

ModelAttribute と SessionAttributes



Spring Framework には ModelFactory というクラスがあります。API ドキュメントを読むと、Model の初期化やアップデート(セッションとモデルアトリビュートの同期)を行うクラスである旨が書かれています。

そこで今回は、@ModelAttribute と @SessionAttributes の作用を調べてみます。

login.jsp(抜粋)
メールアドレスとパスワードを入力する単純なフォームです。<form:form>タグは Spring Framewok が提供するタグライブラリーで modelAttribute という属性で、フォームオブジェクトの共有に使うモデルアトリビュート名(idCard)を設定しています。
<body>
<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}"/><br />
<label for="password">パスワード</label>
<input type="password" id="password" name="password" value="${idCard.password}"/><br />
<input type="submit" value="決定"/>
<h3>POSTed email: ${idCard.email}</h3>
<h3>POSTed password: ${idCard.password}</h3>
</form:form>
</body>

IdCard.java
上記フォームの入力データを格納する IdCard クラスです。2つのプロパティ(email と password)と、それらのセッター/ゲッターのみです。
package wrider.model;

public class IdCard {

private String email;

public String getEmail() {
return this.email;
}

public void setEmail(String email) {
this.email = email;
}

private String password;

public String getPassword() {
return this.password;
}

public void setPassword(String password) {
this.password = password;
}

}

AccountController.java
フォームの表示と入力データの処理を行うコントローラークラスです。GET リクエストの時は /WEB-INF/jsp/account/login.jsp を表示するだけです。POST リクエストの時は、login メソッドの引数として Spring から受け取った BindingResult 中のモデルマップを getModel() で取り出し putAll()ModelAndView に格納しています(コード中の青字の部分)。

makeIdCard は、上記 IdCard クラスの新しいインスタンスを生成するメソッドで、@ModelAttribute アノテーションを付けています(赤字の部分)。

各メソッドが呼び出された順番、BindingResult を介して引き継がれたモデルマップの key と value のリストをコンソールに表示するようにしています(青字の部分)。
package wrider.controller;

import java.util.Map;

import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
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
public class AccountController {
 
 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) {
  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!");
 }

}

@ModelAttribute の作用
ブラウザー(ブラウザーA)で [servlet-name]/account/login.html にアクセスすると、まず @ModelAttribute アノテーションが付いている makeIdCard メソッドが実行され、GET リクエスト用の login メソッドが実行されています(1~2)。

フォームにデータを入力して submit すると、再び makeIdCard メソッドが呼び出された後、今度は POST リクエスト用の login メソッドが実行されています(3~4)。

コンソールの出力(ブラウザーA)
1: makeIdCard has been invoked!
2: login[GET] has been invoked!
3: makeIdCard has been invoked!
4: login[POST] has been invoked!
wrider@abc.com
BindingResult has ..
key: idCard
value: wrider.model.IdCard@1160fa6
-----------------------------------
key: org.springframework.validation.BindingResult.idCard
value: org.springframework.validation.BeanPropertyBindingResult: 0 errors
-----------------------------------

続いて、別のブラウザー(ブラウザーB)を立ち上げて同様にアクセスします。やはり、GET/POST に関係なく毎回 @ModelAttribute メソッドが呼び出され、その度に新しい IdCard インスタンスが生成されています。

コンソールの出力(ブラウザーB)
5: makeIdCard has been invoked!
6: login[GET] has been invoked!
7: makeIdCard has been invoked!
8: login[POST] has been invoked!
picboo@def.com
BindingResult has ..
key: idCard
value: wrider.model.IdCard@1acbf5c
-----------------------------------
key: org.springframework.validation.BindingResult.idCard
value: org.springframework.validation.BeanPropertyBindingResult: 0 errors
-----------------------------------


@SessionAttributes の作用
では、コントローラークラスに以下のように @SessionAttributes アノテーションを付けてみます。

package wrider.controller;
  :
@Controller
@SessionAttributes("idCard")
public class AccountController {
  :
}

すると、@ModelAttribute メソッドが呼び出されるのは、ブラウザーとのセッションが確立した最初だけになります。また、ブラウザーの Cookie を見ると jsessionid が発行されていることが確認できます。

コンソールの出力(ブラウザーA)
1: makeIdCard has been invoked!
2: login[GET] has been invoked!
3: login[POST] has been invoked!
wrider@abc.com
BindingResult has ..
key: idCard
value: wrider.model.IdCard@1bb9805
-----------------------------------
key: org.springframework.validation.BindingResult.idCard
value: org.springframework.validation.BeanPropertyBindingResult: 0 errors
-----------------------------------

コンソールの出力(ブラウザーB)
4: makeIdCard has been invoked!
5: login[GET] has been invoked!
6: login[POST] has been invoked!
picboo@def.com
BindingResult has ..
key: idCard
value: wrider.model.IdCard@ea7211
-----------------------------------
key: org.springframework.validation.BindingResult.idCard
value: org.springframework.validation.BeanPropertyBindingResult: 0 errors
-----------------------------------

以降は、以下のコンソール出力が示す通り POST リクエストに応じて login メソッドが実行されるだけです。BindingResult から取り出した IdCard オブジェクト(key: idCard)の参照 ID は、ブラウザー A が“IdCard@1bb9805”、ブラウザー B が“IdCard@ea7211”と、それぞれ先のアクセス(上記コンソール出力の 3 及び 6)の際の参照 ID と同じです。

一方、@SessionAttributes アノテーションを付加しなかった最初のコードでは、リクエストの度にこの参照 ID が変化しました。

コンソールの出力(ブラウザーA, B)
7: login[POST] has been invoked!
hismail@cba.com
BindingResult has ..
key: idCard
value: wrider.model.IdCard@1bb9805
-----------------------------------
key: org.springframework.validation.BindingResult.idCard
value: org.springframework.validation.BeanPropertyBindingResult: 0 errors
-----------------------------------
8: login[POST] has been invoked!
hermail@fed.com
BindingResult has ..
key: idCard
value: wrider.model.IdCard@ea7211
-----------------------------------
key: org.springframework.validation.BindingResult.idCard
value: org.springframework.validation.BeanPropertyBindingResult: 0 errors
-----------------------------------

まとめ
オブジェクトの生成、初期化などを行うメソッドに @ModelAttribute アノテーションを付けておけば、コントローラーメソッドが呼び出される前に実行してくれます。

一方、コントローラーのクラス定義に @SessionAttributes アノテーションを付けることで、セッションに付随するアトリビュートとして、指定したモデルアトリビュート(のリスト)を引き継ぐことができます。

ただし、SessionAttributes のドキュメントには、「認証オブジェクトのようなパーマネントなセッションアトリビュートには、伝統的な session.setAttribute メソッドを使え」と注意書きが記されています。この session.setAttribute を使った方法は前回の『データ(オブジェクト)共有を伴うリダイレクト』で触れています。

2012年3月26日月曜日

データ(オブジェクト)共有を伴うリダイレクト


他の Web アプリケーションと同様、Spring MVC Framework を使ったアプリケーションでも、フォームに入力されたデータをリダイレクト先に引き継がせたいときがあります。また、共通のアプリケーションを利用するブラウザー間でデータを共有したいときがあります。

そこで今回は、ServletContext および HttpSession、そして redirect: プリフィックを組み合わせて『データ共有を伴うリダイレクト』の仕掛けを考えてみようと思います。

リダイレクトなし
まずは実験の土台となる簡単なコントローラーを作ります。以下のコードには、2種類の index メソッドがあります。リクエストメソッド(GET/POST)に応じて処理を振り分けるよう @RequestMapping アノテーションで指示しています。

WelcomeController.java
package wrider;

import java.util.HashMap;
import java.util.Map;
import java.util.Collections;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.servlet.ModelAndView;

@Controller
public class WelcomeController {

 @RequestMapping(value ="welcome/index", method = RequestMethod.GET)
 public ModelAndView index() {
  
  Map<String, Object> model = new HashMap<String, Object>();
  model.put("pageTitle", "Welcome to WriDer's Site");
  
  ModelAndView modelAndView = new ModelAndView();
  modelAndView.setViewName("welcome/index");
  modelAndView.addAllObjects(model);
  
  return modelAndView;
 }
 
 @RequestMapping(value ="welcome/index", method = RequestMethod.POST)
 public ModelAndView index(HttpServletRequest req, HttpServletResponse res) {
  
  this.reqParamList(req);
  
  Map<String, Object> model = new HashMap<String, Object>();
  model.put("pageTitle", "I'm Postman");
  
  ModelAndView modelAndView = new ModelAndView();
  modelAndView.setViewName("welcome/index");
  modelAndView.addAllObjects(model);
  
  return modelAndView;
 }
 
 private void reqParamList(HttpServletRequest req) {
  System.out.println("[" + req.getRequestURI() + "]Method: " + req.getMethod());
  
  String pname = null;
  for (Object obj : Collections.list(req.getParameterNames())) {
   pname = obj.toString();
   System.out.println(pname + ": " + req.getParameter(pname));
  }
 }
}

index.jsp (抜粋)
<body>
 <h1>WriDer's Demo Site</h1>
 <form:form action="index.html" method="POST">
  <input type="text" name="anyText"/>
  <input type="submit" value="決定"/>
 </form:form>
 <h2>POSTed data: <%= request.getParameter("anyText") %> </h2>
</body>

テキストボックスに適当な文字列を入力して決定ボタンを押します。

画面

コンソールの出力
[/ishtar/welcome/index.html]Method: POST
anyText: 難しいことは後回し

redirect: でリダイレクト
次に、POST リクエストで実行される index メソッドにある setViewName(..) の箇所を以下のように変更し、新たに catcher メソッドを追加します。redirect: プリフィックスは、InternalResourceViewResolver のスーパークラスである UrlBasedViewResolver でサポートされている機能です。

WelcomeController.java (抜粋)
 @RequestMapping(value ="welcome/index", method = RequestMethod.POST)
 public ModelAndView index(HttpServletRequest req, HttpServletResponse res) {
    :
  ModelAndView modelAndView = new ModelAndView();
modelAndView.setViewName("redirect:catcher.html");
  modelAndView.addAllObjects(model);
  
  return modelAndView;
 }
 
 @RequestMapping("welcome/catcher")
 public ModelAndView catcher(HttpServletRequest req, HttpServletResponse res) {
  
  this.reqParamList(req);
  
  Map<String, Object> model = new HashMap<String, Object>();
  model.put("pageTitle", "I'm Catcher");
  
  ModelAndView modelAndView = new ModelAndView();
  modelAndView.setViewName("welcome/catcher");
  modelAndView.addAllObjects(model);
  
  return modelAndView;
 }

画面

コンソールの出力
[/ishtar/welcome/index.html]Method: POST
anyText: 難しいことは後回し
[/ishtar/welcome/catcher.html]Method: GET
pageTitle: I'm Postman

コンソールの出力が示す通り、まず index メソッドが POST で呼び出された後、catcher メソッドにリダイレクトされます。

この場合の特徴的な挙動として...
  • catcher メソッドは GET リクエストで呼び出される。
  • index メソッド内で modelAndView オブジェクトに addAllObjects() で追加したアトリビュート、この場合は model オブジェクトに put した pageTitle が、リダイレクト先である /welcome/catcher.html へのクエリーパラメーターとして渡されている。
  • catcher メソッドが受け取ったパラメーターの中に、最初に POST して anyText は含まれていない。
POST データを引き継ぐ処理を何も行っていないので当然の結果です。

ServletContext と HttpSession
ServletContext にバインドされたアトリビュート(名前付きのオブジェクト)は、同じ Web アプリケーションに対するすべてのリクエスト間で共有できます。また、HttpSession にバインドされたアトリビュートは、同一ブラウザーのセッション間で共有できます。前者は一般に“Application Scope”、後者は“Session Scope”と呼ばれています。これらの仕組みを使って書き換えたのが以下のコードです。

WelcomeController.java (抜粋)
 @RequestMapping(value ="welcome/index", method = RequestMethod.POST)
 public ModelAndView index(HttpServletRequest req, HttpServletResponse res) {
  
  this.reqParamList(req);
  
  Map<String, Object> model = new HashMap<String, Object>();
  /*
   * ServletContext を介したデータの共有 - Applicationスコープ
   */
  req.getServletContext().setAttribute("appScopeText", req.getParameter("anyText"));
  /*
   * HttpSession を介したデータの共有 - Sessionスコープ
   */
  req.getSession().setAttribute("sessScopeText", req.getParameter("anyText"));
  
  ModelAndView modelAndView = new ModelAndView();
  modelAndView.setViewName("redirect:catcher.html");
  modelAndView.addAllObjects(model);
  
  return modelAndView;
 }
 
 @RequestMapping("welcome/catcher")
 public ModelAndView catcher(HttpServletRequest req, HttpServletResponse res) {
  
  this.reqParamList(req);
  
  Map<String, Object> model = new HashMap<String, Object>();
  model.put("pageTitle", "I'm Catcher");
  /*
   * ServletContext を介したデータの共有 - Applicationスコープ
   */
  model.put("appScopeText", req.getServletContext().getAttribute("appScopeText"));
  /*
   * HttpSession を介したデータの共有 - Sessionスコープ
   */
  model.put("sessScopeText", req.getSession().getAttribute("sessScopeText"));
  
  ModelAndView modelAndView = new ModelAndView();
  modelAndView.setViewName("welcome/catcher");
  modelAndView.addAllObjects(model);
  
  return modelAndView;
 }

index.jsp と catcher.jsp (抜粋)
  :
 <h2>POSTed data: <%= request.getParameter("anyText") %> </h2>
 <h2>Application Scope: ${appScopeText}</h2>
 <h2>HttpSession Scope: ${sessScopeText}</h2>
</body>

画面(Chrome)

コンソールの出力
[/ishtar/welcome/index.html]Method: POST
anyText: 難しいことは後回し
[/ishtar/welcome/catcher.html;jsessionid=2F3..]Method: GET

リダイレクト先である catcher メソッドが受け取ったリクエストパラメーターは null です。しかし、ServletContext と HttpSession(※HttpServletRequest.getSession() として取得)に setAttribute(..)でバインドしたオブジェクト――この場合は appScopeText, sessScopeText という名前のString データ――は、ちゃんとリダイレクト先と共有されています。

また、コンソールの出力、あるいは画面のアドレスバーを見ると jsessionid というセッション ID が発行されていることがわかります。

この状態で、異なるブラウザーを立ち上げて index.html にアクセスすると...

画面(Opera)

先のアクセスで ServletContext にバインドした appScopeText の内容が表示されています。一方、HttpSession には sessScopeText はバインドされていません。

このフォームに適当な文字列を入力し送信ボタンを押すと、catcher.html にリダイレクトされ、画面には「Application Scope: とにかく動かす」「HttpSession Scope: とにかく動かす」と表示されます。

コンソールの出力は以下の通り。このブラウザーとのセッション用にセッション ID(jsessionid)が発行されています。

コンソールの出力
[/ishtar/welcome/index.html]Method: POST
anyText: とにかく動かす
[/ishtar/welcome/catcher.html;jsessionid=701..]Method: GET


そして Chrome に戻り、リロードした際の画面が下図です。

画面(Chrome)

コンソールの出力
[/ishtar/welcome/catcher.html;jsessionid=2F3..]Method: GET

「HttpSession Scope: ..」は変わりませんが、「Application Scope: ..」の箇所は更新されました。これは、後のブラウザーからのリクエストに応じて ServletContext 内のアトリビュート(appScopeText)が setAttribute(..)で更新されたからです。

因みにアドレスバーにある jsessionid=.. は、Cookie としてブラウザーに一時保存されるため、下の「コンソールの出力」が示す通り、以降は無くてもかまいません。

コンソールの出力(Chrome)
[/ishtar/welcome/index.html]Method: POST
anyText: Chromeにポスト
[/ishtar/welcome/catcher.html]Method: GET

コンソールの出力(Opera)
[/ishtar/welcome/index.html]Method: POST
anyText: Operaにポスト
[/ishtar/welcome/catcher.html]Method: GET


Servlet の Application Scope と Session Scope、そして Spring が提供する redirect: プリフィックスを組み合わせると、色々と面白いことができそうです。

2012年3月23日金曜日

RequestMapping アノテーション


まず、ここまでの流れを整理すると
  1. Deplyment Descriptor(web.xml)に、ルートWebApplicationContextの立ち上げを行うブートストラップリスナー“ContextLoaderListener”を定義。
  2. 同じく web.xml で、DispatcherServlet の論理名(servlet-name 要素)と URL パターンの紐付けを行う(servlet-mapping 要素)。
  3. [servlet-name]-servlet.xml に、Controller や HandllerMapping, ViewResolver など、関連するビーンを定義。

サーブレットへのマッピング
Java Servlet Specification Version 3.0 によると、「サーブレットへのマッピングに使われるパスは、リクエスト URL からコンテキストパスとパスパラメーターを取り除いたもの」です。

サーブレットとのマッピングに使用される URL パターンは以下のように規定されています。
  • ‘/’で始まり‘/*’で終わる文字列:パスマッピング
  • プリフィックス‘*.’で始まる文字列:拡張子マッピング
  • 空文字列:コンテキストルートへのマッピングを行うスペシャルな URL パターン
  • ‘/’のみ:アプリケーションのデフォルトサーブレットへのマッピング
  • その他の文字列:完全一致

コントローラーへのマッピング
Spring Framework のリファレンス 16.2 The DispatcherServlet にあるイラスト Context hierarchy in Spring Web MVC を見るとなんとなくわかりますが、DispatcherServlet は、[servlet-name]-servlet.xml に基づいて生成された HandlerMapping インスタンスと連携して、リクエストを適切なコントローラーに割り振ります。

@RequestMapping アノテーション
受け取ったリクエストの終着点となるクラスやメソッドを指定するのが @RequestMapping アノテーションです。

package picboo.controller;
   :
  中 略
   :
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.servlet.ModelAndView;
   :
  中 略
   :

@Controller
public class AjaxAccountController {
   :
 @RequestMapping(value="/account/ajax/registration.html", method = RequestMethod.GET)
 public ModelAndView registration( ... ) {
   :
 }

 @RequestMapping(value="/account/ajax/confirmation.html", method = RequestMethod.POST)
 public ModelAndView confirmation( ... ) {
   :
 }

 @RequestMapping(value="/account/ajax/execution.html", method = RequestMethod.POST)
 public ModelAndView execution( ... ) {
   :
 }

}

この例では、registration, confirmation, execution の各メソッドに @RequestMapping アノテーションを付けています。「Controller と Handler Mapping」で、“/account/ajax/”というリクエストを AjaxAccountController に渡す設定を行いました。今度は、呼び出された AjaxAccountController 内のメソッドとリクエストの紐付けを @RequestMapping で行っています。

value および method は、いわゆる実行条件で、上記コードでは「/account/ajax/registration.html に対する GET リクエストが来たら registration メソッドを実行」、「/account/ajax/confirmation.html に対する POST メソッドが来たら confirmation メソッドを実行」という指定を行っています。

因みに、@RequestMapping アノテーションをクラス定義の部分(public class .. { .. } の前)に使えば、リクエストをクラスレベルでマッピングできます。