第5章:Git 集成 --- 代码版本管理的基石
发布系统的核心任务是「把指定的代码版本变成可运行的服务」。这句话里隐含了两个关键动作:确定版本 (要发布哪个 commit/tag/branch)和获取代码(把源码拉到构建机器上)。这两个动作都离不开 Git。
本章拆解发布系统如何通过两层抽象完成 Git 集成:底层的 GitService 负责本地仓库的 clone/fetch/checkout,上层的 GitLabService 负责通过 GitLab API 查询分支、Tag 列表以及创建发布 Tag。
5.1 GitService --- 本地仓库管理
GitService 是整个 Git 操作的执行层。它的职责很纯粹:操作本地磁盘上的 Git 仓库,不关心上游是 GitHub 还是 GitLab。所有命令都通过 ProcessBuilder 执行原生 git 命令。
5.1.1 核心数据结构:GitRepository
在深入 GitService 之前,先看它操作的领域对象 GitRepository:
java
@Data
public class GitRepository {
private String name; // 仓库名(也是本地目录名)
private String git; // Git 远程地址,如 https://gitlab.justgotrip.cn/team/project.git
private String code; // 要检出的版本:branch 名、tag 名或 commit hash
private Path workspace; // 本地工作目录路径(ensurePath 后赋值)
private Path syncPath; // 同步后的路径(通常与 workspace 一致)
}
这个对象贯穿整个构建流水线。调用方只需要填好 name、git、code 三个字段,剩下的 workspace 和 syncPath 由 GitService.ensurePath() 自动填充。
5.1.2 ensurePath:智能仓库准备
ensurePath 是整个 Git 操作的入口方法。它的逻辑是一个简单的二分判断:
java
public void ensurePath(GitRepository repo) {
Path repoPath = Paths.get(repoBasePath, repo.getName());
repo.setWorkspace(repoPath);
File dir = repoPath.toFile();
if (dir.exists()) {
fetchAndCheckout(repoPath, repo.getCode());
} else {
cloneRepo(repo.getGit(), repo.getCode(), repoPath);
if (!dir.exists()) {
throw new IllegalStateException(
"git clone 失败,仓库目录未创建: " + repoPath + "。请检查 Git 地址、分支名称和网络连通性。");
}
}
repo.setSyncPath(repoPath);
}
设计思路:本地磁盘作为一级缓存。第一次构建时 clone 全量仓库,后续构建只需 fetch 增量更新然后 checkout 到指定版本。这避免了每次都全量 clone 带来的网络开销------对于几百 MB 的 Java 项目仓库,这个优化效果显著。
注意 clone 失败后的防御性检查:ProcessBuilder 执行 git 命令时可能返回 exit code 0 但实际并未创建目录(例如 URL 可访问但分支名不存在),这里显式检查目录是否存在,抛出的异常信息同时提示了三种可能的失败原因,方便运维排查。
5.1.3 injectToken:免密认证的钥匙
私有仓库的 clone 需要认证。发布系统采用了 oauth2 Token 注入到 URL 中的方式:
java
private String injectToken(String gitUrl) {
if (privateToken == null || privateToken.isBlank()) return gitUrl;
if (!gitUrl.startsWith("http")) return gitUrl;
int schemeEnd = gitUrl.indexOf("://") + 3;
return gitUrl.substring(0, schemeEnd) + "oauth2:" + privateToken + "@" + gitUrl.substring(schemeEnd);
}
例如,原始 URL https://gitlab.justgotrip.cn/team/project.git 会被转换为:
https://oauth2:glpat-xxxxxxxxxxxx@gitlab.justgotrip.cn/team/project.git
这种方式的巧妙之处在于:
- 不写入磁盘 :Token 仅出现在
git clone命令的参数中,进程结束后即消失。不会像git config --global那样在文件系统中留下凭据。 - 对 GitLab 通用 :
oauth2:前缀是 GitLab Personal Access Token 的标准认证方式,也兼容username:token的传统格式。 - 安全边界清晰:如果 gitUrl 不是 HTTP 协议(比如 SSH),直接跳过注入,避免对不支持的协议做无意义操作。
配置值来自 application.yml:
yaml
gitlab:
url: https://gitlab.justgotrip.cn
private-token: "xxxxxxx"
5.1.4 fetchAndCheckout:增量更新
当本地已有仓库时,不需要重新 clone,而是 fetch 增量数据后 checkout:
java
private void fetchAndCheckout(Path workspace, String code) {
execCommand(workspace, "git fetch " + injectToken(getRemoteUrl(workspace)));
execCommand(workspace, "git checkout " + code);
}
这里有一个容易被忽略的细节:getRemoteUrl(workspace) 方法。它通过 git remote get-url origin 读取本地仓库中存储的远程地址,再对其注入 Token 后执行 fetch。
为什么多此一举?因为本地仓库可能是更早之前 clone 下来的,当时的 URL 可能不含 Token,或者 Token 已经过期。直接从本地读取 origin URL 再注入新 Token,确保了每次 fetch 都使用当前配置的最新凭据。
java
private String getRemoteUrl(Path workspace) {
try {
ProcessBuilder pb = new ProcessBuilder("/bin/bash", "-c", "git remote get-url origin");
pb.directory(workspace.toFile());
pb.redirectErrorStream(true);
Process p = pb.start();
String url = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim();
p.waitFor();
return url.isEmpty() ? "" : url;
} catch (Exception e) {
return "";
}
}
5.1.5 syncRemote:多模块项目的全量同步
对于包含多个子模块的项目(Maven 多模块),syncRemote 提供了完整的远程同步能力:
java
public void syncRemote(PublishBiz biz) {
Path repoPath = Paths.get(repoBasePath, biz.getName());
if (!repoPath.toFile().exists()) {
log.warn("repo not exist for biz {} at {}", biz.getName(), repoPath);
return;
}
execCommand(repoPath, "git fetch --all");
execCommand(repoPath, "git checkout " + biz.getModule());
execCommand(repoPath, "git pull");
}
与 fetchAndCheckout 的区别在于:这里使用 git fetch --all(抓取所有远程的引用),然后 checkout 到 biz.getModule() 指定的分支,再执行 git pull 确保与远程完全同步。这个方法主要用于发布流程中需要确保代码是最新状态时调用------例如部署前做最后一次同步检查。
5.1.6 execCommand:统一的命令执行器
所有 git 命令的执行最终都汇聚到 execCommand 方法:
java
private boolean execCommand(Path workDir, String command) {
try {
ProcessBuilder pb = new ProcessBuilder();
pb.redirectErrorStream(true);
if (workDir != null) pb.directory(workDir.toFile());
pb.command("/bin/bash", "-c", command);
Process process = pb.start();
String output = readStream(process.getInputStream());
int exitCode = process.waitFor();
if (exitCode != 0) log.warn("git cmd failed: {}\noutput: {}", command, output);
return exitCode == 0;
} catch (Exception e) {
log.error("git cmd error: {}", command, e);
return false;
}
}
几个值得关注的设计决策:
1. 为什么用 /bin/bash -c 而不是直接传命令数组?
直接用 pb.command("git", "clone", "-b", branch, url) 也是可行的,但使用 bash 包装的好处是支持管道、重定向等 shell 特性,未来如果需要加 2>&1 或管道过滤,不需要改动调用方式。
2. redirectErrorStream(true) 的作用
将 stderr 合并到 stdout 中,这样 readStream 一次读取就能拿到所有输出。Git 的很多正常信息(如 "Switched to branch 'main'")是输出到 stderr 的,合并后不会遗漏。
3. 没有设置超时
Git 操作默认没有显式超时限制,依赖 Process.waitFor() 的无限等待。这是因为 Git 操作的时间取决于仓库大小和网络状况,很难设定一个普适的超时值。但这也意味着在网络极端异常时可能永久阻塞------这是当前实现的一个已知权衡。
4. readStream 的逐行读取
java
private String readStream(java.io.InputStream in) throws Exception {
StringBuilder sb = new StringBuilder(1024);
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(in, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) sb.append(line).append('\n');
}
return sb.toString();
}
逐行读取而不是一次性 readAllBytes,避免大输出撑爆内存。注意结尾手动加了 \n------readLine() 会去掉行尾换行符。
5.2 GitLabService --- 远程 API 调用
如果 GitService 是操作本地仓库的「手」,那 GitLabService 就是与 GitLab 平台交互的「眼」。它基于 gitlab4j-api 库封装了对 GitLab REST API 的调用,提供分支/Tag 查询、项目搜索、用户认证等功能。
5.2.1 API 实例初始化
java
@Service
@Slf4j
public class GitLabService {
@Value("${gitlab.url:https://gitlab.justgotrip.cn}")
private String gitlabUrl;
@Value("${gitlab.private-token:}")
private String privateToken;
@Value("${gitlab.admin-token:}")
private String adminToken;
@Value("${publish.server.current-env:dev}")
private String currentEnv;
private GitLabApi gitLabApi;
@PostConstruct
public void init() {
if (privateToken != null && !privateToken.isEmpty()) {
this.gitLabApi = new GitLabApi(gitlabUrl, privateToken);
if ("dev".equals(currentEnv) || "test".equals(currentEnv) || "local".equals(currentEnv)) {
this.gitLabApi.setIgnoreCertificateErrors(true);
}
}
}
}
@PostConstruct 中初始化了一个默认的 GitLabApi 实例,使用系统级的 privateToken。非生产环境下通过 setIgnoreCertificateErrors(true) 跳过 SSL 证书校验------开发/测试环境的 GitLab 通常使用自签名证书。
5.2.2 多 Token 的 API 实例工厂
系统支持两种 Token:系统级的 privateToken 和用户个人的 Token。为此设计了 api(token) 方法作为工厂:
java
private GitLabApi api(String token) {
if (token != null && !token.isEmpty()) {
GitLabApi api = new GitLabApi(gitlabUrl, token);
if ("dev".equals(currentEnv) || "test".equals(currentEnv) || "local".equals(currentEnv)) {
api.setIgnoreCertificateErrors(true);
}
return api;
}
if (gitLabApi == null) {
throw new IllegalStateException(
"GitLab API not available: no token configured. " +
"Set gitlab.private-token or bind your GitLab token.");
}
return gitLabApi;
}
逻辑很清晰:如果传入了用户自己的 Token,就用它创建新的 GitLabApi 实例;否则回退到系统默认实例。每个调用 API 的方法都接受一个 token 参数,使得调用方可以灵活选择以什么身份调用 GitLab API。
5.2.3 获取 Tag 和 Branch 列表
getTags 和 getBranches 方法的结构非常相似,这里以 getTags 为例:
java
public List<Tag> getTags(String token, long projectId) {
try {
return api(token).getTagsApi().getTags(projectId);
} catch (GitLabApiException e) {
if (e.getHttpStatus() == 404) {
log.warn("tags not found for projectId={}", projectId);
} else {
log.error("getTags failed for projectId={}", projectId, e);
}
return List.of();
} catch (Exception e) {
log.error("getTags failed for projectId={}", projectId, e);
return List.of();
}
}
这里的 404 容错是刻意设计的:一个全新的 GitLab 项目还没有创建任何 Tag 时,GitLab API 可能返回 404(而非空列表)。如果对 404 直接抛异常,会导致用户在发布页面上看到一个错误对话框。通过区分 404(warn 级别)和其他错误(error 级别),系统在正常场景(新项目无 Tag)下保持安静,只在真正异常时报警。
5.2.4 Tag 与 Branch 的合并展示
前端需要展示一个统一的「版本选择器」,包含 Tag 和 Branch。getTagBranchCols 方法完成这个合并:
java
public List<TagBranchCol> getTagBranchCols(PublishBiz biz, String token) {
List<TagBranchCol> result = new ArrayList<>();
long projectId = biz.getProjectId();
if (projectId <= 0) {
log.warn("getTagBranchCols skipped: projectId={} for biz={}, GitLab project not configured",
projectId, biz.getName());
return result;
}
try {
for (Tag tag : getTags(token, projectId)) {
TagBranchCol col = new TagBranchCol();
col.setName(tag.getName());
col.setDate(tag.getCommit() != null ? tag.getCommit().getCommittedDate() : null);
col.setMessage(tag.getMessage());
result.add(col);
}
} catch (Exception e) {
log.warn("getTags error for project {}", projectId, e);
}
try {
for (Branch branch : getBranches(token, projectId)) {
TagBranchCol col = new TagBranchCol();
col.setName(branch.getName());
col.setDate(branch.getCommit() != null ? branch.getCommit().getCommittedDate() : null);
result.add(col);
}
} catch (Exception e) {
log.warn("getBranches error for project {}", projectId, e);
}
result.sort(Comparator.comparing(TagBranchCol::getDate,
Comparator.nullsLast(Comparator.reverseOrder())));
return result;
}
TagBranchCol 是一个简单的 DTO,统一了 Tag 和 Branch 的数据结构:
java
@Data
public class TagBranchCol {
private String name; // Tag 名或 Branch 名
private Date date; // commit 日期
private String message; // Tag 的 message(Branch 无此字段)
}
排序逻辑是按提交日期倒序,最近的排在最前面,null 日期的排到最后。这样用户打开发布页面时,最先看到的是最新的 Tag 和最近活跃的分支。
5.2.5 版本号自动递增
发布系统支持自动创建 GitLab Tag,标记每次发布的版本。getNextTagPrefix 实现了语义化版本号自动递增:
java
public String getNextTagPrefix(String tagName) {
if (tagName == null) return "v1.0.0";
Matcher matcher = Pattern.compile("v?(\\d+)\\.(\\d+)\\.(\\d+)").matcher(tagName);
if (!matcher.find()) return "v1.0.0";
int major = Integer.parseInt(matcher.group(1));
int minor = Integer.parseInt(matcher.group(2));
int patch = Integer.parseInt(matcher.group(3));
patch++;
if (patch >= 100) { patch = 0; minor++; }
if (minor >= 100) { minor = 0; major++; }
return "v" + major + "." + minor + "." + patch;
}
规则:patch 号递增(v1.0.0 -> v1.0.1 -> ... -> v1.0.99 -> v1.1.0)。patch 和 minor 的上限都是 99,达到后会进位。如果需要从某个已有 Tag 开始自动递增,只需传入当前的 Tag 名即可。
创建和删除 Tag 的方法则直接委托给 gitlab4j-api:
java
public Tag addTag(String token, long projectId, String name, String ref,
String message, String description) {
try {
return api(token).getTagsApi().createTag(projectId, name, ref, message, description);
} catch (Exception e) {
log.error("addTag failed", e);
return null;
}
}
public void deleteTag(String token, long projectId, String name) {
try {
api(token).getTagsApi().deleteTag(projectId, name);
} catch (Exception e) {
log.error("deleteTag failed", e);
}
}
5.2.6 用户认证与项目搜索
getSession 方法用于验证用户提供的 GitLab Token 是否有效:
java
public org.gitlab4j.api.models.User getSession(String token) {
try {
return api(token).getUserApi().getCurrentUser();
} catch (Exception e) {
log.error("getSession failed", e);
return null;
}
}
通过调用 GitLab 的 /api/v4/user 接口,如果返回了用户信息,说明 Token 有效;返回 null 则说明 Token 无效或网络不通。
searchProjectIdByGitUrl 则通过 Git URL 反向查找 GitLab 上的 projectId:
java
public long searchProjectIdByGitUrl(String url, String token) {
if (url == null) return 0;
String useToken = (token != null && !token.isEmpty()) ? token : privateToken;
try {
for (Project p : getAllProjects(useToken)) {
if (url.equals(p.getHttpUrlToRepo()) || url.equals(p.getSshUrlToRepo())) {
return p.getId();
}
}
// fallback: 按 path_with_namespace 后缀匹配
String searchName = url.replace(".git", "");
for (Project p : getAllProjects(useToken)) {
if (p.getPathWithNamespace() != null
&& p.getPathWithNamespace().endsWith("/" + searchName)) {
return p.getId();
}
}
} catch (Exception e) {
log.error("searchProjectIdByGitUrl failed for url {}", url, e);
}
return 0;
}
匹配策略分两轮:第一轮精确匹配 HTTP/SSH URL;第二轮 fallback 到 path 后缀匹配。返回 0 表示未找到。
5.3 Token 安全策略
Git 操作的认证是整个发布系统的安全基石。系统采用了多层 Token 管理策略:
第一层:系统级 privateToken 。通过 application.yml 中的 gitlab.private-token 配置,作为所有 Git 操作(clone/fetch)和 GitLab API 调用的默认凭据。这是系统运行的「底座」。
第二层:用户个人 Token 。PublishUser 表的 token 字段存储了每个用户绑定的 GitLab Personal Access Token。当用户在发布页面上浏览 GitLab 项目列表、分支列表时,系统优先使用用户的个人 Token,确保用户只能看到自己有权限访问的项目。
java
public class PublishUser extends BaseEntity {
private String name;
private String email;
private String displayName;
private String token; // 用户绑定的 GitLab Personal Access Token
private String role;
private Date createTime;
}
第三层:adminToken 兜底 。配置项 gitlab.admin-token 作为管理员 Token,在某些需要提权操作的场景(如查看所有项目的成员信息)中使用。
Token 优先级:用户 Token > 系统 privateToken > adminToken(仅在特定方法中使用)。
URL 注入安全性 :如 5.1.3 节所述,Token 通过 oauth2 前缀注入到 Git URL 的参数中,仅在进程内存中存在,不会写入 .git/config 或任何磁盘文件。发布任务完成后进程退出,Token 随之消失。
5.4 小结
本章拆解了发布系统中 Git 集成的两层架构:
- GitService (执行层):通过
ProcessBuilder执行原生 git 命令,核心流程是ensurePath(首次 clone + 后续 fetch/checkout),Token 以 oauth2 URL 注入方式传递,不落盘。 - GitLabService (数据层):通过
gitlab4j-api封装 GitLab REST API,提供 Tag/Branch 列表查询、版本号递增、用户认证等功能,支持多 Token 切换和 404 容错。
这两层配合,使得发布系统能够灵活地对接 GitLab 平台,安全地管理代码版本。下一章将进入构建系统,看代码从 Git 仓库拉取下来后,如何变成可部署的构建产物。