面向領域的可觀察性

我們軟體系統中的可觀察性一直都很寶貴,在雲端和微服務的時代變得更加重要。然而,我們新增到系統中的可觀察性往往相當低階且技術性,而且似乎太常需要用各種記錄、儀器和分析架構的繁瑣、冗長呼叫來充斥我們的程式碼庫。本文說明一個模式,可以清理這個混亂,並讓我們以乾淨、可測試的方式新增與業務相關的可觀察性。

2019 年 4 月 9 日


Photo of Pete Hodgson

Pete Hodgson 是一位獨立軟體交付顧問,居住在美麗多雨的太平洋西北部。他專門協助新創工程團隊改善他們的工程實務和技術架構。

Pete 之前在 Thoughtworks 擔任顧問六年,領導其西岸業務的技術實務。他也曾在多家舊金山新創公司擔任技術主管。


現代軟體系統變得更加分散,而且在可靠度較低的基礎架構上執行,這要歸功於微服務和雲端等現今趨勢。在我們的系統中建置可觀察性一直是必要的,但這些趨勢讓它變得比以往更加重要。同時,DevOps 運動意味著監控生產環境的人員比以往更有可能能夠在執行中的系統中實際新增自訂儀器碼,而不是必須將可觀察性附加在側邊。

但是,我們要如何將可觀察性新增到我們最關心的商業邏輯中,而不讓儀器碼細節塞爆我們的程式碼庫?而且,如果這個儀器碼很重要,我們要如何測試我們是否已正確實作它?在本文中,我將示範如何將面向領域的可觀察性哲學與稱為「領域探測」的實作模式配對,透過將以商業為重點的可觀察性視為我們的程式碼庫中的首要概念,來提供協助。

要觀察什麼

「可觀察性」的範圍很廣,從低階技術指標到高階商業關鍵績效指標 (KPI)。在技術範圍的末端,我們可以追蹤記憶體和 CPU 使用率、網路和磁碟 I/O、執行緒數目和垃圾回收 (GC) 暫停等事項。在範圍的另一端,我們的商業/領域指標可能會追蹤購物車放棄率、工作階段持續時間或付款失敗率等事項。

因為這些較高階指標是特定於每個系統,所以它們通常需要手動編寫儀器碼邏輯。這與較低階技術儀器碼形成對比,後者較為通用,而且通常可以在不大幅修改系統程式碼庫的情況下達成,除了可能在開機時注入某種監控代理程式。

另外,重要的是要注意,較高階、以產品為導向的指標更有價值,因為根據定義,它們更能反映系統是否朝向其預期的商業目標執行。

透過新增追蹤這些有價值指標的儀器碼,我們達到了「面向領域的可觀察性」。

可觀察性的問題

因此,面向領域的可觀察性很有價值,但它通常需要手動編寫儀器碼邏輯。那個自訂儀器碼與系統的核心領域邏輯並存,在其中,清楚、可維護的程式碼至關重要。不幸的是,儀器碼往往很雜亂,如果我們不小心,可能會導致令人分心的混亂。

讓我們看看儀器碼的引入可能會造成哪種混亂。以下是在我們新增任何可觀察性之前,一個假設的電子商務系統(有點天真)的折扣碼邏輯

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;
  }

除了執行查詢和應用折扣的實際商業邏輯外,我們現在還會呼叫各種工具系統。我們會記錄一些診斷資料供開發人員使用,我們會記錄一些指標供在生產環境中操作此系統的人員使用,我們也會在我們的分析平台中發布事件供產品和行銷人員使用。

很遺憾的是,新增可觀察性會破壞我們良好、乾淨的網域邏輯。現在,我們只有 25% 的程式碼在我們的 applyDiscountCode 方法中,用於其查詢和應用折扣的既定目的。我們一開始的乾淨商業邏輯並未改變,而且仍然清晰簡潔,但它在現在佔用大部分方法的低階工具程式碼中遺失了。更重要的是,我們在網域邏輯中間引入了程式碼重複和神奇字串。

簡而言之,我們的工具程式碼對任何嘗試閱讀此方法並查看其實際執行動作的人來說都是一個巨大的干擾。

清理混亂

讓我們看看是否能透過重構我們的實作來清除此混亂。首先,讓我們將那些令人討厭的低階工具邏輯萃取到個別的方法中

  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 探測,而不是直接在撰寫日誌條目或追蹤分析事件的技術網域中運作,來實作可觀察性。這可能看起來是一個細微的區別,但讓網域程式碼專注於網域,在保持程式碼庫可讀性、可維護性和可擴充性方面,會帶來豐厚的回報。

測試可觀察性

很少看到儀器化邏輯有良好的測試涵蓋率。我很少看到自動化測試驗證如果操作失敗,是否會記錄錯誤,或者在轉換發生時,是否會發布包含正確欄位的分析事件。這或許部分歸因於可觀察性在過去被視為較不重要,但也是因為為低階儀器化程式碼撰寫良好的測試是一件很麻煩的事。

測試儀器程式碼很痛苦

為了示範,讓我們看看我們假設的電子商務系統中不同部分的一些儀器化,並了解我們如何撰寫一些測試來驗證該儀器化程式碼的正確性。

ShoppingCartaddToCart 方法,目前使用對各種可觀察性系統的直接呼叫來進行儀器化 (而不是使用網域探測)

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(...)傳回購物車的產品,這表示我們需要注入一個存根產品服務,該服務被設定為總是傳回特定產品。我們還注入一個間諜analytics,就像我們在先前的測試中注入一個間諜logger。在所有設定完成後,我們呼叫shoppingCart.addToCart(...),然後最後驗證我們的分析工具是否被要求使用預期的參數建立一個事件。

我對這個測試感到相當滿意。將該產品作為間接輸入發送到購物車有點麻煩,但為了增加我們在分析事件中包含該產品的 SKU 的信心,這是一個可以接受的權衡。我們的測試與該事件的確切格式相關聯也有些遺憾:與我們上面的記錄測試一樣,我更希望這個測試不關心如何實現可觀察性的細節,而只關心它使用正確的數據完成。

完成該測試後,我感到害怕,因為如果我也想測試其他儀器邏輯(shopping-cart-totalshopping-cart-size 度量規),我需要創建兩個或三個額外的測試,它們看起來與這個測試非常相似。每個測試都需要經歷相同的繁瑣依賴項設置工作,即使這不是測試的重點。面對這項任務時,一些開發人員會咬緊牙關,複製並貼上現有的測試,更改需要更改的內容,然後繼續他們的一天。實際上,許多開發人員會認為第一個測試已經足夠好,並冒著稍後在我們的儀器邏輯中引入錯誤的風險(一個錯誤,可能會有一段時間沒有被發現,因為損壞的儀器並不總是立即可見的)。

領域探測能啟用更乾淨、更聚焦的測試

讓我們看看如何使用網域探測模式來改進測試故事。以下是我們的 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 Domain Probe 負責實作這些細節,因此該類別的測試是驗證我們取得這些正確細節的自然位置

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
      });
    });
  });

我們的測試在此處又可以變得更聚焦。我們可以直接傳遞 product ,而不是透過 ShoppingCart 測試中模擬出的 productService 進行先前的間接注入。

由於我們對 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
      });
    });
  });

我們的測試現在非常清楚且聚焦。每個測試都驗證我們的低層級技術儀器化的特定部分是否正確觸發,作為較高層級 Domain Observation 的一部分。這些測試擷取 Domain Probe 的意圖:針對我們的各種儀器化系統的無聊技術細節提供特定於網域的抽象。

包含執行內容

儀器化事件總是需要包含脈絡化元資料;也就是說,用於了解已觀察事件周圍更廣泛脈絡的資訊。

元資料類型

Web 服務常見的一種元資料是 要求識別碼,用於促進分散式追蹤,將組成單一邏輯運算的各種分散式呼叫串連在一起(您可能也會看到這些識別碼稱為 關聯識別碼,或 追蹤和區間 識別碼)。

另一個常見的特定於要求的元資料是 使用者識別碼,記錄提出要求的使用者,或在某些情況下,記錄「主體」的資訊,也就是代表外部系統提出要求的行為者。有些系統也會記錄 功能旗標 元資料,也就是資訊說明這個要求已放入哪些實驗性 "區塊",甚至只是每個旗標的原始狀態。在使用 Web 分析將使用者行為與功能變更關聯時,這些元資料至關重要。

還有其他一些更技術性的元資料在嘗試了解事件如何與系統中的變更相關時會很有幫助,例如 軟體版本處理序和執行緒識別碼,也許還有 伺服器主機名稱

有一種元資料對於關聯儀器化事件與系統中的變更非常重要,以至於顯而易見到幾乎不需要特別說明:表示事件發生時間的 時間戳記

注入元資料

提供此情境中繼資料給網域探測可能會有點麻煩。網域觀察呼叫通常由網域程式碼執行,而程式碼預期不會直接接觸技術細節,例如要求 ID 或功能標記組態;這些技術細節不應是網域程式碼的考量。因此,我們要如何確保我們的網域探測擁有所需的技術細節,卻又不讓這些細節污染我們的網域程式碼?

我們這裡遇到的情況是相當典型的依賴注入場景:我們需要將正確組態的網域探測依賴項注入網域類別,而不用將網域探測的所有傳遞依賴項拖進網域類別。我們可以從可用的依賴注入模式選單中選擇偏好的解決方案。

我們來看看前面購物車折扣碼的範例,並檢視幾個替代方案。為了喚起我們的記憶,以下是我們放置已編寫工具的 ShoppingCartapplyDiscountCode 實作:

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;
  }

現在,問題是,我們的 ShoppingCart 類別中的 this.instrumentation(我們的網域探測)是如何設定的?我們可以簡單地將它傳遞到我們的建構函式中

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;
}

這樣更好。我們可以將內容傳遞到我們的網域探測一次,並避免重複傳遞相同的資訊。

收集儀器內容

如果我們退一步,看看我們在這裡做什麼,我們基本上是在建立我們網域探測的更具針對性的版本,特別組態為在此特定內容中記錄網域觀察。

我們可以進一步發揮這個概念,使用它來確保我們的網域探測可以存取它需要包含在編寫工具記錄中的相關技術內容,例如要求識別碼,而完全不必將這些技術細節公開給我們的 ShoppingCart 網域類別。以下是如何執行此項操作的方法之一,透過建立新的觀察內容類別:

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 的建構函式中指定 。其他較為動態的參數(請求識別碼)由 ObservationContext 在請求 網域探測 時的 createShoppingCartInstrumentation 方法中填入 。在同一點,呼叫者本身也可以透過 extraParams 參數將額外的脈絡傳遞到 createShoppingCartInstrumentation 。這三組脈絡參數接著會合併在一起 ,並用來建立 ShoppingCartInstrumentation 的執行個體。

以函數式程式設計來說,我們在此所做的基本上是建立一個 部分套用的 網域觀察。當我們建構 ObservationContext 時,組成我們網域觀察的欄位會部分套用(指定),接著當我們向 ObservationContext 要求 ShoppingCartInstrumentation 的執行個體時,會再套用一些欄位。最後,當我們呼叫 ShoppingCartInstrumentation 上的方法來實際記錄我們的網域觀察時,會套用其餘的欄位。如果我們使用函數式風格,我們可能會實際使用部分套用來實作我們的 網域探測,但在這個脈絡中,我們使用的是物件導向的等效方式,例如 工廠 模式

這種部分套用方法的一個顯著優點是,記錄網域觀察的網域物件不需要知道每個進入該事件的欄位。在前面的範例中,我們可以確保請求識別碼包含在我們的工具中,同時讓我們的 ShoppingCart 網域類別完全不知道這種繁瑣的技術性元資料。我們也可以以集中且一致的方式套用這些標準欄位,而不是依賴我們工具系統的每個客戶端一致地包含這些欄位。

網域探測 的範圍

在設計我們的 網域探測 時,我們必須選擇每個物件的粒度。我們可以建立許多高度專業化的物件,這些物件預先套用許多脈絡資訊,例如先前的折扣碼範例。或者,我們可以建立幾個通用物件,要求消費者在每次記錄網域觀察時傳遞更多脈絡。此處的折衷點在於每個可觀察性呼叫站台的詳細程度(如果我們使用預先套用較少脈絡的較不專業的 網域探測)與如果我們選擇建立許多預先套用脈絡的專業化物件,則會傳遞更多「可觀察性管道」。

這裡沒有真正正確或錯誤的方法,每個團隊都在其程式碼庫中表達自己的風格偏好。傾向於更具功能性的風格的團隊可能會傾向於部分套用 網域探測 的層級。具有更多「企業 Java」風格的團隊可能會偏好幾個大型的通用 網域探測,其中大多數儀器化脈絡作為參數傳遞給這些方法。然而,這兩個團隊都應該使用部分套用的概念來隱藏元資料,例如請求識別碼,以避免 網域探測 客戶端在意這些技術細節。

替代實作

我在本文中提出的 網域探測 模式只是將面向網域的可觀察性新增到程式碼庫的一種方式。我將在此簡要說明一些替代方法。

基於事件的可觀察性

在我們迄今為止的範例中,購物車網域物件直接呼叫 網域探測,而 網域探測 又會呼叫我們的較低層級儀器化系統,如 圖 1 所示。

圖 1:直接 Domain Probe 設計

有些團隊偏好為其網域可觀察性 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;
  }
}

對於我們可能想要監控的每個網域觀察,我們都有對應的 Announcement 類別。當相關網域事件發生時,我們的網域邏輯會建立一個包含相關內容資訊(折扣碼、折扣金額等)的 Announcement,並透過 observationAnnouncer 服務發布它。然後,我們可以透過建立 Monitors 來將這些公告連結到適當的監控系統,這些 Monitors 會透過呼叫那些監控系統來對特定公告做出反應。以下是專門用來處理我們想要記錄到我們的記錄系統中的公告的 Monitor 類別

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;
    }
  }
}

以下是第二個 Monitor,它專門處理我們在指標系統中持續計數的網域觀察公告

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;
    }
  }
}

這些 Monitor 類別中的每個類別都會註冊到一個中央 EventAnnouncer - 我們的 ShoppingCart 網域物件會向這個相同的事件公告者發送公告。這些 Monitor 類別執行的工作與我們早先的 網域探測 相同,我們只是重新安排了該實作所在的位置。這種事件導向方法的解耦特性也讓我們能夠將監控細節分割成這些獨立的專用 Monitor 類別,每個監控系統一個,而不是有一個單一的 網域探測 類別最終負責多種不同監控技術的混亂實作細節。

面向面向程式設計

到目前為止,我們討論過的應用網域導向可觀察性的技術可以從我們的網域程式碼中移除低階監控呼叫,但我們仍然有一些網域可觀察性程式碼穿插在我們的網域邏輯中。它比直接呼叫低階監控程式庫更簡潔且易於閱讀,但它仍然存在。如果我們想要完全從我們的網域程式碼中移除可觀察性雜訊,我們可以考慮使用 面向切面程式設計 (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 日:發布第一個章節