1. takkkun

    Posted

    takkkun
Changes in title
+Kotlin + Spring Bootでリクエスト本文のバリデーションが効かない場合の対処
Changes in tags
Changes in body
Source | HTML | Preview
@@ -0,0 +1,187 @@
+普段KotlinでSpring Bootアプリケーションを書いています。
+
+で、 `@NotBlank` や `@Size(max = 10)` などでバリデーションを指定できると様々な記事で書かれているわけですが、これがいつも効かなくて困っていました。やっとこさ解決できたので、その方法をまとめておきます。
+
+## サンプルの仕様
+
+以下のような仕様とします。
+
+- Personというクラスがあり、nameを持つ
+- nameはPersonName型として指定する(値オブジェクト的な)
+- nameは空文字列を拒否し、10文字以下とする
+- Personの登録を行うエンドポイントを持ち、JSONで受け取る
+
+## サンプルの仕様にもとづいたコード
+
+Personクラスの定義や、PersonName用のJsonDeserializerの定義は省略しています。
+
+### PersonName.kt
+
+```kotlin
+import javax.validation.constraints.NotBlank
+import javax.validation.constraints.Size
+
+data class PersonName(
+ @NotBlank
+ @Size(max = 10)
+ val value: String
+)
+```
+
+### PersonRegisterRequestBody.kt
+
+```kotlin
+class PersonRegisterRequestBody(
+ val name: PersonName
+)
+```
+
+### PersonController.kt
+
+```kotlin
+import javax.validation.Valid
+import org.springframework.http.ResponseEntity
+import org.springframework.validation.BindingResult
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RequestBody
+import org.springframework.web.bind.annotation.RestController
+
+@RestController
+class PersonController {
+
+ @PostMapping("/register")
+ fun register(
+ @RequestBody @Valid requestBody: PersonRegisterRequestBody,
+ result: BindingResult
+ ): ResponseEntity<*> {
+ if (result.hasErrors()) {
+ throw // ...
+ }
+ // ...
+ }
+}
+```
+
+## 「サンプルの仕様にもとづいたコード」の問題点
+
+大体こんな感じのコードだと思います。ポイントとしては:
+
+- バリデーション対象にBean Validationの `@NotBlank` アノテーションや `@Size` アノテーションを付与し、バリデーションのルールを定義
+- 受け取るリクエスト本文に `@Valid` アノテーション(あるいは `@Validated` アノテーション)を付与
+- BindingResultでバリデーション結果を受け取る
+
+というところでしょうか。
+
+しかし、実際にこのSpring Bootアプリケーションを起動し、対象のエンドポイントに `{"name": ""}` なJSONをぶん投げると、 `result.hasErrors()` はfalseになります。パッと見 `@NotBlank` アノテーションが効いていないように見えます。
+
+## 解決法
+
+いくつか問題があるので、ひとつずつ潰していきます。
+
+### そもそもバリデーションを行っていない
+
+バリデーションのルールはたしかに定義されているのですが、これだけではバリデーションが実行されません。そりゃBindingResultも常にエラーなしになるわ。
+
+以下の依存をbuild.gradleに追加しましょう(別のビルドツール使っている方はよしなに)。
+
+```gradle
+implementation "org.springframework.boot:spring-boot-starter-validation:$springBootVersion"
+```
+
+正直これが一番ハマったかもしれない。
+
+ちなみにこの依存を追加していなくても、使える人は使える模様。Spring Bootの仕様を読む限り、JSR-303実装のバリデーター(Hibernateバリデーターなど)がクラスパス上にあれば自動的にバリデーションが有効になるっぽい?実際、[spring-boot-starter-validationもhibernate-validatorを依存に追加しているだけ](https://github.com/spring-projects/spring-boot/blob/master/spring-boot-project/spring-boot-starters/spring-boot-starter-validation/build.gradle)。
+
+### アノテーションの対象が間違っている
+
+Spring Bootの仕様だと思いますが、バリデーションのルールは基本フィールドに対して付与する必要があります。
+
+しかし、Kotlinのclassのコンストラクターにvalを指定した場合、それはコンストラクターのパラメーターでもあるし、フィールドでもあるし、ゲッターでもあります。なので単にアノテーションを付与すると、開発者が期待する対象と実際の対象が食い違うことが起こり得ます。実際、今回の `@NotBlank` などのアノテーションはコンストラクターのパラメーターに対して付与されちゃいます。
+
+Kotlinには[Annotation User-site Targets](https://kotlinlang.org/docs/reference/annotations.html#annotation-use-site-targets)という仕組みがあり、何に対するアノテーションなのか明示的に記述できます。今回はフィールドに対するアノテーションなので、以下のようにしましょう。
+
+```kotlin
+import javax.validation.constraints.NotBlank
+import javax.validation.constraints.Size
+
+data class PersonName(
+ @field: NotBlank
+ @field: Size(max = 10)
+ val value: String
+)
+```
+
+いずれのアノテーションも同一の対象なので、まとめて以下のように書いてもOKです。
+
+```kotlin
+import javax.validation.constraints.NotBlank
+import javax.validation.constraints.Size
+
+data class PersonName(
+ @field: [NotBlank Size(max = 10)]
+ val value: String
+)
+```
+
+### 値オブジェクトのバリデーションが実行されない
+
+サンプルの仕様にもとづいたコードのように、リクエスト本文で受け取る型をPersonNameのような値オブジェクトめいたものにし、そちらのバリデーションのルールを流用しちゃおう、みたいなやつは、先述したコードでは動作しません。
+
+なぜなら「PersonRegisterRequestBodyが受け取るPersonNameは有効であるべき」という指定が抜けているからです。結論から言うと以下のようにします。フィールドを対象にすることを忘れずに!
+
+```kotlin
+import javax.validation.Valid
+
+class PersonRegisterRequestBody(
+ @field: Valid
+ val name: PersonName
+)
+```
+
+なるほど、たしかにコントローラーのメソッドの引数には `@Valid` アノテーションを付与し、「有効であるべき」という指定をしているんだから、それをクラスのフィールドに対しても行わないといかんよな、という感じで納得できる(感想)。
+
+まあStringなどを直接受け取るのであれば別に頭悩ませることではないと思いますが!
+
+## サンプルの仕様にもとづいた正しいコード
+
+というわけで解決法を適用した正しいコードは下記の通り。これでめでたし。
+
+### build.gradle
+
+```gradle
+dependencies {
+ // ...
+
+ implementation "org.springframework.boot:spring-boot-starter-validation:$springBootVersion"
+
+ // ...
+}
+```
+
+### PersonName.kt
+
+```kotlin
+import javax.validation.constraints.NotBlank
+import javax.validation.constraints.Size
+
+data class PersonName(
+ @field: NotBlank
+ @field: Size(max = 10)
+ val value: String
+)
+```
+
+### PersonRegisterRequestBody.kt
+
+```kotlin
+import javax.validation.Valid
+
+class PersonRegisterRequestBody(
+ @field: Valid
+ val name: PersonName
+)
+```
+
+### PersonController.kt
+
+変更なし。