搜索 K
Appearance
Appearance
宠物商店里,一个 Pet 挂着分类、主人、档案与一堆标签,业务代码只想写 pet.getOwner().getName(),而不想关心外键、JOIN 或几条 SQL。本篇从设计视角拆解 forge 是怎么把这件事做成的:注解里声明的「关系元信息」如何在启动期被解析与校验,又如何在运行期被翻译成几条批量查询,最终就地填回对象图。
如果你只想知道怎么用,先看使用指南篇 关系字段的定义与查询,本篇解释「为什么这么设计」。
forge 的关系字段有一条贯穿始终的主线:关系是应用层的概念,不是数据库的约束。Pet 表里只有 categoryId、ownerId、profileId 这些普通标量列,数据库层面不存在外键约束,更没有 JOIN。category、owner、profile、tags 这些关系字段是虚拟字段,不落库,只在内存里被填充。
这条主线带来一组明确的设计取舍。
| 选项 | 选择 | 理由 |
|---|---|---|
| DB 外键约束 vs 纯应用层 | 纯应用层维护 | 关系语义随模型演进灵活变更,不被数据库迁移与约束绑死;多数据源/分库场景下外键本就难以维系 |
SQL JOIN vs 分步批量 IN | 分步批量 IN 查询 | 单条 JOIN 在多关系、深层级时膨胀难控;分步查询每步语义清晰、各自可缓存、便于按需填充 |
| 复合关联键 vs 单字段键 | 仅支持单字段键 | 当前版本约束,复合键直接 fail-fast 报错,避免半成品行为埋坑 |
| 关系列是否落库 | store(false) 一处开关贯穿全链 | 一个布尔位即决定该字段不进 DDL、不进系统元数据、不参与读写 SQL,链路各环节统一判定 |
一处开关贯穿全链
关系字段在解析时被强制标记为不持久化(详见下文 store(false))。这个标记不是局部约定,而是被建表、系统元数据落库、读写 SQL 各环节共同读取的同一面旗子。理解了这一点,就理解了「虚拟字段」为什么能干净地不留痕迹。
关系的全部声明集中在两组注解上:四类标记注解 @Field.O2O / @Field.M2O / @Field.O2M / @Field.M2M 标明关系基数,@Field.Relation 携带连接所需的字段映射。
回顾宠物主角的关系声明:
// M2O:本表 categoryId 指向 Category 主键
@Field.M2O
@Field.Relation(relationFields = "categoryId")
@EqualsAndHashCode.Exclude @ToString.Exclude
private Category category;
// O2O:本表 profileId 指向 PetProfile 主键
@Field.O2O
@Field.Relation(relationFields = "profileId")
@EqualsAndHashCode.Exclude @ToString.Exclude
private PetProfile profile;
// M2M:通过 PetTag 中间表连接 Tag
@Field.M2M
@Field.Relation(throughModel = PetTag.class,
throughRelationFields = "petId",
throughReferenceFields = "tagId")
@EqualsAndHashCode.Exclude @ToString.Exclude
private List<Tag> tags;@Field.Relation 的全部配置项:
| 配置项 | 含义 | 缺省行为 |
|---|---|---|
relationFields | 本模型侧关联字段名 | M2O/O2O 多为本表外键列;O2M 为本模型被引用字段;M2M 为本模型主键 |
referenceFields | 目标模型侧被关联字段名 | 缺省补全为目标模型主键(见启动校验) |
targetModel | 目标模型类 | M2O/O2O 可由字段类型推断而省略;List<Pet> 这类集合字段需显式指定 |
throughModel | M2M 中间表模型类 | M2M 必填,如 PetTag.class |
throughRelationFields | 中间表指向本模型的外键列 | M2M 必填,如 petId |
throughReferenceFields | 中间表指向目标模型的外键列 | M2M 必填,如 tagId |
onDelete / onUpdate | 级联删除/更新策略 | CascadeType.NONE,当前为占位声明,行为未实现 |
双向关系必须排除 equals/hashCode/toString
Pet.owner 与 Owner.pets 构成双向引用,填充后两侧互相持有对方。若不在关系字段上加 @EqualsAndHashCode.Exclude 与 @ToString.Exclude,equals 或 toString 会沿环往复递归直至 StackOverflowError。这是声明阶段就必须遵守的硬约束。
外键标量列与关系字段是对称声明的:Pet 同时显式声明了 categoryId(@Field.Long,落库)与 category(@Field.M2O,不落库)。前者是数据库真实存在的列,后者是供业务读写的虚拟视图。relationFields = "categoryId" 正是把二者绑在一起的纽带。
注解只是静态声明,真正可用的是解析后的 FieldDefinition。启动期要做三件事:解析单个字段的关系属性、强制标记不落库、跨模型校验并补全缺省。
模型解析由 ModelParser 负责,关系字段的属性填充集中在 fillRelationAttributes,产物是 FieldDefinition 上的一组关系属性:targetModelClass、relationFields、referenceFields、throughModelClass、throughRelationFields、throughReferenceFields、onDelete、onUpdate。
与此同时,关系字段被强制置为 store(false)。这个标记决定了它在后续全链路中的命运:
系统元数据落库时对关系字段的特殊处理印证了 store(false) 的贯穿性。SystemMetaManager 在构建 SysField 记录时,对关系字段直接把列类型留空:
// 关系字段为虚拟字段、无数据库列,列类型留空
String columnType = (field.getFieldType() != null && field.getFieldType().isRelation())
? null
: dialect.mapColumnType(field);单个字段解析完,还需把视野放到全部模型之间做一致性校验,这一步对应 checkRelations。两件最关键的事:
其一,缺省补全 referenceFields。Pet.category 只写了 relationFields = "categoryId",没写 referenceFields,校验阶段会把它补全为 Category 的主键字段。这正是 M2O/O2O 最常见的写法能成立的原因——目标侧默认就是主键。
其二,单字段关联键约束。当前版本仅支持单字段关联键,一旦发现 relationFields、referenceFields 等任一角色是复合键,立即 fail-fast:
关系字段 {fieldName} 的 {role} 为复合键,当前版本暂未支持,请使用单字段关联键为什么把校验放在启动期
forge 的一贯偏好是把隐式约束变成启动期主动抛出的异常,而非运行期才暴露。关系映射写错(目标模型缺失、复合键、中间表外键拼错)属于配置类错误,越早炸越好。关于包扫描与启动钩子的整体机制,见 启动期模型扫描与注册。
填充入口是门面方法 Models.listFieldQuery(entities, fieldNames)(单实体版 Models.fieldQuery)。不传 fieldNames 则填充全部关系字段,传了则只填指定字段。核心实现委托 DefaultRelationReadApi,按字段的关系类型分派到三条不同链路。
三条链路共用同一块基建 RelationSupport.queryByColumnIn,它把一批键值收敛成单条 IN 查询:
QueryWrapper<T> wrapper = new QueryWrapper<>();
wrapper.in(column, values);
return repository(target).queryList(wrapper);
// 生成 SQL:SELECT * FROM target_table WHERE column IN (val1, val2, ...)设计上刻意避免「遍历实体逐条查目标」的 N+1 模式。无论列表里有 1 个还是 1000 个 Pet,fillToOne 填充 category 都只发一条 SQL:
fillToOne(M2O/O2O):先 getFieldValues 批量收集全部外键值,一条 IN 查回目标行,按被引用字段值建索引 Map<refValue, targetRow>,再遍历实体命中索引回写。fillO2M(O2M):收集本模型被引用值,一条 IN 查回对端行,按对端外键值分组 Map<refValue, List<targetRows>>,遍历实体回写 List/Set 集合字段。「先批量查、再内存索引、最后回写」是三条链路统一的骨架。SQL 条数与关系字段数挂钩,而与实体数量无关。
多对多没有本表外键可依,必须借道中间表 PetTag。fillM2M 拆成三步:中间表 IN → 目标表 IN → 回填。
对应的 SQL 形态:
-- 步骤 1:中间表 IN 查询(收集本模型主键值后)
SELECT * FROM through_table WHERE through_rel_column IN (id1, id2, ...);
-- 步骤 2:从中间表结果汇总全部目标键,目标表 IN 查询
SELECT * FROM target_table WHERE ref_column IN (tk1, tk2, ...);这意味着填充一批 Pet 的 tags,无论几只宠物、几个标签,固定只有两条 SQL(中间表一条、目标表一条),第三步纯内存回填。
关系字段读填充是显式动作
查询返回的实体默认只有标量列,category、tags 等关系字段为 null。必须显式调用 Models.listFieldQuery 或 Models.fieldQuery 才会填充。这是有意为之——避免每次查询都隐式拉起整张对象图。具体用法见 关系字段的定义与查询。
读填充由 DefaultRelationReadApi 承担,写维护由 DefaultRelationWriteApi 承担,入口是 Models.saveWith(entity, fieldNames)。写侧的设计同样源自「纯应用层」这条主线,但因为涉及落库时机,它对关系类型的处理与读侧并不完全对称。
RelationWriteApi 的实现注释点明了分工:
纯应用层实现,本步覆盖两类存储维护:
- M2O/O2O:applyToOne 从关系对象回填本表外键列(须在实体落库前调用)
- M2M:syncM2M 按关系集合当前值与中间表现状做 diff,增删中间表行(须在实体落库后调用,依赖主键)
O2M 的存储维护落在「对端」模型的外键列上,不在本侧处理,故此处不涉及。由此 saveWith 形成一个有先后顺序的三段编排:
三段顺序不可调换:M2O/O2O 必须在落库前回填外键(否则本表那一行缺列值),M2M 必须在落库后处理(否则没有主键去写中间表)。O2M 的外键在对端,本侧 saveWith 干脆不碰。
写维护的两个关键约束也体现了一致的「显式优先、拒绝歧义」立场:
null 则 fail-fast 报「关系对象其被关联字段为 null」。Set<Long> 这类纯 id 集合——因为 M2M 没有本表外键标量列可作兜底。写维护的完整用法(含 diff 增删中间表的代码示例)见使用指南篇 关系字段的定义与查询。
forge 的关系机制可以浓缩成几句话:
relationFields / referenceFields 把虚拟关系字段与真实标量列绑定,数据库不设外键、查询不用 JOIN。store(false) 是贯穿建表、元数据、读写 SQL 的同一面旗子,让虚拟字段干净地不留痕迹。referenceFields 为主键,复合键直接报错,配置错误越早暴露越好。onDelete / onUpdate)当前仅为占位声明,行为尚未实现。想动手把这套机制用起来,移步 关系字段的定义与查询。