随着业务变迁,即刻后端服务内积累了大量的陈旧代码,维护成本较高,代码重构甚至重写被提上了日程。相比起 Node.js ,Golang 有着一定的优点。由于即刻后端已经较好地服务化了,其他业务在 Go 上也有了一定的实践,直接使用 Go 重写部分即刻服务是一个可行的选择。在此过程中我们可以验证在同一个业务上两种语言的差异,并且可以完善 Go 相关的配套设施。
截至目前,即刻部分非核心服务已经通过 Go 重写并上线。相比原始服务,新版服务的开销显著降低:
接口响应时长降低 50%
旧服务响应时间
新服务响应时间
内存占用降低 95%
服务替换前后内存消耗趋势
CPU 占用降低 90%
服务替换前后 CPU 消耗趋势
注:以上性能数据以用户筛选服务为例,这是一个读远大于写、任务单一的服务。由于在重写的过程中,对原有的实现也进行了一定的优化,所以以上数据仅供参考,不完全代表 Go 和 Node 真实性能比较。
第一步:重写服务
在保证对外接口不变的情况下,需要重写一遍整个业务核心逻辑。不过在重写的过程当中,还是碰到一些问题:
总之,重写不是翻译,需要对业务深入理解,重新实现一套代码。
第二步:正确性验证
由于很多服务没有完整的回归测试,单纯地依赖单元测试是远远不够保证正确性的。
一般来说,只读的接口可以通过数据对拍来验证接口正确性,即对比相同输入的新旧服务的输出。对于小规模的数据集,可以通过在本地启动两个服务进行测试。但是一旦数据规模足够大,就没办法完全在本地测试,一个办法就是流量复制测试。
由于服务之间跨环境调用比较麻烦且影响性能,所以使用消息队列复制请求异步对拍。
第三步:灰度并逐步替换旧服务
等到对业务正确性胸有成竹,就可以逐步上线新版服务了。得益于服务拆分,我们可以在上下游无感的情况下替换服务,只需要将对应服务的逐步替换为新的容器即可。
项目结构是基于 Standard Go Project Layout 的 monorepo:
.├── build: 构建相关文件,可 symbolic link 至外部├── tools: 项目自定义工具├── pkg: 共享代码│ ├── util│ └── ...├── app: 微服务目录│ ├── hello: 示例服务│ │ ├── cmd│ │ │ ├── api│ │ │ │ └── main.go│ │ │ ├── cronjob│ │ │ │ └── main.go│ │ │ └── consumer│ │ │ └── main.go│ │ ├── internal: 具体业务代码一律放在 internal 内,防止被其他服务引用│ │ │ ├── config│ │ │ ├── controller│ │ │ ├── service│ │ │ └── dao│ │ └── Dockerfile│ ├── user: 大业务拆分多个子服务示例│ │ ├── internal: 子业务间共享代码│ │ ├── account:账户服务│ │ │ ├── main.go│ │ │ └── Dockerfile│ │ └── profile: 用户主页服务│ │ ├── main.go│ │ └── Dockerfile│ └── ...├── .drone.yml├── .golangci.yaml├── go.mod└── go.sum
这种模式带来的好处:
静态检查
项目使用 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 进行构建。
有几点是值得注意的:
在 Node 项目里面,我们通常使用 node-config 来为不同环境配置不同的配置。Go 生态内并没有现成的工具可以直接完成相同的工作,不过可以尝试抛弃这种做法。
正如 Twelve-Factor 原则所推崇的,我们要尽可能通过环境变量来配置服务,而不是多个不同的配置文件。事实上,在 Node 项目当中,除开本地开发环境,我们往往也是通过环境变量动态配置,多数的 test.json/beta.json 直接引用了 production.json。
我们将配置分为两部分:
我们可以在服务目录中编写一份 config.toml(选择任何喜欢的配置格式),并编写基础的配置,作为本地开发的时候使用。
# config.tomlport=3000sentryDsn="https://project@sentry.io"[mongodb]url="mongodb://localhost:27017"database="db"
当在线上运行的时候,我们还需要在配置当中注入环境变量。可以使用 Netflix/go-env 将环境变量注入配置数据结构中:
type MongoDBConfig struct { URL string `toml:"url" env:"MONGO_URL,MONGO_URL_ACCOUNT"` Database string `toml:"database"`}type Config struct { Port int `toml:"port" env:"PORT,default=3000"` SentryDSN string `toml:"sentryDsn"` MongoDB *MongoDBConfig `toml:"mongodb"`}//go:embed config.tomlvar configToml stringfunc ParseConfig() (*Config, error) { var cfg Config if _, err := toml.Decode(configToml, &cfg); err != nil { return nil, err } if _, err := env.UnmarshalFromEnviron(&cfg); err != nil { return nil, err } return &cfg, nil}
上面代码还使用了最新的 Go1.16 embed 功能,只需要一行 Compiler Directive 就可以将任意文件一并打包进入最终构建出来二进制文件内,构建镜像只需要拷贝单个可执行文件即可,降低构建发布的复杂度。
代码管理
即刻后端有多种语言的服务(Node/Java/Go),各个服务重复定义类型会造成人力浪费和不统一,故通过 ProtoBuf 定义类型,再用 protoc 生成对应的代码,并在一个仓库内维护各个语言的 client。
.├── go│ ├── internal: 内部实现,如 http client 封装│ ├── service│ │ ├── user│ │ │ ├── api.go: 接口定义与实现│ │ │ ├── api_mock.go: 通过 gomock 生成的接口 mock│ │ │ └── user.pb.go: 通过 protoc 生成的类型文件│ │ ├── hello│ │ └── ...│ ├── go.mod│ ├── go.sum│ └── Makefile├── java├── proto│ ├── user│ │ └── user.proto│ ├── hello│ │ └── hello.proto│ └── ...└── Makefile
每一个服务通过一个独立的 package 对外暴露接口,每一个服务都由四部分组成:
ProtoBuf
正如上面所说,为了降低内部接口对接和维护成本,我们选择使用 ProtoBuf 定义类型,并生成了 Go 类型。虽然使用 ProtoBuf 定义,但服务之间依然通过 JSON 传递数据,数据序列化和反序列化成了问题。
为了简化 ProtoBuf 和 JSON 互相转换,Google 提供了一个叫做 jsonpb 的包,这个包在原生 json 的基础上实现了 Enum Name(string) 和 Value(int32) 互相转换,以兼容传统的 string enum;还支持了 oneof 类型。上面的能力都是 Go 原生的 json 所无法实现的。如果使用原生 json 序列化 proto 类型,将会导致 enum 无法输出字符串和 oneof 完全无法输出。
这么说起来,是不是我们在代码全部都使用 jsonpb 替换掉原生 json 就好了?并不是,jsonpb 只支持对 proto 类型序列化:
func Marshal(w io.Writer, m proto.Message) error
除非所有对外读写接口的类型都用 ProtoBuf 定义,否则就不能一路使用 jsonpb 。
不过天无绝人之路,Go 的原生 json 定义了两个接口:
// Marshaler is the interface implemented by types that// can marshal themselves into valid JSON.type Marshaler interface { MarshalJSON() ([]byte, error)}// Unmarshaler is the interface implemented by types// that can unmarshal a JSON description of themselves.// The input can be assumed to be a valid encoding of// a JSON value. UnmarshalJSON must copy the JSON data// if it wishes to retain the data after returning.//// By convention, to approximate the behavior of Unmarshal itself,// Unmarshalers implement UnmarshalJSON([]byte("null")) as a no-op.type Unmarshaler interface { UnmarshalJSON([]byte) error}
任何类型只要实现了这两个接口,在被(反)序列化的时候就能调用自己的逻辑进行操作,类似 Hook 函数。那样,只需要为所有的 proto 类型实现这两个接口:当 json 尝试(反)序列化自己,就转而使用 jsonpb 进行。
func (msg *Person) MarshalJSON() ([]byte, error) { var buf bytes.Buffer err := (&jsonpb.Marshaler{ EnumsAsInts: false, EmitDefaults: false, OrigName: false, }).Marshal(&buf, msg) return buf.Bytes(), err}func (msg *Person) UnmarshalJSON(b []byte) error { return (&jsonpb.Unmarshaler{ AllowUnknownFields: true, }).Unmarshal(bytes.NewReader(b), msg)}
经过一番寻找,最后找到了一个 protoc 插件 protoc-gen-go-json :它可以在生成 proto 类型的同时,为所有类型实现 json.Marshaler 和 json.Unmarshaler。这样一来就不需要为了序列化兼容而妥协,并且对代码也没有任何侵入性。
发布
由于是独立维护的仓库,需要以 Go module 的形式引入项目内使用。得益于 Go module 的设计,版本发布可以和 GitHub 无缝结合在一起,效率非常高。
由于 go get 本质上就是下载代码,我们的代码托管在 GitHub 上,所以在国内阿里云上构建代码时可能因为网络原因出现拉取依赖失败的情况(private mod 无法通过 goproxy 拉取)。于是我们改造了 goproxy,在集群内部署了一个 goproxy:
我们只需要执行如下代码即可通过内部 goproxy 下载依赖:
GOPROXY="http://goproxy.infra:8081" \GONOSUMDB="github.com/iftechio" \go mod download
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 传递到整个链路当中,所有下游调用立即结束运行。如果整个链路都遵循这个规范,一旦上游关闭请求,所有服务都会取消当前的操作,可以减少大量无谓的消耗。
在开发的时候就需要注意:
// 返回一个仅仅实现了 Value 接口的 context// 只保留 context 内的数据,但忽略 cancel 信号func DetachedContext(ctx context.Context) context.Context { return &detachedContext{Context: context.Background(), orig: ctx}}type detachedContext struct { context.Context orig context.Context}func (c *detachedContext) Value(key interface{}) interface{} { return c.orig.Value(key)}func storeUserInfo(ctx context.Context, info interface{}) { ctx = DetachedContext(ctx) saveToDB(ctx, info) updateCahce(ctx, info)}
上下文透传
每一个请求进入的时候,http request context 都被携带上各种当前 request 的信息,比如 traceId、用户信息,这些数据就能够随着 context 被一路透传至业务整条链路,期间收集到的监控数据都会与这些数据进行关联,便于监控数据聚合。
Context.Value should inform, not control.
使用 context 传递数据最需要注意的就是:context 的数据仅仅用于监控,切勿用于业务逻辑。所谓“显式优于隐式”,由于 context 不直接对外暴露任何内部数据,使用 context 传递业务数据会使程序非常不优雅,而且难以测试。换句话说,任何一个函数哪怕传入了的是 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.
可以看到, Callers 只能收集单个 goroutine 内的调用栈,如果希望收集到完整的 error trace,则需要在跨 goroutine 传递错误的时候,将 stacktrace 包含在 error 内部。这个时候就可以使用第三方库 pkg/errors 的 errors.WithStack 或者 errors.Wrap 来实现,它们会创建一个新的 error 节点,并存入当时的调用栈:
// WithStack annotates err with a stack trace at the point WithStack was called.// If err is nil, WithStack returns nil.func WithStack(err error) error { if err == nil { return nil } return &withStack{ err, callers(), }}func main() { ch := make(chan error) go func() { err := doSomething() ch <- errors.withStack(err) }() err := <-ch fmt.Printf("%w", err)}
最终的错误收集(往往在根部的 web 中间件上),可以直接使用 Sentry:
sentry.CaptureException(errors.WithStack(err)) // 最终上传的时候也不忘收集 stacktrace
Sentry 会基于 errors.Unwrap 接口,取出每一层的 error。Sentry 针对每一层 error 能够自动导出错误栈。由于 stacktrace 并非正式标准,Sentry 主动适配了几个主流的 Stacktrace 方案,其中就包括 pkg/errors 的。
这样就可以通过 Sentry 后台查看完整的报错信息。如下图,每一个大的 section 都是一层 error,每一个 section 内都包含这个 error 内的上下文信息。
作者:sorcererxw
来源-微信公众号:即刻技术团队
出处
:https://mp.weixin.qq.com/s/cepoYJR5Xeloan31-D1iQg