Spring MVCを利用したフォームバリデーションのカスタム事例の後編です。
前編はこちらです。
Spring MVCにおけるフォームバリデーションの適用事例【前編】
4. 相関チェックをバリデーターに実装する
相関チェックをフォームクラスではなく、バリデーターを作成して、そこでチェックする方法です。
フォーム内の項目に自由にアクセスできるため、自由度は格段に高まります。
ラジオボタンの選択内容によって、日付の入力要否が変わるチェックを実装してみます。
フォームクラスにはシンプルに入力項目だけ保持します。
(もちろん、制約を付けておけば、そのチェックも実施されます)
public class ValidationForm4 implements Serializable {
// setter, getterは省略
private String period;
private Date dueDate;
}
入力チェックを実装するバリデーターを作成します。
public class Validator4 implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return ValidationForm4.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object form, Errors errors) {
ValidationForm4 validationForm4 = (ValidationForm4)form;
String period = validationForm4.getPeriod();
Date dueDate = validationForm4.getDueDate();
// 期間のラジオボタン(A:当日、B:期間指定)でBが選択された場合は、日付の入力が必要
if (period != null && period.equals("B") && dueDate == null) {
errors.rejectValue("dueDate", "dueDate.notnull");
}
}
}
validateメソッドで、フォームを受け取り、入力チェックを行います。
入力チェックエラーの場合は、rejectValueで、エラー項目とメッセージを設定します。
このバリデーターを呼び出すには、コントローラーでバリデーターをインジェクションし、@InitBinderで、バリデーターを登録します。
それだけで、フォームクラスの入力チェックのタイミングで、このバリデーターも呼ばれます。
@Controller
public class ValidationController4 {
@Autowired
Validator4 validator4;
@InitBinder
public void validatorBinder(WebDataBinder binder) {
binder.addValidators(validator4);
}
@InitBinder
public void dateBinder(WebDataBinder binder) {
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
CustomDateEditor editor = new CustomDateEditor(dateFormat, true);
binder.registerCustomEditor(Date.class, editor);
}
@ModelAttribute("radios")
public Map<String, String> radioList() {
return Collections.unmodifiableMap(new LinkedHashMap<String, String>() {
{
put("A", "当日");
put("B", "期間指定");
}
});
}
@RequestMapping(value = "validation4", method = RequestMethod.GET)
public String index(ValidationForm4 form, Model model) {
return "validation4";
}
@RequestMapping(value = "validation4", method = RequestMethod.POST)
public String confirm(@Validated ValidationForm4 form, BindingResult bindingResult, Model model) {
// 入力チェックエラーがある場合は入力画面に戻る
if (bindingResult.hasErrors()) {
return "validation4";
}
// 入力チェックエラーがない場合は確認画面に遷移する
model.addAttribute("validationForm4", form);
//ラジオボタンの表示名を設定
model.addAttribute("selectedPeriod", radioList().get(form.getPeriod()));
return "validationConfirm4";
}
}
画面イメージはこちらです。
こちらは、rejectValueで指定した項目に対してThymeleafのerrorclassが適用され、入力枠が赤くなってますね。
5. バリデーターからアノテーションを利用する
これまでのやりかたで、たいがいの入力チェックは実現できるのではないでしょうか?
他のちょっと工夫したやり方として、バリデーターから、条件に応じて適用する制約を変更してチェックを行う方法もあります。
できるだけ制約を利用することで、生産性アップや実装ミス防止につながります。
使用例として、以下の画面のように、受注を登録する画面があり、あらかじめ入力行をいくつか用意してあるとします。
入力チェックは以下のような仕様とします。
- 商品コードが入力された行は、数量と単価が必須、数量と単価に対する桁数チェック、商品のマスタ存在チェックを行う
- 商品コードが入力されていない行は、数量と単価は入力してはいけない
フォームクラスは、階層構造とします。
public class ValidationForm5 implements Serializable {
// setter, getterは省略
private List<ValidationForm5Child> validationForm5ChildList;
}
子の階層であるフォームクラスには、groupsを指定した制約を設定します。
public class ValidationForm5Child implements Serializable {
// setter, getterは省略
@ItemExists(groups = Selected.class)
private String item; // 商品が入力された場合は@ItemExistsでマスタ存在チェック
@NotNull(groups = Selected.class, message = "{qty.notnull}")
@Digits(integer = 3, fraction = 0, groups = Selected.class, message = "{qty.digits}")
@Null(groups = NotSelected.class, message = "{qty.null}")
private Integer qty; // 入力対象行は@NotNullと@Digits、入力対象外行は@Null
@NotNull(groups = Selected.class, message = "{price.notnull}")
@Digits(integer = 3, fraction = 0, groups = Selected.class, message = "{price.digits}")
@Null(groups = NotSelected.class, message = "{price.null}")
private Integer price; // 入力対象行は@NotNullと@Digits、入力対象外行は@Null
}
groupsは、入力行としてSelected
、入力対象外としてNotSelected
を表すクラスを作成します。
中身は不要です。
public @interface Selected {
}
public @interface NotSelected {
}
4と同様、入力チェックを実装するバリデーターを作成します。
@Component
public class Validator5 implements Validator {
@Autowired
SmartValidator smartValidator;
public boolean supports(Class<?> clazz) {
return ValidationForm5.class.isAssignableFrom(clazz);
}
public void validate(Object form, Errors errors) {
ValidationForm5 validationForm5 = (ValidationForm5)form;
List<ValidationForm5Child> validationForm5ChildList = validationForm5.getValidationForm5ChildList();
for(int i=0; i<validationForm5ChildList.size(); i++) {
ValidationForm5Child validationForm5Child = validationForm5ChildList.get(i);
String item = validationForm5Child.getItem();
errors.pushNestedPath("validationForm5ChildList[" + i + "].");
// 商品が入力されている場合は入力対象の行としてチェック
if(item != null && !item.equals("")) {
smartValidator.validate(validationForm5Child, errors, Selected.class);
// 商品が入力されていない場合は入力対象外の行としてチェック
} else {
smartValidator.validate(validationForm5Child, errors, NotSelected.class);
}
errors.popNestedPath();
}
}
}
ここでは、商品コードの有無に応じて、validateの引数であるgroupを変更します。
商品コードが入力されている行に対するチェック
smartValidator.validate(validationForm5Child, errors, Selected.class);
商品コードが入力されていない行に対するチェック
smartValidator.validate(validationForm5Child, errors, NotSelected.class);
エラー対象のフィールドを正しく設定するため、validateメソッドを呼び出す前後で、
errors.pushNestedPath("validationForm5ChildList[" + i + "].");
と
errors.popNestedPath();
を実行します。
コントローラーも4と同様で、コントローラーでバリデーターをインジェクションし、@InitBinderで、バリデーターを登録します。
ちなみに、initFormメソッドで、入力行を初期化しています。
@Controller
public class ValidationController5 {
private static final Logger logger = LoggerFactory.getLogger(ValidationController5.class);
@Autowired
Validator5 validator5;
@InitBinder
public void validatorBinder(WebDataBinder binder) {
binder.addValidators(validator5);
}
@ModelAttribute("validationForm5")
public ValidationForm5 initForm() {
ValidationForm5 form = new ValidationForm5();
List<ValidationForm5Child> validationForm5ChildList = new ArrayList<ValidationForm5Child>();
for(int i=0; i<5; i++) {
validationForm5ChildList.add(new ValidationForm5Child());
}
form.setValidationForm5ChildList(validationForm5ChildList);
return form;
}
@RequestMapping(value = "validation5", method = RequestMethod.GET)
public String index(ValidationForm5 form, Model model) {
return "validation5";
}
@RequestMapping(value = "validation5", method = RequestMethod.POST)
public String confirm(@Validated ValidationForm5 form, BindingResult bindingResult, Model model) {
// 入力チェックエラーがある場合は入力画面に戻る
if (bindingResult.hasErrors()) {
return "validation5";
}
// 入力チェックエラーがない場合は確認画面に遷移する
model.addAttribute("validationForm5", form);
return "validationConfirm5";
}
}
HTMLでは入力行をテーブル形式で表示します。
最後の列はエラーメッセージ表示エリアです。
// 省略
<table>
<tr>
<th>No.</th>
<th>商品</th>
<th>数量</th>
<th>単価</th>
<th></th>
</tr>
<tr th:each="validationForm5ChildList, stat : *{validationForm5ChildList}">
<td th:text="${stat.count}"></td>
<td>
<input type="text" th:field="*{validationForm5ChildList[__${stat.index}__].item}" th:errorclass="fieldError" />
</td>
<td>
<input type="number" th:field="*{validationForm5ChildList[__${stat.index}__].qty}" th:errorclass="fieldError" />
</td>
<td>
<input type="number" th:field="*{validationForm5ChildList[__${stat.index}__].price}" th:errorclass="fieldError" />
</td>
<td>
<span th:if="${#fields.hasErrors('*{validationForm5ChildList[__${stat.index}__].*}')}"
th:errors="*{validationForm5ChildList[__${stat.index}__].*}" class="fieldErrorMessage"></span>
</td>
</tr>
</table>
// 省略
入力チェック実施後の画面イメージはこちらです。
入力対象の行と入力対象外の行で、適用されているチェックが異なることがわかります。
さいごに
画面や入力項目が多い業務システムでは、入力チェックも多く、チェック仕様も複雑になりがちです。
ただ、同じ業務システムでは、画面横断的に使用する入力項目は似てきます。(商品、社員、顧客、数量、金額など)
それらの項目は事前に制約を作成しておくと、実装も1ヶ所にまとめられ、ミスもなくせ、何より生産性がアップします。
今回、いろいろなフォームバリデーションを試してみて、改めてBean Validation APIの便利さと奥深さを知ることができました。