読者です 読者をやめる 読者になる 読者になる

バリデーションライブラリ clarifyの紹介 - Re.Ra.Ku アドベントカレンダー day 14

Re.Ra.Ku アドベントカレンダー 14日目です。

こんにちは。ヘルステックチームの近藤です。

突然ですがみなさんバリデーションしていますか?病めるときも健やかなるときも、アプリケーションサービスを書くときもドメインモデルを書くときも、バリデーションはなんだかんだでせざるを得ないですよね?

当然私も日々バリデーションしているわけですが、いろいろと思うところがあり、自分でバリデーション用のライブラリを書きました。今回はそれを紹介します。

バリデーションライブラリ clarifyのコンセプト

そのバリデーションライブラリはclarifyという名前で、とあるコンセプトに沿って作られています。それは「値を与えたらバリデーション結果を返す」というものです。

まあ当たり前のことなんですが、じゃあ反対にやりたくなかったのは:

  • とあるtraitを継承し
  • DSLにてバリデーションの内容を定義したり
  • validateメソッドでそのオブジェクトが妥当かどうかを確かめる

みたいなやつです。具体的な例で言うとActiveModel::Validationsのようなものです。

何故それをやりたくなかったかと言うと、8日目のリラクのサーバサイド事情 with Scala / ドメイン編でも触れた「ドメインにフレームワークを持ち込みたくない」という事情に起因します。ドメインにおける表現の自由度をバリデーションによって奪いたくありません。

また、バリデーションは「外側の値(ユーザが入力したもの、APIにて受け取ったものなど)が内側のルールに即しているか」を確かめるものです。故にどこにでも現れる処理だと思います。アプリケーションサービスではクライアントから与えられた値が妥当かどうか、ドメインモデルではドメインに沿っているかどうか……。冒頭で言った「アプリケーションサービスを書くときもドメインモデルを書くときも」というのはただノリで言ったわけではなく、本当にそうだと思っています。

そういうことなので、取り回しが利きやすい「値を与えたらバリデーション結果を返す」という単なる「関数」である形を目指しています。

インストール

じゃあ実際にどう使うのかを紹介します。とは言えこのライブラリ、とりあえず私自身が使えるようにしたかったものなので、ドキュメントは雑ですし、テストもなく、配布もただ.jarファイルを置いただけという代物です。なのでまあこういうものがあるんだーーーぐらいのものです。

とりあえず.jarは下記の場所にあります。

https://takkkun.github.io/clarify/org/usagram/clarify_2.11/0.0.4/clarify_2.11-0.0.4.jar

sbtを使用している場合はlibraryDependenciesに下記を加えてあげればOKです。

"org.usagram" %% "clarify" % "0.0.4" from "https://takkkun.github.io/clarify/org/usagram/clarify_2.11/0.0.4/clarify_2.11-0.0.4.jar"

Validator

clarifyは「値を与えたらバリデーション結果を返す」に終始していると言いましたが、その最小単位はValidatorと呼ばれるものです。

NotBlank Validatorは文字列が空じゃないかどうか、GreaterThanOrEqualTo Validatorは数値がとある数値以上かどうかを確かめるものです。

import org.usagram.clarify.validator._

val nameValidator: Validator[String] = NotBlank

println(nameValidator.validate("Kondo")) // => no error
println(nameValidator.validate("")) // => CannotBeBlank error

val ageValidator: Validator[Int] = GreaterThanOrEqualTo(0) // GreaterThanOrEqualTo.zero とも書ける

println(ageValidator.validate(17)) // => no error
println(ageValidator.validate(-1)) // ShouldBeGreaterThan(0) error

コレクションのすべての要素が条件を満たしているかを確かめるEach Validatorなんてのもあります。

val tagsValidator: Validator[Set[String]] = Each(NotBlank)

println(tagsValidator.validate(Set("programmer", "scala"))) // => no error
println(tagsValidator.validate(Set("programmer", ""))) // => ErrorAt(1, CannotBeBlank) error

これらはすべてValidator[-V]トレイトのサブクラスで、 validate(value: V): Iterable[Error] (Errorはclarifyで定義しているtrait)を実装しているだけです。もちろんオリジナルのValidatorを作ることも出来ます。

import org.usagram.clarify.validator.Validator
import org.usagram.clarify.error.Error
import org.usagram.clarify.Tags

class StartWith(prefix: String) extends Validator[String] {
  def validate(value: String) =
    failIf(!value.startsWith(prefix)) { // 第1引数の条件を満たしたら第2引数のエラーを返すfailIfというヘルパー
      NotStartWith(prefix)
    }
}

// Validatorそれぞれに専用のエラーを設ける場合はErrorを継承し、
// そのエラーのメッセージを返す message(tags: Tags): String メソッドを実装する必要がある。
//
// Tagsはバリデーションした値に付与されたメタデータ。
// 値に名前を割り振るためのlabelなどがある。

case class NotStartWith(prefix: String) extends Error {
  def message(tags: Tags) = {
    val label = tags.label getOrElse "(no label)"
    s"$label is not start with $prefix"
  }
}

どうでしょう?十分にシンプルかなーと思います。

Validator同士を合成して大きなValidatorを作る

Validatorたったひとつでは現実世界の要望を満たせないことが多いと思います。文字列においても大体「空ではなく、かつ20文字以内」みたいなのがほとんどじゃないでしょうか?

というわけで、関数合成のようにValidator同士を合成することでその要望に応えています。

val nameValidator: Validator[String] = NotBlank && AtMostCharacters(20) // AtMost(20).characters とも書ける

println(nameValidator.validate("")) // => CannotBeBlank error
println(nameValidator.validate("very very very long name")) // => TooLongString(20) error

val tagsValidator: Validator[Set[String]] = Each(NotBlank) && AtLeast(1) // AtLeast.one とも書ける

println(tagsValidator.validate(Set("programmer", ""))) // => ErrorAt(1, CannotBeBlank) error
println(tagsValidator.validate(Set.empty)) // => RequireAtLeast(1) error

&& というメソッドがそれです。論理演算のそれをイメージしてもらえればと思います。一応「または」を意味する /\ というメソッドもあるのですが、個人的にあまり使う機会がないため動作をやや決め切れてなかったりします*1

ComplexValidatorN

「値を与えたらバリデーション結果を返す」についてはValidatorで十分実現できています。ですが、とある値の集合にたいしてバリデーションを行い、すべてのバリデーションを実行後、ひとつでもエラーとなった場合はバリデーション失敗にしたい、というのはよくあります。

Validatorだけでそれを行うことは不可能ではありませんが、ひとつひとつValidatorに値を与え、最後にまとめるのを何度も書くのはしんどいものがあります。

それを解決するのがComplexValidatorNというものです。Nには1から22までの数字が入ります。Validator同士を <-> というメソッドで繋ぐことでComplexValidatorNを作ることが出来ます。下記のケースではValidatorを3つ繋いでいるので、ComplexValidator3が作られます。

class Person(val name: String, val age: Int, val tags: Set[String])

val personValidator = nameValidator <-> ageValidator <-> tagsValidator

// valid case
{
  val name = "Kondo"
  val age = 17
  val tags = Set("programmer", "scala")

  val result = personValidator(
    Indefinite(name) label "name",
    Indefinite(age) label "age",
    Indefinite(tags) label "tags"
  )

  println(result.isValid) // => true
  
  // result.isValid が true の場合、 result.resolve[A] に
  // 与えた関数 (String, Int, Set[String]) => A が呼ばれ、
  // その実行結果を result.resolve[A] の結果として返す。
  val person = result.resolve { (n, a, t) => new Person(n, a, t) }
}

// invalid case
{
  val name = ""
  val age = 17
  val tags = Set("programmer", "")
  
  val result = personValidator(
    Indefinite(name) label "name",
    Indefinite(age) label "age",
    Indefinite(tags) label "tags"
  )
  
  println(result.isValid) // => false
  println(result.invalidValues) // => Invalid("", Tags("name"), CannotBeBlank), Invalid(Set("programmer", ""), Tags("tags"), ErrorAt(1, CannotBeBlank))
  
  // result.isValid が false の場合、 result.resolve[A] は
  // InvalidValueException 例外を発生させる。
  val person = result.resolve { (n, a, t) => new Person(n, a, t) }
}

ここで出てきたIndefiniteですが、これは値の状態を表しています。ComplexValidatorNにおいて値は必ず以下の4種類の状態を取る必要があります。

  • Indefinite: 値がまだバリデーションされていない状態
  • Valid: 値がバリデーションされ、有効だった状態
  • Invalid: 値がバリデーションされ、無効だった状態
  • Unknown: ComplexValidatorNによってバリデーションが実行されたが、バリデーションの結果が不明な状態

なぜComplexValidatorNにおいて値を直接与えず、状態を持たせる必要があるか。それはバリデーションを強制するためにあります。さきほどのComplexValidatorNによるバリデーション例を、Personのコンパニオンオブジェクトにて行うよう書き換えてみます。

class Person private (val name: String, val age: Int, val tags: Set[String])

object Person {
  val personValidator = nameValidator <-> ageValidator <-> tagsValidator
  
  def apply(name: Indefinite[String], age: Indefinite[Int], tags: Indefinite[Set[String]]): Person =
    personValidator(
      name label "name",
      age label "age",
      tags label "tags"
    ).resolve { (n, a, t) => new Person(n, a, t) }
}

val validPerson = Person(
  Indefinite("Kondo"),
  Indefinite(17),
  Indefinite(Set("programmer", "scala"))
)

val invalidPerson = Person(
  Indefinite(""),
  Indefinite(17),
  Indefinite(Set("programmer", ""))
)

ミソはPerson objectのapplyメソッドにおいてIndefiniteを指定している点です。こう指定されていてはapplyメソッドで受け取った値をそのままnew Personとすることが出来ません。Indefinite[String]とStringは当然異なる型ですからね。Indefinite[String]からStringを抜き出すにはComplexValidatorNによるバリデーションを通過させる必要があります。と、こんな感じでバリデーションを強制しています*2

Indefinite, Valid, Invalidの3つは想像が付きやすいと思うのですが、Unknownはなんでしょうか。これはComplexValidatorNを作るためのもうひとつのメソッド --> と関係があります。

値の集合をバリデーションするときにどうしても発生し得るのが、「値Bは値A以上であること」などのような、他の値に依存するケースです。まあ普通にValidatorで値Aを参照してあげれば良いのですが、バリデーション前の値はIndefiniteで包まれている以上、直接参照できません。そこで使うのが --> というメソッドです。

val aValidator: Validator[Int] = GreaterThanOrEqualTo(0)

val bValidator: Int => ComplexValidator1[Int] = { a =>
  GreaterThanOrEqualTo(a)
}

val validator = a --> b

// a: valid, b: invalid
{
  val result = validator(
    Indefinite(1) label "a",
    Indefinite(0) label "b"
  )
  
  println(result.isValid) // => false
  println(result.values) // => Valid(1, Tags("a")), Invalid(0, Tags("b"), ShouldBeGreaterThanOrEqualTo(1))
}

// a: invalid
{
  val result = validator(
    Indefinite(-1) label "a",
    Indefinite(2) label "b"
  )
  
  println(result.isValid) // => false
  println(result.values) // => Invalid(-1, Tags("a"), ShouldBeGreaterThanOrEqualTo(0)), Unknown(2, Tags("b"))
}

--> メソッドは左辺のComplexValidatorN (Validator)によってバリデーションされた結果がValidの場合、その値を右辺の関数( Int => ComplexValidator1[Int] )に与えます。そしてその関数から得られたComplexValidatorNを用いてさらにバリデーションを行う。といった具合に動作します。

もし左辺の結果がInvalidの場合はどうなるか。左辺がInvalidだとすると、当然Invalidな値からComplexValidatorNが作られることになるのですが、そんなComplexValidatorNでバリデーションしても意味がありません。ですので、右辺の結果はバリデーションの結果が不明ということでUnknownとなります。

まとめ

ということでclarifyでどんなことが出来るかを紹介しました。サンプル付きで長々と書いていますが、まとめると「値を与えたらバリデーション結果を返す」ということです。ので、比較的導入しやすいとは思います。まあ第三者が導入するためにはドキュメントを整え(この記事がドキュメントで良いのでは?)、なによりテストを書かなければいけませんが……。

ともかく、私は実際に使っているのですが、使い勝手はなかなか良く、自分で言うのもなんですが良いものが出来たと思っています。その上で「ビルトインのValidatorはさすがにもっとあっていいわ……」とか「Indefiniteで包むのさすがにめんどうだなー。ここに限ってはimplicit conversionがあってもいいな」とか考えています。

余談ですが2016年7月2日にあったYAP(achimon)C::Asia Hachioji 2016 mid in Shinagawaにて下記の発表をしました。

この発表で言っている内容自体はclarifyと直接関係がないのですが、この発表で言っていることをやるために軽量なバリデーションライブラリが欲しかったという経緯があったりします。興味がありましたら是非ご覧くださいませ。それでは。

*1:一応説明すると / は左辺および右辺のValidator双方とも条件を満たさない場合、右辺のエラーのみを全体のエラーとして採用する(左辺のエラーは結果に含めない)、というものです。 \ は左辺を採用するものです。ただ今思えば素直に両方ともエラーを返しても良い気がしますし、 And(leftError, rightError) という形で包んであげても良かったかな……

*2:まあapplyメソッドにIndefiniteを指定しない場合はどうしようもないのですが……