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
がコケている。コケているのはいいとして、どこでコケたのか一見分かりづらい。OrSpec
のisSatisfiedBy
を分かりやすく実装し直してみる。
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)
AdultUserSpec
とChildUserSpec
が排他であることが分かりにくいので、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
みたいな型になっているが、仕様を通過するたびに型が弱まっていってAge
やName
を失っていくから一発でキメないともう呼べないという感じにすると面白い
型が弱くなっていく仕様は面白いと思う。合成するたびに仕様で使った型が失われていって、合成の最後らへんになると何も判定できなくなっていく。