Sniper 框架两周年回顾

2020-07-25 ⏳5.4分钟(2.2千字)

一年前我向大家介绍了 sniper 框架。年复一年,sniper不觉已平稳运行两年有余,是时候再次总结一拨该框架的实战经验了。

关于RPC协议

Sniper基于Twirp协议。Twirp协议非常清真,编码仅支持 json 和 protobuf,请求方法只允许 http post。然而,在实践中发现太清真也不行,必须稍加变通。

第一,需要支持urlencoded表单。我们的客户端没有从零开始写,其网络库是基于其他部门的,既不能发送 json 也不能发送 protobuf,只能发送 urlencoded 格式的表单。没办法,我们只能修改了Twirp,使其生成的代码支持表单数据到 pb 消息的映射,为此还专门制定了一组映射规则。

很多框架也支持表单绑定,这没什么希奇。但是,映射规则在编译的时候就已经确定了,我们就通过代码生成功具直接生成对应的解码和类型转换代码,这样做可以在运行的时候避免使用反射

第二,需要支持get请求。我司有部分中间件强依赖 http get 接口。同样,只能修改Twirp框架,使其支持get请求,但是默认不开启。

第三,需要支持返回非json/protobuf响应。Twirp协议的响应信息只能是 json/protobuf 格式。但在实践中发现我们需要支持各种格式(虽然场景极少),比如

  1. 纯文本,对接支付平台
  2. XML,对接微信
  3. XLSX,导出报表

我们于是对框架作了修改,只要定义包含string content_typebytes data两个字段的响应消息,框架就会自动设置 http 的 content-type 头和 body。

第四,需要支持多 proto 文件。开始我们规定接口只能定义在形如rpc/foo/v1/service.proto 的文件中。确定了服务跟版本,只能定义一个 service。这就导致有很多 service 接口特别多。有的同学只能使用引入新版本号来解决这个问题(我们的活动服务已经有4个版本了),这虽然能工作,但很不雅观。所以我们调整了接口约定,让大家可以在一个服务下定义多个 proto 文件。假如我们想在 foo 下定义一个 bar 服务,我们可以创建rpc/foo/v1/bar.proto,生成的接口代码为rpc/foo/v1/bar_{pb,twirp}.go,其对应的实现代码则为server/fooserver1/bar.go ,接口路径则为/twirp/foo.v1.Bar/Xxx

如果你定义了多个 service,就会生成多份*.twirp.go文件。Twirp原生工具生成的代码中定义了很多私有工具方法,而我们把这些*.twirp.go放在一个文件夹下就会导致命名冲突。为了解决这个问题,我们把这些工具方法统一迁入 twirp 公共库中,避免多余的定义。

目前这种做法还有一个问题。如果你在不同的*.proto中定义了相同的 message,生成代码没有问题,但编译会报错,因为生成的代码都在一个包里,会相互冲突。目前没有很好的解决办法,只能靠大家人肉避免。

第五,需要支持面向功能的 hook。原生的 twirp 实现只支持 service 级的 hook,但业务上需要支持到 method 级。比如,一个服务,有的需要检查登录态,有的不需要。为此,我们只能让整个服务设置成不需要检查,再在具体的方法中人肉写代码检查,非常不方便。

主流的做法是在注册路由之后再单独配置各接口的 hook,注册一个接口需要改两处内容,不太方便。

Sniper提出的方案是在 proto 文件是一次定义。比如

service Foo {
    rpc Echo(EchoReq) returns (EchoResp); // sniper:login
}

行尾的// sniper:login有特殊含义。Sniper在生成接口代码的时候会给 Echo 方法插入一行代码,在接口调用的时候把 login 注入到 ctx。然后各 hook 在执行的时候可以根据 ctx 中的 login 决定是否执行登录态检查。

关于代码生成

Sniper框架最核心的功能就是约定RPC规则和编码规则。如果在 rpc 中定义了 proto 文件,那么服务的路由注册和实现都就确定了,如果每次都要手工写,不紧罗嗦,还不容易统一风格。有鉴于此,我们提供了cmd/sniper脚手架,如果你想定义一个新服务,你可以

go run cmd/sniper --server=util --service=Conf --version=0

这样脚手架会自动生成rpc/util/v0/Conf.protoserver/utilserver0/conf.go并将自动生成接口注册代码。你甚至可以直接访问/twirp/util.v0.Conf/Echo接口了。

如果你在 proto 文件添加了新接口或修改了已有接口的注释,运行cmd/sniper会在server文件自动生成对应的接口代码并同步最新的接口注释。非常方便。

关于数据类型

第一个要说的是时间类型。我们在系统内部统一使用time.Time格式传输和处理时间。time.Time ,对外我们会格式化成2006-01-02 15:04:05字符串。 如果没有时间,调调用方会得到0001-01-01 00:00:00,这个值在其他语言中不是合法的时间。如果能重来,我不会使用Go语言的默认零值。一个较好的方案是定义一个固定的时间(比如项目的上线时间)做为时间零值,保证吐给调用方的时间都是有效。另外一个就是时区问题。如果可能,我会统一将时间格式化成time.RFC3339格式。

第二个要说的是整数类型。protobuf 比需指定是64位还是32位整数,我们就习惯性地使用了int32做为内部主要的整数类型(少数使用int64)。但是 go 语言本身和好多标准库使用的整数类型多数为 int,这就导致我们在好多地方不得不写很多类型转换代码。如果能重来,我希望直接使用 int 类型。int 在 64 位机器上占用8字节,范围足够广,基本不用考虑溢出的问题,也不用频繁转换类型,比较方便。

第三个要说的是枚举类型。 go 语言本身不支持枚举,我们只能使用 int32 常量来模拟。使用原生 int32 有两个问题。

  1. 其一是相互混淆。我们出现过把定时上下线状态常量传给漫画在线状态字段的问题,都是 int32,取值相同却含义不同。
  2. 其二则是分类问题。有太多业务场景需要对 1/2/3 做A功能,对 4/5/6 做B功能,因为使用的是int32,我们在很多地方都是使用 if 和 switch 进行手工分类,非常繁琐。

如果可以重来,我一定为每一种枚举定义各自类型别名。这样一方面可以杜绝混淆的问题,另一方面可以给枚举添加各种方法,解决分类等业务问题。

关于配置系统

此部分主要是添加了多文件支持。最早只支持 sniper.toml 一个文件,确实不方便,线上配置内容也越来越多,修改配置的心智负担比较大。

关于单元测试

最早我们只能 dao 层编写单元测试。为了隔离环境对测试用例的影响,我们会每个 pipeline 启动独立的 mysql/memcache/redis 实例。如果有代码依赖外部 http 接口,我们使用httpmock进行拦截。

随着业务的发展,我们开始对 service 层甚至是 server 导进行测试。层级越高,依赖越多,需要 mock 的代码就越复杂。于是我们引入了bou.ke/monkey,直接对特定函数进行 mock。虽然 monkey 在使用起来不那么方便,但 mock 功能却非常强大。我们可以在测试 service 层逻辑时直接 mock 依赖的 dao 层逻辑,非常灵活。


好了,这就是我们最新的实践心得,希望能帮到大家。