CLOVER🍀

That was when it all began.

Bean Validationを試してみる

ちょっとBean Validationを試してみたくなりまして。

といっても、全然知らないので、この辺りを参考に試してみました。

JSR 303 Bean Validationで遊んでみるよ!
http://yamkazu.hatenablog.com/entry/20110206/1296985545

Hibernate Validatorについて
http://d.hatena.ne.jp/Yosuke_Taka/20110827/1314413434

Bean Validationの実装は、もちろんHibernate Validatorを使用します。

Hibernate Validator
http://hibernate.org/validator/

あとは、Getting Startedも合わせて見つつ、試してみましょう。

Getting started with Hibernate Validator
http://hibernate.org/validator/documentation/getting-started/

準備

相変わらずScalaでやるので、まずはsbtで依存関係の定義。
build.sbt

name := "bean-validation-getting-started"

version := "0.0.1-SNAPSHOT"

scalaVersion := "2.10.4"

organization := "org.littlewings"

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

fork in Test := true

libraryDependencies ++= Seq(
  "org.hibernate" % "hibernate-validator" % "5.1.0.Final",
  "javax.el" % "javax.el-api" % "2.2.4",
  "org.glassfish.web" % "javax.el" % "2.2.4",
  "org.scalatest" %% "scalatest" % "2.1.2" % "test"
)

今回は、スタンドアロンで使用するので、ELの実装も依存関係に含めています。

バリデーション対象のクラス

とりあえず、簡単なサンプルを用意。
src/main/scala/org/littlewings/javaee7/beanvalidation/Book.scala

package org.littlewings.javaee7.beanvalidation

import javax.validation.constraints.{Min, NotNull, Size}

object Book {
  def apply(isbn: String, title: String, price: Int): Book = {
    val book = new Book
    book.isbn = isbn
    book.title = title
    book.price = price
    book
  }
}

class Book {
  @NotNull
  @Size(min = 14, max = 14)
  var isbn: String = _

  @NotNull
  var title: String = _

  @Min(1)
  var price: Int = _
}

ISBNは、ハイフン入り前提。

では、これを動かすテストコードを用意。
src/test/scala/org/littlewings/javaee7/beanvalidation/BeanValidationGettingStartedSpec.scala

package org.littlewings.javaee7.beanvalidation

import scala.collection.JavaConverters._

import javax.validation.{Validation, ValidatorFactory}
import javax.validation.constraints.{Min, NotNull, Size}

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

class BeanValidationGettingStartedSpec extends FunSpec {
  describe("bean-validation simple spec") {
    // ここに、テストを書く!
  }
}

…の前に、せっかくなのでメッセージを日本語にしてみましょう。
src/test/resources/ValidationMessages.properties.utf8

javax.validation.constraints.Digits.message=は境界以外の数値(予測:<{integer}digits>.<{fraction}digits>)
javax.validation.constraints.Min.message=は{value}以上でなければなりません
javax.validation.constraints.NotNull.message=は必須です
javax.validation.constraints.Size.message=のサイズは、{min}以上{max}以下でなければなりません

エラーになった項目名自体は、含めることができなさそうなので、自分でくっつけることにしました…。

これ、もしかしてnative2asciiしないといけない?

$ native2ascii src/test/resources/ValidationMessages.properties.utf8 src/test/resources/ValidationMessages.properties

そうなのかな…。

ではでは、気を取り直して、まずはエラーが発生しないパターン。

    it("normal") {
      val book = Book("978-4798124605",
                      "Beginning Java EE 6 GlassFish 3で始めるエンタープライズJava",
                      4536)

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

      val constraintViolations = validator.validate(book).asScala

      constraintViolations should be (empty)
    }

使い方は、Validation#buildDefaultValidatorFactoryでValidatorFactoryを取得し、さらにそこからValidatorを取得して使用します。

Validator#validateで実行するので、そこで結果のSetが返ってくるのでその結果を見るという感じみたいですね。中身は、ConstraintViolationとなります。

では、エラーパターン。

    it("required, negative") {
      val book = Book(null,
                      "Beginning Java EE 6 GlassFish 3で始めるエンタープライズJava",
                      -1)

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

      val constraintViolations = validator.validate(book).asScala

      constraintViolations should have size 2

      val violations =
        constraintViolations
          .toArray
          .sortWith(_.getPropertyPath.toString < _.getPropertyPath.toString)

      val v0 = violations(0)
      v0.getPropertyPath.toString should be ("isbn")

      val v1 = violations(1)
      v1.getPropertyPath.toString should be ("price")

      s"${v0.getPropertyPath}${v0.getMessage}" should be ("isbnは必須です")
      s"${v1.getPropertyPath}${v1.getMessage}" should be ("priceは1以上でなければなりません")

      v0.getConstraintDescriptor.getAnnotation should be (a [NotNull])
      v1.getConstraintDescriptor.getAnnotation should be (a [Min])
    }

もうひとつ。

    it("size") {
      val book = Book("12345",
                      "Beginning Java EE 6 GlassFish 3で始めるエンタープライズJava",
                      4536)

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

      val constraintViolations = validator.validate(book).asScala

      constraintViolations should have size 1

      val violations = constraintViolations.toArray

      val v0 = violations(0)
      v0.getPropertyPath.toString should be ("isbn")

      s"${v0.getPropertyPath}${v0.getMessage}" should be ("isbnのサイズは、14以上14以下でなければなりません")

      v0.getConstraintDescriptor.getAnnotation should be (a [Size])
    }

どの制約に引っかかったかは、ConstraintViolation#getConstraintDescriptorで取得できるConstraintDescriptorから、getAnnotationで確認することができるようです。

case classで試す

せっかくScalaでやるのなら、case classで使ってみましょう。

普通にフィールドにアノテーションを付与するだけでは、コンストラクタ引数に対するアノテーションとして解釈されてしまうので、このような形になります。
src/main/scala/org/littlewings/javaee7/beanvalidation/CaseBook.scala

package org.littlewings.javaee7.beanvalidation

import scala.annotation.meta.field

import javax.validation.constraints.{Min, NotNull, Size}

case class CaseBook(
  @(NotNull @field)
  @(Size @field)(min = 14, max = 14)
  isbn: String,

  @(NotNull @field)
  title: String,

  @(Min @field)(1)
  price: Int
)

テストコード自体は、ほぼ変わりません。

  describe("bean-validation case-class spec") {
    it("normal") {
      val book = CaseBook("978-4798124605",
                          "Beginning Java EE 6 GlassFish 3で始めるエンタープライズJava",
                          4536)

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

      val constraintViolations = validator.validate(book).asScala

      constraintViolations should be (empty)
    }

    it("required, negative") {
      val book = CaseBook(null,
                          "Beginning Java EE 6 GlassFish 3で始めるエンタープライズJava",
                          -1)

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

      val constraintViolations = validator.validate(book).asScala

      constraintViolations should have size 2

      val violations =
        constraintViolations
          .toArray
          .sortWith(_.getPropertyPath.toString < _.getPropertyPath.toString)

      val v0 = violations(0)
      v0.getPropertyPath.toString should be ("isbn")

      val v1 = violations(1)
      v1.getPropertyPath.toString should be ("price")

      s"${v0.getPropertyPath}${v0.getMessage}" should be ("isbnは必須です")
      s"${v1.getPropertyPath}${v1.getMessage}" should be ("priceは1以上でなければなりません")

      v0.getConstraintDescriptor.getAnnotation should be (a [NotNull])
      v1.getConstraintDescriptor.getAnnotation should be (a [Min])
    }

    it("size") {
      val book = CaseBook("12345",
                          "Beginning Java EE 6 GlassFish 3で始めるエンタープライズJava",
                          4536)

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

      val constraintViolations = validator.validate(book).asScala

      constraintViolations should have size 1

      val violations = constraintViolations.toArray

      val v0 = violations(0)
      v0.getPropertyPath.toString should be ("isbn")

      s"${v0.getPropertyPath}${v0.getMessage}" should be ("isbnのサイズは、14以上14以下でなければなりません")

      v0.getConstraintDescriptor.getAnnotation should be (a [Size])
    }
  }

複数の制約に引っかかる場合は?

Commons Validatorみたいに、複数の制約に引っかかると全部入るのかなぁ?ということで。
src/main/scala/org/littlewings/javaee7/beanvalidation/Bean.scala

package org.littlewings.javaee7.beanvalidation

import scala.annotation.meta.field

import javax.validation.constraints.{Digits, Size}

case class Bean(
  @(Digits @field)(integer = 2, fraction = 0)
  @(Size @field)(min = 1000, max = 9999)
  value: String
)

まあ、これを満たす値って入らないですけどね…。動作確認ということで。

確認。

  describe("complex constraint") {
    it("test") {
      val bean = Bean("100")

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

      val constraintViolations = validator.validate(bean).asScala

      constraintViolations should have size 2

      val violations =
        constraintViolations
          .toArray
          .sortWith(_.getMessageTemplate < _.getMessageTemplate)

      val v0 = violations(0)
      v0.getPropertyPath.toString should be ("value")

      val v1 = violations(1)
      v1.getPropertyPath.toString should be ("value")

      s"${v0.getPropertyPath}${v0.getMessage}" should be ("valueは境界以外の数値(予測:<2digits>.<0digits>)")
      s"${v1.getPropertyPath}${v1.getMessage}" should be ("valueのサイズは、1000以上9999以下でなければなりません")
    }
  }

同じように、複数入るみたいですね。

なんとなく、初歩の使い方はわかったかな?

今回作成したコードは、こちらにアップしています。

https://github.com/kazuhira-r/javaee7-scala-examples/tree/master/bean-validation-getting-started