Scalaを例に「仕様」パターンを実装する
この記事はFOLIO Advent Calendar 2018の6日目です。
FOLIOでも使っている「仕様」パターンをScalaで実装する方法について紹介します。
「仕様」パターンとは
Eric Evans氏とMartin Fowler氏による仕様パターンに関する論文があります。 https://martinfowler.com/apsupp/spec.pdf ※PDFです
また、DDD本では次のように紹介されています。
特殊な目的を持った述語的な値オブジェクトを明示的に作成すること。仕様とは、あるオブジェクトが何らかの基準を満たしているかどうかを判定する述語である。
(中略)
- オブジェクトを検証して、何らかの要求を満たしているか、何らかの目的のための用意ができているかを調べる。
- コレクションからオブジェクトを選択する(期限が超過した請求書を問い合わせる場合など)。
- 何かの要求に適合する新しいオブジェクトの生成を定義する。
検証、選択、要求に応じた構築という、これら3つの用途は、概念レベルでは同じものである。
今回は1点目のバリデーションに使うケースで考えます。
コード例
まずは基底となるコードです。以降のコードは Scala 2.12.7 で動作確認しています。
※実コードよりも簡便化しています。
sealed abstract class SpecResult[+T] { def map[U](f: T => U): SpecResult[U] = { this match { case Satisfied(value) => Satisfied(f(value)) case NotSatisfied(message) => NotSatisfied(message) } } def flatMap[U](f: T => SpecResult[U]): SpecResult[U] = { this match { case Satisfied(value) => f(value) case NotSatisfied(message) => NotSatisfied(message) } } } object SpecResult { def apply[T](value: T): SpecResult[T] = Satisfied(value) def cond[T](test: Boolean, value: => T, message: => String): SpecResult[T] = if (test) Satisfied(value) else NotSatisfied(message) } final case class Satisfied[+T](value: T) extends SpecResult[T] final case class NotSatisfied(message: String) extends SpecResult[Nothing] trait Specification[T] extends Function[T, SpecResult[T]] { def applyAsRequirement(candidate: T): Unit = { apply(candidate) match { case NotSatisfied(message) => throw new RuntimeException(message) case _: Satisfied[_] => // ignore } } def and(spec: Specification[T]): Specification[T] = { value: T => self(value).flatMap(spec.apply) } def or(spec: Specification[T]): Specification[T] = { value: T => self(value) match { case Satisfied(v) => Satisfied(v) case NotSatisfied(_) => spec(value) } } }
仕様の表明が重要な意味をもつ値オブジェクトに適用する例
たとえば、複数のデータソースから取得したデータをアプリケーション内部で利用しやすいように正規化などを行うようなシーンでは、その正規化された結果はどのようなものであるかを仕様として定義しておくと意図が明確になります。
/** 請求書番号仕様 */ class InvoiceCodeSpec extends Specification[String] { def apply(value: String): SpecResult[String] = { SpecResult.cond(!value.contains("-"), value, "Invoice code must not contain hyphen.") } }
このコードからは、請求書番号にハイフンは含まないことが「仕様」として明らかになっています。 これは簡単すぎる例ですが、複雑なものであったり、ファットなクラスでは非常に適しているかと思います。
仕様オブジェクトにデータソースから取得したデータを適用することで、データ項目に対する個別具体なバリデーション条件とします。
def validate(invoiceCode: String): SpecResult[InvoiceCode] = { for { value <- new InvoiceCodeSpec().apply(invoiceCode) } yield InvoiceCode(value) }
また、仕様クラスとして別に定義していることで、クラスの不変条件を表すことにも使うことができます。 万が一うっかりバリデーションを通さずにインスタンス化してしまったとしても、不変条件によってドメインオブジェクトが満たしているべき状態が守られます。
case class InvoiceCode(value: String) { new InvoiceCodeSpec().applyAsRequirement(value) }
ある種、バリデーションは外部とのインタフェース上の取り決めでもあり、また、ドメインオブジェクトの不変条件でもあります。 これらの仕様を一箇所にまとめられることは利点といえるでしょう。
複数の仕様オブジェクトを使って、それらの仕様を満たしているなら内部表現のオブジェクトに変換するようなコードは次のように書けます。
class OtherSpec extends Specification[String] { def apply(value: String): SpecResult[String] = { SpecResult.cond(value.matches("""[0-9]+"""), value, "Only numeric characters.") } } case class Other(value: String) { new OtherSpec().applyAsRequirement(value) } case class Source(invoiceCode: String, other: String) case class Internal(invoiceCode: InvoiceCode, other: Other) // main def accept(source: Source): SpecResult[Internal] = { for { invoiceCodeString <- new InvoiceCodeSpec().apply(source.invoiceCode) otherString <- new OtherSpec().apply(source.other) } yield Internal(InvoiceCode(invoiceCodeString), Other(otherString)) }
仕様が満たされているうえでインスタンス化できるので安心ですね。
複雑なオブジェクトの仕様を合成によって適用する例
上記はプリミティブな例でした。 一方で、高級なオブジェクトに対しても同様に仕様パターンを適用することができます。
import java.time._ case class Customer(paymentGracePeriod: Int, dueDate: LocalDate) case class Invoice(code: InvoiceCode, customer: Customer, totalAmount: BigDecimal) trait InvoiceSpecification extends Specification[Invoice] /** 延滞請求書仕様 */ class DelinquentInvoiceSpecification(currentDate: LocalDate) extends InvoiceSpecification { override def apply(candidate: Invoice): SpecResult[Invoice] = { val gracePeriod = candidate.customer.paymentGracePeriod val firmDeadline = candidate.customer.dueDate.plusDays(gracePeriod) SpecResult.cond(currentDate.isAfter(firmDeadline), candidate, "延滞していません") } } /** 高額請求書仕様 */ class BigInvoiceSpecification extends InvoiceSpecification { final val threshold: BigDecimal = BigDecimal(100000) override def apply(candidate: Invoice): SpecResult[Invoice] = { SpecResult.cond(candidate.totalAmount > threshold, candidate, "高額ではありません") } } val spec = new DelinquentInvoiceSpecification(LocalDate.of(2018, 11, 22)) and new BigInvoiceSpecification val invoice = Invoice(Customer(20, LocalDate.of(2018, 11, 1)), BigDecimal(200000)) spec(invoice) // 延滞 かつ 高額!
この場合は、Specification#and
によって複数の仕様オブジェクトを合成することで、請求書オブジェクトが「延滞している、かつ、高額である」ことをチェックしています。
仕様をそれぞれ別のクラスとして定義していることで、延滞しているかだけをチェックすることもできますし、新しい仕様を追加するときも and
で繋ぐだけでOKです。
ポイント
Functionクラスそのもの
仕様パターンに第一級オブジェクトとしての関数を用いることで、変数への束縛や引数・戻り値に使うことができます。 たとえば、
def convert[U](f: T => U): U
のような関数を取りたいケースにも仕様オブジェクトを用いることが可能です。 また、関数の合成は組み換えのしやすい柔軟さを持っているため、バリデーション前後の処理を andThen/compose で簡単に書くことができる点も利点です。
そして、クラスとしてコンストラクタを受けることもできるため、Parameterized Specificationの良いところ*1も得られます。
結果が独自のデータ型
仕様クラスの結果として「仕様を満たしている」or「仕様を満たしていない」のいずれかを取ることが自明になっています。 また、代数的データ型、モナドみたいな格好を取る*2ため、仕様を満たす場合の操作を安全に記述することができます*3。 for内包表記を使用した例のように、複数の結果をくっつけて最終成果を構築することも簡単です。
Composite Specification が簡潔に書ける
論文では次の3つの実装方針が述べられています。
- Hard Coded Specification
- Parameterized Specification
- Composite Specification
この3つ目にあげられているComposite Specificationは複数の仕様オブジェクトを and/or で合成することを主に指します。 先の例のように「延滞している、かつ、高額である」のようなこともシンプルにifを足さなくても書けるためオープンクローズド原則にも準拠しています。 コードとしては次の箇所が該当します。
def and(spec: Specification[T]): Specification[T] = { value: T => self(value).flatMap(spec.apply) } def or(spec: Specification[T]): Specification[T] = { value: T => self(value) match { case Satisfied(v) => Satisfied(v) case NotSatisfied(_) => spec(value) } }
仕様パターンのよくある例ではAndSpecification/OrSpecificationのようなコンテナクラスを別途定義しているものがありますが、Scalaでは上記のようにシンプルに書くことができます。
ちなみにこれは実際には次のようなコードなのですが、
def and(spec: Specification[T]): Specification[T] = { new Specification[T] { def apply(value: T): SpecResult[T] = self(value).flatMap(spec.apply) } }
SAM変換*4という仕組みによって、より簡潔な表現が可能になっています。
最後に
今回はバリデーションに対して使用するケースを紹介しました。
仕様パターンのユースケースは、冒頭で述べたように他にもサブセットを選択するケース(検索条件)や複雑な条件を隠しながらオブジェクトを生成するケースなどにも活用余地があります。 DB(コレクション)への選択仕様として用いたいケースでは、ORMなどの技術的なフレームワークが複雑になりがちかなと思いますが、オブジェクトを検証する用途の範囲では十分に実用的かと思います。