https://www.martinfowler.com/articles/domain-oriented-observability.html の和訳
Observability は我々のソフトウェアシステムでは常に価値があり、クラウドやマイクロサービスにおいても以前そうです。 しかしながら、システムに追加するObservabilityは本質的に少々低レベルで技術的になりがちで、 多くの場合、様々なロギングやInstrumentation(ここではメトリクスの計測コード)や分析フレームワークへの複雑で冗長な呼び出しを コードベースに散らかす必要があるようです。 この記事では、この混乱を一掃し、ビジネスに関連するObservabilityをクリーンでテスト可能な方法で実現します。
モダンソフトウェアシステムはクラウドやマイクロサービスなどの現在のトレンドのおかげで、より分散されで信頼性の低いインフラの上で実行しています。 システムにObservabilityを組み込むのは常に必要ですが、これらのトレンドから以前よりもより致命的になってきました。 同時に、DevOpsの動きは、本番を監視する人がObservabilityを外から担保するよりも、動いているシステムにカスタムのinstrumentationコードを実際に追加する能力を持っている可能性が以前よりも高いことを意味します。
しかし、どのように、コードベースにInstrumentationの詳細でぐちゃぐちゃにせずに、最も重要なもの、ビジネスロジックにObservabilityを追加すればよいでしょうか。 そして、このInstrumentationが重要なら、それが正しく実装されていることをどうやって確かめましょう。
この記事では、Domain-Oriented Observabilityの哲学とドメインプルーブと呼ばれる実装パターンが、ビジネス試行のObservabilityをコードベース内のファーストクラスのコンセプトとして扱うことでどう役に立つのかを示します。
Observabilityは低レベルな技術的な指標から高いビジネスKPIまで、広いスコープを持っています。 技術的には、メモリやCPU、ネットワークに加えてディスクI/Oやスレッド数、GCの待ち時間などの使用率などの値を追跡できます。 一方で、ビジネスやドメインのメトリクスは、カートの放棄率、セッションの継続時間、決済失敗率などを追跡する可能性があります。
それぞれのシステムではこれらの高次のメトリクスは特別なので、通常は、手動での計測ロジックが必要です。 これは、低レベルの技術的な計測とは対象的で、これは、より一般的で、起動時になんらかの監視エージェントを注入することでシステムのコードベースに変更を加える必要なく得られることがよくあります。
定義上、そのシステムが意図したビジネスのゴールに向かって影響していることをより密接に示しているので 高次でプロダクト指向のメトリクスはより価値があるということを注意するのもまた重要です。
これらの価値あるメトリクスを追跡する計測方法を追加することで Domain-Oriented Observabilityを得ます。
そのため、Domain-Oriented Observabilityは価値がありますが、通常は手動の計測ロジックを必要とします。 そのカスタムinstrumentationはシステムのドメインロジックと一緒に存在し、明確でメンテナンス可能なコードが重要です。 不運なことに、計測コードはノイズになりやすく、気をつけなければ混乱を招くかもしれません。
計測コードの導入で引き起こされる混乱の種類のサンプルを見てみましょう。 ここに、Observabilityを加える前の素朴な仮想の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._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
は
割引を適用する、のような完全にドメインのコンセプトに注目しており
一方で DiscountInsturumentation
classは割引を適用するプロセスの計測の詳細を全てカプセル化しています。
DiscountInstrumentation
は Domain Probe と読んでいるパターンの例です。
Domain ProbeはDomain Oriented Observablityを得るために必要な高レベルの計測APIで
ドメインのセマンティクスに寄り添って、低レベルの計測設備をカプセル化しています。
これによって、ドメインの言葉で話しながら、ドメインロジックへの可観測性を可能とし、一方で 計測技術の気を散らす詳細を回避できます。
先ほど見せた例では、ShoppingCart
は
ログの書き込みや分析イベントの追跡の技術ドメインで直接作業するのではなく、
Domain Observation(適用されている割引コードや割引コードの検索の失敗)を DiscountInstrumentation
へレポートすることによって
可観測性を実装しました。
これは微妙な違いかもしれませんが、ドメインに注目したドメインコードの維持をすることは コードベースの読みやすさや保守性、拡張性の面において 大きな利点になります。
計測ロジックのテストカバレッジが良いのを見るのは稀です。 私はオペレーションが失敗したらエラーがログに記録されることやコンバージョンが発生したときに分析イベントが正しいフィールドを含んで発行されているかを検証している自動テストをあまり見ません。 これはおそらく、可観測性が部分的に歴史的により価値が低いものとしてみなされてきたからですが 低レベルな計測コードに良いテストを書くのは苦しいからでもあります。
実演するために、架空のECシステムの違う場面での計測について見て、計測コードの正しさを検証するテストを書く方法を見てみましょう。
ShoppingCart
は addToCart
メソッドを持っており、これは現在(Domain Probeを使わずに)様々な可観測性システムに対する直接呼び出しで計測されています。
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}'`);
});
});
このテストでは、テストのためにショッピングカートをセットアップし、spy logger ("spy" はテスト対象がどのように他のオブジェクトへの働きかけているかを検証するために使われる
テストダブルのタイプです)で繋ぎこんでいます。
参考までに、testableShoppingCart
は小さなヘルパー関数で、ShoppingCart
のインスタンスをデフォルトで偽物の依存で作ります。
スパイを設置して、shoppingCart.addToCart(...)
を呼び、そのあと
適切なメッセージをログ出力するためにロガーをショッピングカードが使ったことを確認します。
書いてある通り、このテストは カートに商品が追加されたときにログが吐かれることを合理的な保証してくれます。 しかしながら、そのロギングの詳細に非常に結合しています。 将来のどこかでログメッセージのフォーマットを変更したならば、適切な理由もなしにこのテストは壊れるでしょう。
このテストはログ出力されるものの正確な詳細に関心を持つべきではなく、 正しいコンテキストのデータと共に何かが出力されることだけに関心を持つべきでしょう。
正確な文字列の代わりに、正規表現へのマッチによって、テストのログメッセージのフォーマットへの詳細に対する結合具合を下げることはできます。 しかしながら、これは、検査を少し不透明にするでしょう。加えて、頑健な正規表現を作るために必要な努力は通常、時間の無駄な投資です。
さらに、これはどういった内容がログ出力されたかをテストする単にシンプルな例です。 より複雑なシナリオ(ex. 例外のロギング)ではより苦痛を伴います。 ロギングフレームワークのAPIとその同類はモックされている場合に簡単な検証に役立ちません。
Domain Probeパターンを使うことでどのようにテストストーリーを改善できるか見てみましょう。 ここに、またShoppingCartがあり、今Domain Probeを使ってリファクタされています。
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
の instrumentationのテストはこちらです。
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()
};
}
});
Domain Probe の導入は、少し抽象化のレベルを上げて、コードやテストをより頑健にすると同時にで少し読みやすくしました。
まだ instrumentation が正しく実装されていることをテストしています。事実として、テストは今私達の Observability の要件を完全に検証をしています。 しかし、私達のテストの期待①、②はもはや、instrumentationがどのような実装をしているかの詳細を含まなくても良く、適切なコンテキストが渡されるだけで良いです。
私達のテストは過度な非本質的な複雑さを持ち込むことなく、Observabilityの追加の本質的な複雑さを捉えています。
ただし泥臭い低レベルな instrumentation の詳細が正しく実装されているかどうかを検証することはまだ懸命でしょう。
instrumentationに正しい情報が含まれているかどうかを軽視することはコストの高いミスになる可能性があります。
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
});
});
});
ここでまたテストは少しより焦点を絞ることができるでしょう。
ShoppingCart
テストで モックされた productService
を介した以前の間接的なインジェクションのダンスよりも、①で 直接 product
を渡すことが出来るでしょう。
なぜなら、ShoppingCartInstrumentation
はそのクラスがサードパーティの instrumentation ライブラリをどのように使うかに焦点を合わせているため
それらの依存関係のための事前に用意されたスパイをセットアップする 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
});
});
});
今私達のテストはとても明確で焦点が絞られています。それぞれのテストは、低レベルな技術的なinstrumentationの一つの特定の部分が、高レベルな Domain Observation の一部として、正しくトリガーされていることを検証しています。
そのテストは Domain Probe
の意図を掴んでおり、さまざまな instrumentation システムのつまらない技術的な詳細を超えて、ドメイン固有の抽象化を表現しています。