cover

Golang 在即刻后端的实践

sorcererxw

背景

随着业务变迁,即刻后端服务内积累了大量的陈旧代码,维护成本较高,代码重构甚至重写被提上了日程。相比起 Node.js,Golang 有着一定的优点。由于即刻后端已经较好地服务化了,其他业务在 Go 上也有了一定的实践,直接使用 Go 重写部分即刻服务是一个可行的选择。在此过程中我们可以验证在同一个业务上两种语言的差异,并且可以完善 Go 相关的配套设施。

改造成果

截至目前,即刻推荐流、用户筛选服务已经通过 Go 重写并上线。相比原始服务, 新版服务的开销显著降低:

  • 接口响应时长降低 50%
    旧服务接口响应时长
    新服务接口响应时长
  • 内存占用降低 95%
  • CPU 占用降低 90%

注:以上性能数据以用户筛选服务为例,这是一个读远大于写、任务单一的服务。由于在重写的过程中,对原有的实现也进行了一定的优化,所以以上数据仅供参考,不完全代表 Go 和 Node 真实性能比较。

改造方案

第一步:重写服务

在保证对外接口不变的情况下,需要重写一遍整个业务核心逻辑。不过在重写的过程当中,还是碰到一些问题:

  • 由于以往的 Node 服务大多没有显式声明接口的输入输出类型,重写的时候需要找到所有相关字段。
  • 由于以往代码绝大多数不包含单元测试,重写之后需要理解业务需求并设计单元测试。
  • 老代码里面大量使用了 any 类型,需要费一番功夫才能明确所有可能的类型。很多类型在 Node 里面不需要非常严格,但是放到 Go 里面就不容偏差。

总之,重写不是翻译,需要对业务深入理解,重新实现一套代码。

第二步:正确性验证

由于很多服务没有完整的回归测试,单纯地依赖单元测试是远远不够保证正确性的。

一般来说,只读的接口可以通过数据对拍来验证接口正确性,即对比相同输入的新旧服务的输出。对于小规模的数据集,可以通过在本地启动两个服务进行测试。但是一旦数据规模足够大,就没办法完全在本地测试,一个办法就是流量复制测试。

由于服务之间跨环境调用比较麻烦且影响性能,所以使用消息队列复制请求异步对拍。

  • 原始服务在每一次响应的时候,将输入和输出打包成消息发送至消息队列。
  • 在测试环境下的消费服务会接受消息,并将输入重新发送至新版服务。
  • 等到新版服务响应之后,消费服务会对比前后两次响应体,如果结果不同则输出日志。
  • 最后,只需要下载日志到本地,根据测试数据逐一修正代码即可。

第三步:灰度并逐步替换旧服务

等到对业务正确性胸有成竹,就可以逐步上线新版服务了。得益于服务拆分,我们可以在上下游无感的情况下替换服务,只需要将对应服务的逐步替换为新的容器即可。

工程实践

仓库结构

项目结构是基于 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
  • app 目录包含了所有服务代码,可以自由划分层级。
  • 所有服务共享的代码放置于根目录的 pkg 内。
  • 所有外部依赖在根目录的 go.mod 内声明。
  • 每一个服务或者一组服务,通过 internal 目录,独占下面的所有的代码,避免被其他服务引用。

这种模式带来的好处:

  • 开发时只需要关心单一代码仓库,提高开发效率。
  • 所有服务的代码都可以放在一起,大到一整个大功能的服务集,小到一个运营活动服务,通过合理的层级组织,都可以在 app 目录下清晰维护。
  • 在修改公共代码的时候,对所有依赖其的服务保证兼容。即便是不兼容,通过 IDE 提供的重构功能,能够轻松地进行替换。

持续集成与构建

静态检查

项目使用 golangci-lint 静态检查。每一次代码 push,Github Action 会自动运行 golangci-lint,非常快且方便,如果发生了错误会将警告直接 comment 的 PR 上。

golangci-lint 本身不包含 lint 策略,但是可以集成 各式 linter 以实现非常细致的静态检查,把潜在错误扼杀在摇篮。

测试+构建镜像

使用 Drone 进行测试和构建镜像。虽然尝试过在 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/gift/Dockerfile 在构建之后会生成 {registry}/gift-live-app:{branch}-{commitId} 形式的镜像。
  • 所有构建(包括下载依赖、编译)由 Dockerfile 定义,避免在 CI 主流程上引入过多逻辑降低灵活度。通过 Docker 本身的缓存机制也能使构建速度飞快。
  • 一个问题,一旦服务目录之外的共享代码发生变化,Drone 无法感知并构建受到影响的服务。解决方案是在 git commit message 内加上特定的字段,告知 Drone 执行相应的构建。

配置管理

在 Node 项目里面,我们通常使用 node-config 来为不同环境配置不同的配置。Go 生态内并没有现成的工具可以直接完成相同的工作,不过可以尝试抛弃这种做法。

正如 Twelve-Factor 原则 所推崇的,我们要尽可能通过环境变量来配置服务,而不是多个不同的配置文件。事实上,在 Node 项目当中,除开本地开发环境,我们往往也是通过环境变量动态配置,多数的 test.json/beta.json 直接引用了 production.json。

我们将配置分为两部分:

  • 单一配置文件

    我们在服务内通过文件的方式,定义一份完整的配置,作为基础配置,并且可以在本地开发的时候使用。

  • 动态环境变量

    当服务部署到线上之后,在基础配置的基础上,我们将环境变量注入到配置当中。

我们可以在服务目录中编写一份 config.toml (选择任何喜欢的配置格式),并编写基础的配置,作为本地开发的时候使用。

# config.toml
port=3000
sentryDsn="https://[email protected]"

[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.toml
var configToml string

func 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.proto
│   ├── hello.proto
│   └── ...
└── Makefile

每一个服务通过一个独立的 package 对外暴露接口,每一个服务都由四部分组成:

  • 接口定义
  • 基于接口定义实现的具体调用代码
  • 基于接口定义由 gomock 生成 mock 实现
  • 基于 proto 生成类型代码

API 设计

接口定义在不使用代码生成的前提下,通过可选参数,为每一个接口添加降级、重试、超时等选项。

result, err := userservice.DefaultClient.IsBetaUser(
  context.Background(), 
  []string{"guoguo"}, 
  option.WithRetries(3),  // 重试三次
  option.WithDowngrade(func() interface{} { return map[string]bool{"guoguo":false} }), // 接口降级
  option.WithTimeout(3*time.Second), // 超时控制,也可以直接使用 context.WithTimeout
)

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.Marshalerjson.Unmarshaler。这样一来就不需要为了序列化兼容而妥协,对现有代码也没有任何侵入性。

发布

由于是独立维护的仓库,需要以 Go module 的形式引入项目内使用。得益于 Go module 的设计,版本发布可以和 Github 无缝结合在一起,效率非常高。

  • 测试版本

    go mod 支持直接拉取对应的分支的代码作为依赖,不需要手动发布 alpha 版本,只需要在调用方的代码执目录执行 go get -u github.com/iftechio/jike-sdk/go@{branch} 就可以直接下载对应开发分支的最新版本了。

  • 正式版本

    当改动合并进入主分支,只需通过 Github Release 就可以发布一个稳定版本(也可以在本地打 git tag),即可通过具体版本号拉到对应的仓库快照:go get github.com/iftechio/jike-sdk/go@{version}

    由于 go get 本质上就是下载代码,我们的代码托管在 Github 上,所以在国内阿里云上构建代码时可能因为网络原因出现拉取依赖失败的情况(private mod 无法通过 goproxy 拉取)。于是我们改造了 goproxy,在集群内部署了一个 goproxy:

    • 针对公共仓库会通过 goproxy.cn 拉取。
    • 针对私有仓库,则可以通过代理直接从 Github 上拉取,并且 goproxy 也会代为处理好 Github 私有仓库鉴权工作。

      我们只需要执行如下代码即可通过内部 goproxy 下载依赖:

      GOPROXY="http://goproxy.infra:8081" \
      GONOSUMDB="github.com/iftechio" \
      go mod download

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 错误,以使调用者能够感知异常并退出。但是 RPC 断路器也会捕获这个错误并记录为失败。极端场景下,客户端不断发起请求并立刻取消,就能够使服务的断路器纷纷打开,造成服务的不稳定。解决方案就是改造断路器,对于特定的错误依然抛出,但不记录为失败。
  • 分布式场景下绝大多数数据写入无法使用事务,需要考虑一个操作如果被中途取消,最终一致性还能否得到保证?对于一致性要求高的操作,需要在执行前主动屏蔽掉 cancel 信号:
    // 返回一个仅仅实现了 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/errorserrors.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(err)

Sentry 会基于 errors.Unwrap 接口,取出每一层的 error。Sentry 针对每一层 error 能够自动导出错误栈。由于 stacktrace 并非正式标准,Sentry 主动适配了几个主流的 Stacktrace 方案,其中就包括 pkg/errors 的。

最终可以通过 Sentry 后台查看完整的报错信息。如下图,每一个大的 section 都是一层 error,每一个 section 内都包含这个 error 内的上下文信息。

参考链接

TJ 谈 Go 相比 Node 的生产力优势

Standard Go Project Layout

The Tweleve-Factor App

Go Wiki - Module: Releaseing Modules (V2 or Higher)

How to correctly use context.Context in Go 1.7

Dave Cheney - Don’t just check errors, handle them gracefully

Uber Go 编码规范