Spring MVCにおけるフォームバリデーションの適用事例【前編】

More than 1 year has passed since last update.

フォームバリデーションを行うにあたり、Bean Validation APIの標準の制約(@Size@Patternなど)はとても便利ですが、いざ業務で利用するとなるとカスタマイズしたくなります。

そこで、実際の業務で利用できそうなカスタマイズをいくつかやってみたいと思います。

1. 標準の制約を組み合わせる

2. 独自のチェック機能を実装した制約を作成する

3. 相関チェックをフォームクラスに実装する

4. 相関チェックをバリデーターに実装する

5. バリデーターからアノテーションを利用する

ちょっとボリュームが多くなってしまったので、前編で1から3を、残りを後編に分けたいと思います。


環境


  • Spring Framework 4.3.2

  • Thymeleaf 3.0.0

  • GlassFish Server Open Source Edition 4.1

  • JDK 1.8

  • MyBatis 3.4.1

  • PostgreSQL 9.4

ソースコードはこちらにあります。

https://github.com/kenjihori/spring


1. 標準の制約を組み合わせる

こちらは語りつくされている感がありますが、基本中の基本として標準の制約を組み合わせる方法をやってみたいと思います。

システムのドメインとして、


  • 金額は、小数点を含まず10桁まで

  • 組織コードは、先頭がアルファベット1桁で、続いて数字5桁

といったことを定義していると思います。

このような制約は、標準の@Ditigs@Size@Patternなどを利用して実現することもできます。

しかし、各項目に複数の制約をつける必要があったり、別々のフォームクラスに同じような定義が分散してしまうこともあり、視認性やメンテナンス性がよくないです。

そこで、標準の制約を組み合わせて、新たなルールを作成することができます。

ここでは、郵便番号として、7桁の数字(ハイフン含まず)とするための制約@ZipCodeを作成してみます。

@Patternでもできますが、複数の制約の組み合わせということで、@Size@Digitsを使ってみます。


ZipCode.java

@Target(java.lang.annotation.ElementType.FIELD)

@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = {})
@ReportAsSingleViolation
@Size(min = 7, max = 7) // 最小・最大7桁の@Sizeを指定
@Digits(integer = 7, fraction = 0) // 数字7桁を指定
public @interface ZipCode {

String message() default "{zipcode.invalid}";

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

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

@Target(java.lang.annotation.ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public static @interface List {
ZipCode[] value();
}

}


フォームクラスでの利用方法は標準のバリデーションと同じです。


ValidationForm1.java

public class ValidationForm1 implements Serializable {

// setter, getterは省略
@ZipCode
private String zipCode;
}

画面イメージはこちらです。

うまくいきましたね。

validation1e.png


2. 独自のチェック機能を実装した制約を作成する

標準の制約の組み合わせだけでは実現できない場合、独自のチェック機能を実装することもできます。

実際の利用例としては、


  • 入力された商品コードが商品マスタに存在しているか

  • 入力された顧客コードの顧客が取引停止となっていないか

などです。

商品コードが商品マスタに存在しているかの制約@ItemExistsを作成してみます。

こちらも、まずは制約を作成します。


ItemExists.java

@Target(ElementType.FIELD)

@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = {ItemExistsValidator.class}) // Validatorの実装クラスを指定
@ReportAsSingleViolation
public @interface ItemExists {

String message() default "{itemexists}";

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

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

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public static @interface List {
ItemExists[] value();
}

}


1との違いは、

@Constraint(validatedBy = {ItemExistsValidator.class})

として、バリデーターのクラスを指定することです。

そして、このバリデーターでisValidメソッドを@Overrideして、チェック機能を実装します。

この例ではデータベースにアクセスするため、Serviceクラスをインジェクションしています。


ItemExistsValidator.java

public class ItemExistsValidator implements ConstraintValidator<ItemExists, String> {

@Autowired
ItemService itemService;

@Override
public void initialize(ItemExists annotation) {
}

@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
// 商品コードが入力されていない場合はチェックしない(フォームクラスのNotNullでチェックする)
if(value == null) {
return true;
}
// 商品マスタより商品を取得する
Item item = itemService.findItem(value);
// 商品が存在する場合はチェックOK、存在しない場合はチェックNGとする
if(item != null) {
return true;
} else {
return false;
}
}
}


こちらも利用方法は標準のバリデーションと同じです。


ValidationForm2.java

public class ValidationForm2 implements Serializable {

// setter, getterは省略
@NotNull
@ItemExists
private String itemCode;

画面イメージはこちらです。

うまくいきましたね。

validation2e.png


複数項目を利用する制約の作成

ちなみに、上記は単項目を使用したチェックでしたが、複数項目を使用することもできます。

上記の派生として、マスタが複合キーの場合を考えます。

複合キーはあまり推奨されないようですが、それでも実際にはよくありますよね。

例として、社員マスタの複合キーが会社コードと社員コードで、画面で入力された社員がマスタに存在しているかのチェックをします。

まずは、@EmployeeExists制約を作成します。

こちらには、2つのパラメータとして、会社コードのcompanyCode()と社員コードのemployeeCode()を定義します。


EmployeeExists.java

@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE})

@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = {EmployeeExistsValidator.class})
@ReportAsSingleViolation
public @interface EmployeeExists {

String message() default "{employeeexists}";

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

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

String companyCode(); // 会社コード
String employeeCode(); // 社員コード

@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public static @interface List {
EmployeeExists[] value();
}

}


そして、制約の実装クラスで、isValidメソッドを@Overrideします。

入力されたcompanyCodeとemployeeCodeを元に@Serviceクラス経由でマスタにアクセスし、存在しない場合にチェックエラーとします。


EmployeeExistsValidator.java

public class EmployeeExistsValidator implements ConstraintValidator<EmployeeExists, Object> {

@Autowired
EmployeeService employeeService;

private String message;
private String companyCode;
private String employeeCode;

@Override
public void initialize(EmployeeExists annotation) {
this.message = annotation.message();
this.companyCode = annotation.companyCode();
this.employeeCode = annotation.employeeCode();
}

@Override
public boolean isValid(Object value, ConstraintValidatorContext context) {
BeanWrapper beanWrapper = new BeanWrapperImpl(value);
String companyCodeValue = (String)beanWrapper.getPropertyValue(companyCode);
String employeeCodeValue = (String)beanWrapper.getPropertyValue(employeeCode);

// 会社コード、社員コードが入力されていない場合はチェックしない(フォームクラスのNotNullでチェックする)
if(companyCodeValue == null || employeeCodeValue == null) {
return true;
}
// 社員マスタを検索
Employee employee = employeeService.findEmployee(companyCodeValue, employeeCodeValue);
// 社員が存在する場合はチェックOK、存在しない場合はチェックNGとする
if(employee != null) {
return true;
} else {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(message).addPropertyNode(companyCode).addConstraintViolation();
context.buildConstraintViolationWithTemplate("").addPropertyNode(employeeCode).addConstraintViolation();
return false;
}

}
}


フォームクラスでは、クラスレベルで制約を設定します。


ValidationForm2.java

@EmployeeExists(companyCode = "companyCode", employeeCode = "employeeCode")

public class ValidationForm2 implements Serializable {
// 省略
private String companyCode;
private String employeeCode;

画面イメージはこちらです。

うまくいきましたね。

validation2xe.PNG


3. 相関チェックをフォームクラスに実装する

画面の複数の入力項目に対するチェック(相関チェック)のうち、

フォームクラスの項目だけでチェックが完結できるのであれば、フォームクラスにチェック機能を追加することができます。

例えば、以下のようなチェックです。


  • ラジオボタンで「その他」を選択した場合は、理由の入力が必須

  • パスワード変更画面で入力した「パスワード」と「確認用パスワード」が一致しているか

実装例として、日付の開始日と終了日の入力項目に対して、開始日≦終了日となっているかのチェックを実装してみます。


ValidationForm3.java

public class ValidationForm3 implements Serializable {

// setter, getterは省略
@NotNull(message = "{dateFrom.notnull}")
private Date dateFrom;

@NotNull(message = "{dateTo.notnull}")
private Date dateTo;

private boolean validDate;

@AssertTrue(message = "{invalidDate}")
public boolean isValidDate() {
if(dateFrom == null) return true;
if(dateTo == null) return true;
if(dateFrom.compareTo(dateTo) <= 0) return true;
return false;
}

}


フォームクラスに、@AssertTrueを付与したメソッドを作成し、開始日と終了日を比較します。

日付の項目がNullの場合は、それぞれの項目に対して@NotNullでチェックするので、ここではチェック対象外とします。

フォームにbooleanのプロパティvalidDateを追加しておくと、エラーメッセージの表示のときに便利です。


validation3.html

日付(期間):

<input type="date" th:field="*{dateFrom}" th:errorclass="fieldError" />
-
<input type="date" th:field="*{dateTo}" th:errorclass="fieldError" />
<span th:if="${#fields.hasErrors('dateFrom')}" th:errors="*{dateFrom}" class="fieldErrorMessage"></span>
<span th:if="${#fields.hasErrors('dateTo')}" th:errors="*{dateTo}" class="fieldErrorMessage"></span>
<span th:if="${#fields.hasErrors('validDate')}" th:errors="*{validDate}" class="fieldErrorMessage"></span>

画面イメージはこちらです。

validation3e.png

こちらは、エラー対象のプロパティを別途作成しているため、日付の入力項目に対してThymeleafのerrorclassは適用されません。


前編はここまで

今回は既存の制約の組み合わせ、独自のチェック機能を実装した制約、フォームクラスでの入力チェックを行いました。

後編ではバリデーターのクラスを作成してチェックを行う方法をやってみたいと思います。