Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

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

wrongwrong
public class Main { public static void main(String[] args) { console.log("Hello World!"); } } Error: java: シンボルを見つけられません シンボル: 変数 console 場所: クラス com.wrongwrong.Main
https://wrongwrong163377.hatenablog.com/
microad
データとテクノロジーをかけ合わせたマーケティングプラットフォームを提供する会社です。
https://www.microad.co.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした