概要
入力フォームを Spring Web MVC の Annotation Controllers で実装しているときに、 @ModelAttribute
と @SessionAttributes
を使ったところ、すんなりと上手くいかない部分がありました。
そこで、起きた問題と解決策を記事に書きました。
前提
- Java 17
- Kotlin 1.7
- Spring Boot 3.0
プロジェクトを作るときに spring-boot-starter-web
と spring-boot-starter-thymeleaf
を依存ライブラリとして追加しました。
結論
@SessionAttributes
とメソッド引数の@ModelAttribute
を組み合わせるときは、あらかじめ HTTP セッションにインスタンスが必要である- HTTP セッションに保存されたインスタンスのフィールドを書き換えるには、フィールドを可変にする必要がある
上手くいかなかったところ
上手くいかなかったところが二つありました。 起きた問題と解決策を一つずつ説明します。
リクエストハンドラで @ModelAttribute
を付けた引数の解決に失敗する
問題
以下のように PostArticleController
を実装しました。
data class PostArticleForm(val headline: String = "", val body: String = "") @SessionAttributes(PostArticleController.FORM_MODEL_NAME) @Controller class PostArticleController { companion object { const val FORM_MODEL_NAME = "postArticleForm" } @GetMapping("article/new") fun viewForm(@ModelAttribute(FORM_MODEL_NAME) postArticleForm: PostArticleForm): ModelAndView { return ModelAndView("article/new") } }
GET /article/new
にリクエストを送信したところ、以下のようなエラーが発生しました。
org.springframework.web.HttpSessionRequiredException: Expected session attribute 'postArticleForm'
原因と解決策
当初は、デフォルトコンストラクタを使って生成したインスタンスをモデルに追加する程度の意味で @ModelAttribute
を使っていました。
You can use the @ModelAttribute annotation on a method argument to access an attribute from the model or have it be instantiated if not present.
Spring Web MVCより引用
しかし、 @SessionAttributes
を追加したことで、以下のように「HTTP セッションからインスタンスを取り出す」という挙動に変わってしまい、エラーが発生するようになりました。
- Retrieved from the model where it may have been added by a @ModelAttribute method.
- Retrieved from the HTTP session if the model attribute was listed in the class-level @SessionAttributes annotation.
- Obtained through a Converter where the model attribute name matches the name of a request value such as a path variable or a request parameter (see next example).
- Instantiated using its default constructor.
- Instantiated through a “primary constructor” with arguments that match to Servlet request parameters. Argument names are determined through JavaBeans @ConstructorProperties or through runtime-retained parameter names in the bytecode.
そこでリストの一つ目に載っている、@ModelAttribute
メソッドからモデルにインスタンスを追加する処理を実装しました。
data class PostArticleForm(val headline: String = "", val body: String = "") @Controller @SessionAttributes(PostArticleController.FORM_MODEL_NAME) class PostArticleController { companion object { const val FORM_MODEL_NAME = "postArticleForm" } @ModelAttribute(FORM_MODEL_NAME) fun postArticleForm(): PostArticleForm { return PostArticleForm() } }
再度 GET /article/new
にリクエストを送信したところ、入力画面が表示されるようになりました。
送信されたフォームデータを @ModelAttribute
を付けた引数にバインドできない
問題
以下のように PostArticleController
を実装しました。
data class PostArticleForm(val headline: String = "", val body: String = "") @Controller @SessionAttributes(PostArticleController.FORM_MODEL_NAME) class PostArticleController { companion object { const val FORM_MODEL_NAME = "postArticleForm" } @ModelAttribute(FORM_MODEL_NAME) fun postArticleForm(): PostArticleForm { return PostArticleForm() } @GetMapping("article/new") fun viewForm(@ModelAttribute(FORM_MODEL_NAME) postArticleForm: PostArticleForm): ModelAndView { return ModelAndView("article/new") } @PostMapping("article/post") fun post( @ModelAttribute(FORM_MODEL_NAME) @Validated postArticleForm: PostArticleForm, sessionStatus: SessionStatus ): RedirectView { println(postArticleForm) sessionStatus.setComplete() return RedirectView("/") } }
入力画面からフォームを送信したところ、 引数 postArticleForm
内のフィールドがいずれも空文字列になっていました。
@SessionAttributes
を付ける前までは、送信されたフォームデータを受け取れていました。
原因と解決策
これは HTTP セッションに保存されたインスタンスのフィールドが不変だから起きている問題でした。
@SessionAttributes
を付ける前までは、下記 5 点目のように、受け取ったフォームデータを使ってプライマリコンストラクタを呼び出し、インスタンスを生成していました。
- Retrieved from the model where it may have been added by a @ModelAttribute method.
- Retrieved from the HTTP session if the model attribute was listed in the class-level @SessionAttributes annotation.
- Obtained through a Converter where the model attribute name matches the name of a request value such as a path variable or a request parameter (see next example).
- Instantiated using its default constructor.
- Instantiated through a “primary constructor” with arguments that match to Servlet request parameters. Argument names are determined through JavaBeans @ConstructorProperties or through runtime-retained parameter names in the bytecode.
しかし、 @SessionAttributes
を付けると、 HTTP セッションに保存されたインスタンスを取り出し、受け取ったフォームデータでフィールドを書き換えることになります。
Spring Web MVC の公式リファレンス では、 JavaBeans 規約に則ったアクセッサが公開されているフィールドがデータバインディングの対象になるとされています。
In the context of web applications, data binding involves the binding of HTTP request parameters (that is, form data or query parameters) to properties in a model object and its nested objects.
Only public properties following the JavaBeans naming conventions are exposed for data binding — for example, public String getFirstName() and public void setFirstName(String) methods for a firstName property.
Kotlin の公式リファレンス によると、 var
で定義したフィールドは setter が生成されるようです。そこで、クラス PostArticleForm
のフィールドを val
ではなく var
で定義しました。
data class PostArticleForm(var headline: String = "", var body: String = "")
再度、入力画面からフォームを送信したところ、送信されたフォームデータを受け取れるようになりました。
想定している入力だけを受け取ることや、実装時の間違いを減らすことを考えると、以下 2 点を徹底したほうが良いと考えました。
- 入力値をバインドするクラスには入力値に対応するフィールドだけを定義する
- 入力フォームはユーザーがデータの書き換えを行うのだから、それに対応するクラスのフィールドも
var
にする
最後に
Spring Framework のリファレンスを見れば、わからないことはちゃんと書いてあったので良かったです。 Spring Boot のリファレンスだけでは分からないこともあるので、分からないことがあったら Spring Framework のリファレンスも参照することをお勧めします。