@ModelAttribute を利用したリクエストパラメータをオブジェクトにバインドする処理ってどうなってるんだっけ、と調べることがあったので結果をまとめておく。
あまりドキュメントにも記載がないことが多く、ソースコードを読んだ結果をまとめている。
なお、@RequestParam でもリクエストパラメータを取得できるが、今回は対象外。
環境
- Spring Boot 2.1.6.RELEASE
 
@ModelAttribute が利用できる箇所
- メソッド
 - Handler メソッドの引数
 
実は、Handler メソッドの引数には @ModelAttribute 付与しなくてもよかったりする。
デフォルトの挙動では、Handler メソッドの引数に BeanUtils.isSimpleProperty で false と判定されるクラスを指定すると、@ModelAttribute が付与された場合と同様の動作となる。
具体的には、プリミティブ型とそのラッパー、Enum、String、CharSequence、Number、Date、URI、URL、Locale、Class、配列、以外のクラス。
ただ、優先度は一番低いので、他の HandlerMethodArgumentResolver が引数を解決できるのであれば、そちらで解決される。
ModelAttributeMethodProcessor
Spring では、HandlerAdapter が  Handler メソッドを呼び出す。いくつかの実装クラスがあるが、@RequestMapiing を利用している場合は、RequestMappingHandlerAdapter が Handler メソッドの呼び出しを担う。
このクラスが Handler メソッドの引数に渡す値を解決したり、戻り値をハンドリングしたりしていて、Handler メソッドの引数に Model や RedirectAttributes を指定するといい感じに処理できるのは、このクラスが頑張っているから。
で、実際に引数に渡す値を解決するのは HandlerMethodArgumentResolver の実装クラスの役割で、@ModelAttribute が指定された場合には ModelAttributeMethodProcessor が処理を行っている。
メソッドに @ModelAttribute を付与した場合の挙動
ここは本題ではなかったが、@ModelAttribute を指定したメソッドの動作もまとめておく。
メソッドの戻り値を Model に格納する処理が Handler メソッドの実行前に処理が行われる。
(厳密には ModelAndViewContainer 内の ModelMap だが、ややこしくなりそうなので Model ということにしておく。)
例えば以下のような Controller を作ったとする。
@Controller
public class HelloController {}
    @ModelAttribute
    public User setUp() {
        return new User();
    }
    @GetMapping("/")
    public String index() {
        return "hello";
    }
}
その際の挙動は以下のようなイメージになる。(あくまでもイメージで厳密には違う。)
@Controller
public class HelloController {
    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute(new User());
        return "hello";
    }
}
以下のように@ModelAttriubte に属性名を指定した場合は、
@ModelAttribute("hoge")
public User setUp() {
    return new User();
}
model.addAttribute() の第一引数に追加されるイメージ。
@GetMapping("/")
public String index(Model model) {
    model.addAttribute("hoge", new User());
    return "index";
}
ちなみに、@ModelAttribute が付与されたメソッドを呼び出しているのは、ModelFactory というクラス。
Handler メソッドの引数に @ModelAttribute を付与した場合の挙動
ここからが本題。
オブジェクトの取得、生成
まず、Model から指定されたオブジェクトを取得する。@ModelAttribute に value or name を指定した場合は Model から取得する際の key を指定できる。省略した場合は、クラス名から Spring が自動的に決定する。
Model から取得できるパターンはいくつかあり、前述の @ModelAttribute を付与したメソッドで Model に格納していたり、リダイレクト時に Flash スコープを利用していたり、@SessionAttribute で Session スコープを利用したりしていると、Handler メソッド実行前(というより引数に渡す値の解決前)に Model に格納されていることがある。
Model からオブジェクトが取得できなかった場合は、オブジェクトを生成する。
コンストラクタが 1 つの場合はそれを利用し、複数ある場合はデフォルトコンストラクタを利用するが、このときデフォルトコンストラクタが無い場合は死ぬ。
引数ありコンストラクタを利用する場合は、リクエストパラメータをバインドする処理が挟まるが、これは後述。
リクエストパラメータのバインド
取得 or 生成したオブジェクトに対して、リクエストパラメータの値をバインドする。
リクエストパラメータの名前と一致するオブジェクトのフィールドに対して値がバインドされる。
オブジェクトの値を上書きするが、リクエストパラメータに含まれない値に対しては何も行わない。
@Controller
public class HelloController {
    @ModelAttribute
    public User setUp() {
        User user = new User();
        user.setName("なまえ");
        user.setEmail("メール");
        return user;
    }
    @GetMapping("/")
    public String index(User user) {
        return "hello";
    }
}
User が name と email というフィールドを持っているとする。
で、以下のように name だけをリクエストパラメータに付与して送信する。
curl http://localhost:8080?name=hogehoge
User オブジェクトの name は hogehoge に、email は メール となり、null で上書きされることはない。
パラメータありのコンストラクタでオブジェクトを生成する場合
パラメータありのコンストラクタを用いてオブジェクトを生成する場合は、そのタイミングでデータのバインドを試みる。
この場合、フィールド名ではなくコンストラクタのパラメータ名 or @ConstructorProperties で指定された値と一致するリクエストパラメータをバインドする。
public class User {
    private String name;
    private String email;
    public User(String n, String e) {
        this.name = n;
        this.email = e;
    }
    public String getName() {
        return name;
    }
    public String getEmail() {
        return email;
    }
}
上記のようなコンストラクタの場合、リクエストパラメータ名は n や e にしないといけない。
public class User {
    private String name;
    private String email;
    @ConstructorProperties({"name", "email"})
    public User(String n, String e) {
        this.name = n;
        this.email = e;
    }
    public String getName() {
        return name;
    }
    public String getEmail() {
        return email;
    }
}
上記のように @ConstructorProperties を使えば、リクエストパラメータ名は name や email にできる。なお、@ConstructorProperties がある場合はそちらが優先されてしまうため、n や e では動作しなくなる。
ただ、オブジェクトの生成が完了した後に、通常と同様のリクエストパラメータをバインドする処理が実行されるので、setter がある場合や、ダイレクトフィールドアクセスが可能な DataBinder を利用している場合は、そちらの結果が優先されてしまう。
例えば、以下のようなクラスを作った場合、
public class User {
    private String name;
    private String email;
    public User(String name, String email) {
        this.name = name + "hoge";
        this.email = email + "fuga";
    }
    public String getName() {
        return name;
    }
    public String getEmail() {
        return email;
    }
    public void setName(String name) {
        this.name = name;
    }
    public void setEmail(String email) {
        this.email = email;
    }
}
コンストラクタでは、hoge や fuga を付与してフィールドに保持しているが、その後 setter が実行されてしまうので、最終的にバインドされる値には、hoge や fuga が付与されていない。
リクエストパラメータのバインドを抑止する
@ModelAttribute の binding を false にすることで、リクエストパラメータのバインドを抑止することができる。
@Controller
public class HelloController {
    @ModelAttribute
    public User setUp() {
        User user = new User();
        user.setName("なまえ");
        user.setEmail("メール");
        return user;
    }
    @GetMapping("/")
    public String index(@ModelAttribute(binding = false) User user) {
        return "hello";
    }
}
上記のような状態で以下のリクエストを送信しても User のオブジェクトの name は なまえ、email は メール のままとなる。
curl http://localhost:8080?name=hogehoge&email=fugafuga
バリデーション
引数に @Validated が付与されるなど、バリデーションが必要な場合は実施する。
バリデーションの結果、エラーがある場合でかつ Handler メソッドの直後の引数に Errors がない場合、BindingException がスローされ、ある場合は BindingResult として結果を保持する。(Errors は BindingResult の親インターフェース)
ちなみに Handler メソッドで BindingResult を引数に設定する際に、順番に気を付けなければならないのは、ここの実装によるもの。
話が横道にそれるが Handler メソッドの引数に BindingResult を設定した場合、ErrorsMethodArgumentResolver というクラスが引数の値を解決する。
このクラスは Model に格納された最後の要素が BindingResult かどうかをチェックし、そうであれば引数に設定するという処理を行っている。
そのため、バリデーションを行って BindingResult を Model に格納した直後に実行されないと上手く動作しない可能性がある。
なので、Handler メソッドの引数には @Validated を付与した引数の直後に BindingResult が無いといけないっぽい。
これを調べて思ったが、以下のようにリクエストパラメータをバインドするオブジェクトを 2 つに分けて、それぞれのバリデーション結果を取得するようなコードも実現できる。
User のバリデーション結果は result1 に、OtherObj のバリデーション結果は result2 に格納される。
@Controller
public class HelloController {
    @GetMapping("/")
    public String index(@Validated User user, BindingResult result1, @Validated OtherObj other, BindingResult result2) {
        return "index";
    }
}
パラメータありのコンストラクタを利用する場合
パラメータありのコンストラクタを用いる場合は、オブジェクトを生成するタイミングで必要に応じて型変換が行われる。
その際、型変換ができないフィールドが含まれていた場合はその結果を BindingResult として保持するが、それ以降のバインド処理、バリデーションが実施されない。
つまり、setter によるリクエストパラメータのバインド処理や、Bean Validation によるフィールドチェックが実施されない。
public class User {
    @NotEmpty
    private String name;
    private Integer age;
    public User(String name, Integer age) {
        this.name = name;
        this.age = age;
    }
    // getter 省略
}
@Controller
public class HelloController {
    @GetMapping("/")
    public String index(@Validated User user, BindingResult result) {
        return "index";
    }
}
上記のようなクラスを作成した場合に、以下のようなリクエストを送る。
curl http://localhost:8080?age=hogehoge
本来期待しているバリデーション結果は、name が空であること、age が Integer に変換できないことだが、後者しか検出することができない。
パラメータありのコンストラクタを削除し、デフォルトコンストラクタと setter を利用するように変更すると、どちらも検出できる。
Model へ格納
上記までの処理で生成したオブジェクトと BindingResult を Model に格納する。
@ModelAttribute で属性名が指定されている場合は、その値を key として利用する。
さいごに
なるほどー。
パラメータありのコンストラクタを利用する場合に、微妙に動作が異なることがあるので注意しないといけない。