Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

Eitherを使ったSpecificationパターンで遊ぶ + 仕様を通過するたびに型が弱まっていくのはどうか

DDDにSpecificationパターンというのがある。仕様をドメインモデルから切り離して、単独で扱えるようにしましょう、というもの。

素朴にScalaで実装すると……

trait Spec[A] {
  def isSatisfiedBy(a: A): Boolean
}

こうなる。具体的にSpecを実装してみると、以下のような感じ。

case class User(name: String, age: Int)

trait UserSpec extends Spec[User] {}

object NameLengthSpec extends UserSpec {
  def isSatisfiedBy(u: User) = u.name.length >= 3 && u.name.length <= 15
}

object AdultUserSpec extends UserSpec {
  def isSatisfiedBy(u: User) = u.age >= 20
}

民法が改正されてもAdultSpecだけ書き換えればよいので便利。

object AdultUserSpec extends UserSpec {
  def isSatisfiedBy(u: User) = u.age >= 18
}

Specの合成

仕様があるのであれば合成できるようにしたい。素朴にやってみると、AndSpecというメタ仕様を作ることになる。

final class AndSpec[A](s: Spec[A], t: Spec[A]) extends Spec[A] {
  def isSatisfiedBy(a: A) = s.isSatisfiedBy(a) && t.isSatisfiedBy(a)
}

Sugar syntaxも用意しておこう:

trait Spec[A] {
  def &&(s: Spec[A]): Spec[A] = new AndSpec(this, s)
}

すると2つの仕様を合成できるようになった。

val canRegisterSpec= NameLengthSpec && AdultUserSpec
val u = User("windymelt", 28)
canRegisterSpec.isSatisfiedBy(u) // true

たくさんSpecがあるとき

ある日あなたは新規サービスを立ち上げることになった。ユーザ登録の条件はまあまあ面倒だ。

  • 成人していなければ、身分証が提示されていなければならない
  • 成人していれば、身分証は不要
  • 成人しているかにかかわらず、名前は3文字以上15文字以下
  • 成人しているかにかかわらず、名前にadminを含むことはできない

Userは以下の通り。

case class User(name: String, age: Int, idCard: Option[String])

面倒なことになってきた。ベタ書きで条件を定義してもよいが、仕様とその実行とは分離したいから、がんばって実装する。

まず、どこの仕様でコケているのかを知れるように、BooleanではなくEither[Spec[A], Unit]を返すようにする。合成力を上げるために述語も増やしておこう。

trait Spec[A] {
  def isSatisfiedBy(a: A): Either[Spec[A], Unit] = {
    val result: Boolean = this.isSatisfiedByBool(a)
    result match {
      case true  => Right(())
      case false => Left(this)
    }
  }
  def isSatisfiedByBool(a: A): Boolean
  def &&(s: Spec[A]): Spec[A] = new AndSpec(this, s)
  def ||(s: Spec[A]): Spec[A] = new OrSpec(this, s)
  def negate: Spec[A] = new NotSpec(this)
}

class AndSpec[A](s: Spec[A], t: Spec[A]) extends Spec[A] {
  override def isSatisfiedBy(a: A) = s.isSatisfiedBy(a).flatMap(_ => t.isSatisfiedBy(a))
  def isSatisfiedByBool(a: A) = this.isSatisfiedBy(a).isRight
}

class OrSpec[A](s: Spec[A], t: Spec[A]) extends Spec[A] {
  def isSatisfiedByBool(a: A) = s.isSatisfiedByBool(a) || t.isSatisfiedByBool(a)
}

class NotSpec[A](s: Spec[A]) extends Spec[A] {
  def isSatisfiedByBool(a: A) = ! s.isSatisfiedByBool(a)
}

だんだん、DSLを書いているという意識が高まってくる。この調子で仕様を定義してみよう。

val AdultUserSpec: UserSpec = { _.age >= 18 }
val NotAdultUserSpec = AdultUserSpec.negate
val NameLengthSpec: UserSpec = { u => u.name.length >= 3 && u.name.length <= 15 }
val BannedNameSpec: UserSpec = { ! _.name.contains("admin") }
val IdentifiedSpec: UserSpec = { _.idCard.isDefined }
val ChildUserSpec = NotAdultUserSpec && IdentifiedSpec

val canRegisterSpec = NameLengthSpec && (AdultUserSpec || ChildUserSpec)  && BannedNameSpec

なんとか読める状態で仕様を定義できている。objectの代わりにちょっと変わった記法を使っているが、これはSAM変換というもの。1つだけabstract methodがあるようなclass/traitは、このような表記でinstantiateすることができる。

この仕様が動くかどうか試してみよう。

val u = User("windymelt", 28, None)
val v = User("boymelt", 10, None)

canRegisterSpec.isSatisfiedBy(u) // Right(())
canRegisterSpec.isSatisfiedBy(v) // Left(OrSpec(...))

boymeltがコケている。コケているのはいいとして、どこでコケたのか一見分かりづらい。OrSpecisSatisfiedByを分かりやすく実装し直してみる。

class OrSpec[A](s: Spec[A], t: Spec[A]) extends Spec[A] {
  override def isSatisfiedBy(a: A) = {
    val sResult = s.isSatisfiedBy(a)
    if (sResult.isRight) return Right(())
    t.isSatisfiedBy(a)
  }
  def isSatisfiedByBool(a: A) = s.isSatisfiedByBool(a) || t.isSatisfiedByBool(a)
}

// ...

// objectにしないとエラー理由が分かりにくいのでSAM変換を戻した
object AdultUserSpec extends UserSpec { def isSatisfiedByBool(u: User) = u.age >= 18 }
val NotAdultUserSpec = AdultUserSpec.negate
object NameLengthSpec extends UserSpec { def isSatisfiedByBool(u: User) = u.name.length >= 3 && u.name.length <= 15 }
object BannedNameSpec extends UserSpec { def isSatisfiedByBool(u: User) = ! u.name.contains("admin") }
object IdentifiedSpec extends UserSpec { def isSatisfiedByBool(u: User) = u.idCard.isDefined }
val ChildUserSpec = NotAdultUserSpec && IdentifiedSpec

val canRegisterSpec = NameLengthSpec && (AdultUserSpec || ChildUserSpec)  && BannedNameSpec

するとコケている理由が分かるようになる。

val v = User("boymelt", 10, None)

canRegisterSpec.isSatisfiedBy(v) // Left(IdentifiedSpec)

AdultUserSpecChildUserSpecが排他であることが分かりにくいので、Specの判定結果をもとに2つのうちいずれかのSpecを選択するConditionalSpecを導入してみる。

case class ConditionalSpec[A](pred: Spec[A], trueSpec: Spec[A], falseSpec: Spec[A]) extends Spec[A] {
  override def isSatisfiedBy(a: A) = {
    pred.isSatisfiedByBool(a) match {
      case true => trueSpec.isSatisfiedBy(a)
      case false => falseSpec.isSatisfiedBy(a)
    }
  }
  def isSatisfiedByBool(a: A) = pred.isSatisfiedByBool(a) match {
    case true => trueSpec.isSatisfiedByBool(a)
    case false => falseSpec.isSatisfiedByBool(a)
  }
}

AdultSpecを満たしている場合は何もする必要がないので、常に成功するPassSpecも定義しておく。

case class PassSpec[A]() extends Spec[A] {
  def isSatisfiedByBool(x: A) = true
}

仕様が少し分かりやすくなった:

val ChildIdentifySpec = ConditionalSpec(AdultUserSpec, PassSpec(), IdentifiedSpec)
val canRegisterSpec = NameLengthSpec && ChildIdentifySpec && BannedNameSpec

val v = User("boymelt", 10, None)

canRegisterSpec.isSatisfiedBy(v) // Left(IdentifiedSpec)

全て&&でつないだ形にできて美しい感じがする。さらにcatsのValidatedなどを導入すると複数のエラーを同時に報告することができそう。この状態だと、Spec自体のテストができるというメリットがある。

そもそも仕様の難しさとは

仕様は時として矛盾していたり、未定義の箇所があったりする。また判定順序に依存性があったりするからさらに問題が複雑になってしまうことがしばしばある。順序に依存性があると合成がやりにくくなる。そして大抵の場合、仕様には複数のドメインモデルがかかわっているし、Specificationパターンは本来そのためのもの……。

  • 順序を持たない合成を行いたい => Specをモノイドにするとよさそう
    • たぶんそのまま突き詰むとブール環になってしまう。俺がやりたいのはそういうことなのか??
  • 仕様が矛盾しないようにしたい => 矛盾とは??
  • 極論するとすべてのパターンの組み合わせを考える(matrixをつくる)ということになる
    • すべてのパターンを考えたくないはず
    • 条件考慮に利用する属性は一度しか使えないという制約を課すとどうなるか。制約を課すことで頑健な仕様にならないか
      • 年齢が18以上のとき、という条件を一度作ったなら、別の箇所で「年齢が……」という条件を作成できない
      • Rustの所有権みたいな話になってきたけどそういう話のはず(??)
      • 最初はUser with Age.Field with Name.Field with IdCard.Fieldみたいな型になっているが、仕様を通過するたびに型が弱まっていってAgeNameを失っていくから一発でキメないともう呼べないという感じにすると面白い

型が弱くなっていく仕様は面白いと思う。合成するたびに仕様で使った型が失われていって、合成の最後らへんになると何も判定できなくなっていく。

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?