搜索 K
Appearance
Appearance
本篇解释 forge 数据建模层「为什么这么切」:注解为何拆成外层 @Field 加类型子注解加 @Field.Relation,运行时的 ModelDefinition / FieldDefinition 为何长成这个样子,以及关系类型为何并入 FieldType 而不另立枚举。如果你想直接上手写实体,请先看使用篇 数据建模 · 快速开始,本篇只谈取舍与结构。
一句话定位
注解是「人写的源代码契约」,ModelDefinition / FieldDefinition 是「机器读的运行时事实」。注解层解析一次、落到定义对象上,后续 DDL、CRUD、关系填充、元数据落库全部只读定义对象,不再回头碰反射与注解。
整个建模层只做一件事——把贴在宠物实体上的注解,翻译成一棵可被各下游消费的运行时定义树。
这样分层的核心理由:注解只能在编译期与类加载期被读到,且读注解依赖反射,成本高、不便组合查询。把它一次性归约成普通 POJO(ModelDefinition),下游就能用最朴素的字段访问拿到一切信息,无需重复反射,也方便单元测试中直接 new 一个定义来跑用例。关于反射与注解保留期的硬性前提,见 Java 注解前置知识 与 Java 反射前置知识。
forge 没有把 @Field 做成一个塞满几十个参数的「上帝注解」,而是切成了三个职责层次。仍以宠物 Pet 为例:
@Field(displayName = "名称") // 外层:基础元信息
@Field.String(length = 64) // 类型子注解:类型语义 + 类型专属参数
private String name;
@Field(displayName = "售价")
@Field.BigDecimal(precision = 10, scale = 2)
private BigDecimal price;
@Field.M2O // 关系类型子注解
@Field.Relation(relationFields = "categoryId") // 关系配置
@EqualsAndHashCode.Exclude @ToString.Exclude
private Category category;三层各自的职责:
@Field:描述「这是一个字段」,承载所有类型都需要的基础元信息——displayName、summary、notNull。@Field.String / @Field.BigDecimal / @Field.Enum / @Field.Date …):描述「这是什么类型」,并携带仅该类型需要的参数。@Field.String 有 length,@Field.BigDecimal 有 precision / scale,@Field.Date 有 DateType,@Field.Enum 有 multiSerialize / length。@Field.Advanced:承载持久化层配置——store(是否落库,关系字段置 false)、column(显式列名)。那么 @Field 至少要长成 length / precision / scale / dateType / multiSerialize / enumStoreType … 一长串。绝大多数字段只用得上其中一两个,其余全是噪声默认值;新增一种列类型还得改动这个被所有字段共享的注解,破坏向后兼容。
| 设计取舍 | 备选方案 | 最终选择 | 理由 |
|---|---|---|---|
| 类型参数归属 | 全塞进外层 @Field | 拆成类型子注解 | length 只属于字符串、precision 只属于定点数,归类后外层注解清爽,少数派参数不污染多数派 |
| 类型表达方式 | 用一个 type 枚举属性 | 用独立子注解 | 子注解能携带各自的专属参数,枚举属性做不到「随类型变参数」 |
| 扩展新列类型 | 改 @Field 加参数 | 新增一个内嵌子注解 | 不触碰已有注解,向后兼容,新增点收敛 |
| 持久化配置 | 混在外层 | 独立 @Field.Advanced | store / column 是「怎么落库」,与「是什么字段」正交,分开更内聚 |
类型子注解最终被解析进 FieldDefinition 的 fieldType、length、precision、scale、dateType 等属性,详见下一节。
关系字段的写法刻意做成「关系类型标记」加「关系配置」两枚注解并存:
@Field.M2M
@Field.Relation(throughModel = PetTag.class,
throughRelationFields = "petId",
throughReferenceFields = "tagId")
private List<Tag> tags;@Field.O2O / @Field.M2O / @Field.O2M / @Field.M2M 四个无参标记注解只回答「这是哪一类关系」,而所有配置——relationFields、referenceFields、targetModel、throughModel、throughRelationFields、throughReferenceFields、onDelete、onUpdate——统一收在 @Field.Relation 里。
这样分离的好处是:四类关系共享同一套配置模型,解析逻辑只需读一个 @Field.Relation,再看伴随的是哪个标记注解去决定该读哪几项配置。M2O / O2O 的 targetModel 可由字段 Java 类型推断而省略,M2M 则必须显式给出 throughModel 与两端中间表外键列。
单字段关联键限制
当前版本仅支持单字段关联键,复合键会在解析期抛出明确异常:「关系字段 {fieldName} 的 {role} 为复合键,当前版本暂未支持,请使用单字段关联键」。@Field.Relation 的 relationFields / referenceFields 虽是数组形态,但设计上为未来复合键预留,现阶段只取首位。
级联策略当前为占位
@Field.Relation 的 onDelete / onUpdate 接受 CascadeType(NONE / SET_NULL / CASCADE / RESTRICT),但当前版本仅声明不实现行为,源码中以占位注释标注。写在实体上不会报错,但不会触发任何级联动作。
解析完成后,每个宠物实体对应一个 ModelDefinition,其下挂一串 FieldDefinition;关系属性内联在 FieldDefinition 上,而非单独建一棵关系树。
FieldDefinition 的属性按三组来理解:
fieldName / columnName / javaType / fieldType / length / precision / scale / dateType / nullable / store / primaryKey。enumClass / enumStoreType / multi / multiSerialize / collectionAsSet。targetModelClass / relationFields / referenceFields / throughModelClass / throughRelationFields / throughReferenceFields / onDelete / onUpdate。| 设计取舍 | 备选方案 | 最终选择 | 理由 |
|---|---|---|---|
| 关系信息存放位置 | 单独的 RelationDefinition 树 | 内联进 FieldDefinition | 关系本质上「就是一个字段」,宠物的 category 与 name 在源码里地位对等,统一成字段定义后下游只需遍历一种集合 |
| 关系字段是否落库 | 另设标志位 | 复用 store=false | 关系字段是虚拟字段,与普通「应用层不落库字段」共用同一语义,无需新概念 |
| 列类型生成 | 关系字段也给个占位类型 | 关系字段 columnType 留空 | 关系字段无数据库列,落库与建表时据 fieldType.isRelation() 判定后直接跳过列类型映射 |
关系字段是虚拟字段、无数据库列,因此 sys_meta 落库时其 columnType 留空:
// 关系字段为虚拟字段、无数据库列,列类型留空
String columnType = (field.getFieldType() != null && field.getFieldType().isRelation())
? null
: dialect.mapColumnType(field);最值得展开的一个决策:四类关系(O2O / M2O / O2M / M2M)不另立枚举,而是和 STRING、INT、ENUM 等标量类型并列进同一个 FieldType 枚举,并由 isRelation() 一个方法区分。
并入同一枚举的理由:下游消费点(建表的列类型映射、sys_meta 落库、字段遍历)拿到的本就是同一个 FieldType,只需一个分支 isRelation() 就能把「要不要生成列、要不要参与关系填充」一刀切开。如果关系类型独立成第二个枚举,FieldDefinition 就得多一个「这字段到底看哪个枚举」的状态字段,每个消费点都要先判空再二选一,分支成倍增加。
| 设计取舍 | 备选方案 | 最终选择 | 理由 |
|---|---|---|---|
| 关系类型存放 | 独立 RelationType 枚举 | 并入 FieldType | 字段只有一个「类型」概念,消费点一个 isRelation() 即可分流,避免双枚举二选一的状态 |
| 关系 / 标量分流 | instanceof 或额外布尔位 | FieldType.isRelation() | 类型自带判定方法,语义内聚,调用处零额外字段 |
宠物的 status 字段是 PetStatus 枚举,能落库的前提是它实现了 ValueEnum<Integer>。这是建模层对枚举的一条强约束:普通 Java 枚举不允许落库,否则解析期校验失败。
public enum PetStatus implements ValueEnum<Integer> {
ON_SALE(1, "在售"),
SOLD(2, "已售"),
OFF_SHELF(3, "下架");
// getValue() / getDisplayName() / getHelper()
}设计要点在于 EnumStoreType 不再由注解显式声明,而是从 ValueEnum<T> 的泛型实参推断:ValueEnum<Integer> 推出 EnumStoreType.INT,ValueEnum<String> 推出 STRING,ValueEnum<Long> 推出 LONG。推断逻辑落在 FieldTypeHelper.resolveEnumStoreType()。
| 设计取舍 | 备选方案 | 最终选择 | 理由 |
|---|---|---|---|
| 枚举入库类型来源 | 注解上写 storeType 属性 | 从 ValueEnum<T> 泛型推断 | 泛型实参已是唯一事实源,再写一遍注解属性必然出现「声明 STRING 却实现 ValueEnum<Integer>」的不一致 |
| 普通枚举能否落库 | 宽松接受 | 强制实现 ValueEnum | 普通枚举的 name() 不稳定(改名即破库),强制 getValue() 给出稳定标量值 |
多选枚举的序列化方式由 @Field.Enum 的 multiSerialize 决定,落进 FieldDefinition.multiSerialize:MultiSerialize.COMMA(逗号分隔,默认)/ JSON / BITMASK。
宠物实体继承自 IdModel<T>,后者本身也是一个 @Model,类型为 ModelTypeEnum.ABSTRACT:
IdModel<T>:提供 id 主键、createTime / updateTime 审计字段,以及 save()(主键为 null 走 insert,否则 updateById)。CodeModel<T>:在 IdModel 之上加 code 唯一编码列,并以 @Model.Advanced(unique = "code") 声明唯一索引。RelationModel:M2M 中间表基类,宠物的 PetTag 继承它,从而与普通 STORE 模型享有一致的元数据、DDL、逻辑删除待遇。索引由 @Model.Advanced 的 index / unique 声明,解析为 IndexDefinition。其中 effectiveColumns(logicDelete, logicDeleteColumn) 体现了一个细节设计——唯一索引在模型启用逻辑删除时,会在末尾追加逻辑删除列:
让唯一性约束变成「未删数据内唯一」。软删一行后,该行的唯一值(如宠物分类 code)就能被新数据复用,避免「删了却还占着唯一坑位」。逻辑删除的整体语义见 DDL 设计篇。
ModelDefinition 解析完成后注册进模型注册中心,成为 DDL、CRUD 门面、关系读写、元数据落库的唯一事实源。注册与扫描机制见 模型注册中心 与 扫描机制;关系字段的运行时读填充与写维护链路另见关系设计篇。要动手定义自己的宠物实体,请回到 数据建模 · 快速开始。