Skip to main content

· One min read
Windfarer

我们在微服务框架kratos v2的默认项目模板中kratos-layout使用了google/wire进行依赖注入,也建议开发者在维护项目时使用该工具。

wire 乍看起来比较违反直觉,导致很多同学不理解为什么要用或不清楚如何用(也包括曾经的我),本文来帮助大家理解 wire 的使用。

What

wire是由 google 开源的一个供 Go 语言使用的依赖注入代码生成工具。它能够根据你的代码,生成相应的依赖注入 go 代码。

而与其它依靠反射实现的依赖注入工具不同的是,wire 能在编译期(准确地说是代码生成时)如果依赖注入有问题,在代码生成时即可报出来,不会拖到运行时才报,更便于 debug。

Why

理解依赖注入

什么是依赖注入?为什么要依赖注入? 依赖注入就是 Java 遗毒(不是)

依赖注入 (Dependency Injection,缩写为 DI),可以理解为一种代码的构造模式(就是写法),按照这样的方式来写,能够让你的代码更加容易维护。

对于很多软件设计模式和架构的理念,我们都无法理解他们要绕好大一圈做复杂的体操、用奇怪的方式进行实现的意义。他们通常都只是丢出来一段样例,说这样写就很好很优雅,由于省略掉了这种模式是如何发展出来的推导过程,我们只看到了结果,导致理解起来很困难。那么接下来我们来尝试推导还原一下整个过程,看看代码是如何和为什么演进到依赖注入模式的,以便能够更好理解使用依赖注入的意义。

依赖是什么?

这里的依赖是个名词,不是指软件包的依赖(比如那坨塞在 node_modules 里面的东西),而是指软件中某一个模块(对象/实例)所依赖的其它外部模块(对象/实例)。

注入到哪里?

被依赖的模块,在创建模块时,被注入到(即当作参数传入)模块的里面。

不 DI 是啥样?DI 了又样子?

下面用 go 伪代码来做例子,领会精神即可。

假设个场景,你在打工搞一个 web 应用,它有一个简单接口。最开始的项目代码可能长这个样子:

# 下面为伪代码,忽略了很多与主题无关的细节

type App struct {
}

# 假设这个方法将会匹配并处理 GET /biu/<id> 这样的请求
func (a *App) GetData(id string) string {
# todo: write your data query
return "some data"
}

func NewApp() *App {
return &App{}
}

app := App()
app.Run()

你要做的是接一个 mysql,从里面把数据按照 id 查出来,返回。 要连 mysql 的话,假设我们已经有了个NewMySQLClient的方法返回 client 给你,初始化时传个地址进去就能拿到数据库连接,并假设它有个Exec的方法给你执行参数。

不用 DI,通过全局变量传递依赖实例

一种写法是,在外面全局初始化好 client,然后 App 直接拿来调用。


var mysqlUrl = "mysql://blabla"
var db = NewMySQLClient(mysqlUrl)


type App struct {

}

func (a *App) GetData(id string) string {
data := db.Exec("select data from biu where id = ? limit 1", id)
return data
}


func NewApp() *App {
return &App{}
}
func main() {
app := App()
app.Run()
}

这就是没用依赖注入,app 依赖了全局变量 db,这是比较糟糕的一种做法。db 这个对象游离在全局作用域,暴露给包下的其他模块,比较危险。(设想如果这个包里其他代码在运行时悄悄把你的这个 db 变量替换掉会发生啥)

不用 DI,在 App 的初始化方法里创建依赖实例

另一种方式是这样的:

type App struct {
db *MySQLClient
}

func (a *App) GetData(id string) string {
data := a.db.Exec("select data from biu where id = ? limit 1", id)
return data
}


func NewApp() *App {
return &App{db: NewMySQLClient(mysqlUrl)}
}
func main() {
app := NewApp("mysql://blabla")
app.Run()
}

这种方法稍微好一些,db 被塞到 app 里面了,不会有 app 之外的无关代码碰它,比较安全,但这依然不是依赖注入,而是在内部创建了依赖,接下来你会看到它带来的问题。

老板:我们的数据要换个地方存 (需要变更实现)

你的老板不知道从哪听说——Redis 贼特么快,要不我们的数据改从 Redis 里读吧。这个时候你的内心有点崩溃,但毕竟要恰饭的,就硬着头皮改上面的代码。

type App struct {
ds *RedisClient
}

func (a *App) GetData(id string) string {
data := a.ds.Do("GET", "biu_"+id)
return data
}


func NewApp() *App {
return &App{ds: NewRedisClient(redisAddr)}
}

func main() {
app := NewApp("redis://ooo")
app.Run()
}

上面基本进行了 3 处修改:

  1. App 初始化方法里改成了初始化 RedisClient
  2. get_data 里取数据时改用 run 方法,并且查询语句也换了
  3. App 实例化时传入的参数改成了 redis 地址
老板:要不,我们再换个地方存?/我们要加测试,需要 Mock

老板的思路总是很广的,又过了两天他又想换成 Postgres 存了;或者让你们给 App 写点测试代码,只测接口里面的逻辑,通常我们不太愿意在旁边再起一个数据库,那么就需要 mock 掉数据源这块东西,让它直接返回数据给请求的 handler 用,来进行针对性的测试。

这种情况怎么办?再改里面的代码?这不科学。

面向接口编程

一个很重要的思路就是要面向接口(interface)编程,而不是面向具体实现编程。

什么叫面向具体实现编程呢?比如上述的例子里改动的部分:调 mysqlclient 的 exec_sql 执行一条 sql,被改成了:调 redisclient 的 do 执行一句 get 指令。由于每种 client 的接口设计不同,每换一个实现,就得改一遍。

而面向接口编程的思路,则完全不同。我们不要听老板想用啥就马上写代码。首先就得预料到,这个数据源的实现很有可能被更换,因此在一开始就应该做好准备(设计)。

设计接口

Python 里面有个概念叫鸭子类型(duck-typing),就是如果你叫起来像鸭子,走路像鸭子,游泳像鸭子,那么你就是一只鸭子。这里的叫、走路、游泳就是我们约定的鸭子接口,而你如果完整实现了这些接口,我们可以像对待一个鸭子一样对待你。

在我们上面的例子中,不论是 Mysql 实现还是 Redis 实现,他们都有个共同的功能:用一个 id,查一个数据出来,那么这就是共同的接口。

我们可以约定一个叫 DataSource 的接口,它必须有一个方法叫 GetById,功能是要接收一个 id,返回一个字符串

type DataSource interface {
GetById(id string) string
}

然后我们就可以把各个数据源分别进行封装,按照这个 interface 定义实现接口,这样我们的 App 里处理请求的部分就可以稳定地调用 GetById 这个方法,而底层数据实现只要实现了 DataSource 这个 interface 就能花式替换,不用改 App 内部的代码了。

// 封装个redis
type redis struct {
r *RedisClient
}

func NewRedis(addr string) *redis {
return &redis{r: NewRedisClient(addr)}
}

func (r *redis) GetById(id string) string {
return r.r.Do("GET", "biu_"+id)
}


// 再封装个mysql
type mysql struct {
m *MySQLClient
}

func NewMySQL(addr string) *mysql {
return &mysql{m: NewMySQLClient(addr)}
}

func (m *mysql) GetById(id string) string {
return r.m.Exec("select data from biu where id = ? limit 1", id)
}


type App struct {
ds DataSource
}

func NewApp(addr string) *App {
//需要用Mysql的时候
return &App{ds: NewMySQLClient(addr)}

//需要用Redis的时候
return &App{ds: NewRedisClient(addr)}
}

由于两种数据源都实现了 DataSource 接口,因此可以直接创建一个塞到 App 里面了,想用哪个用哪个,看着还不错?

等一等,好像少了些什么

addr 作为参数,是不是有点简单?通常初始化一个数据库连接,可能有一堆参数,配在一个 yaml 文件里,需要解析到一个 struct 里面,然后再传给对应的 New 方法。

配置文件可能是这样的:

redis:
addr: 127.0.0.1:6379
read_timeout: 0.2s
write_timeout: 0.2s

解析结构体是这样的:

type RedisConfig struct {
Network string `json:"network,omitempty"`
Addr string `json:"addr,omitempty"`
ReadTimeout *duration.Duration `json:"read_timeout,omitempty"`
WriteTimeout *duration.Duration `json:"write_timeout,omitempty"`
}

结果你的NewApp方法可能就变成了这个德性:

func NewApp() *App {
var conf *RedisConfig
yamlFile, err := ioutil.ReadFile("redis_conf.yaml")
if err != nil {
panic(err)
}
err = yaml.Unmarshal(yamlFile, &conf)
if err != nil {
panic(err)
}
return &App{ds: NewRedisClient(conf)}
}

NewApp 说,停停,你们年轻人不讲武德,我的责任就是创建一个 App 实例,我只需要一个 DataSource 注册进去,至于这个 DataSource 是怎么来的我不想管,这么一坨处理 conf 的代码凭什么要放在我这里,我也不想关心你这配置文件是通过网络请求拿来的还是从本地磁盘读的,我只想把 App 组装好扔出去直接下班。

依赖注入终于可以登场了

还记得前面是怎么说依赖注入的吗?被依赖的模块,在创建模块时,被注入到(即当作参数传入)初始化函数里面。通过这种模式,正好可以让 NewApp 早点下班。我们在外面初始化好 NewRedis 或者 NewMysql,得到的 DataSource 直接扔给 NewApp。

也就是这样

func NewApp(ds DataSource) *App {
return &App{ds: ds}
}

那坨读配置文件初始化 redis 的代码扔到初始化 DataSource 的方法里去

func NewRedis() DataSource {
var conf *RedisConfig
yamlFile, err := ioutil.ReadFile("redis_conf.yaml")
if err != nil {
panic(err)
}
err = yaml.Unmarshal(yamlFile, &conf)
if err != nil {
panic(err)
}
return &redis{r: NewRedisClient(conf)}
}

更进一步,NewRedis 这个方法甚至也不需要关心文件是怎么读的,它的责任只是通过 conf 初始化一个 DataSource 出来,因此你可以继续把读 config 的代码往外抽,把 NewRedis 做成接收一个 conf,输出一个 DataSource

func GetRedisConf() *RedisConfig
func NewRedis(conf *RedisConfig) DataSource

因为之前整个组装过程是散放在 main 函数下面的,我们把它抽出来搞成一个独立的 initApp 方法。最后你的 App 初始化逻辑就变成了这样

func initApp() *App {
c := GetRedisConf()
r := NewRedis(c)
app := NewApp(r)
return app
}

func main() {
app := initApp()
app.Run()
}

然后你可以通过实现 DataSource 的接口,更换前面的读取配置文件的方法,和更换创建 DataSource 的方法,来任意修改你的底层实现(读配置文件的实现,和用哪种 DataSource 来查数据),而不用每次都改一大堆代码。这使得你的代码层次划分得更加清楚,更容易维护了。

这就是依赖注入。

手工依赖注入的问题

上文这一坨代码,把各个实例初始化好,再按照各个初始化方法的需求塞进去,最终构造出 app 的这坨代码,就是注入依赖的过程。

c := GetRedisConf()
r := NewRedis(c)
app := NewApp(r)

目前只有一个 DataSource,这样手写注入过程还可以,一旦你要维护的东西多了,比如你的 NewApp 是这样的NewApp(r *Redis, es *ES, us *UserSerivce, db *MySQL) *App然后其中 UserService 是这样的UserService(pg *Postgres, mm *Memcached),这样形成了多层次的一堆依赖需要注入,徒手去写非常麻烦。

而这部分,就是 wire 这样的依赖注入工具能够起作用的地方了——他的功能只是通过生成代码帮你注入依赖,而实际的依赖实例需要你自己创建(初始化)。

How

wire 的主要问题是,看文档学不会。反正我最初看完文档之后是一头雾水——这是啥,这要干啥?但通过我们刚才的推导过程,应该大概理解了为什么要用依赖注入,以及 wire 在这其中起到什么作用——通过生成代码帮你注入依赖,而实际的依赖实例需要你自己创建(初始化)。

接下来就比较清楚了。

首先要实现一个wire.go的文件,里面定义好 Injector。

// +build wireinject

func initApp() (*App) {
panic(wire.Build(GetRedisConf, NewRedis, SomeProviderSet, NewApp))
}

然后分别实现好 Provider。

执行wire命令后 他会扫描整个项目,并帮你生成一个wire_gen.go文件,如果你有什么没有实现好,它会报错出来。

你学会了吗?

重新理解

等一等,先别放弃治疗,让我们用神奇的中文编程来解释一下要怎么做。

谁参与编译?

上面那个initApp方法,官方文档叫它 Injector,由于文件里首行// +build wireinject这句注释,这个 wire.go 文件只会由 wire 读取,在 go 编译器在编译代码时不会去管它,实际会读的是生成的 wire_gen.go 文件。

而 Provider 就是你代码的一部分,肯定会参与到编译过程。

Injector 是什么鬼东西?

Injector 就是你最终想要的结果——最终的 App 对象的初始化函数,也就是前面那个例子里的initApp方法。

把它理解为你去吃金拱门,进门看到点餐机,噼里啪啦点了一堆,最后打出一张单子。

// +build wireinject

func 来一袋垃圾食品() 一袋垃圾食品 {
panic(wire.Build(来一份巨无霸套餐, 来一份双层鳕鱼堡套餐, 来一盒麦乐鸡, 垃圾食品打包))
}

这就是你点的单子,它不参与编译,实际参与编译的代码是由 wire 帮你生成的。

Provider 是什么鬼东西?

Provider 就是创建各个依赖的方法,比如前面例子里的 NewRedis 和 NewApp 等。

你可以理解为,这些是金拱门的服务员和后厨要干的事情: 金拱门后厨需要提供这些食品的制作服务——实现这些实例初始化方法。

func 来一盒麦乐鸡() 一盒麦乐鸡 {}
func 垃圾食品打包(一份巨无霸套餐, 一份双层鳕鱼堡套餐, 一盒麦乐鸡) 一袋垃圾食品 {}

wire 里面还有个 ProviderSet 的概念,就是把一组 Provider 打包,因为通常你点单的时候很懒,不想这样点你的巨无霸套餐:我要一杯可乐,一包薯条,一个巨无霸汉堡;你想直接戳一下就好了,来一份巨无霸套餐。这个套餐就是 ProviderSet,一组约定好的配方,不然你的点单列表(injector 里的 Build)就会变得超级长,这样你很麻烦,服务员看着也很累。

用其中一个套餐举例

// 先定义套餐内容
var 巨无霸套餐 = wire.NewSet(来一杯可乐,来一包薯条,来一个巨无霸汉堡)

// 然后实现各个食品的做法
func 来一杯可乐() 一杯可乐 {}
func 来一包薯条() 一包薯条 {}
func 来一个巨无霸汉堡() 一个巨无霸汉堡 {}

wire 工具做了啥?

重要的事情说三遍,通过生成代码帮你注入依赖

在金拱门的例子里就是,wire 就是个服务员,它按照你的订单,去叫做相应的同事把各个食物/套餐做好,然后最终按需求打包给你。这个中间协调构建的过程,就是注入依赖。

这样的好处就是, 对于金拱门,假设他们突然换可乐供应商了,直接把来一杯可乐替换掉就行,返回一种新的可乐,而对于顾客不需要有啥改动。 对于顾客来说,点单内容可以变换,比如我今天不想要麦乐鸡了,或者想加点别的,只要改动我的点单(只要金拱门能做得出来),然后通过 wire 重新去生成即可,不需要关注这个服务员是如何去做这个订单的。

现在你应该大概理解 wire 的用处和好处了。

总结

让我们从金拱门回来,重新总结一下用 wire 做依赖注入的过程。

1. 定义 Injector

创建wire.go文件,定义下你最终想用的实例初始化函数例如initApp(即 Injector),定好它返回的东西*App,在方法里用panic(wire.Build(NewRedis, SomeProviderSet, NewApp))罗列出它依赖哪些实例的初始化方法(即 Provider)/或者哪些组初始化方法(ProviderSet)

2. 定义 ProviderSet(如果有的话)

ProviderSet 就是一组初始化函数,是为了少写一些代码,能够更清晰的组织各个模块的依赖才出现的。也可以不用,但 Injector 里面的东西就需要写一堆。 像这样 var SomeProviderSet = wire.NewSet(NewES,NewDB)定义 ProviderSet 里面包含哪些 Provider

3. 实现各个 Provider

Provider 就是初始化方法,你需要自己实现,比如 NewApp,NewRedis,NewMySQL,GetConfig 等,注意他们们各自的输入输出

4. 生成代码

执行 wire 命令生成代码,工具会扫描你的代码,依照你的 Injector 定义来组织各个 Provider 的执行顺序,并自动按照 Provider 们的类型需求来按照顺序执行和安排参数传递,如果有哪些 Provider 的要求没有满足,会在终端报出来,持续修复执行 wire,直到成功生成wire_gen.go文件。接下来就可以正常使用initApp来写你后续的代码了。

如果需要替换实现,对 Injector 进行相应的修改,实现必须的 Provider,重新生成即可。

它生成的代码其实就是类似我们之前需要手写的这个

func initApp() *App {  // injector
c := GetRedisConf() // provider
r := NewRedis(c) // provider
app := NewApp(r) // provider
return app
}

由于我们的例子比较简单,通过 wire 生成体现不出优势,但如果我们的软件复杂,有很多层级的依赖,使用 wire 自动生成注入逻辑,无疑更加方便和准确。

5. 高级用法

wire 还有更多功能,比如 cleanup, bind 等等,请参考官方文档来使用。

最后,其实多折腾几次,就会使用了,希望本文能对您起到一定程度上的帮助。

相关文献

· One min read
shenqidebaozi

链路追踪的前世今生

分布式跟踪(也称为分布式请求跟踪)是一种用于分析和监控应用程序的方法,尤其是使用微服务架构构建的应用程序。分布式跟踪有助于精确定位故障发生的位置以及导致性能差的原因。

起源

链路追踪(Distributed Tracing) 一词最早出现于谷歌发布的论文 《Dapper, a Large-Scale Distributed Systems Tracing Infrastructure》 中,这篇论文对于实现链路追踪,对于后来出现的 Jaeger、Zipkin 等开源分布式追踪项目设计理念仍有很深的影响。

微服务架构是一个分布式的架构,会有很多个不同的服务。不同的服务之前相互调用,如果出现了错误由于一个请求经过了 N 个服务。随着业务的增加越来越多的服务之间的调用,如果没有一个工具去记录调用链,解决问题的时候就会像下面图片里小猫咪玩的毛线球一样,毫无头绪,无从下手 image.png 所以需要有一个工具能够清楚的了解一个请求经过了哪些服务,顺序是如何,从而能够轻易的定位问题。 image.png

百家争艳

从谷歌发布 Dapper 后,分布式链路追踪工具越来越多,以下简单列举了一些常用的链路追踪系统

  • Skywalking
  • 阿里 鹰眼
  • 大众点评 CAT
  • Twitter Zipkin
  • Naver pinpoint
  • Uber Jaeger

争锋相对?

随着链路追踪工具越来越多,开源领域主要分为两派,一派是以 CNCF技术委员 会为主的 OpenTracing 的规范,例如 jaeger zipkin 都是遵循了OpenTracing 的规范。而另一派则是谷歌作为发起者的 OpenCensus,而且谷歌本身还是最早提出链路追踪概念的公司,后期连微软也加入了 OpenCensus 截屏2021-05-29 下午9.56.57.png

OpenTelemetry 诞生

OpenTelemetric 是一组 API、SDK、模组和集成,专为创建和管理‎‎遥测数据‎‎(如追踪、指标和日志)而设

微软加入 OpenCensus 后,直接打破了之前平衡的局面,间接的导致了 OpenTelemetry 的诞生 谷歌和微软下定决心结束江湖之乱,首要的问题是如何整合两个两个社区已有的项目,OpenTelemetry 主要的理念就是,兼容 OpenCensusOpenTracing ,可以让使用者无需改动或者很小的改动就可以接入 OpenTelemetry

Kratos 的链路追踪实践

Kratos 一套轻量级 Go 微服务框架,包含大量微服务相关框架及工具。

tracing 中间件

kratos 框架提供的自带中间件中有一个名为 tracing 中间件,它基于 Opentelemetry 实现了kratos 框架的链路追踪功能,中间件的代码可以从 middleware/tracing 中看到。

实现原理

kratos 的链路追踪中间件由三个文件组成 carrie.go,tracer.go,tracing.go。client和 server 的实现原理基本相同,本文以 server 实现进行原理解析。

  1. 首先当请求进入时,tracing 中间件会被调用,首先调用了 tracer.go 中的 NewTracer 方法
// Server returns a new server middleware for OpenTelemetry.
func Server(opts ...Option) middleware.Middleware {
// 调用 tracer.go 中的 NewTracer 传入了一个 SpanKindServer 和配置项
tracer := NewTracer(trace.SpanKindServer, opts...)
// ... 省略代码
}
  1. tracer.go 中的 NewTracer 方法被调用后会返回一个 Tracer,实现如下
func NewTracer(kind trace.SpanKind, opts ...Option) *Tracer {
options := options{}
for _, o := range opts {
o(&options)
}
// 判断是否存在 otel 追踪提供者配置,如果存在则设置
if options.TracerProvider != nil {
otel.SetTracerProvider(options.TracerProvider)
}
/*
判断是否存在 Propagators 设置,如果存在设置则覆盖,不存在则设置一个默认的TextMapPropagator
注意如果没有设置默认的TextMapPropagator,链路信息则无法正确的传递
*/
if options.Propagators != nil {
otel.SetTextMapPropagator(options.Propagators)
} else {
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.Baggage{}, propagation.TraceContex{}))
}

var name string
// 判断当前中间件的类型,是 server 还是 client
if kind == trace.SpanKindServer {
name = "server"
} else if kind == trace.SpanKindClient {
name = "client"
} else {
panic(fmt.Sprintf("unsupported span kind: %v", kind))
}
// 调用 otel包的 Tracer 方法 传入 name 用来创建一个 tracer 实例
tracer := otel.Tracer(name)
return &Tracer{tracer: tracer, kind: kind}
}
  1. 判断当前请求类型,处理需要采集的数据,并调用 tracer.go 中的 Start 方法
var (
component string
operation string
carrier propagation.TextMapCarrier
)

// 判断请求类型
if info, ok := http.FromServerContext(ctx); ok {
// HTTP
component = "HTTP"
// 取出请求的地址
operation = info.Request.RequestURI
// 调用 otel/propagation包中的 HeaderCarrier,会处理 http.Header 以用来满足TextMapCarrier interface
// TextMapCarrier 是一个文本映射载体,用于承载信息
carrier = propagation.HeaderCarrier(info.Request.Header)
// otel.GetTextMapPropagator().Extract() 方法用于将文本映射载体,读取到上下文中
ctx = otel.GetTextMapPropagator().Extract(ctx, propagation.HeaderCarrier(info.Request.Header))
} else if info, ok := grpc.FromServerContext(ctx); ok {
// Grpc
component = "gRPC"
operation = info.FullMethod
//
// 调用 grpc/metadata包中metadata.FromIncomingContext(ctx)传入 ctx,转换 grpc 的元数据
if md, ok := metadata.FromIncomingContext(ctx); ok {
// 调用carrier.go 中的 MetadataCarrier 将 MD 转换 成文本映射载体
carrier = MetadataCarrier(md)
}
}

// 调用 tracer.Start 方法
ctx, span := tracer.Start(ctx, component, operation, carrier)
// ... 省略代码
  1. 调用 tracing.go 中的 Start 方法
func (t *Tracer) Start(ctx context.Context, component string, operation string, carrier propagation.TextMapCarrier) (context.Context, trace.Span) {
// 判断当前中间件如果是 server则将 carrier 注入到上下文中
if t.kind == trace.SpanKindServer {
ctx = otel.GetTextMapPropagator().Extract(ctx, carrier)
}
// 调用otel/tracer 包中的 start 方法,用来创建一个 span
ctx, span := t.tracer.Start(ctx,
// tracing.go 中声明的请求路由作为 spanName
operation,
// 设置 span 的属性,设置了一个 component,component的值为请求类型
trace.WithAttributes(attribute.String("component", component)),
// 设置 span种类
trace.WithSpanKind(t.kind),
)
// 判断如果当前中间件是 client 则将 carrier 注入到请求里面
if t.kind == trace.SpanKindClient {
otel.GetTextMapPropagator().Inject(ctx, carrier)
}
return ctx, span
}
  1. defer 声明了一个闭包方法
// 这个地方要注意,需要使用闭包,因为 defer 的参数是实时计算的如果异常发生,err 会一直为 nil
// https://github.com/go-kratos/kratos/issues/927
defer func() { tracer.End(ctx, span, err) }()
  1. 中间件继续执行
// tracing.go 69行
reply, err = handler(ctx, req)
  1. 中间件调用结束 defer 中的闭包被调用后执行了 tracer.go 中的 End 方法
func (t *Tracer) End(ctx context.Context, span trace.Span, err error) {
// 判断是否有异常发生,如果有则设置一些异常信息
if err != nil {
// 记录异常
span.RecordError(err)
// 设置span 属性
span.SetAttributes(
// 设置事件为异常
attribute.String("event", "error"),
// 设置 message 为 err.Error().
attribute.String("message", err.Error()),
)
//设置了 span 的状态
span.SetStatus(codes.Error, err.Error())
} else {
// 如果没有发生异常,span 状态则为 ok
span.SetStatus(codes.Ok, "OK")
}
// 中止 span
span.End()
}

如何使用

tracing 中间件的使用示例可以从 kratos/examples/traces ,该示例简单的实现了跨服务间的链路追踪,以下代码片段包含部分示例代码。

// https://github.com/go-kratos/kratos/blob/7f835db398c9d0332e69b81bad4c652b4b45ae2e/examples/traces/app/message/main.go#L38
// 首先调用otel 库方法,得到一个 TracerProvider
func tracerProvider(url string) (*tracesdk.TracerProvider, error) {
// examples/traces 中使用的是 jaeger,其他方式可以查看 opentelemetry 官方示例
exp, err := jaeger.NewRawExporter(jaeger.WithCollectorEndpoint(jaeger.WithEndpoint(url)))
if err != nil {
return nil, err
}
tp := tracesdk.NewTracerProvider(
tracesdk.WithSampler(tracesdk.AlwaysSample()),
// 设置 Batcher,注册jaeger导出程序
tracesdk.WithBatcher(exp),
// 记录一些默认信息
tracesdk.WithResource(resource.NewWithAttributes(
semconv.ServiceNameKey.String(pb.User_ServiceDesc.ServiceName),
attribute.String("environment", "development"),
attribute.Int64("ID", 1),
)),
)
return tp, nil
}

在 grpc/server 中使用

// https://github.com/go-kratos/kratos/blob/main/examples/traces/app/message/main.go
grpcSrv := grpc.NewServer(
grpc.Address(":9000"),
grpc.Middleware(
// Configuring tracing Middleware
tracing.Server(
tracing.WithTracerProvider(tp),
),
),
)

在 grpc/client 中使用

// https://github.com/go-kratos/kratos/blob/149fc0195eb62ee1fbc2728adb92e1bcd1a12c4e/examples/traces/app/user/main.go#L63
conn, err := grpc.DialInsecure(ctx,
grpc.WithEndpoint("127.0.0.1:9000"),
grpc.WithMiddleware(
tracing.Client(
tracing.WithTracerProvider(s.tracer),
tracing.WithPropagators(
propagation.NewCompositeTextMapPropagator(propagation.Baggage{}, propagation.TraceContext{}),
),
)
),
grpc.WithTimeout(2*time.Second),
)

在 http/server 中使用

// https://github.com/go-kratos/kratos/blob/main/examples/traces/app/user/main.go
httpSrv := http.NewServer(http.Address(":8000"))
httpSrv.HandlePrefix("/", pb.NewUserHandler(s,
http.Middleware(
// Configuring tracing middleware
tracing.Server(
tracing.WithTracerProvider(tp),
tracing.WithPropagators(
propagation.NewCompositeTextMapPropagator(propagation.Baggage{}, propagation.TraceContext{}),
),
),
),
)

在 http/client 中使用

http.NewClient(ctx, http.WithMiddleware(
tracing.Client(
tracing.WithTracerProvider(s.tracer),
),
))

如何实现一个其他场景的 tracing

我们可以借鉴 kratostracing 中间件的代码来实现例如数据库的 tracing,如下面的代码片段,作者借鉴了tracing 中间件,实现了 qmgo 库操作 MongoDB 数据库的 tracing

func mongoTracer(ctx context.Context,tp trace.TracerProvider, command interface{}) {
var (
commandName string
failure string
nanos int64
reply bson.Raw
queryId int64
eventName string
)
otel.SetTracerProvider(tp)
reply = bson.Raw{}
switch value := command.(type) {
case *event.CommandStartedEvent:
commandName = value.CommandName
reply = value.Command
queryId = value.RequestID
eventName = "CommandStartedEvent"
case *event.CommandSucceededEvent:
commandName = value.CommandName
nanos = value.DurationNanos
queryId = value.RequestID
eventName = "CommandSucceededEvent"
case *event.CommandFailedEvent:
commandName = value.CommandName
failure = value.Failure
nanos = value.DurationNanos
queryId = value.RequestID
eventName = "CommandFailedEvent"
}
duration, _ := time.ParseDuration(strconv.FormatInt(nanos, 10) + "ns")
tracer := otel.Tracer("mongodb")
kind := trace.SpanKindServer
ctx, span := tracer.Start(ctx,
commandName,
trace.WithAttributes(
attribute.String("event", eventName),
attribute.String("command", commandName),
attribute.String("query", reply.String()),
attribute.Int64("queryId", queryId),
attribute.String("ms", duration.String()),
),
trace.WithSpanKind(kind),
)
if failure != "" {
span.RecordError(errors.New(failure))
}
span.End()
}

参考文献

· One min read
shenqidebaozi

0X01 通过 layout 探索 kratos 运行原理(kratos v2.0.0-beta4)

创建项目

首先需要安装好对应的依赖环境,以及工具:

  • go
  • protoc
  • protoc-gen-go
# 创建项目模板
kratos new helloworld

cd helloworld
# 拉取项目依赖
go mod download
# 生成proto模板
kratos proto add api/helloworld/v1/helloworld.proto
# 生成proto源码
kratos proto client api/helloworld/v1/helloworld.proto
# 生成server模板
kratos proto server api/helloworld/v1/helloworld.proto -t internal/service

执行命令后,会在当前目录下生成一个 service 工程,工程骨架如下,具体的工程骨架说明可以访问 layout image.png

运行项目

# 生成所有proto源码、wire等等
go generate ./...

# 编译成可执行文件
go build -o ./bin/ ./...

# 运行项目
./bin/helloworld -conf ./configs

看到如下输出则证明项目启动正常

level=INFO module=app service_id=7114ad8a-b3bf-11eb-a1b9-f0189850d2cb service_name=  version=
level=INFO module=transport/grpc msg=[gRPC] server listening on: [::]:9000
level=INFO module=transport/http msg=[HTTP] server listening on: [::]:8000

测试接口

curl 'http://127.0.0.1:8000/helloworld/krtaos'

输出:
{
"message": "Hello kratos"
}

应用是如何跑起来的?

image.png 通过上面的图例👆,我们可以直观观察到应用的调用链,简化来说如下图流程所示👇

未命名文件(2).png

1. 注入依赖并调用 newApp() 方法

// helloword/cmd/main.go
func main() {
flag.Parse()
logger := log.NewStdLogger(os.Stdout)

// 调用 go-kratos/kratos/v2/config,创建 config 实例,并指定了来源和配置解析方法
c := config.New(
config.WithSource(
file.NewSource(flagconf),
),
config.WithDecoder(func(kv *config.KeyValue, v map[string]interface{}) error {
return yaml.Unmarshal(kv.Value, v)
}),
)
if err := c.Load(); err != nil {
panic(err)
}

// 将配置扫描到,通过 proto 声明的 conf struct 上
var bc conf.Bootstrap
if err := c.Scan(&bc); err != nil {
panic(err)
}

// 通过 wire 将依赖注入,并调用 newApp 方法
app, cleanup, err := initApp(bc.Server, bc.Data, logger)
if err != nil {
panic(err)
}
// 省略代码...
}

2. 创建 kratos 实例

项目 main.go 的 newApp() 方法中,调用了 go-kratos/kratos/v2/app.go 中的 kratos.New() 方法

// helloword/cmd/main.go
func newApp(logger log.Logger, hs *http.Server, gs *grpc.Server) *kratos.App {
return kratos.New(
// 配置应用
kratos.Name(Name),
kratos.Version(Version),
kratos.Metadata(map[string]string{}),
kratos.Logger(logger),
// kratos.Server() 传入的 http/grpc 服务会通过 buildInstance() 转换成registry.ServiceInstance struct*
kratos.Server(
hs,
gs,
),
)
}

该方法会返回一个 App struct,包含 Run()Stop() 方法

// go-kratos/kratos/v2/app.go
type App struct {
opts options //配置
ctx context.Context // 上下文
cancel func() // context 的取消方法
instance *registry.ServiceInstance //通过 kratos.Server()声明的实例,并通过 buildInstance() 转换后的 *registry.ServiceInstance struct
log *log.Helper // 日志
}

// Run executes all OnStart hooks registered with the application's Lifecycle.
func (a *App) Run() error {
// 省略代码...
}

// Stop gracefully stops the application.
func (a *App) Stop() error {
// 省略代码...
}

3. 调用 Run() 方法

项目在 main 方法中调用了 kratos.App structRun() 方法.

// helloword/cmd/main.go
// 省略代码...
// 启动 Kratos
if err := app.Run(); err != nil {
panic(err)
}

Run() 方法的实现细节

// go-kratos/kratos/v2/app.go
func (a *App) Run() error {
a.log.Infow(
"service_id", a.opts.id,
"service_name", a.opts.name,
"version", a.opts.version,
)
g, ctx := errgroup.WithContext(a.ctx)
// 遍历通过 kratos.Server() 声明的服务实例
for _, srv := range a.opts.servers {
srv := srv
// 执行两个goroutine, 用于处理服务启动和退出
g.Go(func() error {
<-ctx.Done() // 阻塞,等待调用 cancel 方法
return srv.Stop() // 协程退出后,调用实例的停止方法
})
g.Go(func() error {
return srv.Start() // 调用实例的运行方法
})
}
// 判断是否调用 kratos.Registrar() 配置了注册发现中心
if a.opts.registrar != nil {
// 将实例注册到注册中心
if err := a.opts.registrar.Register(a.opts.ctx, a.instance); err != nil
return err
}
}
// 监听进程退出信号
c := make(chan os.Signal, 1)
signal.Notify(c, a.opts.sigs...)

// 处理进程退出和 context 退出
g.Go(func() error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-c:
// 调用 kratos.App 的停止方法
a.Stop()
}
}
})
if err := g.Wait(); err != nil && !errors.Is(err, context.Canceled) {
return err
}
return nil
}

4. 应用退出

Kratos 实例在启动时,监听了系统的进程退出信号,当收到退出信号时,kratos 会调用 App structStop() 方法

// go-kratos/kratos/v2/app.go
func (a *App) Stop() error {
// 判断是否有注册中心配置
if a.opts.registrar != nil {
// 在注册中心中将实例注销
if err := a.opts.registrar.Deregister(a.opts.ctx, a.instance); err != nil {
return err
}
}
// 控制 goroutine 的退出,当调用 a.cancel()时,Run()方法中 监听的 <-ctx.Done() 收到消息后,没有阻塞后,方法会调用 server 的 Stop()方法,停止服务
if a.cancel != nil {
a.cancel()
}
return nil
}

· One min read
Tony

介绍

这篇文章主要讲 Go 项目工程化 上的一些思考,以及 Kratos 在项目不同角度中的设计理念。

Go 是一个面向包名设计的语言,可以通过各个包名进行组织 Go 的项目布局,而大家遵循规范设计准则,可以很好地改善团队成员之间的沟通。

项目布局

每个公司都应当为不同的微服务建立一个统一的 Kit 工具包项目(基础库/框架)和 Application 项目。 基础库 Kit 为独立项目,公司级建议只有一个,按照功能目录来拆分会带来不少的管理工作,因此建议合并整合。

by Package Oriented Design “To this end, the Kit project is not allowed to have a vendor folder. If any of packages are dependent on 3rd party packages, they must always build against the latest version of those dependences.”

Kit 基础库

将 Kit 项目作为公司的标准库,因此应该只有一个。并且 Kit 基础库也应该具备以下这些特点:

  • 简单:不过度设计,代码平实简单;
  • 通用:通用业务开发所需要的基础库的功能;
  • 高效:提高业务迭代的效率;
  • 稳定:基础库可测试性高,覆盖率高,有线上实践安全可靠;
  • 健壮:通过良好的基础库设计,减少错用;
  • 高性能:性能高,但不特定为了性能做 hack 优化,引入 unsafe ;
  • 扩展性:良好的接口设计,来扩展实现,或者通过新增基础库目录来扩展功能;
  • 容错性:为失败设计,大量引入对 SRE 的理解,鲁棒性高;
  • 工具链:包含大量工具链,比如辅助代码生成,lint 工具等等;

以 Kratos 为例子,一个典型的 Kit 基础库 可能看起来像这样:

github.com/go-kratos/kratos
├── cmd
├── docs
├── internal
├── examples
├── api
├── errors
├── config
├── encoding
├── log
├── metrics
├── metadata
├── middleware
├── transport
├── registry
├── third_party
├── app.go
├── options.go
├── go.mod
├── go.sum

注意:为了保证 Kit 基础库的可移植性,尽可能进行接口抽象,并且 go.mod 依赖第三方库也尽可能简单,然后再通过 plugins 进行扩展基础库,以满足不同的业务需求定制化。

Application 应用项目

如果你尝试学习 Go,或者你正在为自己建立一个 PoC 或一个玩具项目,这个项目布局是没啥必要的。从一些非常简单的事情开始(一个 main.go 文件绰绰有余)。当有更多的人参与这个项目时,你将需要更多的结构,包括需要一个 Toolkit 来方便生成项目的模板,尽可能大家统一的工程目录布局。

kratos ddd

例如,通过 Kratos 工具生成一个 Go工程化项目 模板:

# 创建项目模板
kratos new helloworld

cd helloworld
# 拉取项目依赖
go mod download
# 生成proto模板
kratos proto add api/helloworld/helloworld.proto
# 生成client源码
kratos proto client api/helloworld/helloworld.proto
# 生成server模板
kratos proto server api/helloworld/helloworld.proto -t internal/service

在 Kratos 中,一个典型的 Go 项目布局 可能看起来像这样:

application
|____api
| |____helloworld
| | |____v1
| | |____errors
|____cmd
| |____helloworld
|____configs
|____internal
| |____conf
| |____data
| |____biz
| |____service
| |____server
|____test
|____pkg
|____go.mod
|____go.sum
|____LICENSE
|____README.md

应用类型

微服务中的 app 服务类型主要分为5类:interface、service、job、admin、task,应用 cmd 目录负责程序的:启动、关闭、配置初始化等。

  • interface: 对外的 BFF 服务,接受来自用户的请求,比如暴露了 HTTP/gRPC 接口。
  • service: 对内的微服务,仅接受来自内部其他服务或者网关的请求,比如暴露了gRPC 接口只对内服务。
  • admin:区别于 service,更多是面向运营测的服务,通常数据权限更高,隔离带来更好的代码级别安全。
  • job: 流式任务处理的服务,上游一般依赖 message broker。
  • task: 定时任务,类似 cronjob,部署到 task 托管平台中。

应用目录

/cmd

本项目的主干。 每个应用程序的目录名应该与你想要的可执行文件的名称相匹配(例如,/cmd/myapp)。 不要在这个目录中放置太多代码。如果你认为代码可以导入并在其他项目中使用,那么它应该位于 /pkg 目录中。如果代码不是可重用的,或者你不希望其他人重用它,请将该代码放到 /internal 目录中。

/internal

私有应用程序和库代码。这是你不希望其他人在其应用程序或库中导入代码。请注意,这个布局模式是由 Go 编译器本身执行的。有关更多细节,请参阅 Go 1.4 release notes。注意,你并不局限于顶级 internal 目录。在项目树的任何级别上都可以有多个内部目录。 你可以选择向 internal 包中添加一些额外的结构,以分隔共享和非共享的内部代码。这不是必需的(特别是对于较小的项目),但是最好有有可视化的线索来显示预期的包的用途。你的实际应用程序代码可以放在 /internal/app 目录下(例如 /internal/app/myapp),这些应用程序共享的代码可以放在 /internal/pkg 目录下(例如 /internal/pkg/myprivlib)。 因为我们习惯把相关的服务,比如账号服务,内部有 rpc、job、admin 等,相关的服务整合一起后,需要区分 app。单一的服务,可以去掉 /internal/myapp

/pkg

外部应用程序可以使用的库代码(例如 /pkg/mypubliclib)。其他项目会导入这些库,所以在这里放东西之前要三思:-)注意,internal 目录是确保私有包不可导入的更好方法,因为它是由 Go 强制执行的。/pkg 目录仍然是一种很好的方式,可以显式地表示该目录中的代码对于其他人来说是安全使用的好方法。

/pkg 目录内,可以参考 go 标准库的组织方式,按照功能分类。/internal/pkg 一般用于项目内的 跨多个应用的公共共享代码,但其作用域仅在单个项目工程内。

由 Travis Jeffery 撰写的 I'll take pkg over internal 博客文章提供了 pkginternal 目录的一个很好的概述,以及什么时候使用它们是有意义的。 当根目录包含大量非 Go 组件和目录时,这也是一种将 Go 代码分组到一个位置的方法,这使得运行各种 Go 工具变得更加容易组织。

服务应用目录

/api

​API 协议定义目录,services.proto protobuf 文件,以及生成的 go 文件。我们通常把 api 文档直接在 proto 文件中描述。

/configs

​配置文件模板或默认配置。

/test

​额外的外部测试应用程序和测试数据。你可以随时根据需求构造 /test 目录。对于较大的项目,有一个数据子目录是有意义的。例如,你可以使用 /test/data 或 /test/testdata (如果你需要忽略目录中的内容)。请注意,Go 还会忽略以 “.” 或 “_” 开头的目录或文件,因此在如何命名测试数据目录方面有更大的灵活性。

服务内部目录

Application 目录下有 api、cmd、configs、internal、pkg 目录,目录里一般还会放置 README、CHANGELOG、OWNERS。

internal 是为了避免有同业务下有人跨目录引用了内部的 data、biz、service、server 等内部 struct。

data

业务数据访问,包含 cache、db 等封装,实现了 biz 的 repo 接口。我们可能会把 data 与 dao 混淆在一起,data 偏重业务的含义,它所要做的是将领域对象重新拿出来,我们去掉了 DDD 的 infra 层。

biz

业务逻辑的组装层,类似 DDD 的 domain 层,data 类似 DDD 的 repo,repo 接口在这里定义,使用依赖倒置的原则。

service

实现了 api 定义的服务层,类似 DDD 的 application 层,处理 DTO 到 biz 领域实体的转换(DTO -> DO),同时协同各类 biz 交互,但是不应处理复杂逻辑。

server

为http和grpc实例的创建和配置,以及注册对应的 service 。

不建议的目录

src/

src 目录在 java 开发语言的项目中是一个常用的模式,但是在 go 开发项目中,尽量不要使用 src 目录。

model/

在其他语言开发中一个非常通用的模块叫 model,把所有类型都放在 model 里。但是在 go 里不建议的,因为 go 的包设计是根据功能职责划分的。比如一个 User 模型,应该声明在他被用的功能模块里。

xxs/

带复数的目录或包。虽然 go 源码中有 strings 包,但更多都是用单数形式。

总结

在实际 go 项目开发中,一定要灵活运用,当然也可以完全不按照这样架构分层、包设计的规则,一切以项目的大小、业务的复杂度、个人专业技能认知的广度和深度、时间的紧迫度为准。

并且,一定要按实际情况,选择合适自己团队的 Kit 基础库,进行充分的调研以及是否可满足插件定制化,需要维护好属于团队的 Kit 基础库 和 代码规范 ,带动开发者进行积极参与贡献。

如果大家有更好的架构设计理念,欢迎到 go-kratos 社区进行探讨,希望这篇文章对您有帮助~

参考文献