合意ディスパッチャ

ビジネス合意に基づき、ドメインイベントのプロセッサを構築する。

これは、2000年代半ばに執筆していたエンタープライズアプリケーションアーキテクチャ開発のさらなる発展の一部です。残念ながら、それ以来、他の多くのことが私の注意を引くようになり、それらをさらに進展させる時間がありませんでしたし、近い将来にも時間が見込めません。そのため、この資料はまさに草案の段階であり、再び作業に取り組む時間ができるまでは、修正や更新は行いません。

ドメインイベントによって駆動されるシステムでは、イベントに反応する処理ルールを簡単に見つけて変更できることが重要です。イベントは発生後に出現することが多いため、古いルールで古いイベントを処理する機能も保持する必要があります。

合意ディスパッチャは、イベントに対する反応のバリエーションを整理する一般的な形式であるため、主にビジネス合意を中心にイベントプロセッサを構築します。時間的プロパティを使用してプロセッサをイベントタイプ別に整理することで、変化するイベントルールを制御された方法で処理できます。

仕組み

合意ディスパッチャは、ドメインイベントのプロセッサをモジュール化する1つの方法であり、主要なモジュールがビジネス合意と一致するようにします。「契約」ではなく「合意」という用語を使用するのは、合意が正式な契約ではないことが多く、また、契約による設計におけるソフトウェア契約の概念との衝突を避けるためです。

私の合意ディスパッチャに関する議論は、委任ディスパッチの原則に従っています。各合意を、イベントを処理するメソッドを持つオブジェクトとしてモデル化します。実際の処理コードは、構造化された方法で合意にロードされる個別のプロセッサオブジェクトに配置されます。合意の本質的な動作は、正しいプロセッサを見つけて、それを使用してイベントを処理することです。

これは、正しい構造は何かという疑問につながります。当然、これはある程度問題ドメインに依存しますが、私が何度か見てきたうまく機能する1つの構造は、プロセッサをイベントタイプと時間で構造化することです。

(時間的プロパティに慣れていない場合、これはかなりトリッキーに見えるかもしれません。基本的には、合意にハッシュマップが含まれていると考えてください。そのキーはイベントタイプであり、値全体はプロセッサの時間的コレクションです。時間的コレクションは、時間的プロパティの実装である特別なコレクションです。)

ここで時間的プロパティを使用する利点は、時間とともに変化する処理ルールを保存し、古いルールでイベントを処理できるようにすることです。

運送会社は、3月14日に料金を10ドルから15ドルに値上げすることを決定しました。3月22日、3月7日に出荷が行われたというイベントを受信します。イベントを処理する日に有効なルール(15ドルの料金)ではなく、3月7日に有効だったルール(10ドルの料金)を使用してこのイベントを処理することが重要です。

継承は、合意を構造化する上で自然な概念です。これにはプログラミング言語の継承を使用しないでください。代わりに、合意に親のプロパティを与えます。子合意にルールがなく、親にルールを探している場合に備えて、処理コードを設定します。

いつ使うか

合意ディスパッチャには、2つの主な強みがあります。合意を中心とした性質と、時間的ルールにうまく対応できることです。

ディスパッチ委任の最初のステップとして合意を使用すると、イベントを処理するためのロジックが、実施されているビジネス合意によって異なる場合を処理しやすくなります。これは一般的なシナリオです。

時間的プロパティで整理されたルールへのディスパッチは、古いイベントを古いルールで処理できるようにしながら、ルールの変更に対応するための優れた構造を提供するため、便利です。これは、その機能がないシステムで、条件付き日付ロジックが面倒になる一般的な原因です。

このパターンをここで説明したのは、多くのケースでうまく機能しているのを見てきたからです。しかし、私が不安に思っていることの1つは、バリエーションや代替案に関してあまり見ていないことです。この種のイベントプロセッサを実行する最も一般的な方法は、構造がほとんどないかまったくないことです。優れた代替構造があるケースを見たことがありません。

ただし、これを行う主な2つの理由から、代替案が示唆されます。ビジネス処理の主なバリエーションポイントが合意ではない場合は、その代替案をディスパッチャの中心点として使用してください。ルールを異なる時間に実行できるようにするために、プロセッサを時間的コレクションに配置するという考え方は、他のコンテキストでも使用できます。

合意ディスパッチャは、適応オブジェクトモデルの例であり、そのため、その長所と短所をもたらします。複雑さにはうまく対処します(少なくともその可変性の範囲内では)。モデルに精通している人を非常に生産的にすることができます。しかし、習得するのは難しく、初心者はかなり戸惑う可能性があります。

例:公益事業の請求(Java)

私が何度か遭遇したパターンの特定の組み合わせは、ドメインイベント合意ディスパッチャ、および会計エントリです。この組み合わせでは、ドメインイベント合意ディスパッチャによって処理され、会計エントリが作成されます。この組み合わせは、合意ディスパッチャが会計エントリを作成するため、調整が容易であり、一般的な調整戦略を使用して誤った情報の調整を処理できるため、特にうまく機能します。

これを説明するために、公益事業の請求の例を使用します。請求システムは、さまざまなドメインイベントを受信し、それらを顧客アカウントの会計エントリに処理します。モデルは説明するのがかなり複雑なので、段階的に説明します。

  • 最初に、フレームワーククラスの構造を示します
  • 次に、フレームワーククラスをどのように接続するかを示します

フレームワーク構造

図2は、関係する基本クラスを示しています。このデータ構造は、コードでは次のようになります。

図2:公益事業の例のクラス

会計イベントは、ここで使用されるドメインイベントです。そのデータは簡単です。

class AccountingEvent...

  private EventType type;
  private MfDate whenOccurred;
  private MfDate whenNoticed;
  private Subject subject; 

サブジェクトは、イベントプロセッサに必要なメソッドを定義するインターフェースです。私たちが関心を持っている唯一の実装はCustomerです。顧客には、サービス契約と、イベント処理の結果を保持する一連のアカウントの2つの関心事項があります。

class Customer implements Subject

  private ServiceAgreement serviceAgreement;
private Map<AccountType, Account> accounts, savedRealAccounts;
public Customer(String name) {
    _name = name;
    setUpAccounts();
}
void setUpAccounts() {
    accounts = new HashMap<AccountType, Account>();
    for (AccountType type : AccountType.values())
        accounts.put(type, new Account(Currency.USD, type));
}
public Account accountFor(AccountType type) {
    assert accounts.containsKey(type);
    return accounts.get(type);
}
public void addEntry(Entry arg, AccountType type) {
    accountFor(type).post(arg);
}
public Money balanceFor(AccountType key) {
    return accountFor(key).balance();
}

アカウントは、アカウントタイプごとに1つずつ、Mapに格納されます。

サービス契約のデータ構造はもう少し複雑です。基本的には、キーがイベントタイプであり、値が転記ルールの時間的コレクションであるMapで構成されます。この構造は動的に設定します。

class ServiceAgreement...

  private Map postingRules = new HashMap();
  public void addPostingRule(EventType eventType, PostingRule rule, MfDate date) {
      if (postingRules.get(eventType) == null)
          postingRules.put(eventType, new SingleTemporalCollection());
      getRulesTemporalCollectionFor(eventType).put(date, rule);
  }
  private TemporalCollection getRulesTemporalCollectionFor(EventType eventType) {
      TemporalCollection result = (TemporalCollection) postingRules.get(eventType);
      assert result != null;
      return result;
  }

時間的コレクションクラスは、そこで説明した時間的プロパティの実装です。

public interface TemporalCollection {
    //get and put at a supplied date
  Object get(MfDate when);
  void put(MfDate at, Object item);
  Object get(int year, int month, int date);
    //get and put at today's date
  Object get();
  void put(Object item);
}

転記ルールは、金額をアカウントに転記する方法を知っている単純なプロセッサです。転記先のアカウントと課税対象かどうかを指定することで、それらを設定します。

class PostingRule...

  private AccountType type;
  private boolean isTaxable;
  protected PostingRule(AccountType type, boolean isTaxable) {
      this.type = type;
      this.isTaxable = isTaxable;
  }

転記ルールは、請求される金額を計算し、それを指定されたアカウントに請求する特殊なコードチャンクです。

class PostingRule...

  public void process(AccountingEvent evt) {
      makeEntry(evt, calculateAmount(evt));
      if (isTaxable) generateTax(evt);
  }
  abstract protected Money calculateAmount(AccountingEvent evt);
  private void makeEntry(AccountingEvent evt, Money amount) {
      Entry newEntry = new Entry(amount, evt.getWhenNoticed());
      evt.getSubject().addEntry(newEntry, type);
      evt.addResultingEntry(newEntry);
  }

この金額を計算するにはさまざまな方法があり、これらはサブクラスに任されています。(税金を処理するためのメカニズムもありますが、それについては後で説明します。)

使用量の転記ルールを追加する

それでは、この構造に単一の転記ルールを配置する方法を見てみましょう。

標準契約では、顧客が電気を使用したことを示すイベントを受信すると、顧客のサービス契約で定義されている使用量と料金の倍数である料金を請求します。

この単純な合意を成立させるには、電気使用量に関する情報を提供する会計イベントを処理するための転記ルールを含むサービス契約を作成する必要があります。

使用イベントは次のとおりです。

class Usage...

  private Quantity amount;
  public Usage(Quantity amount, MfDate whenOccurred, MfDate whenNoticed, Customer customer) {
      super(EventType.USAGE, whenOccurred, whenNoticed, customer);
      this.amount = amount;
  }
  public Quantity getAmount() {
      return amount;
  }

使用量の料金を計算できるようにするには、使用量に料金を掛け合わせることができる転記ルールが必要です。このため、転記ルールのサブクラスをまとめます。

class MultiplyByRatePR...

  public class MultiplyByRatePR extends PostingRule {
      public MultiplyByRatePR(AccountType type, boolean isTaxable) {
          super(type, isTaxable);
      }
      protected Money calculateAmount(AccountingEvent evt) {
          Usage usageEvent = (Usage) evt;
          return Money.dollars(usageEvent.getAmount().getAmount() * usageEvent.getRate());
      }
  }

class Usage...

  double getRate() {
      return ((Customer) getSubject()).getServiceAgreement().getRate(getWhenOccurred());
  }

演習を完了するには、新しい転記ルールをサービス契約に追加します

class ExampleTester…

  private ServiceAgreement simpleAgreement() {
      ServiceAgreement result = new ServiceAgreement();
      result.setRate(10, MfDate.PAST);
      result.addPostingRule(EventType.USAGE,
              new MultiplyByRatePR(AccountType.BASE_USAGE, false),
              new MfDate(1999, 10, 1));
      return result;
  }

図3:単純な例に必要な追加クラスを示すクラス図。

図4:転記ルールが合意にどのように接続されているかを示すオブジェクト図。

フレームワークの実行方法

これが単純な合意の結び付け方です。次に、それがどのように実行されるかを見ていきます。

最初は、イベントデータは外部ソースから取得されますが、これは便宜上無視します。代わりに、リーダーが使用イベントオブジェクトをインスタンス化してイベントリストに配置すると想定します。次に、イベントリストを処理してイベントを起動できます。これはJUnitテストケースでキャプチャできます。

class ExampleTester…

  public void testSimpleRule() {
      Customer mycroft = new Customer ("Mycroft Homes");
      mycroft.setServiceAgreement(simpleAgreement());
      AccountingEvent usageEvent = new Usage(Unit.KWH.amount(50),
              new MfDate(1999, 10, 1),
              new MfDate(1999, 10, 15),
              mycroft);
      EventList eventList = new EventList();
      eventList.add(usageEvent);
      eventList.process();
      assertEquals(Money.dollars(500), mycroft.balanceFor(AccountType.BASE_USAGE));
      assertEquals(Money.dollars(0), mycroft.balanceFor(AccountType.SERVICE));
      assertEquals(Money.dollars(0), mycroft.balanceFor(AccountType.TAX));
  }

コードの内容を言い換えると、顧客の例を作成し、上記で作成した標準契約を割り当てます。次に、マイクロフトの使用イベントを作成し、それをイベントリストに追加して、イベントリストを処理します。結果の料金は、マイクロフトのアカウントに表示されます。

これらすべてを実行することは、委任の練習です。図5に示すように、かなり長いチェーンです。イベントリストから始めますが、それは未処理のイベントを処理するだけです。

class EventList...

  public void process() {
      for (AccountingEvent event : unprocessedEvents()) {
          try {
              event.process();
          } catch (Exception e) {
              if (shouldOnlyLogProcessingErrors) logProcessingError (event, e);
              else throw new RuntimeException(e);
          }
      }
  }

処理に失敗したイベントがある場合は、ログに記録して続行します。

この例では、イベントクラスをイベントプロセッサにしました。最初はイベントプロセッサを別のクラスと考えるのは理にかなっていますが、基本的にはプロセッサはイベントに対して多くの密接な操作を実行するため、情報エキスパートの原則に従ってイベント自体に処理動作を配置するのが理にかなっています。

class AccountingEvent...

  public void process() {
      assert !isProcessed;
      if (adjustedEvent != null) adjustedEvent.reverse();
      subject.process(this);
      markProcessed();
  }

今のところ調整に関する行は無視してください。については、で詳しく説明します。

基本的に、イベントが行うことは、そのサブジェクトに委任することだけです。サブジェクトは、複数の顧客が同じサービス契約を共有するため、そのサービス契約に委任します。

クラス Customer...

  public void process(AccountingEvent e) {
      serviceAgreement.process(e);
  }

サービス契約もまた、今度は転記ルールに委任したいと考えています。しかし、今回はサービス契約が最初に使用するルールを決定する必要があるため、少し複雑です。

class ServiceAgreement...

  public void process(AccountingEvent e) {
      getPostingRule(e).process(e);
  }
  private PostingRule getPostingRule(AccountingEvent event) {
      final TemporalCollection rules = getRulesTemporalCollectionFor(event.getEventType());
      if (rules == null) throw new MissingPostingRuleException(this, event);
      try {
          return (PostingRule) rules.get(event.getWhenOccurred());
      } catch(IllegalArgumentException e) {
          throw new MissingPostingRuleException(this, event);
      }
  }

サービス契約は、イベントの種類とイベントの実際の発生時刻に基づいて転記ルールを検索します。

より多くのケースへの対応

この基本的なフレームワークがあれば、それを拡張してより多くの種類のイベントを処理することができます。

サービスコールには費用に基づいた料金が発生します。この標準契約では、料金に料金の10%と処理手数料として10ドルを加算して請求します。

この追加ルールを追加するには、契約に別の転記ルールを追加するだけで済みます。結果として得られる契約の設定は次のようになります。

class ExampleTester…

  private ServiceAgreement simpleAgreement2() {
      ServiceAgreement result = new ServiceAgreement();
      result.setRate(10, MfDate.PAST);
      result.addPostingRule(EventType.USAGE,
              new MultiplyByRatePR(AccountType.BASE_USAGE, false),
              new MfDate(1999, 10, 1));
      result.addPostingRule(EventType.SERVICE_CALL,
              new AmountFormulaPR(1.1, Money.dollars(10), AccountType.SERVICE, false),
              new MfDate(1999, 10, 1));
      return result;
 }
public class AmountFormulaPR extends PostingRule {
    private double multiplier;
    private Money fixedFee;
    public AmountFormulaPR(double multiplier, Money fixedFee, AccountType type, boolean isTaxable) {
        super(type, isTaxable);
        this.multiplier = multiplier;
        this.fixedFee = fixedFee;
    }
    protected Money calculateAmount(AccountingEvent evt) {
        Money eventAmount = ((MonetaryEvent) evt).getAmount();
        return (Money) eventAmount.multiply(multiplier).add(fixedFee);
    }
}

このためには、単純な式(この場合は金額に乗数を掛けて定数を足すだけ)をサポートする新しい種類の転記ルールが必要です。

もう1つの変化の領域は時間です。

12月1日には、サービスコールの処理手数料が15ドルに上がります。

これを追加するために、同じイベントタイプで日付が異なる別の転記ルールを追加します。これで、契約の定義は次のようになります。

class ExampleTester…

  private ServiceAgreement simpleAgreement3() {
      ServiceAgreement result = new ServiceAgreement();
      result.setRate(10, MfDate.PAST);
      result.addPostingRule(EventType.USAGE,
              new MultiplyByRatePR(AccountType.BASE_USAGE, false),
              new MfDate(1999, 10, 1));
      result.addPostingRule(EventType.SERVICE_CALL,
              new AmountFormulaPR(1.1, Money.dollars(10), AccountType.SERVICE, false),
              new MfDate(1999, 10, 1));
      result.addPostingRule(EventType.SERVICE_CALL,
              new AmountFormulaPR(1.1, Money.dollars(15), AccountType.SERVICE, false),
              new MfDate(1999, 12, 1));
      return result;
 }

これにより、12月より前のサービスコールには低い料金が適用されますが、12月以降のサービスコールには高い料金が適用されます。

class ExampleTester…

  public void testSimpleRule3() {
      Customer mycroft = new Customer("Mycroft Homes");
      mycroft.setServiceAgreement(simpleAgreement3());
      EventList eventList = new EventList();

      //service call before rule change
      AccountingEvent call1 = new MonetaryEvent(Money.dollars(100),
              EventType.SERVICE_CALL,
              new MfDate(1999, 10, 1),
              new MfDate(1999, 10, 15),
              mycroft);
      eventList.add(call1);
      eventList.process();
      assertEquals(Money.dollars(0), mycroft.balanceFor(AccountType.BASE_USAGE));
      assertEquals(Money.dollars(120), mycroft.balanceFor(AccountType.SERVICE));
      assertEquals(Money.dollars(0), mycroft.balanceFor(AccountType.TAX));

      //service call after rule change
      AccountingEvent call2 = new MonetaryEvent(Money.dollars(100),
              EventType.SERVICE_CALL,
              new MfDate(1999, 12, 1),
              new MfDate(1999, 12, 15),
              mycroft);
      eventList.add(call2);
      eventList.process();
      assertEquals(Money.dollars(0), mycroft.balanceFor(AccountType.BASE_USAGE));
      assertEquals(Money.dollars(245), mycroft.balanceFor(AccountType.SERVICE));
      assertEquals(Money.dollars(0), mycroft.balanceFor(AccountType.TAX));
  }