Edited at

【Kotlin】アノテーションを自作する【SpringBoot】

この記事はMicroAd Advent Calendarの7日目の記事です。


TL;DR

Kotlinでバリデーション用のアノテーションを自作します。


前書き

この記事は以下の記事の続きです。コントローラーやモデルは前回の状況を引き続き利用しています。

以下の記事を元に書いています。


アノテーションの自作

大概のバリデーションは既存のアノテーションを利用して解決でき、そこから漏れる内容もAssertTrueを使えば実装できるので、余程理由が無ければ自作する必要は無いと個人的に思ってますが、せっかくの機会なので1つの内容を処理するアノテーションと、相関チェックを行うアノテーションの両方を作ります。


1つの内容を処理するアノテーション

文字列が半角スペースで2分割できるかどうかをチェックする1@CanSplitBySpaceアノテーションを作成します。

作成は以下の順で行います。


  1. アノテーションクラスを用意する

  2. バリデーターを作る

  3. 付与して使う


アノテーションクラスを用意する

annotation classは、Javaでは@interfaceに当たります。

完成したアノテーションクラスは以下の通りです。


CanSplitBySpace

@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独自のクラスになっている点と、messagegroups等の指定の方法が異なる点があります。

また、後述しますが、Kotlinで作成したアノテーションはfield:を指定しなくても正常に動作します。

@Constraint(validatedBy = [CanSplitBySpaceValidator::class])に指定しているクラスは、次に実装するバリデーター本体です。

今回はフィールド以外に付与するつもりが無かったので、@TargetにはAnnotationTarget.FIELDのみを指定しています。


バリデーターを作る

完成したバリデーターは以下の通りです。


CanSplitSpaceValidator

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ファイルに複数クラスを書けるので、ファイル数を減らせるのはいいですね。


CanSplitBySpace.kt

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:を付ける必要はありません(付けても問題なく動きます)。


MyModel.kt

//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ステップで作成します。


  1. アノテーションクラスを用意する

  2. バリデーターを作る

  3. 付与して使う


アノテーションクラスを用意する

完成したアノテーションクラスは以下の通りです。


IsLater

@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を指定しています。

また、クラスの引数にbeforeafterという2つが増えていますが、これはバリデーション対象のクラスが比較対象としたい2つのフィールドの名前です。


バリデーターを作る

完成したバリデーターは以下の通りです。


IsLaterValidator

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つの内容を処理するアノテーションと比べると、以下の点が異なっています。



  • ConstraintValidatorisValidに取るvalueAnyとなっている


  • initializeで受け取った値を元にBeanWrapperを使ってvalueから値を取り出している

これは対象がクラスであることによる違いです。

入力となる型にどのようなフィールドが含まれているのかは分からないので、使い手側でフィールド名を指定し、取り出しています。


ここまでを合わせて

ここまで作ったアノテーションクラスとバリデーターを合わせたものが以下です。


IsLater.kt

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のメソッドを置き換えたのが以下です。


MyModel.kt

//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日も業務外のことをやらせて頂きありがとうございました(ごめんなさい、反省してます。。。)。


参考にさせていただいた記事





  1. これは既存のPatternアノテーションで実装が可能です。今回はサンプルとして自力で全て実装しています。