指定したフィールドのいずれかが空ではないという相関チェックをBean Validationで実装する

こんにちは。サービスグループの武田です。今回はSpring FrameworkとBean Validationの環境で、指定したフィールドのいずれかが空ではないことを検証するカスタムアノテーションを作りました。
2018.04.26

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは。サービスグループの武田です。

現在携わっているプロジェクトではJava(とSprint Framework)を利用して開発をしています。今回はその中で相関チェックを行うカスタムバリデーションの実装方法について調べたので、調査したことを残しておきます。

指定したフィールドのいずれかが空ではないことを検証するバリデーション

現在のプロジェクトではSpringを利用してWebアプリケーションを開発しています。Webアプリケーションに限った話ではありませんが、外部から入力として値を受け取る場合、システムが許容している値かをチェックしなければいけません。この値のチェックのことをバリデーションと呼びますが、得てして複雑(かつ機能とは無関係)になりがちです。

そこで何らかのライブラリなどを使用するわけですが、SpringではBean Validationとの連携が簡単で便利です。Bean Validationを使うと、アノテーションで宣言的にバリデーションを行うことができます。

実際に利用する場合は、Bean Validationのリファレンス実装でもあるHibernate Validatorがよく使われます。このライブラリが提供しているビルトインアノテーションは公式ドキュメントを参考にしてください。

Hibernate Validator 6.0.9.Final - JSR 380 Reference Implementation: Reference Guide

用意されているアノテーションでだいたいの用は足りますが、そうでない場合は自作することになります。今回は 指定したフィールドのいずれかが空ではない 、つまり最低でもひとつのフィールドに値が入っていることを検証するカスタムアノテーションを作りました。

環境

  • OS
    • macOS High Sierra
  • IDE
    • IntelliJ IDEA 2018.1 (Ultimate Edition)
  • Java
    • 9
  • Spring Boot
    • 2.0.1
  • Hibernate Validator
    • 6.0.9.Final
  • Lombok
    • 1.16.20
  • JUnit
    • 4.12

プロジェクトのひな型作成

IntelliJではSpring Initializrを使ったSpring Bootプロジェクトの作成ウィザードがあるため、それを利用します。

ウィザードの途中でプロジェクトタイプや依存ライブラリの選択ができます。

ウィザードが完了するとひな型となるプロジェクトが作成されます。今回はプロジェクトタイプとしてGradle Projectを選択したため、次のようなbuild.gradleが作成されていました。

build.gradle

buildscript {
    ext {
        springBootVersion = '2.0.1.RELEASE'
    }
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = 9

repositories {
    mavenCentral()
}


dependencies {
    compile('org.springframework.boot:spring-boot-starter-validation')
    compileOnly('org.projectlombok:lombok')
    testCompile('org.springframework.boot:spring-boot-starter-test')
}

テストケース

今回やりたいことを明確にするため、先にテストケースを見ておきます。

DemoApplicationTests.java

package com.example.demo;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
import java.lang.annotation.Annotation;
import java.util.Set;

import static org.hamcrest.MatcherAssert.*;
import static org.hamcrest.Matchers.*;

@RunWith(SpringRunner.class)
@SpringBootTest
public class DemoApplicationTests {

    private User user;
    private Validator validator;

    private static final String LEGAL_NAME = "test name";
    private static final String LEGAL_PHONE_NUMBER = "0311112222";
    private static final String LEGAL_MOBILE_NUMBER = "09011112222";

    private static final String ILLEGAL_PHONE_NUMBER = "031111";
    private static final String ILLEGAL_MOBILE_NUMBER = "0901111";

    @Before
    public void before() {
        user = new User();
        ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
        validator = validatorFactory.getValidator();
    }

    @Test
    public void nameMustNotBeNull() {
      user.setPhoneNumber(LEGAL_PHONE_NUMBER);
      user.setMobileNumber(LEGAL_MOBILE_NUMBER);

      Set<ConstraintViolation<User>> violations = validator.validate(user);
      assertThat(violations.size(), is(1));
      assertThat(getAnnotation(violations, "name"), is(instanceOf(NotNull.class)));
    }

    @Test
    public void bothPhoneNumberAndMobileNumberEmpty() {
        user.setName(LEGAL_NAME);

        Set<ConstraintViolation<User>> violations = validator.validate(user);
        assertThat(violations.size(), is(1));
        assertThat(getAnnotation(violations, ""), is(instanceOf(NotEmptyAny.class)));
    }

    @Test
    public void eitherPhoneNumberOrMobileNumberNotEmpty() {
        Set<ConstraintViolation<User>> violations;
        user.setName(LEGAL_NAME);

        // phoneNumberのみ
        user.setPhoneNumber(LEGAL_PHONE_NUMBER);

        violations = validator.validate(user);
        assertThat(violations.size(), is(0));

        // mobileNumberのみ
        user.setPhoneNumber(null);
        user.setMobileNumber(LEGAL_MOBILE_NUMBER);

        violations = validator.validate(user);
        assertThat(violations.size(), is(0));

        // phoneNumberとmobileNumber
        user.setPhoneNumber(LEGAL_PHONE_NUMBER);

        violations = validator.validate(user);
        assertThat(violations.size(), is(0));
    }

    @Test
    public void testIllegalNumber() {
        user.setName(LEGAL_NAME);
        user.setPhoneNumber(ILLEGAL_PHONE_NUMBER);
        user.setMobileNumber(ILLEGAL_MOBILE_NUMBER);

        Set<ConstraintViolation<User>> violations = validator.validate(user);

        assertThat(violations.size(), is(2));
        assertThat(getAnnotation(violations, "phoneNumber"), is(instanceOf(Pattern.class)));
        assertThat(getAnnotation(violations, "mobileNumber"), is(instanceOf(Pattern.class)));
    }

    private Annotation getAnnotation(Set<ConstraintViolation<User>> violations, String path) {
        return violations.stream()
                .filter(cv -> cv.getPropertyPath().toString().equals(path))
                .findFirst()
                .map(cv -> cv.getConstraintDescriptor().getAnnotation())
                .get();
    }

}

Userクラスはこれから作成するクラスで、FormBean(あるいはEntity)に該当するクラスです。UserクラスはnamephoneNumbermobileNumberというフィールドを持ちます。名前、電話番号、携帯番号のイメージです。nameは必須、phoneNumbermobileNumber両方とも空 はNGで、どちらか一方または両方入力されていればOKです。また番号は(入力されていれば)それぞれ10桁の数字列、11桁の数字列である必要があります。

Userクラスの作成

続いて、アノテーションを付与するクラスを作成します。今回はUserクラスですね。またLombokを利用することで紋切り型のアクセサーなどの宣言を省略します。

User.java

package com.example.demo;

import lombok.Data;

import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;

@Data
public class User {

    @NotNull
    private String name;

    @Pattern(regexp = "[0-9]{10}")
    private String phoneNumber;

    @Pattern(regexp = "[0-9]{11}")
    private String mobileNumber;
}

とてもシンプルですね!@DataのアノテーションはLombokのもので、自動的にフィールドのアクセサーを実装してくれたり、hashCodeequalsメソッドなどをオーバーライドしてくれます。詳しくは公式ドキュメントを参照してください。

Lombok Project - @Data

またコンパイルエラーを直すためにNotEmptyAnyを形だけ作ります。

NotEmptyAny.java

package com.example.demo;

public @interface NotEmptyAny {

}

それでは一度ユニットテストを走らせてみます。

bothPhoneNumberAndMobileNumberEmptyだけ失敗しています。番号が両方とも空の場合エラーがあるはずですが、現在はそのチェックを行なっていません。次はこのテストを通すために実装していきます。

NotEmptyAnyアノテーションの実装

今回は次のように実装してみました。

NotEmptyAny.java

package com.example.demo;

import org.springframework.beans.BeanWrapper;
import org.springframework.beans.BeanWrapperImpl;

import javax.validation.Constraint;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import javax.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.stream.Stream;

@Documented
@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = NotEmptyAny.NotEmptyAnyValidator.class)
public @interface NotEmptyAny {
    public String message() default "One of {fields} must not be empty";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    String[] fields();

    class NotEmptyAnyValidator implements ConstraintValidator<NotEmptyAny, Object> {
        private String[] fields;

        @Override
        public void initialize(NotEmptyAny constraintAnnotation) {
            fields = constraintAnnotation.fields();
        }

        @Override
        public boolean isValid(Object bean, ConstraintValidatorContext context) {
            if (bean == null) {
                return true;
            }

            BeanWrapper beanWrapper = new BeanWrapperImpl(bean);
            return Stream.of(fields)
                    .map(beanWrapper::getPropertyValue)
                    .filter(v -> v != null)
                    .map(Object::toString)
                    .anyMatch(v -> !v.isEmpty());
        }
    }

}

簡単にポイントをピックアップします。

  • 18行目
    • 個別のフィールドではなく相関チェックを行うため、クラスに付与させます
    • @Targetはこのアノテーションを付与できる対象を宣言するものです
  • 28行目
    • チェック対象のフィールド名をfieldsで受け取ります
    • 実際の受け取るコードは35行目です
  • 44行目
    • チェック対象のフィールドの値を取得するためBeanWrapperを利用します
    • 46行目のgetPropertyValueで値を取得しています

再テスト

最後にUserクラスを、NotEmptyAnyを使用するように修正します。

User.java

@Data
@NotEmptyAny(fields = {"phoneNumber", "mobileNumber"})
public class User {

phoneNumbermobileNumberのどちらかが入力されていればOKとしたいので、@NotEmptyAnyfieldsで宣言します。

アノテーションを付与したら再度テストを実行してみます。

無事にすべてのテストをパスしました!

まとめ

今回は 指定したフィールドのいずれかが空ではない ことを検証するバリデーションを作成してみました。はじめて相関バリデーションを作りましたが、個別のフィールドではなく複数のフィールドの関係に基づいてチェックするため、通常よりやや複雑ですね。

またJavaは9を使用しましたが、最初は10でやろうとしました。ところがLombokが10に対応していないようでエラーが出てしまいました。

Lombok fails with JDK 10 · Issue #1572 · rzwitserloot/lombok

このあたりの事情もウォッチしていきたいですね。