背景
随着业务变迁,即刻后端服务内积累了大量的陈旧代码,维护成本较高,代码重构甚至重写被提上了日程。相比起 Node.js ,Golang 有着一定的优点。由于即刻后端已经较好地服务化了,其他业务在 Go 上也有了一定的实践,直接使用 Go 重写部分即刻服务是一个可行的选择。在此过程中我们可以验证在同一个业务上两种语言的差异,并且可以完善 Go 相关的配套设施。
改造成果
截至目前,即刻部分非核心服务已经通过 Go 重写并上线。相比原始服务,新版服务的开销显著降低:
接口响应时长降低 50%
内存占用降低 95%
CPU 占用降低 90%
注:以上性能数据以用户筛选服务为例,这是一个读远大于写、任务单一的服务。由于在重写的过程中,对原有的实现也进行了一定的优化,所以以上数据仅供参考,不完全代表 Go 和 Node 真实性能比较。
改造方案
第一步:重写服务
在保证对外接口不变的情况下,需要重写一遍整个业务核心逻辑。不过在重写的过程当中,还是碰到一些问题:
- 以往的 Node 服务大多没有显式声明接口的输入输出类型,重写的时候需要找到所有相关字段。
- 以往代码绝大多数不包含单元测试,重写之后需要理解业务需求并设计单元测试。
- 老代码里面大量使用了 any 类型,需要费一番功夫才能明确所有可能的类型。很多类型在 Node 里面不需要非常严格,但是放到 Go 里面就不容偏差。
总之,重写不是翻译,需要对业务深入理解,重新实现一套代码。
第二步:正确性验证
由于很多服务没有完整的回归测试,单纯地依赖单元测试是远远不够保证正确性的。
一般来说,只读的接口可以通过数据对拍来验证接口正确性,即对比相同输入的新旧服务的输出。对于小规模的数据集,可以通过在本地启动两个服务进行测试。但是一旦数据规模足够大,就没办法完全在本地测试,一个办法就是流量复制测试。
由于服务之间跨环境调用比较麻烦且影响性能,所以使用消息队列复制请求异步对拍。
- 原始服务在每一次响应的时候,将输入和输出打包成消息发送至消息队列。
- 在测试环境下的消费服务会接受消息,并将输入重新发送至新版服务。
- 等到新版服务响应之后,消费服务会对比前后两次响应体,如果结果不同则输出日志。
- 最后,只需要下载日志到本地,根据测试数据逐一修正代码即可。
第三步:灰度并逐步替换旧服务
等到对业务正确性胸有成竹,就可以逐步上线新版服务了。得益于服务拆分,我们可以在上下游无感的情况下替换服务,只需要将对应服务的逐步替换为新的容器即可。
仓库结构
项目结构是基于 Standard Go Project Layout 的 monorepo:
apppkggo.modinternal
这种模式带来的好处:
app
持续集成与构建
静态检查
项目使用 golangci-lint 静态检查。每一次代码 push,Github Action 会自动运行 golangci-lint,非常快且方便,如果发生了错误会将警告直接 comment 的 PR 上。
golangci-lint 本身不包含 lint 策略,但是可以集成各式 linter 以实现非常细致的静态检查,把潜在错误扼杀在摇篮。
测试+构建镜像
为了更快的构建速度,我们尝试过在 GitHub Action 上构建镜像,通过 matrix 特性可以良好地支持 monorepo。但是构建镜像毕竟相对耗时,放在 GitHub Action 上构建会耗费大量的 GitHub Action 额度,一旦额度用完会影响正常开发工作。
最终选择了自建的 Drone 来构建,通过 Drone Configuration Extension 也可以自定义复杂的构建策略。
通常来讲,我们希望 CI 系统构建策略足够智能,能够自动分辨哪些代码是需要构建,哪些代码是需要测试的。在开发初期,我也深以为然,通过编写脚本分析整个项目的依赖拓扑,结合文件变动,找到所有受到影响的 package,进而执行测试和构建。看上去非常美好,但是现实是,一旦改动公共代码,几乎所有服务都会被重新构建,简直就是噩梦。这种方式可能更加适合单元测试,而不是打包。
于是,我现在选择了一种更加简单粗暴的策略,以 Dockerfile 作为构建的标志:如果一个目录包含 Dockerfile,那么表示此目录为“可构建“的;一旦此目录子文件发生变动(新增或者修改),则表示此 Dockerfile 是“待构建“的。Drone 会为每一个待构建的 Dockerfile 启动一个 pipeline 进行构建。
有几点是值得注意的:
docker build --file app/hello/Dockerfile --build-arg TARGET="./app/hello" ../app/live/chat/Dockerfile{registry}/chat-live-app:{branch}-{commitId}
配置管理
在 Node 项目里面,我们通常使用 node-config 来为不同环境配置不同的配置。Go 生态内并没有现成的工具可以直接完成相同的工作,不过可以尝试抛弃这种做法。
正如 Twelve-Factor 原则所推崇的,我们要尽可能通过环境变量来配置服务,而不是多个不同的配置文件。事实上,在 Node 项目当中,除开本地开发环境,我们往往也是通过环境变量动态配置,多数的 test.json/beta.json 直接引用了 production.json。
我们将配置分为两部分:
- 单一配置文件
我们在服务内通过文件的方式,定义一份完整的配置,作为基础配置,并且可以在本地开发的时候使用。 - 动态环境变量
当服务部署到线上之后,在基础配置的基础上,我们将环境变量注入到配置当中。
config.toml
当在线上运行的时候,我们还需要在配置当中注入环境变量。可以使用 Netflix/go-env 将环境变量注入配置数据结构中:
embed
服务调用
代码管理
即刻后端有多种语言的服务(Node/Java/Go),各个服务重复定义类型会造成人力浪费和不统一,故通过 ProtoBuf 定义类型,再用 protoc 生成对应的代码,并在一个仓库内维护各个语言的 client。
每一个服务通过一个独立的 package 对外暴露接口,每一个服务都由四部分组成:
- 接口定义
- 基于接口定义实现的具体调用代码
- 基于接口定义由 gomock 生成 mock 实现
- 基于 proto 生成类型代码
API 设计
接口定义在不使用代码生成的前提下,通过可选参数,为每一个接口添加降级、重试、超时等选项。
ProtoBuf
正如上面所说,为了降低内部接口对接和维护成本,我们选择使用 ProtoBuf 定义类型,并生成了 Go 类型。虽然使用 ProtoBuf 定义,但服务之间依然通过 JSON 传递数据,数据序列化和反序列化成了问题。
jsonpbjsononeofjsonjsonenumoneof
jsonpbjsonjsonpb
jsonpb
json
jsonjsonpb
json.Marshalerjson.Unmarshaler
发布
由于是独立维护的仓库,需要以 Go module 的形式引入项目内使用。得益于 Go module 的设计,版本发布可以和 GitHub 无缝结合在一起,效率非常高。
go get -u github.com/iftechio/rpc/go@{branch}go get github.com/iftechio/rpc/go@{version}
由于 go get 本质上就是下载代码,我们的代码托管在 GitHub 上,所以在国内阿里云上构建代码时可能因为网络原因出现拉取依赖失败的情况(private mod 无法通过 goproxy 拉取)。于是我们改造了 goproxy,在集群内部署了一个 goproxy:
- 针对公共仓库会通过 goproxy.cn 拉取。
- 针对私有仓库,则可以通过代理直接从 GitHub 上拉取,并且 goproxy 也会代为处理好 GitHub 私有仓库鉴权工作。
我们只需要执行如下代码即可通过内部 goproxy 下载依赖:
Context
Context provides a means of transmitting deadlines, caller cancellations, and other request-scoped values across API boundaries and between processes.
Context 是 Go 当中一个非常特别的存在,可以像一座桥一样将整个业务串起来,使得数据和信号可以在业务链路上下游之间传递。在我们的项目当中,context 也有不少的应用:
取消信号
每一个 http 请求都会携带一个 context,一旦请求超时或者 client 端主动关闭连接,最外层会将一个 cancel 信号通过 context 传递到整个链路当中,所有下游调用立即结束运行。如果整个链路都遵循这个规范,一旦上游关闭请求,所有服务都会取消当前的操作,可以减少大量无谓的消耗。
在开发的时候就需要注意:
context.ErrCancelled
上下文透传
每一个请求进入的时候,http request context 都被携带上各种当前 request 的信息,比如 traceId、用户信息,这些数据就能够随着 context 被一路透传至业务整条链路,期间收集到的监控数据都会与这些数据进行关联,便于监控数据聚合。
Context.Value should inform, not control.
emptyCtx
错误收集
Errors are just values.
Go 的错误是一个普通的值(从外部看来就是一个字符串),这给收集错误带来了一定的麻烦:我们收集错误不单需要知道那一行错误的内容,还需要知道错误的上下文信息。
Go1.13 引入了 error wrap 的概念,通过 Wrap/Unwrap 的设计, 就可以将一个 error 变成单向链表的结构,每一个节点上都能够存储自定义的上下文信息,并且可以使用一个 error 作为链表头读取后方所有错误节点。
对于单个错误来说,错误的 stacktrace 是最重要的信息之一。Go 通过 runtime.Callers 实现 stacktrace 收集:
Callers fills the slice pc with the return program counters of function invocations on the calling goroutine's stack.
Callerserrors.WithStackerrors.Wrap
最终的错误收集(往往在根部的 web 中间件上),可以直接使用 Sentry:
errors.Unwrappkg/errors
这样就可以通过 Sentry 后台查看完整的报错信息。如下图,每一个大的 section 都是一层 error,每一个 section 内都包含这个 error 内的上下文信息。