くちたと計算記

プログラミングのことを書きます

Spring Web MVC のアノテーション `ModelAttribute` の挙動と、 `SessionAttributes` との併用でうまくいかなかったこと

概要

入力フォームを Spring Web MVC の Annotation Controllers で実装しているときに、 @ModelAttribute@SessionAttributes を使ったところ、すんなりと上手くいかない部分がありました。 そこで、起きた問題と解決策を記事に書きました。

前提

  • Java 17
  • Kotlin 1.7
  • Spring Boot 3.0

プロジェクトを作るときに spring-boot-starter-webspring-boot-starter-thymeleaf を依存ライブラリとして追加しました。

結論

  1. @SessionAttributes とメソッド引数の @ModelAttribute を組み合わせるときは、あらかじめ HTTP セッションにインスタンスが必要である
  2. 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.

Spring Web MVC 公式リファレンスより引用

そこでリストの一つ目に載っている、@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.

Spring Web MVC 公式リファレンス より引用

しかし、 @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.

Spring Web MVC 公式リファレンス より引用

Kotlin の公式リファレンス によると、 var で定義したフィールドは setter が生成されるようです。そこで、クラス PostArticleForm のフィールドを val ではなく var で定義しました。

data class PostArticleForm(var headline: String = "", var body: String = "")

再度、入力画面からフォームを送信したところ、送信されたフォームデータを受け取れるようになりました。

想定している入力だけを受け取ることや、実装時の間違いを減らすことを考えると、以下 2 点を徹底したほうが良いと考えました。

  • 入力値をバインドするクラスには入力値に対応するフィールドだけを定義する
  • 入力フォームはユーザーがデータの書き換えを行うのだから、それに対応するクラスのフィールドも var にする

最後に

Spring Framework のリファレンスを見れば、わからないことはちゃんと書いてあったので良かったです。 Spring Boot のリファレンスだけでは分からないこともあるので、分からないことがあったら Spring Framework のリファレンスも参照することをお勧めします。

引用/参考サイト