LoginSignup
5
7

More than 3 years have passed since last update.

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

Posted at

概要

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

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 の略だと思われる。

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

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

Bean Validation 実装の参考資料

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

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

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

メールアドレス - Wikipedia

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

5
7
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
5
7