1. 前言
今年上半年一直在做一个离线作业的调度系统。这个季度为了更好的用户体验,避免用户手动上传和管理文件,做了与公司内部 GitLab 打通的功能。
一方面通过 GitLab 提供的 API,可以很方便地选定某个脚本文件作为数据加工的执行脚本。每次升级的时候选定不同 commit 的版本即可快速地发布任务。
另一种场景下,通过打通 GitLab 的 WebHook 可以在用户向 master 分支推送代码的时候自动触发构建和上传构建好的 artifact,避免用户手动上传几百兆的 JAR 包的等待时间。
2.1 文件获取与存储
先说一下文件存储,由于作业调度在分布式环境下执行,所以文件的存储也必须是一个高可用的分布式的文件存储。
作为一个离线作业的调度系统,第一个想到的自然是 HDFS。毕竟如果 HDFS 挂了,基本离线计算任务你也别想跑了。
文件的获取方式分为两种:
第一种是直接从 GitLab 的 Repository 里面拉取文件,这种只适合 SQL、Shell、Python 纯文本的脚本文件(相关接口见:Get file from repository)。
第二种则是面向诸如 Spark 程序的 JAR 包,动辄上百兆。具体做法是与 Jenkins 这种打包构建的工具相结合,master 分支的代码更新后触发 WebHook,由构建工具将源代码打包并通过接口回传。接口可以根据项目的 Project id 和 commit id 组合成一个确定的路径写入 HDFS。
2.2 账号与权限
说到 GitLab API 的调用就不得不提 GitLab 的认证的过程。GitLab 提供了三种验证方式:
本着猛糙快的互联网精神,果断选择了第二种方式:使用账户生成一个 TOKEN,通过 HTTP 请求的 Header 参数或者 URL 中的查询参数传递给服务器。简单粗暴!
那么第一个问题来了,这个账号用谁的账号呢?
从代码安全性的角度考虑,个人账号不合适。原因有两点:首先私有项目如果要使用该功能必须把这个账号加入到项目的 Members 中,意味着个人账号可以看到别人(或者别的组)私有项目的内容。本着不粘不背锅的精神,能不碰的就不碰。第二点,GitLab 的账号与企业 LDAP 账号是互通的,一旦离职很可能直接导致 TOKEN 失效,API 无法调用(负责 GitLab 的小姐姐一直强调交接的问题,一种明天就要离职的感觉)。
解决方案是申请一个应用账号,这个账号默认不带有任何项目的权限,用户需要使用这个功能的时候将应用账号加入到 Members 中,赋予 Reporter 角色(至少是 Reporter 角色,否则无法获取文件内容)。
第二个问题:如果共享一个应用账号如防止用户窥探无权限的项目呢?
Get project users 接口获取有权限用户的用户名列表,与请求用户的用户名对照。
2.3 交互过程
无论是通过 GitLab 获取文件还是构建好的 artifact,第一步都是先确定一个项目以及其版本信息。搜索 GitLab 项目、选取 Master 分支的特定 commit 流程可以用下图来简单描述一下:
接下来首先说一下 GitLab 文件的上传过程。要唯一的确定 GitLab 中的一个文件需要三个要素:GitLab Project id、Commit id 以及文件在项目中相对路径。因此,后续的交互过程可以用下图来描述:
然后说一下 Git artifact 的上传。使用 WebHook 之后 Jenkins 会把所有 artifact 通过接口回传,我们要做的只是权限和文件的验证即可:
3. 踩过的坑
3.1 分页参数
先从 GitLab 的 API 返回格式说起吧。在过往的工作经历中,后端返回 JSON 一半分为三个部分:
-
Response 元信息,比如返回的状态码、错误提示、请求的唯一标识等等。
-
Response 数据题,真正承载数据的部分。
-
Response 分页信息,主要针对列表查询。
但是 GitLab API 的风格完全不一样,没有响应的元信息和分页信息,直接使用 HTTP 的 Status Code 描述请求的异常。
这倒也罢了,但是类似于列表的接口完全没有分页信息,请求的参数里也没有提到分页参数的设置。一开始还自作聪明地以为 GitLab 的 API 返回了全量的数据,结果在通过关键字搜索 Git 仓库的时候竟然搜不到自己的项目!之后查阅文档才发现 GitLab 的分页信息是写在 Header 里面的(详情请见 Pagination)!
3.2 URL 参数转义
在 Get file from repository 这个接口的参数定义中,file_path
的说明是 Url encoded full path to new file. Ex. lib%2Fclass%2Erb
。意思是说 lib/class.rub
这种文件的相对路径要进行转义,在使用 RestTemplate
的时候猜到了 URL 参数转义的坑。
首先看如下代码:
public void testForUriTemplateWithRawPathParam() {
String url = "https://gitlab.example.com/api/v4/projects/{project_id}/repository/files/{file_path}"
+ "?private_token={token}";
UriTemplate uriTemplate = new UriTemplate(url);
URI expand = uriTemplate.expand(ImmutableMap.of("project_id", 1,
"file_path", "lib/class.rb",
"token", "abc"));
System.out.println("expand = " + expand.toString());
}
标准输出流的结果是:
expand = https://gitlab.example.com/api/v4/projects/1/repository/files/lib/class.rb?private_token=abc
看来 UriTemplate(org.springframework.web.util.UriTemplate)
并不会主动为参数进行转义,那么我们手动为参数进行转义试试:
public void testForUriTemplateWithEncodedPathParam() throws UnsupportedEncodingException {
String url = "https://gitlab.example.com/api/v4/projects/{project_id}/repository/files/{file_path}"
+ "?private_token={token}";
UriTemplate uriTemplate = new UriTemplate(url);
String encode = UriUtils.encode("lib/class.rb", "UTF-8");
System.out.println("encode = " + encode);
URI expand = uriTemplate.expand(ImmutableMap.of("project_id", 1, "file_path", encode, "token", "abc"));
System.out.println("expand = " + expand.toString());
}
输出的结果为:
encode = lib%2Fclass.rb expand = https://gitlab.example.com/api/v4/projects/1/repository/files/lib%252Fclass.rb?private_token=abc
看来 UriTemplate
把我们手动转义的参数中的 %
又进行了一次转义变成了 %25
!
看来 UriTemplate
要么不转义,要么把结果再给转一次,反正是没法用了。
那么到底如何才能得到正确的参数呢?
我们需要使用 UriComponentsBuilder(org.springframework.web.util.UriComponentsBuilder)
!
虽然 UriTemplate
底层也是使用的 UriComponentsBuilder
,但是我们需要更加精细的控制:
public void testForUriComponentsBuilder() throws UnsupportedEncodingException {
URI uri;
String filePath = "lib/class.rb";
// Illegal for path, it should use pathSegment
//uri = UriComponentsBuilder.fromUriString("https://gitlab.example.com/api/v4/projects/")
// .path(String.valueOf(1)).path("repository").path("files").path("lib/class.rb")
// .queryParam("private_token", "abc").build(false).toUri();
//System.out.println("uri = " + uri.toString());
// result: uri = https://gitlab.example.com/api/v4/projects/1repositoryfileslib/class.rb?private_token=abc
//
//uri = UriComponentsBuilder.fromUriString("https://gitlab.example.com/api/v4/projects/")
// .path(String.valueOf(1)).path("repository").path("files").path("lib/class.rb")
// .queryParam("private_token", "abc").build(true).toUri();
//System.out.println("uri = " + uri.toString());
// result: uri = https://gitlab.example.com/api/v4/projects/1repositoryfileslib/class.rb?private_token=abc
// exception for build true parameter because 'lib/class.rb' contains '/'
//uri = UriComponentsBuilder.fromUriString("https://gitlab.example.com/api/v4/projects/")
// .pathSegment(String.valueOf(1), "repository", "files", "lib/class.rb")
// .queryParam("private_token", "abc").build(true).toUri();
//System.out.println("uri = " + uri.toString());
String encode = UriUtils.encode(filePath, "UTF-8");
System.out.println("encode = " + encode);
String encodePath = UriUtils.encodePath(filePath, "UTF-8");
System.out.println("encodePath = " + encodePath);
String encodePathSegment = UriUtils.encodePathSegment(filePath, "UTF-8");
System.out.println("encodePathSegment = " + encodePathSegment);
uri = UriComponentsBuilder.fromUriString("https://gitlab.example.com/api/v4/projects/")
.pathSegment(String.valueOf(1), "repository", "files", encodePathSegment)
.queryParam("private_token", "abc").build(true).toUri();
System.out.println("uri = " + uri.toString());
}
使用上述单元测试里的代码即可构造出访问 GitLab 所需要的 URI 了!
4. 小结
本文所有的参考资料全部来源于 GitLab 的 API 文档,涉及到的 API 有:
以上。