概要
- Bean Validation 2.0 を使用して独自に判定するアノテーションを作る
- アノテーションではメールアドレスとして適切な文字列であるかを検証する
サンプルコード
ファイル一覧
- Gradle 用の設定ファイル: build.gradle
- Bean Validation 2.0 を使用するサンプルアプリケーション: App.java
- メールアドレス文字列であるか検証するアノテーション: EmailAddress.java
- ユニットテスト用クラス: EmailAddressTest.java
├── build.gradle
└── src
├── main
│ └── java
│ └── com
│ └── example
│ ├── App.java
│ └── annotation
│ └── EMailAddress.java
└── test
└── java
└── com
└── example
└── annotation
└── EMailAddressTest.java
build.gradle
Gradle 用の設定ファイル。
Bean Validation 2.0 を使用するためのライブラリと、テストに必要なライブラリを dependencies に記述している。
plugins {
id 'java'
id 'application'
}
group 'com.example'
version '1.0'
sourceCompatibility = 11
repositories {
mavenCentral()
}
dependencies {
// https://mvnrepository.com/artifact/javax.validation/validation-api
implementation 'javax.validation:validation-api:2.0.1.Final'
// https://mvnrepository.com/artifact/org.hibernate.validator/hibernate-validator
runtimeOnly 'org.hibernate.validator:hibernate-validator:6.0.17.Final'
// https://mvnrepository.com/artifact/org.glassfish/javax.el
runtimeOnly 'org.glassfish:javax.el:3.0.1-b11'
// https://mvnrepository.com/artifact/junit/junit
testImplementation 'junit:junit:4.12'
// https://mvnrepository.com/artifact/org.hamcrest/hamcrest-library
testImplementation 'org.hamcrest:hamcrest-library:1.3'
}
test {
testLogging {
events 'passed', 'failed'
}
}
application {
mainClassName = 'com.example.App'
}
App.java
Bean Validation 2.0 を使用するサンプルアプリケーションのクラス。
package com.example;
import com.example.annotation.EMailAddress;
import java.util.Set;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
public class App {
public static void main(String[] args) {
// Validator を取得
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
for (String arg : args) {
// 入力データを用意
InputData input = new InputData();
input.setEMailAddress(arg);
// バリデーションを実行
Set<ConstraintViolation<InputData>> v = validator.validate(input);
// 結果の確認
if (v.size() == 0) {
System.out.println(input.getEMailAddress() + ": メールアドレスとして適切です");
} else {
System.out.println(input.getEMailAddress() + ": " + v.iterator().next().getMessage());
}
}
}
private static class InputData {
@EMailAddress(message = "メールアドレスとして適切ではありません", docomo = false)
private String eMailAddress;
// getter / setter
public String getEMailAddress() { return eMailAddress; }
public void setEMailAddress(String eMailAddress) { this.eMailAddress = eMailAddress; }
}
}
EMailAddress.java
EMailAddress はメールアドレス文字列であるか検証するアノテーション。
実際の検証処理をするバリデータークラス EMailAddressValidator を内包している。
それぞれ Bean Validation 2.0 のアノテーションとバリデーションに必要なメソッド等を実装している。
package com.example.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.util.regex.Pattern;
import javax.validation.Constraint;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import javax.validation.Payload;
import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
import static java.lang.annotation.ElementType.CONSTRUCTOR;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
@Constraint(validatedBy = EMailAddress.EMailAddressValidator.class) // Bean Validation として扱う
@Documented // javadoc および同様のツールによってドキュメント化
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER}) // 適用可能な箇所
@Retention(RUNTIME) // コンパイラによってクラス・ファイルに記録され、実行時にVMによって保持される
public @interface EMailAddress {
public static final String DEFAULT_MESSAGE = "not a well-formed email address";
// Bean Validation として必要: エラーメッセージ
String message() default DEFAULT_MESSAGE;
// Bean Validation として必要: ターゲットグループをカスタマイズする用
Class<?>[] groups() default {};
// Bean Validation として必要: メタデータ情報の拡張用
Class<? extends Payload>[] payload() default {};
// 今回のアノテーション用に追加したメソッド
// docomo の古いメールアドレスを許容するか否かを表す
boolean docomo() default false;
// ひとつの対象に複数の @EMailAddress を指定するための設定
@Documented
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
@Retention(RUNTIME)
public @interface List {
EMailAddress[] value();
}
// バリデーター。検証処理をするクラス。
static class EMailAddressValidator implements ConstraintValidator<EMailAddress, CharSequence> {
// HTML Standard の正規表現を使用
// https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address
private static final Pattern PATTERN = Pattern.compile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$");
// アノテーションのインスタンス
private EMailAddress constraintAnnotation;
/**
* バリデーターを初期化。
* @param constraintAnnotation アノテーションのインスタンス
*/
@Override
public void initialize(EMailAddress constraintAnnotation) {
this.constraintAnnotation = constraintAnnotation;
}
/**
* 検証ロジックの実装。このメソッドは同時にアクセスされる可能性があるため、スレッドセーフにする必要がある。
* @param value 検証するオブジェクト
* @param context コンテキスト情報
* @return オブジェクトがバリデーションに引っかかったら false
*/
@Override
public boolean isValid(CharSequence value, ConstraintValidatorContext context) {
// null が渡された場合は true を返すことが推奨されている
// 標準の @Email では空文字列が渡された場合に true を返しているのでそれに合わせる
if (value == null || value.length() == 0) {
return true;
}
// HTML Standard のメールアドレスの正規表現にマッチするか
if (!PATTERN.matcher(value).matches()) {
return false;
}
// 必要に応じて細かい判定を以下に追加
// @EMailAddress アノテーションで条件を設定できるようにメソッドを定義しておけば
// 利用側が細かい判定をON/OFF制御するように記述することも可能
// たとえば docomo の古いメールアドレスを許容するか否か
if (!constraintAnnotation.docomo()) {
// 先頭の文字がピリオドだった
if (value.charAt(0) == '.') {
return false;
}
// @の前がピリオドだった
if (value.toString().contains(".@")) {
return false;
}
// ピリオドが2つ以上つながっていた
if (value.toString().contains("..")) {
// 必要に応じて、デフォルトの制約違反情報を破棄し、新しい情報を追加することもできる
String newMessage = constraintAnnotation.message() + "(ダブルピリオドダメゼッタイ)";
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(newMessage).addConstraintViolation();
return false;
}
}
// メールアドレスとして適切な文字列と判断
return true;
}
}
}
EMailAddressTest.java
ユニットテスト用クラス。
package com.example.annotation;
import org.junit.BeforeClass;
import org.junit.Test;
import java.util.Set;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.not;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.instanceOf;
// EMailAddress アノテーションをユニットテストするクラス
public class EMailAddressTest {
// テストしやすいようにテスト用 Bean に統一したインターフェースを持たせる
private interface TestBean {
void setTestString(String testString);
}
// テスト用に Bean クラスを作成
private static class NormalTestBean implements TestBean {
@EMailAddress
private String testString;
NormalTestBean(String testString) { this.testString = testString; }
// getter / setter
public String getTestString() { return testString; }
public void setTestString(String testString) { this.testString = testString; }
}
// テスト用に Bean クラスを作成
private static class MessageTestBean implements TestBean {
@EMailAddress(message = "メールアドレスとして適切ではありません")
private String testString;
MessageTestBean(String testString) { this.testString = testString; }
// getter / setter
public String getTestString() { return testString; }
public void setTestString(String testString) { this.testString = testString; }
}
// テスト用に Bean クラスを作成
private static class DocomoTestBean implements TestBean {
@EMailAddress(docomo = true) // docomo の古いメールアドレスを許容する設定
private String testString;
DocomoTestBean(String testString) { this.testString = testString; }
// getter / setter
public String getTestString() { return testString; }
public void setTestString(String testString) { this.testString = testString; }
}
// テストに使うバリデーター
private static Validator validator;
@BeforeClass
public static void setUpBeforeClass() {
// バリデーターを用意
ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
validator = validatorFactory.getValidator();
}
// メールアドレスとして適切な文字列をテストする
@Test
public void testValidEMailAddress() {
assertValid(new NormalTestBean("abc@example.com"));
assertValid(new NormalTestBean("abc+xyz@example.com"));
assertValid(new NormalTestBean("abc.xyz@example.com"));
}
// メールアドレスとして適切でない文字列をテストする
@Test
public void testInvalidEMailAddress() {
assertInvalid(new NormalTestBean("abc.example.com"), EMailAddress.DEFAULT_MESSAGE);
assertInvalid(new NormalTestBean("a c.example.com"), EMailAddress.DEFAULT_MESSAGE);
assertInvalid(new NormalTestBean("あいうえお@example.com"), EMailAddress.DEFAULT_MESSAGE);
// 複数メールアドレス
assertInvalid(new NormalTestBean("abc@example.com,xyz@example.com"), EMailAddress.DEFAULT_MESSAGE);
assertInvalid(new NormalTestBean("abc@example.com xyz@example.com"), EMailAddress.DEFAULT_MESSAGE);
// 以前NTTドコモの携帯電話用メアドで許可されていたRFC違反の形式
assertInvalid(new NormalTestBean(".abc@example.com"), EMailAddress.DEFAULT_MESSAGE); // 先頭にピリオド
assertInvalid(new NormalTestBean("abc.@example.com"), EMailAddress.DEFAULT_MESSAGE); // @の直前にピリオド
assertInvalid(new NormalTestBean("abc..xyz@example.com"), EMailAddress.DEFAULT_MESSAGE + "(ダブルピリオドダメゼッタイ)"); // ピリオド2つ以上連続
}
// メッセージが置き換わっていることをテストする
@Test
public void testMessage() {
assertInvalid(new MessageTestBean("abc.example.com"), "メールアドレスとして適切ではありません");
}
// docomo の古いメールアドレスを許容できていることをテストする
@Test
public void testDocomo() {
assertValid(new DocomoTestBean(".abc@example.com"));
assertValid(new DocomoTestBean("abc.@example.com"));
assertValid(new DocomoTestBean("abc..xyz@example.com"));
}
// バリデーションOKタイプ
private void assertValid(TestBean bean) {
Set<ConstraintViolation<TestBean>> violations = validator.validate(bean);
assertThat(violations, is(empty())); // バリデーションに引っかからなければOK
}
// バリデーションNGタイプ
private void assertInvalid(TestBean bean, String expectedMessage) {
Set<ConstraintViolation<TestBean>> violations = validator.validate(bean);
// バリデーションに引っかかったかどうか
assertThat(violations, is(not(empty())));
// 引っかかったバリデーションが EMailAddress であるか
ConstraintViolation<TestBean> violation = violations.iterator().next();
assertThat(violation.getConstraintDescriptor().getAnnotation(), is(instanceOf(EMailAddress.class)));
// メッセージが一致しているか
assertThat(violation.getMessage(), is(expectedMessage));
}
}
ユニットテストを実行する
$ gradle test
> Task :test
com.example.annotation.EMailAddressTest > testInvalidEMailAddress PASSED
com.example.annotation.EMailAddressTest > testValidEMailAddress PASSED
com.example.annotation.EMailAddressTest > testMessage PASSED
com.example.annotation.EMailAddressTest > testDocomo PASSED
BUILD SUCCESSFUL in 2s
3 actionable tasks: 3 executed
サンプルアプリケーションを実行する
$ gradle run --args="abc@example.com .abc@example.com a..bc@example.com"
> Task :run
10月 25, 2019 8:28:41 午後 org.hibernate.validator.internal.util.Version <clinit>
INFO: HV000001: Hibernate Validator 6.0.17.Final
abc@example.com: メールアドレスとして適切です
.abc@example.com: メールアドレスとして適切ではありません
a..bc@example.com: メールアドレスとして適切ではありません(ダブルピリオドダメゼッタイ)
BUILD SUCCESSFUL in 1s
2 actionable tasks: 1 executed, 1 up-to-date
参考資料
ConstraintValidator の isValid について
ConstraintValidator の isValid に null が渡された場合は true を返すことが推奨されている。null の検証は @NotNull で行う。
Bean Validation specification - 3.4. Constraint validation implementation
While not mandatory, it is considered a good practice to split the core constraint validation from the not null constraint validation (for example, an @Email constraint will return true on a null object, i.e. will not take care of the @NotNull validation).
null can have multiple meanings but is commonly used to express that a value does not make sense, is not available or is simply unknown. Those constraints on the value are orthogonal in most cases to other constraints. For example a String, if present, must be an email but can be null. Separating both concerns is a good practice.
アノテーションとバリデーターの実装の参考
Hibernate Validator の実装が参考になる。
org.hibernate.validator.internal.constraintvalidators パッケージの下には bv パッケージと hv パッケージが用意されている。bv は Bean Validation の略で hv は Hybernate Validation の略だと思われる。
- hibernate-validator/AbstractEmailValidator.java at 6.0.17.Final · hibernate/hibernate-validator · GitHub
- hibernate-validator/EmailValidator.java at 6.0.17.Final · hibernate/hibernate-validator · GitHub
- hibernate-validator/NotEmptyValidatorForCharSequence.java at 6.0.17.Final · hibernate/hibernate-validator · GitHub
Bean Validation 公式とそれに準ずる資料
- Bean Validation - Bean Validation 2.0 (JSR 380)
- Bean Validation specification
- The Bean Validation reference implementation. - Hibernate Validator
- Getting started with Hibernate Validator - Hibernate Validator
Bean Validation 実装に関連するAPIリファレンス資料
- Overview (Jakarta Bean Validation API 2.0.2)
- Constraint (Jakarta Bean Validation API 2.0.2)
- ConstraintValidator (Jakarta Bean Validation API 2.0.2)
- java.lang.annotation (Java Platform SE 8 )
Bean Validation 実装の参考資料
- 4.1. 入力チェック — TERASOLUNA Server Framework for Java (5.x) Development Guideline 5.5.1.RELEASE documentation
- Spring カスタムアノテーションに出会った話 - Qiita
- JavaEE使い方メモ(Bean Validation) - Qiita
- Bean Validationのアノテーションに付いてるListについて - CLOVER🍀
Bean Validation ユニットテストの参考資料
- 10.2.3. 機能ごとのテスト実装 — TERASOLUNA Server Framework for Java (5.x) Development Guideline 5.5.1.RELEASE documentation
- Spring 単項目バリデーションのテスト - Qiita
メールアドレス仕様の参考資料
HTML Standard に記載されているメールアドレスの正規表現。RFC 的に完全ではないがある程度はこれでチェックできる。
HTML Standard - 4.10.5.1.5 E-mail state (type=email)
The following JavaScript- and Perl-compatible regular expression is an implementation of the above definition.
/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
docomo の携帯電話ではピリオドが連続するメールアドレスを認めていたことがある。
ローカル部にはquoted-string形式でなければ“.”を先頭と末尾で使用することや2個以上連続して使用することはできない。 しかし、一部の実装(実例:携帯電話のメール)はこの仕様を逸脱しており、規定外の特殊な文字が使用可能な場合もある。