開発環境
- Java 1.8.0
- Spring Boot 2.0.2.RELEASE
- Spring core 5.0.6.RELEASE
- Bean Validation 1.1
はじめに
※Spring Bootを使っていますが、Spring要素はほぼありません。
Bean Validationの使い方
Java
で入力チェックをするといったらBean Validation
ですよね。アノテーションを付与するだけなので手軽に使えて便利です。
ここで以下のような、プロパティ値が引数として渡された文字列の配列に含まれるかチェックするアノテーションを作成してみます。
@Documented
@Constraint(validatedBy = {AcceptedStringValuesValidator.class})
@Target({METHOD, FIELD})
@Retention(RUNTIME)
public @interface AcceptedStringValues {
String message() default "{com.neriudon.example.validator.AcceptedStringValues.message}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String[] value();
@Target({METHOD, FIELD})
@Retention(RUNTIME)
@Documented
public @interface List {
AcceptedStringValues[] value();
}
}
Validator
ではAcceptedStringValues
アノテーションのvalue()
に渡された文字列に、アノテーションを付与されたフィールドまたはメソッドの返り値が含まれるかチェックします。
public class AcceptedStringValuesValidator
implements ConstraintValidator<AcceptedStringValues, String> {
// accepted values array
private String[] validValues;
@Override
public void initialize(AcceptedStringValues constraintAnnotation) {
validValues = constraintAnnotation.value();
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) {
return true;
}
// check to exist value or not in accepted values array
return Arrays.stream(validValues).anyMatch(s -> Objects.equals(value, s));
}
}
テストコードは↓の感じです。
public class ValidationSampleApplicationTests {
private ValidatorFactory validatorFactory;
private Validator validator;
@Before
public void setup() {
validatorFactory = Validation.buildDefaultValidatorFactory();
validator = validatorFactory.getValidator();
}
@Test
public void acceptedStringValuesNormal() throws UnsupportedEncodingException {
AcceptedStringValuesSample sample = new AcceptedStringValuesSample("1");
Set<ConstraintViolation<AcceptedStringValuesSample>> result = validator.validate(sample);
// no error
assertThat(result.isEmpty(), is(true));
}
@Test
public void acceptedStringValuesNg() throws Exception {
AcceptedStringValuesSample sample = new AcceptedStringValuesSample("0");
Set<ConstraintViolation<AcceptedStringValuesSample>> result = validator.validate(sample);
// error
assertThat(result.size(), is(1));
// assert error value and message
result.stream().forEach(r -> {
assertThat(r.getInvalidValue(), is("0"));
assertThat(r.getMessage(), is("not accepted value."));
});
}
private static class AcceptedStringValuesSample {
@AcceptedStringValues({"1", "2", "3", "4", "5"})
private String code;
public AcceptedStringValuesSample(String code) {
this.code = code;
}
}
}
エラーメッセージは、クラスパス配下にValidationMessages.properties
を作成し、アノテーションクラスのmessage
に指定した値をキーにメッセージを設定しておくと、自動的に呼び出されるのはご存知ですよね。
今回の例だと↓のような感じです。
com.neriudon.example.validator.AcceptedStringValues.message = not accepted values.
エラーメッセージに値を埋め込む
↑のような感じでもいいのですが、メッセージが固定だと利用者にわかりづらいこともあると思います。
Bean Validation
では、アノテーションクラスのプロパティ値をエラーメッセージに埋め込むことができるので、{value}
でアノテーションクラスのvalueに設定された値を文字列として出力してみます。
com.neriudon.example.validator.AcceptedStringValues.message = not contained accepted values: {value}.
とすると、not contained accepted values: [1, 2, 3, 4, 5].
というメッセージになります。
エラーメッセージを賢くする
Bean Validation
では1.1からEL式をサポートしています!
${validatedValue}
を指定することでエラーになったオブジェクトを埋め込むことができるので……
com.neriudon.example.validator.AcceptedStringValues.message = ${validatedValue} is not contained accepted values.
こうすると、xxx is not contained accepted values.
というメッセージになります。(XXXはエラーになったオブジェクト)
ただし${validatedValue}
はエラーになったオブジェクトをそのまま出力するので、パスワードなどの秘匿情報を扱う場合は使わないようにしましょう。
他の配列を埋め込めない問題
さて、ここからが本題。
これまではString
を扱ってきましたが、Integer
で同じ機能を持つアノテーションを作成します。
@Documented
@Constraint(validatedBy = { AcceptedIntegerValuesValidator.class })
@Target({ METHOD, FIELD })
@Retention(RUNTIME)
public @interface AcceptedIntegerValues {
String message() default "{com.neriudon.example.validator.AcceptedIntegerValues.message}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
int[] value();
@Target({ METHOD, FIELD })
@Retention(RUNTIME)
@Documented
@interface List {
AcceptedIntegerValues[] value();
}
}
public class AcceptedIntegerValuesValidator implements ConstraintValidator<AcceptedIntegerValues, Integer> {
// accepted values array
private Integer[] validValues;
@Override
public void initialize(AcceptedIntegerValues constraintAnnotation) {
validValues = ArrayUtils.toObject(constraintAnnotation.value());
}
@Override
public boolean isValid(Integer value, ConstraintValidatorContext context) {
if (value == null) {
return true;
}
// check to exist value or not in accepted values array
return Arrays.stream(validValues).anyMatch(s -> Objects.equals(value, s));
}
}
エラーメッセージを設定して……
com.neriudon.example.validator.AcceptedIntegerValues.message = not contained accepted values: {value}.
いざ、テスト
@Test
public void acceptedIntegerValuesNormal() {
AcceptedIntegerValuesSample sample = new AcceptedIntegerValuesSample(1);
Set<ConstraintViolation<AcceptedIntegerValuesSample>> result = validator.validate(sample);
assertThat(result.isEmpty(), is(true));
}
@Test
public void acceptedIntegerValuesNg() {
AcceptedIntegerValuesSample sample = new AcceptedIntegerValuesSample(0);
Set<ConstraintViolation<AcceptedIntegerValuesSample>> result = validator.validate(sample);
assertThat(result.size(), is(1));
result.stream().forEach(r -> {
assertThat(r.getInvalidValue(), is(0));
});
}
private static class AcceptedIntegerValuesSample {
@AcceptedIntegerValues({ 1, 2, 3, 4, 5 })
private int code;
public AcceptedIntegerValuesSample(int code) {
this.code = code;
}
}
すると……
javax.validation.ValidationException: HV000149: An exception occurred during message interpolation
at org.hibernate.validator.internal.engine.ValidationContext.interpolate(ValidationContext.java:477)
at org.hibernate.validator.internal.engine.ValidationContext.createConstraintViolation(ValidationContext.java:322)
at org.hibernate.validator.internal.engine.ValidationContext.lambda$createConstraintViolations$0(ValidationContext.java:279)
at java.util.stream.ReferencePipeline$3$1.accept(Unknown Source)
at java.util.Collections$2.tryAdvance(Unknown Source)
at java.util.Collections$2.forEachRemaining(Unknown Source)
at java.util.stream.AbstractPipeline.copyInto(Unknown Source)
at java.util.stream.AbstractPipeline.wrapAndCopyInto(Unknown Source)
at java.util.stream.ReduceOps$ReduceOp.evaluateSequential(Unknown Source)
at java.util.stream.AbstractPipeline.evaluate(Unknown Source)
at java.util.stream.ReferencePipeline.collect(Unknown Source)
at org.hibernate.validator.internal.engine.ValidationContext.createConstraintViolations(ValidationContext.java:280)
at org.hibernate.validator.internal.engine.constraintvalidation.ConstraintTree.validateSingleConstraint(ConstraintTree.java:182)
at org.hibernate.validator.internal.engine.constraintvalidation.SimpleConstraintTree.validateConstraints(SimpleConstraintTree.java:68)
at org.hibernate.validator.internal.engine.constraintvalidation.ConstraintTree.validateConstraints(ConstraintTree.java:73)
at org.hibernate.validator.internal.metadata.core.MetaConstraint.doValidateConstraint(MetaConstraint.java:127)
at org.hibernate.validator.internal.metadata.core.MetaConstraint.validateConstraint(MetaConstraint.java:120)
at org.hibernate.validator.internal.engine.ValidatorImpl.validateMetaConstraint(ValidatorImpl.java:533)
at org.hibernate.validator.internal.engine.ValidatorImpl.validateConstraintsForSingleDefaultGroupElement(ValidatorImpl.java:496)
at org.hibernate.validator.internal.engine.ValidatorImpl.validateConstraintsForDefaultGroup(ValidatorImpl.java:465)
at org.hibernate.validator.internal.engine.ValidatorImpl.validateConstraintsForCurrentGroup(ValidatorImpl.java:430)
at org.hibernate.validator.internal.engine.ValidatorImpl.validateInContext(ValidatorImpl.java:380)
at org.hibernate.validator.internal.engine.ValidatorImpl.validate(ValidatorImpl.java:169)
at com.neriudon.example.ValidationSampleApplicationTests.acceptedIntegerValuesNg(ValidationSampleApplicationTests.java:98)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(Unknown Source)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(Unknown Source)
at java.lang.reflect.Method.invoke(Unknown Source)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.springframework.test.context.junit4.statements.RunBeforeTestExecutionCallbacks.evaluate(RunBeforeTestExecutionCallbacks.java:73)
at org.springframework.test.context.junit4.statements.RunAfterTestExecutionCallbacks.evaluate(RunAfterTestExecutionCallbacks.java:83)
at org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:26)
at org.springframework.test.context.junit4.statements.RunBeforeTestMethodCallbacks.evaluate(RunBeforeTestMethodCallbacks.java:75)
at org.springframework.test.context.junit4.statements.RunAfterTestMethodCallbacks.evaluate(RunAfterTestMethodCallbacks.java:86)
at org.springframework.test.context.junit4.statements.SpringRepeat.evaluate(SpringRepeat.java:84)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)
at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.runChild(SpringJUnit4ClassRunner.java:251)
at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.runChild(SpringJUnit4ClassRunner.java:97)
at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
at org.springframework.test.context.junit4.statements.RunBeforeTestClassCallbacks.evaluate(RunBeforeTestClassCallbacks.java:61)
at org.springframework.test.context.junit4.statements.RunAfterTestClassCallbacks.evaluate(RunAfterTestClassCallbacks.java:70)
at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.run(SpringJUnit4ClassRunner.java:190)
at org.eclipse.jdt.internal.junit4.runner.JUnit4TestReference.run(JUnit4TestReference.java:86)
at org.eclipse.jdt.internal.junit.runner.TestExecution.run(TestExecution.java:38)
at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:538)
at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:760)
at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.run(RemoteTestRunner.java:460)
at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.main(RemoteTestRunner.java:206)
Caused by: java.lang.ClassCastException: [I cannot be cast to [Ljava.lang.Object;
at org.hibernate.validator.internal.engine.messageinterpolation.ParameterTermResolver.interpolate(ParameterTermResolver.java:30)
at org.hibernate.validator.internal.engine.messageinterpolation.InterpolationTerm.interpolate(InterpolationTerm.java:64)
at org.hibernate.validator.messageinterpolation.ResourceBundleMessageInterpolator.interpolate(ResourceBundleMessageInterpolator.java:76)
at org.hibernate.validator.messageinterpolation.AbstractMessageInterpolator.interpolateExpression(AbstractMessageInterpolator.java:385)
at org.hibernate.validator.messageinterpolation.AbstractMessageInterpolator.interpolateMessage(AbstractMessageInterpolator.java:274)
at org.hibernate.validator.messageinterpolation.AbstractMessageInterpolator.interpolate(AbstractMessageInterpolator.java:220)
at org.hibernate.validator.internal.engine.ValidationContext.interpolate(ValidationContext.java:468)
... 55 more
なんかエラーになりました。
「[I cannot be cast to [Ljava.lang.Object;
」からInteger
の配列からキャストに失敗しているようです。
試しにEL式でString
にしてみます。
com.neriudon.example.validator.AcceptedIntegerValues.message = not contained accepted values: ${value.toString()}.
これでテストしてみると、エラーにはなりませんでしたが、以下のようになってしまいます。
not contained accepted values: [I@6e9319f. //Integer[]版
not contained accepted values: [1, 2, 3, 4, 5]. // String[]版
というわけで、Arrays.toString
でInteger[]
からString
に変換してみます。
com.neriudon.example.validator.AcceptedIntegerValues.message = not contained accepted values: ${java.util.Arrays.toString(value)}.
で、実行すると……
javax.el.PropertyNotFoundException: ELResolver cannot handle a null base Object with identifier [java]
javaなんてオブジェクトないぞ!というエラーが出ました。
ここで初心に帰ってHibernate Validator 5のエラーメッセージのEL式について読んできましょう。
すると……
The validation engine makes the following objects available in the EL context:
the attribute values of the constraint mapped to the attribute names
the currently validated value (property, bean, method parameter etc.) under the name validatedValue
a bean mapped to the name formatter exposing the var-arg method format(String format, Object… args) which behaves like java.util.Formatter.format(String format, Object… args).
意訳すると……
validation engineは以下のオブジェクトをEL式で使用できます。
- アノテーションに設定した属性値
- 実際にエラーになった値
-
java.util.Formatter.format(String format, Object… args)
ベースのフォーマッティング
つまり、それ以外は使えない……?
リンク先の下にあるサンプルを参照しても、Javaが提供するクラスを呼び出してメッセージを加工していないですね。/(^o^)\ナンテコッタイ。
エラーメッセージで任意の値を呼び出せるようにする
しかし、方法がないわけではありません。
サンプルの下の書いてあるTipsで以下のように書いてあります。
Only actual constraint attributes can be interpolated using message parameters in the form {attributeName}. When referring to the validated value or custom expression variables added to the interpolation context (see Section 11.9.1, “HibernateConstraintValidatorContext”), an EL expression in the form ${attributeName} must be used.
意訳すると……
コンテキストに使いたい値を設定しておけば、${}
で参照できるよ!
とのこと。さっそく11.9.1. HibernateConstraintValidatorContextに書いてあるサンプルをベースに設定してみましょう。
Interger[]
をString
に変換したものをacceptedValuesToString
と名前を付けてコンテキストに格納する処理をisValid
メソッドに追加します。
@Override
public boolean isValid(Integer value, ConstraintValidatorContext context) {
if (value == null) {
return true;
}
// add acceptedValuesToString variable converted accepted integer values to string
context.unwrap(HibernateConstraintValidatorContext.class).addExpressionVariable("acceptedValuesToString", Arrays.toString(validValues));
// check to exist value or not in accepted values array
return Arrays.stream(validValues).anyMatch(s -> Objects.equals(value, s));
}
で、ValidationMessages.properties
で${acceptedValuesToString}
を呼び出すと……
com.neriudon.example.validator.AcceptedIntegerValues.message = not contained accepted values: ${acceptedValuesToString}.
not contained accepted values: [1, 2, 3, 4, 5].
というエラーメッセージが表示されました!やったね!
まとめ
この記事では、Bean Validationのエラーメッセージで任意の値を呼び出せるようにする方法を紹介しました。しかし、この方法には問題点が2つあります。
- 利用者に対して、エラーメッセージで使える値を説明する必要がある
デフォルトのValidation機能を拡張しているので、個人で開発する場合は問題ないのですが、例えばライブラリなどとして公開する場合は、どのような値が使えるのかをJavaDoc等に記載しておく必要があります。
- この機能自体が将来変更される可能性がある
HibernateConstraintValidatorContextのセクションの下にWARNで書かれていますが、この機能自体が将来変更される可能性があります。
記事を書いた本人が言うのもあれですが、あまり多用すべきではない機能ですね。
Hibernate Validator 6.0.13.Finalでは、上記の警告が削除されていたのでFixされたのでしょうか?
だとしたら、プロジェクトでHibernate Validator
を使用するのが決まっているのであれば、この機能は有用ですね(どっちだ)。