Kotlin × Spring Boot 3 でコントローラの null 非許容型のバリデーションを 3 種類の方法で実装
概要
本記事では、筆者が Kotlin × Spring Boot 3 の組み合わせで、null 非許容型のバリデーションについて詰まった経験から、実装方法について考察しまとめた記事です。
全部で 3 つの方法を実践し、それぞれのメリットデメリットについて解説します。
本記事で作成したサンプルコードは以下です。詳細な部分が気になれば活用してください。
400 系のリクエストのハンドリングは以下の記事にまとめましたので、そちらを参照してください。
本記事が扱うコントローラの null 非許容型の Validation の課題
下記のように Kotlin × Spring Boot 3 プロジェクトのコントローラで @field:NotNull
や @field:NotBlank
アノテーションのモデルがあったとします。
data class NewNonNullArticleRequest(
@field:Valid
@field:NotNull
@field:JsonProperty("article", required = true) val newNonNullArticle: NewNonNullArticle
)
data class NewNonNullArticle(
@field:Size(max = 32)
@field:NotBlank
@field:JsonProperty("title", required = true) val title: String,
@field:Size(max = 1024)
@field:NotBlank
@field:JsonProperty("description", required = true) val description: String,
@field:Size(max = 2048)
@field:NotBlank
@field:JsonProperty("body", required = true) val body: String,
)
当初、null 非許容なプロパティに対して null の値でリクエストをおくると、MethodArgumentNotValidException が発生し、バリデーションエラー扱いになると考えました。
具体的には、下記のような @RestControllerAdvice
を利用した例外ハンドラでは、MethodArgumentNotValidException でハンドリングされると考えました。
/**
* ErrorHandlerController
*
* グローバルなエラーハンドリングを実装するコントローラクラス
*
* 予期しない例外が発生した際に、以下を実施する。
* - 秘密情報をレスポンスに含めないためのエラーハンドリング
* - 原因調査のためのログ出力
*
*/
@RestControllerAdvice
class ExceptionHandleController {
/**
* methodArgumentNotValidExceptionHandler
*
* リクエストのバリデーションエラーが発生した場合に発生させるエラーレスポンスを作成する関数
*
* @param e
* @return 400 エラーのレスポンス
*/
@ExceptionHandler(MethodArgumentNotValidException::class)
fun methodArgumentNotValidExceptionHandler(e: MethodArgumentNotValidException): ResponseEntity<GenericErrorModel> {
// エラーメッセージ本文の作成。FieldError の場合は、フィールド名を含める
val messages = e.bindingResult.allErrors.map {
when (it) {
is FieldError -> "${it.field}は${it.defaultMessage}"
else -> it.defaultMessage.toString()
}
}
return ResponseEntity<GenericErrorModel>(
GenericErrorModel(
errors = GenericErrorModelErrors(
body = messages
),
),
HttpStatus.BAD_REQUEST
)
}
/**
* httpMessageNotReadableExceptionHandler
*
* API スキーマが想定していないリクエストだった場合に発生させるエラーレスポンスを作成する関数
*
* @param e
* @return 400 エラーのレスポンス
*/
@ExceptionHandler(HttpMessageNotReadableException::class)
fun httpMessageNotReadableExceptionHandler(e: HttpMessageNotReadableException): ResponseEntity<GenericErrorModel> {
// 本番環境ではここで、エラーログをだす
println(e.message)
return ResponseEntity<GenericErrorModel>(
GenericErrorModel(
errors = GenericErrorModelErrors(
body = listOf("API が想定していない形式または型のリクエストが送られました。リクエストのパラメータを再度確認してください")
),
),
HttpStatus.BAD_REQUEST
)
}
}
しかし、実際には以下のように、HttpMessageNotReadableException が発生することがわかりました。
$ curl --location 'http://localhost:8080/non-null-article-model' \
--header 'Content-Type: application/json' \
--data '{
"article": {
"title": null,
"body": null,
"description": null
}
}' | jq .
{
"errors": {
"body": [
"API が想定していない形式または型のリクエストが送られました。リクエストのパラメータを再度確認してください"
]
}
}
ログには以下のようになります。
JSON parse error: Instantiation of [simple type, class com.example.patternofkotlinspringbootvalidation.presentation.model.NewNonNullArticle] value failed for JSON property title due to missing (therefore NULL) value for creator parameter title which is a non-nullable type
これはバリデーションの検証の前に、null 非許容型の変数に対して null を代入できずにインスタンス化できなかったため例外が発生します。
このままだと、以下の課題があると考えました。
- JSON のフォーマットが不正(閉じ括弧がない、プロパティの終端にカンマが余剰・不足など)とバリデーションエラーの識別がつかない
- 複数の必須プロパティが不足していたときに、どの必須プロパティがなかったのか判断できない
対処法
本項では、先述した課題に対して 3 つの実装を検討しましたので、それぞれ紹介します。
対処法 1 null 非許容型にする
実装
1 つめは課題の説明のときと同じ実装です。
リクエストモデルはすべて null 非許容型にします
そのため、リクエスト時点で null 非許容なプロパティに対して null をリクエストしないようにします。
data class NewNonNullArticleRequest(
@field:Valid
@field:NotNull
@field:JsonProperty("article", required = true) val newNonNullArticle: NewNonNullArticle
)
data class NewNonNullArticle(
@field:Size(max = 32)
@field:NotBlank
@field:JsonProperty("title", required = true) val title: String,
@field:Size(max = 1024)
@field:NotBlank
@field:JsonProperty("description", required = true) val description: String,
@field:Size(max = 2048)
@field:NotBlank
@field:JsonProperty("body", required = true) val body: String,
)
コントローラの実装は下記のようになります。
リクストモデルが null 非許容型ですので、コントローラの後続の処理でも not-null assertion operator(!!
)による null 非許容型への変換不要な簡潔な実装となります。
@RestController
@Validated
class PatternOfNullableNonnullController {
@PostMapping(
value = ["/non-null-article-model"],
produces = [MediaType.APPLICATION_JSON_VALUE],
consumes = [MediaType.APPLICATION_JSON_VALUE],
)
fun nonNullModel(
@Valid @RequestBody newNonNullArticleRequest: NewNonNullArticleRequest
): ResponseEntity<SingleArticleResponse> {
return ResponseEntity(
SingleArticleResponse(
article = Article(
slug = "non-null-article-slug",
title = newNonNullArticleRequest.newNonNullArticle.title,
body = newNonNullArticleRequest.newNonNullArticle.body,
description = newNonNullArticleRequest.newNonNullArticle.description
)
),
HttpStatus.OK
)
}
}
メリット
メリットは !!
が不要のため、コードの記述量が減ることです。
リクエストモデルからコントローラに値が渡される時点で、null 非許容型かそうでないかが確定しているので、特別に気にすることなく処理できます。
考えることが減るとミスも減らせます。
デメリット
デメリットは以下の 2 つです。
- null だったときのバリデーションエラーのレスポンスがわかりづらい
- クライアント側が null 非許容型のリクエストするミスを減らす工夫が必要
前者は課題を紹介したときと同様です。具体的には以下のように、null のパラメータが 2 つあるリクエストを送ったとします。
しかし、レスポンスでは、どのパラメータが null だったのかや null ではなく JSON に誤りがあったのかわかりづらいです。
$ curl --location 'http://localhost:8080/non-null-article-model' \
--header 'Content-Type: application/json' \
--data '{
"article": {
"title": null,
"body": "body",
"description": null
}
}' | jq .
{
"errors": {
"body": [
"API が想定していない形式または型のリクエストが送られました。リクエストのパラメータを再度確認してください"
]
}
}
後者は、サーバ側で考える必要がなくなったため、責務がクライアント側で持つ必要があります。
OpenAPI Generator や Swagger Codegen などを利用してミスを減らす方法がありますが、常に同様の状況とは限らないため、必ずしも解決策になりえるとは言い切れません。
対処法 2 null 許容型にする
実装
2 つめの対処法は API スキーマ的には必須パラメータでもリクエストモデルでは null 許容型にする方法です。
この方法を利用すれば、必須のバリデーションも MethodArgumentNotValidException でハンドリング可能です。
data class NewNullableArticle(
@field:Size(max = 32)
@field:NotBlank
@field:JsonProperty("title", required = true) val title: String? = null,
@field:Size(max = 1024)
@field:NotBlank
@field:JsonProperty("description", required = true) val description: String? = null,
@field:Size(max = 2048)
@field:NotBlank
@field:JsonProperty("body", required = true) val body: String? = null,
)
data class NewNullableArticleRequest(
@field:Valid
@field:NotNull
@field:JsonProperty("article", required = true) val article: NewNullableArticle? = null
)
コントローラ側で以下のように実装します。
後続の処理で null 非許容型を前提にしている場合は、!!
で null 非許容型に変換する必要があります。
@RestController
@Validated
class PatternOfNullableNonnullController {
@PostMapping(
value = ["/nullable-article-model"],
produces = [MediaType.APPLICATION_JSON_VALUE],
consumes = [MediaType.APPLICATION_JSON_VALUE],
)
// not-null assertion operator(`!!`)をコントローラに書く必要があるので linter で `!!` を許容する設定が必要になる
@Suppress("UnsafeCallOnNullableType")
fun nullableModel(
@Valid @RequestBody newNullableArticleRequest: NewNullableArticleRequest
): ResponseEntity<SingleArticleResponse> {
// ユースケース層やアプリケーションサービス層に渡す前に、not-null assertion を実施する必要があり、アノテーションに対して null 非許容になっているのに冗長
val nonNullValidatedArticle = newNullableArticleRequest.article!!
val nonNullValidatedTitle = nonNullValidatedArticle.title!!
val nonNullValidatedBody = nonNullValidatedArticle.body!!
val nonNullValidatedDescription = nonNullValidatedArticle.description!!
return ResponseEntity(
SingleArticleResponse(
article = Article(
slug = "non-null-article-slug",
title = nonNullValidatedTitle,
body = nonNullValidatedBody,
description = nonNullValidatedDescription
)
),
HttpStatus.OK
)
}
}
具体的なリクエストは下記の通りです。課題 1 と異なり、どの必須パラメータが欠けているのかわかりやすいです。
$ curl --location 'http://localhost:8080/nullable-article-model' \
--header 'Content-Type: application/json' \
--data '{
"article": {
"title": null,
"body": "body",
"description": null
}
}' | jq .
{
"errors": {
"body": [
"article.titleは必須です",
"article.descriptionは必須です"
]
}
}
メリット
メリットは先述のとおり、必須パラメータが null や欠損していたときのバリデーションエラーのレスポンスをわかりやすくできることです。
クライアント側が誤って実装したときの原因を特定しやすくなります。
デメリット
デメリットは以下の 3 つだと考えました。それぞれについて解説します。
- アノテーションの記述内容(
@NotNull
、required = true
)とプロパティの型が一致しなくなる - コントローラに null 非許容型の知識が漏れる
- not-null assertion operator(
!!
)をコントローラに書く必要があるので linter で!!
を許容する設定が必要になる
1 つめは、リクエストモデルに記述するアノテーションの記述内容(@NotNull
、required = true
)とプロパティの型(String?
)が一致しなくなることです。
直感的な実装が損なわれるのではないかと思いました。
data class NewNullableArticle(
// アノテーション的には null が入らないのに、プロパティは null 許容型
@field:Size(max = 32)
@field:NotBlank
@field:JsonProperty("title", required = true) val title: String? = null,
2 つめは、コントローラの null 非許容型の知識が漏れることです。
具体的には先述もした下記の実装が示しています。
後続処理で null 非許容型前提の場合、コントローラが null 非許容型に変換する必要があります。
これは実装が分離しているコントローラとリクエストモデル間で暗黙的な依存が発生しているため、保守性が落ちます。
@RestController
@Validated
class PatternOfNullableNonnullController {
@PostMapping(
value = ["/nullable-article-model"],
produces = [MediaType.APPLICATION_JSON_VALUE],
consumes = [MediaType.APPLICATION_JSON_VALUE],
)
// not-null assertion operator(`!!`)をコントローラに書く必要があるので linter で `!!` を許容する設定が必要になる
@Suppress("UnsafeCallOnNullableType")
fun nullableModel(
@Valid @RequestBody newNullableArticleRequest: NewNullableArticleRequest
): ResponseEntity<SingleArticleResponse> {
// ユースケース層やアプリケーションサービス層に渡す前に、not-null assertion を実施する必要があり、アノテーションに対して null 非許容になっているのに冗長
val nonNullValidatedArticle = newNullableArticleRequest.article!!
val nonNullValidatedTitle = nonNullValidatedArticle.title!!
val nonNullValidatedBody = nonNullValidatedArticle.body!!
val nonNullValidatedDescription = nonNullValidatedArticle.description!!
return ResponseEntity(
SingleArticleResponse(
article = Article(
slug = "non-null-article-slug",
title = nonNullValidatedTitle,
body = nonNullValidatedBody,
description = nonNullValidatedDescription
)
),
HttpStatus.OK
)
}
}
最後に 3 つめは、not-null assertion operator(!!
)をコントローラに書く必要があるので linter で !!
を許容する設定が必要になることです。
安全でない非 null 型への変換を linter によって検出できるには、Kotlin を採用する理由の 1 つです。
そのため、可能な限り安全でない非 null 型への変換は避けたいですが、コントローラに対して許容すると影響が大きくなってしまいます。
対処法 3 null 許容型をプライベートにして、セッターで null 非許容型にする
実装
3 つめの対処法は、JSON を null 許容型をプライベートにして、セッターで null 非許容型の別プロパティに初期化することです。
具体的には下記の実装になります。
JSON 側のプロパティは private のため呼び出すことができません。
その代わり setter で null 非許容型に初期化したプロパティを呼び出します。
本実装では linter(detekt)の設定でリクエストモデルの package には null 許容型に対して安全でない呼び出しの禁止の対象外にしています。
detekt の設定例(一部抜粋)
# 略
# 潜在的なバグの検出
potential-bugs:
active: true
#
# UnsafeCallOnNullableType
#
# 概要
# - null 許容型に対しての安全でない呼び出しを禁止する
UnsafeCallOnNullableType:
active: true
# 以下を除外対象に追加
# - "**/presentation/model/**"
# - 理由: Validation のアノテーションで null チェックを実施し、セッターで `!!` を利用して non-null 型にするため。
excludes: ["**/presentation/model/**", '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**']
data class NewNullableSetNotNullArticle(
@field:Size(max = 32)
@field:NotBlank
@field:JsonProperty("title", required = true) private val title: String? = null,
@field:Size(max = 1024)
@field:NotBlank
@field:JsonProperty("description", required = true) private val description: String? = null,
@field:Size(max = 2048)
@field:NotBlank
@field:JsonProperty("body", required = true) private val body: String? = null,
) {
val validatedTitle: String
get() = title!!
val validatedDescription: String
get() = description!!
val validatedBody: String
get() = body!!
}
data class NewNullableSetNotNullArticleRequest(
@field:Valid
@field:NotNull
@field:JsonProperty("article", required = true) private val article:
NewNullableSetNotNullArticle? = null,
) {
/**
* null 非許容型のリクエストモデル
*/
val validatedNotNullArticle: NewNullableSetNotNullArticle
get() = article!!
}
コントローラ側の実装は以下になります。
対処法 2 と異なり、コントローラ側で !!
を記述する必要がなく簡潔な実装になります。
@RestController
@Validated
class PatternOfNullableNonnullController {
@PostMapping(
value = ["/nullable-set-not-null-article-model"],
produces = [MediaType.APPLICATION_JSON_VALUE],
consumes = [MediaType.APPLICATION_JSON_VALUE],
)
fun nullableSetNotNullModel(
@Valid @RequestBody newNullableSetNotNullArticleRequest: NewNullableSetNotNullArticleRequest
): ResponseEntity<SingleArticleResponse> {
return ResponseEntity(
SingleArticleResponse(
article = Article(
slug = "nullable-set-non-null-article-slug",
title = newNullableSetNotNullArticleRequest.validatedNotNullArticle.validatedTitle,
body = newNullableSetNotNullArticleRequest.validatedNotNullArticle.validatedBody,
description = newNullableSetNotNullArticleRequest.validatedNotNullArticle.validatedDescription,
)
),
HttpStatus.OK
)
}
}
リクエストは、対処法 2 と同様の結果になります。
$ curl --location 'http://localhost:8080/nullable-set-not-null-article-model' \
--header 'Content-Type: application/json' \
--data '{
"article": {
"title": null,
"body": "body"
}
}' | jq .
{
"errors": {
"body": [
"article.descriptionは必須です",
"article.titleは必須です"
]
}
}
メリット
メリットは以下の 3 つです。
- 必須パラメータが null や欠損していたときのバリデーションエラーのレスポンスをわかりやすくできる(対処法 2 のメリット)
- コントローラに null 非許容型の知識が漏れない
-
!!
は必要になるが、コントローラに記述するよりは許容しやすい & 設定しやすい
1 つめは対処法 2 のメリットと同じ、必須パラメータが null や欠損していたときのバリデーションエラーのレスポンスをわかりやすくできることです。
2 つめはコントローラに null 非許容型の知識が漏れないことです。
対処法 2 のデメリットを解決した箇所です。
コントローラ側がモデルの処理を気にすることなく、後続の処理に値を渡せます。
3 つめは、!!
は必要になるが、コントローラに記述するよりは許容しやすい & 設定しやすいことです。
こちらも、対処法 2 のデメリットを解決した箇所です。
リクエストのアノテーションと隣接した箇所で、!!
が記述可能ですので、対処法 2 と比較して対応しやすいです。
これでもミスが発生する可能性はありますが、テストで拾うようにしましょう。
デメリット
デメリットは以下の 3 つです。
- 結局
!!
は必要になり linter の設定が必要 - プロパティ名についてのルールを決める必要がある
- 記述量が増える
1 つめは、結局 !!
が必要にあり、linter の設定が必要な点です。
null 変換がバグの原因になる可能性は高いので、可能であれば linter の抑制を設定せずに実装したかったのですが、できませんでした。
マイクロサービス化されていると認識合わせは難しくなるので、可能な限り OpenAPI Generator などで自動化によって解決したいです。
2 つめは、プロパティ名についてルールを決める必要があることです。
些細なことですが、本実装で記述した validated
は API スキーマとしてなのかドメインルールを通過していることなのかあいまいにしていると思いました。
最終的に運用する個人またはチームで決められたらと考えていますが、そのときの選択が中長期的に見て負債になる可能性があるので、難しそうだと思いました。
3 つめは、単純に記述量が増えることです。
記述量が増えると、ミスも誘発しやすくなるので、テストに頼らざるおえない部分が増えています。
まとめ
null 許容型のバリデーションについての課題とバリデーションについて 3 つの対処法を記述しました。
Kotlin に限らず型の厳格さが強い静的型付き言語なら、直面する問題だと考えています。
いろいろ記載しましたが、自分が今から導入するのであれば、対処法 3 を採用します。
ほかにも良さそうな方法があれば、コメントで教えてください。
参考
Discussion