普段KotlinでSpring Bootアプリケーションを書いています。
で、 @NotBlank
や @Size(max = 10)
などでバリデーションを指定できると様々な記事で書かれているわけですが、これがいつも効かなくて困っていました。やっとこさ解決できたので、その方法をまとめておきます。
サンプルの仕様
以下のような仕様とします。
- Personというクラスがあり、nameを持つ
- nameはPersonName型として指定する(値オブジェクト的な)
- nameは空文字列を拒否し、10文字以下とする
- Personの登録を行うエンドポイントを持ち、JSONで受け取る
サンプルの仕様にもとづいたコード
Personクラスの定義や、PersonName用のJsonDeserializerの定義は省略しています。
PersonName.kt
import javax.validation.constraints.NotBlank
import javax.validation.constraints.Size
data class PersonName(
@NotBlank
@Size(max = 10)
val value: String
)
PersonRegisterRequestBody.kt
class PersonRegisterRequestBody(
val name: PersonName
)
PersonController.kt
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に追加しましょう(別のビルドツール使っている方はよしなに)。
implementation "org.springframework.boot:spring-boot-starter-validation:$springBootVersion"
正直これが一番ハマったかもしれない。
ちなみにこの依存を追加していなくても、使える人は使える模様。Spring Bootの仕様を読む限り、JSR-303実装のバリデーター(Hibernateバリデーターなど)がクラスパス上にあれば自動的にバリデーションが有効になるっぽい?実際、spring-boot-starter-validationもhibernate-validatorを依存に追加しているだけ。
アノテーションの対象が間違っている
Spring Bootの仕様だと思いますが、バリデーションのルールは基本フィールドに対して付与する必要があります。
しかし、Kotlinのclassのコンストラクターにvalを指定した場合、それはコンストラクターのパラメーターでもあるし、フィールドでもあるし、ゲッターでもあります。なので単にアノテーションを付与すると、開発者が期待する対象と実際の対象が食い違うことが起こり得ます。実際、今回の @NotBlank
などのアノテーションはコンストラクターのパラメーターに対して付与されちゃいます。
KotlinにはAnnotation User-site Targetsという仕組みがあり、何に対するアノテーションなのか明示的に記述できます。今回はフィールドに対するアノテーションなので、以下のようにしましょう。
import javax.validation.constraints.NotBlank
import javax.validation.constraints.Size
data class PersonName(
@field: NotBlank
@field: Size(max = 10)
val value: String
)
いずれのアノテーションも同一の対象なので、まとめて以下のように書いてもOKです。
import javax.validation.constraints.NotBlank
import javax.validation.constraints.Size
data class PersonName(
@field: [NotBlank Size(max = 10)]
val value: String
)
値オブジェクトのバリデーションが実行されない
サンプルの仕様にもとづいたコードのように、リクエスト本文で受け取る型をPersonNameのような値オブジェクトめいたものにし、そちらのバリデーションのルールを流用しちゃおう、みたいなやつは、先述したコードでは動作しません。
なぜなら「PersonRegisterRequestBodyが受け取るPersonNameは有効であるべき」という指定が抜けているからです。結論から言うと以下のようにします。フィールドを対象にすることを忘れずに!
import javax.validation.Valid
class PersonRegisterRequestBody(
@field: Valid
val name: PersonName
)
なるほど、たしかにコントローラーのメソッドの引数には @Valid
アノテーションを付与し、「有効であるべき」という指定をしているんだから、それをクラスのフィールドに対しても行わないといかんよな、という感じで納得できる(感想)。
まあStringなどを直接受け取るのであれば別に頭悩ませることではないと思いますが!
サンプルの仕様にもとづいた正しいコード
というわけで解決法を適用した正しいコードは下記の通り。これでめでたし。
build.gradle
dependencies {
// ...
implementation "org.springframework.boot:spring-boot-starter-validation:$springBootVersion"
// ...
}
PersonName.kt
import javax.validation.constraints.NotBlank
import javax.validation.constraints.Size
data class PersonName(
@field: NotBlank
@field: Size(max = 10)
val value: String
)
PersonRegisterRequestBody.kt
import javax.validation.Valid
class PersonRegisterRequestBody(
@field: Valid
val name: PersonName
)
PersonController.kt
変更なし。