搜索 K
Appearance
Appearance
forge 在启动阶段会把全部模型定义沉淀成三张系统表:sys_module、sys_model、sys_field。这一步不是为了「记账」,而是要让运行时的 schema 可被查询、可被比对、可被回溯——它是 DDL 演进、声明式后台管理、关系填充等上层能力共同依赖的元数据底座。本篇解释这套落库机制为什么这样设计,落库流程长什么样,以及关系字段与逻辑删除在其中的特殊处理。
使用层面的开关与日志请配合阅读 系统元数据落库(使用指南)。包扫描、启动钩子如何把模型送进这一步,见 前置知识。
模型定义本身存在于 JVM 内存的 ModelRegistry 中,进程一关就没了。但有几类需求恰恰需要一份「持久、可查、跨重启稳定」的 schema 镜像:
设计取舍:落库 vs 纯内存
| 选项 | 选择 | 理由 |
|---|---|---|
仅保留内存 ModelRegistry | 否 | 重启即丢,无法支撑跨进程的 DDL 比对与历史回溯 |
元数据落库到 sys_* 表 | 是 | schema 可用 SQL 查询、可比对、可作为 DDL 与后台管理的统一事实来源 |
| 额外写一份外部文件/注册中心 | 否 | 增加运维面,且数据库本就是模型的归宿,复用即可 |
落库由 SystemMetaManager(cn.cvking.forge.boot.manager.SystemMetaManager)负责,入口方法签名为 save(modules, models, dialect)。
SystemMetaManager.save 采用「全量加载现状 → 构建本轮实体 → 批量 upsert → 软删消失项」的四段式。它不做增量 diff 的精细判断,而是依赖业务键(模块 code、模型 modelName、字段 fieldName)做幂等覆盖,再单独处理本轮「消失」的记录。
existModules = Models.origin(SysModule.class).queryCodeMap();
existModels = Models.origin(SysModel.class).queryCodeMap();
existFields = loadFields();这里用 queryCodeMap() 把既有记录按业务键索引成 Map,供后续 buildXxx 判断「是新增还是更新」——命中既有记录则沿用其主键走 update,否则 insert。
遍历定义时同步登记本轮「活着」的业务键,这是后续软删的判据:
for (ModuleDefinition m : modules) {
moduleEntities.add(buildModule(existModules.get(m.getCode()), m, now));
aliveModuleCodes.add(m.getCode());
}
for (ModelDefinition model : models) {
modelEntities.add(buildModel(existModels.get(model.getModelName()), model, now));
aliveModelCodes.add(model.getModelName());
for (FieldDefinition field : model.getFields()) {
aliveFields.add(field.getFieldName());
fieldEntities.add(buildField(/* ... */ model, field, now, dialect));
}
}模块、模型、字段三批分别走 Models.ofBatch(...).saveBatch()。saveBatch() 会按主键和唯一键分流,存在的走 update、不存在的走 insert,从而保证幂等。
凡是上一轮在库、本轮 alive 集合里没有的记录,交给 deleteVanished(...) 软删(逻辑删除),而非物理 DELETE。完成后打印统计日志:
[sys_meta] 元数据落库完成,耗时 N ms(模块 X 个、模型 Y 个)关系字段(@Field.M2O / @Field.O2O / @Field.O2M / @Field.M2M)是虚拟字段,store=false,本身不对应任何数据库列。落 sys_field 时,它们的 columnType 必须留空,否则 DDL 链路会误以为该字段需要建列。
// SystemMetaManager 建字段时的判断
String columnType = (field.getFieldType() != null && field.getFieldType().isRelation())
? null
: dialect.mapColumnType(field);判定依据是 FieldType.isRelation()。换言之,是否落列类型完全由字段的业务类型决定,方言只对「真正有列」的标量字段调用 dialect.mapColumnType(field)。这样以宠物商店里的 Pet 为例,name、price、categoryId 会得到具体列类型,而 category、owner、profile、tags 这四个关系字段的 columnType 都是 null。
设计取舍:关系字段如何入 sys_field
| 选项 | 选择 | 理由 |
|---|---|---|
关系字段不写入 sys_field | 否 | 后台管理仍需读取关系字段的显示名/目标模型等元信息 |
写入但 columnType 置空 | 是 | 既保留元信息,又让 DDL 据 isRelation() 跳过建列 |
| 为关系字段也映射列类型 | 否 | 会让 DDL 凭空建出不该存在的列 |
模型类型有 STORE / TRANSIENT / ABSTRACT / PROXY 四种。只有 STORE 模型对应真实物理表,其余类型(如抽象基类 IdModel、CodeModel)不落表。因此 sys_model.tableName 只有 STORE 模型才有值,其余为 null。
这条规则之所以重要,是因为它直接关系到 DDL 的删表安全。消失表检测(SystemMetaManager.detectVanishedTables)会遍历 sys_model,过滤掉两类记录后才把剩下的当作「待处理的消失表」:
_d_(SchemaComparator.SOFT_DELETE_PREFIX)开头的——这是已归档的旧表;如果非 STORE 模型也写了 tableName,那些根本没有物理表的记录就会混进消失表判定,导致误删或误归档。把 tableName 限定在 STORE 模型,是从源头上避免这类「幽灵表」的关键约束。
删表语义由配置决定
判定为消失表后,是物理删还是逻辑归档由 forge.ddl.allow-drop 决定(默认 false):
false:逻辑删表,重命名为 _d_{原表名}_{时间戳},日志 [DDL] 逻辑删表,归档为 {archived};true:物理 DROP TABLE,日志 [DDL] 物理删表 DROP TABLE {table}(allow-drop=true)。完整开关与示例见 使用指南的删表小节。
落库写入的三张表字段如下(均带 updateTime 审计列)。
sys_module(来自 cn.cvking.forge.boot.sys.SysModule):
| 字段 | 含义 |
|---|---|
code | 模块编码 |
name | 模块名称 |
scanPackages | 扫描包列表(逗号分隔) |
priority | 优先级 |
dependencies | 依赖模块编码(逗号分隔) |
version / versionName | 版本号 / 版本名称 |
sys_model(来自 cn.cvking.forge.boot.sys.SysModel):
| 字段 | 含义 |
|---|---|
code | 模型编码(即 modelName) |
tableName | 数据库表名,仅 STORE 模型有值,其余为 null |
moduleCode | 所属模块编码 |
displayName / comment | 显示名称 / 描述摘要 |
modelType | 模型类型 |
primaryKey | 主键字段名 |
logicDelete / logicDeleteColumn | 是否启用逻辑删除 / 逻辑删除列名 |
uniqueKeys / indexes | 唯一索引、普通索引的 JSON,如 [["email"],["code"]] |
sys_field(来自 cn.cvking.forge.boot.sys.SysField):
| 字段 | 含义 |
|---|---|
modelCode / fieldCode | 关联模型编码 / Java 字段名 |
columnName | 数据库列名 |
displayName | 字段显示名 |
javaType | Java 类型全限定名 |
fieldType | 字段业务类型(FieldType 枚举) |
columnType | 数据库列类型,关系字段为 null |
length / precision / scale | 长度 / 总位数 / 小数位数 |
nullable / store / primaryKey | 是否可空 / 是否持久化 / 是否主键 |
enumClass / enumStoreType | 枚举类全限定名 / 枚举标量存储类型 |
FieldType 是字段元数据的核心枢纽,落库只是它的一个消费方。理解它的下游链路,才能在新增一种字段类型时不漏改地方。
当你扩展 FieldType 时,至少要关注这些消费点:
| 消费点 | 位置 | 关注什么 |
|---|---|---|
| 列类型映射 | 方言 mapColumnType(field) | 新类型若有物理列,需补对应 SQL 类型 |
| 关系判定 | FieldType.isRelation() | 决定落库是否置空 columnType、是否走关系填充 |
| 元数据落库 | SystemMetaManager | fieldType 原样落入 sys_field |
| 关系读/写 | DefaultRelationReadApi / DefaultRelationWriteApi | 仅关系类型涉及,标量类型不触达 |
把「是否关系字段」收敛到 FieldType.isRelation() 一个方法,意味着落库、读填充、写维护、DDL 都共用同一判据。新增一种关系子类型时,只要它在 isRelation() 里归位,所有下游会自动一致对待,不必到处补 if (type == M2O || type == O2O || ...) 的散落分支。
SystemMetaManager.save 是「加载现状 → 构建实体 → 批量 upsert → 软删消失项」四段式,依赖业务键做幂等;columnType 置空、仅 STORE 模型落 tableName,二者共同保证 DDL 不会凭空建列、不会误删幽灵表;FieldType 时以 isRelation() 为统一判据,沿建列、落库、关系读写四条链路自检。落库开关、调试日志与排查实操,见 系统元数据落库(使用指南)。