一つのFormから複数のModelAttributeを受け取る場合に、バリデーションエラーが400エラーになってしまうのを回避する

最近Spring Frameworkを触っているのだけど、バリデーション時の不可思議な挙動で2時間くらいハマったのでメモ。Spring Boot固有の問題かもしれない(そこまでは調べてない)。

掲題の通り、一つのFormに複数のModelAttributeを割り当て、リクエスト値をアクションメソッドの@Validated @ModelAttributeな引数でバインドして受け取るとき、いずれかのModelAttributeでバリデーションエラーが発生するとなぜか400エラーとなってしまい、アクションメソッドの処理に入らずエラーページに飛ばされてしまう。

400エラー(Spring Boot使用時のため、Whitelabelエラーページに飛ばされる):

There was an unexpected error (type=Bad Request, status=400).
Validation failed for object='model1'. Error count: 1

これを回避し、期待する動作(アクションメソッド内でBindingResult#hasErrors()を使用してバリデーションエラー時の処理を行う)をさせるには、ModelAttribute引数ごとに、個別のBindingResult引数を設定する。

つまり、下記のように、model1、model2それぞれの直後にBindingResult引数を配置する。

Controller:

@RequestMapping(value = "/hoge", method = RequestMethod.POST)
public ModelAndView newStoryComplete(
  @Validated @ModelAttribute("model1") Model1 model1,
  BindingResult result1,
  @Validated @ModelAttribute("model2") Model2 model2,
  BindingResult result2
) {

  if (result1.hasErrors() || result2.hasErrors()) {
    // エラー時処理

View (Thymeleaf):

<form action="#" th:action="@{/hoge}" method="post">
  <div>Param1: <input type="text" th:field="${model1.param1}" /><span th:errors="${model1.param1}"></span></div>
  <div>Param2: <input type="text" th:field="${model2.param2}" /><span th:errors="${model2.param2}"></span></div>
  <button type="submit">Send</button>
</form>

これで400エラーは発生せず、アクションメソッド内に入ってくれるようになる。

下記参考記事が大きなヒントになった。そろそろSpringのソースを追うしかないかな……とあきらめかけていたところだったので本当に救われた。

@ModelAttributeとBindingResultの順序を正しく設定しないとリクエストがマッピングされない件 – Springバッドノウハウ | Developers.IO

ちなみに、一つのFormに複数のModelAttributeを割り当てるのがいいことなのかは知らない。一般的なお作法では、Commandクラス(もしくはFormオブジェクト)と呼ぶPOJO1フォームごとに作って1対1にするみたいなのだけど……。値の詰め直しとか、バリデーションの定義がCommandクラスごとに散らばる(重複する)可能性とか率直に面倒と思うが、そういうものなんですかね。そもそもJSPの場合、で指定するから必然的に一つになってしまうという事情もありそうだが……。

環境

  • JDK 1.7.0_21
  • Spring MVC 4.0.7
  • Spring Boot 1.1.7
  • Thymeleaf 2.1.3