CLOVER🍀

That was when it all began.

Bean Validationで相関バリデーションを作る

バリデーションで面倒なものといえば、相関バリデーション。

Bean Validationを使っても、いろいろ悩みがあるようです。

BeanValidationの相関バリデーションとそもそもの話
http://backpaper0.github.io/2014/12/04/validation.html

私のBeanValidationの使い方(Java EE Advent Calendar 2013)
http://backpaper0.github.io/2013/12/03/javaee_advent_calendar_2013.html

Bean ValidationのGroup sequenceは単項目チェック、相関チェックの順序指定で使うのは止めた方が良さそう
http://qiita.com/eiryu/items/95a206d617bd2b956953

う〜ん…。

まあ、とりあえず、自分としてはここは初めてBean Validationを使って相関バリデーションを書いてみるので、何かに習って書いてみましょう。

上記で書いた参考ページも良いと思いますが、今回はこちらに習ってみました。

5.5.3.2.2. 相関項目チェックルール
https://terasolunaorg.github.io/guideline/public_review/ArchitectureInDetail/Validation.html#id13

準備

ビルド定義。
build.sbt

name := "bean-validation-interrelation"

version := "0.0.1-SNAPSHOT"

scalaVersion := "2.11.6"

organization := "org.littlewings"

scalacOptions ++= Seq("-Xlint", "-deprecation", "-unchecked", "-feature")

updateOptions := updateOptions.value.withCachedResolution(true)

libraryDependencies ++= Seq(
  "org.hibernate" % "hibernate-validator" % "5.1.3.Final",
  "javax.el" % "javax.el-api" % "2.2.5",
  "org.glassfish.web" % "javax.el" % "2.2.6",
  "org.scalatest" %% "scalatest" % "2.2.5" % "test"
)

アノテーションとValidatorを書く

今回のお題は、従業員的なものにしてみました。役職と給料があって…

  • 課長であれば、給料は50000以上でなければならない
  • 部長であれば、給料は100000以上でなければならない
  • それ以外であれば、給料は50000より低くなければならない

といった感じで。ちょっと微妙ですが、まあこんなところで…。

まずはアノテーション
src/main/java/org/littlewings/javaee7/beanvalidation/PostSalaryConstraint.java

package org.littlewings.javaee7.beanvalidation;

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 javax.validation.Constraint;
import javax.validation.Payload;

@Documented
@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PostSalaryConstraintValidator.class)
public @interface PostSalaryConstraint {
    String message() default "";

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

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

    String postField() default "post";

    String salaryField() default "salary";
}

アノテーションを付与できる対象は絞ります。

@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE})

型に付与できればよいですよね。

メッセージは、今回はValidator側で文字列として設定することにしました。なので、messageとしては空です。

    String message() default "";

また、アノテーションを付与するクラスに対して、役職と給料のフィールド名はアノテーションのパラメーターとして設定可能にしました。

    String postField() default "post";

    String salaryField() default "salary";

まあ、今回はこれとは異なるフィールド名は使わないんですけど。

続いて、Validator。
src/main/scala/org/littlewings/javaee7/beanvalidation/PostSalaryConstraintValidator.scala

package org.littlewings.javaee7.beanvalidation

import javax.validation.{ConstraintValidator, ConstraintValidatorContext}

class PostSalaryConstraintValidator extends ConstraintValidator[PostSalaryConstraint, AnyRef] {
  private var postField: String = _
  private var salaryField: String = _

  override def initialize(constraintAnnotation: PostSalaryConstraint): Unit = {
    postField = constraintAnnotation.postField
    salaryField = constraintAnnotation.salaryField
  }

  override def isValid(value: AnyRef, context: ConstraintValidatorContext): Boolean =
    value match {
      case null => true
      case employee =>
        val salary = getFieldValue[Number](employee, salaryField).longValue()

        getFieldValue[String](employee, postField) match {
          case "課長" if salary >= 50000 => true
          case "課長" =>
            context.disableDefaultConstraintViolation()
            context
              .buildConstraintViolationWithTemplate("課長の給料は、50000以上でなければなりません")
              .addPropertyNode(salaryField)
              .addConstraintViolation()
            false
          case "部長" if salary >= 100000 => true
          case "部長" =>
            context.disableDefaultConstraintViolation()
            context
              .buildConstraintViolationWithTemplate("部長の給料は、100000以上でなければなりません")
              .addPropertyNode(postField)
              .addPropertyNode(salaryField)
              .addConstraintViolation()
            false
          case _ if salary < 50000 => true
          case _ =>
            context.disableDefaultConstraintViolation()
            context
              .buildConstraintViolationWithTemplate("平社員の給料は、50000より低くなければなりません")
              .addPropertyNode(salaryField)
              .addConstraintViolation()
            false
        }
    }

  private def getFieldValue[T](owner: AnyRef, fieldName: String): T = {
    val field = owner.getClass.getDeclaredField(fieldName)
    field.setAccessible(true)
    field.get(owner).asInstanceOf[T]
  }
}

今回初めて学んだのは、このあたりですね。

            context.disableDefaultConstraintViolation()
            context
              .buildConstraintViolationWithTemplate("課長の給料は、50000以上でなければなりません")
              .addPropertyNode(salaryField)
              .addConstraintViolation()

まず、ConstraintValidatorContext#disableDefaultConstraintViolationを呼び出すことで、デフォルトのバリデーションエラーの生成を抑止します。

続いて、ConstraintValidatorContext#buildConstraintViolationWithTemplateでメッセージテンプレートを指定。といっても、ここではメッセージそのものですが…。そして、ConstraintValidatorContext#addPropertyNodeでどのプロパティでエラーが発生したのかを設定します。

最後にConstraintValidatorContext#addConstraintViolationを呼び出してConstraintViolationを追加し、このメソッドとしてはfalseとして終了(バリデーションエラーとする)します。

また、ConstraintValidatorContext#addPropertyNodeはaddというくらいなので、複数のプロパティに対して設定できます。部長の場合は、postとsalaryの両方のフィールドに設定してみましょう。

            context
              .buildConstraintViolationWithTemplate("部長の給料は、100000以上でなければなりません")
              .addPropertyNode(postField)
              .addPropertyNode(salaryField)
              .addConstraintViolation()

アノテーションおよびValidatorの実装は、これで完了です。

メッセージリソースファイルは、今回はValidatorにメッセージを埋め込んだので用意しません。

確認

それでは、テストコードを書いて動作確認してみます。

今回作成したアノテーションを付与したクラス。
src/test/scala/org/littlewings/javaee7/beanvalidation/Employee.scala

package org.littlewings.javaee7.beanvalidation

import scala.beans.BeanProperty

@PostSalaryConstraint
class Employee {
  var name: String = _
  var post: String = _
  var salary: Int = _
}

フィールド名をアノテーションで指定できるようにしましたが、今回は使っていません…。

テストコードの雛形。
src/test/scala/org/littlewings/javaee7/beanvalidation/InterrelationConstraintSpec.scala

package org.littlewings.javaee7.beanvalidation

import scala.collection.JavaConverters._

import javax.validation.{ConstraintViolation, Validation}

import org.scalatest.FunSpec
import org.scalatest.Matchers._

class InterrelationConstraintSpec extends FunSpec {
  describe("InterrelationConstraint Spec") {
    // ここに、テストを書く!
  }
}

課長でバリデーションNGのパターン。

    it("invalid manager") {
      val employee = new Employee
      employee.name = "次郎"
      employee.post = "課長"
      employee.salary = 30000

      val factory = Validation.buildDefaultValidatorFactory
      val validator = factory.getValidator

      val constraintViolations =
        validator
          .validate(employee)
          .toArray(Array.empty[ConstraintViolation[Any]])
          .sortWith(_.getPropertyPath.toString < _.getPropertyPath.toString)

      constraintViolations should have size (1)
      constraintViolations(0).getPropertyPath.toString should be("salary")
      constraintViolations(0).getMessage should be("課長の給料は、50000以上でなければなりません")
    }

「salary」フィールドでエラーが発生したことになっています。

課長でOKのパターン。

    it("valid manager") {
      val employee = new Employee
      employee.name = "次郎"
      employee.post = "課長"
      employee.salary = 50000

      val factory = Validation.buildDefaultValidatorFactory
      val validator = factory.getValidator

      val constraintViolations =
        validator
          .validate(employee)
          .toArray(Array.empty[ConstraintViolation[Any]])
          .sortWith(_.getPropertyPath.toString < _.getPropertyPath.toString)

      constraintViolations should be(empty)
    }


部長でバリデーションNGのパターン。

    it("invalid general manager") {
      val employee = new Employee
      employee.name = "次郎"
      employee.post = "部長"
      employee.salary = 90000

      val factory = Validation.buildDefaultValidatorFactory
      val validator = factory.getValidator

      val constraintViolations =
        validator
          .validate(employee)
          .toArray(Array.empty[ConstraintViolation[Any]])
          .sortWith(_.getPropertyPath.toString < _.getPropertyPath.toString)

      constraintViolations should have size (1)
      constraintViolations(0)
        .getPropertyPath
        .iterator
        .asScala
        .map(_.toString)
        .toArray should contain only("post", "salary")
      constraintViolations(0).getMessage should be("部長の給料は、100000以上でなければなりません")
    }

こちらでは、「post」および「salary」フィールドでエラーが発生したことになっています。

部長でOKのパターン。

    it("valid general manager") {
      val employee = new Employee
      employee.name = "次郎"
      employee.post = "部長"
      employee.salary = 100000

      val factory = Validation.buildDefaultValidatorFactory
      val validator = factory.getValidator

      val constraintViolations =
        validator
          .validate(employee)
          .toArray(Array.empty[ConstraintViolation[Any]])
          .sortWith(_.getPropertyPath.toString < _.getPropertyPath.toString)

      constraintViolations should be(empty)
    }

その他のパターンは、NG、OKまとめて。

    it("invalid staff") {
      val employee = new Employee
      employee.name = "次郎"
      employee.post = "平社員"
      employee.salary = 50000

      val factory = Validation.buildDefaultValidatorFactory
      val validator = factory.getValidator

      val constraintViolations =
        validator
          .validate(employee)
          .toArray(Array.empty[ConstraintViolation[Any]])
          .sortWith(_.getPropertyPath.toString < _.getPropertyPath.toString)

      constraintViolations should have size (1)
      constraintViolations(0).getPropertyPath.toString should be("salary")
      constraintViolations(0).getMessage should be("平社員の給料は、50000より低くなければなりません")
    }

    it("valid staff") {
      val employee = new Employee
      employee.name = "次郎"
      employee.post = "平社員"
      employee.salary = 30000

      val factory = Validation.buildDefaultValidatorFactory
      val validator = factory.getValidator

      val constraintViolations =
        validator
          .validate(employee)
          .toArray(Array.empty[ConstraintViolation[Any]])
          .sortWith(_.getPropertyPath.toString < _.getPropertyPath.toString)

      constraintViolations should be(empty)
    }

初めてBean Validationで相関バリデーションを実装しましたが、ConstraintValidatorContextの使い方がよくわからなったりしてけっこうハマりました…。でも、いろいろと勉強になった気がします。

今回作成したコードは、こちらに置いています。
https://github.com/kazuhira-r/javaee7-scala-examples/tree/master/bean-validation-interrelation