この記事はMicroAd Advent Calendarの7日目の記事です。
TL;DR
Kotlinでバリデーション用のアノテーションを自作します。
前書き
この記事は以下の記事の続きです。コントローラーやモデルは前回の状況を引き続き利用しています。
以下の記事を元に書いています。
アノテーションの自作
大概のバリデーションは既存のアノテーションを利用して解決でき、そこから漏れる内容もAssertTrueを使えば実装できるので、余程理由が無ければ自作する必要は無いと個人的に思ってますが、せっかくの機会なので1つの内容を処理するアノテーションと、相関チェックを行うアノテーションの両方を作ります。
1つの内容を処理するアノテーション
文字列が半角スペースで2分割できるかどうかをチェックする1@CanSplitBySpace
アノテーションを作成します。
- アノテーションクラスを用意する
- バリデーターを作る
- 付与して使う
アノテーションクラスを用意する
annotation class
は、Javaでは@interface
に当たります。
完成したアノテーションクラスは以下の通りです。
@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@ReportAsSingleViolation
@Constraint(validatedBy = [CanSplitBySpaceValidator::class])
annotation class CanSplitBySpace(
val message: String = "message",
val groups: Array<KClass<out Any>> = [],
val payload: Array<KClass<out Payload>> = []
)
付与しているアノテーション関連は大体Javaと同じです。個々のアノテーションの説明は長くなるので省略します。
Javaとの相違点としては、@Target
や@Retention
辺りがKotlin独自のクラスになっている点と、message
やgroups
等の指定の方法が異なる点があります。
また、後述しますが、Kotlinで作成したアノテーションはfield:を指定しなくても正常に動作します。
@Constraint(validatedBy = [CanSplitBySpaceValidator::class])
に指定しているクラスは、次に実装するバリデーター本体です。
今回はフィールド以外に付与するつもりが無かったので、@Target
にはAnnotationTarget.FIELD
のみを指定しています。
バリデーターを作る
完成したバリデーターは以下の通りです。
class CanSplitBySpaceValidator: ConstraintValidator<CanSplitBySpace, String>{
override fun initialize(constraintAnnotation: CanSplitBySpace) {}
override fun isValid(value: String?, context: ConstraintValidatorContext?): Boolean {
//nullなら何もしない
if(value == null) return true
//スペースで2分割できなければいけない
return value.split(" ").size == 2
}
}
こちらはJavaとそう変わりありません。isValid
内の実装がバリデーション時に呼び出される部分です。
value
にはバリデーション対象が入ります。
ConstraintValidator
に指定しているのは、作成したアノテーションと、アノテーションが処理する対象のクラスです。
ここまでを合わせて
ここまで作ったアノテーションクラスとバリデーターを合わせたものが以下です。
Kotlinは1ファイルに複数クラスを書けるので、ファイル数を減らせるのはいいですね。
import javax.validation.*
import kotlin.reflect.KClass
//アノテーション
@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@ReportAsSingleViolation
@Constraint(validatedBy = [CanSplitBySpaceValidator::class])
annotation class CanSplitBySpace(
val message: String = "message",
val groups: Array<KClass<out Any>> = [],
val payload: Array<KClass<out Payload>> = []
)
//バリデーター本体
class CanSplitBySpaceValidator: ConstraintValidator<CanSplitBySpace, String>{
override fun initialize(constraintAnnotation: CanSplitBySpace) {}
override fun isValid(value: String?, context: ConstraintValidatorContext?): Boolean {
//nullなら何もしない
if(value == null) return true
//スペースで2分割できなければいけない
return value.split(" ").size == 2
}
}
付与して使う
前述の通り、field:
を付ける必要はありません(付けても問題なく動きます)。
//import com.wrongwrong.modeltest.annotation.CanSplitBySpace
import java.util.*
import javax.validation.constraints.AssertTrue
import javax.validation.constraints.NotNull
data class MyModel(
@field:NotNull(message = "idはnull不許可")
val id: Long?,
@CanSplitBySpace(message = "名前が半角スペースで2つに分割できない")
val name: String?,
val create: Date?,
val update: Date?
) {
@AssertTrue(message = "updateがcreateより過去")
fun isLater(): Boolean {
if(create == null || update == null) return true
return create.before(update) || create == update
}
}
$ curl -X POST -H "Content-Type: application/json" -d '{"id":1, "name":"wrongwrong", "create":"2018-11-01", "update":"2018-11-02"}' localhost:8080/my
[Field error in object 'myModel' on field 'name': rejected value [wrongwrong]; codes [CanSplitBySpace.myModel.name,CanSplitBySpace.name,CanSplitBySpace.java.lang.String,CanSplitBySpace]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [myModel.name,name]; arguments []; default message [name]]; default message [名前が半角スペースで2つに分割できない]]
相関チェックを行うアノテーション
2つの時間を比較し、片方がもう片方よりも過去であることをチェックする@IsLater
アノテーションを作り、AssertTrueのメソッドを置き換えます。
こちらも1つの内容を処理するアノテーション同様以下の3ステップで作成します。
- アノテーションクラスを用意する
- バリデーターを作る
- 付与して使う
アノテーションクラスを用意する
完成したアノテーションクラスは以下の通りです。
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@ReportAsSingleViolation
@Constraint(validatedBy = [IsLaterValidator::class])
annotation class IsLater(
val message: String = "message",
val groups: Array<KClass<out Any>> = [],
val payload: Array<KClass<out Payload>> = [],
val before: String,
val after: String
)
今回は付与対象がクラスなので、@Target
にはAnnotationTarget.CLASS
を指定しています。
また、クラスの引数にbefore
とafter
という2つが増えていますが、これはバリデーション対象のクラスが比較対象としたい2つのフィールドの名前です。
バリデーターを作る
完成したバリデーターは以下の通りです。
class IsLaterValidator: ConstraintValidator<IsLater, Any> {
lateinit var beforeName: String
lateinit var afterName: String
override fun initialize(constraintAnnotation: IsLater) {
beforeName = constraintAnnotation.before
afterName = constraintAnnotation.after
}
override fun isValid(value: Any?, context: ConstraintValidatorContext?): Boolean {
//valueがnullなら何もしない
if(value == null) return true
val beanWrapper = BeanWrapperImpl(value)
//入力から値を取り出す
val before = beanWrapper.getPropertyValue(beforeName) as Date?
val after = beanWrapper.getPropertyValue(afterName) as Date?
//比較結果を返却
if(before == null || after == null) return true
return before.before(after) || before == after
}
}
1つの内容を処理するアノテーションと比べると、以下の点が異なっています。
-
ConstraintValidator
とisValid
に取るvalue
がAny
となっている -
initialize
で受け取った値を元にBeanWrapper
を使ってvalue
から値を取り出している
これは対象がクラスであることによる違いです。
入力となる型にどのようなフィールドが含まれているのかは分からないので、使い手側でフィールド名を指定し、取り出しています。
ここまでを合わせて
ここまで作ったアノテーションクラスとバリデーターを合わせたものが以下です。
package com.wrongwrong.modeltest.annotation
import org.springframework.beans.BeanWrapperImpl
import java.util.*
import javax.validation.*
import kotlin.reflect.KClass
//アノテーション
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@ReportAsSingleViolation
@Constraint(validatedBy = [IsLaterValidator::class])
annotation class IsLater(
val message: String = "message",
val groups: Array<KClass<out Any>> = [],
val payload: Array<KClass<out Payload>> = [],
val before: String,
val after: String
)
//バリデーター本体
class IsLaterValidator: ConstraintValidator<IsLater, Any> {
lateinit var beforeName: String
lateinit var afterName: String
override fun initialize(constraintAnnotation: IsLater) {
beforeName = constraintAnnotation.before
afterName = constraintAnnotation.after
}
override fun isValid(value: Any?, context: ConstraintValidatorContext?): Boolean {
//valueがnullなら何もしない
if(value == null) return true
val beanWrapper = BeanWrapperImpl(value)
//入力から値を取り出す
val before = beanWrapper.getPropertyValue(beforeName) as Date?
val after = beanWrapper.getPropertyValue(afterName) as Date?
//比較結果を返却
if(before == null || after == null) return true
return before.before(after) || before == after
}
}
付与して使う
AssertTrueのメソッドを置き換えたのが以下です。
//import com.wrongwrong.modeltest.annotation.CanSplitBySpace
//import com.wrongwrong.modeltest.annotation.IsLater
import java.util.*
import javax.validation.constraints.NotNull
@IsLater(before = "create", after = "update", message = "updateがcreateより過去")
data class MyModel(
@field:NotNull(message = "idはnull不許可")
val id: Long?,
@CanSplitBySpace(message = "名前が半角スペースで2つに分割できない")
val name: String?,
val create: Date?,
val update: Date?
)
before
としてcreate
を、after
としてupdate
を指定しています。
$ curl -X POST -H "Content-Type: application/json" -d '{"id":1, "name":" ", "create":"2018-11-03", "update":"2018-11-02"}' localhost:8080/my
[Error in object 'myModel': codes [IsLater.myModel,IsLater]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [myModel.,]; arguments []; default message [],org.springframework.validation.beanvalidation.SpringValidatorAdapter$ResolvableAttribute@5d7a875b,org.springframework.validation.beanvalidation.SpringValidatorAdapter$ResolvableAttribute@731471]; default message [updateがcreateより過去]]
後書き
この記事含めKotlinについて2記事を投稿しましたが、MicroAdではKotlinをまだ使ってません。
これと次の記事のために丸2日も業務外のことをやらせて頂きありがとうございました(ごめんなさい、反省してます。。。)。
参考にさせていただいた記事
- 入力チェック:複数項目の相関チェックをするアノテーションの作り方 STS +Spring Boot+thymeleaf - アラカン"BOKU"のITな日常
- Custom Bean Validation (JSR-303) Annotations - Kotlin Discussions
- Spring書き込み編.独自のバリデーションを作って入力チェックをする。 - Qiita
- 型に汎用的なアノテーションを作成したい - Qiita
-
これは既存のPatternアノテーションで実装が可能です。今回はサンプルとして自力で全て実装しています。
作成は以下の順で行います。 ↩