@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 として利用する。
さいごに
なるほどー。
パラメータありのコンストラクタを利用する場合に、微妙に動作が異なることがあるので注意しないといけない。