LoginSignup
85
75

More than 5 years have passed since last update.

Spring カスタムアノテーションに出会った話

Last updated at Posted at 2017-12-09

はじめまして

この記事はMicroAd Advent Calendar 2017の9日目の記事です。
初 Qiita の試みということで、自分メモとしても記録として残そうと思いました。
誤った情報や脱字など、ご指摘がありましたらご教示ください。

ゴール

簡単だけど地味に使い回せそうなツールを作ってみる。独自アノテーションはその工夫の小さな一歩です。
そして少しだけ中身を知る。

サービスロジックで十分なんじゃ

そりゃそうだ

心惹かれるモノは試したくなる。なぜか @ この記号は昔から魅力的に思えます。
また、今後同じチェックロジックが必要になった時、アノテーションをつけるだけで済み、どんなチェックが行われるのかぱっと見わかりやすい。

アノテーション (@interface)

一応 java におけるアノテーションは位置づけとしてはインタフェースとされています。
リンク先の引用によると

The motivation for the introduction of annotations into the Java programming
language was the need for semantic information for a given piece of code

java プログラミングにおいて所定のコードの「意味」を扱う必要性から出てきたものと記述されています。

以下は標準 API として提供されている代表的なアノテーションのオーバーライドです。

Override.java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}

アノテーションについているアノテーションは
java.lang.annotation パッケージにて提供しているメタアノテーションです。

@Target

アノテーションをつける対象です。{} で囲んで複数指定できます。
対象の種類として

  • TYPE(クラス・インタフェース・アノテーション)
  • FIELD(フィールド・ENUM)
  • CONSTRUCTOR(コンストラクタ)
  • などなど...

いくつかありますが、@Override の場合 METHOD を対象としています。
実際 @Override はメソッド以外の要素につけることができません。

@Retention

アノテーションが影響する範囲を記述しています。
Retention は RetentionPolicy をリターンし、RetentionPolicy の種類は三つのみです。

  • SOURCE : このアノテーションはコンパイル時に破棄される

  • CLASS(デフォルト) : class ファイルに記録はされるが、実行時保持はされない

  • RUNTIME : class ファイルに記録され、かつ、実行時に参照できる == 実行時 JVM にこのアノテーションの情報が読み込まれる

@Override の場合 RetentionPolicy が SOURCE なので、
コンパイル終了時は何の意味も持たない == バイトコード変換されない ことになります。

Target も Retention も、場合によっては可読性を考慮して import static してシンプルに書いたりもします。

今回の例

今回欲しかったのは半角と全角を分けてカウントして最大長さを制限するためのアノテーションだったので、
ひな型は概ね javax.validation.constraints パッケージを参考 (@Size とか @NotNull とかありますよね)しました。

というか、今回の独自アノテーション、つまりは制約アノテーション(Constraint Annotation) には決まったひな形(message(), groups(), payload() の設定が必須)があるので、それにしたがって作成します。

名前は適当につけますね。

CustomSize.java
@Target({FIELD})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {CustomSizeValidator.class})
public @interface CustomSize {

  String message() default "{validation.CustomSize.message}";
  Class<?>[] groups() default {};
  Class<? extends Payload>[] payload() default {};
  int max();

  @Target({FIELD})
  @Retention(RUNTIME)
  @Documented
  @interface List {
    CustomSize[] value();
  }
}

@Documented

このアノテーションを使うにあたって、javadoc などによる文章化が必要であることを示します。
正直今回は必要なかったかもしれません。

@Constraint

このアノテーションで制約(チェック)したい具体的なロジックを記述したクラスを指定します。
以下が、今回のアノテーションのバリデーション実装クラスです。

CustomSizeValidator.java
public class CustomSizeValidator implements ConstraintValidator<CustomSize, String> {

  private int max;

  @Override
  public void initialize(CustomSize customSize) {
    max = customSize.max();
  }

  @Override
  public boolean isValid(String value, ConstraintValidatorContext context) {
    // CustomSizeUtil はチェックロジックだけを記述したクラス
    // getLength の戻り値は int
    return CustomSizeUtil.getLength(value) <= max;
  }
}

initializeisValidが呼ばれるための初期化処理です。

isValid は実際のバリデーションロジックを実装します。パラメーターとして渡される <T> value が実際の検証対象のオブジェクトになります。
今回は、入力された文字列に対する長さのチェックなので、String 型の文字列がここに該当します。

不正な値の入力によりvalueがチェックを通れなかった場合は、false がリターンされます。

message()

不正入力時に警告(?)として出したいメッセージです。
メッセージプロパティ(hibernate の ValidationMessages.properties とか)に定義する文言が入ります。
また、キーは完全修飾クラス名で記述するように、となっています。

たとえばこのような形になると思います。

ValidationMessages_jp.properties
validation.CustomSize.message = {0} を超えないように入力してください

groups()

特定のバリデーショングループがカスタマイズできるような設定です。
空の Class<?> 型で初期化されている必要があります。

グループ化とは、制約に順序を持たせるためのものです。

@GroupSequence({CheckA.class, CheckB.class}) // A のチェックの後 B
public interface GroupedChecks {
}

payload()

チェック対象のオブジェクトになんらかのメタ情報を与えるためだけの宣言です。
実際 javax.validation.Payload インタフェースの中身は空っぽで、マーカーもしくはカテゴライズするためのものだと思います。

たとえば

CustomSize.java
@Target({FIELD})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {CustomSizeValidator.class})
public @interface CustomSize {

  String message() default "{validation.CustomSize.message}";
  Class<?>[] groups() default {};
  class Text implements Payload{} // Add Payload
  // omit
}
SampleModel.java
public class SampleModel {
  @CustomSizeValidator(max = 20, payload = {CustomSizeValidator.Text.class})
  private String memo;
  // omit
}

正しい言い方なのかはいまいちですが、Textといったカテゴライズとして識別されるよう、memo フィールドに意味を与えることができます。

特にない場合は空のままにします。

max()

今回は「最大長さ」に制約をかけるバリデーションなので、max という名前を設定しました。

 @CustomSizeValidator(max = 20) // ここに入る名前
 private String memo;

List { A[] value() }

ConstraintValidator の実装に寄ってチェック可能な対象(複数可能)を定義します。

@interface List {
  CustomSizeValidator value();
  AnotherValidator aaa();
}

特別な理由がない限りは要素は1つで十分かもしれません。

使う

他の入力チェックと同じですが、コントローラーのパラメータとして渡される上位モデルには @Valid をつけて、すぐ隣に並べて BindingResult を定義します。

SampleController.java
  @RequestMapping(value = "/sample", method = RequestMethod.POST)
  public String samplePost(@ModelAttribute @Valid BigModel model, BindingResult result) {
    // omit
   }

入れ子モデルの場合、上位モデルにも @Valid をつけます。

BigModel.java
public class BigModel {
  @Valid
  private SmallModel smallModel;
  // omit
}

今回のカスタムアノテーションは @Target({ElementType.FIELD}) なので、フィールドを対象としています。
実際チェックの対象とするフィールドにつけます。

SmallModel.java
public class SmallModel {
  @CustomSize(max = 20)
  private String input;
  // omit
}

テスト

  • アプリ稼働して直接試すテスト (BindingResultにてエラーが格納される)

  • テストコードでテスト(いくつか値を与えて本当にその通りに計算されるか)

決まった制限がある文字数カウントなので、境界値分析でテストしました。
アノテーション制約の実装クラスのisValid()をテストしています。

CustomTest.groovy
   // omit
   when:
      def result = validator.isValid("", null)
    then:
      assert result == excepted
    where:
      testCase                | maxLen | doubleLen || excepted
      "Boundary value, small" | 5      | 4.5       || true
      "Boundary value, same"  | 5      | 5         || true
      "Boundary value, big"   | 5      | 5.5       || false

おわりまして

こういうのをやってみたら、楽だったよ。くらいで終わらせるつもりでしたが、
やはり中身を知ることは非常に興味深く、とてもいい勉強のきっかけになりました。

参考

Hibernate Community Documentation

以上

85
75
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
85
75