Bean Validation 2.0 用での独自アノテーション実装とそのユニットテストのサンプルコード

## 概要
- 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 に記述している。

```build.gradle
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 を使用するサンプルアプリケーションのクラス。

```java
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 のアノテーションとバリデーションに必要なメソッド等を実装している。

```java
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

ユニットテスト用クラス。

```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](https://beanvalidation.org/2.0/spec/#constraintsdefinitionimplementation-validationimplementation)

> 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](https://github.com/hibernate/hibernate-validator/blob/6.0.17.Final/engine/src/main/java/org/hibernate/validator/internal/constraintvalidators/AbstractEmailValidator.java)
- [hibernate\-validator/EmailValidator\.java at 6\.0\.17\.Final · hibernate/hibernate\-validator · GitHub](https://github.com/hibernate/hibernate-validator/blob/6.0.17.Final/engine/src/main/java/org/hibernate/validator/internal/constraintvalidators/bv/EmailValidator.java)
- [hibernate\-validator/NotEmptyValidatorForCharSequence\.java at 6\.0\.17\.Final · hibernate/hibernate\-validator · GitHub](https://github.com/hibernate/hibernate-validator/blob/6.0.17.Final/engine/src/main/java/org/hibernate/validator/internal/constraintvalidators/bv/notempty/NotEmptyValidatorForCharSequence.java)

### Bean Validation 公式とそれに準ずる資料

- [Bean Validation \- Bean Validation 2\.0 \(JSR 380\)](https://beanvalidation.org/2.0/)
- [Bean Validation specification](https://beanvalidation.org/2.0/spec/)
- [The Bean Validation reference implementation\. \- Hibernate Validator](https://hibernate.org/validator/)
- [Getting started with Hibernate Validator \- Hibernate Validator](http://hibernate.org/validator/documentation/getting-started/)

### Bean Validation 実装に関連するAPIリファレンス資料

- [Overview \(Jakarta Bean Validation API 2\.0\.2\)](https://docs.jboss.org/hibernate/beanvalidation/spec/2.0/api/)
- [Constraint \(Jakarta Bean Validation API 2\.0\.2\)](https://docs.jboss.org/hibernate/beanvalidation/spec/2.0/api/javax/validation/Constraint.html)
- [ConstraintValidator \(Jakarta Bean Validation API 2\.0\.2\)](https://docs.jboss.org/hibernate/beanvalidation/spec/2.0/api/javax/validation/ConstraintValidator.html)
- [java\.lang\.annotation \(Java Platform SE 8 \)](https://docs.oracle.com/javase/jp/8/docs/api/java/lang/annotation/package-summary.html)

### Bean Validation 実装の参考資料

- [4\.1\. 入力チェック — TERASOLUNA Server Framework for Java \(5\.x\) Development Guideline 5\.5\.1\.RELEASE documentation](http://terasolunaorg.github.io/guideline/5.5.1.RELEASE/ja/ArchitectureInDetail/WebApplicationDetail/Validation.html)
- [Spring カスタムアノテーションに出会った話 \- Qiita](https://qiita.com/b1ueskydragon/items/07ac1f9bc8c55dffc541)
- [JavaEE使い方メモ（Bean Validation） \- Qiita](https://qiita.com/opengl-8080/items/3926fbde5469c0b330c2)
- [Bean Validationのアノテーションに付いてるListについて \- CLOVER🍀](https://kazuhira-r.hatenablog.com/entry/20150622/1434985462)

### Bean Validation ユニットテストの参考資料

- [10\.2\.3\. 機能ごとのテスト実装 — TERASOLUNA Server Framework for Java \(5\.x\) Development Guideline 5\.5\.1\.RELEASE documentation](http://terasolunaorg.github.io/guideline/5.5.1.RELEASE/ja/UnitTest/ImplementsOfUnitTest/ImplementsOfTestByFunction.html)
- [Spring 単項目バリデーションのテスト \- Qiita](https://qiita.com/kamikenta/items/da6f5f30d05f7abc1da4)

### メールアドレス仕様の参考資料

HTML Standard に記載されているメールアドレスの正規表現。RFC 的に完全ではないがある程度はこれでチェックできる。

[HTML Standard - 4.10.5.1.5 E-mail state (type=email)](https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address)

```
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 の携帯電話ではピリオドが連続するメールアドレスを認めていたことがある。

[メールアドレス \- Wikipedia](https://ja.wikipedia.org/wiki/%E3%83%A1%E3%83%BC%E3%83%AB%E3%82%A2%E3%83%89%E3%83%AC%E3%82%B9)

> ローカル部にはquoted-string形式でなければ“.”を先頭と末尾で使用することや2個以上連続して使用することはできない。 しかし、一部の実装（実例：携帯電話のメール）はこの仕様を逸脱しており、規定外の特殊な文字が使用可能な場合もある。