LoginSignup
4
1

More than 5 years have passed since last update.

型に汎用的なアノテーションを作成したい

Last updated at Posted at 2018-06-04

開発環境

  • Java 1.8.0
  • Spring Boot 2.0.2.RELEASE
    • Spring core 5.0.6.RELEASE
  • Bean Validation 2.0

導入

入力された値がコード定義されているリストに含まれるかをチェックするアノテーションをBean Validationを使って作成します。
使い方としては↓のような感じです。

Sample.java
public class Sample{

  @CodeExists(StringCode.class)
  private String code;

  :

※コード定義はString型とします。

コードクラスを作る

コードはMap(コード値,コード名)で定義します。入力された値がコード定義されているかをチェックするメソッドexistsを作成しておきます。
またコードクラスはnewされないようにシングルトンで作成します。シングルトンの実装方法はいくつかありますが、デザインパターン「Singleton」を参考にHolderクラスを利用しました。

StringCode.java

public class StringCode {

  public static final StringCode INSTANCE = StringCodeHolder.INSTANCE;

  private final Map<String, String> codeMap = createCodeMap();

  private StringCode() {}

  public boolean exists(String code) {
    return codeMap.containsKey(code);
  }

  private Map<String, String> createCodeMap() {
    Map<String, String> map = new LinkedHashMap<>();
    map.put("1", "foo");
    map.put("2", "bar");
    map.put("3", "hage");
    return Collections.unmodifiableMap(map);
  }

  private static class StringCodeHolder {
    private static final StringCode INSTANCE = new StringCode();
  }
}

入力チェック処理を実装する

アノテーションの引数のクラスからインスタンスを生成するところが雑ですが、existsメソッドの結果を返します。

CodeExistsValidator.java
public class CodeExistsValidator implements ConstraintValidator<CodeExists, String> {

  StringCode target;

  @Override
  public void initialize(CodeExists constraintAnnotation) {
    try {
      // 対象の定数クラスのインスタンスを取得する
      target = (StringCode) (constraintAnnotation.value().getDeclaredField("INSTANCE").get(this));
    } catch (IllegalArgumentException | IllegalAccessException | NoSuchFieldException
        | SecurityException e) {
      // 適当
      e.printStackTrace();
    }
  }

  @Override
  public boolean isValid(String value, ConstraintValidatorContext context) {
    // existsメソッドの結果を返す
    return target.exists(value);
  }
}

アノテーションを作成する

ここは特に書くことはないですね。

CodeExists.java
@Documented
@Constraint(validatedBy = {CodeExistsValidator.class})
@Target({METHOD, FIELD})
@Retention(RUNTIME)
public @interface CodeExists {
  String message() default "";

  Class<?>[] groups() default {};

  Class<? extends Payload>[] payload() default {};

  Class<?> value();

  @Target({METHOD, FIELD})
  @Retention(RUNTIME)
  @Documented
  public @interface List {
    CodeExists[] value();
  }
}

アノテーションのテスト

では、さっそくテストしてみましょう。

ValidationSampleApplicationTests.java
@RunWith(SpringRunner.class)
@SpringBootTest
public class ValidationSampleApplicationTests {

  private static final String EXIST_CODE = "1";
  private static final String NOT_EXIST_CODE = "5";

  private ValidatorFactory validatorFactory;
  private Validator validator;

  @Before
  public void setup() {
    validatorFactory = Validation.buildDefaultValidatorFactory();
    validator = validatorFactory.getValidator();
  }

  @Test
  public void existStringCode() throws Exception {
    StringCodeSample code = new StringCodeSample(EXIST_CODE);
    Set<ConstraintViolation<StringCodeSample>> result = validator.validate(code);
    assertThat(result.isEmpty(), is(true));
  }

  @Test
  public void notExistStringCode() throws Exception {
    StringCodeSample code = new StringCodeSample(NOT_EXIST_CODE);
    Set<ConstraintViolation<StringCodeSample>> result = validator.validate(code);
    assertThat(result.size(), is(1));
    assertThat(result.iterator().next().getInvalidValue(), is(NOT_EXIST_CODE));
  }

  /**
   * String型のコードを持つクラス
   */
  private static class StringCodeSample {
    @CodeExists(StringCode.class)
    private String code;

    public StringCodeSample(String code) {
      this.code = code;
    }
  }
}

StringCode.javaでコード定義されている"1"を設定すると入力チェックエラーは0、コード定義されていない"5"を設定すると入力チェックエラーになることが確認できます。

本題

前置きがすごく長くなってしまいましたが……
↑では、コード定義をStringに限定していましたが、intboolean型でもコード定義できるようにしたいという要件が追加されたとします。

コード定義にインタフェースを適用

汎用的なコード定義クラスを作成するために、インタフェースを作成します。コード存在チェックは型によらず共通になるのでデフォルトメソッドで定義しておきます。

※コード名は重要ではないのでString型で固定します。

Code.java
public interface Code<T> {

  /**
   * 指定したコードがコードリスト上に存在するかチェックする.
   */
  default boolean exists(T code) {
    return asMap().containsKey(code);
  }

  /**
   * コード値とコード名を紐づけたマップを返す.
   */
  Map<T, String> asMap();
}

コード定義の修正

↑で作成したStringCode.javaを修正します。

  • Code<String>を実装
  • existsメソッドを削除
  • asMapcodeMapを返す
StringCode.java
public class StringCode implements Code<String>{

  public static final StringCode INSTANCE = StringCodeHolder.INSTANCE;

  private final Map<String, String> codeMap = createCodeMap();

  private StringCode() {}

  @Override
  public Map<String, String> asMap() {
    return codeMap;
  }

  private Map<String, String> createCodeMap() {
    Map<String, String> map = new LinkedHashMap<>();
    map.put("1", "foo");
    map.put("2", "bar");
    map.put("3", "hage");
    return Collections.unmodifiableMap(map);
  }

  private static class StringCodeHolder {
    private static final StringCode INSTANCE = new StringCode();
  }
}

入力チェック処理の修正

↑で作成したCodeExistsValidator.javaを修正します。

  • ConstraintValidatorのパラメータをStringからTに変更
  • targetの型をCode<T>に変更
  • コード定義クラスからインスタンス取得をメソッドに抜き出し
CodeExistsValidator.java
public class CodeExistsValidator<T> implements ConstraintValidator<CodeExists, T> {

  Code<T> target;

  @Override
  public void initialize(CodeExists constraintAnnotation) {
    try {
      // 対象の定数クラスのインスタンスを取得する
      target = convert(constraintAnnotation.value().getDeclaredField("INSTANCE").get(this));
    } catch (IllegalArgumentException | IllegalAccessException | NoSuchFieldException
        | SecurityException e) {
      // 適当
      e.printStackTrace();
    }
  }

  @Override
  public boolean isValid(T value, ConstraintValidatorContext context) {
    // existsメソッドの結果を返す
    return target.exists(value);
  }

  @SuppressWarnings("unchecked")
  private Code<T> convert(Object o) {
    return (Code<T>) o;
  }
}

アノテーションを修正

コード定義と入力チェック処理を修正したので、後はアノテーションを修正するだけです。……が、@Constraintに指定するパラメータはConstraintValidatorを実装した実クラスなので、仮型引数を含めることはできないようです。
まぁ総称型を指定されても困りますよね。

CodeExists.java
@Documented
@Constraint(validatedBy = {CodeExistsValidator.class}) // NG
@Target({METHOD, FIELD})
@Retention(RUNTIME)
public @interface CodeExists {
  String message() default "";

  Class<?>[] groups() default {};

  Class<? extends Payload>[] payload() default {};

  Class<? extends Code<?>> value();

  @Target({METHOD, FIELD})
  @Retention(RUNTIME)
  @Documented
  public @interface List {
    CodeExists[] value();
  }
}

出力されるエラー

Type mismatch: cannot convert from Class<CodeExistsValidator> to Class<? extends ConstraintValidator<?,?>>

@Constraintに型ごとの入力チェッククラスを指定できる

@Constraint(validatedBy = {CodeExistsValidator.class})のような書き方が出来れば楽で良かったんですが、無理っぽいので……Bean Validationのドキュメント5.7.4. ConstraintValidator resolution algorithmを読んでいて気づいたのですが、ConstraintValidator<A, T>Tに対してマッチする入力チェックが自動的に実行される仕組みがあるようです。
つまり、String用の入力チェック、int用の入力チェックを指定すればOKってことですね。

String用の入力チェック処理を実装する

というわけで、CodeExistsValidatorを継承したString用の入力チェック処理を実装します。と言っても処理内容はCodeExistsValidatorで実装済みなので中身は空です。

StringCodeExistsValidator.java
public class StringCodeExistsValidator extends CodeExistsValidator<String> {
}

アノテーションには、実装クラスを指定します。

CodeExists.java
@Documented
@Constraint(validatedBy = {StringCodeExistsValidator.class}) // OK
@Target({METHOD, FIELD})
@Retention(RUNTIME)
public @interface CodeExists {
  String message() default "";

  Class<?>[] groups() default {};

  Class<? extends Payload>[] payload() default {};

  Class<? extends Code<?>> value(); // Codeを実装したクラスのみ

  @Target({METHOD, FIELD})
  @Retention(RUNTIME)
  @Documented
  public @interface List {
    CodeExists[] value();
  }
}

int用の入力チェック処理を実装する

int型をキーに持つMapを返すクラスを作成します。型以外はStringと同じなので一部のみ抜粋します。

IntCode.java
public class IntCode implements Code<Integer> {

 :

  @Override
  public Map<Integer, String> asMap() {
    return codeMap;
  }

  private Map<Integer, String> createCodeMap() {
    Map<Integer, String> map = new LinkedHashMap<>();
    map.put(4, "foo");
    map.put(5, "bar");
    map.put(6, "hage");
    return Collections.unmodifiableMap(map);
  }

 :
}

入力チェック処理はStringと同様にクラスを作るだけで実装は空です。
また、元となるCodeExistsValidatorは抽象クラスにします。

IntCodeExistsValidator.java
public class IntCodeExistsValidator extends CodeExistsValidator<Integer> {
}

最後にアノテーションに実装クラスを追加します。

CodeExists.java
@Documented
@Constraint(validatedBy = {StringCodeExistsValidator.class, IntCodeExistsValidator.class}) // ここ
@Target({METHOD, FIELD})
@Retention(RUNTIME)
public @interface CodeExists {

 :
}

この状態でテストを追加します。

ValidationSampleApplicationTests.java
@RunWith(SpringRunner.class)
@SpringBootTest
public class ValidationSampleApplicationTests {

  private static final String STRING_EXIST_CODE = "1";
  private static final String STRING_NOT_EXIST_CODE = "5";
  private static final int INT_EXIST_CODE = 4;
  private static final int INT_NOT_EXIST_CODE = 1;

  private ValidatorFactory validatorFactory;
  private Validator validator;

  @Before
  public void setup() {
    validatorFactory = Validation.buildDefaultValidatorFactory();
    validator = validatorFactory.getValidator();
  }

  @Test
  public void existStringCode() throws Exception {
    StringCodeSample code = new StringCodeSample(STRING_EXIST_CODE);
    Set<ConstraintViolation<StringCodeSample>> result = validator.validate(code);
    assertThat(result.isEmpty(), is(true));
  }

  @Test
  public void notExistStringCode() throws Exception {
    StringCodeSample code = new StringCodeSample(STRING_NOT_EXIST_CODE);
    Set<ConstraintViolation<StringCodeSample>> result = validator.validate(code);
    assertThat(result.size(), is(1));
    assertThat(result.iterator().next().getInvalidValue(), is(STRING_NOT_EXIST_CODE));
  }

  @Test
  public void existIntCode() throws Exception {
    IntCodeSample code = new IntCodeSample(INT_EXIST_CODE);
    Set<ConstraintViolation<IntCodeSample>> result = validator.validate(code);
    assertThat(result.isEmpty(), is(true));
  }

  @Test
  public void notExistIntCode() throws Exception {
    IntCodeSample code = new IntCodeSample(INT_NOT_EXIST_CODE);
    Set<ConstraintViolation<IntCodeSample>> result = validator.validate(code);
    assertThat(result.size(), is(1));
    assertThat(result.iterator().next().getInvalidValue(), is(INT_NOT_EXIST_CODE));
  }

  /**
   * String型のコードを持つクラス
   */
  private static class StringCodeSample {
    @CodeExists(StringCode.class)
    private String code;

    public StringCodeSample(String code) {
      this.code = code;
    }
  }

  /**
   * int型のコードを持つクラス
   */
  private static class IntCodeSample {
    @CodeExists(IntCode.class)
    private int code;

    public IntCodeSample(int code) {
      this.code = code;
    }
  }
}

@CodeExistsに指定するクラスによって入力チェックするコード定義が変わっていることが確認できました。
ただbooleanとかcharとか型が増えていくとクラスが増えていくのはしょうがないですかねぇ。

Stack OverFlowに投稿されていた質問:
Hibernate Validator - Add a Dynamic ConstraintValidator
に対する回答も参考になりました。

この記事に記載したコードはGitHub上で公開しております

4
1
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
4
1