第3章:数据库设计与领域实体
本章从数据库设计和领域实体的角度,拆解 publish 发布系统的数据模型。系统的核心职责是将 Git 仓库中的代码,按指定的版本(tag/branch),自动部署到目标服务器的指定路径------这决定了实体关系的设计走向。
3.1 核心实体关系梳理
publish 系统的实体模型可以分为三条线索:
主线(发布流程):Biz →\to→ App →\to→ Task →\to→ Flow
PublishBiz:一个可发布单元,对应一个 Git 仓库PublishApp:Biz 的部署实例,指定目标机器、路径和环境PublishTask:一次具体的发布操作,记录发布人、版本、目标机器PublishFlow:Task 的步骤拆分,每步独立记录输出和耗时
辅助线(自动化支撑):
PublishHook:GitLab WebHook 注册,bizId 与 hookId 的映射PublishRelation:分支 →\to→ App 的自动部署映射(哪个分支 push 后自动部署到哪个实例)PublishDependency:Biz 的 Maven 依赖版本追踪PublishPack:构建缓存(同一 biz + code 不重复打包)
账号线(认证与审计):
PublishAccount:本地登录账号(BCrypt 密码)PublishUser:GitLab Private Token 绑定PublishVisit:用户访问足迹记录
三条线以 Biz 为枢纽交织在一起:Biz 是核心,其余实体都通过 bizId 外键与之关联。
3.2 PublishBiz ------ 可发布单元
PublishBiz 是整个系统的中心概念,表示"一个可以独立构建和部署的代码单元"。在 publish 系统中,一个 Git 仓库 = 一个 Biz。
源码 PublishBiz.java:
java
@Getter
@Setter
@TableName("publish_biz")
public class PublishBiz extends BaseEntity {
private String name; // 业务名称
private String git; // Git 仓库地址
private String owner; // 负责人
private String module; // 子模块名(Maven 多模块项目时指定具体模块)
private String type; // 类型: java / jar / python / angular
private String warmUp; // 预热 URL(多个逗号分隔)
private String preScript; // 打包前置脚本
private String postScript; // 启动后脚本
private long projectId; // GitLab 项目 ID
private boolean mask; // 是否用 iptables 屏蔽端口
private boolean testPass; // 是否测试通过
private boolean timeRestrict; // 是否有上线时间限制
private boolean pingable; // 是否接入 ping 监控
@TableField(exist = false)
private long deployTimes; // 瞬态字段:发布次数统计,不持久化
}
几个设计要点值得关注:
type 字段的多语言支持。 系统不只支持 Java 应用------jar 类型表示独立 JAR 包部署,python 表示 Python 项目,angular 表示前端项目。不同 type 在后续的构建和部署步骤中执行不同的脚本逻辑。
preScript / postScript 的扩展点设计。 这两个字段允许运维人员在标准部署流程之外注入自定义命令。preScript 在 mvn package 之前执行(例如拉取私有依赖),postScript 在部署完成后执行(例如通知监控系统、触发健康检查)。这避免了为每个特殊需求修改核心逻辑。
getTagUrl() 方法的推导逻辑。 Biz 从 Git URL 自动推导 GitLab 的 tags 页面地址:
java
public String getTagUrl() {
if (git == null) {
return null;
}
return git.replace(".git", "/tags");
}
例如 git@gitlab.com:team/project.git 会被转换为 git@gitlab.com:team/project/tags,供前端页面直接链接到 GitLab 的版本标签页。
3.3 PublishApp ------ 部署实例
PublishApp 描述一个 Biz 的一个具体部署实例------"哪个业务、部署到哪台机器、哪个端口、哪个路径、什么环境"。同一 Biz 的同一 profile 可以部署到多台机器(水平扩展场景)。
源码 PublishApp.java:
java
@Getter
@Setter
@TableName("publish_app")
public class PublishApp extends BaseEntity {
private long bizId; // 关联 publish_biz.id
private String name; // 应用名称
private String profile; // 环境分组: yinke / pre / dev / ceshi113 ...
private String ip; // 服务器 IP
private int port; // HTTP 端口
private String path; // 部署路径,如 /data/tomcat/webapps/ROOT
@TableField(updateStrategy = FieldStrategy.IGNORED)
private String locker; // 锁定者(防止并发发布)
private String code; // 当前部署版本(tag/branch)
private String env; // bin/setenv.sh 内容
private String conf; // conf/server.xml 内容
private String description; // 备注
@TableField(exist = false)
private PublishBiz biz; // 瞬态:关联的 Biz 对象
@TableField(exist = false)
private Object runningStatus; // 瞬态:运行时状态(不持久化)
}
getFullPath() ------ 部署坐标的唯一标识。 ip:path 组合为每台机器上的部署提供了唯一坐标:
java
public String getFullPath() {
return ip + ':' + path;
}
由于一台服务器上可能部署多个应用,仅靠 IP 无法唯一定位,加上 path 之后才构成完整的部署坐标。这也对应数据库中的唯一索引 uk_ip_path。
locker 字段的并发控制。 locker 采用乐观锁策略------发布前检查是否有其他人在操作同一实例,发布完成后释放。@TableField(updateStrategy = FieldStrategy.IGNORED) 表示更新时即使 locker 为 null 也会写入数据库,保证锁的释放能正确持久化。
profile 环境分组。 profile 是连接 Biz 和机器的重要维度。通过 findByBizIdAndProfile(bizId, profile) 方法,DeployService 可以精确选定"某个业务在某个环境下的所有部署实例",然后逐台执行发布。环境分组使得同一 Biz 的测试环境(pre)和生产环境(yinke)的发布流程可以隔离管理。
3.4 PublishTask ------ 发布任务
PublishTask 是一次具体的发布操作记录。它保存了"谁、对哪个业务、发布了哪个版本、到哪些机器"的完整信息。
源码 PublishTask.java:
java
@Getter
@Setter
@TableName("publish_task")
public class PublishTask extends BaseEntity {
private String type; // 任务类型: pkg / deploy / onekey / retry / rollback
private long bizId; // 关联 publish_biz.id
private long cost; // 耗时(毫秒),DB 持久化字段
private String name; // 业务名称快照
private String profile; // 环境分组
private String editor; // 发布人
private String code; // 发布版本(tag/branch)
private String processes; // 目标机器列表(ip:path 逗号分隔)
private String description; // 发布说明
private String err; // 错误信息(V3 迁移新增)
private String status; // 状态:create / running / succ / fail / killed / cancelled / timeout
@TableField("`interval`")
private int interval; // 多台机器间发布间隔(秒)
// 瞬态字段,不持久化:
@TableField(exist = false) private List<Long> apps;
@TableField(exist = false) private PublishBiz biz;
@TableField(exist = false) private List<PublishFlow> flows;
@TableField(exist = false) private List<PublishApp> appList;
@TableField(exist = false) private boolean pack;
}
瞬态字段设计。 apps、biz、flows、appList 这四个字段全部标注了 @TableField(exist = false),表示它们只存在于 Java 对象的内存中,不映射到数据库列。它们的作用是运行时数据传递------当 Controller 层传入 Task 对象时,这些字段携带了关联的 App ID 列表、Biz 详情和 Flow 列表,供 Service 层使用。这种设计避免了在每个方法调用时反复查询关联数据。
getCost() 的两阶段计时策略。 这是 Task 设计中最精妙的地方:
java
public long getCost() {
if (createTime != null
&& ("running".equals(status) || "create".equals(status))) {
return System.currentTimeMillis() - createTime.getTime();
}
return cost;
}
任务运行时,createTime 对象还在内存中(瞬态),getCost() 实时计算当前时间与创建时间的差值。任务结束后,stop() 方法将计算结果写入 cost 字段并持久化到数据库,此后 getCost() 直接返回 DB 中存储的最终耗时。这样一来,前端轮询运行中的任务时能获得实时耗时,历史任务查询时又能直接从数据库读取,无需额外计算。
getHosts() 从 processes 文本中提取 IP。 processes 字段存储的是类似 192.168.1.1:/data/app,192.168.1.2:/data/app 的文本,getHosts() 用正则 \b(?:\d{1,3}\.){3}\d{1,3}\b 提取出纯 IP 列表,供 SSH 连接使用。
7 种任务状态。 Task 的生命周期覆盖了完整的发布流程:create(创建)→\to→ running(执行中)→\to→ succ(成功)/ fail(失败)/ killed(被终止)/ cancelled(被取消)/ timeout(超时)。interval 字段控制多台机器间发布的时间间隔,防止流量瞬间涌入刚启动的单台实例。
err 字段是后期新增。 最初的任务表没有 err 字段,V3 迁移通过 ALTER TABLE publish_task ADD COLUMN err TEXT 补充。这使得 Task 本身可以直接记录顶层错误信息,而不必到 Flow 中逐个排查。
3.5 PublishFlow ------ 步骤流水(核心创新)
PublishFlow 是整个系统在设计层面最值得关注的实体。它将一个发布任务拆分为多个独立的步骤,每个步骤都有独立的名称、状态、标准输出、错误输出和耗时。
源码 PublishFlow.java:
java
@Getter
@Setter
@TableName("publish_flow")
public class PublishFlow extends BaseEntity {
private static final int MAX_TEXT_LEN = 2000;
private long taskId; // 关联 publish_task.id
private String name; // 步骤名称,如 "checkout" / "package" / "deploy"
private String status; // 状态
private String step; // 步骤序号,如 "1/5"
private long cost; // 耗时(毫秒)
@TableField("`out`")
private String out; // 标准输出
@TableField("`err`")
private String err; // 错误输出
}
out/err 字段的自动净化。 这是 Flow 设计中最具工程价值的细节。执行 Maven 构建或 SSH 命令时,输出中可能包含 ANSI 转义码(Maven 的彩色日志)、换行符等特殊字符,直接写入 MySQL 会导致 PreparedStatement 参数绑定异常------尤其是 MySQL Connector/J 9.x 对 \n 字符更加敏感。Flow 的 setter 方法内部自动执行净化:
java
public void setOut(String out) {
this.out = sanitizeField(out);
}
private static String sanitizeField(String s) {
if (s == null) return null;
// 1. 去掉 ANSI 转义码
String cleaned = s.replaceAll("\\e\\[[\\d;]*[a-zA-Z]", "");
// 2. 换行/回车 → 空格(MySQL Connector/J 9.x 兼容)
cleaned = cleaned.replace('\n', ' ').replace('\r', ' ');
// 3. 去掉其余 ASCII 控制字符
cleaned = cleaned.replaceAll("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]", "");
// 4. 截断到 2000 字符(取末尾,保留最新的输出)
if (cleaned.length() > MAX_TEXT_LEN) {
cleaned = cleaned.substring(cleaned.length() - MAX_TEXT_LEN);
}
return cleaned;
}
净化分四步:
- ANSI 转义码去除 :通过正则
\e\[[\d;]*[a-zA-Z]匹配形如\e[1;32m的颜色控制序列并移除。 - 换行转空格 :
\n和\r替换为空格。这是 MySQL Connector/J 9.x 兼容的关键------高版本驱动对包含\n的参数绑定会抛出异常。 - 其余控制字符清除 :去除
\x00-\x08、\x0B、\x0C、\x0E-\x1F范围内的不可见字符。 - 末尾截断 :超过 2000 字符时,保留末尾(最新)的 2000 字符。取末尾而非开头,是因为流水执行过程中,最末尾的输出往往包含最关键的结论信息(如构建失败的原因)。
注意 Lombok 的 @Setter(AccessLevel.NONE)。 out 和 err 字段上都加了 @Setter(AccessLevel.NONE),这禁止了 Lombok 生成默认的 public setter。原因是这两个字段的设值必须经过 sanitizeField 净化逻辑------如果允许直接赋值,就会绕过净化步骤,导致脏数据写入数据库。开发者在 Flow 对象的其他地方赋值时,只能调用 setOut() 和 setErr() 方法,确保净化逻辑始终生效。
@TableField("`out`") 的反引号映射。 out 是 MySQL 保留字,因此 @TableField 中使用反引号包裹列名,生成的 SQL 为 out,避免语法冲突。
getCost() 和 stop() 的两阶段计时。 与 PublishTask 完全相同的设计模式------运行中实时计算,结束后从 DB 取持久化值。这种一致性使得前端无论是轮询进行中的任务还是查看历史记录,都能获得正确的耗时数据。
3.6 辅助实体
PublishHook ------ WebHook 注册
java
@TableName("publish_hook")
public class PublishHook extends BaseEntity {
private long bizId; // 关联 publish_biz.id
private String hookId; // GitLab WebHook ID
private String type; // Hook 类型
}
一条 Biz 可以注册一个或多个 GitLab WebHook。当 GitLab 收到 push 事件时,回调 publish 系统的 WebHook 接口,系统根据 hookId 找到对应的 bizId,再结合分支匹配 PublishRelation,决定是否触发自动部署。
PublishRelation ------ 自动部署映射
java
@TableName("publish_relation")
public class PublishRelation extends BaseEntity {
private long bizId; // 关联 publish_biz.id
private long appId; // 关联 publish_app.id
private String branch; // Git 分支名
@TableField(exist = false) private String appName;
@TableField(exist = false) private String ip;
@TableField(exist = false) private String path;
}
PublishRelation 建立"分支 →\to→ 部署实例"的映射。例如配置 dev 分支关联到测试环境的 App,push 到 dev 分支后自动触发该 App 的部署。appName、ip、path 是瞬态字段,用于前端列表页面直接展示 App 的关键信息,避免多次查询。
PublishDependency ------ 依赖版本追踪
java
@TableName("publish_dependency")
public class PublishDependency extends BaseEntity {
private long bizId; // 关联 publish_biz.id
private String name; // 依赖名称,如 "justgotrip-core"
private String version; // 版本号,如 "1.2.3"
private Date updateTime; // 更新时间(ON UPDATE CURRENT_TIMESTAMP)
}
用于追踪每个业务引用的核心依赖版本。当 justgotrip-core 发新版时,运维人员可以快速排查哪些业务尚未升级。
PublishPack ------ 构建缓存
java
@TableName("publish_pack")
public class PublishPack extends BaseEntity {
private long bizId; // 关联 publish_biz.id
private String code; // 版本标识(tag/branch)
private String path; // 包文件路径
private String profile; // 环境分组
@TableField(exist = false)
private String owner; // 瞬态:文件大小,不持久化
}
构建缓存的核心价值在于:同一 (bizId, code) 组合只构建一次。当多台机器需要部署同一个版本时,第一台触发 mvn package,后续机器直接复用服务器上已有的 WAR/JAR 包。数据库唯一索引 uk_biz_code 保证了同一版本不会重复插入缓存记录。getSizeMB() 方法从文件系统实时获取包大小,不在数据库中冗余存储。
PublishAccount ------ BCrypt 登录
java
@TableName("publish_account")
public class PublishAccount extends BaseEntity {
private String username; // 登录用户名
private String password; // BCrypt 加密密码
private String displayName; // 显示名称
private String role; // 角色: admin / user
private boolean enabled; // 是否启用
}
系统采用本地账号认证而非 SSO,密码使用 BCrypt 加密存储。V2 迁移自动插入默认管理员账号 admin/admin123。
PublishUser ------ GitLab Token 绑定
java
@TableName("publish_user")
public class PublishUser extends BaseEntity {
private String name; // 用户名
private String email; // 邮箱
private String displayName; // 显示名
private String token; // GitLab Private Token
private String role; // 角色
}
token 字段存储 GitLab 的 Private Token,用于调用 GitLab API(如查询 tags、创建 WebHook)。
PublishVisit ------ 访问足迹
java
@TableName("publish_visit")
public class PublishVisit extends BaseEntity {
private String editor; // 用户名
private String footprints; // 访问足迹(bizId 逗号分隔)
}
footprints 字段用逗号分隔的 bizId 列表记录用户最近访问过的业务,用于前端"最近使用"快捷入口。每个用户只保留一条记录(uk_editor 唯一索引),每次访问时更新覆盖。
3.7 枚举设计
TaskStatus ------ 任务状态
java
public enum TaskStatus {
create("create"), running("running"), succ("succ"),
fail("fail"), killed("killed"), cancelled("cancelled"),
timeout("timeout");
private final String name;
public boolean isRunning() {
return this == running || this == create;
}
public boolean isError() {
return this != succ;
}
public static TaskStatus valueOfName(String name) {
for (TaskStatus s : values()) {
if (s.name.equals(name)) return s;
}
return create;
}
}
状态枚举提供了两个关键的辅助判断方法:
isRunning():将create和running都视为"执行中"。这是因为任务从创建到真正开始执行之间可能存在短暂延迟,将两者统一处理避免了状态判断的边界问题。isError():非succ即异常。这个反向判断简单实用------只要不是成功,就需要关注。
valueOfName() 是 Java valueOf() 的安全替代,传入未知字符串时返回默认值 create 而非抛出 IllegalArgumentException。这在从数据库读取状态字符串时尤为重要------数据库可能因为代码升级而包含尚未定义的枚举值。
TaskType ------ 任务类型
java
public enum TaskType {
unknown("unknown"), pkg("pkg"), deploy("deploy"),
onekey("onekey"), retry("retry"), rollback("rollback");
}
五种类型覆盖了完整的发布操作集合:
pkg:仅构建打包,不部署deploy:仅部署已有的包onekey:一键打包+部署(最常用)retry:对失败的任务重试rollback:回滚到上一版本
3.8 完整表结构
系统使用 Flyway 管理数据库版本,初始版本 V1 包含 12 张核心表,V2 新增账号表,V3 为任务表补充 error 字段。
主线表
publish_biz ------ 唯一索引 uk_name 保证业务名不重复,uk_module_git 防止同一 Git 仓库的同一模块被重复注册。
sql
CREATE TABLE `publish_biz` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(64) NOT NULL,
`git` VARCHAR(512) NOT NULL DEFAULT '',
`owner` VARCHAR(100) DEFAULT NULL,
`module` VARCHAR(256) DEFAULT NULL,
`type` VARCHAR(32) NOT NULL DEFAULT 'java',
`pre_script` VARCHAR(2048) DEFAULT NULL,
`post_script` VARCHAR(2048) DEFAULT NULL,
`warm_up` VARCHAR(1024) DEFAULT NULL,
`mask` TINYINT(1) NOT NULL DEFAULT 0,
`project_id` BIGINT NOT NULL DEFAULT 0,
`test_pass` TINYINT(1) NOT NULL DEFAULT 0,
`time_restrict` TINYINT(1) NOT NULL DEFAULT 0,
`pingable` TINYINT(1) NOT NULL DEFAULT 0,
`create_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_name` (`name`),
UNIQUE KEY `uk_module_git` (`module`, `git`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
publish_app ------ 唯一索引 uk_ip_path 确保同一台机器的同一路径只有一个应用实例;普通索引 idx_biz_id 支撑按业务查询所有部署实例的高频操作。
sql
CREATE TABLE `publish_app` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`biz_id` BIGINT UNSIGNED NOT NULL,
`name` VARCHAR(128) NOT NULL,
`profile` VARCHAR(50) NOT NULL,
`ip` VARCHAR(50) NOT NULL,
`port` INT NOT NULL DEFAULT 0,
`path` VARCHAR(512) NOT NULL,
`locker` VARCHAR(100) DEFAULT NULL,
`code` VARCHAR(255) DEFAULT NULL,
`env` TEXT DEFAULT NULL,
`conf` TEXT DEFAULT NULL,
`description` TEXT DEFAULT NULL,
`publish_time` TIMESTAMP NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_ip_path` (`ip`, `path`),
KEY `idx_biz_id` (`biz_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
publish_task ------ interval 使用反引号包裹(MySQL 保留字)。四个普通索引覆盖了按业务查询、按发布人查询、按状态过滤、按时间排序的常见查询模式。err 字段通过 V3 迁移补充。
sql
CREATE TABLE `publish_task` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`biz_id` BIGINT UNSIGNED DEFAULT NULL,
`type` VARCHAR(20) NOT NULL DEFAULT 'onekey',
`status` VARCHAR(20) NOT NULL DEFAULT 'create',
`name` VARCHAR(256) NOT NULL,
`profile` VARCHAR(100) DEFAULT NULL,
`code` VARCHAR(255) DEFAULT NULL,
`editor` VARCHAR(100) NOT NULL,
`description` TEXT DEFAULT NULL,
`processes` TEXT DEFAULT NULL,
`interval` INT NOT NULL DEFAULT 0,
`cost` BIGINT DEFAULT NULL,
`create_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_biz_id` (`biz_id`),
KEY `idx_editor` (`editor`),
KEY `idx_status` (`status`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
辅助与账号表索引设计总结
| 表名 | 核心唯一索引 | 核心普通索引 |
|---|---|---|
publish_biz |
uk_name,uk_module_git |
--- |
publish_app |
uk_ip_path |
idx_biz_id |
publish_task |
--- | idx_biz_id,idx_editor,idx_status,idx_create_time |
publish_flow |
--- | idx_task_id |
publish_pack |
uk_biz_code |
--- |
publish_hook |
--- | idx_biz_id |
publish_dependency |
--- | idx_biz_id |
publish_relation |
uk_app_branch |
idx_biz_id |
publish_user |
uk_name |
--- |
publish_visit |
uk_editor |
--- |
publish_account |
uk_username |
--- |
整体设计遵循一个原则:唯一索引保障数据完整性,普通索引覆盖高频查询列 。Biz 和 App 的查询最为频繁(每次发布都要查),因此 idx_biz_id 散布在几乎所有关联表上。Task 表额外加了按编辑人和按状态的索引,因为前端列表页面支持按发布人和任务状态做筛选。
本章小结
publish 系统的数据模型以 Biz(可发布单元) 为核心,通过 App(部署实例) 描述部署目标,Task(发布任务) 记录每次操作,Flow(步骤流水) 提供细粒度的执行追踪。实体设计中值得关注的关键工程决策包括:
- Flow 的
sanitizeField自动净化输出文本,防止 ANSI 转义码和换行符污染数据库 - Task 和 Flow 的
getCost()两阶段计时------运行中实时计算,结束后从 DB 取 @TableField(exist = false)瞬态字段在实体间传递关联数据,避免反复查询valueOfName()安全解析枚举,替代 Java 原生valueOf()避免未知值抛异常