ドメイン指向のオブザーバビリティ

クラウドとマイクロサービスの時代において、ソフトウェアシステムのオブザーバビリティは常に重要であり、さらに重要性を増しています。しかし、システムに追加するオブザーバビリティは、どちらかというと低レベルで技術的な性質のものであり、ログ記録、計測、分析フレームワークへの粗雑で冗長な呼び出しでコードベースを散らかしてしまうことが多々あります。この記事では、この混乱を解消し、ビジネスに関連するオブザーバビリティをクリーンでテスト可能な方法で追加できるパターンについて説明します。

2019年4月9日


Photo of Pete Hodgson

ピート・ホジソンは、美しく雨の多い太平洋岸北西部を拠点とする独立系ソフトウェアデリバリーコンサルタントです。彼は、スタートアップのエンジニアリングチームがエンジニアリングプラクティスと技術アーキテクチャを改善するのを支援することを専門としています。

ピートは以前、Thoughtworksのコンサルタントとして6年間、西海岸事業の技術プラクティスをリードしていました。また、サンフランシスコのさまざまなスタートアップ企業で、テックリードとして数回勤務した経験があります。


マイクロサービスやクラウドといった現在のトレンドのおかげで、最新のソフトウェアシステムは、より分散化され、信頼性の低いインフラストラクチャ上で実行されるようになっています。システムにオブザーバビリティを組み込むことは常に必要でしたが、これらのトレンドによって、これまで以上に重要になっています。同時に、DevOpsムーブメントにより、本番環境を監視する担当者は、オブザーバビリティを側面にボルトで固定するのではなく、実行中のシステム内にカスタム計測コードを実際に追加できる可能性が高くなっています。

しかし、コードベースを計測の詳細で詰まらせることなく、最も重要なビジネスロジックにオブザーバビリティを追加するにはどうすればよいでしょうか? また、この計測が重要な場合、正しく実装されていることをどのようにテストすればよいでしょうか?この記事では、ドメイン指向のオブザーバビリティの哲学と_ドメインプローブ_と呼ばれる実装パターンを組み合わせることで、ビジネスに焦点を当てたオブザーバビリティをコードベース内の第一級の概念として扱うことで、どのように役立つかを説明します。

何を監視すべきか

「オブザーバビリティ」は、低レベルの技術メトリクスから高レベルのビジネス主要業績評価指標(KPI)まで、幅広い範囲を網羅しています。技術的な側面では、メモリとCPUの使用率、ネットワークとディスクのI/O、スレッド数、ガベージコレクション(GC)の一時停止などを追跡できます。一方、ビジネス/ドメインメトリクスでは、カート放棄率、セッション時間、支払い失敗率などを追跡する場合があります。

これらの高レベルのメトリクスは各システムに固有であるため、通常は手動で計測ロジックを作成する必要があります。これは、より一般的であり、起動時に何らかの監視エージェントを挿入する以外に、システムのコードベースをあまり変更せずに実現できる、低レベルの技術計測とは対照的です。

また、高レベルの製品指向メトリクスは、定義上、システムが意図したビジネス目標に向かって実行されていることをより正確に反映しているため、より価値があることに注意することも重要です。

これらの貴重なメトリクスを追跡する計測を追加することで、_ドメイン指向のオブザーバビリティ_を実現します。

オブザーバビリティの問題点

つまり、ドメイン指向のオブザーバビリティは貴重ですが、通常は手動で計測ロジックを作成する必要があります。そのカスタム計測は、システムのコアドメインロジックと並んで存在し、明確で保守しやすいコードが不可欠です。残念ながら、計測コードはノイズが多く、注意しないと、気が散るような混乱につながる可能性があります。

計測コードの導入によってどのような混乱が生じるか、例を見てみましょう。オブザーバビリティを追加する前の、架空のeコマースシステムの(やや単純な)割引コードロジックを次に示します。

class ShoppingCart…

  applyDiscountCode(discountCode){

    let discount; 
    try {
      discount = this.discountService.lookupDiscount(discountCode);
    } catch (error) {
      return 0;
    }

    const amountDiscounted = discount.applyToCart(this);
    return amountDiscounted;
  }

ドメインロジックが明確に表現されていると言えるでしょう。割引コードに基づいて割引を検索し、カートに割引を適用します。最後に、割引された金額を返します。割引が見つからなかった場合は、何もしないで早期に終了します。

カートへの割引の適用は重要な機能であるため、適切なオブザーバビリティが重要です。計測を追加してみましょう。

class ShoppingCart…

  applyDiscountCode(discountCode){
    this.logger.log(`attempting to apply discount code: ${discountCode}`);

    let discount; 
    try {
      discount = this.discountService.lookupDiscount(discountCode);
    } catch (error) {
      this.logger.error('discount lookup failed',error);
      this.metrics.increment(
        'discount-lookup-failure',
        {code:discountCode});
      return 0;
    }
    this.metrics.increment(
      'discount-lookup-success',
      {code:discountCode});

    const amountDiscounted = discount.applyToCart(this);

    this.logger.log(`Discount applied, of amount: ${amountDiscounted}`);
    this.analytics.track('Discount Code Applied',{
      code:discount.code, 
      discount:discount.amount, 
      amountDiscounted:amountDiscounted
    });

    return amountDiscounted;
  }

割引を検索して適用するという実際のビジネスロジックを実行することに加えて、さまざまな計測システムを呼び出しています。開発者向けの診断ログを記録し、本番環境でこのシステムを運用する担当者向けのメトリクスを記録し、製品とマーケティングの担当者が使用するために分析プラットフォームにイベントを公開しています。

残念ながら、オブザーバビリティを追加したことで、きれいでクリーンなドメインロジックが台無しになってしまいました。`applyDiscountCode`メソッドのコードの25%だけが、割引を検索して適用するという本来の目的に使用されています。最初に作成したクリーンなビジネスロジックは変更されておらず、明確で簡潔なままですが、メソッドの大部分を占めるようになった低レベルの計測コードの中に埋もれてしまっています。さらに、コードの重複とマジックストリングがドメインロジックの途中に導入されてしまいました。

要するに、計測コードは、このメソッドを読んで実際に_何をしているのか_を理解しようとする人にとって、大きな邪魔になっています。

混乱の解消

実装をリファクタリングして、この混乱を解消できるかどうか見てみましょう。まず、厄介な低レベルの計測ロジックを個別のメソッドに抽出します。

  class ShoppingCart {
    applyDiscountCode(discountCode){
      this._instrumentApplyingDiscountCode(discountCode);
  
      let discount; 
      try {
        discount = this.discountService.lookupDiscount(discountCode);
      } catch (error) {
        this._instrumentDiscountCodeLookupFailed(discountCode,error);
        return 0;
      }
      this._instrumentDiscountCodeLookupSucceeded(discountCode);
  
      const amountDiscounted = discount.applyToCart(this);
      this._instrumentDiscountApplied(discount,amountDiscounted);
      return amountDiscounted;
    }
  
    _instrumentApplyingDiscountCode(discountCode){
      this.logger.log(`attempting to apply discount code: ${discountCode}`);
    }
    _instrumentDiscountCodeLookupFailed(discountCode,error){
      this.logger.error('discount lookup failed',error);
      this.metrics.increment(
        'discount-lookup-failure',
        {code:discountCode});
    }
    _instrumentDiscountCodeLookupSucceeded(discountCode){
      this.metrics.increment(
        'discount-lookup-success',
        {code:discountCode});
    }
    _instrumentDiscountApplied(discount,amountDiscounted){
      this.logger.log(`Discount applied, of amount: ${amountDiscounted}`);
      this.analytics.track('Discount Code Applied',{
        code:discount.code, 
        discount:discount.amount, 
        amountDiscounted:amountDiscounted
      });
    }
  }

これは良いスタートです。計測の詳細を焦点を絞った計測メソッドに抽出し、ビジネスロジックには各計測ポイントで単純なメソッド呼び出しを残しました。さまざまな計測システムの邪魔な詳細が`_instrument...`メソッドにプッシュダウンされたため、`applyDiscountCode`が読みやすく理解しやすくなりました。

ただし、`ShoppingCart`に計測のみに焦点を当てたプライベートメソッドが多数あるのは適切ではありません。それは`ShoppingCart`の責任ではありません。クラス内の機能のクラスターが、そのクラスの主な責任とは無関係である場合、新しいクラスが出現しようとしている兆候であることがよくあります。

そのヒントに従って、これらの計測メソッドをまとめて、独自の`DiscountInstrumentation`クラスに移動してみましょう。

class ShoppingCart…

  applyDiscountCode(discountCode){
    this.instrumentation.applyingDiscountCode(discountCode);

    let discount; 
    try {
      discount = this.discountService.lookupDiscount(discountCode);
    } catch (error) {
      this.instrumentation.discountCodeLookupFailed(discountCode,error);
      return 0;
    }
    this.instrumentation.discountCodeLookupSucceeded(discountCode);

    const amountDiscounted = discount.applyToCart(this);
    this.instrumention.discountApplied(discount,amountDiscounted);
    return amountDiscounted;
  }

メソッドに変更は加えず、適切なコンストラクターを使用して独自のクラスに移動するだけです。

class DiscountInstrumentation {
  constructor({logger,metrics,analytics}){
    this.logger = logger;
    this.metrics = metrics;
    this.analytics = analytics;
  }

  applyingDiscountCode(discountCode){
    this.logger.log(`attempting to apply discount code: ${discountCode}`);
  }

  discountCodeLookupFailed(discountCode,error){
    this.logger.error('discount lookup failed',error);
    this.metrics.increment(
      'discount-lookup-failure',
      {code:discountCode});
  }
  
  discountCodeLookupSucceeded(discountCode){
    this.metrics.increment(
      'discount-lookup-success',
      {code:discountCode});
  }

  discountApplied(discount,amountDiscounted){
    this.logger.log(`Discount applied, of amount: ${amountDiscounted}`);
    this.analytics.track('Discount Code Applied',{
      code:discount.code, 
      discount:discount.amount, 
      amountDiscounted:amountDiscounted
    });
  }
}

これで、責任の明確な分離ができました。`ShoppingCart`は、割引の適用などのドメインの概念のみに焦点を当て、新しい`DiscountInstrumentation`クラスは、割引の適用プロセスを計測するためのすべての詳細をカプセル化します。

ドメインプローブ

ドメインプローブ[...]を使用すると、ドメインの言語を使用してドメインロジックにオブザーバビリティを追加できます。

`DiscountInstrumentation`は、_ドメインプローブ_と呼ばれるパターンの例です。_ドメインプローブ_は、ドメインセマンティクスを中心とした高レベルの計測APIを提供し、ドメイン指向のオブザーバビリティを実現するために必要な低レベルの計測プラミングをカプセル化します。これにより、計測技術の邪魔な詳細を回避しながら、_ドメイン_の言語を使用してドメインロジックにオブザーバビリティを追加できます。上記の例では、`ShoppingCart`は、ログエントリを記述したり、分析イベントを追跡したりするなどの技術ドメインで直接作業するのではなく、ドメインオブザベーション(適用されている割引コードと検索に失敗した割引コード)を`DiscountInstrumentation`プローブに報告することで、オブザーバビリティを実装しました。これは微妙な違いに見えるかもしれませんが、ドメインコードをドメインに焦点を合わせたままにすることで、コードベースを読みやすく、保守しやすく、拡張しやすくするという点で大きなメリットが得られます。

オブザーバビリティのテスト

計測ロジックのテストカバレッジが良いことはまれです。操作が失敗した場合にエラーがログに記録されること、またはコンバージョンが発生したときに正しいフィールドを含む分析イベントが公開されることを検証する自動テストは、あまり見かけません。これは、オブザーバビリティが歴史的にそれほど価値がないと見なされてきたことにも一部起因している可能性がありますが、低レベルの計測コードの良いテストを作成するのが面倒であることも原因です。

計測コードのテストは面倒

例として、架空のeコマースシステムの別の部分の計測を見て、その計測コードの正確性を検証するテストをどのように記述するかを見てみましょう。

`ShoppingCart`には`addToCart`メソッドがあり、現在は(_ドメインプローブ_を使用するのではなく)さまざまなオブザーバビリティシステムへの直接呼び出しで計測されています。

class ShoppingCart…

  addToCart(productId){
    this.logger.log(`adding product '${productId}' to cart '${this.id}'`);

    const product = this.productService.lookupProduct(productId);

    this.products.push(product);
    this.recalculateTotals();

    this.analytics.track(
      'Product Added To Cart',
      {sku: product.sku}
    );
    this.metrics.gauge(
      'shopping-cart-total',
      this.totalPrice
    );
    this.metrics.gauge(
      'shopping-cart-size',
      this.products.length
    );
  }

この計測ロジックのテストを開始する方法を見てみましょう。

shoppingCart.test.js

  const sinon = require('sinon');
  
  describe('addToCart', () => {
    // ...
  
    it('logs that a product is being added to the cart', () => {
      const spyLogger = {
        log: sinon.spy()
      };
      const shoppingCart = testableShoppingCart({
        logger:spyLogger
      });
  
  
      shoppingCart.addToCart('the-product-id');
  
      
      expect(spyLogger.log)
        .calledWith(`adding product 'the-product-id' to cart '${shoppingCart.id}'`);
    });
  });

このテストでは、_スパイ_ロガー(「スパイ」は、テスト対象が他のオブジェクトとどのように対話しているかを検証するために使用されるテストダブルの一種です)に接続されたショッピングカートをテスト用に設定しています。疑問に思われる方のために説明すると、`testableShoppingCart`は、デフォルトで偽の依存関係を持つ`ShoppingCart`のインスタンスを作成する小さなヘルパー関数です。スパイを設定したら、`shoppingCart.addToCart(...)`を呼び出し、ショッピングカートがロガーを使用して適切なメッセージをログに記録したことを確認します。

現状の記述では、このテストは商品がカートに追加されたときにログが記録されることを合理的に保証します。しかし、このテストはロギングの詳細に非常に密接に結びついています。将来のある時点でログメッセージの形式を変更することにした場合、正当な理由なくこのテストが壊れてしまいます。このテストは、*何* がログに記録されたかの正確な詳細ではなく、正しいコンテキストデータと共に *何か* がログに記録されたかどうかだけを気にするべきです。

正確な文字列ではなく正規表現(regex)と照合することで、テストがログメッセージ形式の詳細にどれだけ密接に結びついているかを軽減しようとすることができます。しかし、これは検証を少し分かりにくくします。さらに、堅牢な正規表現を作成するために必要な労力は、通常は時間の無駄遣いです。

さらに、これは物事がどのようにログに記録されるかをテストする簡単な例に過ぎません。より複雑なシナリオ(例えば、例外のロギング)はさらに厄介です。ロギングフレームワークとその同類のAPIは、モックアウトされている場合、容易な検証には適していません。

次に進み、別のテストを見てみましょう。今回は分析統合を検証します。

shoppingCart.test.js

  const sinon = require('sinon');
  
  describe('addToCart', () => {
    // ...
  
    it('publishes analytics event', () => {
      const theProduct = genericProduct();
      const stubProductService = productServiceWhichAlwaysReturns(theProduct);  
  
      const spyAnalytics = {
        track: sinon.spy()
      };
  
      const shoppingCart = testableShoppingCart({
        productService: stubProductService,  
        analytics: spyAnalytics  
      });
  
  
      shoppingCart.addToCart('some-product-id');
  
      
      expect(spyAnalytics.track).calledWith(  
        'Product Added To Cart',
        {sku: theProduct.sku}
      );
    });
  });

このテストは、`productService.lookupProduct(...)` からショッピングカートに返される商品を制御する必要があるため、少し複雑です。つまり、常に特定の商品 を返すように仕組まれたスタブ商品サービス を注入する必要があります。また、前のテストでスパイ `logger` を注入したのと同様に、スパイ `analytics` を注入します。これらすべてを設定した後、`shoppingCart.addToCart(...)` を呼び出し、最後に、分析計測が期待されるパラメータでイベントを作成するように要求されたことを検証します

このテストにはかなり満足しています。商品は間接入力としてカートに送られるため、少し面倒ですが、分析イベントにその商品のSKUが含まれているという確信を得るための妥当なトレードオフです。また、テストがそのイベントの正確な形式に結びついているのは少し残念です。上記のロギングテストと同様に、このテストは可観測性がどのように実現されるかの詳細ではなく、正しいデータを使用して行われているかどうかだけを気にする方が良いでしょう。

このテストを完了した後、他の計測ロジック(`shopping-cart-total` および `shopping-cart-size` メトリックゲージ)もテストしたい場合、このテストと非常によく似た2つまたは3つの追加テストを作成する必要があるという事実にとどまらざるを得ません。各テストは、それがテストの焦点ではないにもかかわらず、同じ面倒な依存関係設定作業を行う必要があります。このタスクに直面したとき、一部の開発者は歯を食いしばり、既存のテストをコピーして貼り付け、必要な変更を加えて、その日を過ごします。実際には、多くの開発者は最初のテストで十分であると判断し、後で計測ロジックにバグが導入されるリスクを負います(壊れた計測は必ずしもすぐに明らかになるわけではないため、しばらくの間気付かれない可能性のあるバグ)。

ドメインプローブで、よりクリーンで焦点を絞ったテストが可能になる

*ドメインプローブ* パターンを使用することで、テストのストーリーがどのように改善されるかを見てみましょう。*ドメインプローブ* を使用するようにリファクタリングされた `ShoppingCart` をもう一度示します。

class ShoppingCart…

  addToCart(productId){
    this.instrumentation.addingProductToCart({
      productId:productId,
      cart:this
    });

    const product = this.productService.lookupProduct(productId);

    this.products.push(product);
    this.recalculateTotals();

    this.instrumentation.addedProductToCart({
      product:product,
      cart:this
    });
  }

`addToCart` の計測テストを次に示します。

shoppingCart.test.js

  const sinon = require('sinon');
  
  describe('addToCart', () => {
    // ...
  
    it('instruments adding a product to the cart', () => {
      const spyInstrumentation = createSpyInstrumentation();
      const shoppingCart = testableShoppingCart({
        instrumentation:spyInstrumentation
      });
  
  
      shoppingCart.addToCart('the-product-id');
  
      
      expect(spyInstrumentation.addingProductToCart).calledWith({  
        productId:'the-product-id',
        cart:shoppingCart
      });
    });
  
    it('instruments a product being successfully added to the cart', () => {
      const theProduct = genericProduct();
      const stubProductService = productServiceWhichAlwaysReturns(theProduct);
  
      const spyInstrumentation = createSpyInstrumentation();
  
      const shoppingCart = testableShoppingCart({
        productService: stubProductService,
        instrumentation: spyInstrumentation
      });
  
  
      shoppingCart.addToCart('some-product-id');
  
      
      expect(spyInstrumentation.addedProductToCart).calledWith({  
        product:theProduct,
        cart:shoppingCart
      });
    });
  
    function createSpyInstrumentation(){
      return {
        addingProductToCart: sinon.spy(),
        addedProductToCart: sinon.spy()
      };
    }
  });

*ドメインプローブ* の導入により、抽象化のレベルが少し上がり、コードとテストが少し読みやすくなり、もろさが軽減されました。計測が正しく実装されていることをまだテストしています。実際、テストは現在、可観測性の要件を完全に検証しています。しかし、テストの期待 ①② には、計測が *どのように* 実装されるかの詳細を含める必要はなくなり、適切なコンテキストが渡されることだけが必要になりました。

テストは、過度の偶発的な複雑さを持ち込むことなく、可観測性を追加するという本質的な複雑さを捉えています。

それでも、厄介な下位レベルの計測の詳細が正しく実装されていることを検証することをお勧めします。計測に正しい情報を含めないことは、コストのかかる間違いになる可能性があります。`ShoppingCartInstrumentation` *ドメインプローブ* はこれらの詳細を実装する責任があるため、そのクラスのテストは、これらの詳細が正しいことを検証するのに自然な場所です。

ShoppingCartInstrumentation.test.js

  const sinon = require('sinon');
  
  describe('ShoppingCartInstrumentation', () => {
    describe('addingProductToCart', () => {
      it('logs the correct message', () => {
        const spyLogger = {
          log: sinon.spy()
        };
        const instrumentation = testableInstrumentation({
          logger:spyLogger
        });
        const fakeCart = {
          id: 'the-cart-id'
        };
        
  
        instrumentation.addingProductToCart({
          cart: fakeCart,
          productId: 'the-product-id'
        });
  
        
        expect(spyLogger.log)
          .calledWith("adding product 'the-product-id' to cart 'the-cart-id'");
      });
    });
  
    describe('addedProductToCart', () => {
      it('publishes the correct analytics event', () => {
        const spyAnalytics = {
          track: sinon.spy()
        };
        const instrumentation = testableInstrumentation({
          analytics:spyAnalytics
        });
  
        const fakeCart = {};
        const fakeProduct = {
          sku: 'the-product-sku'
        };
  
  
        instrumentation.addedProductToCart({
          cart: fakeCart,
          product: fakeProduct  
        });
  
  
        expect(spyAnalytics.track).calledWith(
          'Product Added To Cart',
          {sku: 'the-product-sku'}
        );
      });
  
      it('updates shopping-cart-total gauge', () => {
        // ...etc
      });
  
      it('updates shopping-cart-size gauge', () => {
        // ...etc
      });
    });
  });

ここでも、テストはもう少し焦点を絞ることができます。`ShoppingCart` テストでモックアウトされた `productService` を介した以前の間接的な注入ダンスではなく、`product` を直接渡すことができます

`ShoppingCartInstrumentation` のテストは、そのクラスがサードパーティの計測ライブラリをどのように使用するか焦点を当てているため、`before` ブロックを使用して、これらの依存関係の事前配線されたスパイを設定することで、テストを少し簡潔にすることができます。

shoppingCartInstrumentation.test.js

  const sinon = require('sinon');
  
  describe('ShoppingCartInstrumentation', () => {
    let instrumentation, spyLogger, spyAnalytics, spyMetrics;
    before(()=>{
        spyLogger = { log: sinon.spy() };
        spyAnalytics = { track: sinon.spy() };
        spyMetrics = { gauge: sinon.spy() };
        instrumentation = new ShoppingCartInstrumentation({
          logger: spyLogger,
          analytics: spyAnalytics,
          metrics: spyMetrics
        });
    });
  
    describe('addingProductToCart', () => {
      it('logs the correct message', () => {
        const spyLogger = {
          log: sinon.spy()
        };
        const instrumentation = testableInstrumentation({
          logger:spyLogger
        });
        const fakeCart = {
          id: 'the-cart-id'
        };
        
  
        instrumentation.addingProductToCart({
          cart: fakeCart,
          productId: 'the-product-id'
        });
  
      
        expect(spyLogger.log)
          .calledWith("adding product 'the-product-id' to cart 'the-cart-id'");
        });
    });
  
    describe('addedProductToCart', () => {
      it('publishes the correct analytics event', () => {
        const spyAnalytics = {
          track: sinon.spy()
        };
        const instrumentation = testableInstrumentation({
          analytics:spyAnalytics
        });
        const fakeCart = {};
        const fakeProduct = {
          sku: 'the-product-sku'
        };
  
  
        instrumentation.addedProductToCart({
          cart: fakeCart,
          product: fakeProduct
        });
  
  
        expect(spyAnalytics.track).calledWith(
          'Product Added To Cart',
          {sku: 'the-product-sku'}
        );
      });
  
      it('updates shopping-cart-total gauge', () => {
        const fakeCart = {
          totalPrice: 123.45
        };
        const fakeProduct = {};
  
  
        instrumentation.addedProductToCart({
          cart: fakeCart,
          product: fakeProduct
        });
  
  
        expect(spyMetrics.gauge).calledWith(
          'shopping-cart-total',
          123.45
        );
      });
  
      it('updates shopping-cart-size gauge', () => {
        // ...etc
      });
    });
  });

テストは jetzt sehr klar und fokussiertです。各テストは、下位レベルの技術計測の1つの特定の部分が、上位レベルのドメイン観測の一部として正しくトリガーされることを検証します。テストは、*ドメインプローブ* の意図、つまり、さまざまな計測システムの退屈な技術的詳細に対するドメイン固有の抽象化を提示することを捉えています。

実行コンテキストを含める

計測イベントには、常にコンテキストメタデータ、つまり、観測されたイベントを取り巻くより広いコンテキストを理解するために使用される情報を含める必要があります。

メタデータの種類

Webサービスでよく見られるメタデータの1つは、*リクエスト識別子* です。これは、分散トレースを容易にするために使用されます。単一の論理操作を構成するさまざまな分散呼び出しを結び付けます(これらの識別子は、相関識別子、または トレースとスパン 識別子と呼ばれることもあります)。

リクエスト固有のメタデータのもう1つの一般的な部分は、*ユーザー識別子* です。これは、リクエストを行っているユーザー、または場合によっては、「プリンシパル」、つまり外部システムがリクエストを行っているアクターに関する情報を記録します。一部のシステムは、機能フラグメタデータ、つまり、このリクエストが配置された実験的な"バケット"に関する情報、またはすべてのフラグの生の状態さえも記録します。これらのメタデータは、Web分析を使用してユーザーの行動と機能の変更を関連付けるときに非常に重要です。

イベントがシステムの変更とどのように関連しているかを理解するのに役立つ、より技術的なメタデータがいくつかあります。たとえば、*ソフトウェアバージョン*、*プロセスとスレッド識別子*、おそらく*サーバーホスト名*などです。

1つのメタデータは、計測イベントの関連付けに非常に重要であるため、言及するまでもありません。それは、イベントが発生した時刻を示す*タイムスタンプ*です。

メタデータの注入

このコンテキストメタデータを *ドメインプローブ* に提供するのは少し面倒な場合があります。ドメイン観測の呼び出しは通常、ドメインコードによって行われます。ドメインコードは、リクエストIDや機能フラグ構成などの技術的な詳細に直接アクセスできないことが望ましいです。これらの技術的な詳細は、ドメインコードの懸念事項ではありません。では、ドメインコードをそれらの詳細で汚染することなく、*ドメインプローブ* が必要な技術的な詳細を持っていることをどのように確認するのでしょうか?

ここで私たちが持っているのは、かなり典型的な依存性注入のシナリオです。ドメインプローブのすべての推移的な依存関係をドメインクラスにドラッグすることなく、正しく構成された *ドメインプローブ* 依存関係をドメインクラスに注入する必要があります。利用可能な依存性注入パターンのメニューから、好みのソリューションを選択できます。

ショッピングカートの割引コードの例を以前から取り上げて、いくつかの選択肢を検討してみましょう。記憶を新たにするために、計測された `ShoppingCart` の `applyDiscountCode` 実装を次に示します。

class ShoppingCart…

  applyDiscountCode(discountCode){
    this.instrumentation.applyingDiscountCode(discountCode);

    let discount; 
    try {
      discount = this.discountService.lookupDiscount(discountCode);
    } catch (error) {
      this.instrumentation.discountCodeLookupFailed(discountCode,error);
      return 0;
    }
    this.instrumentation.discountCodeLookupSucceeded(discountCode);

    const amountDiscounted = discount.applyToCart(this);
    this.instrumention.discountApplied(discount,amountDiscounted);
    return amountDiscounted;
  }

さて、問題は、`this.instrumentation`(私たちの*ドメインプローブ*)が `ShoppingCart` クラスでどのように設定されるかです。コンストラクターに渡すだけです。

class ShoppingCart…

  constructor({instrumentation,discountService}){
    this.instrumentation = instrumentation;
    this.discountService = discountService;
  }

あるいは、*ドメインプローブ* が追加のコンテキストメタデータを取得する方法をより詳細に制御したい場合は、ある種の計測ファクトリーを渡すことができます。

constructor({createInstrumentation,discountService}){
  this.createInstrumentation = createInstrumentation;
  this.discountService = discountService;
}

次に、このファクトリー関数を使用して、*ドメインプローブ* のインスタンスをオンデマンドで作成できます。

applyDiscountCode(discountCode){
  const instrumentation = this.createInstrumentation();

  instrumentation.applyDiscountCode(discountCode);

  let discount; 
  try {
    discount = this.discountService.lookupDiscount(discountCode);
  } catch (error) {
    instrumentation.discountCodeLookupFailed(discountCode,error);
    return 0;
  }
  instrumentation.discountCodeLookupSucceeded(discountCode);

  const amountDiscounted = discount.applyToCart(this);
  instrumention.discountApplied(discount,amountDiscounted);
  return amountDiscounted;
}

一見すると、このようなファクトリー関数を導入すると、不必要な間接参照が追加されます。ただし、*ドメインプローブ* を作成する方法と、コンテキスト情報を使用して構成する方法に、より柔軟性を持たせることもできます。たとえば、計測に割引コードを含める方法を見てみましょう。既存の実装では、`discountCode` を各計測呼び出しにパラメーターとして渡します。ただし、`applyDiscountCode` の特定の呼び出し内では、その `discountCode` は一定のままです。作成時に*ドメインプローブ* に一度だけ渡してみませんか?

applyDiscountCode(discountCode){
  const instrumentation = this.createInstrumentation({discountCode});

  instrumentation.applyDiscountCode(discountCode);

  let discount; 
  try {
    discount = this.discountService.lookupDiscount(discountCode);
  } catch (error) {
    instrumentation.discountCodeLookupFailed(discountCode,error);
    return 0;
  }
  instrumentation.discountCodeLookupSucceeded(discountCode);

  const amountDiscounted = discount.applyToCart(this);
  instrumention.discountApplied(discount,amountDiscounted);
  return amountDiscounted;
}

それはいいですね。コンテキストを*ドメインプローブ* に一度渡すことができ、同じ情報を繰り返し渡すことを避けることができます。

計測コンテキストの収集

一歩下がってここで何をしているのかを見てみると、本質的に、この1つの特定のコンテキストでドメイン観測を記録するように特別に構成された、より的を絞ったバージョンの*ドメインプローブ* を作成しています。

このアイデアをさらに発展させて、*ドメインプローブ* が計測レコードに含める必要がある関連する技術コンテキスト(たとえば、リクエスト識別子)にアクセスできるようにすることができます。`ShoppingCart` ドメインクラスにそれらの技術的な詳細をまったく公開する必要はありません。新しい*観測コンテキスト*クラスを作成することで、これを行う1つの方法のスケッチを次に示します。

class ObservationContext {
  constructor({requestContext,standardParams}){
    this.requestContext = requestContext;
    this.standardParams = standardParams;  
  }

  createShoppingCartInstrumentation(extraParams){  
    const paramsFromContext = {  
      requestId: this.requestContext.requestId
    };

    const mergedParams = {  
      ...this.standardParams,
      ...paramsFromContext,
      ...extraParams
    };

    return new ShoppingCartInstrumentation(mergedParams);
  }
}

`ObservationContext` は、ドメイン観測を記録するために `ShoppingCartInstrumentation` に必要なすべてのコンテキストのクリアリングハウスとして機能します。いくつかの標準の固定パラメーターは、`ObservationContext` のコンストラクター で指定されています。より動的なその他のパラメーター(リクエスト識別子)は、`createShoppingCartInstrumentation` メソッド 内で*ドメインプローブ* がリクエストされた時点で `ObservationContext` によって入力されます。同時に、呼び出し元は `extraParams` パラメーター を介して `createShoppingCartInstrumentation` に追加のコンテキストを渡すこともできます。これらの3セットのコンテキストパラメーターはマージされ 、`ShoppingCartInstrumentation` のインスタンスを作成するために使用されます。

関数型プログラミングの用語では、本質的にここで行っているのは、部分的に適用されたドメイン観測を作成することです。ドメイン観測を構成するフィールドは、`ObservationContext` を構築するときに部分的に適用(指定)され、`ObservationContext` に `ShoppingCartInstrumentation` のインスタンスを要求するときにさらにいくつか適用されます。最後に、残りのフィールドは、`ShoppingCartInstrumentation` のメソッドを呼び出してドメイン観測を実際に記録するときに適用されます。関数型スタイルで作業している場合、部分適用を使用して*ドメインプローブ* を文字通り実装する可能性がありますが、このコンテキストでは、*ファクトリー*パターンなどのOOの同等のものを使用しています。

この部分適用アプローチの大きな利点は、ドメイン観測を記録しているドメインオブジェクトが、そのイベントに含まれるすべてのフィールドを認識する必要がないことです。前の例では、`ShoppingCart` ドメインクラスにそのような退屈な技術メタデータをまったく認識させずに、リクエスト識別子が計測に含まれていることを確認できます。また、計測システムのすべてのクライアントが一貫してそれらを含めることに依存するのではなく、これらの標準フィールドを集中化された一貫した方法で適用することもできます。

*ドメインプローブ*のスコープ

ドメインプローブを設計する際には、各オブジェクトの粒度をどのように設定するかを選択する必要があります。 前述の割引コードの例のように、多くのコンテキスト情報があらかじめ適用された、高度に特殊化されたオブジェクトを多数作成することができます。 あるいは、コンシューマーがドメイン観測を記録するたびに、より多くのコンテキストを渡す必要がある、少数の汎用オブジェクトを作成することもできます。 ここでは、各可観測性呼び出しサイトでの冗長性(コンテキストがあらかじめ適用されていない、特殊化されていないドメインプローブを使用する場合)と、コンテキストがあらかじめ適用された特殊化されたオブジェクトを多数作成することを選択した場合に渡される「可観測性配管」の量のバランスを取る必要があります。

ここでは、正しいアプローチや間違ったアプローチは実際にはありません。各チームは、独自のスタイルの好みをコードベースで表現します。 より関数型スタイルを好むチームは、部分的に適用されたドメインプローブのレイヤーに傾くかもしれません。 より「エンタープライズJava」スタイルを持つチームは、計測コンテキストのほとんどがメソッドのパラメーターとして渡される、少数の大きな汎用ドメインプローブを好むかもしれません。 ただし、どちらのチームも、リクエスト識別子などのメタデータを、そのような技術的な詳細を気にしないドメインプローブクライアントから隠すために、部分適用の概念を使用する必要があります。

代替実装

この記事で説明したドメインプローブパターンは、コードベースにドメイン指向の可観測性を追加する1つの方法にすぎません。ここでは、いくつかの代替アプローチについて簡単に触れます。

イベントベースのオブザーバビリティ

これまでの例では、ショッピングカートドメインオブジェクトはドメインプローブを直接呼び出し、それが次に低レベルの計測システムを呼び出します。 図1 を参照してください。

図1:直接的なドメインプローブ設計

一部のチームは、ドメイン可観測性APIのイベント指向設計を好みます。 ドメインオブジェクトは直接メソッドを呼び出すのではなく、 図2 に示すように、関心のあるオブザーバーに進捗状況を通知するドメイン観測イベント(アナウンスと呼びます)を発行します。

図2:分離されたイベント指向設計

これが、例のShoppingCartでどのように見えるかの概要です。

class ShoppingCart {
  constructor({observationAnnouncer,discountService}){
    this.observationAnnouncer = observationAnnouncer;
    this.discountService = discountService;
  }
  
  applyDiscountCode(discountCode){
    this.observationAnnouncer.announce(
      new ApplyingDiscountCode(discountCode)
    );

    let discount; 
    try {
      discount = this.discountService.lookupDiscount(discountCode);
    } catch (error) {
      this.observationAnnouncer.announce(
        new DiscountCodeLookupFailed(discountCode,error)
      );
      return 0;
    }

    this.observationAnnouncer.announce(
      new DiscountCodeLookupSucceeded(discountCode)
    );

    const amountDiscounted = discount.applyToCart(this);

    this.instrumention.discountApplied(discount,amountDiscounted);

    this.observationAnnouncer.announce(
      new DiscountApplied(discountCode)
    );

    return amountDiscounted;
  }
}

計測したいドメイン観測ごとに、対応するアナウンスクラスがあります。 関連するドメインイベントが発生すると、ドメインロジックは関連するコンテキスト情報(割引コード、割引額など)を含むアナウンスを作成し、observationAnnouncerサービスを介して公開します。 これらのアナウンスを適切な計測システムに関連付けるには、これらの計測システムを呼び出すことで特定のアナウンスに反応するモニターを作成します。 これは、ロギングシステムに記録したいアナウンスを処理するために特化したモニタークラスです。

class LoggingMonitor {
  constructor({logger}){
    this.logger = logger;
  }

  handleAnnouncement(announcement){
    switch (announcement.constructor) {
      case ApplyingDiscountCode:
        this.logger.log(
          `attempting to apply discount code: ${announcement.discountCode}`
        );
        return;

      case DiscountCodeLookupFailed:
        this.logger.error(
          'discount lookup failed',
          announcement.error
        );
        return;

      case DiscountApplied:
        this.logger.log(
          `Discount applied, of amount: ${announcement.amountDiscounted}`
        );
        return;
    }
  }
}

そして、これは2番目のモニターで、メトリックシステムでカウントを保持しているドメイン観測のアナウンスに特化しています。

class MetricsMonitor {
  constructor({metrics}){
    this.metrics = metrics;
  }

  handleAnnouncement(announcement){
    switch (announcement.constructor) {
      case DiscountCodeLookupFailed:
        this.metrics.increment(
          'discount-lookup-failure',
          {code:announcement.discountCode});
        return;

      case DiscountCodeLookupSucceeded:
        this.metrics.increment(
          'discount-lookup-success',
          {code:announcement.discountCode});
        return;
    }
  }
}

これらのモニタークラスはそれぞれ、中央のEventAnnouncerに登録されます。これは、ShoppingCartドメインオブジェクトがアナウンスを送信するのと同じイベントアナウンサーです。 これらのモニタークラスは、以前のドメインプローブと同じ作業を実行しています。実装が存在する場所を再配置しただけです。 このイベント指向アプローチのより分離された性質により、複数の異なる計測テクノロジーの面倒な実装の詳細を担当する単一のドメインプローブクラスを持つのではなく、計測の詳細を、各計測システムに1つずつ、これらの個別の特殊化されたモニタークラスに分割することもできます。

アスペクト指向プログラミング

これまでに説明したドメイン指向の可観測性を適用する手法は、ドメインコードから低レベルの計測呼び出しを削除できますが、それでもドメインロジック全体に散在するドメイン可観測性コードが残っています。 低レベルの計測ライブラリを直接呼び出すよりもクリーンで読みやすいですが、それでも存在します。 ドメインコードから可観測性のノイズを完全に削除したい場合は、おそらくアスペクト指向プログラミング(AOP)に目を向けることができます。 AOPは、可観測性などの横断的な懸念事項をメインのコードフローから抽出することを試みるパラダイムです。 AOPフレームワークは、ソースコードで直接表現されていないロジックを挿入することにより、プログラムの動作を変更します。 その動作がどのように挿入されるかを制御するのは、一種のメタプログラミングによって行われます。ソースコードにメタデータを注釈付けして、横断的なロジックが挿入される場所とその動作を制御します。

この記事で説明してきた可観測性の動作は、まさにAOPが対象とする横断的な懸念事項です。 実際、コードベースにロギングを追加することは、AOPを紹介するために使用される典型的な例です。 また、コードベースですでに何らかのアスペクト指向のメタプログラミングを活用している場合は、AOP手法を使用してドメイン指向の可観測性を実現できるかどうかを検討する価値があります。 ただし、まだAOPを使用していない場合は、ここで注意が必要です。 抽象的には非常に洗練されたアプローチのように思えるかもしれませんが、詳細にはそうではないことが判明する可能性があります。

根本的な問題は、AOPはソースコードレベルで機能しますが、ドメイン可観測性の粒度はコードの粒度と正確に一致しないことです。 一方では、ドメインコードのすべてのメソッド呼び出しを監視し、すべてのパラメーターとすべての戻り値を追跡するような可観測性は望ましくありません。 一方、条件付きステートメントの両側(たとえば、ログインしたユーザーが管理者かどうか)の可観測性を必要とする場合があり、観測対象のドメインイベントが発生した時点では直接利用できない追加のコンテキスト情報を観測に含めたい場合があります。 AOPを使用してドメイン指向の可観測性を実装する場合、難解な注釈でドメインコードを装飾することにより、このインピーダンスの不一致を回避する必要があります。注釈コードが、ドメインコードから削除したい直接の可観測性呼び出しと同じくらい邪魔になるまでです。

このインピーダンスの不一致の問題に加えて、メタプログラミングにはいくつかの一般的な欠点があり、DOOに使用する場合は同じことが当てはまります。 可観測性の実装は、やや「魔法の」ようになり、理解するのが難しくなる可能性があります。[1] AOPを搭載した可観測性のテストも、ドメインプローブに移行することの大きなメリットとして以前に特定した明確なテスト容易性とは対照的に、それほど簡単ではありません。

ドメイン指向のオブザーバビリティはいつ適用すべきか?

これは有用なパターンです。どこに適用する必要がありますか? 私の推奨事項は、ドメインコード(技術的な配管ではなく、ビジネスロジックに焦点を当てたコードベースの領域)に可観測性を追加するときは、常に何らかのドメイン指向の可観測性の抽象化を使用することです。 ドメインプローブのようなものを使用すると、ドメインコードは計測インフラストラクチャの技術的な詳細から分離され、可観測性のテストが実現可能な作業になります。 ドメインコード内で追加される可観測性のタイプは、通常、製品指向であり、価値が高いです。 ここでは、より厳密なドメイン指向の可観測性のアプローチに投資する価値があります。

従うべき簡単なルールは、ドメインクラスは計測システムへの直接参照を持たず、それらのシステムの技術的な詳細を抽象化したドメイン指向の可観測性クラスへのみ参照を持つことです。

既存のコードベースへの適用

これらのパターンを既存のコードベース、おそらくこれまで可観測性がアドホックな方法でしか実装されていないコードベースに導入する方法が疑問に思われるかもしれません。 ここでの私のアドバイスは、テストの自動化を導入する場合と同じアドバイスです。他の理由ですでに作業しているコードベースの領域のみを改修します。すべてを一度に移行するための専用作業を割り当てないでください。 こうすることで、コードの「ホットスポット」(頻繁に変更され、ビジネスにとってより価値のある可能性のある領域)の可観測性が高まり、テストが容易になります。 逆に、コードベースの「休眠」領域にエネルギーを投資することを避けられます。


謝辞

ドメイン指向の可観測性は、私が発明または個人的に発見したものではありません。 あらゆるパターンの記述と同様に、私は長年にわたってさまざまなチームが適用してきたプラクティス、そして他の多くのチームが間違いなく他の場所で使用してきたプラクティスを文書化しているだけです。

ここで説明されているアイデアのいくつかを初めて知ったのは、素晴らしい本 Growing Object-Oriented Software Guided by Tests を通じてでした。 具体的には、第20章の「ロギングは機能です」セクションでは、ロギングをドメインレベルの懸念事項に引き上げること、およびそれがもたらすテスト容易性の利点について説明しています。

Andrew KiellorまたはToby Clemsonのどちらかが、Thoughtworksプロジェクトで一緒に作業しているときに(セマンティックロギングという名前で)ドメインプローブと同様のアプローチを適用する方法を最初に教えてくれたと思います。そして、この概念は、より広範なThoughtworksの集合知の中で長い間出回っていたと確信しています。

より広く可観測性に適用されたこの同じパターンの記述を見たことがありません。そのため、この記事があります。 見つけることができた最も近い類似物は、Microsoftのパターン&プラクティスグループの セマンティックロギングアプリケーションブロック です。 私が理解できる限り、セマンティックロギングに関する彼らの見解は、.NETアプリケーション内で構造化ロギングを容易にする具体的なライブラリです。

この記事の初期の草稿に対する思慮深いフィードバックを提供してくれたCharlie Groves、Chris Richardson、Chris Stevenson、Clare Sudbery、Dan Richelson、Dan Tao、Dan Wellman、Elise McCallum、Jack Bolles、James Gregory、James Richardson、Josh Graham、Kris Hicks、Michael Feathers、Nat Pryce、Pam Ocampo、Steve Freemanに感謝します。

コピー編集してくれた Bob Russell に感謝します。

この記事を彼のサイトでホストすることを快く申し出てくれたMartin Fowlerと、豊富なアドバイスと編集サポートに感謝します。

脚注

1: 元同僚であり、非常に思慮深い人物である Dan Tao は、この記事をレビューしているときに興味深い質問をしました。 ドメインロジックからの可観測性のノイズの量を減らすことは明らかに目標ですが、すべての可観測性ロジックを削除することを目指すべきでしょうか? あるいは、それは行き過ぎ、「魔法」すぎるでしょうか? どれだけの量が適切でしょうか?

重要な改訂

2019年4月9日:最終回を公開

2019年4月8日:実行コンテキストを含めることについての分割払いを公開

2019年4月3日:テストに関する分割払いを公開

2019年4月2日:最初の分割払いを公開