面向领域的可观测性

在这个云和微服务时代,软件系统中的可观测性一直是很有价值的,甚至变得更为重要。然而,我们在系统中增加的可观测性往往是相当低的水平和技术性的,而且,它似乎常常要求我们的代码库乱丢东西,对各种日志的详细调用,仪器,和分析框架。这篇文章描述了一个清理混乱的模式,允许我们在一个清理中添加与业务相关的可观察性,可测试的方式。

03 2019年4月

皮特·霍奇森的照片

皮特·霍奇森是独立软件交付顾问总部设在旧金山湾地区。他专门帮助初创工程团队改进他们的工程实践和技术架构。

皮特以前在ThoughtWorks公司做过六年顾问,负责西海岸业务的主要技术实践。他在旧金山各大初创公司也做过几次技术领先。

发现 类似物品通过查看这些标签: 连续输水· 干净代码· 应用程序体系结构· 测试

由于当前的趋势,如微服务和云计算,现代软件系统正变得更加分布式,运行在不太可靠的基础设施上。在我们的系统中建立可观测性一直是必要的,但这些趋势使其变得比以往任何时候都更加重要。同时,DevOps运动意味着监视生产的人员比以往任何时候都更有可能在运行系统中实际添加自定义的工具代码,而不是将可观测性栓接到一边。

但是,我们如何在我们最关心的事情上增加可观察性?我们的业务逻辑,不会用仪器细节阻塞我们的代码库?而且,如果仪器很重要,我们如何测试我们是否正确地实现了它?在本文中,我演示了面向领域的可观测性哲学如何与一个名为领域探针可以帮助,通过将以业务为中心的可观测性作为我们代码库中的一流概念来处理。


观察什么

“可观测性”的范围很广,从低级技术指标到高级业务关键绩效指标(KPI)。在技术层面,我们可以跟踪内存和CPU利用率,网络和磁盘I/O,线程计数,垃圾收集(GC)暂停。在光谱的另一端,我们的业务/域指标可能会跟踪诸如购物车放弃率等问题,会话持续时间,或支付失败率。

因为这些更高级别的度量是特定于每个系统的,它们通常需要手动滚动仪表逻辑。这与较低水平的技术仪器形成对比,这是更通用的,并且通常是在不修改系统代码库的情况下实现的,除了在引导时注入某种监视代理之外。

同样重要的是要注意,更高的层次,面向产品的度量更具价值,因为,根据定义,它们更紧密地反映了系统正朝着其预期的业务目标运行。

通过添加跟踪这些宝贵指标的工具,我们实现了面向领域的可观测性.


可观测性问题

所以,面向领域的可观测性很有价值,但它通常需要手动滚动仪表逻辑。定制工具与我们系统的核心域逻辑共存,哪里清楚,可维护的代码至关重要。不幸的是,仪表代码往往很嘈杂,如果我们不小心,这会导致混乱的分心。

让我们来看一个引入插入代码可能导致的混乱的例子。在我们添加任何可观察性之前,这里有一个假设的电子商务系统(有些幼稚)的折扣代码逻辑:

班级购物车…

applyDiscountCode (discountCode){让折扣;try discount=this.discountservice.lookupdiscount(折扣代码);}catch(错误)返回0;}const amountdiscounted=折扣。应用于购物车(this);折价返还金额;}

我想说我们这里有一些明确表达的领域逻辑。我们根据折扣代码查找折扣,然后将折扣应用于购物车。最后,我们退回打折的金额。如果我们找不到折扣,我们什么都不做,早点离开。

向购物车应用折扣是一项关键功能,所以良好的可观测性在这里很重要。让我们添加一些工具:

班级购物车…

应用discountcode(折扣代码){this.logger.log(`尝试应用折扣代码:$折扣代码`);让折扣;try discount=this.discountservice.lookupdiscount(折扣代码);}捕获(错误){this.logger.error(“折扣查找失败”,错误);
此.度量.增量('折扣查找失败',代码:折扣代码);返回0;}此.度量.增量(
'折扣查找成功',代码:折扣代码);const amountdiscounted=折扣。应用于购物车(this);this.logger.log(`应用折扣,金额:美元折扣金额`);
this.analytics.track('应用了折扣代码',代码:discount.code,折扣:折扣。金额,折扣金额:折扣金额);折价返还金额;}

除了执行查询和应用折扣的实际业务逻辑之外,我们现在也在呼吁各种仪表系统。我们正在为开发人员记录一些诊断,我们正在为生产中操作此系统的人员记录一些指标,我们还将向我们的分析平台发布一个活动,供产品和营销人员使用。

不幸的是,加上可观测性让我们的好感一团糟,清除域逻辑。我们现在只有25%的代码应用discountcode用于查询和应用折扣的方法。我们刚开始的清洁业务逻辑没有改变,保持清晰和简洁,但它在现在占据方法大部分的低级检测代码中丢失了。另外,我们在域逻辑的中间部分引入了代码复制和魔术字符串。

简而言之,我们的检测代码对于任何试图阅读此方法并查看其实际内容的人来说都是一个巨大的干扰。.


收拾残局

让我们看看是否可以通过重构实现来清理这一混乱。188足球比分直播第一,让我们将这个讨厌的低级仪表逻辑提取到单独的方法中:

类购物车应用discountcode(折扣代码){此._InstrumentApplyingDiscountcode(折扣代码);让折扣;try discount=this.discountservice.lookupdiscount(折扣代码);}捕获(错误){此。_InstrumentDiscountCodeLookupFailed(折扣代码,错误);返回0;}此。_instructDiscountcodeLookupSucceed(Discountcode);const amountdiscounted=折扣。应用于购物车(this);此。_instructure discounted applied(折扣,金额折扣);折价返还金额;}_仪表应用折扣代码(折扣代码)this.logger.log(`尝试应用折扣代码:$折扣代码`);}_ InstrumentDiscountCodeLookupFailed(折扣代码,错误)this.logger.error('折扣查找失败',错误);this.metrics.increment('折扣查找失败',代码:折扣代码);}_ InstrumentDiscountcodeLookupSucceeded(折扣代码)this.metrics.increment('折扣查找成功',代码:折扣代码);}_适用的工具折扣(折扣、折扣金额)this.logger.log(`应用折扣,金额:美元折扣金额`);this.analytics.track('应用了折扣代码',代码:discount.code,折扣:折扣。金额,折扣金额:折扣金额);}

这是一个良好的开端。我们将仪器的细节提取到集中的仪器方法中,将我们的业务逻辑留给每个检测点一个简单的方法调用。它更容易阅读和理解应用discountcode现在,各种仪器系统的分散细节已被压入这些系统中仪器…方法。

然而,这似乎不对购物卡现在有了一系列的私有方法,它们完全专注于不真正购物卡的责任。类中与该类的主要职责无关的功能集群通常表示有一个新类正在尝试出现。

让我们按照这个提示,收集这些工具方法并将它们移入自己的方法中。折扣表班级:

班级购物车…

应用discountcode(折扣代码){此.仪器.应用折扣代码(折扣代码);让折扣;try discount=this.discountservice.lookupdiscount(折扣代码);}捕获(错误){this.instruction.discountcodelookupfailed(discountcode,错误);返回0;}this.instruction.discountcodelookupsucceeded(discountcode);const amountdiscounted=折扣。应用于购物车(this);此。工具。折扣适用(折扣,金额折扣);折价返还金额;}

我们不改变方法;我们只是用一个适当的构造函数将它们移到自己的类中:

等级折扣表{constructor(logger,metrics,analytics)this.logger=logger;this.metrics=指标;this.analytics=分析;}applyingdiscountcode(discount code)this.logger.log(`尝试应用折扣代码:$discount code `);}折扣代码查找失败(折扣代码,错误)this.logger.error(“折扣查找失败”,错误);this.metrics.increment('折扣查找失败',代码:折扣代码);}discountcodelookupsucceeded(discountcode)this.metrics.increment('折扣查找成功',代码:折扣代码);}已应用折扣(折扣,金额折扣)this.logger.log(`已应用折扣,金额:美元折扣金额`);this.analytics.track('应用了折扣代码',代码:discount.code,折扣:折扣。金额,折扣金额:折扣金额);}

我们现在有了一个很好的,明确职责分离:购物卡完全专注于领域概念,如应用折扣,而我们的新折扣表类封装了检测应用折扣过程的所有详细信息。

领域探针

域探测器[…]使我们能够在域逻辑中添加可观测性,同时仍使用域语言进行对话。

折扣表是我调用的模式的示例领域探针.一域探测器介绍了一个面向领域语义的高级工具API,封装实现面向领域的可观测性所需的低级仪器管道。这使我们能够在仍然使用领域,避免仪器技术的分散细节。在前面的示例中,我们的购物卡通过报告正在应用的域观测折扣代码和未能折扣表探测而不是直接在写日志条目或跟踪分析事件的技术领域工作。这似乎是一个微妙的区别,但是,将域代码集中在域上对于保持代码库的可读性有很大的好处,可维护的,可扩展性。


测试可观测性

很少能看到仪器逻辑的良好测试覆盖。我不经常看到自动测试来验证操作失败时是否记录了错误,或者在转换发生时发布包含正确字段的分析事件。这可能部分是由于历史上被认为价值较低的可观测性,但是,这也是因为为低级仪器代码编写好的测试是一件痛苦的事情。

测试仪器代码是一个难题

演示,让我们看看假设的电子商务系统不同部分的一些工具,看看我们如何编写一些测试来验证该工具代码的正确性。

购物卡有一个附加程序方法,它目前通过直接调用各种可观测系统(而不是使用域探测器):

班级购物车…

addtocart(product id)this.logger.log(`adding product'$product id'to cart'$this.id'`);const product=this.productService.lookupproduct(productID);这个。产品。推(产品);此.重新计算总数();this.analytics.track(“产品已添加到购物车”,sku:产品.sku);this.metrics.gauge(“购物车总数”,本.总价);this.metrics.gauge(“购物车大小”,本产品长度);}

让我们看看如何开始测试这个仪表逻辑:

购物车.test.js

const sinon=需要(“sinon”);describe('addtocart',()=>//…它(“记录产品正在添加到购物车中”,()=>const spylogger=日志: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'`);(});(});

在这个测试中,我们正在设置一个购物车进行测试,用一个间谍记录器(一个“间谍”是双倍试验用于验证测试对象如何与其他对象交互)。如果你想知道,可测试的购物车只是一个小助手函数,用于创建购物卡默认情况下具有伪造的依赖项。我们的间谍就位后,我们打电话购物车.addtocart(…)然后检查购物车是否使用记录器记录了适当的消息。

如书面的,此测试确实提供了合理的保证,即当产品添加到购物车时,我们正在记录。然而,它与日志记录的细节非常相关。如果我们决定在将来的某个时候更改日志消息的格式,我们会无缘无故地通过这次考试。这个测试不应该涉及什么已记录,只是那个东西使用正确的上下文数据记录。

我们可以尝试通过与正则表达式(regex)而不是精确的字符串进行匹配来减少测试与日志消息格式细节的耦合程度。然而,这会使验证有点不透明。此外,制作一个强大的regex所需的努力通常是一个很差的时间投资。

此外,这只是一个测试如何记录事件的简单示例。更复杂的情况(例如,日志例外)更让人头疼的是,日志框架的API和它们的ILK在被模拟时无法方便地进行验证。

让我们继续看另一个测试,这次验证我们的分析集成:

购物车.test.js

const sinon=需要(“sinon”);describe('addtocart',()=>//…IT('发布分析事件',()=>const theproduct=genericProduct();const stubproductservice=返回的产品服务(产品);γconst spyAnalytics=跟踪:sinon.spy()const shoppingcart=testableshoppingcart(productService:StubProductService,γ分析:SpyAnalyticsγ(});shoppingcart.addtocart('some-product-id');Expect(spyAnalytics.track)。calledWith(γ'产品已添加到购物车',sku:产品sku);(});(});

这个测试有点复杂,因为我们需要控制从传递回购物车的产品productService.lookupproduct(…),这意味着我们需要注入一个存根产品服务这是为了总是返回特定的产品.我们还注射了一个间谍分析学 ,就像我们注射间谍一样记录器在我们之前的测试中。一切就绪后,我们打电话购物车.addtocart(…)然后,最后,验证四月我们的分析工具被要求创建一个具有预期参数的事件。

我对这次考试相当满意。把那个产品作为间接输入,但这是一个可以接受的折衷,以换取我们在分析活动中对该产品的SKU有信心。我们的测试与事件的确切格式相结合,这也有点遗憾:正如上面的日志测试一样,我宁愿这个测试不关心如何实现可观测性的细节,只是使用正确的数据来完成。

完成测试后,我被这样一个事实吓了一跳,如果我还想测试其他的插入逻辑购物车总计购物车大小米制量规,我需要创建两个或三个额外的测试,它们看起来与这个非常相似。每个测试都需要经过相同的精细依赖性设置工作,尽管这不是测试的重点。当面对这项任务时,一些开发商会咬紧牙关,复制并粘贴现有测试,改变需要改变的东西,继续他们的一天。事实上,许多开发人员会认为第一个测试足够好,并冒着稍后在我们的仪器逻辑中引入错误的风险(可能会有一段时间不被注意到的错误,鉴于仪器的损坏并不总是显而易见的)。

域探测器启用清理程序,更集中的测试

让我们看看如何使用域探测器模式可以改进测试过程。这是我们的购物卡再一次,现在重构为使用领域探针

班级购物车…

AddToCart(产品ID){this.instruction.addingProductToCart(productID:productID,推车:这个);const product=this.productService.lookupproduct(productID);这个。产品。推(产品);此.重新计算总数();this.instruction.addedProductToCart(产品:产品,推车:这个);}

以下是对附加程序

购物车.test.js

const sinon=需要(“sinon”);describe('addtocart',()=>//…它(“将产品添加到购物车的工具”,()= {const spysInstrumentation=createSpyInstrumentation();const shoppingcart=测试购物车({检测:SpyInstrumentation(});shoppingcart.addtocart('the-product-id');Expect(SpyInstrumentation.AddingProductToCart).CalledWith({γproductID:'the-product-id',购物车:购物车);(});它(“将产品成功添加到购物车中”,()=>const theproduct=genericProduct();const stubproductservice=返回的产品服务(产品);const spysInstrumentation=createSpyInstrumentation();const shoppingcart=testableshoppingcart(productService:StubProductService,检测:SpyInstrumentation(});shoppingcart.addtocart('some-product-id');Expect(SpySpecification.AddedProductToCart)。calledWith({γ产品:产品,购物车:购物车);(});函数createSpyInstrumentation()。{return addingProductToCart:sinon.spy(),addedProductToCart:sinon.spy();}(});

介绍域探测器稍微提高了抽象层次,使代码和测试更容易阅读,也不那么脆弱。我们仍在测试仪器是否已经正确实施,我们的测试现在完全验证了我们的可观测性需求,但是我们的测试期望①②不再需要包括怎样实施仪器,只是传递了适当的上下文。

我们的测试捕获本质复杂性在不拖拽太多意外复杂性的情况下增加可观察性。

不过,验证低级仪表细节的正确实施仍然是明智的;忽略在我们的仪器中包含正确的信息可能是一个代价高昂的错误。我们的购物车仪表领域探针负责实施这些细节,因此,对该类的测试是验证这些细节是否正确的自然场所:

购物车Instrumentation.test.js

const sinon=需要(“sinon”);describe('购物车检测',()=>描述(‘addingProductToCart’,()=>it('记录正确的消息',()=>const spylogger=日志:sinon.spy()const instruction=testableinstruction(logger:spylogger);const fakecart=id:'购物车id'仪器。添加产品到零件(零件车:FakeCart,productID:'产品ID')';expect(spylogger.log).calledWith(“将产品'the product id'添加到购物车'the cart id'”);(});(});describe('addedProductToCart',()=>it('发布正确的分析事件',()=>const spyAnalytics=track:sinon.spy()const instruction=testableinstruction(analytics:spyanalytics);const fakecart=const fakeproduct=sku:'产品sku'Instrumentation.AddedProductToCart(Cart:FakeCart,产品:FakeProductγ(});expect(spyanalytics.track).calledWith(“产品已添加到购物车”,sku:'产品sku')';(});它(“更新购物车总仪表”,()=>/…等);它(“更新购物车尺寸表”,()=>/…等);(});(});

在这里,我们的测试可以变得更加集中。我们可以通过产品直接而不是以前的间接注射舞蹈通过模拟出来产品服务在我们购物卡测验。

因为我们的测试购物车仪表重点是该类如何使用第三方工具库,我们可以通过使用之前阻止为这些依赖项设置预连线SPIE:

购物车Instrumentation.test.js

const sinon=需要(“sinon”);describe('购物车检测',()= {让仪器,斯皮洛格间谍分析,计量学;之前(()=>SpyLogger=日志:sinon.spy()SpyAnalytics=跟踪:sinon.spy()spymetrics=规格:sinon.spy()Instrumentation=新购物车Instrumentation(记录器:SpyLogger,分析:间谍分析,指标:间谍指标);(});describe('addingProductToCart',()=>it('记录正确的消息',()= {const spylogger=日志:sinon.spy()const instruction=testableinstruction(logger:spylogger);const fakecart=id:'购物车id'仪器。添加产品到零件(零件车:FakeCart,productID:'产品ID')';expect(spylogger.log).calledWith(“将产品'the product id'添加到购物车'the cart id'”);(});(});describe('addedProductToCart',()=>it('发布正确的分析事件',()= {const spyAnalytics=跟踪:sinon.spy()const instruction=testableinstruction(analytics:spyanalytics);const fakecart=const fakeproduct=sku:'产品sku'Instrumentation.AddedProductToCart(Cart:FakeCart,产品:fakeproduct);expect(spyanalytics.track).calledWith(“产品已添加到购物车”,sku:'产品sku')';(});它(“更新购物车总仪表”,()=>const fakecart=总价:123.45_const fakeproduct=Instrumentation.AddedProductToCart(Cart:FakeCart,产品:fakeproduct);expect(spymmetrics.gauge).calledWith(“购物车总数”,123.45条);(});它(“更新购物车尺寸表”,()=>/…等);(});(});

我们的测试现在非常清晰和集中。每项测试都会验证我们的低级技术仪器的一个特定部分是否作为高级域观测的一部分被正确触发。测试捕获了领域探针:针对我们各种仪器系统的枯燥技术细节呈现特定领域的抽象。

我们将分期发行这篇文章。未来的部分将研究如何为域探测提供上下文,并与使用事件和面向方面编程的备选方案进行比较。

要了解我们何时发布下一期,请订阅网站的RSS馈源,皮特的Twitter订阅源,或马丁的推特流


分享:
如果你觉得这篇文章有用,请分享。感谢您的反馈和鼓励

有关类似主题的文章…

…查看以下标签:

连续输水 干净代码 应用程序体系结构 测试


致谢

面向领域的可观测性不是我发明或亲自发现的。就像任何模式写的一样,我只是在记录一个实践,多年来我看到了不同团队的应用,其他很多球队无疑也在其他地方使用过。

我对这里提出的一些想法最早的介绍是通过这本神奇的书由测试引导的面向对象软件的发展.明确地,第20章中的“日志记录是一个特性”部分讨论了将日志记录提高到域级别的问题,以及带来的可测试性优势。

我认为,安德鲁·基勒或托比·克莱姆森首先向我展示了如何应用类似于领域探针当我们在一起进行一个thoughtworks项目时(我相信这个名称是语义日志记录)。我敢肯定,这个概念在很长一段时间内,一直在更广泛的思想体系中进行循环。

我还没有看到对这种模式的描述更广泛地应用于可观测性;因此本文。我能找到的最接近的模拟是语义日志应用程序块来自Microsoft的模式和实践组。据我所知,他们采用的语义日志是一个具体的库,这使得在.NET应用程序中进行结构化日志记录更加容易。

多亏了查理·格罗夫斯,克里斯·理查森,克里斯史蒂文森,Clare SudberyDan RichelsonDan Tao丹·威尔曼,Elise McCallumJack BollesJames Gregory詹姆斯·理查森,乔希·格雷厄姆,Kris Hicks迈克尔·费瑟,Nat PrycePam Ocampo史蒂夫·弗里曼对本文的早期草稿提供了深思熟虑的反馈。

多亏了鲍勃·罗素用于复制编辑。

非常感谢马丁·福勒慷慨地在他的188bet足球充值网站上主持这篇文章,以及大量的建议和编辑支持。

重要修改

03 2019年4月:已发布的测试分期付款

02 2019年4月:出版的第一期