虽然我的博客一直挂着 自动化, 分布式系统, 存储 这样的 Slogan,但是因为工作内容的关系,这段时间很少聊到自动化相关的话题。最近恰巧做了一些自动化相关的工作,今天来聊聊他们。

背景

这段时间以来我一直在做 BeyondStorage,这是一个专注于提供跨云数据服务的开源社区,愿景是让数据在云上自由流动。Beyond 一方面指原意超越,另一方面也蕴涵着基于现有存储之上的服务这么个概念。而 go-storage 是这个社区最核心的项目,它是一个供应商中立的 Golang 存储库,希望能实现 Write once, run on every storage service.

go-storage 定义了存储层的 API,向下允许各个服务来实现,向上对应用暴露统一的接口。为了避免导致用户一次性引入太多不需要的库,go-storage 将各个服务的实现拆分到独立的 repo 中,统一命名为 go-service-xxx,所以 BeyondStorage 组织下会有 go-storage 和一大堆 go-service-* 项目。

实现 go-storage 的 API 有很多枯燥的事情需要去做。比如 go-storage 的所有 API 都是强类型的,为了支持传递可选参数,增加了一个叫做 Pair 的概念:

type Pair struct {
	Key   string
	Value interface{}
}

然后对外暴露强类型的 API:

func WithContentType(v string) Pair {
	return Pair{
		Key:   "content_type",
		Value: v,
	}
}

而存储的 API 接口就会接受这些参数:

type Storager interface {
    ...

    Write(path string, r io.Reader, size int64, pairs ...Pair) (n int64, err error)
    
    ...
}

在这个过程中,Pair 的强类型 API 需要维护,Write 的实现过程中也需要解析 Pair。为了降低这些成本,go-storage 实现了一个 codegen,维护一组 toml 文件,然后读取这些文件来生成代码。所以服务的维护者真正需要实现的 API 是这样的:

func (s *Storage) write(ctx context.Context, path string, r io.Reader, size int64, opt pairStorageWrite) (n int64, err error) {}

这里的 pairStorageWrite 是根据当前服务支持的可选参数自动生成出来的,形如:

type pairStorageWrite struct {
	pairs                                    []Pair
	HasContentMd5                            bool
	ContentMd5                               string
	HasContentType                           bool
	ContentType                              string
	HasExceptedBucketOwner                   bool
	ExceptedBucketOwner                      string
	HasIoCallback                            bool
	IoCallback                               func([]byte)
	HasServerSideEncryption                  bool
	ServerSideEncryption                     string
	HasServerSideEncryptionAwsKmsKeyID       bool
	ServerSideEncryptionAwsKmsKeyID          string
	HasServerSideEncryptionBucketKeyEnabled  bool
	ServerSideEncryptionBucketKeyEnabled     bool
	HasServerSideEncryptionContext           bool
	ServerSideEncryptionContext              string
	HasServerSideEncryptionCustomerAlgorithm bool
	ServerSideEncryptionCustomerAlgorithm    string
	HasServerSideEncryptionCustomerKey       bool
	ServerSideEncryptionCustomerKey          []byte
	HasStorageClass                          bool
	StorageClass                             string
}

所以所有的服务实现的构建过程都需要依赖 go-storage 提供的命令行工具 definitions 来生成这些代码。这就导致不能简单的依赖 dependabot 来实现依赖的自动更新,每次都需要人工介入来生成代码。

除此以外,go-storage 是一个高度依赖 proposal 的项目。所有特性的增加都需要经历如下的过程:

  • 撰写 proposal
  • 创建 Tracking issue
  • 在 go-storage 中实现需求
  • go-service-* 更新 go-storage 并跟进实现

在 service 数量少的时候,维护者会在 go-storage 的 tracking issue 中维护所有需要更新的服务,但是现在维护中的 services 已经膨胀到了 20 多个。在 go-storage 的 tracking issue 维护服务的实现情况往往会导致这个 issue 久久无法 close。

总的来说,正如 @xxchanHow to maintain go-service-* more easily? 中指出的那样,当前的项目架构遇到了很严重的可维护性问题。

分析

go-storage 项目的可维护性问题的本质是强耦合。虽然看起来 go-storage 是一个独立项目,但是每次 go-storage 有更新,所有的服务立马就需要同步,这实际上还是耦合在一起的。

为此,我们可以把现在的流程:

  • go-storage update 1
  • go-service-* update to master
  • go-storage update 2
  • go-service-* update to master
  • go-storage release
  • go-service-* update to the new release and also release

转化为:

  • go-storage update 1
  • go-storage update 2
  • go-storage release
  • go-service-* update to the new release and also release

在新的流程下,新特性的实现方式将会是这样的

go-storage 侧

  • 撰写 proposal
  • 创建 Tracking issue
  • 实现 proposal 要求的特性
  • 发布版本

go-service-* 侧

  • 创建 Service Tracking issue
  • 更新 go-storage
  • 实现该特性

在这个过程中,创建 Service Tracking issue更新 go-storage 是高度同化的工作,完全可以自动化这些流程使得服务的维护者可以专注于特性的实现。

解决

创建 Service tracking issue

Service tracking issue 的价值是把原来集中在 go-storage 的实现记录分散到各个 service 中,一方面降低 go-storage 维护者的负担,另一方面便于各个服务跟踪特性的实现进展。

社区有一个自己维护的工具 go-community,我在这个工具中增加了一个叫做 track 的功能,命令行看起来是这样:

community track --title "Service tracking issue for GSP-127: Add ServiceInternel and RequestThrottled Errors" --path /tmp/content.md --repo go-service-*
  • title 指定 issue 的标题
  • path 指定 issue 的内容
  • repo 是一个 glob 的表达式,支持通配符

效果是这样:

创建的时机由 go-storage 的维护者来决定,基本上是在 go-storage 侧已经完整实现,准备 close tracking issue 的时候。

自动更新 go-storage

所有项目都默认配置了 dependabot,当依赖有更新的时候会自动创建 PR,我们可以想办法让 dependabot 在更新 go-storage 的时候自动进行 make build 并 commit & push。

这里参考了 github 的文档: Automating Dependabot with GitHub Actions,在 service 中增加了这样一个 workflow 来实现:

name: Dependabot Auto Build
on: pull_request_target

permissions:
  pull-requests: write
  contents: write

jobs:
  dependabot:
    runs-on: ubuntu-latest
    if: ${{ github.actor == 'dependabot[bot]' }}
    steps:
      - name: Dependabot metadata
        id: metadata
        uses: dependabot/fetch-metadata@v1.1.0
        with:
          github-token: "${{ secrets.GITHUB_TOKEN }}"

      - name: Set up Go 1.x
        uses: actions/setup-go@v2
        with:
          go-version: "1.15"

      - name: Checkout repository
        uses: actions/checkout@v2
        with:
          ref: ${{ github.head_ref }}
          token: ${{ secrets.ROBOT_GITHUB_TOKEN }}

      - name: Auto build
        run: make build

      - name: Auto commit
        uses: stefanzweifel/git-auto-commit-action@v4
        with:
          commit_message: Auto build to generate code

比较关键的地方是这样几处:

  • on: pull_request_targetpull_request_target 会读取 base 分支(不是当前分支)并让 runner 拿到写权限,这样才有权限 commit 并 push
  • if: ${{ github.actor == 'dependabot[bot]' }} 检查 PR 的发起者是不是 dependabot,如果不是就跳过
  • ref: ${{ github.head_ref }} checkout 到这个 PR 的 HEAD ref,确保跑在正确的分支上
  • token: ${{ secrets.ROBOT_GITHUB_TOKEN }} 指定 Personal Access Token 来 checkout 目录

这里额外需要注意的是 token,使用 github action 默认的身份 push 的新 commit 无法触发 workflow run。解决方案是创建一个 Personal Access Token,比较推荐的实践是使用一个 robot 帐号。

总结

基于 go-storage 目前的项目架构,我们采用调整流程并辅以自动化解决了可维护性的问题。目前社区正在起草 RFC 将这个流程固化下来,届时再来看看成效吧~