以下语言可用: 简体中文 正體中文 English
业务扩张导致的问题
随着公司前几年的扩张,部门内的团队越来越多、业务越来越复杂。为了应对这种情况,前端团队使用了微前端,后端团队则使用了微服务。单个仓库的维护成本确实变低了,但这又带来了另外几个问题:
- 业务模块归属的团队可能会变化,例如“订单追踪服务”之前属于团队 A,但有一天拆到了团队 B;
- 很难整理出网状的模块调用关系,一个业务需求可能会改动到多个仓库的代码,例如我们对接一个新的物流渠道,从最底层的数据模型服务,到最上层对外封装的服务,可能都要有变化;
- 前后端模块无法一一映射,因为前后端的拆分粒度不同,由于有 BFF,前端是按照页面来拆分的,而后端是按照业务模块来拆分的;对于跨团队的需求,需要开多个 Jira 单,但前端可能是同一个项目。
这导致的后果就是:在发布需求量大的情况下(每次发版大概需要发十几个需求,涉及到不知道多少项目),发布人不可能了解自己团队的每个需求是否依赖了别的团队,也无法快速了解每个项目每个分支是否已经被合并、是否已经有了发布 tag,容易出现遗漏。此外,如果多个团队的需求改动到同一项目(如两个团队都对微前端的主应用做了改动),那么发布人也需要花时间协调谁来发、何时发。
此外,还有一些特殊情况:
- 可能会出现需求已经回归完成,但因为某些原因部分需求无法发布,此时需要回滚部分代码;
- 有些项目没有依赖,如果将他们独立发布,可以极大减少整体的发布时间。
回顾现有能力
当我经历过几次这样的情况后,我意识到我们需要一个工具以更好地管理发布。推动整个部门共建工具是非常困难的,所以我决定先着手解决我遇到的问题,如果好用再慢慢推广;至于究竟能多么好用,可能需要先看看我们已有的能力。
Jira
公司使用 Jira 来管理需求,每个需求都有一个或多个 Jira 单。在生命周期内,Jira 单中的如下字段会被填写:
- 相关人员:
reporter
、assignee
、people 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 条数据,其中包含了 key
、summary
、fixVersions
等必要的字段,以及 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
对象来进行各种操作了,例如获取分支信息、创建分支、合并分支等。详细思路如下:
- 针对某一仓库,不断分页调用
git.Branches.ListBranches
来获取所有分支,以判断是否有本次需求所需分支、是否已创建版本分支; - 若没有版本分支,则调用
git.Branches.CreateBranch
来创建; - 通过
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)
:合并x
和y
两个元素所在的集合。
并查集的实现非常简单,我们用一棵树来表示一个集合,树根的 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 上面发消息通知相关人员,如需求开发者、发布负责人。
总而言之,这个工具先收集了必要的信息,然后通过一些方式识别风险、通过并查集来降低发布耗时,是一次比较成功的尝试。