7.02 Middleware-Trace简介和OpenTelemetry
PART1. Trace简介
Tracing:踪迹.它记录了从收到请求到返回响应的整个过程.在分布式环境下,一般代表着请求从Web收到,沿着后续微服务链条传递,得到响应再返回前端的过程.
如图所示,这里说的"请求从Web收到",是指BFF层收到.
BFF层:Backend For Frontend层,针对聚合服务的一层,用于为前端页面拼凑来自各个领域服务的数据
在BFF层之前的过程(比如Nginx网关),不会被记录在Tracing中.
通常状况下,BFF层调用聚合服务层,聚合服务调用(多个)领域服务.包括被聚合服务调用到的领域服务,其本身也会调用其他领域服务.
普适的调用链如下:
BFF --调用--> 聚合服务 --调用--> 领域服务1 --调用--> 领域服务2
BFF <--响应-- 聚合服务 <--响应-- 领域服务1 <--响应-- 领域服务2
经过这个完整的调用链后,即可得到一个记录了完整调用过程的trace
1.1 链路追踪
step1. BFF层收到请求,调用订单聚合服务
step2. 订单聚合服务调用风控服务.这里的调用取决于公司的技术选型,有可能是RPC调用,也有可能是HTTP调用
step3. 风控服务响应订单聚合服务:当笔订单能否继续进行
一般真实的创建订单的过程,非常复杂.以风控服务为例,会判断很多因素:
商家是否为正常商家(商家有无洗钱嫌疑);
订单中的商品是否为正常的商品
买家是否为一个正常的买家(是否存在刷单嫌疑);
step4. 订单聚合服务调用商品服务
step5. 商品服务返回商品的定价、商品的库存等商品关键信息
step6. 订单聚合服务调用促销服务,获取当笔订单可用的优惠券、红包等折扣信息
step7. 促销服务返回可用的优惠券、红包
step8. 订单聚合服务计算订单金额(这里的金额包括很多金额:优惠前金额、优惠后金额、实付金额等各种金额)
有些大厂在这个步骤上的实现非常复杂,比如根据价格引擎中配置的计价规则(这个规则是运营人员配置的,很复杂)计算各种商品的价格,最终求出订单的金额
step9. 订单聚合服务创建订单
这个步骤也很复杂,涉及到扣除库存等细节问题的处理
需要注意的是,trace中不仅包含服务调用信息,而是包含整条链路上的信息,共包括:
RPC调用
HTTP调用
数据库查询
发送消息(例如发送消息至MQ)
业务步骤(例如上述步骤的step8)
这些步骤,也可以进一步被细分,分成更加细的span
注意:图中绿色的BFF创建订单
和紫色的订单聚合服务
都叫做span
长方形的宽表示耗时,宽的值越大则表示耗时越长.
需要注意的是,并不一定能在BFF的span内看到所有的调用信息.因为有一些敏感信息(比如图中的风控服务)可能不希望被看到,那么可以设置在风控服务的span内,将一些步骤的链路追踪设置为不可见.
这样一来,在BFF的span内就只能看到风控服务span的总体情况,而看不到内部的细节.
1.2 相关概念
tracer:表示记录trace(踪迹)的实例,一般来说,tracer会有一个对应的接口.可以理解为Factory或Builder的概念,用于构造trace
span:代表trace中的一段.因此trace本身也可以看做是一个span.span本身是一个层级概念,因此有父子关系.一个trace的span可以看做是多叉树
可以看到上图中存在2处空隙.空隙表示的是代码运行的耗时.如果一个空隙很宽的话,那么说明打点不够详细.需要再在代码中打入一些追踪点
1.3 tracing工具
目前在业界里面,tracing工具还处于百花齐放阶段,有很多开源实现.例如:SkyWalking、Zipkin、Jeager等
1.3.1 第1个问题:需不需要同时支持多种tracing工具?
这个问题的本质是:我们设计的框架,其使用者是谁?
给公司内部使用:那么就做成仅支持公司使用的、特定的tracing工具即可.例如公司用zipkin,那就支持zipkin就行了;甚至公司有自研的tracing工具,那么就支持这个自研的tracing工具即可
设计开源框架:开源框架的使用者,那就是使用任何tracing工具都有可能了.因此需要支持主流的tracing工具
那么问题来了,该怎么支持这些开源工具?
1.3.2 第2个问题:怎么支持这些开源工具?
总体的思路其实就是两种:
为每个tracing工具写一个Middleware(如上图的上半部分)
这个思路的缺点在于:
每个中间件都会和对应的tracing工具强耦合
框架会对这些具体的tracing工具产生依赖关系
定义一个统一的API,框架只和这个API交互.这个API允许用户注入自己的tracing工具的实现(如下图的上半部分)
很明显后一种思路是抽象层级更高、更通用的解决方案.因此我们采用这种思路来设计我们框架中的Tracing部分
1.4 自定义API
一般来讲,如果一个中间件设计者想要摆脱对任何第三方的依赖,都需要采用定义自己的API的方式来达到这个目的,常见的有:
定义Log API
定义Config API
定义Tracing API
定义Metrics API
1.4.1 为什么我们没有设计设计Log API和Config API?
那么问题来了:我们在设计框架的时候,并没有设计Log API和Config API啊?
这里以HTTPServer.Start()
方法和NewHTTPServer()
函数举例说明.
来看我们的HTTPServer.Start()
方法的方法签名:
调用HTTPServer.Start()
方法时,我们作为框架的设计者,要求用户把参数传进来.至于这个参数是用户通过何种途径得到的,框架的设计者并不关心.
至于这个参数到底是怎么来的,那可能性就太多了:
从配置文件中读取的
从环境变量中读取的
从程序启动时的参数中读取的
我们作为框架的设计者,如果要去管"参数怎么来的"这件事,那根本是管不完的,而且管这个事儿的意义和含金量都不大.
不如提供一个编程接口,像HTTPServer.Start()
方法那样设计:框架的设计者不关心使用者的参数从何种途径获得,只关心用户传到方法中的参数值即可.
和这种思路相反的思路是耦合式开发.我们以"创建HTTPServer
实例"这个功能为例来说明:
按照这种设计形态,则必然需要设计一个Config
结构体.
如果采取这种设计思想,面临的问题会很多,这里列举几个:
参数
configFilePath
,是相对路径还是绝对路径?如果是相对路径,是相对于哪个路径的路径?
让使用者传递相对路径,是比较容易出错的.因为使用者很有可能不知道这个函数中定义的相对路径,相对的是哪个路径.可能有人会直觉性地认为相对的是工程根目录,但在稍微复杂一些的运维环境中,研发人员真的未必知道工程根目录的路径是什么
配置文件格式问题
由于需要读取不同格式(json/yaml/toml/xml/envfile)的配置文件,因此不可避免地会引入类似viper这种第三方库,这就对第三方产生了依赖
也是因为配置文件格式的多样性,框架的设计者还需要实现针对不同格式的序列化与反序列化.这也是一个复杂且恶心的过程
但实际上设计者直接把这个权利交给用户就好了,根本没必要去实现这些父母式编程的功能.只实现编程接口就可以了
1.4.2 如何摆脱第三方依赖?
如上图示,想要彻底摆脱第三方依赖,那就得定义出自己的API.换言之就是我们设计的框架核心是稳定的、不直接依赖于第三方库的.使用者在初始化框架的过程中,将API对应的实现通过框架暴露给外部的接口注入进来即可.
a. 自定义API的优点
这种设计的好处就在于框架的内核稳定,任何第三方库的变更都不会影响到框架
b. 自定义API的缺点
过度设计:有些场景下即使定义了API,也只会有1个默认实现(比如
echo.Context
接口,它仅有1个实现echo.context
)API设计的并不怎么样
当然,这并不是自定义API这种设计形态的缺点,而是写代码的人(就是我自己)自身的缺点.
评判一个API设计的好不好,大体上从以下几个方面就能看出来:
易用性:
框架的使用者通过我定义的API接入一个实现,接入的过程是否容易?
扩展性:
你设计的API不可能不发生任何的变更,随着第三方库的功能扩展或其他原因,你设计的用于接入第三方的API,迟早会有需要变更的一天.当变更来到时,你当初的设计能否让变更对第三方库的影响降到最低?
TODO:我对这句话没概念
忠告:如果你设计不好这种API,那就不要采用自定义API的设计方案!
在公司可以这么干,因为干完了能拿来吹牛逼;搞开源不能这么搞,因为大概率你这么搞完了事儿就搞砸了,你真就成个睾丸了.
1.5 采用OpenTelemetry API
如上图示,最终我们决定依赖OpenTelemetry API来完成各种链路追踪中间件的接入.理由很简单:我们定义的API肯定不如OpenTelemetry API.因此采用OpenTelemetry API作为抽象层.
图中的OpenTelemetryMiddleware,实际上就是1.3.2小节图中的TracingMiddleware.然后就可以注入OpenTelemetryMiddleware的不同实现了
那么问题来了,OpenTelemetry是啥?
PART2. OpenTelemetry简介
OpenTelemetry是OpenTracing和OpenCensus合并而来
OpenTelemetry同时支持了logging、tracing和metrics
OpenTelemetry提供了各种语言的SDK
OpenTelemetry适配了各种开源链路追踪中间件,如Zipkin、Jeager、Prometheus
总而言之言而总之,OpenTelemetry是新时代的可观测性平台
2.1 OpenTelemetry GO SDK入门
注:这个图中的代码我也没写过,就是理解一下图中每个API的含义
2.1.1 TracerProvider
TracerProvider:用于构造tracer实例.Provider在平时开发中也很常见,有时可以看作是轻量级的工厂模式或轻量级的生成器模式.
通常Provider会提供一些缓存机制.比如图中的otel.GetTracerProvider()
这行代码,调用过这个API之后,后续如果再调用otel.GetTracerProvider()
,你拿到的TracerProvider和之前调用otel.GetTracerProvider()
拿到的TracerProvider是同一个TracerProvider
2.1.2 tracer
tracer:追踪者,用于构造trace
构造tracer需要一个instrumentationName
,一般来说就是你构造tracer的地方的包名(保证唯一即可).例如图中的Tracer("gitbub.com/flycash/geekbang-middleware")
.这里的"gitbub.com/flycash/geekbang-middleware"
就是instrumentationName
2.1.3 span
span:调用tracer上的Start()
方法.如果传入的context里面已经有一个span了,那么新创建的span就是老的span的儿子.span要记住调用End()
方法
图中的
即为调用了tracer上的Start()
方法.
这里需要注意的是,如果调用tracer.Start()
方法时传入的context(我们将这个context命名为contextA)已经和一个span(我们将这个span命名为spanB)绑定过的话,则使用contextA调用tracer.Start()
方法时返回的span(我们将这个span命名为spanB)是spanA的儿子,二者构成层级关系
图中的span.End()
,其含义为结束这个span,标志着这个span的生命周期结束
2.2 OpenTelemetry与Zipkin和Jeager的结合
上图为向OpenTelemetry注入Zipkin的实现;
下图为向OpenTelemetry注入Jeager的实现;
具体的代码都不用记,重点是看这两段代码的相似性:
都是先构建各自的exporter
然后再将这个exporter转换成对应的TraceProvider
最后调用
otel.SetTracerProvider()
方法,将TraceProvider注入到OpenTelemetry中
可以把这个注入的过程理解为一个适配器模式:各种exporter要去适配TraceProvider.
至于具体怎么创建exporter,这个完全可以抄代码.不用记,就抄图中的代码即可
PART3. OpenTelemetry Middleware
3.1 Builder模式
本节课工程结构如下:
和写accessLog时一样,也是先定义出Builder模式的基本结构:
middlewares/open_telemetry/middlewareBuilder.go
:
3.2 初始化Tracer
3.2.1 将Tracer设置为一个公有字段的方案
middlewares/open_telemetry/middlewareBuilder.go
:
3.2.2 将Tracer设置为一个私有字段的方案
middlewares/open_telemetry/middlewareBuilder.go
:
也可以将这个字段的属性设置为私有,在实例化MiddlewareBuilder
时要求框架使用者必须传入一个trace.Tracer
我们在这里采用公有字段的方案.因为通常情况下框架的使用者会创建一个全局的Tracer,所以就没必要再让他们把一个创建好的实例传到open_telemetry
包里来了
3.3 创建span并记录数据
3.3.1 创建span并修改span的名称
middlewares/open_telemetry/middlewareBuilder.go
:
这里有3个需要注意的地方:
Tracer.Start()
方法接收的第1个参数是context.Context
接口的实现,而非是我们自己设计的web.Context
.因此需要拿请求的上下文作为此处的参数Tracer.Start()
方法接收的第2个参数是一个span的名称,通常以请求命中的路由作为span的名称.可是在创建span时,还不确定当前的请求能否命中路由,所以先用unknown
替代当请求命中了路由,并执行完对应的HandleFunc后,就能够确定命中的路由了.这时要将span的名称修改为请求命中的路由.这里需要注意的是,如果没有找到命中的路由,说明就404了,这种情况下同样不需要修改span的名称.换言之,仅在确定命中了路由时,才需要修改span的名称
调用完成后要关闭span
3.3.2 记录数据
middlewares/open_telemetry/middlewareBuilder.go
:
3.3.3 与上下游结合
截至目前我们的实现都是有一个边界的:仅在进程内记录了span.那么问题来了:如果现在的场景是跨进程通信的,且我们的客户端也有其自己的span,该如何把我们创建的span和客户端的span结合在一起?
middlewares/open_telemetry/middlewareBuilder.go
:
这里有3个需要注意的地方:
从HTTP请求头中获取客户端的traceId和spanId
这里需要将
request.Header
转换为一个propagation.HeaderCarrier
(其实二者本质上都是map[string][]string
)
获取客户端的traceId和spanId后,会拿到一个基于客户端的
context.Context
重新封装过context.Context
后续创建span时,要基于这个重新封装过的
context.Context
来创建.这样才能让我们创建的span和客户端的span构成父子关系
TODO: 其实这块我也不是很懂,但是我隐隐感觉,基本上80%的代码照着抄就行
Last updated