面向领域的 Observability
在我们的软件系统中,可观察性一直都很重要,并且在这个云和微服务时代变得更加重要。 然而,我们添加到系统中的可观察性往往在本质上是相当底层和技术性的,而且它似乎经常需要在我们代码库中充斥着对各种日志记录、仪表和分析框架的粗糙、冗长的调用。 本文描述了一种模式,可以清理这种混乱,并允许我们以一种干净、可测试的方式添加与业务相关的可观察性。
2019 年 4 月 9 日
由于微服务和云计算等当前趋势,现代软件系统正在变得更加分布式,并且运行在不太可靠的基础设施上。 在我们的系统中构建可观察性一直都是必要的,但这些趋势使其比以往任何时候都更加重要。 同时,DevOps 运动意味着监控生产的人员比以往任何时候都更有可能在运行的系统中添加自定义 Instrumentation 代码,而不是不得不将可观察性附加到侧面。
但是,我们如何在我们最关心的业务逻辑中添加可观察性,而不会用 Instrumentation 细节阻塞我们的代码库? 而且,如果这种 Instrumentation 很重要,我们如何测试我们是否正确实现了它? 在本文中,我将演示面向领域的 Observability 的理念与称为“领域探针”的实现模式如何通过将以业务为中心的 Observability 视为我们代码库中的一等概念来提供帮助。
观察什么
“可观察性”的范围很广,从低级技术指标到高级业务关键绩效指标 (KPI)。 在频谱的技术端,我们可以跟踪内存和 CPU 利用率、网络和磁盘 I/O、线程数和垃圾收集 (GC) 暂停等内容。 在频谱的另一端,我们的业务/领域指标可能会跟踪诸如购物车放弃率、会话持续时间或付款失败率之类的内容。
因为这些更高级别的指标是特定于每个系统的,所以它们通常需要手工编写的 Instrumentation 逻辑。 这与低级技术 Instrumentation 形成对比,后者更通用,并且通常可以在不大幅修改系统代码库的情况下实现,除了可能在启动时注入某种监控代理。
同样重要的是要注意,更高级别的、面向产品的指标更有价值,因为根据定义,它们更能反映系统正在朝着其预期的业务目标执行。
通过添加跟踪这些有价值指标的 Instrumentation,我们实现了“面向领域的 Observability”。
Observability 的问题
因此,面向领域的 Observability 很有价值,但它通常需要手工编写的 Instrumentation 逻辑。 该自定义 Instrumentation 与我们系统的核心领域逻辑一起存在,在这些系统中,清晰、可维护的代码至关重要。 不幸的是,Instrumentation 代码往往很嘈杂,如果我们不小心,它会导致令人分心的混乱。
让我们看一个例子,说明引入 Instrumentation 代码会导致什么样的混乱。 这是我们在添加任何可观察性之前假设的电子商务系统(有点天真)的折扣代码逻辑
class ShoppingCart…
applyDiscountCode(discountCode){ let discount; try { discount = this.discountService.lookupDiscount(discountCode); } catch (error) { return 0; } const amountDiscounted = discount.applyToCart(this); return amountDiscounted; }
我想说我们在这里有一些明确表达的领域逻辑。 我们根据折扣代码查找折扣,然后将折扣应用于购物车。 最后,我们返回折扣金额。 如果我们找不到折扣,我们什么也不做,提前退出。
这种将折扣应用于购物车的做法是一项关键功能,因此良好的可观察性在这里很重要。 让我们添加一些 Instrumentation
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; }
除了执行查找和应用折扣的实际业务逻辑之外,我们现在还调用各种 Instrumentation 系统。 我们正在为开发人员记录一些诊断信息,我们正在为在生产中运行此系统的人员记录一些指标,我们还将事件发布到我们的分析平台中,供产品和营销人员使用。
不幸的是,添加可观察性使我们漂亮、干净的领域逻辑变得一团糟。 我们现在只有 25% 的代码在我们的 applyDiscountCode
方法中用于其查找和应用折扣的既定目的。 我们开始使用的干净业务逻辑没有改变,并且仍然清晰简洁,但它在我们现在占据该方法大部分的低级 Instrumentation 代码中丢失了。 更重要的是,我们在领域逻辑的中间引入了代码重复和魔术字符串。
简而言之,我们的 Instrumentation 代码对于任何试图阅读此方法并查看其实际“作用”的人来说都是一个巨大的干扰。
清理混乱
让我们看看是否可以通过重构我们的实现来清理这种混乱。 首先,让我们将那些讨厌的低级 Instrumentation 逻辑提取到单独的方法中
…
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 }); } }
这是一个好的开始。 我们将 Instrumentation 细节提取到重点突出的 Instrumentation 方法中,并在每个 Instrumentation 点留下一个简单的方法调用来处理我们的业务逻辑。 现在更容易阅读和理解 applyDiscountCode
了,因为各种 Instrumentation 系统的令人分心的细节已被推送到那些 _instrument...
方法中。
但是,ShoppingCart
现在有一堆完全专注于 Instrumentation 的私有方法,这似乎不太对——这实际上不是 ShoppingCart
的责任。 类中的一组与其类的主要职责无关的功能通常表明有一个新类正在出现。
让我们通过收集这些 Instrumentation 方法并将它们移到自己的 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
类封装了 Instrumentation 应用折扣过程的所有细节。
领域探针
领域探针[...] 使我们能够在仍然使用领域语言的情况下为领域逻辑添加可观察性
DiscountInstrumentation
是我称之为“领域探针”的模式的一个例子。 “领域探针”提供了一个面向领域语义的高级 Instrumentation API,封装了实现面向领域的 Observability 所需的低级 Instrumentation 管道。 这使我们能够在仍然使用“领域”语言的情况下为领域逻辑添加可观察性,从而避免了 Instrumentation 技术的令人分心的细节。 在我们前面的示例中,我们的 ShoppingCart
通过向 DiscountInstrumentation
探针报告领域观察结果(正在应用的折扣代码和查找失败的折扣代码)而不是直接在编写日志条目或跟踪分析事件的技术领域工作来实现可观察性。 这可能看起来是一个微妙的区别,但保持领域代码专注于领域会在保持代码库可读、可维护和可扩展方面带来丰厚的回报。
测试 Observability
很少看到 Instrumentation 逻辑的良好测试覆盖率。 我不经常看到自动化测试来验证如果操作失败是否记录了错误,或者在发生转换时是否发布了包含正确字段的分析事件。 这可能部分是由于历史上认为可观察性的价值较低,但也因为它很难为低级 Instrumentation 代码编写好的测试。
测试 Instrumentation 代码很痛苦
为了演示,让我们看看我们假设的电子商务系统另一部分的一些 Instrumentation,并看看我们如何编写一些测试来验证该 Instrumentation 代码的正确性。
ShoppingCart
有一个 addToCart
方法,该方法当前使用对各种可观察性系统的直接调用进行 Instrumentation(而不是使用“领域探针”)
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 ); }
让我们看看我们如何开始测试这个 Instrumentation 逻辑
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(...)
,然后最终验证 ④ 我们的分析 Instrumentation 是否被要求使用预期参数创建事件。
我对这个测试感到相当满意。 将该产品作为间接输入发送到购物车有点麻烦,但这是一个可以接受的折衷方案,以换取我们确信在我们的分析事件中包含该产品的 SKU。 我们的测试与该事件的确切格式相耦合也有点可惜:就像我们上面的日志记录测试一样,我希望此测试不关心如何实现可观察性的细节,而只关心它是否正在使用正确的数据完成。
完成该测试后,我感到很沮丧,因为如果我还想测试其他仪表逻辑(`shopping-cart-total` 和 `shopping-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` *域探针*负责实现这些细节,因此该类的测试是验证我们是否正确获取这些细节的自然场所
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 }); }); });
我们的测试现在非常清晰和集中。每个测试都验证了我们低级技术仪表的一个特定部分是否作为更高级别的域观察的一部分被正确触发。这些测试捕获了*域探针*的意图:在各种仪表系统的无聊技术细节之上呈现特定于域的抽象。
包含执行上下文
仪表事件始终需要包含上下文元数据;也就是说,用于理解已观察到的事件周围更广泛上下文的信息。
元数据类型
Web 服务中常见的一段元数据是*请求标识符*,用于促进分布式跟踪——将构成单个逻辑操作的各种分布式调用联系在一起(您可能还会看到这些标识符被称为关联标识符,或跟踪和跨度标识符)。
另一个常见的特定于请求的元数据是*用户标识符*,记录哪个用户正在发出请求,或者在某些情况下,记录“主体”的信息——代表其发出请求的外部系统的参与者。某些系统还会记录功能标志元数据——有关此请求已放入哪些实验性“存储桶”的信息,甚至只是每个标志的原始状态。当使用网络分析将用户行为与功能更改相关联时,这些元数据位至关重要。
还有一些其他的、更技术性的元数据,在试图理解事件如何与系统中的更改相关联时可能会有所帮助,例如*软件版本*、*进程和线程标识符*,也许还有*服务器主机名*。
有一段元数据对于关联仪表事件至关重要,以至于几乎不用说:指示事件发生时间的*时间戳*。
注入元数据
将此上下文元数据提供给*域探针*可能有点麻烦。域观察调用通常由域代码发出,希望域代码不会直接暴露于请求 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; }
那更好。我们能够将上下文传递给*域探针*一次,并避免重复传递相同的信息。
收集 Instrumentation 上下文
如果我们退后一步看看我们在这里所做的事情,我们本质上是在创建一个更有针对性的*域探针*版本,专门配置为在此特定上下文中记录域观察。
我们可以进一步利用这个想法,使用它来确保我们的*域探针*可以访问它需要包含在仪表记录中的相关技术上下文——例如,请求标识符——而无需将这些技术细节暴露给我们的 `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` 上的方法来实际记录我们的域观察时,将应用剩余的字段。如果我们以函数式风格工作,我们可能会使用部分应用程序来实现我们的*域探针*,但在这种情况下,我们使用的是 OO 等价物,例如*工厂*模式。
这种部分应用程序方法的一个显着优势是,记录域观察的域对象不需要知道进入该事件的每个字段。在前面的示例中,我们可以确保请求标识符包含在我们的仪表中,同时让我们的 `ShoppingCart` 域类完全不知道此类繁琐的技术元数据。我们还能够以集中、一致的方式应用这些标准字段,而不是依赖于我们仪表系统的每个客户端来一致地包含它们。
*域探针*的范围
在设计我们的*域探针*时,我们需要选择每个对象的粒度。我们可以创建许多高度专业化的对象,这些对象具有大量预先应用的上下文信息,例如之前的折扣代码示例。或者,我们可以创建一些通用对象,这些对象要求使用者在每次记录域观察时传递更多上下文。这里的权衡是在每个可观察性调用站点上的更多冗长(如果我们使用预先应用的上下文较少的、专业化程度较低的*域探针*)与如果我们选择创建许多预先应用上下文的专业化对象,则会传递更多“可观察性管道”。
这里没有真正的好坏方法——每个团队都在他们的代码库中表达他们自己的风格偏好。倾向于更实用风格的团队可能会倾向于部分应用的*域探针*层。具有更多“企业 Java”风格的团队可能更喜欢一些大型的、通用的*域探针*,其中大多数仪表上下文作为参数传递给这些方法。但是,这两个团队都应该使用部分应用程序的想法来隐藏元数据,例如来自不关心此类技术细节的*域探针*客户端的请求标识符。
替代实现
我在本文中提出的*域探针*模式只是将面向域的可观察性添加到代码库的一种方法。我在这里简要介绍一些替代方法。
基于事件的 Observability
到目前为止,在我们的示例中,购物车域对象直接调用*域探针*,后者又调用我们的低级仪表系统,如图 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; } } }
这是第二个监视器,专门用于我们在指标系统中统计的域观察公告
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 类中,每个检测系统一个,而不是只有一个*领域探针*类,最终负责多个不同检测技术的混乱实现细节。
面向切面编程
到目前为止,我们讨论的应用面向领域的 Observability 的技术可以从我们的领域代码中删除低级检测调用,但我们仍然在领域逻辑中穿插了一些领域 Observability 代码。它比直接调用低级检测库更清晰、更容易阅读,但它仍然存在。如果我们想完全从领域代码中删除可观察性噪声,我们也许可以求助于*面向方面编程* (AOP)。AOP 是一种尝试从主代码流中提取横切关注点(如可观察性)的范 paradigm 。AOP 框架通过注入未在源代码中直接表达的逻辑来修改程序的行为。我们通过一种元编程来控制该行为的注入方式,在这种元编程中,我们使用元数据来注释源代码,这些元数据控制着横切逻辑的注入位置和行为方式。
我们在本文中一直在讨论的可观察性行为正是 AOP 旨在解决的横切关注点类型。事实上,向代码库添加日志记录几乎是用于介绍 AOP 的典型示例。如果您的代码库已经在利用某种面向方面的元编程,那么当然值得考虑是否可以使用 AOP 技术来实现面向领域的 Observability。但是,如果您还没有使用 AOP,我建议您在这里谨慎行事。尽管从抽象的角度来看,它似乎是一种非常优雅的方法,但在细节上,它可能并非如此。
根本问题是 AOP 在源代码级别工作,但领域 Observability 的粒度与我们代码的粒度并不完全一致。一方面,我们不希望围绕领域代码中的每个方法调用进行可观察性,跟踪每个参数和每个返回值。另一方面,我们有时确实希望在条件语句的两侧进行可观察性——例如,刚刚登录的用户是否是管理员——并且我们有时希望在我们的观察中包含可能无法直接获得的额外上下文信息在观察到的领域事件发生的那一刻。如果使用 AOP 来实现面向领域的 Observability,那么我们必须通过用抽象注释来装饰我们的领域代码来解决这种阻抗不匹配问题,以至于注释代码变得像我们想要从领域代码中删除的直接可观察性调用一样分散注意力。
除了这种阻抗不匹配问题之外,元编程还有一些普遍的缺点,这些缺点在将其用于 DOO 时也同样适用。可观察性实现可能会变得有些“神奇”且难以理解。[1] 测试 AOP 驱动的可观察性也远没有那么简单,这与我们之前确定的转向*领域探针*的主要优势(清晰的可测试性)形成了鲜明对比。
何时应用面向领域的 Observability?
这是一种有用的模式;我们应该在哪里应用它?我的建议是在向*领域*代码添加可观察性时始终使用某种面向领域的 Observability 抽象——代码库中专注于业务逻辑的区域,而不是技术管道。使用类似*领域探针*的东西可以使领域代码与检测基础设施的技术细节分离,并使测试可观察性成为一项可行的努力。在您的领域代码中添加的可观察性类型通常是面向产品的并且具有很高的价值。在这里值得投资于更严格的面向领域的 Observability 方法。
一个简单的规则是,您的领域类永远不应该直接引用任何检测系统,而只应引用抽象化这些系统技术细节的面向领域的 Observability 类。
改造现有代码库
您可能想知道如何将这些模式引入到现有的代码库中,也许到目前为止,可观察性只是以临时的方式实现的。我在这里的建议与我对引入测试自动化的建议相同:只改造您出于其他原因已经在处理的代码库区域。不要一次性分配专门的工作来移动所有内容。这样,您就可以确保代码中的“热点”(经常更改且可能对业务更有价值的区域)变得更易于观察和测试。相反,您避免将精力投入到代码库中“休眠”的区域。
致谢
面向领域的 Observability 不是我发明或个人发现的东西。与任何模式文章一样,我只是记录了我多年来看到各个团队应用的实践,以及许多其他团队无疑在其他地方使用过的实践。
我对这里提出的一些想法的最早介绍是通过这本很棒的书 测试驱动面向对象软件的开发。具体来说,第 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 日:发布第一部分