在本篇文章中,我仔细讨论了对子模块进行持续集成的三种方案,并利用自动化手段实现逐层往上提交子模块 commit id 从而触发主工程构建。这些构建结果为我们快速定位工程的编译问题提供了重要的线索。

需求描述

上一篇文章 中,我简单描述了我们一个项目的复杂程度:子模块、嵌套子模块、多分支。除了工程分支切换上的复杂,我们还遇到另一个问题:子模块持续集成。

主工程持续集成

先说说主工程如何做持续集成。我们使用 Gitlab 自带的 Gitlab-Ci 作为我们的持续集成系统。Android 端的主工程的持续集成脚本如下:

1
2
3
4
5
6
7
8
build:
tags:
- android
script:
- ./fmanager checkout -f $CI_BUILD_REF_NAME
- ./fmanager update
- gradle clean
- gradle aR

其中, CI_BUILD_REF_NAME 指定要编译哪个分支的主工程。当我们推送代码到某个分支时,该分支下的持续集成脚本就会被调用,CI_BUILD_REF_NAME 变量就会是那个分支的名字。在执行构建前,先用 fmanager 完成主工程和所有模块的分支切换 ,之后再用 fmanager 更新整个项目的代码。最后再执行编译指令。

主工程的持续集成就是这么简单。然而这远远不能满足我们的需求:我们的工程有多个子模块。一个子模块的某个分支可能被多个父模块的多个分支依赖。例如,common 模块的 master_dev 分支可能被 framework 模块的 master_dev、jilin_dev、taishan_dev 分支依赖。在这样的情况下,任何一个子模块如果不注意提交前自测,都有可能导致多个分支的整个工程编译失败,阻塞多个分支的开发进度。比这更困难的是,对某个模块的修改也许可以保证在当前主工程分支上编译通过,但却意外导致了另外一个依赖该子模块的主工程分支的编译失败。

因此,我们除了要对主工程进行持续集成测试之外,也不得不对子模块做持续集成测试:任何一个子模块某个分支一旦推送了代码,就触发所有依赖它的主工程的分支的持续集成测试。为了实现这个目标,我们尝试了三种方案。

方案一:trigger

第一种方案是利用 Gitlab-Ci 的 trigger 机制。trigger 提供了直接在脚本中触发任何一个仓库的持续集成的方法。利用 trigger,我们可以为子模块也写一份持续集成脚本,而它仅仅用来触发依赖它的所有主工程的分支的持续集成。例如,假如主工程的 master_dev 分支和 jilin_dev 分支都依赖了 framework 子模块的 master_dev 分支,那么可以为 framework 的 master_dev 编写一个持续集成脚本:

1
2
3
4
5
build:
stage: deploy
script:
- "curl -X POST -F token=3ef8939a8e50c5e98f459789b966a4 -F ref=refs/heads/master_dev http://yourcompany.com/api/v3/projects/10/trigger/builds"
- "curl -X POST -F token=3ef8939a8e50c5e98f459789b966a4 -F ref=refs/heads/jilin_dev http://yourcompany.com/api/v3/projects/10/trigger/builds"

其中,ref 参数指定了要触发持续集成测试的项目的分支。这样,当中央仓库上 framework 模块的 master_dev 分支有新的代码推送时,主工程的 master_dev 分支和 jilin_dev 分支就会触发构建:

使用 trigger 虽然能有效触发所依赖的主工程的分支,但它有很多不足之处:

1、维护成本高。每个子模块都需要编写持续集成脚本,且由于主工程经常需要新增新业务分支,需要频繁维护每个子模块的持续集成脚本,添加依赖它的分支。 2、无法跟踪。子模块的持续集成脚本的作用仅仅只是触发了主工程的持续集成,而当次触发的结果并不会返回给子模块作为子模块持续集成的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
gitlab-ci-multi-runner 1.0.4 (014aa8c)
Using Shell executor...
Running on appdevdeiMac.local...
Fetching changes...
HEAD is now at 826d126 Merge branch 'master_dev' of http://yourcompany.com/yourgroup/framework_android into master_dev
From http://yourcompany.com/FFProject/appframework_android
826d126..13ba8f3 master_dev -> origin/master_dev
Checking out 13ba8f33 as master_dev...
Previous HEAD position was 826d126... Merge branch 'master_dev' of http://yourcompany.com/yourgroup/framework into master_dev
HEAD is now at 13ba8f3... [master_dev][bank][c:panweizhou][r:chendingyi]update ci script.
$ curl -X POST -F token=2587402741a9ef3a09d0dd64f83b90 -F ref=$CI_BUILD_REF_NAME http://yourcompany.com/api/v3/projects/11/trigger/builds
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0100 298 100 26 100 272 220 2304 --:--:-- --:--:-- --:--:-- 2324
{"id":48,"variables":null}
Build succeeded.

所以子模块的持续集成一直是成功的:

而实际却可能早已导致主工程编译失败:

3、无法定位触发源。主工程的构建日志页仅记录触发本次构建的 trigger 的 Token 。但这个 trigger 是主工程自己的 --bb

换句话说,除非为每一个子模块提供一个单独的 Token ,否则我们根本无法判断究竟是子模块触发了这个 trigger 。第一种方案歇菜。

方案二:子模块测试工程

第二种方案是为所有需要做集成测试的子模块都单独编写一个测试工程。当子模块有推送代码时,不再触发主工程的持续集成,而是触发测试工程的持续集成。

由于每个子模块与其测试工程是一对一的关系,一旦测试工程编译失败,那其对应的子模块就很有可能存在问题。然而这个方案也有很大的局限性。

  1. 需要为每个核心子模块都维护一个测试工程,且测试工程的开发进度需要一直与主工程同步。当测试工程的维护进度落后于主工程,就有可能出现子模块能保证主工程编译通过,却导致测试工程编译不过。
  2. 当子模块有多个分支时,每个重要分支都需要相应建立测试工程的分支,这使得测试工程的维护成本同比增加。
  3. 最致命的问题是:子模块的测试工程仅仅只能覆盖子模块,而整个主工程由多个子模块组合而成的,模块与模块之间也有相互依赖关系,模块级别的覆盖度并不足以保证整个工程的可编译。

综上所述,用子模块测试工程来对子模块进行持续集成并不理想。方案二也失败了。看来 trigger 并不适合用来解决我们的问题,于是我对 trigger 的尝试也到此为止。

方案三:自动更新子模块 commit id

前面两种方案走不通,我开始思考:Git 难道就没有关于子模块持续集成的 best practice 吗?直到我看到了 blahgeek 的这篇文章 ,里头提出用 commit id 的改动来触发工程更新,顿时恍然大悟:Git 本身建议通过在主工程记录子模块的 commit id 来控制子模块的版本。除了控制版本,commit id 其实还有另一个好处,那就是持续集成!子模块发生修改后,为了让主工程同步该子模块的更新,你需要不断往上提交上层模块的 commit id ,这就会顺带触发主工程的持续集成。

然而,上一篇文章中提到,为了避免只提 commit id 没提代码的情况发生,我们直接禁止了 commit id 的提交。矛盾来了。

幸运的是我们有折中的办法。如果子模块代码已推送成功,那么此时该模块在父工程中的 commit id 一定可以更新。而这个更新为什么不能让计算机帮忙自动完成?我只需要在子模块的中央仓库中加入 post-receive 钩子,当子模块代码推送完成时,post-receive 钩子里的脚本就会自动被触发,帮助我们到上层提交该子模块的 commit id 。对于嵌套子模块,这个过程会一直递归地做,直到父工程就是主工程为止,而这最终就会触发主工程的持续集成!

方法听起来可行,但实际做起来我依然遇到了不少困难。

首先,服务器上的仓库都是 bare repository ,不能提交代码,也没有相互依赖关系,主工程和所有子模块的仓库都是平级的存放在同个目录下的。这意味着你无法利用 post-receive 钩子原地地修改自身仓库和依赖它的其他仓库。

其次,依赖每个子模块的父工程及分支各不相同。当一个子模块的某个分支有更新时,你需要为父工程中为所有依赖该子模块那个分支的全部分支都提交一遍新的 commit id 。

最后,每一个子模块也都需要安装一个这样的 post-receive 钩子,且子模块经常需要新增,依赖关系也经常变动,维护成本高。

解决第一个问题的方法就是在服务器也像本地那样 clone 出一份整个工程的 working repository ,这个工程和我们本地开发的仓库没什么区别,交给服务器来自动维护。唯一的难点在于怎么将每个 bare repository 与该 working repository 里的每个子模块相关联。于是,只需要写个工具,遍历一遍所有主工程分支,并生成每个分支所依赖的每个子模块的仓库地址与本地路径信息。内容类似这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
"master": {
"app": {"branch": "master"},
"common": {"branch": "master"},
...
"react_native/node_modules": {"branch": "master"}
},
"master_dev": {
"app": {"branch": "master_dev"},
"common": {"branch": "master_dev"},
...
"react_native/node_modules": {"branch": "master"}
},
"jilin": {
"app": {"branch": "master"},
"common": {"branch": "master"},
...
"react_native/node_modules": {"branch": "jilin"}
},
"jilin_dev": {
"app": {"branch": "master"},
"common": {"branch": "master"},
...
"react_native/node_modules": {"branch": "jilin_dev"}
}
}

解决第二个问题的方法就是利用每个主工程分支的 modules.json 。我们在主工程的每个分支上都编写了一份 modules.json ,这个文件记录了所有子模块的依赖关系。只要对所有分支的 modules.json 进行归并,就可以得到一份完整的记录所有模块所有分支的依赖关系。内容类似这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
app:
repo: http://yourcampany.com/yourgroup/app_android.git
path: /home/git/app_android

common:
repo: http://yourcampany.com/yourgroup/core_lib_android.git
path: /home/git/app_android/common

...

node_modules:
repo: http://yourcampany.com/yourgroup/node_modules.git
path: /home/git/app_android/react_native/node_modules

有了这两个文件,post-receive 钩子也就可以写得通用化:先获取该子模块的仓库名,然后根据这个文件找到在 working repository 下对应的目录,然后用 fmanager 切到依赖该子模块该分支的主工程。更新该子模块的 working tree ,最后 cd 到上级目录提交该子模块的 commit id 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
#!/usr/bin/env python
#author:panweizhou

'''
After push, automatically update commit id of current submodule.
'''

import sys
sys.path.append('/usr/lib/python2.6/site-packages')
sys.path.append('/usr/lib64/python2.6/site-packages')

import fileinput
import json
import subprocess
import os
import yaml
from filelock import FileLock

module_file = "/home/git/modules/modules_android.json"
module_path_file = "/home/git/modules/modules_android.yml"
project_root = "/home/git/app_android"
env_path = "/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin:/root/bin:/usr/local/libexec/git-core/:/home/git/bin"

lock_file_name = "/tmp/" + os.path.basename(os.getcwd())

with FileLock(lock_file_name):
print ("Updating commit id")
# read global module config
global_module_config = {}
try:
with open(module_file) as f:
global_module_config = json.load(f)
except ValueError:
print("Global modules.json parsed Error!")
exit(1)

# Read in each ref that the user is trying to update
for line in fileinput.input():
line = line.strip()
(from_commit, to_commit, ref) = line.split(' ')

# Get branch name
pos = ref.rfind('/')
if pos >= 0:
branch = ref[pos+1:]
else:
branch = ref

if branch == 'master' or branch.lower().endswith('bank') or branch.endswith('_dev'):
# if branch == 'WeiZhouBank_dev':
# Get repo name
output = subprocess.Popen(['git summary | egrep "^ project"'], env={"PATH": env_path}, stdout=subprocess.PIPE, shell=True)
oc = output.communicate()[0]
repo = oc.split(':')[1].strip()

# Get the corresponding working path
module_path_info = {}
with open (module_path_file) as f:
module_path_info = yaml.load(f)
module_path = ""
module_name = ""
for module_name, module_info in module_path_info.items():
if module_info.has_key('repo'):
module_repo = module_info["repo"]
if module_repo.endswith(repo):
has_such_module = True
module_path = module_info["path"]
break

# Get all the main projects branch that use this module
main_branch_set = set()
for branch_name, config_list in global_module_config.items():
if config_list.has_key(module_name):
mconfig = config_list[module_name]
if (mconfig.has_key('branch')):
mbranch = mconfig['branch']
if branch == mbranch:
main_branch_set.add(branch_name)

# Do the following stuff for each branch of main project
# that use this module of this branch
for main_branch in main_branch_set:
os.chdir(project_root)
output = subprocess.Popen(["./fmanager checkout -f %s" % main_branch], env={"PATH": env_path}, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
res = output.wait()
if res == 0:
os.chdir(module_path)
# update current submodule
output = subprocess.Popen(['/home/git/bin/update_root', branch], cwd=module_path, env={"PATH": env_path})
res = output.wait()
if res == 0:
# Get commit log
output = subprocess.Popen(['git log -n 1'], env={"PATH": env_path}, cwd=module_path, stdout=subprocess.PIPE, shell=True)
commit_log = output.communicate()[0]
commit_log = "Bump Version for submodule %s:\n\n%s" % (module_name, commit_log)
# Go to father module
os.chdir('..')
father_path = os.getcwd()
# Get branch of father module
output = subprocess.Popen(['git', 'symbolic-ref', '--short', 'HEAD'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, env={"PATH": env_path})
oc = output.communicate()
father_branch = oc[0].strip()
if (father_branch.strip() != ""):
print("Bumping version of branch %s of father project..." % father_branch)
output = subprocess.Popen(['/home/git/bin/update_root', father_branch], cwd=father_path, env={"PATH": env_path})
res = output.wait()
if res == 0:
output = subprocess.Popen(['git diff | wc -l'], cwd=father_path, env={"PATH": env_path}, stdout=subprocess.PIPE, shell=True)
diff_num = output.communicate()[0]
if diff_num > 1:
subprocess.call(['git', 'add', module_name], env={"PATH": env_path})
output.wait()
output = subprocess.Popen(['git', 'commit', '-m', commit_log, '--no-verify'], env={"PATH": env_path})
res = output.wait()
if res == 0:
output = subprocess.Popen(['git', 'push', 'origin', 'HEAD', '--no-verify'], env={"PATH": env_path})
output.wait()
if res == 0:
print "Successfully bumped version branch %s of father project" % father_branch
else:
print "Error bumping version for branch %s of father project" % father_branch
else:
print "Error bumping version for branch %s of father project" % father_branch
else:
print "Error bumping version for branch %s of father project" % father_branch
else:
print("Father project is a tag. Stop bumping verison.")
else:
print "Error updating the remote working tree."

之后只需将钩子安装到每个子模块的 bare repository 里的 custom_hooks 目录下。同样可以利用脚本来一次性完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#!/bin/bash
#author: panweizhou

for module in `ls -d *_android.git`
do
if test $module != "App_Android.git" # Don't install to root project
then
module_dir=${module}
if test ! -d ${module_dir}/custom_hooks
then
mkdir ${module_dir}/custom_hooks
fi
for hook in `ls submodule_hooks_android`
do
cp submodule_hooks_android/${hook} ${module_dir}/custom_hooks/
sudo chmod -R 755 ${module_dir}/custom_hooks
sudo chmod +x ${module_dir}/custom_hooks/${hook}
done
fi
done

钩子装好后,试着为 framework 子模块推送一下代码,终端中会看到 Bump Version for submodule framework 的字眼,表示 framework 的 commit id 已被成功更新到主工程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
C02PGTP8FVH5:PAFFHouse hahack$ git push -u origin jilin_dev
Counting objects: 3, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (2/2), done.
Writing objects: 100% (3/3), 308 bytes | 0 bytes/s, done.
Total 3 (delta 1), reused 0 (delta 0)
remote:
remote: ========================================================================
remote:
remote: faq: http://yourcampany.com/yourgroup/FFWiki/wikis/git-faq
remote:
remote: ========================================================================
remote: [jilin_dev 941f8c5] Bump Version for submodule framework:
remote: 1 file changed, 1 insertion(+), 1 deletion(-)
remote: Updating commit id
remote: Bumping version of branch jilin_dev of main project...
remote: Bumping version of branch jilin_dev of father project...
remote: Successfully bumped version branch jilin_dev of father project
remote: remote:
remote: remote: ========================================================================
remote: remote:
remote: remote: faq: http://yourcampany.com/yourgroup/FFWiki/wikis/git-faq
remote: remote:
remote: remote: ========================================================================
remote: To http://yourcampany.com/yourgroup/App_Android.git
remote: aa13394..69e6c467 HEAD -> jilin_dev
To http://yourcampany.com/yourgroup/framework_android.git
e41b275..35141bf jilin_dev -> jilin_dev
Branch jilin_dev set up to track remote branch jilin_dev from origin.

上面的步骤执行了两次 push 操作:

  1. push framework 子模块的代码;
  2. push 主工程的代码,更新 framework 的 commit id 。这个 push 操作是由 framework 的 post-receive 钩子自动完成的。

等候一段时间后,打开主工程的持续集成页面,可以找到这次子模块更新触发的提交以及持续集成的结果:

非嵌套子模块的持续集成结果
非嵌套子模块的持续集成结果

对于嵌套子模块,父模块提交完子模块的 commit id ,同样会触发父模块的 post-receive 钩子,于是会看到这样的推送结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
C02PGTP8FVH5:HFCommon hahack$ git push -u origin master_dev
Counting objects: 3, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 305 bytes | 0 bytes/s, done.
Total 3 (delta 2), reused 0 (delta 0)
remote:
remote: ========================================================================
remote:
remote: faq: http://yourcampany.com/yourgroup/FFWiki/wikis/git-faq
remote:
remote: ========================================================================
remote: warning: unable to rmdir FinancialProduct: Directory not empty
remote: [taishan 941b017] Bump Version for submodule HFCommon:
remote: 1 file changed, 1 insertion(+), 1 deletion(-)
remote: Updating commit id
remote: Bumping version of branch taishan of main project...
remote: Bumping version of branch taishan of father project...
remote: Successfully bumped version branch taishan of father project
remote: Bumping version of branch taishan_dev of main project...
remote: remote:
remote: remote: ========================================================================
remote: remote:
remote: remote: faq: http://yourcampany.com/yourgroup/FFWiki/wikis/git-faq
remote: remote:
remote: remote: ========================================================================
remote: remote: [taishan f43ab33] Bump Version for submodule react_native:
remote: remote: 1 file changed, 1 insertion(+), 1 deletion(-)
remote: remote: Updating commit id
remote: remote: Bumping version of branch taishan of main project...
remote: remote: Bumping version of branch taishan of father project...
remote: remote: Successfully bumped version branch taishan of father project
remote: remote: remote:
remote: remote: remote: ========================================================================
remote: remote: remote:
remote: remote: remote: faq: http://yourcampany.com/yourgroup/FFWiki/wikis/git-faq
remote: remote: remote:
remote: remote: remote: ========================================================================
remote: remote: To http://yourcampany.com/yourgroup/App_Android.git
remote: remote: 50b73a2..f43ab33 HEAD -> taishan
remote: To http://yourcampany.com/yourgroup/react_native.git
remote: 3552248..941b017 HEAD -> taishan
To http://yourcampany.com/yourgroup/HFCommon.git
4940def..7a59ff0 master_dev -> master_dev
Branch master_dev set up to track remote branch master_dev from origin.

在主工程的持续集成页面中同样可以找出嵌套子模块触发的提交和持续集成结果:

嵌套子模块的持续集成结果
嵌套子模块的持续集成结果

只剩第三个问题未解决了。由于模块和分支不断在新增,上面的这两个文件肯定是需要经常更新,新增模块也需要安装这个钩子。这些既然已经可以用工具自动完成,只需要把工具都加进了 crontab 计划任务里,设定每天凌晨三点钟就自动执行一遍,问题完美解决!

使用这个方案后,所有的子模块发生更新,都会触发依赖该子模块的主工程的持续集成测试。当发现主工程突然不能编译了,可以打开 Gitlab ,迅速定位到最早导致编译不过的子模块及提交:

利用Gitlab定位编译问题
利用Gitlab定位编译问题

这为我们定位编译问题提供了非常重要的线索!

后话

在本篇文章中,我仔细讨论了对子模块进行持续集成的三种方案,并利用自动化手段实现逐层往上提交子模块 commit id 从而触发主工程构建。这些构建结果对我们快速定位工程编译不过的问题提供了重要的线索。

说下其他一些值得注意的地方。有些时候某个模块的代码推送无法避免会导致暂时性的编译失败(比如涉及多个模块的代码提交),又不想被误认为是导致后面编译不过的罪魁祸首,那就可以通过在这些中间提交任务的 commit message 中加上 [ci skip] 字段,告诉 Gitlab 跳过对这些提交的构建测试,只在最后一次提交中去除该字段,检查最后一次的提交即可。

另外一个问题是,自从启用了这种方案,我们服务器上的构建任务一下子爆增。一个子模块的代码推送可能会触发多个构建任务,而我们目前负责持续集成的机器还很少。这使得推送完代码后,往往需要等上半天才能看到结果,这可能会影响问题定位的及时性。我们在后面准备进行一个有趣的尝试:每个客户端开发者的机器其实已具备了构建至少一个平台的客户端的条件,所以可以利用开发机的剩余资源来帮忙构建。具体方法是:每个开发者将自己的机器注册为一个 Runner ,并自行打上 android 或者 ios 标签,标明机器能编译哪个平台的客户端:

1
$ gitlab-ci-multi-runner register -url http://yourcompany.com/ci -registration-token z_AvFaPcdF9aE3sseEvw --name "android-panweizhou"  --limit 1 --executor shell --shell bash --tag-list "android"

当机器暂时空余时,可以开启这个 Runner ,加入帮忙构建的队伍。Gitlab 将根据该 Runner 的标签为其安排相应平台的构建任务:

1
$ gitlab-ci-multi-runner start

年底我们将统计出 Gitlab 上这些 Runner 的构建次数,对次数多的 Runner 进行表彰。真是躺着就把钱挣了有木有!

Comments