Kotlin
spring-boot
BeanValidation

【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アノテーションで実装が可能です。今回はサンプルとして自力で全て実装しています。