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 .. { .. } の前)に使えば、リクエストをクラスレベルでマッピングできます。

2012年3月21日水曜日

DispatcherServlet



Spring Framework Reference Documentation 3.1 16.2 The DispatcherServlet より
Springのウェブ MVC フレームワークは、他の多くの MVC フレームワーク同様、リクエスト駆動(request-driven)であり、コントローラーに対するリクエストのディスパッチ、そしてウェブアプリケーションの開発を容易にするその他の機能を提供するサーブレットを中心にデザインされている。しかし、Spring の DispatcherServlet の機能は、それだけではない。Spring が持つそれらすべての機能は、Spring IoC コンテナーに完全に統合され、利用できるようになる。
つまり、Spring の MVC フレームワークを利用した Web アプリケーションを開発するためには DispatcherServlet が不可欠というわけです。DispatcherServlet の先祖を辿っていくと、HttpServlet や GenericServlet があります。

GenericServlet と HttpServlet
GenericServlet は、Servlet および ServletConfig インターフェースを実装するクラスで、Web application deployment descriptor(web.xml) に記述されたパラメータに基づくサーブレットの初期化やサーブレットコンテキストの取得、サーブレットの破棄といったサーブレットのライフサイクル管理のための汎用メソッドを提供します。それらを継承しつつ、HTTPサーブレット固有の拡張が施されたのが HttpServlet クラスです。

DispatcherServlet の定義
Spring の DispatcherServlet を利用するためには、上記の先祖クラスと同様、Servlet API 仕様に従い web.xml に DispatcherServlet に対応するサーブレット名(servlet-name 要素)やサーブレット名と URL の紐付け(servlet-mapping 要素)などを定義します。

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://java.sun.com/xml/ns/javaee"
   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
   xsi:schemaLocation="http://java.sun.com/xml/ns/javaee
   http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd"
   version="2.5">
  :
 中 略
  :
 <!-- Dispatcher -->
 <servlet>
  <servlet-name>venus</servlet-name>
  <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
  <load-on-startup>1</load-on-startup>
 </servlet>

 <servlet-mapping>
  <servlet-name>venus</servlet-name>
  <url-pattern>*.html</url-pattern>
 </servlet-mapping>
  :
 中 略
  :
</web-app>

上の例であれば「このアプリケーションは *.html に合致するパターンのリクエストを受けたら venus と名づけられた DispatcherServlet に処理を渡す」という意味の定義になります。Spring Framework は、この定義に基づき WEB-INF フォルダー内から [servlet-name]-servlet.xml を見つけ、そこに記述された定義に従って DispatcherServlet の初期化を行います。

尚、Servlet API 仕様のドキュメントは The Java Community Process をはじめ、色々なところで公開されており、その中で Deployment Descriptor の各要素についても詳しく記載されています。

2012年3月19日月曜日

ContextLoaderListener


前回の「component-scan と Stereotypeアノテーション」で、IoC Container の機能を提供する ApplicationContext インターフェース――アプリケーションを構成するための一般的な機能を提供する BeanFactory のサブインターフェース――について少し触れました。Spring Framework には、さらに ApplicationContext のサブインターフェースの一つとして Web アプリケーション向けの WebApplicationContext が用意されています。

WebApplicationContext の立ち上げ
WebApplicationContext の立ち上げは、ブートストラップリスナーである“ContextLoaderListener”が行います。そのための指定は web.xml の中で行います。

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://java.sun.com/xml/ns/javaee"
   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
   xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd"
    version="2.5">
  :
 中 略
  :
 <listener>
  <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
 </listener>
  :
 中 略
  :
 <context-param>
  <param-name>contextConfigLocation</param-name>
  <param-value>
   /WEB-INF/applicationContext.xml
  </param-value>
 </context-param>
  :
 中 略
  :
 <!-- Dispatcher -->
 <servlet>
  <servlet-name>venus</servlet-name>
  <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
  <load-on-startup>1</load-on-startup>
 </servlet>
  :
 中 略
  :
</web-app>

listener要素として web.xml に登録された ContextLoaderListener は、WebApplicationContext 立ち上げ時に ContextLoader を呼び出し、XML ファイルに記述されたメタデータに基づいてアプリケーションコンテキストの初期化を行います。この XML ファイルの所在を指定しているのが context-param 要素内の contextConfigLocation です。

Spring Web Flow や Spring Security といった複数のフレームワークを利用するような場合、それぞれに専用の XML ファイルを作る方がすっきりします。そのような時には contextConfigLocation の中の param-value 要素に必要なファイルをカンマ、あるいはスペース区切りで指定することができます。

今のところ /WEB-INF/applicationContext.xml というデフォルトの状態で事足りているため contextConfigLocation の指定は不要ですが、いずれファイルを増やしていくことになると思うので覚書的に敢えて記述しています。

2012年3月18日日曜日

component-scan と Stereotypeアノテーション


オブジェクト同士の依存性をコンストラクター引数、ファクトリーメソッドへの引数、インスタンスにセットされるプロパティといった形で注入するプロセスを Spring Framework では“IoC(Inversion of Control)”と呼んでいます。一般に“DI(dependency injection)”という呼び名で知られているこの機能を実現しているのが“IoC Container”です。

詳しくは、リファレンスの 4. The IoC Container に書かれていますが、Spring におけるビーンとは、IoC Containerによりインスタンス化され、組み立てられるオブジェクトで、そうした IoC Container の基本機能を提供するのが BeanFactory インターフェースや、そのサブインターフェースである ApplicationContext インターフェースです。

Container は、XML やソース中に記述されたアノテーションで定義されたメタデータに基づきビーンのインスタンス化、構成、組み立てを行いアプリケーションを構築します。

前回の「Controller と Handler Mapping」で、URL とコントローラーの紐付けを行いました。今回は Container にコントローラークラスを自動検出させる設定を [servlet-name]-servlet.xml とソースに施します。

context:component-scan
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
  :
    http://www.springframework.org/schema/tx/spring-tx-3.0.xsd">
  :
 中 略
  :
 <context:component-scan base-package="picboo"/>
  :
 中 略
  :

まず、servlet.xml に context:component-scan を追加し、base-package にスキャンの開始点となるクラスパスを指定します。後は、このパス階層の中に必要なクラスを作っていくだけです。

stereotypeアノテーション
package picboo.controller;
  :
import org.springframework.stereotype.Controller;
  :

@Controller
public class AccountController {
  :
}

上のソースは AccountControler.java の一部です。public class AccountController{...}の先頭に@Controller アノテーションが付いています。こうすることで Container は、context:component-scan で指定されたクラスパス階層から、コントローラークラスを自動検出できるようになります。

同様に FrontController, WelcomeController, AjaxAccountController にも、@Controller アノテーションを記述します。

この @Controller アノテーションは、stereotype アノテーションの一つで、このほかに @Service, @Repository, @Component があります。今回使用した @Controller、および @Service, @Repository は、それぞれ @Component アノテーションをコントローラー、サービス、DAO(Data Access Object)向けに特化したものです。

@Controller とアノテートされているクラスは「コントローラー」だよ、というまさにステレオタイプな関連付けを Container に行わせるのが stereotype アノテーション というわけです。