5.5. 入力チェック

Caution

本バージョンの内容は既に古くなっています。最新のガイドラインはこちらからご参照ください。

5.5.1. Overview

ユーザーが入力した値が不正かどうかを検証することは必須である。 入力値の検証は大きく分けて、

  1. 長さや形式など、文脈によらず入力値だけを見て、それが妥当かどうかを判定できる検証
  2. システムの状態によって入力値が妥当かどうかが変わる検証

がある。

1.の例としては必須チェックや、桁数チェックがあり、2.の例としては 登録済みのEMailかどうかのチェックや、注文数が在庫数以内であるかどうかのチェックが挙げられる。

本節では、基本的には前者のことを説明し、このチェックのことを「入力チェック」を呼ぶ。 後者のチェックは「業務ロジックチェック」と呼ぶ。業務ロジックチェックについては ドメイン層の実装を参照されたい。

本ガイドラインでは、基本的に入力チェックをアプリケーション層で行い、 業務ロジックチェックは、ドメイン層で行うことをポリシーとする。

Webアプリケーションの入力チェックには、サーバサイドで行うチェックと、クライアントサイド(JavaScript)で行うチェックがある。 サーバーサイドのチェックは必須であるが、クライアントサイドでも同じチェックを実施すると、 サーバー通信なしでチェック結果が分かるため、ユーザビリティが向上する。

Warning

JavaScriptによるクライアントサイドの処理は、改ざん可能であるため、サーバーサイドのチェックは、必ず行うこと。 クライアントサイドのみでチェックを行い、サーバーサイドでチェックを省略した場合は、システムが危険な状態に晒されていることになる。

Todo

クライアントサイドの入力チェックについては今後追記する。初版では、サーバーサイドの入力チェックのみ言及する。

5.5.1.1. 入力チェックの分類

入力チェックは、単項目チェック、相関項目チェックに分類される。

種類 説明 実現方法
単項目チェック
単一のフィールドで完結するチェック
入力必須チェック
桁チェック
型チェック
Bean Validation (実装ライブラリとしてHibernate Validatorを使用)
相関項目チェック
複数のフィールドを比較するチェック
パスワードと確認用パスワードの一致チェック
org.springframework.validation.Validatorインタフェースを実装したValidationクラス
または Bean Validation

Spring は、Java標準であるBean Validationをサポートしている。 単項目チェックには、このBean Validationを利用する。 相関項目チェックの場合は、Bean ValidationまたはSpringが提供しているorg.springframework.validation.Validatorインタフェースを利用する。

5.5.2. How to use

5.5.2.1. 依存ライブラリの追加

Bean Validation 1.1(Hibernate Validator 5.x)以上を使用する場合、 Bean ValidationのAPI仕様クラス(javax.validationパッケージのクラス)が格納されているjarファイルとHibernate Validatorのjarファイルに加えて、

  • Expression Language 2.2以上のAPI仕様クラス (javax.elパッケージのクラス)
  • Expression Language 2.2以上のリファレンス実装クラス

が格納されているライブラリが必要となる。

アプリケーションサーバにデプロイして動かす場合は、 これらのライブラリはアプリケーションサーバから提供されているため、 依存ライブラリの追加は不要である。 ただし、スタンドアロン環境(JUnitなど)で動かす場合は、これらのライブラリを依存ライブラリとして追加する必要がある。

スタンドアロン環境でBean Validation 1.1以上を動かす際に必要となるライブラリの追加例を以下に示す。

<!-- (1) -->
<dependency>
    <groupId>org.apache.tomcat.embed</groupId>
    <artifactId>tomcat-embed-el</artifactId>
    <scope>test</scope> <!-- (2) -->
</dependency>
項番 説明
(1)

スタンドアロン環境で動かすプロジェクトの pom.xml ファイルに、 Expression Language用のクラスが格納されているライブラリを追加する。

上記例では、組込み用のApache Tomcat向けに提供されているライブラリを指定している。 tomcat-embed-elのjarファイルには、Expression LanguageのAPI仕様クラスとリファレンス実装クラスの両方が格納されている。

(2)
JUnitを実行するために依存ライブラリが必要になる場合は、スコープは testが適切である。

Note

上記設定例では、依存ライブラリのバージョンは親プロジェクトで管理する前提である。 そのため、<version>要素は指定していない。

5.5.2.2. 単項目チェック

単項目チェックを実装するには、

  • フォームクラスのフィールドに、Bean Validation用のアノテーションを付与する
  • Controllerに、検証するための@Validatedアノテーションを付与する
  • JSPに、検証エラーメッセージを表示するためのタグを追加する

が必要である。

Note

spring-mvc.xmlに<mvc:annotation-driven>の設定が行われていれば、Bean Validationは有効になる。

5.5.2.2.1. 基本的な単項目チェック

「新規ユーザー登録」処理を例に用いて、実装方法を説明する。ここでは「新規ユーザー登録」のフォームに、以下のチェックルールを設ける。

フィールド名 ルール
name
java.lang.String
入力必須
1文字以上
20文字以下
email
java.lang.String
入力必須
1文字以上
50文字以下
Email形式
age
java.lang.Integer
入力必須
1以上
200以下
  • フォームクラス

    フォームクラスの各フィールドに、Bean Validationのアノテーションを付ける。

    package com.example.sample.app.validation;
    
    import java.io.Serializable;
    
    import javax.validation.constraints.Max;
    import javax.validation.constraints.Min;
    import javax.validation.constraints.NotNull;
    import javax.validation.constraints.Size;
    
    import org.hibernate.validator.constraints.Email;
    
    public class UserForm implements Serializable {
    
      private static final long serialVersionUID = 1L;
    
      @NotNull // (1)
      @Size(min = 1, max = 20) // (2)
      private String name;
    
      @NotNull
      @Size(min = 1, max = 50)
      @Email // (3)
      private String email;
    
      @NotNull // (4)
      @Min(0) // (5)
      @Max(200) // (6)
      private Integer age;
    
      // omitted setter/getter
    }
    
    項番 説明
    (1)
    対象のフィールドがnullでないことを示すjavax.validation.constraints.NotNullを付ける。

    Spring MVCでは、文字列の入力フィールドに未入力の状態でフォームを送信した場合、
    デフォルトではフォームオブジェクトにnullではなく、空文字がバインドされる
    この@NotNullは、そもそもリクエストパラメータとしてnameが存在することをチェックする。
    (2)
    対象のフィールドの文字列長(またはコレクションのサイズ)が指定したサイズの範囲内にあることを示すjavax.validation.constraints.Sizeを付ける。

    上記の通り、Spring MVCではデフォルトで、未入力の文字列フィールドには、空文字がバインドされるため、
    1文字以上というルールが入力必須を表す。
    (3)
    対象のフィールドがRFC2822準拠のE-mail形式であることを示すorg.hibernate.validator.constraints.Emailを付ける。
    E-mail形式の要件がRFC2822準拠の制限よりも緩い場合は、@Emailを使用せず、javax.validation.constraints.Patternを用いて、正規表現を指定する必要がある。
    (4)
    数値の入力フィールドに未入力の状態でフォームを送信した場合、フォームオブジェクトにnull がバインドされるため、@NotNullageの入力必須条件を表す。
    (5)
    対象のフィールドが指定した数値の以上であることを示すjavax.validation.constraints.Minを付ける。
    (6)
    対象のフィールドが指定した数値の以下であることを示すjavax.validation.constraints.Maxを付ける。

    Tip

    Bean Validation標準のアノテーション、Hibernate Validationが用意しているアノテーションについては、Bean ValidationのチェックルールHibernate Validatorのチェックルールを参照されたい。

    Tip

    入力フィールドが未入力の場合に、空文字ではなくnullにバインドする方法に関しては、文字列フィールドが未入力の場合にnullをバインドするを参照されたい、

  • Controllerクラス

    入力チェック対象のフォームクラスに、@Validatedを付ける。

    package com.example.sample.app.validation;
    
    import org.springframework.stereotype.Controller;
    import org.springframework.validation.BindingResult;
    import org.springframework.validation.annotation.Validated;
    import org.springframework.web.bind.annotation.ModelAttribute;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RequestMethod;
    
    @Controller
    @RequestMapping("user")
    public class UserController {
    
      @ModelAttribute
      public UserForm setupForm() {
        return new UserForm();
      }
    
      @RequestMapping(value = "create", method = RequestMethod.GET, params = "form")
      public String createForm() {
        return "user/createForm"; // (1)
      }
    
      @RequestMapping(value = "create", method = RequestMethod.POST, params = "confirm")
      public String createConfirm(@Validated /* (2) */ UserForm form, BindingResult /* (3) */ result) {
        if (result.hasErrors()) { // (4)
          return "user/createForm";
        }
        return "user/createConfirm";
      }
    
      @RequestMapping(value = "create", method = RequestMethod.POST)
      public String create(@Validated UserForm form, BindingResult result) { // (5)
        if (result.hasErrors()) {
          return "user/createForm";
        }
        // omitted business logic
        return "redirect:/user/create?complete";
      }
    
      @RequestMapping(value = "create", method = RequestMethod.GET, params = "complete")
      public String createComplete() {
        return "user/createComplete";
      }
    }
    
    項番 説明
    (1)
    「新規ユーザー登録」フォーム画面を表示する。
    (2)
    フォームにつけたアノテーションで入力チェックをするために、フォームの引数に org.springframework.validation.annotation.Validatedを付ける。
    (3)
    (2)のチェック結果を格納するorg.springframework.validation.BindingResultを、引数に加える。
    このBindingResultは、フォームの直後に記述する必要がある。

    直後に指定されていない場合は、検証後に結果をバインドできず、org.springframework.validation.BindExceptionがスローされる。
    (4)
    (2)のチェック結果は、BindingResult.hasErrors()メソッドで判定できる。
    hasErrors()の結果がtrueの場合は、入力値に問題があるため、フォーム表示画面に戻す。
    (5)
    入力内容確認画面から新規作成処理にリクエストを送る際にも、入力チェックを必ず再実行すること
    途中でデータを改ざんすることは可能であるため、必ず業務処理の直前で入力チェックは必要である。

    Note

    @Validatedは、Bean Validation標準ではなく、Springの独自アノテーションである。 Bean Validation標準のjavax.validation.Validアノテーションも使用できるが、@Validated@Validに比べて、 バリデーションのグループを指定できる点で優れているため、本ガイドラインではControllerの引数には、@Validatedを使用することを推奨する。

  • JSP

    <form:errors>タグで、入力エラーがある場合にエラーメッセージを表示できる。

    <!DOCTYPE html>
    <html>
    <%-- WEB-INF/views/user/createForm.jsp --%>
    <body>
        <form:form modelAttribute="userForm" method="post"
            action="${pageContext.request.contextPath}/user/create">
            <form:label path="name">Name:</form:label>
            <form:input path="name" />
            <form:errors path="name" /><%--(1) --%>
            <br>
            <form:label path="email">Email:</form:label>
            <form:input path="email" />
            <form:errors path="email" />
            <br>
            <form:label path="age">Age:</form:label>
            <form:input path="age" />
            <form:errors path="age" />
            <br>
            <form:button name="confirm">Confirm</form:button>
        </form:form>
    </body>
    </html>
    
    項番 説明
    (1)
    <form:errors>タグのpath属性に、対象のフィールド名を指定する。
    この例では、フィールド毎に入力フィールドの横にエラーメッセージを表示する。

フォームは、以下のように表示される。

../_images/validations-first-sample1.png

このフォームに対して、すべての入力フィールドを未入力のまま送信すると、以下のようにエラーメッセージが表示される。

../_images/validations-first-sample2.png

NameとEmailが空文字であることに対するエラーメッセージと、Agがnullであることに対するエラーメッセージが表示されている。

Note

Bean Validationでは、通常、入力値がnullの場合は正常な値とみなす。ただし、 以下のアノテーションを除く。

  • javax.validation.constraints.NotNull
  • org.hibernate.validator.constraints.NotEmpty
  • org.hibernate.validator.constraints.NotBlank

上記の例では、Ageの値はnullであるため、@Min@Maxによるチェックは正常とみなされ、 エラーメッセージは出力されていない。

次に、フィールドに何らかの値を入力してフォームを送信する。

../_images/validations-first-sample3.png
Nameの入力値は、チェック条件を満たすため、エラーメッセージが表示されない。
Emailの入力値は文字列長に関する条件は満たすが、Email形式ではないため、エラーメッセージが表示される。
Ageの入力値は最大値を超えているため、エラーメッセージが表示される。

エラー時にスタイルを変更したい場合は、前述のフォームを、以下のように変更する。

<form:form modelAttribute="userForm" method="post"
    class="form-horizontal"
    action="${pageContext.request.contextPath}/user/create">
    <form:label path="name" cssErrorClass="error-label">Name:</form:label><%-- (1) --%>
    <form:input path="name" cssErrorClass="error-input" /><%-- (2) --%>
    <form:errors path="name" cssClass="error-messages" /><%-- (3) --%>
    <br>
    <form:label path="email" cssErrorClass="error-label">Email:</form:label>
    <form:input path="email" cssErrorClass="error-input" />
    <form:errors path="email" cssClass="error-messages" />
    <br>
    <form:label path="age" cssErrorClass="error-label">Age:</form:label>
    <form:input path="age" cssErrorClass="error-input" />
    <form:errors path="age" cssClass="error-messages" />
    <br>
    <form:button name="confirm">Confirm</form:button>
</form:form>
項番 説明
(1)
エラー時に<label>タグへ加えるクラス名を、cssErrorClass属性で指定する。
(2)
エラー時に<input>タグへ加えるクラス名を、cssErrorClass属性で指定する。
(3)
エラーメッセージに加えるクラス名を、cssClass属性で指定する。

このJSPに対して、例えば以下のCSSを適用すると、

.form-horizontal input {
    display: block;
    float: left;
}

.form-horizontal label {
    display: block;
    float: left;
    text-align: right;
    float: left;
}

.form-horizontal br {
    clear: left;
}

.error-label {
    color: #b94a48;
}

.error-input {
    border-color: #b94a48;
    margin-left: 5px;
}

.error-messages {
    color: #b94a48;
    display: block;
    padding-left: 5px;
    overflow-x: auto;
}

エラー画面は、以下のように表示される。

../_images/validations-has-errors1.png

画面の要件に応じてCSSをカスタマイズすればよい。

エラーメッセージを、入力フィールドの横に一件一件出力する代わりに、 まとめて出力することもできる。

<form:form modelAttribute="userForm" method="post"
    action="${pageContext.request.contextPath}/user/create">
    <form:errors path="*" element="div" cssClass="error-message-list" /><%-- (1) --%>

    <form:label path="name" cssErrorClass="error-label">Name:</form:label>
    <form:input path="name" cssErrorClass="error-input" />
    <br>
    <form:label path="email" cssErrorClass="error-label">Email:</form:label>
    <form:input path="email" cssErrorClass="error-input" />
    <br>
    <form:label path="age" cssErrorClass="error-label">Age:</form:label>
    <form:input path="age" cssErrorClass="error-input" />
    <br>
    <form:button name="confirm">Confirm</form:button>
</form:form>
項番 説明
(1)
<form:form>タグ内で、<form:errors>path属性に*を指定することで、
<form:form>modelAttribute属性に指定したModelに関する全エラーメッセージを出力できる。
element属性に、これらのエラーメッセージを包含するタグ名を指定できる。デフォルトでは、spanであるが、
ここではエラーメッセージ一覧をブロック要素として出力するために、divを指定する。
また、CSSのクラスを cssClass属性に指定する。

例として、以下のCSSクラスを適用した場合の、エラーメッセージ出力例を示す。

.form-horizontal input {
    display: block;
    float: left;
}

.form-horizontal label {
    display: block;
    float: left;
    text-align: right;
    float: left;
}

.form-horizontal br {
    clear: left;
}

.error-label {
    color: #b94a48;
}

.error-input {
    border-color: #b94a48;
    margin-left: 5px;
}

.error-message-list {
    color: #b94a48;
    padding:5px 10px;
    background-color: #fde9f3;
    border:1px solid #c98186;
    border-radius:5px;
    margin-bottom: 10px;
}
../_images/validations-has-errors2.png
デフォルトでは、エラーメッセージにフィールド名は含まれず、どのフィールドのエラーメッセージなのかが分かりにくい。
そのため、エラーメッセージを一覧で表示する場合は、エラーメッセージの中にフィールド名を含めるようにメッセージを定義する必要がある。
エラーメッセージの定義方法については、「エラーメッセージの定義」を参照されたい。

Note

エラーメッセージを一覧で表示する際の注意点

エラーメッセージの出力順序は順不同であり、標準機能で出力順序を制御することはできない。 そのため、出力順序を制御する(一定に保つ)必要がある場合は、エラー情報をソートするなどの拡張実装が必要となる。

「エラーメッセージを一覧で表示する」方式では、

  • フィード単位のエラーメッセージ定義
  • エラーメッセージの出力順序を制御するための拡張実装

が必要となるため、「入力フィールドの横にエラーメッセージを表示する」方式に比べて対応コストが高くなる。 本ガイドラインでは、画面要件による制約がない場合は「入力フィールドの横にエラーメッセージを表示する」方式を推奨する。

なお、エラーメッセージの出力順序を制御するための拡張方法としては、 Spring Frameworkから提供されているorg.springframework.validation.beanvalidation.LocalValidatorFactoryBeanの継承クラスを作成し、 processConstraintViolationsメソッドをオーバーライドしてエラー情報をソートする方法などが考えられる。

Note

@GroupSequenceアノテーションについて

チェック順番を制御するための仕組みとして@GroupSequenceアノテーションが提供されているが、 この仕組みは以下のような動作になるため、エラーメッセージの出力順序を制御するための仕組みではないという点を補足しておく。

  • エラーが発生した場合に後続のグループのチェックが実行されない。
  • 同一グループ内のチェックで複数のエラー(複数の項目でエラー)が発生するとエラーメッセージの出力順序は順不同になる。

Note

エラーメッセージまとめて表示する際に、<form:form>タグの外に表示したい場合は以下のように<spring:nestedPath>タグを使用する。

<spring:nestedPath path="userForm">
    <form:errors path="*" element="div"
        cssClass="error-message-list" />
</spring:nestedPath>
<hr>
<form:form modelAttribute="userForm" method="post"
    action="${pageContext.request.contextPath}/user/create">
    <form:label path="name" cssErrorClass="error-label">Name:</form:label>
    <form:input path="name" cssErrorClass="error-input" />
    <br>
    <form:label path="email" cssErrorClass="error-label">Email:</form:label>
    <form:input path="email" cssErrorClass="error-input" />
    <br>
    <form:label path="age" cssErrorClass="error-label">Age:</form:label>
    <form:input path="age" cssErrorClass="error-input" />
    <br>
    <form:button name="confirm">Confirm</form:button>
</form:form>

5.5.2.2.2. ネストしたBeanの単項目チェック

ネストしたBeanをBean Validationで検証する方法を説明する。

ECサイトにおける「注文」処理の例を考える。「注文」フォームでは、以下のチェックルールを設ける。

フィールド名 ルール 説明
coupon
java.lang.String
5文字以下
半角英数字
クーポンコード
receiverAddress.name
java.lang.String
入力必須
1文字以上
50文字以下
お届け先氏名
receiverAddress.postcode
java.lang.String
入力必須
1文字以上
10文字以下
お届け先郵便番号
receiverAddress.address
java.lang.String
入力必須
1文字以上
100文字以下
お届け先住所
senderAddress.name
java.lang.String
入力必須
1文字以上
50文字以下
請求先氏名
senderAddress.postcode
java.lang.String
入力必須
1文字以上
10文字以下
請求先郵便番号
senderAddress.address
java.lang.String
入力必須
1文字以上
100文字以下
請求先住所

receiverAddresssenderAddressは、同じ項目であるため、同じフォームクラスを使用する。

  • フォームクラス

    package com.example.sample.app.validation;
    
    import java.io.Serializable;
    
    import javax.validation.Valid;
    import javax.validation.constraints.NotNull;
    import javax.validation.constraints.Pattern;
    import javax.validation.constraints.Size;
    
    public class OrderForm implements Serializable {
        private static final long serialVersionUID = 1L;
    
        @Size(max = 5)
        @Pattern(regexp = "[a-zA-Z0-9]*")
        private String coupon;
    
        @NotNull // (1)
        @Valid // (2)
        private AddressForm receiverAddress;
    
        @NotNull
        @Valid
        private AddressForm senderAddress;
    
        // omitted setter/getter
    }
    
    package com.example.sample.app.validation;
    
    import java.io.Serializable;
    
    import javax.validation.constraints.NotNull;
    import javax.validation.constraints.Size;
    
    public class AddressForm implements Serializable {
        private static final long serialVersionUID = 1L;
    
        @NotNull
        @Size(min = 1, max = 50)
        private String name;
    
        @NotNull
        @Size(min = 1, max = 10)
        private String postcode;
    
        @NotNull
        @Size(min = 1, max = 100)
        private String address;
    
        // omitted setter/getter
    }
    
    項番 説明
    (1)
    子フォーム自体が必須であることを示す。
    この設定がない場合、receiverAddressnullが設定されても、正常とみなされる。
    (2)
    ネストしたBeanのBean Validationを有効にするために、javax.validation.Validアノテーションを付与する。
  • Controllerクラス

    前述のControllerと違いはない。

    package com.example.sample.app.validation;
    
    import org.springframework.stereotype.Controller;
    import org.springframework.validation.BindingResult;
    import org.springframework.validation.annotation.Validated;
    import org.springframework.web.bind.annotation.ModelAttribute;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RequestMethod;
    
    @RequestMapping("order")
    @Controller
    public class OrderController {
    
        @ModelAttribute
        public OrderForm setupForm() {
            return new OrderForm();
        }
    
        @RequestMapping(value = "order", method = RequestMethod.GET, params = "form")
        public String orderForm() {
            return "order/orderForm";
        }
    
        @RequestMapping(value = "order", method = RequestMethod.POST, params = "confirm")
        public String orderConfirm(@Validated OrderForm form, BindingResult result) {
            if (result.hasErrors()) {
                return "order/orderForm";
            }
            return "order/orderConfirm";
        }
    }
    
  • JSP

    <!DOCTYPE html>
    <html>
    <%-- WEB-INF/views/order/orderForm.jsp --%>
    <head>
    <style type="text/css">
      /* omitted (same as previous sample) */
    </style>
    </head>
    <body>
        <form:form modelAttribute="orderForm" method="post"
            class="form-horizontal"
            action="${pageContext.request.contextPath}/order/order">
            <form:label path="coupon" cssErrorClass="error-label">Coupon Code:</form:label>
            <form:input path="coupon" cssErrorClass="error-input" />
            <form:errors path="coupon" cssClass="error-messages" />
            <br>
        <fieldset>
            <legend>Receiver</legend>
            <%-- (1) --%>
            <form:errors path="receiverAddress"
                cssClass="error-messages" />
            <%-- (2) --%>
            <form:label path="receiverAddress.name"
                cssErrorClass="error-label">Name:</form:label>
            <form:input path="receiverAddress.name"
                cssErrorClass="error-input" />
            <form:errors path="receiverAddress.name"
                cssClass="error-messages" />
            <br>
            <form:label path="receiverAddress.postcode"
                cssErrorClass="error-label">Postcode:</form:label>
            <form:input path="receiverAddress.postcode"
                cssErrorClass="error-input" />
            <form:errors path="receiverAddress.postcode"
                cssClass="error-messages" />
            <br>
            <form:label path="receiverAddress.address"
                cssErrorClass="error-label">Address:</form:label>
            <form:input path="receiverAddress.address"
                cssErrorClass="error-input" />
            <form:errors path="receiverAddress.address"
                cssClass="error-messages" />
        </fieldset>
        <br>
        <fieldset>
            <legend>Sender</legend>
            <form:errors path="senderAddress"
                cssClass="error-messages" />
            <form:label path="senderAddress.name"
                cssErrorClass="error-label">Name:</form:label>
            <form:input path="senderAddress.name"
                cssErrorClass="error-input" />
            <form:errors path="senderAddress.name"
                cssClass="error-messages" />
            <br>
            <form:label path="senderAddress.postcode"
                cssErrorClass="error-label">Postcode:</form:label>
            <form:input path="senderAddress.postcode"
                cssErrorClass="error-input" />
            <form:errors path="senderAddress.postcode"
                cssClass="error-messages" />
            <br>
            <form:label path="senderAddress.address"
                cssErrorClass="error-label">Address:</form:label>
            <form:input path="senderAddress.address"
                cssErrorClass="error-input" />
            <form:errors path="senderAddress.address"
                cssClass="error-messages" />
        </fieldset>
    
            <form:button name="confirm">Confirm</form:button>
        </form:form>
    </body>
    </html>
    
    項番 説明
    (1)
    不正な操作により、receiverAddress.namereceiverAddress.postcodereceiverAddress.addressのすべて
    がリクエストパラメータとして送信されない場合、receiverAddressnullとみなされ、この位置にエラーメッセージが表示される。
    (2)
    子フォームのフィールドは、親フィールド名.子フィールド名で指定する。

フォームは、以下のように表示される。

../_images/validations-nested1.png

このフォームに対して、すべての入力フィールドを未入力のまま送信すると、以下のようにエラーメッセージが表示される。

../_images/validations-nested2.png

ネストしたBeanのバリデーションはコレクションに対しても有効である。

最初に説明した「ユーザー登録」フォームに住所を3件まで登録できるようにフィールドを追加する。 住所には、前述のAddressFormを利用する。

  • フォームクラス AddressFormのリストを、フィールドに追加する。

    package com.example.sample.app.validation;
    
    import java.io.Serializable;
    import java.util.List;
    
    import javax.validation.Valid;
    import javax.validation.constraints.Max;
    import javax.validation.constraints.Min;
    import javax.validation.constraints.NotNull;
    import javax.validation.constraints.Size;
    
    import org.hibernate.validator.constraints.Email;
    
    public class UserForm implements Serializable {
    
        private static final long serialVersionUID = 1L;
    
        @NotNull
        @Size(min = 1, max = 20)
        private String name;
    
        @NotNull
        @Size(min = 1, max = 50)
        @Email
        private String email;
    
        @NotNull
        @Min(0)
        @Max(200)
        private Integer age;
    
        @NotNull
        @Size(min = 1, max = 3) // (1)
        @Valid
        private List<AddressForm> addresses;
    
        // omitted setter/getter
    }
    
    項番 説明
    (1)
    コレクションのサイズチェックにも、@Sizeアノテーションを使用できる。
  • JSP

    <!DOCTYPE html>
    <html>
    <%-- WEB-INF/views/user/createForm.jsp --%>
    <head>
    <style type="text/css">
      /* omitted (same as previous sample) */
    </style>
    </head>
    <body>
    
        <form:form modelAttribute="userForm" method="post"
            class="form-horizontal"
            action="${pageContext.request.contextPath}/user/create">
            <form:label path="name" cssErrorClass="error-label">Name:</form:label>
            <form:input path="name" cssErrorClass="error-input" />
            <form:errors path="name" cssClass="error-messages" />
            <br>
            <form:label path="email" cssErrorClass="error-label">Email:</form:label>
            <form:input path="email" cssErrorClass="error-input" />
            <form:errors path="email" cssClass="error-messages" />
            <br>
            <form:label path="age" cssErrorClass="error-label">Age:</form:label>
            <form:input path="age" cssErrorClass="error-input" />
            <form:errors path="age" cssClass="error-messages" />
            <br>
            <form:errors path="addresses" cssClass="error-messages" /><%-- (1) --%>
            <c:forEach items="${userForm.addresses}" varStatus="status"><%-- (2) --%>
                <fieldset class="address">
                    <legend>Address${f:h(status.index + 1)}</legend>
                    <form:label path="addresses[${status.index}].name"
                        cssErrorClass="error-label">Name:</form:label><%-- (3) --%>
                    <form:input path="addresses[${status.index}].name"
                        cssErrorClass="error-input" />
                    <form:errors path="addresses[${status.index}].name"
                        cssClass="error-messages" />
                    <br>
                    <form:label path="addresses[${status.index}].postcode"
                        cssErrorClass="error-label">Postcode:</form:label>
                    <form:input path="addresses[${status.index}].postcode"
                        cssErrorClass="error-input" />
                    <form:errors path="addresses[${status.index}].postcode"
                        cssClass="error-messages" />
                    <br>
                    <form:label path="addresses[${status.index}].address"
                        cssErrorClass="error-label">Address:</form:label>
                    <form:input path="addresses[${status.index}].address"
                        cssErrorClass="error-input" />
                    <form:errors path="addresses[${status.index}].address"
                        cssClass="error-messages" />
                    <c:if test="${status.index > 0}">
                        <br>
                        <button class="remove-address-button">Remove</button>
                    </c:if>
                </fieldset>
                <br>
            </c:forEach>
            <button id="add-address-button">Add address</button>
            <br>
            <form:button name="confirm">Confirm</form:button>
        </form:form>
        <script type="text/javascript"
            src="${pageContext.request.contextPath}/resources/vendor/js/jquery-1.10.2.min.js"></script>
        <script type="text/javascript"
            src="${pageContext.request.contextPath}/resources/app/js/AddressesView.js"></script>
    </body>
    </html>
    
    項番 説明
    (1)
    addressフィールドに対するエラーメッセージを表示する。
    (2)
    子フォームのコレクションを、<c:forEach>タグを使ってループで処理する。
    (3)
    コレクション中の子フォームのフィールドは、親フィールド名[インデックス].子フィールド名で指定する。
  • Controllerクラス

    package com.example.sample.app.validation;
    
    import java.util.ArrayList;
    import java.util.List;
    
    import org.springframework.stereotype.Controller;
    import org.springframework.validation.BindingResult;
    import org.springframework.validation.annotation.Validated;
    import org.springframework.web.bind.annotation.ModelAttribute;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RequestMethod;
    
    @Controller
    @RequestMapping("user")
    public class UserController {
    
        @ModelAttribute
        public UserForm setupForm() {
            UserForm form = new UserForm();
            List<AddressForm> addresses = new ArrayList<AddressForm>();
            addresses.add(new AddressForm());
            form.setAddresses(addresses); // (1)
            return form;
        }
    
        @RequestMapping(value = "create", method = RequestMethod.GET, params = "form")
        public String createForm() {
            return "user/createForm";
        }
    
        @RequestMapping(value = "create", method = RequestMethod.POST, params = "confirm")
        public String createConfirm(@Validated UserForm form, BindingResult result) {
            if (result.hasErrors()) {
                return "user/createForm";
            }
            return "user/createConfirm";
        }
    }
    
    項番 説明
    (1)
    「ユーザー登録」フォーム初期表示時に、一件の住所フォームを表示させるために、フォームオブジェクトを編集する。
  • JavaScript

    動的にアドレス入力フィールドを追加するためのJavaScriptも記載するが、このコードの説明は、本質的ではないため割愛する。

    // webapp/resources/app/js/AddressesView.js
    
    function AddressesView() {
      this.addressSize = $('fieldset.address').size();
    };
    
    AddressesView.prototype.addAddress = function() {
      var $address = $('fieldset.address');
      var newHtml = addressTemplate(this.addressSize++);
      $address.last().next().after($(newHtml));
    };
    
    AddressesView.prototype.removeAddress = function($fieldset) {
      $fieldset.next().remove(); // remove <br>
      $fieldset.remove(); // remove <fieldset>
    };
    
    function addressTemplate(number) {
      return '\
    <fieldset class="address">\
        <legend>Address' + (number + 1) + '</legend>\
        <label for="addresses' + number + '.name">Name:</label>\
        <input id="addresses' + number + '.name" name="addresses[' + number + '].name" type="text" value=""/><br>\
        <label for="addresses' + number + '.postcode">Postcode:</label>\
        <input id="addresses' + number + '.postcode" name="addresses[' + number + '].postcode" type="text" value=""/><br>\
        <label for="addresses' + number + '.address">Address:</label>\
        <input id="addresses' + number + '.address" name="addresses[' + number + '].address" type="text" value=""/><br>\
        <button class="remove-address-button">Remove</button>\
    </fieldset>\
    <br>\
    ';
    }
    
    $(function() {
      var addressesView = new AddressesView();
    
      $('#add-address-button').on('click', function(e) {
        e.preventDefault();
        addressesView.addAddress();
      });
    
      $(document).on('click', '.remove-address-button', function(e) {
        if (this === e.target) {
          e.preventDefault();
          var $this = $(this); // this button
          var $fieldset = $this.parent(); // fieldset
          addressesView.removeAddress($fieldset);
        }
      });
    
    });
    

フォームは、以下のように表示される。

../_images/validations-nested-collection1.png

「Add address」ボタンを2回押して、住所フォームを2件追加する。

../_images/validations-nested-collection2.png

このフォームに対して、すべての入力フィールドを未入力のまま送信すると、以下のようにエラーメッセージが表示される。

../_images/validations-nested-collection3.png

5.5.2.2.3. バリデーションのグループ化

バリデーショングループを作成し、一つのフィールドに対して、グループごとに入力チェックルールを指定することができる。

前述の「新規ユーザー登録」の例で、ageフィールドに「成年であること」というルールを追加する。 「成年」かどうかは国によってルールが違うため、countryフィールドも追加する。

Bean Validationでグループを指定する場合、アノテーションのgroup属性に、グループを示す任意のjava.lang.Classオブジェクトを設定する。

ここでは、以下の3グループ(interface)を作成する。

グループ 成人条件
Chinese 18歳以上
Japanese 20歳以上
Singaporean 21歳以上

このグループをつかって、バリデーションを実行する例を示す。

  • フォームクラス

    package com.example.sample.app.validation;
    
    import java.io.Serializable;
    import java.util.List;
    
    import javax.validation.Valid;
    import javax.validation.constraints.Max;
    import javax.validation.constraints.Min;
    import javax.validation.constraints.NotNull;
    import javax.validation.constraints.Size;
    
    import org.hibernate.validator.constraints.Email;
    
    public class UserForm implements Serializable {
    
        private static final long serialVersionUID = 1L;
    
        // (1)
        public static interface Chinese {
        };
    
        public static interface Japanese {
        };
    
        public static interface Singaporean {
        };
    
        @NotNull
        @Size(min = 1, max = 20)
        private String name;
    
        @NotNull
        @Size(min = 1, max = 50)
        @Email
        private String email;
    
        @NotNull
        @Min.List({ // (2)
                @Min(value = 18, groups = Chinese.class), // (3)
                @Min(value = 20, groups = Japanese.class),
                @Min(value = 21, groups = Singaporean.class)
                })
        @Max(200)
        private Integer age;
    
        @NotNull
        @Size(min = 2, max = 2)
        private String country; // (4)
    
        // omitted setter/getter
    }
    
    項番 説明
    (1)
    グループクラスを指定するために、各グループをインタフェースで定義する。
    (2)
    一つのフィールドに同じルールを複数指定するために、@Min.Listアノテーションを使用する。
    他のアノテーションを使用する場合も同様である。
    (3)
    各グループごとにルールを定義し、グループを指定するために、group属性に対象のグループクラスを指定する。
    group属性を省略した場合、javax.validation.groups.Defaultグループが使用される。
    (4)
    グループを振り分けるための、フィールドを追加する。
  • JSP

    JSPに大きな変更はない。

    <form:form modelAttribute="userForm" method="post"
        class="form-horizontal"
        action="${pageContext.request.contextPath}/user/create">
        <form:label path="name" cssErrorClass="error-label">Name:</form:label>
        <form:input path="name" cssErrorClass="error-input" />
        <form:errors path="name" cssClass="error-messages" />
        <br>
        <form:label path="email" cssErrorClass="error-label">Email:</form:label>
        <form:input path="email" cssErrorClass="error-input" />
        <form:errors path="email" cssClass="error-messages" />
        <br>
        <form:label path="age" cssErrorClass="error-label">Age:</form:label>
        <form:input path="age" cssErrorClass="error-input" />
        <form:errors path="age" cssClass="error-messages" />
        <br>
        <form:label path="country" cssErrorClass="error-label">Country:</form:label>
        <form:select path="country" cssErrorClass="error-input">
            <form:option value="cn">China</form:option>
            <form:option value="jp">Japan</form:option>
            <form:option value="sg">Singapore</form:option>
        </form:select>
        <form:errors path="country" cssClass="error-messages" />
        <br>
        <form:button name="confirm">Confirm</form:button>
    </form:form>
    
  • Controllerクラス

    @Validatedに、対象のグループを設定することで、バリデーションルールを変更できる。

    package com.example.sample.app.validation;
    
    
    import javax.validation.groups.Default;
    
    import org.springframework.stereotype.Controller;
    import org.springframework.validation.BindingResult;
    import org.springframework.validation.annotation.Validated;
    import org.springframework.web.bind.annotation.ModelAttribute;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RequestMethod;
    
    import com.example.sample.app.validation.UserForm.Chinese;
    import com.example.sample.app.validation.UserForm.Japanese;
    import com.example.sample.app.validation.UserForm.Singaporean;
    
    @Controller
    @RequestMapping("user")
    public class UserController {
    
        @ModelAttribute
        public UserForm setupForm() {
            UserForm form = new UserForm();
            return form;
        }
    
        @RequestMapping(value = "create", method = RequestMethod.GET, params = "form")
        public String createForm() {
            return "user/createForm";
        }
    
        String createConfirm(UserForm form, BindingResult result) {
            if (result.hasErrors()) {
                return "user/createForm";
            }
            return "user/createConfirm";
        }
    
        @RequestMapping(value = "create", method = RequestMethod.POST, params = {
                "confirm",  /* (1) */ "country=cn" })
        public String createConfirmForChinese(@Validated({ /* (2) */ Chinese.class,
                Default.class }) UserForm form, BindingResult result) {
            return createConfirm(form, result);
        }
    
        @RequestMapping(value = "create", method = RequestMethod.POST, params = {
                "confirm", "country=jp" })
        public String createConfirmForJapanese(@Validated({ Japanese.class,
                Default.class }) UserForm form, BindingResult result) {
            return createConfirm(form, result);
        }
    
        @RequestMapping(value = "create", method = RequestMethod.POST, params = {
                "confirm", "country=sg" })
        public String createConfirmForSingaporean(@Validated({ Singaporean.class,
                Default.class }) UserForm form, BindingResult result) {
            return createConfirm(form, result);
        }
    }
    
    項番 説明
    (1)
    グループを振り分けるためのパラメータの条件を、param属性に追加する。
    (2)
    ageフィールドの@Min以外のアノテーションは、Defaultグループに属しているため、Defaultの指定も必要である。

この例では、各入力値の組み合わせに対するチェック結果は、以下の表の通りである。

ageの値 countryの値 入力チェック結果 エラーメッセージ
17
cn
NG
must be greater than or equal to 18

jp
NG
must be greater than or equal to 20

sg
NG
must be greater than or equal to 21
18
cn
OK


jp
NG
must be greater than or equal to 20

sg
NG
must be greater than or equal to 21
20
cn
OK


jp
OK


sg
NG
must be greater than or equal to 21
21
cn
OK


jp
OK


sg
OK

Warning

このControllerの実装は、countryの値が、”cn”、”jp”、”sg”のいづれでもない場合のハンドリングが行われておらず、不十分である。 countryの値が、想定外の場合に、400エラーが返却される。

次にチェック対象の国が増えたため、成人条件18歳以上をデフォルトルールとしたい場合を考える。

ルールは、以下のようになる。

グループ 成人条件
Japanese 20歳以上
Singaporean 21歳以上
上記以外の国(Default) 18歳以上
  • フォームクラス

    Defaultグループに意味を持たせるため、@Min以外のアノテーションにも、明示的に全グループを指定する必要がある。

    package com.example.sample.app.validation;
    
    import java.io.Serializable;
    import java.util.List;
    
    import javax.validation.Valid;
    import javax.validation.constraints.Max;
    import javax.validation.constraints.Min;
    import javax.validation.constraints.NotNull;
    import javax.validation.constraints.Size;
    import javax.validation.groups.Default;
    
    import org.hibernate.validator.constraints.Email;
    
    public class UserForm implements Serializable {
    
        private static final long serialVersionUID = 1L;
    
        public static interface Japanese {
        };
    
        public static interface Singaporean {
        };
    
        @NotNull(groups = { Default.class, Japanese.class, Singaporean.class }) // (1)
        @Size(min = 1, max = 20, groups = { Default.class, Japanese.class,
                Singaporean.class })
        private String name;
    
        @NotNull(groups = { Default.class, Japanese.class, Singaporean.class })
        @Size(min = 1, max = 50, groups = { Default.class, Japanese.class,
                Singaporean.class })
        @Email(groups = { Default.class, Japanese.class, Singaporean.class })
        private String email;
    
        @NotNull(groups = { Default.class, Japanese.class, Singaporean.class })
        @Min.List({
                @Min(value = 18, groups = Default.class), // (2)
                @Min(value = 20, groups = Japanese.class),
                @Min(value = 21, groups = Singaporean.class) })
        @Max(200)
        private Integer age;
    
        @NotNull(groups = { Default.class, Japanese.class, Singaporean.class })
        @Size(min = 2, max = 2, groups = { Default.class, Japanese.class,
                Singaporean.class })
        private String country;
    
        // omitted setter/getter
    }
    
    項番 説明
    (1)
    ageフィールドの@Min以外のアノテーションにも、全グループを設定する。
    (2)
    Defaultグループに対するルールを設定する。
  • JSP

    JSPに変更はない

  • Controllerクラス

    package com.example.sample.app.validation;
    
    import org.springframework.stereotype.Controller;
    import org.springframework.validation.BindingResult;
    import org.springframework.validation.annotation.Validated;
    import org.springframework.web.bind.annotation.ModelAttribute;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RequestMethod;
    
    import com.example.sample.app.validation.UserForm.Japanese;
    import com.example.sample.app.validation.UserForm.Singaporean;
    
    @Controller
    @RequestMapping("user")
    public class UserController {
    
        @ModelAttribute
        public UserForm setupForm() {
            UserForm form = new UserForm();
            return form;
        }
    
        @RequestMapping(value = "create", method = RequestMethod.GET, params = "form")
        public String createForm() {
            return "user/createForm";
        }
    
        String createConfirm(UserForm form, BindingResult result) {
            if (result.hasErrors()) {
                return "user/createForm";
            }
            return "user/createConfirm";
        }
    
        @RequestMapping(value = "create", method = RequestMethod.POST, params = { "confirm" })
        public String createConfirmForDefault(@Validated /* (1) */ UserForm form,
                BindingResult result) {
            return createConfirm(form, result);
        }
    
        @RequestMapping(value = "create", method = RequestMethod.POST, params = {
                "confirm", "country=jp" })
        public String createConfirmForJapanese(
                @Validated(Japanese.class)  /* (2) */ UserForm form, BindingResult result) {
            return createConfirm(form, result);
        }
    
        @RequestMapping(value = "create", method = RequestMethod.POST, params = {
                "confirm", "country=sg" })
        public String createConfirmForSingaporean(
                @Validated(Singaporean.class) UserForm form, BindingResult result) {
            return createConfirm(form, result);
        }
    }
    
    項番 説明
    (1)
    countryフィールド指定がない場合に、Defaultグループが使用されるように設定する。
    (2)
    countryフィールド指定がある場合に、Defaultグループが含まれないように設定する。

バリデーショングループを使用する方法について、2パターン説明した。

前者はDefaultグループをControllerクラスで使用し、後者はDefaultグループをフォームクラスで使用した。

パターン メリット デメリット 使用の判断ポイント
DefaultグループをControllerクラスで使用 グループ化する必要のないルールは、group属性を設定する必要がない。 グループの全パターンを定義する必要があるので、グループパターンが多いと、定義が困難になる。 グループパターンが、数種類の場合に使用すべき(新規作成グループ、更新グループ、削除グループ等)
Defaultグループをフォームクラスで使用 デフォルトに属さないグループのみ定義すればよいため、パターンが多くても対応できる。 グループ化する必要のないルールにも、group属性を設定する必要があり、管理が煩雑になる。 グループパターンにデフォルト値を設定できる(グループの大多数に共通項がある)場合に使用すべき

使用の判断ポイントのどちらにも当てはまらない場合は、Bean Validationの使用が不適切であることが考えられる。設計を見直したうえで、Spring Validatorの使用または業務ロジックチェックでの実装を検討すること。

Note

これまでの例ではバリデーショングループの切り替えは、リクエストパラメータ等、@RequestMappingアノテーションで指定できるパラメータによって行った。 この方法では認証オブジェクトが有する権限情報など、@RequestMappingアノテーションでは扱えない情報でグループを切り替えることはできない。

この場合は、@Validatedアノテーションを使用せず、org.springframework.validation.SmartValidatorを使用し、Controllerの処理メソッド内でグループを指定したバリデーションを行えばよい。

@Controller
@RequestMapping("user")
public class UserController {

    @Inject
    SmartValidator smartValidator; // (1)

    // omitted

    @RequestMapping(value = "create", method = RequestMethod.POST, params = "confirm")
    public String createConfirm(/* (2) */ UserForm form, BindingResult result) {
        // (3)
        Class<?> validationGroup = Default.class;
        // logic to determine validation group
        // if (xxx) {
        //     validationGroup = Xxx.class;
        // }
        smartValidator.validate(form, result, validationGroup); // (4)
        if (result.hasErrors()) {
            return "user/createForm";
        }
        return "user/createConfirm";
    }

}
項番 説明
(1)
SmartValidatorをインジェクションする。SmartValidator<mvc:annotation-driven>の設定が行われていれば使用できるため、別途Bean定義不要である。
(2)
@Validatedアノテーションは使わない。
(3)
バリデーショングループを決定する。
バリデーショングループを決定するロジックは、Helperクラスに委譲して、Controller内のロジックをシンプルな状態に保つことを推奨する。
(4)
SmartValidatorvalidateメソッドを使用して、グループを指定したバリデーションを実行する。
グループの指定は可変長引数になっており、複数指定できる。

基本的には、Controllerにロジックを書くべきではないため、@RequestMappingの属性でルールを切り替えられるのであれば、SmartValidatorは使わない方がよい。

5.5.2.3. 相関項目チェック

複数フィールドにまたがる相関項目チェックには、 Spring Validator(org.springframework.validation.Validatorインタフェースを実装したValidator)、 または、Bean Validationを用いる。

それぞれ説明するが、先にそれぞれの特徴と推奨する使い分けを述べる。

方式 特徴 用途
Spring Validator
特定のクラスに対する入力チェックの作成が容易である。
Controllerでの利用が不便。
特定のフォームに依存した業務要件固有の入力チェック実装
Bean Validation
入力チェックの作成はSpring Validatorほど容易でない。
Controllerでの利用が容易。
特定のフォームに依存しない、開発プロジェクト共通の入力チェック実装

5.5.2.3.1. Spring Validatorによる相関項目チェック実装

「パスワードリセット」処理を例に実装方法を説明する。
以下のルールを実装する。ここでは「パスワードリセット」のフォームに以下のチェックルールを設ける。
フィールド名 ルール 説明
password
java.lang.String
入力必須
8文字以上
confirmPasswordと同じ値であること
パスワード
confirmPassword
java.lang.String
特になし
確認用パスワード

「confirmPasswordと同じ値であること」というルールはpasswordフィールドとpasswordConfirmフィールドの両方の情報が必要であるため、相関項目チェックルールである。

  • フォームクラス

    相関項目チェックルール以外は、これまで通りBean Validationのアノテーションで実装する。

    package com.example.sample.app.validation;
    
    import java.io.Serializable;
    
    import javax.validation.constraints.NotNull;
    import javax.validation.constraints.Size;
    
    public class PasswordResetForm implements Serializable {
        private static final long serialVersionUID = 1L;
    
        @NotNull
        @Size(min = 8)
        private String password;
    
        private String confirmPassword;
    
        // omitted setter/getter
    }
    

    Note

    パスワードは、通常ハッシュ化してデータベースに保存するため、最大値のチェックは行わなくても良い。

  • Validatorクラス

    org.springframework.validation.Validatorインタフェースを実装して、相関項目チェックルールを実現する。

    package com.example.sample.app.validation;
    
    import org.springframework.stereotype.Component;
    import org.springframework.validation.Errors;
    import org.springframework.validation.Validator;
    
    @Component // (1)
    public class PasswordEqualsValidator implements Validator {
    
        @Override
        public boolean supports(Class<?> clazz) {
            return PasswordResetForm.class.isAssignableFrom(clazz); // (2)
        }
    
        @Override
        public void validate(Object target, Errors errors) {
    
            if (errors.hasFieldErrors("password")) { // (3)
                return;
            }
    
            PasswordResetForm form = (PasswordResetForm) target;
            String password = form.getPassword();
            String confirmPassword = form.getConfirmPassword();
    
            if (!password.equals(confirmPassword)) { // (4)
                errors.rejectValue(/* (5) */ "password",
                /* (6) */ "PasswordEqualsValidator.passwordResetForm.password",
                /* (7) */ "password and confirm password must be same.");
            }
        }
    }
    
    項番 説明
    (1)
    @Componentを付与し、Validatorをコンポーネントスキャン対象にする。
    (2)
    このValidatorのチェック対象であるかどうかを判別する。ここでは、PasswordResetFormクラスをチェック対象とする。
    (3)
    単項目チェック時に対象フィールドでエラーが発生している場合は、このValidatorで相関チェックは行わない。
    相関チェックを必ず行う必要がある場合は、この判定ロジックは不要である。
    (4)
    チェックロジックを実装する。
    (5)
    エラー対象のフィールド名を指定する。
    (6)
    エラーメッセージのコード名を指定する。ここではコードを、
    “バリデータ名.フォーム属性名.プロパティ名”
    とする。メッセージ定義はapplication-messages.propertiesに定義するメッセージを参照されたい。
    (7)
    エラーメッセージをコードで解決できなかった場合に使用する、デフォルトメッセージを設定する。

    Note

    Spring Validator実装クラスは、使用するControllerと同じパッケージに配置することを推奨する。

  • Controllerクラス

    package com.example.sample.app.validation;
    
    import javax.inject.Inject;
    
    import org.springframework.stereotype.Controller;
    import org.springframework.validation.BindingResult;
    import org.springframework.validation.annotation.Validated;
    import org.springframework.web.bind.WebDataBinder;
    import org.springframework.web.bind.annotation.InitBinder;
    import org.springframework.web.bind.annotation.ModelAttribute;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RequestMethod;
    
    @Controller
    @RequestMapping("password")
    public class PasswordResetController {
        @Inject
        PasswordEqualsValidator passwordEqualsValidator; // (1)
    
        @ModelAttribute
        public PasswordResetForm setupForm() {
            return new PasswordResetForm();
        }
    
        @InitBinder
        public void initBinder(WebDataBinder binder) {
            binder.addValidators(passwordEqualsValidator); // (2)
        }
    
        @RequestMapping(value = "reset", method = RequestMethod.GET, params = "form")
        public String resetForm() {
            return "password/resetForm";
        }
    
        @RequestMapping(value = "reset", method = RequestMethod.POST)
        public String reset(@Validated PasswordResetForm form, BindingResult result) { // (3)
            if (result.hasErrors()) {
                return "password/resetForm";
            }
            return "redirect:/password/reset?complete";
        }
    
        @RequestMapping(value = "reset", method = RequestMethod.GET, params = "complete")
        public String resetComplete() {
            return "password/resetComplete";
        }
    }
    
    項番 説明
    (1)
    使用するSpring Validatorを、インジェクションする。
    (2)
    @InitBinderアノテーションがついたメソッド内で、WebDataBinder.addValidatorsメソッドにより、Validatorを追加する。
    これにより、@Validatedアノテーションでバリデーションをする際に、追加したValidatorも呼び出される。
    (3)
    入力チェックの実装は、これまで通りである。
  • JSP

    JSPに特筆すべき点はない。

    <!DOCTYPE html>
    <html>
    <%-- WEB-INF/views/password/resetForm.jsp --%>
    <head>
    <style type="text/css">
    /* omitted */
    </style>
    </head>
    <body>
        <form:form modelAttribute="passwordResetForm" method="post"
            class="form-horizontal"
            action="${pageContext.request.contextPath}/password/reset">
            <form:label path="password" cssErrorClass="error-label">Password:</form:label>
            <form:password path="password" cssErrorClass="error-input" />
            <form:errors path="password" cssClass="error-messages" />
            <br>
            <form:label path="confirmPassword" cssErrorClass="error-label">Password (Confirm):</form:label>
            <form:password path="confirmPassword"
                cssErrorClass="error-input" />
            <form:errors path="confirmPassword" cssClass="error-messages" />
            <br>
            <form:button>Reset</form:button>
        </form:form>
    </body>
    </html>
    

passwordフィールドと、confirmPasswordフィールドに、別の値を入力してフォームを送信した場合は、以下のようにエラーメッセージが表示される。

../_images/validations-correlation-check1.png

Note

<form:password>タグを使用すると、再表示時に、データがクリアされる。

Note

一つのControllerで複数のフォームを扱う場合は、Validatorの対象を限定するために、@InitBinder("xxx")でモデル名を指定する必要がある。

@Controller
@RequestMapping("xxx")
public class XxxController {
    // omitted
    @ModelAttribute("aaa")
    public AaaForm() {
        return new AaaForm();
    }

    @ModelAttribute("bbb")
    public BbbForm() {
        return new BbbForm();
    }

    @InitBinder("aaa")
    public void initBinderForAaa(WebDataBinder binder) {
        // add validators for AaaForm
        binder.addValidators(aaaValidator);
    }

    @InitBinder("bbb")
    public void initBinderForBbb(WebDataBinder binder) {
        // add validators for BbbForm
        binder.addValidators(bbbValidator);
    }
    // omitted
}

Note

相関項目チェックルールのチェック内容をバリデーショングループに応じて変更したい場合(例えば、特定のバリデーショングループが指定された場合だけ相関項目チェックを実施したい場合など)は、 org.springframework.validation.Validatorインターフェイスを実装する代わりに、 org.springframework.validation.SmartValidatorインターフェイスを実装し、validateメソッド内で処理を切り替えるとよい。

package com.example.sample.app.validation;

import org.apache.commons.lang3.ArrayUtils;
import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;
import org.springframework.validation.SmartValidator;

@Component
public class PasswordEqualsValidator implements SmartValidator { // Implements SmartValidator instead of Validator interface

    @Override
    public boolean supports(Class<?> clazz) {
        return PasswordResetForm.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        validate(target, errors, new Object[] {});
    }

    @Override
    public void validate(Object target, Errors errors, Object... validationHints) {
        // Check validationHints(groups) and apply validation logic only when 'Update.class' is specified
        if (ArrayUtils.contains(validationHints, Update.class)) {
            PasswordResetForm form = (PasswordResetForm) target;
            String password = form.getPassword();
            String confirmPassword = form.getConfirmPassword();

            // omitted...
        }
    }
}

5.5.2.3.2. Bean Validationによる相関項目チェック実装

Bean Validationによって、相関項目チェックの実装するためには、独自バリデーションルールの追加を行う必要がある。

How to extendにて説明する。

5.5.2.4. エラーメッセージの定義

入力チェックエラーメッセージを変更する方法を説明する。

Spring MVCによるBean Validationのエラーメッセージは、以下の順で解決される。

  1. org.springframework.context.MessageSourceに定義されているメッセージの中に、ルールに合致するものがあればそれをエラーメッセージとして使用する (Springのルール)。
    Springのデフォルトのルールについては、「DefaultMessageCodesResolverのJavaDoc」を参照されたい。
  2. 1.でメッセージが見つからない場合、アノテーションのmessage属性に、指定されたメッセージからエラーメッセージを取得する (Bean Validationのルール)

  1. message属性に指定されたメッセージが、”{メッセージキー}”形式でない場合、そのテキストをエラーメッセージとして使用する。
  2. message属性に指定されたメッセージが、”{メッセージキー}”形式の場合、クラスパス直下のValidationMessages.propertiesから、メッセージキーに対応するメッセージを探す。
  1. メッセージキーに対応するメッセージが定義されている場合は、そのメッセージを使用する
  2. メッセージキーに対応するメッセージが定義されていない場合は、”{メッセージキー}”をそのままエラーメッセージとして使用する

基本的にエラーメッセージは、propertiesファイルに定義することを推奨する。

定義する箇所は、以下の2パターン存在する。

  • org.springframework.context.MessageSourceが読み込むpropertiesファイル
  • クラスパス直下のValidationMessages.properties

以下の説明では、applicationContext.xmlに次の設定があることを前提とし、前者を”application-messages.properties”、後者を”ValidationMessages.properties”と呼ぶ。

<bean id="messageSource"
    class="org.springframework.context.support.ResourceBundleMessageSource">
    <property name="basenames">
        <list>
            <value>i18n/application-messages</value>
        </list>
    </property>
</bean>
../_images/validations-message-properties-position-image.png

Warning

ValidationMessages.propertiesファイルは、クラスパスの直下に複数存在させてはいけない。

クラスパスの直下に複数のValidationMessages.propertiesファイルが存在する場合、 いずれか1つのファイルが読み込まれ、他のファイルが読み込まれないため、適切なメッセージが表示されない可能性がある。

  • マルチプロジェクト構成を採用する場合は、ValidationMessages.propertiesファイルを複数のプロジェクトに配置しないように注意すること。
  • Bean Validation用の共通部品をjarファイルとして配布する際に、ValidationMessages.propertiesファイルをjarファイルの中に含めないように注意すること。

なお、version 1.0.2.RELEASE以降の ブランクプロジェクト からプロジェクトを生成した場合は、 xxx-web/src/main/resourcesの直下にValidationMessages.propertiesが格納されている。


本ガイドラインでは、以下のように定義を分けることを推奨する。

プロパティファイル名 定義する内容
ValidationMessages.properties
システムで定めたBean Validationのデフォルトエラーメッセージ
application-messages.properties
個別で上書きしたいBean Validationのエラーメッセージ
Spring Validatorで実装した入力チェックのエラーメッセージ

ValidationMessages.propertiesを用意しない場合は、Hibernate Validatorが用意するデフォルトメッセージが使用される。

5.5.2.4.1. ValidationMessages.propertiesに定義するメッセージ

クラスパス直下(通常src/main/resources)のValidationMessages.properties内の、 Bean Validationのアノテーションのmessage属性に指定されたメッセージキーに対して、メッセージを定義する。

基本的な単項目チェックの初めに使用した、以下のフォームを用いて説明する。

  • フォームクラス(再掲)

    public class UserForm implements Serializable {
    
        @NotNull
        @Size(min = 1, max = 20)
        private String name;
    
        @NotNull
        @Size(min = 1, max = 50)
        @Email
        private String email;
    
        @NotNull
        @Min(0)
        @Max(200)
        private Integer age;
    
        // omitted getter/setter
    }
    
  • ValidationMessages.properties

    @NotNull, @Size, @Min, @Max, @Emailのエラーメッセージを変更する。

    javax.validation.constraints.NotNull.message=is required.
    # (1)
    javax.validation.constraints.Size.message=size is not in the range {min} through {max}.
    # (2)
    javax.validation.constraints.Min.message=can not be less than {value}.
    javax.validation.constraints.Max.message=can not be greater than {value}.
    org.hibernate.validator.constraints.Email.message=is an invalid e-mail address.
    
    項番 説明
    (1)
    アノテーションに指定した属性値は、{属性名}で埋め込むことができる。
    (2)
    不正となった入力値は、{value}で埋め込むことができる。

この設定を加えた状態で、すべての入力フィールドを未入力のままフォームを送信すると、以下のように変更したエラーメッセージが、表示される。

../_images/validations-customize-message1.png

Warning

Bean Validation標準のアノテーションやHibernate Validator独自のアノテーションにはmessage属性に{アノテーションのFQCN.message}という値が設定されているため、

アノテーションのFQCN.message=メッセージ

という形式でプロパティファイルにメッセージを定義すればよいが、すべてのアノテーションが、この形式になっているわけではないので、 対象のアノテーションのJavadocまたはソースコードを確認すること。

エラーメッセージに、フィールド名を含める場合は、以下のように、メッセージに{0}を加える。

  • ValidationMessages.properties

    @NotNull@Size@Min@Max@Emailのエラーメッセージを変更する。

    javax.validation.constraints.NotNull.message="{0}" is required.
    javax.validation.constraints.Size.message=The size of "{0}" is not in the range {min} through {max}.
    javax.validation.constraints.Min.message="{0}" can not be less than {value}.
    javax.validation.constraints.Max.message="{0}" can not be greater than {value}.
    org.hibernate.validator.constraints.Email.message="{0}" is an invalid e-mail address.
    

エラーメッセージは、以下のように変更される。

../_images/validations-customize-message2.png

このままでは、フォームクラスのプロパティ名が表示されてしまい、ユーザーフレンドリではない。 適切なフィールド名を表示したい場合は、application-messages.propertiesに

フォームのプロパティ名=表示するフィールド名

形式でフィールド名を定義すればよい。

これまでの例に、以下の設定を追加する。

  • application-messages.properties

    name=Name
    email=Email
    age=Age
    

エラーメッセージは、以下のように変更される。

../_images/validations-customize-message3.png

Note

{0}でフィールド名を埋め込めむのは、Bean Validationの機能ではなく、Springの機能である。 したがって、フィールド名変更の設定は、Spring管理下のapplication-messages.properties(ResourceBundleMessageSource)に定義する必要がある。

Tip

Bean Validation 1.1より、 ValidationMessages.properties に指定するメッセージの中にExpression Language(以降、「EL式」と呼ぶ)を使用する事ができるようになった。 Hibernate Validator 5.xでは、Expression Language 2.2以上をサポートしている。

実行可能なEL式のバージョンは、アプリケーションサーバのバージョンによって異なる。 そのため、EL式を使用する場合は、アプリケーションサーバがサポートしているEL式のバージョンを確認した上で使用すること。

以下に、Hibernate Validatorがデフォルトで用意している ValidationMessages.properties に定義されているメッセージを例に、EL式の使用例を示す。

# ...
# (1)
javax.validation.constraints.DecimalMax.message  = must be less than ${inclusive == true ? 'or equal to ' : ''}{value}
# ...
項番 説明
(1)

メッセージの中の 「${inclusive == true ? 'or equal to ' : ''}」の部分がEL式である。

上記のメッセージ定義から実際に生成されるメッセージのパターンは、

  • must be less than or equal to {value}
  • must be less than {value}

の2パターンとなる。({value}の部分には、@DecimalMaxアノテーションの value属性に指定した値が埋め込まれる)

前者は@DecimalMaxアノテーションの inclusive属性に trueを指定した場合(又は指定しなかった場合)、 後者は@DecimalMaxアノテーションの inclusive属性に falseを指定した場合に生成される。

Bean ValidationにおけるEL式の扱いについては、 Hibernate Validator Reference Guide(Interpolation with message expressions)を参照されたい。

5.5.2.4.2. application-messages.propertiesに定義するメッセージ

ValidationMessages.propertiesでシステムで利用するデフォルトのメッセージを定義したが、 画面によっては、デフォルトメッセージから変更したい場合が出てくる。

その場合、application-messages.propertiesに、以下の形式でメッセージを定義する。

アノテーション名.フォーム属性名.プロパティ名=対象のメッセージ

ValidationMessages.propertiesに定義するメッセージの設定がある前提で、以下の設定でageフィールドのメッセージを上書きする。

  • application-messages.properties

    # override messages
    NotNull.userForm.age="{0}" is compulsory.
    Max.userForm.age="{0}" must be less than or equal to {1}.
    Max.userForm.age="{0}" must be less than or equal to {1}.
    NotNull.userForm.email="{0}" is compulsory.
    Size.userForm.age=The size of "{0}" must be between {2} and {1}.
    # filed names
    name=Name
    email=Email
    age=Age
    

アノテーションの属性値は、{1}以降に埋め込まれる。

エラーメッセージは以下のように変更される。

../_images/validations-customize-message4.png

Note

application-messages.propertiesのメッセージキーの形式は、これ以外にも用意されているが、 デフォルトメッセージを一部上書きする目的で使用するのであれば、基本的に、アノテーション名.フォーム属性名.プロパティ名形式でよい。


5.5.3. How to extend

Bean Validationは標準で用意されているチェックルール以外に、独自ルール用アノテーションを作成する仕組みをもつ。

作成方法は大きく分けて、以下の観点で分かれる。

  • 既存ルールの組み合わせ
  • 新規ルールの作成

基本的には、以下の雛形を使用して、ルール毎にアノテーションを作成する。

package com.example.common.validation;

import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import javax.validation.Constraint;
import javax.validation.Payload;
import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
import static java.lang.annotation.ElementType.CONSTRUCTOR;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Documented
@Constraint(validatedBy = {})
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
@Retention(RUNTIME)
public @interface Xxx {
    String message() default "{com.example.common.validation.Xxx.message}";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
    @Retention(RUNTIME)
    @Documented
    public @interface List {
        Xxx[] value();
    }
}

5.5.3.1. 既存ルールを組み合わせたBean Validationアノテーションの作成

システム共通で、

  • 文字列は半角英数字の文字種に限定したい
  • 数値は正の数に限定したい
または、ドメイン共通で、
  • 「ユーザーID」は、4文字以上20文字以下の半角英字に制限したい
  • 「年齢」は、1歳以上150歳以下に制限したい
という制約がある場合を考える。
これらは既存ルールの@Pattern@Size@Min@Max等を組み合わせることでも実現できるが、
同じルールを複数の箇所で使用すると、設定内容が分散してしまい、メンテナンス性が悪化する。

複数のルールを組み合わせて一つのルールを作成することができる。 独自アノテーションを作成すると、正規表現パターンや、最大値・最小値などの値を共通化できるだけでなく、エラーメッセージも共通化できるというメリットがある。 これにより、再利用性や保守性が高まる。複数のルールの組み合わせではなくても、一つのルールの属性を特定するだけでも効果的である。

以下に、実装例を示す。

  • 半角英数字の文字種に限定する@Alphanumericアノテーションの実装例

    package com.example.common.validation;
    
    import java.lang.annotation.Documented;
    import java.lang.annotation.Retention;
    import java.lang.annotation.Target;
    import javax.validation.Constraint;
    import javax.validation.Payload;
    import javax.validation.ReportAsSingleViolation;
    import javax.validation.constraints.Pattern;
    
    import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
    import static java.lang.annotation.ElementType.CONSTRUCTOR;
    import static java.lang.annotation.ElementType.FIELD;
    import static java.lang.annotation.ElementType.METHOD;
    import static java.lang.annotation.ElementType.PARAMETER;
    import static java.lang.annotation.RetentionPolicy.RUNTIME;
    
    @Documented
    @Constraint(validatedBy = {})
    @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
    @Retention(RUNTIME)
    @ReportAsSingleViolation // (1)
    @Pattern(regexp = "[a-zA-Z0-9]*") // (2)
    public @interface AlphaNumeric {
        String message() default "{com.example.common.validation.AlphaNumeric.message}"; // (3)
    
        Class<?>[] groups() default {};
    
        Class<? extends Payload>[] payload() default {};
    
        @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
        @Retention(RUNTIME)
        @Documented
        public @interface List {
            AlphaNumeric[] value();
        }
    }
    
    項番 説明
    (1)
    エラーメッセージをまとめ、エラー時はこのアノテーションによるメッセージだけを変えるようにする。
    (2)
    このアノテーションにより使用されるルールを定義する。
    (3)
    エラーメッセージのデフォルト値を定義する。
  • 正の数に限定する@NotNegaitiveアノテーションの実装例

    package com.example.common.validation;
    
    import java.lang.annotation.Documented;
    import java.lang.annotation.Retention;
    import java.lang.annotation.Target;
    import javax.validation.Constraint;
    import javax.validation.Payload;
    import javax.validation.ReportAsSingleViolation;
    import javax.validation.constraints.Min;
    
    import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
    import static java.lang.annotation.ElementType.CONSTRUCTOR;
    import static java.lang.annotation.ElementType.FIELD;
    import static java.lang.annotation.ElementType.METHOD;
    import static java.lang.annotation.ElementType.PARAMETER;
    import static java.lang.annotation.RetentionPolicy.RUNTIME;
    
    @Documented
    @Constraint(validatedBy = {})
    @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
    @Retention(RUNTIME)
    @ReportAsSingleViolation
    @Min(value = 0)
    public @interface NotNegaitive {
        String message() default "{com.example.common.validation.NotNegaitive.message}";
    
        Class<?>[] groups() default {};
    
        Class<? extends Payload>[] payload() default {};
    
        @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
        @Retention(RUNTIME)
        @Documented
        public @interface List {
            NotNegaitive[] value();
        }
    }
    
  • 「ユーザーID」のフォーマットを規定する@UserIdアノテーションの実装例

    package com.example.sample.domain.validation;
    
    import java.lang.annotation.Documented;
    import java.lang.annotation.Retention;
    import java.lang.annotation.Target;
    import javax.validation.Constraint;
    import javax.validation.Payload;
    import javax.validation.ReportAsSingleViolation;
    import javax.validation.constraints.Pattern;
    import javax.validation.constraints.Size;
    
    import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
    import static java.lang.annotation.ElementType.CONSTRUCTOR;
    import static java.lang.annotation.ElementType.FIELD;
    import static java.lang.annotation.ElementType.METHOD;
    import static java.lang.annotation.ElementType.PARAMETER;
    import static java.lang.annotation.RetentionPolicy.RUNTIME;
    
    @Documented
    @Constraint(validatedBy = {})
    @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
    @Retention(RUNTIME)
    @ReportAsSingleViolation
    @Size(min = 4, max = 20)
    @Pattern(regexp = "[a-z]*")
    public @interface UserId {
        String message() default "{com.example.sample.domain.validation.UserId.message}";
    
        Class<?>[] groups() default {};
    
        Class<? extends Payload>[] payload() default {};
    
        @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
        @Retention(RUNTIME)
        @Documented
        public @interface List {
            UserId[] value();
        }
    }
    
  • 「年齢」の制限を規定する@Ageアノテーションの実装例

    package com.example.sample.domain.validation;
    
    import java.lang.annotation.Documented;
    import java.lang.annotation.Retention;
    import java.lang.annotation.Target;
    import javax.validation.Constraint;
    import javax.validation.Payload;
    import javax.validation.ReportAsSingleViolation;
    import javax.validation.constraints.Max;
    import javax.validation.constraints.Min;
    
    import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
    import static java.lang.annotation.ElementType.CONSTRUCTOR;
    import static java.lang.annotation.ElementType.FIELD;
    import static java.lang.annotation.ElementType.METHOD;
    import static java.lang.annotation.ElementType.PARAMETER;
    import static java.lang.annotation.RetentionPolicy.RUNTIME;
    
    @Documented
    @Constraint(validatedBy = {})
    @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
    @Retention(RUNTIME)
    @ReportAsSingleViolation
    @Min(1)
    @Max(150)
    public @interface Age {
        String message() default "{com.example.sample.domain.validation.Age.message}";
    
        Class<?>[] groups() default {};
    
        Class<? extends Payload>[] payload() default {};
    
        @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
        @Retention(RUNTIME)
        @Documented
        public @interface List {
            Age[] value();
        }
    }
    

    Note

    1つのアノテーションに複数のルールを設定した場合、それらのAND条件が複合ルールとなる。 Hibernate Validatorでは、OR条件を実現するための@ConstraintCompositionアノテーションが用意されている。 詳細は、Hibernate Validatorのドキュメントを参照されたい。

5.5.3.2. 新規ルールを実装したBean Validationアノテーションの作成

javax.validation.ConstraintValidatorインタフェースを実装し、そのValidatorを使用するアノテーションを作成することで、任意のルールを作成することができる。

用途としては、以下の3通りが挙げられる。

  • 既存のルールの組み合わせでは表現できないルール
  • 相関項目チェックルール
  • 業務ロジックチェック

5.5.3.2.1. 既存のルールの組み合わせでは表現できないルール

@Pattern@Size@Min@Max等を組み合わせても表現できないルールは、javax.validation.ConstraintValidator実装クラスに記述する。

例として、ISBN(International Standard Book Number)-13の形式をチェックするルールを挙げる。

  • アノテーション

    package com.example.common.validation;
    
    import java.lang.annotation.Documented;
    import java.lang.annotation.Retention;
    import java.lang.annotation.Target;
    import javax.validation.Constraint;
    import javax.validation.Payload;
    import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
    import static java.lang.annotation.ElementType.CONSTRUCTOR;
    import static java.lang.annotation.ElementType.FIELD;
    import static java.lang.annotation.ElementType.METHOD;
    import static java.lang.annotation.ElementType.PARAMETER;
    import static java.lang.annotation.RetentionPolicy.RUNTIME;
    
    @Documented
    @Constraint(validatedBy = { ISBN13Validator.class }) // (1)
    @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
    @Retention(RUNTIME)
    public @interface ISBN13 {
        String message() default "{com.example.common.validation.ISBN13.message}";
    
        Class<?>[] groups() default {};
    
        Class<? extends Payload>[] payload() default {};
    
        @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
        @Retention(RUNTIME)
        @Documented
        public @interface List {
            ISBN13[] value();
        }
    }
    
    項番 説明
    (1)
    このアノテーションを使用したときに実行されるConstraintValidatorを指定する。複数指定することができる。
  • Validator

    package com.example.common.validation;
    
    import javax.validation.ConstraintValidator;
    import javax.validation.ConstraintValidatorContext;
    
    public class ISBN13Validator implements ConstraintValidator<ISBN13, String> { // (1)
    
        @Override
        public void initialize(ISBN13 constraintAnnotation) { // (2)
        }
    
        @Override
        public boolean isValid(String value, ConstraintValidatorContext context) { // (3)
            if (value == null) {
                return true; // (4)
            }
            return isISBN13Valid(value); // (5)
        }
    
        // This logic is written in http://en.wikipedia.org/wiki/International_Standard_Book_Number
        static boolean isISBN13Valid(String isbn) {
            if (isbn.length() != 13) {
                return false;
            }
            int check = 0;
            try {
                for (int i = 0; i < 12; i += 2) {
                    check += Integer.parseInt(isbn.substring(i, i + 1));
                }
                for (int i = 1; i < 12; i += 2) {
                    check += Integer.parseInt(isbn.substring(i, i + 1)) * 3;
                }
                check += Integer.parseInt(isbn.substring(12));
            } catch (NumberFormatException e) {
                return false;
            }
            return check % 10 == 0;
        }
    }
    
    項番 説明
    (1)
    ジェネリクスのパラメータに、対象のアノテーションとフィールドの型を指定する。
    (2)
    initializeメソッドに、初期化処理を実装する。
    (3)
    isValidメソッドで入力チェック処理を実装する。
    (4)
    入力値が、nullの場合は、正常とみなす。
    (5)
    ISBN-13の形式のチェックを行う。

Tip

ファイルアップロードのBean Validationの例も、ここに分類される。また共通ライブラリでは、この実装として@ExistInCodeListを用意している。

5.5.3.2.2. 相関項目チェックルール

相関項目チェックで説明したように、Bean Validationによって複数のフィールドにまたがる相関項目チェックを実装できる。
Bean Validationで相関項目チェックルールを実装する場合は、汎用的なルールを対象とすることを推奨する。

以下では、「あるフィールドとその確認用フィールドの内容が一致すること」というルールを実現する例を挙げる。

ここでは、確認用フィールドの先頭に、「confirm」を付与する規約を設ける。

  • アノテーション

    相関項目チェック用のアノテーションはクラスレベルに付与できるようにする。

    package com.example.common.validation;
    
    import java.lang.annotation.Documented;
    import java.lang.annotation.Retention;
    import java.lang.annotation.Target;
    import javax.validation.Constraint;
    import javax.validation.Payload;
    import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
    import static java.lang.annotation.ElementType.TYPE;
    import static java.lang.annotation.RetentionPolicy.RUNTIME;
    
    @Documented
    @Constraint(validatedBy = { ConfirmValidator.class })
    @Target({ TYPE, ANNOTATION_TYPE }) // (1)
    @Retention(RUNTIME)
    public @interface Confirm {
        String message() default "{com.example.common.validation.Confirm.message}";
    
        Class<?>[] groups() default {};
    
        Class<? extends Payload>[] payload() default {};
    
        /**
         * Field name
         */
        String field(); // (2)
    
        @Target({ TYPE, ANNOTATION_TYPE })
        @Retention(RUNTIME)
        @Documented
        public @interface List {
            Confirm[] value();
        }
    }
    
    項番 説明
    (1)
    このアノテーションが、クラスまたはアノテーションにのみ付加できるように、対象を絞る。
    (2)
    アノテーションに渡すパラメータを定義する。
  • Validator

    package com.example.common.validation;
    
    import javax.validation.ConstraintValidator;
    import javax.validation.ConstraintValidatorContext;
    
    import org.springframework.beans.BeanWrapper;
    import org.springframework.beans.BeanWrapperImpl;
    import org.springframework.util.ObjectUtils;
    import org.springframework.util.StringUtils;
    
    public class ConfirmValidator implements ConstraintValidator<Confirm, Object> {
        private String field;
    
        private String confirmField;
    
        private String message;
    
        public void initialize(Confirm constraintAnnotation) {
            field = constraintAnnotation.field();
            confirmField = "confirm" + StringUtils.capitalize(field);
            message = constraintAnnotation.message();
        }
    
        public boolean isValid(Object value, ConstraintValidatorContext context) {
            BeanWrapper beanWrapper = new BeanWrapperImpl(value); // (1)
            Object fieldValue = beanWrapper.getPropertyValue(field); // (2)
            Object confirmFieldValue = beanWrapper.getPropertyValue(confirmField);
            boolean matched = ObjectUtils.nullSafeEquals(fieldValue,
                    confirmFieldValue);
            if (matched) {
                return true;
            } else {
                context.disableDefaultConstraintViolation(); // (3)
                context.buildConstraintViolationWithTemplate(message)
                        .addPropertyNode(field).addConstraintViolation(); // (4)
                return false;
            }
        }
    
    }
    
    項番 説明
    (1)
    JavaBeanのプロパティにアクセスする際に便利なorg.springframework.beans.BeanWrapperを使用する。
    (2)
    BeanWrapper経由で、フォームオブジェクトからプロパティ値を取得する。
    (3)
    デフォルトのConstraintViolationオブジェクトの生成を無効にする。
    (4)
    独自ConstraintViolationオブジェクトを生成する。
    ConstraintValidatorContext.buildConstraintViolationWithTemplateで出力するメッセージを定義する。
    ConstraintViolationBuilder.addPropertyNodeでエラーメッセージを出力したいフィールド名を指定する。
    詳細は、以下のJavaDocを参照されたい。

Tip

ConstraintViolationBuilder.addPropertyNodeメソッドは、Bean Validation 1.1 から追加されたメソッドである。

Bean Validation 1.0では ConstraintViolationBuilder.addNodeというメソッドを使用していたが、Bean Validation 1.1から非推奨のAPIとなっている。

Bean Validationの非推奨APIについては、Bean Validation API Document(Deprecated API)を参照されたい。

この@Confirmアノテーションを使用して、前述の「パスワードリセット」処理を再実装すると、以下のようになる。

  • フォームクラス

    package com.example.sample.app.validation;
    
    import java.io.Serializable;
    
    import javax.validation.constraints.NotNull;
    import javax.validation.constraints.Size;
    
    import com.example.common.validation.Confirm;
    
    @Confirm(field = "password") // (1)
    public class PasswordResetForm implements Serializable {
        private static final long serialVersionUID = 1L;
    
        @NotNull
        @Size(min = 8)
        private String password;
    
        private String confirmPassword;
    
        // omitted geter/setter
    }
    
    項番 説明
    (1)
    クラスレベルに@Confirmアノテーションを付与する。
    これによりConstraintValidator.isValidの引数にはフォームオブジェクトが渡る。
  • Controllerクラス

    Validatorのインジェクションおよび@InitBinderによるValidatorの追加は、不要になる。

    package com.example.sample.app.validation;
    
    import org.springframework.stereotype.Controller;
    import org.springframework.validation.BindingResult;
    import org.springframework.validation.annotation.Validated;
    import org.springframework.web.bind.annotation.ModelAttribute;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RequestMethod;
    
    @Controller
    @RequestMapping("password")
    public class PasswordResetController {
    
        @ModelAttribute
        public PasswordResetForm setupForm() {
            return new PasswordResetForm();
        }
    
        @RequestMapping(value = "reset", method = RequestMethod.GET, params = "form")
        public String resetForm() {
            return "password/resetForm";
        }
    
        @RequestMapping(value = "reset", method = RequestMethod.POST)
        public String reset(@Validated PasswordResetForm form, BindingResult result) {
            if (result.hasErrors()) {
                return "password/resetForm";
            }
            return "redirect:/password/reset?complete";
        }
    
        @RequestMapping(value = "reset", method = RequestMethod.GET, params = "complete")
        public String resetComplete() {
            return "password/resetComplete";
        }
    }
    

5.5.3.2.3. 業務ロジックチェック

業務ロジックチェックは、基本的にはドメイン層のServiceで実装し、結果メッセージはResultMessagesオブジェクトに格納することを推奨している。

一方で、「入力されたユーザー名が既に登録済みかどうか」など、対象の入力フィールドに対する業務ロジックエラーメッセージを、フィールドの横に表示したい場合もある。 このような場合は、ValidatorクラスにServiceクラスをインジェクションして、業務ロジックチェックを実行し、その結果を、ConstraintValidator.isValidの結果に使用すればよい。

「入力されたユーザー名が既に登録済みかどうか」をBean Validationで実現する例を示す。

  • Serviceクラス

    実装クラス(UserServiceImpl)は割愛する。

    package com.example.sample.domain.service.user;
    
    public interface UserService {
    
        boolean isUnusedUserId(String userId);
    
        // omitted other methods
    }
    
  • アノテーション

    package com.example.sample.domain.validation;
    
    import java.lang.annotation.Documented;
    import java.lang.annotation.Retention;
    import java.lang.annotation.Target;
    import javax.validation.Constraint;
    import javax.validation.Payload;
    import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
    import static java.lang.annotation.ElementType.CONSTRUCTOR;
    import static java.lang.annotation.ElementType.FIELD;
    import static java.lang.annotation.ElementType.METHOD;
    import static java.lang.annotation.ElementType.PARAMETER;
    import static java.lang.annotation.RetentionPolicy.RUNTIME;
    
    @Documented
    @Constraint(validatedBy = { UnusedUserIdValidator.class })
    @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
    @Retention(RUNTIME)
    public @interface UnusedUserId {
        String message() default "{com.example.sample.domain.validation.UnusedUserId.message}";
    
        Class<?>[] groups() default {};
    
        Class<? extends Payload>[] payload() default {};
    
        @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
        @Retention(RUNTIME)
        @Documented
        public @interface List {
            UnusedUserId[] value();
        }
    }
    
  • Validatorクラス

    package com.example.sample.domain.validation;
    
    import javax.inject.Inject;
    import javax.validation.ConstraintValidator;
    import javax.validation.ConstraintValidatorContext;
    
    import org.springframework.stereotype.Component;
    
    import com.example.sample.domain.service.user.UserService;
    
    @Component // (1)
    public class UnusedUserIdValidator implements
                                      ConstraintValidator<UnusedUserId, String> {
    
        @Inject // (2)
        UserService userService;
    
        @Override
        public void initialize(UnusedUserId constraintAnnotation) {
        }
    
        @Override
        public boolean isValid(String value, ConstraintValidatorContext context) {
            if (value == null) {
                return true;
            }
    
            return userService.isUnusedUserId(value); // (3)
        }
    
    }
    
    項番 説明
    (1)
    Validatorクラスをコンポーネントスキャンの対象にする。
    パッケージがBean定義ファイルの<context:component-scan base-package="..." />の設定に含まれている必要がある。
    (2)
    呼び出すServiceクラスを、インジェクションする。
    (3)
    業務ロジックの結果を返却する。isValidメソッド名で業務ロジックを記述せず、かならずServiceに処理を委譲すること。

5.5.4. Appendix

5.5.4.1. Hibernate Validatorが用意する入力チェックルール

Hibernate ValidatorはBean Validationで定義されたアノテーションに加え、検証できるアノテーションを追加している。
検証に使用することができるアノテーションのリストは、こちらを参照されたい。

5.5.4.1.1. Bean Validationのチェックルール

Bean Validationの標準アノテーションを、以下に示す。

詳細は、Bean Validation specificationの7章を参照されたい。

アノテーション(javax.validation.*) 対象の型 用途 使用例
@NotNull 任意 対象のフィールドが、nullでないことを検証する。
@NotNull
private String id;
@Null 任意
対象のフィールドが、nullであることを検証する。
(例:グループ検証での使用)
@Nulll(groups={Update.class})
private String id;
@Pattern String
対象のフィールドが正規表現にマッチするかどうか
(Hibernate Validator実装では、任意のCharSequence継承クラスにも適用可能)
@Pattern(regexp = “[0-9]+”)
private String tel;
@Min
BigDecimal, BigInteger, byte, short, int, longおよびラッパー
(Hibernate Validator実装では、任意のNumber,CharSequence継承クラスにも適用可能。ただし、文字列が数値表現の場合に限る。)
値が、最小値以上であるかどうかを検証する。 @Max参照
@Max
BigDecimal, BigInteger, byte, short, int, longおよびラッパー
(Hibernate Validator実装では任意のNumber,CharSequence継承クラスにも適用可能。ただし、文字列が数値表現の場合に限る。)
値が、最大値以下であるかどうかを検証する。
@Min(1)
@Max(100)
private int quantity;
@DecimalMin BigDecimal, BigInteger, String, byte, short, int, longおよびラッパー (Hibernate Validator実装では任意のNumber,CharSequence継承クラスにも適用可能)
Decimal型の値が、最小値以上であるかどうかを検証する。
inclusive = falseを指定する事で、最小値より大きいかどうかを検証するように動作を変更する事ができる。
@DecimalMax参照
@DecimalMax BigDecimal, BigInteger, String, byte, short, int, longおよびラッパー (Hibernate Validator実装では任意のNumber,CharSequence継承クラスにも適用可能)
Decimal型の値が、最大値以下であるかどうかを検証する。
inclusive = falseを指定する事で、最大値より小さいかどうかを検証するように動作を変更する事ができる。
@DecimalMin(“0.0”)
@DecimalMax(“99999.99”)
private BigDecimal price;
@Size String(length), Collection(size), Map(size), Array(length) (Hibernate Validator実装では、任意のCharSequence継承クラスにも適用可能)
lengthがminとmaxの間のサイズか検証する。
minとmaxは省略可能であるが、デフォルトはmin=0,max= Integer.MAX_VALUEとなる。
@Size(min=4, max=64)
private String password;
@Digits BigDecimal, BigInteger, String, byte, short, int, longおよびラッパー
値が指定された範囲内の数値であるかチェックする。
integerに最大整数の桁を指定し、fractionに最大小数桁を指定する。
@Digits(integer=6, fraction=2)
private BigDecimal price;
@AssertTrue boolean,Boolean 対象のフィールドがtrueであることを検証する(例:規約に同意したかどうか)
@AssertTrue
private boolean checked;
@AssertFalse boolean,Boolean 対象のフィールドがfalseであることを検証する
@AssertFalse
private boolean checked;
@Future Date, Calender (Hibernate Validator実装ではJoda-Timeのクラスにも適用可能) 未来日付であるか検証する。
@Future
private Date eventDate;
@Past Date, Calender (Hibernate Validator実装ではJoda-Timeのクラスにも適用可能) 過去日付であるか検証する。
@Past
private Date eventDate;
@Valid 任意の非プリミティブ型 関連付けられているオブジェクトについて、再帰的に検証を行う。
@Valid
private List<Employer> employers;

@Valid
private Dept dept;

Tip

@DecimalMin@DecimalMaxアノテーションの inclusive属性は、 Bean Validation 1.1 から追加された属性である。

inclusive属性のデフォルト値には true(指定した閾値と同じ値を許容する)が指定されており、 Bean Validation 1.0 との互換性が保たれている。

5.5.4.1.2. Hibernate Validatorのチェックルール

Hibernate Validatorの代表的なアノテーションを、以下に示す。

詳細は、Hibernate Validator仕様を参照されたい。

アノテーション(org.hibernate.validator.constraints.*) 対象の型 用途 使用例
@CreditCardNumber 任意のCharSequence継承クラスに適用可能
Luhnアルゴリズムでクレジットカード番号が妥当かどうかを検証する。使用可能な番号かどうかをチェックするわけではない。
ignoreNonDigitCharacters = trueを指定する事で、数字以外の文字を無視して検証する事ができる。
@CreditCardNumber
private String cardNumber;
@Email 任意のCharSequence継承クラスに適用可能 RFC2822に準拠したEmailアドレスかどうか検証する。
@Email
private String email;
@URL 任意のCharSequence継承クラスに適用可能 RFC2396に準拠しているかどうか検証する。
@URL
private String url;
@NotBlank 任意のCharSequence継承クラスに適用可能 Null、空文字(“”)、空白のみでないことを検証する。
@NotBlank
private String userId;
@NotEmpty Collection、Map、arrays、任意のCharSequence継承クラスに適用可能
Null、または空でないことを検証する。
@NotNull + @Min(1)の組み合わせでチェックする場合は、@NotEmptyを使用すること。
@NotEmpty
private String password;

Warning

Hibernate Validatorから提供されている以下のアノテーションを使用した場合、 デフォルトで提供されているメッセージを使用するとメッセージが正しく生成されないバグ(HV-881HV-949)が存在する。

  • @CreditCardNumber(メッセージは表示されるがWARNログが出力される)
  • @LuhnCheck
  • @Mod10Check
  • @Mod11Check
  • @ModCheck(5.1.0.Finalから非推奨API)

このバグは、デフォルトで提供されているメッセージ定義の不備が原因なので、 デフォルトで提供されているメッセージを適切なメッセージで上書きする事で回避可能である。

デフォルトで提供されているメッセージを上書きする場合は、 クラスパス直下(通常src/main/resources)に ValidationMessages.properties を作成し、 適切なメッセージ定義を行えばよい。

適切なメッセージ定義については、 Hibernate Validator 5.2系(次のマイナーバージョンアップ)に対して行われている修正内容を参照されたい。

5.5.4.2. Hibernate Validatorが用意するデフォルトメッセージ

hibernate-validator-<version>.jar内のorg/hibernate/validatorに、ValidationMessages.propertiesのデフォルト値が定義されている。

javax.validation.constraints.AssertFalse.message = must be false
javax.validation.constraints.AssertTrue.message  = must be true
javax.validation.constraints.DecimalMax.message  = must be less than ${inclusive == true ? 'or equal to ' : ''}{value}
javax.validation.constraints.DecimalMin.message  = must be greater than ${inclusive == true ? 'or equal to ' : ''}{value}
javax.validation.constraints.Digits.message      = numeric value out of bounds (<{integer} digits>.<{fraction} digits> expected)
javax.validation.constraints.Future.message      = must be in the future
javax.validation.constraints.Max.message         = must be less than or equal to {value}
javax.validation.constraints.Min.message         = must be greater than or equal to {value}
javax.validation.constraints.NotNull.message     = may not be null
javax.validation.constraints.Null.message        = must be null
javax.validation.constraints.Past.message        = must be in the past
javax.validation.constraints.Pattern.message     = must match "{regexp}"
javax.validation.constraints.Size.message        = size must be between {min} and {max}

org.hibernate.validator.constraints.CreditCardNumber.message        = invalid credit card number
org.hibernate.validator.constraints.EAN.message                     = invalid {type} barcode
org.hibernate.validator.constraints.Email.message                   = not a well-formed email address
org.hibernate.validator.constraints.Length.message                  = length must be between {min} and {max}
org.hibernate.validator.constraints.LuhnCheck.message               = The check digit for ${validatedValue} is invalid, Luhn Modulo 10 checksum failed
org.hibernate.validator.constraints.Mod10Check.message              = The check digit for ${validatedValue} is invalid, Modulo 10 checksum failed
org.hibernate.validator.constraints.Mod11Check.message              = The check digit for ${validatedValue} is invalid, Modulo 11 checksum failed
org.hibernate.validator.constraints.ModCheck.message                = The check digit for ${validatedValue} is invalid, ${modType} checksum failed
org.hibernate.validator.constraints.NotBlank.message                = may not be empty
org.hibernate.validator.constraints.NotEmpty.message                = may not be empty
org.hibernate.validator.constraints.ParametersScriptAssert.message  = script expression "{script}" didn't evaluate to true
org.hibernate.validator.constraints.Range.message                   = must be between {min} and {max}
org.hibernate.validator.constraints.SafeHtml.message                = may have unsafe html content
org.hibernate.validator.constraints.ScriptAssert.message            = script expression "{script}" didn't evaluate to true
org.hibernate.validator.constraints.URL.message                     = must be a valid URL

org.hibernate.validator.constraints.br.CNPJ.message                 = invalid Brazilian corporate taxpayer registry number (CNPJ)
org.hibernate.validator.constraints.br.CPF.message                  = invalid Brazilian individual taxpayer registry number (CPF)
org.hibernate.validator.constraints.br.TituloEleitoral.message      = invalid Brazilian Voter ID card number

5.5.4.3. 型のミスマッチ

フォームオブジェクトのString以外のフィールドに対して、変換不可能な値を送信した場合はorg.springframework.beans.TypeMismatchExceptionがスローされる。

「新規ユーザー登録」処理の例では「Age」フィールドはIntegerで定義されているが、このフィールドに対して整数に変換できない値を入力すると、以下のようなエラーメッセージが表示される。

../_images/validations-typemismatch1.png

例外の原因がそのまま表示されてしまい、エラーメッセージとしては不適切である。 型がミスマッチの場合のエラーメッセージは、org.springframework.context.MessageSourceが読み込むpropertiesファイル(application-messages.properties)に定義できる。

以下のルールで、エラーメッセージを定義すればよい。

メッセージキー メッセージ内容 用途
typeMismatch 型ミスマッチエラーのデフォルトメッセージ システム全体のデフォルト値
typeMismatch.対象のFQCN 特定の型ミスマッチエラーのデフォルトメッセージ システム全体のデフォルト値
typeMismatch.フォーム属性名.プロパティ名 特定のフォームのフィールドに対する型ミスマッチエラーのメッセージ 画面毎に変更したいメッセージ

application-messages.propertiesに以下の定義を行った場合、

# typemismatch
typeMismatch="{0}" is invalid.
typeMismatch.int="{0}" must be an integer.
typeMismatch.double="{0}" must be a double.
typeMismatch.float="{0}" must be a float.
typeMismatch.long="{0}" must be a long.
typeMismatch.short="{0}" must be a short.
typeMismatch.java.lang.Integer="{0}" must be an integer.
typeMismatch.java.lang.Double="{0}" must be a double.
typeMismatch.java.lang.Float="{0}" must be a float.
typeMismatch.java.lang.Long="{0}" must be a long.
typeMismatch.java.lang.Short="{0}" must be a short.
typeMismatch.java.util.Date="{0}" is not a date.

# filed names
name=Name
email=Email
age=Age

エラーメッセージは、次のように変更される。

../_images/validations-typemismatch2.png
application-messages.propertiesに定義するメッセージで説明したように、{0}でフィールド名を埋めることができる。
基本的にデフォルトメッセージは定義しておくこと

Tip

メッセージキーのルールの詳細は、Javadocを参照されたい。

5.5.4.4. 文字列フィールドが未入力の場合にnullをバインドする

これまで説明してきたように、Spring MVCでは文字列の入力フィールドに未入力の状態でフォームを送信した場合、 デフォルトでは、フォームオブジェクトにnullではなく、空文字がバインドされる。

この場合、「未入力は許容するが、入力された場合は6文字以上であること」という要件を、既存のアノテーションで満たすことができない。

文字列フィールドが未入力の場合に、空文字ではなく、nullをフォームオブジェクトにバインドするには、
以下のようにorg.springframework.beans.propertyeditors.StringTrimmerEditorを使用すればよい。
@Controller
@RequestMapping("xxx")
public class XxxController {

    @InitBinder
    public void initBinder(WebDataBinder binder) {
        // bind empty strings as null
        binder.registerCustomEditor(String.class, new StringTrimmerEditor(true));
    }

    // omitted ...
}

この設定により、Controller毎に空文字をnullとみなすかどうかを設定できる。

プロジェクト全体で空文字をnullにしたい場合は、プロジェクト共通設定として@ControllerAdviceで設定すればよい。

Tip

Spring Framework 4.0 より追加された@ControllerAdviceアノテーションの属性について

@ControllerAdviceアノテーションの属性を指定することで、 @ControllerAdviceが付与されたクラスで実装したメソッドを適用するControllerを柔軟に指定できるように改善されている。 属性の詳細については、@ControllerAdviceの属性を参照されたい。

@ControllerAdvice
public class XxxControllerAdvice {

    @InitBinder
    public void initBinder(WebDataBinder binder) {
        // bind empty strings as null
        binder.registerCustomEditor(String.class, new StringTrimmerEditor(true));
    }

    // omitted ...
}
この設定を行った場合は、フォームオブジェクトの文字列フィールドに設定される空文字がすべてnullになる。
したがって、必須チェックに、かならず@NotNullが必要であることに注意しないといけない。