R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。

发版辅助工具:因为规范,所以有效

以下语言可用: 简体中文 正體中文 English

业务扩张导致的问题

随着公司前几年的扩张,部门内的团队越来越多、业务越来越复杂。为了应对这种情况,前端团队使用了微前端,后端团队则使用了微服务。单个仓库的维护成本确实变低了,但这又带来了另外几个问题:

  • 业务模块归属的团队可能会变化,例如“订单追踪服务”之前属于团队 A,但有一天拆到了团队 B;
  • 很难整理出网状的模块调用关系,一个业务需求可能会改动到多个仓库的代码,例如我们对接一个新的物流渠道,从最底层的数据模型服务,到最上层对外封装的服务,可能都要有变化;
  • 前后端模块无法一一映射,因为前后端的拆分粒度不同,由于有 BFF,前端是按照页面来拆分的,而后端是按照业务模块来拆分的;对于跨团队的需求,需要开多个 Jira 单,但前端可能是同一个项目。

这导致的后果就是:在发布需求量大的情况下(每次发版大概需要发十几个需求,涉及到不知道多少项目),发布人不可能了解自己团队的每个需求是否依赖了别的团队,也无法快速了解每个项目每个分支是否已经被合并、是否已经有了发布 tag,容易出现遗漏。此外,如果多个团队的需求改动到同一项目(如两个团队都对微前端的主应用做了改动),那么发布人也需要花时间协调谁来发、何时发。

此外,还有一些特殊情况:

  • 可能会出现需求已经回归完成,但因为某些原因部分需求无法发布,此时需要回滚部分代码;
  • 有些项目没有依赖,如果将他们独立发布,可以极大减少整体的发布时间。

令人头大

回顾现有能力

当我经历过几次这样的情况后,我意识到我们需要一个工具以更好地管理发布。推动整个部门共建工具是非常困难的,所以我决定先着手解决我遇到的问题,如果好用再慢慢推广;至于究竟能多么好用,可能需要先看看我们已有的能力。

Jira

公司使用 Jira 来管理需求,每个需求都有一个或多个 Jira 单。在生命周期内,Jira 单中的如下字段会被填写:

  • 相关人员:reporterassigneepeople involved,均为成员邮箱;
  • 上线版本:fixVersion,有固定格式 YYMMDD.团队名,如 240101.sls
  • 待办事项:checklist 字段,可能会记录需求的上线步骤和配置项;
  • 关联数据:如 GitLab 的合并请求链接、分支链接、其它 Jira 单链接等,这些是由各系统自动生成的。

Jira 的 JQL 也非常强大,查数据几乎跟 SQL 一样方便。

分支管理规范

我们通过一些工具和流水线来规范分支的命名、创建、合并、删除等操作:

  • 功能分支的名字是 feature/JIRA-描述,例如 feature/PROJ-1234-add-new-feature;此外临时分支、Hotfix 分支也都有 Jira 编号;
  • 发布前的版本分支是 version/release-YYMMDD,例如 version/release-240101
  • 所有分支都要合到版本分支上回归,版本分支最终合到 release 分支并打 tag 后发布。

可以解决的问题

结合目前遇到的问题以及现有的能力,一个简单的发版辅助工具需求就出来了:

  • 通过 Jira 的 JQL 搜索当天的 fixVersion,可以知道该版本的所有需求;
  • 通过 Jira 单的各个字段,可以知道某个需求的相关方以及关联的需求;
  • 通过 GitLab 的 API,可以知道哪些项目有对应分支、某个分支是否已经合并、是否已经有了发布 tag;
  • 通过 GitLab 的 API 帮助发布人自动创建版本分支;
  • 可以简单写一个页面来展示这些信息,方便发布人查看。

Jira 的交互

Jira 的交互主要是通过 JQL 查询。在开始查询之前,需要先获取一个 token,然后在请求头中带上这个 token。通过 https://hostname/secure/ViewProfile.jspa 打开个人信息页面,点击 Personal Access Tokens,创建一个新的 token,然后通过如下方式使用 JQL 查询:

GET /rest/api/2/search?jql=fixVersion%20in%20("240101.sls")&maxResults=1000&fields=key,summary,fixVersions,<various fields needed>&expand=changelog
Authorization: Bearer <token>
Content-Type: application/json

这相当于执行了一个 JQL 查询 fixVersion in ("240101.sls"),并且返回了最多 1000 条数据,其中包含了 keysummaryfixVersions 等必要的字段,以及 changelog 的详细信息。

这是一个一次性的查询,里面返回了所有需要的数据。

GitLab 的交互

跟 GitLab 的交互主要使用到了 github.com/xanzy/go-gitlab 库。首先打开 https://hostname/-/profile/personal_access_tokens 页面生成一个 token(其中 scope 选择 api 即可);然后在代码中初始化:

git, err := gl.NewClient(
    config.Value().GitLab.Token,
    gl.WithBaseURL("https://hostname/api/v4"),
)
if err != nil {
    fmt.Fprintf(os.Stderr, "error when connecting GitLab: %s\n", err.Error())
    return []int{}
}

之后就可以用 git 对象来进行各种操作了,例如获取分支信息、创建分支、合并分支等。详细思路如下:

  1. 针对某一仓库,不断分页调用 git.Branches.ListBranches 来获取所有分支,以判断是否有本次需求所需分支、是否已创建版本分支;
  2. 若没有版本分支,则调用 git.Branches.CreateBranch 来创建;
  3. 通过 git.Commits.GetCommitRefs 来获取包含了某个 commit 的引用(分支名、tag),以判断这个 commit 所在的分支是否已经合并至版本分支、是否已经有了发布 tag。

至此,我们已经可以获取到所有需要的数据了。可以基于这些数据编写简单的展示页面。

通过数据识别风险

在需求开发和发布的时候,有很多可能的风险,其中一些风险可以通过收集到的数据识别出来。例如:

  • 某个需求未找到分支:可能是需求忘记开发、无代码修改、合并时不小心删除了分支;
  • 某个分支的上次提交时间过久:可能会在合代码时造成冲突,需格外留意;
  • 某个分支的最新一次提交刚好是一个 tag,但不是这个版本的 tag:说明这个分支从 release 分支切出来后就没有推过代码;
  • Jira 的相关方没有团队成员,但团队项目内出现了分支:这相当于我们要做这个需求,但很可能收不到需求变更的通知;
  • 在数据展示的时候,需求需要按 UAT 版本来排序,这样在解决合并冲突时可以更容易确定顺序。

如果我们多次运行这个数据收集工具,也可以根据数据的变化来识别风险。例如:

  • 之前收集到的一个 Jira 突然没有了:可能是需求被取消了,如果分支已合并,需要立即回滚,如果没合并就不要继续合并了;
  • 到了规定时间还没有合并的分支、还没打 tag 的仓库、还没完成的 checklist:需要立即通知相关人员处理,避免影响发布;
  • 某个 Jira 突然出现了被移动的记录:可能是所属业务域变更了,例如从 PROJ1-1234 变成了 PROJ2-5678,那么这两个单需要被看作是一个,在检查分支时除了检查 PROJ1-1234,还需要检查 PROJ2-5678

下面是一个示例数据的展示,里面包括了前文提到的各种风险的识别:

一个展示了几乎所有问题的示例数据

更进一步:最速发布计划

这个工具的初衷是为了解决我遇到的问题,但是它的功能并不局限于此。还记得前文提过的发布效率问题吗?这是来自某个同事的反馈,因为他的合作团队项目众多、发版时间长,因此他希望工具可以识别依赖关系,并且找到一个最快速的发布计划。

依赖关系可以通过这两种方式来收集:

  • 两个仓库如果有相同 Jira 的分支,则它们之间有依赖;
  • 通过 Jira 单的关联 ticket 找到其它团队的需求,并借此找到相关仓库。

这样,我们就可以得到一个依赖关系图。这是一个无向图,其中节点是需求,边是依赖关系。图中的每个连通块都是一个发布单元,我们只需要找出所有的连通块即可。

一个依赖关系图,用三种颜色展示了三个发布单元

图论解法

第一眼看上去这属于图论问题,我们可以用广度优先搜索(BFS)来解决。对于每个节点依次执行 BFS,每次 BFS 访问到的点就是一个连通块。如果一个节点已经访问过了,就直接跳过;总的时间复杂度是 O(n+m),其中 n 是节点数,m 是边数。大致思路如下:

// nodes 是邻接表,返回的结果是本次发现的连通块中的所有点的 ID
func bfs(nodes []Node) []int {
    // ...
}

// 返回所有连通块,每个连通块是一个数组
func GetConnectedBlocks(nodes []Node) [][]int {
    var blocks [][]int
    visited := make(map[int]bool)

    for _, node := range nodes {
        if visited[node.ID] {
            continue
        }

        block := bfs(nodes)
        blocks = append(blocks, block)

        for _, id := range block {
            visited[id] = true
        }
    }

    return blocks
}

但实际上,有一个代码量更少、常数更低的方案:并查集。

并查集解法

并查集是一种数据结构,它支持两种操作:

  • Find(x):查找元素 x 所在的集合;
  • Union(x, y):合并 xy 两个元素所在的集合。

并查集的实现非常简单,我们用一棵树来表示一个集合,树根的 ID 就是集合的 ID。这可以只用一个数组来保存——数组的下标就是元素自身的 ID,数组的值就是元素所在的集合的 ID。

初始化操作就是让每个元素自成一个集合,Find 操作就是不断向上找树根,Union 操作就是将一个树的根指向另一个树的根。下面就是并查集的全部代码了:

type DisjointSet struct {
    parent []int
}

func NewDisjointSet(total int) *DisjointSet {
    parent := make([]int, total)
    for i := range parent {
        parent[i] = i
    }
    return &DisjointSet{parent}
}

func (d *DisjointSet) Find(x int) int {
    if d.parent[x] != x {
        return d.Find(d.parent[x])
    }
    return d.parent[x]
}

func (d *DisjointSet) Union(x, y int) {
    d.parent[d.Find(x)] = d.Find(y)
}

如果用图像来表示的话,就是这样的(图片来源:https://www2.hawaii.edu/~nodari/teaching/s18/Notes/Topic-16.html,使用 waifu2x 放大和降噪):

并查集的示意图

并查集还有两个常见的优化——路径压缩和按秩合并。路径压缩是在 Find 操作的时候,将路径上的所有节点都“顺手”指向树根,这样可以减小整棵树的高度以加速后续查找;按秩合并则是在 Union 操作的时候,将高度低的树合并到高度高的树上。在日常应用中,路径压缩已经足够了。

func (d *DisjointSet) Find(x int) int {
    if d.parent[x] != x {
        // return d.Find(d.parent[x])
        d.parent[x] = d.Find(d.parent[x])
    }
    return d.parent[x]
}

最终,同一集合的元素就是一个发布单元。各发布单元之间可以并行发布,以减少总时间。

最终的效果,三个发布单元可以并行发布

从工具到服务

这个工具的初衷是为了解决我遇到的问题,但是在我的团队经过多次试用并改进后,隔壁的几个团队也打算尝试一下。因此我将其合并到了团队的内部服务中,这样就可以更方便地使用了。

我们的内部服务

为了适配多团队的使用,我用了内部服务的数据库来存储凭据、配置等信息,并将每一次的数据收集结果也存了下来,以便后续分析。表结构大概是这样的:

CREATE TABLE `release_checker_config_tab` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `team` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL,
  `jira_auth` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL,
  `jira_projects_json` text COLLATE utf8mb4_unicode_ci NOT NULL,
  `jira_fix_version_formats` text COLLATE utf8mb4_unicode_ci NOT NULL,
  `gitlab_token` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
  `gitlab_auto_create_version_branch` tinyint(4) NOT NULL,
  `im_webhook` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uniq_team` (`team`)
);

CREATE TABLE `team_member_tab` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `team` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL,
  `email` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL,
  `biz` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_team` (`team`)
);

CREATE TABLE `git_tab` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `team` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL,
  `pid` int(11) DEFAULT NULL,
  `url` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
  `git_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
  `creator` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
  `ctime` bigint(20) DEFAULT NULL,
  `mtime` bigint(20) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uniq_url` (`url`),
  KEY `idx_creator` (`creator`),
  KEY `idx_git_name` (`git_name`),
  KEY `idx_url` (`url`),
  KEY `idx_team` (`team`)
);

CREATE TABLE `release_tab` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `team` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL,
  `release_date` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
  `content` mediumtext COLLATE utf8mb4_unicode_ci,
  `ctime` bigint(20) DEFAULT NULL,
  `mtime` bigint(20) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_fix_version` (`release_date`),
  KEY `idx_team` (`team`)
);

值得留意的细节功能,以及更多

在工具的使用过程中,我发现一些同事可能会因为着急而忽略重要信息,或者认为这工具可以提供更简便的操作。例如以红色、删除线等方式高亮展示需要注意的内容,或者提供链接让用户一键创建合并请求。

此外,还有一些功能可以进一步完善:

  • 对于一些确定不是问题的情况,可以由发布负责人提交信息后确认,以减少对用户的干扰;
  • 允许与各团队的配置同步平台对接,更便于检查漏配或配置不一致的地方;
  • 允许对推荐的发布计划进行微调,以适应实际情况;
  • 针对各类问题,在公司 IM 上面发消息通知相关人员,如需求开发者、发布负责人。

总而言之,这个工具先收集了必要的信息,然后通过一些方式识别风险、通过并查集来降低发布耗时,是一次比较成功的尝试。

Disqus 加载中……如未能加载,请将 disqus.com 和 disquscdn.com 加入白名单。

这是我们共同度过的

第 3015 天