KotlinでformバインディングするときもやっぱりJava Beansにした方が良さそう

Posted on 2025/05/04

はじめに

前回 の続きになります。
前回はJavaでformバインディングしvalidationを行いました。

今回は、Kotlinでformバインディングしてみて挙動を確認します。

いろんなバリエーションの型でformバインディングしてみる

primary-constructor val non-null

  • ソースコード
    • hash: e26aa159289f866fe64484ff04f885093cc31988
    • path: spring/validation/kotlin-form-binding

Javaプログラマー(私)がKotlinを始めてまずやろうとするのはこんなところではないでしょうか:

  • formバインディングクラスは不変であるべき -> val でプロパティ宣言する
  • 必須入力のものはnon-nullで定義する

具体的には次のような実装です(DataClassValForm.kt):

data class DataClassValForm(
    /** 名前(必須) */
    @field:NotNull
    @field:NotEmpty
    val name: String,

    /** 生年月日(必須) */
    @field:NotNull
    @field:DateTimeFormat(iso = DATE)
    val birthDate: LocalDate,

    /** 年齢(任意) */
    val age: Short?,

    /** 家族 */
    @field:Valid
    @field:NotNull
    val families: List<DataClassFamily> = emptyList(),
)

data class DataClassFamily(
    /** 家族の名前(必須) */
    @field:NotNull
    @field:NotEmpty
    val familyName: String,
)

ではこれを実行してみましょう。

./gradlew bootRun

で起動したらブラウザーで http://localhost:8080/dataClassVal にアクセスしてみます。

…サーバーでエラーが発生しますね。

Parameter specified as non-null is null: method com.example.kotlin_form_binding.DataClassValForm., parameter name

というこどで、 DataClassValForm のコンストラクション時に、 String 型である namenull が入ってしまっているようです。

改めて実装を見てみると、ハンドラー

fun dataClassValForm(model: Model, @ModelAttribute("profile") form: DataClassValForm): String {

の引数、 form オブジェクトがフレームワークによって生成されているはずです。
このオブジェクトの name プロパティのデフォルト値は…まあ普通に考えて null でしょうね。
なのでオブジェクト型はnullableでなければならないようです。

ちなみにpostではどうなるかというと、

curl -X POST http://localhost:8080/dataClassVal \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "name=鈴木一郎" \
  -d "birthDate=x" \
  -d "age=28" \
  -d "families[0].familyName=鈴木さくら"

birthdate に不正な値が入った時、データバインディングフェーズで LocalDatenull が設定されるのでやはりうまくいきません。

primary-constructor val nullable

前節の通り、必須項目であってもnullableにしなければどうもうまく動かないようです。

本節では全てのプロパティ(collectionを除く)をnullableにして試してみます。

  • ソースコード
    • hash: 92bd707a15296e574794f15bbc7a14368e3c4fad
    • path: spring/validation/kotlin-form-binding

DataClassValNullableForm.kt:

data class DataClassValNullableForm(
    /** 名前(必須) */
    @field:NotNull
    @field:NotEmpty
    val name: String?,

    /** 生年月日(必須) */
    @field:NotNull
    @field:DateTimeFormat(iso = DATE)
    val birthDate: LocalDate?,

    /** 年齢(任意) */
    val age: Short?,

    /** 家族 */
    @field:Valid
    @field:NotNull
    val families: List<DataClassFamilyNullable> = emptyList(),
)

data class DataClassFamilyNullable(
    /** 家族の名前(必須) */
    @field:NotNull
    @field:NotEmpty
    val familyName: String?,
)

前回との違いは、全てのプロパティの型に ? が付いていることです。

http://localhost:8080/dataClassValNullable にアクセスして試してみます。

正常系ではうまく動いていそうに見えます。が、生年月日に “aaaa” を入力してみると、

Caused by: org.attoparser.ParseException: Exception evaluating SpringEL expression: "name" (template: "form" - line 29, col 18)
...
Caused by: org.springframework.expression.spel.SpelEvaluationException: EL1007E: Property or field 'name' cannot be found on null

という例外がスローされ Internal Server Error になってしまいます。

メッセージからは、どうもThymeleafプロセッサーに渡るべき DataClassValNullableForm 型オブジェクトが null になっている(ので nullname プロパティなんて無いよ、と言っている)ようです。

が、この「Thymeleafプロセッサーに渡るべき DataClassValNullableForm 型オブジェクト」ってハンドラーの @ModelAttribute が付いた引数 form だと思うのですが、これはちゃんと生成されているのですよね…なぜこのエラーが出るのかがわかっていないです。

原因はわからないのですが、Javaでうまくいく以上、Kotlinでも書き方をどうにかすればこの問題は起こらなくなるはずです。その書き方を探っていきます。

primary-constructor var nullable

valvar に変え、デフォルト値も設定してみます。
これでJavaからは引数なしのデフォルトコンストラクター、setter/getterを備えているように見えているはずです。

  • ソースコード
    • hash: fe9cd643e486d814d7274b7bbcf76c000caeb8ba
    • path: spring/validation/kotlin-form-binding

http://localhost:8080/dataClassValNullable にアクセスして前回と同じく生年月日に妥当でない文字列を入力してみます。
…同じエラーが出ます。

without primary-constructor

primary constructor でのプロパティ宣言をやめ、bodyで宣言するように変更します。

  • ソースコード
    • hash: e75e84b047b464292e8f8070112f84811944e921
    • path: spring/validation/kotlin-form-binding

http://localhost:8080/kotlin へアクセス、同様に年月日として妥当でない文字列を入力。
…やっと想定通り動作しました!

まとめ

これまで見てきた結果、KotlinでThymeleafのformバインディングクラスを実装する場合、プロパティは

  • var で宣言する
  • nullable で宣言する
  • primary constructorでなくbodyで宣言する
    • したがってデフォルト値が必要になる

を守る必要があるようです。つまり、 Java Bean 的な実装が求められるようです。

Spring MVCのドキュメントには constructor binding ができるって書いてあるし、そもそもSpring自身Kotlin対応してるって言ってるのでなんでこんな挙動になるのだろう?と思っていたのですが、よくよく考えると今回エラーが出ているのはSpring部分ではなくてThymeleafエンジン部分ですね。

そういえばThymeleafってKotlin向けに考慮している、みたいな話を聞いたことが無いし…と納得することにしました。

したがって、 RestController (一般的にJSONとJavaオブジェクトの変換はJacksonを使うと思います)などではまた話が変わってくるかなと思います。
Jacksonは積極的にKotlin対応してたはずですしね。