@ModelAttribute を使う

Spring 関連の小ネタで、@ModelAttribute を使おう、という話。当たり前に使われているからか、私の探し方が悪いからか、「活用しよう」という記事をあまり見た覚えがないわりに、わりと便利に使えるので紹介したい。


@ModelAttribute の動き

@ModelAttribute アノテーションは、コントローラのメソッドか引数につけて使う。いずれの場合も、ハンドラメソッドが動く前にアノテーションが検出され、メソッドについているか、引数についているかに応じて、Spring によって処理される。

メソッドについている場合、Spring がハンドラメソッドを呼ぶ前に、@ModelAttribute つきのメソッドを呼び出す。Spring は、その戻り値を Model に詰める。Model に詰める際のキー (名前) は、@ModelAttribute.name で指定する。未指定の場合は戻り値のオブジェクトのクラス名から自動生成されたものになる。

引数についている場合、Spring がハンドラメソッドを呼ぶ前に、その引数につけられた @ModelAttribute.name に対応するオブジェクトを Model から探す。Model に該当するオブジェクトがなければ、そのタイミングで new する。引数のオブジェクトが用意できたら、Spring はそのオブジェクトに対してリクエストボディのバインディングを行う。そうして用意されたオブジェクトが、ハンドラメソッドのオブジェクトとして渡される。バインディングを行いたくない場合は @ModelAttribute.bindingfalse を設定する。

@ModelAttribute の用途

かんたんな動きを抑えたところで、@ModelAttribute の具体的な使い方をいくつか介したい。

リクエストボディの内容を受け取る

ど定番の使い方。POST で送られてくるリクエストボディの内容を、オブジェクトにバインディングするというもの。ハンドラメソッドの引数に @ModelAttribute をつける。特に説明する点はない。

全ハンドラメソッドに共通するオブジェクトを作る

例えばこんなコントローラがあったとする:

@Controller
@RequestMapping("/")
public class HomeController {

  @GetMapping
  public String index(Model model) {
    model.addAttribute("menu", findMenuForCurrentUser());
    return "top";
  }
  
  @GetMapping
  public String sitemap(Model model) {
    model.addAttribute("menu", findMenuForCurrentUser());
    return "sitemap";
  }
}

このコントローラでは全ハンドラメソッド (といっても 2 つしかないが) で "menu" という Attribute を詰めている。@ModelAttribute を使えば、これを DRY にできる。

@ModelAttribute
public Menu getMenu() {
  return findMenuForCurrentUser();
}

@GetMapping
public String index() {
  return "top";
}

@GetMapping
public String sitemap() {
  return "sitemap";
}

findMenuForCurrentUser の結果を Model に突っ込む処理が、@ModelAttribute を使うことで getMenu に集約されている。

全ハンドラメソッドに共通するオブジェクトを作る - その 2

URL の一部をエンティティの ID とし、URL 内の ID から対象となるエンティティを検索して処理をする。なんていう設計は、よくあると思う。@ModelAttribute を使うと、これも DRY にできる。

@Controller
@RequestMapping("/posts/{id}")
public class PostController {
  @ModelAttribute
  public Post getPost(@PathVariable long id) {
    return findPostById(id);
  }
  
  @GetMapping
  public String show() {
    return "show";
  }
  
  @GetMapping("/edit")
  public String edit() {
    return "edit";
  }
  
  @PutMapping
  public String update(@ModelAttribute Post post, BindingResult results) {
    // ...
    return "redirect:/posts/" + post.id;
  }
}

ここでは URL 内の ID から Post というエンティティを探してくる処理を @ModelAttribute つきのメソッドで実装した。@ModelAttribute つきのメソッドでは、ハンドラメソッドと同じような引数を受け取ることができるので、@PathVariable をメソッドの引数につけることができる。

ハンドラメソッド update では引数にも @ModelAttribute をつけているので、バインディングが行われる。このときバインディングする対象となるのは、getPost の戻り値のオブジェクトとなる (引数のオブジェクトを Model から探すから)。

存在チェック

URL の一部をエンティティの ID とし、URL 内の ID から対象となるエンティティを検索して処理をする。エンティティが見つからない場合は 404 とする。なんていう設計は、よくあると思う。@ModelAttribute を使うと、これも DRY にできる。先の例の getPost を修正する。

@ModelAttribute
public Post getPost(@PathVariable long id) {
  return findPostById(id).orElseThrow(NotFoundException::new);
}

ここでは Post が見つからなければ NotFoundException (という例外が Spring に用意されているわけではない) を投げるようにした。@ModelAttribute つきのメソッドは常にハンドラメソッドより前に呼び出されるので、Post が見つからなければハンドラメソッドに入る前に例外が飛ぶので、リクエストをエラーとすることができる。NotFoundException を 404 にひもづける実装は @ExceptionHandler@ResponseStatus で行うとよい。

パラメータの前処理

例えば送られてくるクエリパラメータを正規化するとか。

@Controller
@RequestMapping("/search")
public class SearchController {
  @ModelAttribute(name = "query")
  public String normalize(@RequestParam String q) {
    return Normalizer.normalize(q, Normalizer.Form.NFKC);
  }

  @GetMapping("/users")
  public String searchUsers(@ModelAttribute(name = "query", binding = false) String query, Model model) {
    model.addAttribute("users", searchUsersByQuery(query));
    return "searchUsers";
  }
  
  @GetMapping("/posts")
  public String searchPosts(@ModelAttribute(name = "query", binding = false) String query, Model model) {
    model.addAttribute("posts", searchPostsByQuery(query));
    return "searchPosts";
  }
}

基本的な原理はこれまで解説してきた通り。@ModelAttribute.binding を使うことで、リクエストボディのバインディングを行わないよう Spring に伝えている。今回は扱っているオブジェクトが String なので特に問題はないが、独自の (mutable な) クラスで、かつバインディング不要な引数に binding=false を付け忘れると、予期しないバインディングが行われた結果、バグを作りこむかもしれない。バインディングが不要なら、指定が必須 (= 指定しなきゃ動かない) かどうかにかかわらず、おとなしく binding=false をつけておくのが無難だと思う。


とまぁ、色々便利といったわりには例が少なくなってしまったのだけど、POST のボディをバインディングする以外にも使い道があるよ、というお話でした。