バリデーションで面倒なものといえば、相関バリデーション。
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