2011年12月23日金曜日

ScalazでBean Validation

このエントリはScala Advent Calender 2011の第23日目です。

Java業界で今年最大のニュースといえば、Oracle Open Worldで発表されたOracleのクラウド参入を上げる人も多いでしょう。OracleのPaaSが成功するのかどうかは未知数ですが、Java EEがPaaSのプラットフォームとして強力に推進されることは確実で、Java VM系での標準コンテナ仕様になるとみて間違いないと思います。

現状では、WebアプリケーションをJavaで作る場合TomcatやJettyなどの生ServletにSpringなどのフレームワークを載せて使うのが一般的で、Java EEのEJBはあまり使われていません。Java EEからEJBを引くと生Servletになるのであれば、Java EE仕様はあまり関係なくて、今まで通りTomcatやJettyベースでよいことになります。

しかし、実はJava EEのプラットフォームも地味に進歩してきていて、Java EEからEJB(のフルスペックやその他管理系の機能)を引いた部分の機能がかなり大きくなってきており、生Servletとの乖離が大きくなっています。この部分は、Java EEのWeb Profileとして仕様化もされています。Java EEのフルスペックは大変ですが、Web Profileなら使用頻度とのバランス的にリーズナブルな大きさであり、このWeb ProfileがJava VM系のPaaS標準仕様になると予想されるわけです。

ScalaもJava VM上で動作させることが普通なので、Javaプラットフォームの進化とは切っても切れない関係です。そういうわけでScalaでクラウドアプリケーションを書く場合も、Java EEのWeb Profileを念頭に置いておきたいところです。

Web Profileではいくつか重要な機能が追加されていますが、その一つがBean Validationです。Bean Validationは、Java Beansのプロパティを検証する機能です。プロパティ(属性やメソッド)に対するアノテーションで指定された値の値域と実際に格納されている値がマッチしているか検証してくれます。この機能は、普通のJava SEベースのプログラムでも利用したいぐらいの便利な機能で、たまたまJava EEの枠組みで仕様化されていますが、事実上Javaの標準機能といえます。(Jarを追加すればJava SE上でも簡単に使えます。) そういう意味でもScalaプログラミングにも積極的に取り入れたいところです。

さて、ScalaでValidationといえばScalaz Validationですね(笑)。

Scalaz Validationは、applicative functorとして実現されたValidationクラスを中心とした機能で、いわゆるapplicative styleというプログラミングスタイルで、正常系処理と異常系処理を綺麗に取り扱うメカニズムを提供します。

Scalaでクラウドアプリケーションを作る場合、Beans ValidationとScalaz Validationを併用するのが望ましいことはいうまでもありません。そこで、この2つのValidationシステムを統一的に扱うためのプログラミング方法を試行することにしました。

Person

まず、検証対象となるクラスをPersonを定義します。通常のScalaクラスですが、属性に@NotNullや@Size(min=1)といったアノテーションがつけられている点がBean Validationのための追加点です。

class Person(nm: String, ag: Int, ad: Option[String]) {
  @NotNull @Size(min=1)
  val name: String = nm
  @Min(0) @Max(150)
  val age: Int = ag;
  @NotNull // Option内の判定はできない
  val address: Option[String] = ad
}

本来は、case classを使いたいところですが、コンストラクタの引数につけたアノテーションをBean Validationは認識してくれないみたいなので、泣く泣くこの実装にしています。これは、ScalaからBean Validationを使うときの要注意項目ですね。

また、Optionの中身はBean Validationの基本機能では扱えないので、より本格的に作り込む場合は、カスタムのバリデーターを作る必要があります。

Personのコンパニオンオブジェクト

次に、Personクラスを操作するための関数を集めたコンパニオンオブジェクトPersonを定義します。ここで、Bean ValidationとScalaz Validationを接続する処理を実現します。

以下では、プログラムに直接コメントしていきます。

object Person {
  // Scalaでは、型名が長くなることが多いので、よく使うものはtypeで定義しておくとよい。
  type ViolationNelA[A] = NonEmptyList[ConstraintViolation[A]]
  type ViolationNel = ViolationNelA[Person]
  type ValidationB[B] = Validation[ViolationNel, B]

  // javax.validationパッケージからBeanValidationのValidatorを取得。
  val validator = {
    // Validationがscalaz.Validationと重なるので、BeanValidationという名前で取り込む。
    import javax.validation.{Validation => BeanValidation}
    val vf = BeanValidation.buildDefaultValidatorFactory()
    vf.getValidator()
  }

  def isValid(p: Person) = validate(p).isEmpty

  // Bean Validatorを使って、Personオブジェクトの検証を行う。
  // asScala.toListでScalaのListに格納する。
  // ConstraintViolationはBean Validatorが検出した異常情報。
  def validate(p: Person): List[ConstraintViolation[Person]] = {
    validator.validate(p).asScala.toList
  }

  // 指定された値からPersonオブジェクトを生成する。成功した場合はscalaz.SuccessにPersonオブジェクトを、失敗した場合はscalaz.FailureにConstraintViolationのリストを格納する。
  // scalaz.Successとscalaz.FailureはScalaz.Validationのサブクラスでそれぞれ検証の成功と失敗を示す。
  // この関数でBean Validationの結果をScalaz Validation化している。
  def createV(name: String, age: Int, address: Option[String]): ValidationB[Person] = {
    val p = new Person(name, age, address)
    validate(p) match {
      case vs if vs.isEmpty => p.success
      // vs.toNel.get.failといったものがScalaz的な書き方。コンパクトに記述できる。
      case vs => vs.toNel.get.fail
    }
  }

  // Bean Validationは、Java Beanに値を設定した後にしか使えない。
  // Webアプリなどで、生文字列からJava Beansを生成する場合、文字列が適切な値に変換できないため、そもそもJava Beansを生成できないケースもある。そのケースを取り扱うため、文字列からの値変換に失敗した場合はその時点でエラー、Java Beansを生成後はBean Validatorで検証し、いずれの場合も結果はscalaz.Validationで通知する。
  def createVFromStrings(name: String, age: String, address: String): Validation[NonEmptyList[String], Person] = {
    // 型名が長くなりがちなので、内部関数やvalで吸収する。
    // 名前はコメントをつける気持ちでつけるとよい。
    def parseint(s: String, name: String) = s.parseInt match {
      case Success(a) => a.success[NonEmptyList[String]]
      case Failure(e) => violationmsg(name, s, e.getMessage).fail.liftFailNel
    }
    def parsestring(a: String, name: String) = a.success[String].liftFailNel
    def parseoption[T](a: Option[T], name: String) = a.success[String].liftFailNel
    def constraintviolation2string(cv: ConstraintViolation[Person]) = {
      violationmsg(cv.getPropertyPath.toString, cv.getInvalidValue.toString, cv.getMessage)
    }
    def violationmsg(path: String, value: String, msg: String) = {
      "%s = %s: %s".format(path, value, msg)
    }
    // ロジックの中心は内部関数を使ってコンパクトに書く
    (parsestring(name, "name") |@| parseint(age, "age") |@| parseoption(Option(address), "address"))(createV(_, _, _)) match {
      case Success(Success(a)) => a.success[String].liftFailNel
      case Success(Failure(e)) => e.map(constraintviolation2string(_)).fail
      case Failure(e) => e.fail[Person]
    }
  }

  // List[ValidationB[Person]]をValidationB[List[Person]]に変換。型クラスTraverseと似たような動き。
  // TraverseでValidationをうまく扱うことができなかったので、foldrで実装してみた。
  // 「(s <**> p)(_ :: _)」の所がapplicative styleのプログラミング。ValidationがSuccessの場合に走るロジック(正常処理)を記述する。ValidationがFailureの場合(異常処理)は、applicative functorであるValidationのコンテナ側が実装している裏ロジック(?)が走って、monoidとして実現されているエラー情報を蓄積していく。
// applicative functorのメカニズムを用いることでプログラマが記述する正常系ロジックとValidationが自動的に実行する異常系ロジックを綺麗に分離できる。
  def sequenceV(persons: List[ValidationB[Person]]): ValidationB[List[Person]] = {
    persons.foldr(mzero[List[Person]].success[ViolationNel])((s, p) => (s <**> p)(_ :: _))
  }
}

applicative functorはfunctorとmonadの中間に位置する型クラスです。それぞれ計算の文脈(コンテナ)の扱いに違いが出てきます。functorはピュアなアプリケーションロジック実行後ピュアな文脈(コンテナ)を生成、monadはアプリケーションロジックが文脈(コンテナ)を操作するのに対して、applicative functorはピュアなアプリケーションロジックの裏で暗黙的に文脈(コンテナ)が引き継がれていきます。このapplicative functorの有名な応用がValidationで、実際に触ってみるとピュアなアプリケーションロジックをベースに文脈(コンテナ)依存の処理を進められるapplicative styleのプログラミングがなかなか便利なことが分かります。

ValidationMatchers

Bean ValidationをScalaプログラムで扱うときは、ScalaTestのボキャブラリとなるカスタムマッチャーを作っておくと便利です。Bean ValidationとScalaz Validationを併用するので、両方の機能を包含したValidationMatchersを定義することにします。

package advent2011

import org.scalatest.matchers._
import javax.validation.ConstraintViolation
import scalaz._
import Scalaz._

// アプリケーションロジックのためのボキャブラリの追加はtraitの典型的な使い方の一つ。
trait ValidationMatchers {
  // Bean Validation用のボキャブラリ
  def containViolations(violations: List[(String, String)]) = {
    ContainViolationsMatcher(violations)
  }

  // Scalaz Validation用のボキャブラリ
  object success extends ValidationSuccessMatcher
  def fail(messages: List[String]) = {
    ValidationFailMatcher(messages)
  }
}

// Bean Validation用のマッチャー。
// BeMatcherやMatcherをextendsして、applyメソッドを定義するだけなので非常に簡単。
// テスト用のボキャブラリを簡単に追加できる。
case class ContainViolationsMatcher(violations: List[(String, String)]) extends BeMatcher[List[ConstraintViolation[_]]] {
  def apply(value: List[ConstraintViolation[_]]) = {
    def iscontain(nm: (String, String)) = {
      val (name, message) = nm
      value.any(v => v.getPropertyPath.toString == name && v.getMessage == message)
    }
    // allはscalazの型クラスFoldableの関数。関数名に∀の記号を使うこともできる。
    val result = violations.all(iscontain)
    MatchResult(result, "does not contains expected violation", "contains expected violation")
  }
}

// Scalaz Validation用のマッチャー。Successの判定をする。
case class ValidationSuccessMatcher() extends Matcher[Validation[NonEmptyList[String], _]] {
  def apply(value: Validation[NonEmptyList[String], _]) = {
    val result = value.isSuccess
    MatchResult(result, "failure", "success")
  }
}

// Scalaz Validation用のマッチャー。Failureの判定をする。
case class ValidationFailMatcher(messages: List[String]) extends Matcher[Validation[NonEmptyList[String], _]] {
  def apply(value: Validation[NonEmptyList[String], _]) = {
    value match {
      case Success(a) => MatchResult(false, "incorrect success", "")
      case Failure(e) => if (e.all(messages.contains)) {
        MatchResult(true, "", "correct failure")
      } else {
        MatchResult(false, "incorrect failure", "")
      }
    }
  }
}

PersonSpec

最後に、Personの使い方をScalatestのWordSpecで書いてみました。WordSpecはBDD(Behavior Driven Development)スタイルのSpecを記述するためのクラスです。Scalatest標準のShouldMatchersに加えて、先ほど作成したValidationMatchersのボキャブラリを追加しています。
ScalatestのBDDは、テスト用のアプリケーションロジックと結果判定がコーディングスタイル上明確に分離できるので、プログラムの視認性が高くなります。また、カスタムマッチャーを作り足すことで、より英文っぽい記述が可能になるので、そのあたりの遊び的な要素がプログラミングを進める上で良い感じです。

class PersonSpec extends WordSpec with ShouldMatchers with ValidationMatchers {
  "A Person" should {
    "provide isValid and validate operation" that {
      "against valid Person" in {
        val p = new Person("taro", 30, "Yokohama".some)
        Person.isValid(p) should be (true)
        Person.validate(p) should be === Nil
      }
      "against invalid Person" in {
        val p = new Person("", -150, null)
        val expected = List("address" -> "may not be null",
                            "name" -> "size must be between 1 and 2147483647",
                            "age" -> "must be greater than or equal to 0")
        Person.isValid(p) should be (false)
        Person.validate(p) should have length (expected.size)
        // 追加したボキャブラリを使用
        Person.validate(p) should be (containViolations(expected))
      }
    }
    "provide createV to create Person with Validation." that {
      // アプリケーションロジックを普通のScalaプログラム的に書いた場合。
      "Plain usage." in {
        val taro = Person.createV("taro", 30, Some("Yokohama"))
        val hanako = Person.createV("hanako", 25, Some("Kamakura"))
        if (taro.isSuccess && hanako.isSuccess) {
          val tage = taro match {
            case Success(p) => p.age
          }
          val hage = hanako match {
            case Success(p) => p.age
          }
          val avg = (tage + hage) / 2.0
          avg should be (27.5)
        } else {
          sys.error("invalid")
        }
      }
      // アプリケーションロジックをScalazのapplicative styleで書いた場合。
      // よりコンパクトで分かりやすく記述できる。
      "Scalaz usage, applicative style." in {
        val taro = Person.createV("taro", 30, Some("Yokohama"))
        val hanako = Person.createV("hanako", 25, Some("Kamakura"))
        val avgv = (taro <**> hanako)((x, y) => (x.age + y.age) / 2.0)
        // この段階までValidationの文脈(コンテナ)の上で計算が進んでいる。
        // 以下のmatch式で、アプリケーションロジックが正常に動作した場合と、エラーがある場合を分離して、それぞれのロジックを記述している。
        avgv match {
          case Success(avg) => avg should be (27.5)
          case Failure(e) => sys.error("invalid")
        }
      }
    }
    "provide createV and sequenceV for applicative style." that {
     // ValidationのListを扱う場合2例。いずれもapplicative style。
      "Use sequenceV to convert List[Validation[Person]] to Validation[List[Person]] " in {
        val taro = Person.createV("taro", 30, Some("Yokohama"))
        val hanako = Person.createV("hanako", 25, Some("Kamakura"))
        val jiro = Person.createV("jiro", 35, Some("Tokyo"))
        val persons = List(taro, hanako, jiro)
        val personsv = Person.sequenceV(persons)
        val avgv = personsv.map(x => x.map(_.age).sum.toFloat / x.length)
        avgv match {
          case Success(avg) => avg should be (30.0)
          case Failure(errors) => sys.error("invalid")
        }
      }
      "Use foldl to sum of age" in {
        val taro = Person.createV("taro", 30, Some("Yokohama"))
        val hanako = Person.createV("hanako", 25, Some("Kamakura"))
        val jiro = Person.createV("jiro", 35, Some("Tokyo"))
        val persons = List(taro, hanako, jiro)
        val sumv = persons.foldl(0.success[Person.ViolationNel])((s, p) => (s <**> p)(_ + _.age))
        val avgv = sumv.map(_.toFloat / persons.length)
        avgv match {
          case Success(avg) => avg should be (30.0)
          case Failure(errors) => sys.error("invalid")
        }
      }
    }
    "provide createVFromStrings to create Person from plain strings." that {
      "Valid parameters." in {
        val person = Person.createVFromStrings("taro", "30", "Yokohama")
        // 追加したボキャブラリを使用
        person should success
      }
      "Invalid parameters of type mismatch." in {
        val person = Person.createVFromStrings("", "a", "Yokohama")
        val expected = List("""age = a: For input string: "a"""")
        // 追加したボキャブラリを使用
        person should fail(expected)
      }
      "Invalid parameters of invalid value." in {
        val person = Person.createVFromStrings("", "30", "Yokohama")
        val expected = List("""name = : size must be between 1 and 2147483647""")
        // 追加したボキャブラリを使用
        person should fail(expected)
      }
    }
  }
}
実行結果は以下のようになります。トップレベル, that, inの三層でテストを整理できるのがなかなか便利です。
[info] PersonSpec:
[info] A Person 
[info]   should provide isValid and validate operation that 
[info]   - against valid Person
[info]   - against invalid Person
[info]   should provide createV to create Person with Validation. that 
[info]   - Plain usage.
[info]   - Scalaz usage, applicative style.
[info]   should provide createV and sequenceV for applicative style. that 
[info]   - Use sequenceV to convert List[Validation[Person]] to Validation[List[Person]] 
[info]   - Use foldl to sum of age
[info]   should provide createVFromStrings to create Person from plain strings. that 
[info]   - Valid parameters.
[info]   - Invalid parameters of type mismatch.
[info]   - Invalid parameters of invalid value.
[info] Passed: : Total 9, Failed 0, Errors 0, Passed 9, Skipped 0
[success] Total time: 2 s, completed 2011/12/23 11:54:09

まとめ

Bean ValidationとScalaz Validationを併用する方法について試行してみました。
Bean Validation用のアノテーションとScalaの相性に若干問題があるようですが、プログラミング的には特に問題なくシームレスに繋げることが確認できました。

また、Scalaz Validationの実現技術であるapplicative functorによるapplicative styleによるプログラミング、ScalatestによるBDDという技術も合わせて使ってみました。いずれもJavaでは実用化が難しい技術で、Scalaを使うメリットですね。

今回使用した技術は以下のものになります。合わせてプログラミングしてみてJava EE web profile技術、Scalaz、ScalaTestによるTDD/BDDといったところがScalaプログラミングのベースになりそう、という感を強くしました。

  • Bean Validation
  • Scalaz Validation
  • Scalaz applicative functor (applicative style)
  • Scalatest BDD
  • Scalatest カスタムマッチャー

0 件のコメント:

コメントを投稿