下一代 Sniper 框架

2021-08-01 ⏳6.1分钟(2.4千字)

两年前我写了Sniper 轻量级 go 业务框架的思考,一年前写了Sniper 框架两周年回顾。又一年过去了,我对 Sniper 框架有了新的认识。今年考虑发布一个新的版本,现在介绍一下未来可能引入的改动。

微调目录结构

原来的 util 工具包会改成 pkg,跟社区保持一致。util 改成 pkg 后就只有 service 不是三字母文件夹了。索性改成 svc,全用三个字母的名字,整齐划一。

原来的 cmd/server 改成 cmd/http,原来的cmd/job 改成 cmd/cron,这样大家一看就明白每个子命令的作用。再就是将 cmd/http/main.gocmd/http/cmd.go 合并,这样在 cmd/httpcmd/cron 下各有一个 cmd.go 文件来定义启动逻辑,另外各有一个「路由」文件(http.go 和 cron.go)来注册业务逻辑。如此调整,来实现一种内在的统一。

最开始我是想把 cmd/http/hooks/ 挪到 pkg 包的,因为新版本支持指定服务和接口级的 hook 了(下面会讲)。在 rpc 目录引用 cmd/http/hooks 包感觉不太科学。但是,移到 pkg 目录也会带来项目引用层级的混乱。因为有些业务上的 hook(比如登录)需要调用 dao 层。一般来说只应该在 dao 层引用 pkg,而不该在 pkg 引用 dao。考虑再三,还是把 hooks 放在 cmd/http 目录吧。如果有业务需要开发自己的 hook,可以放到 svc/hooks 目录。这样引用层级则变成 cmd -> rpc -> svc -> dao -> pkg,没毛病。

清理遗留业务逻辑

Sniper 项目的代码一直是从公司业务代码自动生成的。也就是说,我们有一个脚本,会自动将业务无关的逻辑同步到 Sniper 开源项目。这样做的优点是方便同步开源代码。但缺点也很明显,一方面要维护一个比较复杂的同步脚本;另一方面开源项目里难免会夹杂一些业务上遗留的代码。

随着业务的发展,在我们内部已经用多个项目在使用 Sniper 框架。为了方便维护,我们把公共的部分抽了出来,形成一个单独的工具包。这样我们内部使用的框架跟开源版就无法保持代码同步了。这样也好,可以抛弃历史包袱,做到更快速地迭代。

在我们内部没法合并 rpc 和 server 层的代码,所以有两套生成和注册的代码逻辑,现在可以清除了。框架会真对 v0 版本的接口作特殊处理,只有添加 --internal 参数启动才能注册对应接口。这也是我们内部的逻辑,现在也可以清理了。

脱离了业务的羁绊,Sniper 框架会朝着更加纯粹的方向演进。

支持服务和接口级的 hook

之前 Sniper 框架只支持全局 hook,不能针对服务或者接口指定。这在使用上非常麻烦。为

为了绕过这个问题,最早是定义两组 hooks,一组不校验登录态,另一组校验。然后在生成代码的时候通过 --need-login 参数指定使用哪组 hooks。是不是很傻。

后来我还想了一个办法。就是在 rpc 的声明中加入 //sniper:foo这样的行尾注释。框架在生成代码的时候会把 foo 注入到请求的 ctx 中。hook 在处理请求的时候可以读到(通过 twirp.MethodOption 方法)这个 foo 然后做不同的处理。这个方案稍微好那么一点,但依然很二。

现在是时候支持服务和接口级的 hook 了。用法也非常简单。每个服务在生成的时候会自动插入下面的方法:

func (s *FooServer) Hooks() map[string]*twirp.ServerHooks {
  return map[string]*twirp.ServerHooks{
    // "": nil, // Server 一级 hooks
    // "Echo": nil, // Echo 方法的 hooks
  }
}

这个 Hooks 一个特殊的方法,返回一个 map,key 是接口函数名,值是对应的 hook。如果你想给所有接口设定 hook,则可以使用空字符串""作为 key 指定 hook。框架在运行的时候会先执行全局 hook。然后尝试执行接口的 hook。如果接口定义了 hook,则只会执行接口 hook;如果接口没有定义 hook,则尝试执行服务的 hook(也就是 key 为 "" 的 hook)。

通过这种方式,每个服务都会有自己的 hook,互不影响。用起来也比较方便。如果不需要指定服务或者接口一级的 hook,则可以返回 nil 甚至不需要声明 Hooks 方法。

这种设计可能不是最好的,但跟之前的方案要优雅不少。

集成 OpenTelemetry

分布式系统要想平稳运行,一定要支持所谓「可观测」特性。Sniper 项目虽然简单,但也集成了 OpenTracing, Prometheus 和日志组件。但是 Sniper 目前做的还不够好,也就刚刚达到通用的水平。

目前业界在可观测领域已经取得了长足的发展,最重要的就是诞生了 OpenTelemetry 项目。简单来说 OpenTelemetry 就是把 trace, metrics 和 log 三种观测手段合并,对上提供统一的 API 和 SDK,对下提供统一的 Agent 来适配不同的存储后端。

目前 OpenTelemetry 的 trace 特性已经稳定,而 metrics 和 log 估计在 2021 年年底也会正式发布。已经有很多项目(比如 go-redis)接入 OpenTelemetry。Sniper 框架会持续关注 OpenTelemtry 项目的进展。等 metrics 和 log 特性稳定之后会立马启动适配工作。

集成更多业务组件

Sniper 作为一个轻量级的框架,并没有集成太多的组件。之所以这样做,一方面是不同的业务对基础组件有不同的要求;另一方面,我们内部的封装功能并不完善(主要是可观测相关的支持)。所以框架把这些事情留给了业务开发者。

但这样做无疑增加了初学者的使用和学习成本。大家甚至认为 Sniper 太过简陋,不能称之为一个完整的框架。为此,我认为后面至少应该集成数据库和缓存两个组件。

但不论要集成哪个组件,首先就得解决「可观测」的问题。

先说缓存。现还可以用 go-redis 了。因为 go-redis v8 版本不但支持传入 ctx,还支持集成 OpenTelemetry(目前好像只支持 trace)。对于 metrics 和 log,可以考虑等等看或者直接给 go-redis 贡献代码。

再说数据库首先还是不引入 ORM,这个大家可以看我前面写的文章。但三年的实践表明,裸写 sql 确实很繁琐,尤其是写 select 语句和 scan 方法,字段一多简直爆炸。为此,我想引入 sqlx。sqlx 最吸引我的地方就是 StructScan 功能,可以实现下面的效果:

type Place struct {
  Country       string
  City          sql.NullString
  TelephoneCode int `db:"telcode"`
}

rows, err := db.Queryx("SELECT * FROM place")
for rows.Next() {
  var p Place
  err = rows.StructScan(&p)
}

用 20% 的代码解决 80% 的问题,非常优雅。我也看过它的实现,确实用了反射,但会缓存反射结果,性能损失可以忽略。

但有一个问题,目前无论是 sqlx 还是 database/sql 都不支持事件回调(估计以后也不会支持,参考这里),也就不能很好地集成 OpenTelemetry 等观测组件。我查了一下,目前最优雅的方案是写一个 driver wrapper,可以使用 sqlmw。后面会看一下如何集成。

目前的 http 组件也会重新改造以适配 OpenTelemetry。

最终,Sniper 框架会开箱提供数据库、Redis 和 HTTP 客户端三大组件,对于一般的项目基本也够用了。

完善文档&国际化

目前的文档都散落在不同的 README 文件中,不是很系统,有的还不完善。后面会统一整理放到单独的站点(使用 GitHub Page 实现)。项目的核心文档会翻译成英文,希望能吸引外国人使用和参与开发。

目前 Sniper 框架比较特色的地方就在于代码自动生成和注册。这一部分会涉及到 protobuf 插件开发和 go 语法树解析,有一定的难度。后面会将这部分的功能和设计都整理成文档,目标是让所有 go 程序员都能看懂,都能根据自己的需要完成定制。

总结

总得来说,Sniper 框架会一直朝着轻量化、标准化方向发展,在满足业务迭代的同时也时刻关注业界最新的技术进展并加以融合。Sniper 框架的初衷是为开发者提供一套借鉴的思路、提供一个修改的蓝本。这一点从未改变。

部分改动已经推送到 github ,有兴趣的朋友可以尝试一下。