虽然我的博客一直挂着 自动化, 分布式系统, 存储 这样的 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。
总的来说,正如 @xxchan 在 How 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 side: https://github.com/beyondstorage/go-storage/issues/612
- go-service-* side: https://github.com/beyondstorage/go-service-s3/issues/143
创建的时机由 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_target
:pull_request_target
会读取 base 分支(不是当前分支)并让 runner 拿到写权限,这样才有权限 commit 并 pushif: ${{ 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 将这个流程固化下来,届时再来看看成效吧~