内部DSL:ノイズとの戦い

ファウラーのDSL本から、ノイズを除去して自然言語にできる限り近づけるための内部DSLのパターンを示す。

導入

あらためて、DSLとは"Domain Specific Language"、すなわち「ドメイン固有言語」あるいは「ドメイン特化言語」です。したがって、ポイントは対象となる「ドメイン」を適切に表現することに特化しているということになります。しかし、その言語で書かれたものを読むのがプログラマではなく、ドメインエキスパートでもあると考えた場合、その言語はドメインに特化しているだけでは不十分で、プログラム言語的なノイズを除去したいという欲求が生まれることになります。今回はこのような「自然言語への接近」をテーマに「内部DSL」と言うにはやや極端な実装パターンを取り上げます。

サンプル1:動的受領("Dynamic Reception")

動的受領とは、クラスにメソッドを定義することなくメッセージを受け取ることを指しています(原文はこちら)。まずはDSLをご覧下さい。

builder.score._400.when.from.equals.BOS

今回の要件は特定の旅程に対して定められたスコアを与えるというものです。図の例では、「ボストンからのフライトであれば、400ポイント」ということを意味します。ホスト言語(Groovy)の文法に従うならば、以下のようになるところです。

builder.score.(400).when.from.equals("BOS")

つまり、「メソッドチェーンを利用して意味モデルを組み立てる」という処理自体はDSLにおける基本と変わりません。ただし、カッコやダブルクォーテーションをノイズとして取り除き、値を擬似的なメソッド呼び出し(この場合はプロパティへのアクセス)としてメソッドチェーンに組み込んでいるのです(「_400」の「_」は、数字からメソッド名を開始できないという言語的な制約によりやむを得ずつけています)。そして、このメソッド(プロパティ)として渡される値を動的に受け取るために、メソッド/プロパティが存在しなかった場合に呼び出されるメソッドである"methodMissing"および"propertyMissing"をオーバーライドします。(メソッドチェーンについてあまりなじみがないという方は、先にこちらをご参照下さい。)

モデル

モデルは大きく、ルートとなるItinerary、フライトやホテルなど旅程の各項目を設定するためのItem具象クラス群およびルールを設定するためのPromotionRuleから構成されます。DSLによって組み立てられるのはルールですので、これに関連するクラスを示します。


ルールはスコアに対して複数の条件を内部に保持します。旅程がすべての条件を満たしている場合には設定されたスコアとなり、そうでなければスコアは0です。

public class PromotionRule {
    int score
    List<Condition> conditions = []

    int scoreOf(Itinerary itinerary) {
        boolean matches = true
        for (Condition condition in conditions) {
            if (!condition.isSatisfiedBy(itinerary)) {
                matches = false
                break;
            }
        }
        matches ? score : 0
    }
}

条件クラスはSpecificationパターンで実装されています。

public abstract class Condition {
    String name
    String value

    abstract boolean isSatisfiedBy(Itinerary itinerary)
}

"when.from.equals.BOS"と指定された場合、条件のnameに"from"、valueに"BOS"が設定されます。equalsによって生成されるConditionクラスの具象オブジェクトは、EqualityConditionで、これは旅程のうち、名前と値の両方とも等しいものがあった場合に条件に合致したと見なします。

public class EqualityCondition extends Condition {

    boolean isSatisfiedBy(Itinerary itinerary) {
        for (Item item in itinerary.items) { 
            if (item.name() == name && item.value == value) { 
                return true
            }
        }
        false
    }
}
ビルダ

今回のサンプルでは、ビルダクラスを実装するにあたり、DSLを以下の2つの部分に分解しています(この実装は原文とは異なっています)。

  • score._400
  • when.from.equals.BOS

そのうえで、それぞれに対して、スコアの値を受け取るビルダと条件の値を受け取るビルダを作成しています。

public class RuleBuilder ...

    // score
    RuleBuilder getScore() {
        rule = new PromotionRule()
        itinerary.rules.add rule
        this
    }

    // value of score
    def propertyMissing(String value) { 
        if('_' != value.charAt(0)) {
            throw new IllegalStateException()
        }
        
        rule.score = Integer.valueOf(value.substring(1))

        new ConditionBuilder(ruleBuilder:this)
    }
public class ConditionBuilder...

    // when
    ConditionBuilder getWhen() {
        this
    }

    // from
    ConditionBuilder getFrom() {
        conditionName = "from"
        this
    }

    // equals
    ConditionBuilder getEquals() {
        condition = new EqualityCondition()
        this
    }

    // value of condition
    def propertyMissing(String value) {
        condition.name = conditionName
        condition.value = value
        ruleBuilder.addCondition(condition)
        this
    }

いずれも、値を受け取るためにpropertyMissingをオーバーライドしているのがお分かり頂けると思います(.score._400.when...とカッコなしでつなぐため、使用するのはmethodMissingではなくpropertyMissingとなります。)。なお、今回のサンプルでは、whenなどの固定文言はあらかじめ静的に準備していますが、原文のサンプルではこれらの文言もmissingMethodを使用して組み立てられています。

サンプル2:テクスト研磨("Text Polishing")

「ノイズ」という今回の主旨からすると、上の例にはまだ不満が残ります。つまり「.」や、ましてや「_」は不要なノイズなのです。本来は以下のように記述したいのですが、

score 600 when from equals BOS and brand equals Hyatt

上の例で実装すると、以下のようになってしまいます(and実装済み)。

builder.score._600.when.from.equals.BOS.and.brand.equals.Hyatt

ここで登場するのが、「テキスト研磨」パターンです(原文はこちら)。テストコードを示します。

// RULE:from "BOS" & brand "Hyatt" -> 600
RuleEvaluator evaluator = new RuleEvaluator()
evaluator.rule("score 600 when from equals BOS and brand equals Hyatt")

Itinerary itinerary = evaluator.itinerary

// ITINERARY:from "BOS" & brand "Hyatt"
Flight flight = new Flight(value:"BOS")
Hotel hotel = new Hotel(value:"Hyatt")
itinerary.items.add flight
itinerary.items.add hotel

assertEquals 600, itinerary.score()

evaluator.ruleの内部で行っているのは単純なテキスト整形とevalです。

public class RuleEvaluator ...

    private RuleBuilder builder = new RuleBuilder()

    void rule(String rule) {
        Eval.x(builder, polish(rule))
    }

    String polish(String rule) {
        String result = rule.replaceAll(" ", ".")
        result = result.replaceAll("score.", "score._")
        result = "x." + result
        println result
        result
    }

GroovyではRubyのようなinstance_evalは組み込まれていませんが、引数を渡してのevalが可能であることから同じことが実装できます。


さて、DSLを再掲します。

score 600 when from equals BOS and brand equals Hyatt

果たしてこれを「内部」DSLと呼んで良いのかどうかについては一抹の疑問が残ります。現実的にはこの文字列はインラインではなく外部ファイルに定義されるであろうことを考えると、なおさらそう言えるのではないでしょうか。ただし、入力された文字列を構文解析する必要がなく、整形だけすれば続きはホスト言語に任せられることにより、実装が容易にはなっています。

まとめ

「ノイズの除去」をテーマに、methodMissing/propertyMissingおよびevalという動的言語ならではの機能を利用した内部DSLの実装パターンをご紹介しました。DSLを書くのも読むのもプログラマであれば、ここまでやる必要はまずないでしょう。どこまでが妥当でどこからが「やり過ぎ」かというラインは、Fowlerの解説から読み取ることができます。

The fact that expressions using Dynamic Reception don't work well for complex conditionals isn't a reason to avoid them for simple cases. Active Record uses Dynamic Reception to provide dynamic finders for simple cases, but deliberately does not support more complex expressions, encouraging you to use a different mechanism instead. Some people don't like that, preferring a single mechanism, but I think it's good to realize that different solutions work better at different complexities and provide more than one.


動的受領を利用した式は複雑な条件ではうまく機能しません。しかし、だからと言って単純なケースでこれを避ける理由にはなりません。アクティブレコードは動的受領を利用して単純なケースにおける動的な検索を提供します。しかし、より複雑な式は意図的にサポートしておらず、かわりに異なる機構を使うように仕向けています。単一の機構を好んでこれを嫌う人もいますが、私自身は複雑さが異なれば別の解決方法がうまく機能すると認識し、複数の機構を提供することは良いことだと思います。
http://martinfowler.com/dslwip/DynamicReception.html

I confess I'm rather wary of Textual Polishing, my feeling is that if you use a little it doesn't help much and if you use it a lot it gets very complicated and it's better to use an external DSL. Although the basic notion of repeated substitutions are simple, it's very easy to get mistakes in the regular expressions.


正直言って、テキスト研磨に関しては慎重な態度を取っています。感覚としては、わずかな場所でしか利用しないのであればあまり役に立ちませんし、多くの場所で使うのであれば、きわめて複雑になってしまうので、外部DSLを用いた方が良いでしょう。反復的な置換という基本的な考え方はシンプルですが、正規表現ではミスが起こりやすいのです。
http://martinfowler.com/dslwip/TextPolishing.html

「一度パーサを書くことを覚えてしまえば、より一層の柔軟性を得ることができるし、メンテナンスも容易」とも説明されており、おおよそこの辺りが内部DSLと外部DSLとの境目になっていると言えるでしょう。


今回使用したサンプルのソースコードこちらで公開しています。