開発環境
- Java 1.8.0
- Spring Boot 2.0.2.RELEASE
- Spring core 5.0.6.RELEASE
- Bean Validation 2.0
導入
入力された値がコード定義されているリストに含まれるかをチェックするアノテーションをBean Validation
を使って作成します。
使い方としては↓のような感じです。
public class Sample{
@CodeExists(StringCode.class)
private String code;
:
※コード定義はString
型とします。
コードクラスを作る
コードはMap
(コード値,コード名)で定義します。入力された値がコード定義されているかをチェックするメソッドexists
を作成しておきます。
またコードクラスはnew
されないようにシングルトンで作成します。シングルトンの実装方法はいくつかありますが、デザインパターン「Singleton」を参考にHolder
クラスを利用しました。
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
メソッドの結果を返します。
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);
}
}
アノテーションを作成する
ここは特に書くことはないですね。
@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();
}
}
アノテーションのテスト
では、さっそくテストしてみましょう。
@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
に限定していましたが、int
やboolean
型でもコード定義できるようにしたいという要件が追加されたとします。
コード定義にインタフェースを適用
汎用的なコード定義クラスを作成するために、インタフェースを作成します。コード存在チェックは型によらず共通になるのでデフォルトメソッドで定義しておきます。
※コード名は重要ではないのでString
型で固定します。
public interface Code<T> {
/**
* 指定したコードがコードリスト上に存在するかチェックする.
*/
default boolean exists(T code) {
return asMap().containsKey(code);
}
/**
* コード値とコード名を紐づけたマップを返す.
*/
Map<T, String> asMap();
}
コード定義の修正
↑で作成したStringCode.java
を修正します。
-
Code<String>
を実装 -
exists
メソッドを削除 -
asMap
でcodeMap
を返す
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>
に変更 - コード定義クラスからインスタンス取得をメソッドに抜き出し
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
を実装した実クラスなので、仮型引数を含めることはできないようです。
まぁ総称型を指定されても困りますよね。
@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
で実装済みなので中身は空です。
public class StringCodeExistsValidator extends CodeExistsValidator<String> {
}
アノテーションには、実装クラスを指定します。
@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
と同じなので一部のみ抜粋します。
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
は抽象クラスにします。
public class IntCodeExistsValidator extends CodeExistsValidator<Integer> {
}
最後にアノテーションに実装クラスを追加します。
@Documented
@Constraint(validatedBy = {StringCodeExistsValidator.class, IntCodeExistsValidator.class}) // ここ
@Target({METHOD, FIELD})
@Retention(RUNTIME)
public @interface CodeExists {
:
}
この状態でテストを追加します。
@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上で公開しております