Skip to content

揭秘关系字段查询如何实现

宠物商店里,一个 Pet 挂着分类、主人、档案与一堆标签,业务代码只想写 pet.getOwner().getName(),而不想关心外键、JOIN 或几条 SQL。本篇从设计视角拆解 forge 是怎么把这件事做成的:注解里声明的「关系元信息」如何在启动期被解析与校验,又如何在运行期被翻译成几条批量查询,最终就地填回对象图。

如果你只想知道怎么用,先看使用指南篇 关系字段的定义与查询,本篇解释「为什么这么设计」。

一、设计目标与总体取舍

forge 的关系字段有一条贯穿始终的主线:关系是应用层的概念,不是数据库的约束。Pet 表里只有 categoryIdownerIdprofileId 这些普通标量列,数据库层面不存在外键约束,更没有 JOINcategoryownerprofiletags 这些关系字段是虚拟字段,不落库,只在内存里被填充。

这条主线带来一组明确的设计取舍。

选项选择理由
DB 外键约束 vs 纯应用层纯应用层维护关系语义随模型演进灵活变更,不被数据库迁移与约束绑死;多数据源/分库场景下外键本就难以维系
SQL JOIN vs 分步批量 IN分步批量 IN 查询单条 JOIN 在多关系、深层级时膨胀难控;分步查询每步语义清晰、各自可缓存、便于按需填充
复合关联键 vs 单字段键仅支持单字段键当前版本约束,复合键直接 fail-fast 报错,避免半成品行为埋坑
关系列是否落库store(false) 一处开关贯穿全链一个布尔位即决定该字段不进 DDL、不进系统元数据、不参与读写 SQL,链路各环节统一判定

一处开关贯穿全链

关系字段在解析时被强制标记为不持久化(详见下文 store(false))。这个标记不是局部约定,而是被建表、系统元数据落库、读写 SQL 各环节共同读取的同一面旗子。理解了这一点,就理解了「虚拟字段」为什么能干净地不留痕迹。

二、用注解定义关系:@Field.Relation 承载什么元信息

关系的全部声明集中在两组注解上:四类标记注解 @Field.O2O / @Field.M2O / @Field.O2M / @Field.M2M 标明关系基数,@Field.Relation 携带连接所需的字段映射。

回顾宠物主角的关系声明:

java
// 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> 这类集合字段需显式指定
throughModelM2M 中间表模型类M2M 必填,如 PetTag.class
throughRelationFields中间表指向本模型的外键列M2M 必填,如 petId
throughReferenceFields中间表指向目标模型的外键列M2M 必填,如 tagId
onDelete / onUpdate级联删除/更新策略CascadeType.NONE,当前为占位声明,行为未实现

双向关系必须排除 equals/hashCode/toString

Pet.ownerOwner.pets 构成双向引用,填充后两侧互相持有对方。若不在关系字段上加 @EqualsAndHashCode.Exclude@ToString.ExcludeequalstoString 会沿环往复递归直至 StackOverflowError。这是声明阶段就必须遵守的硬约束。

外键标量列与关系字段是对称声明的:Pet 同时显式声明了 categoryId@Field.Long,落库)与 category@Field.M2O,不落库)。前者是数据库真实存在的列,后者是供业务读写的虚拟视图。relationFields = "categoryId" 正是把二者绑在一起的纽带。

三、启动扫描:从注解到「关系元信息就绪」

注解只是静态声明,真正可用的是解析后的 FieldDefinition。启动期要做三件事:解析单个字段的关系属性、强制标记不落库、跨模型校验并补全缺省。

3.1 解析时机与产物

模型解析由 ModelParser 负责,关系字段的属性填充集中在 fillRelationAttributes,产物是 FieldDefinition 上的一组关系属性:targetModelClassrelationFieldsreferenceFieldsthroughModelClassthroughRelationFieldsthroughReferenceFieldsonDeleteonUpdate

与此同时,关系字段被强制置为 store(false)。这个标记决定了它在后续全链路中的命运:

系统元数据落库时对关系字段的特殊处理印证了 store(false) 的贯穿性。SystemMetaManager 在构建 SysField 记录时,对关系字段直接把列类型留空:

java
// 关系字段为虚拟字段、无数据库列,列类型留空
String columnType = (field.getFieldType() != null && field.getFieldType().isRelation())
    ? null
    : dialect.mapColumnType(field);

3.2 跨模型校验与缺省补全

单个字段解析完,还需把视野放到全部模型之间做一致性校验,这一步对应 checkRelations。两件最关键的事:

其一,缺省补全 referenceFieldsPet.category 只写了 relationFields = "categoryId",没写 referenceFields,校验阶段会把它补全为 Category 的主键字段。这正是 M2O/O2O 最常见的写法能成立的原因——目标侧默认就是主键。

其二,单字段关联键约束。当前版本仅支持单字段关联键,一旦发现 relationFieldsreferenceFields 等任一角色是复合键,立即 fail-fast:

关系字段 {fieldName} 的 {role} 为复合键,当前版本暂未支持,请使用单字段关联键

为什么把校验放在启动期

forge 的一贯偏好是把隐式约束变成启动期主动抛出的异常,而非运行期才暴露。关系映射写错(目标模型缺失、复合键、中间表外键拼错)属于配置类错误,越早炸越好。关于包扫描与启动钩子的整体机制,见 启动期模型扫描与注册

四、运行期查询填充:listFieldQuery 如何分派

填充入口是门面方法 Models.listFieldQuery(entities, fieldNames)(单实体版 Models.fieldQuery)。不传 fieldNames 则填充全部关系字段,传了则只填指定字段。核心实现委托 DefaultRelationReadApi,按字段的关系类型分派到三条不同链路。

三条链路共用同一块基建 RelationSupport.queryByColumnIn,它把一批键值收敛成单条 IN 查询:

java
QueryWrapper<T> wrapper = new QueryWrapper<>();
wrapper.in(column, values);
return repository(target).queryList(wrapper);
// 生成 SQL:SELECT * FROM target_table WHERE column IN (val1, val2, ...)

4.1 防 N+1 的核心:批量 IN + 内存索引

设计上刻意避免「遍历实体逐条查目标」的 N+1 模式。无论列表里有 1 个还是 1000 个 PetfillToOne 填充 category 都只发一条 SQL:

  • fillToOne(M2O/O2O):先 getFieldValues 批量收集全部外键值,一条 IN 查回目标行,按被引用字段值建索引 Map<refValue, targetRow>,再遍历实体命中索引回写。
  • fillO2M(O2M):收集本模型被引用值,一条 IN 查回对端行,按对端外键值分组 Map<refValue, List<targetRows>>,遍历实体回写 List/Set 集合字段。

「先批量查、再内存索引、最后回写」是三条链路统一的骨架。SQL 条数与关系字段数挂钩,而与实体数量无关。

4.2 M2M 的三步查询

多对多没有本表外键可依,必须借道中间表 PetTagfillM2M 拆成三步:中间表 IN → 目标表 IN → 回填。

对应的 SQL 形态:

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, ...);

这意味着填充一批 Pettags,无论几只宠物、几个标签,固定只有两条 SQL(中间表一条、目标表一条),第三步纯内存回填。

关系字段读填充是显式动作

查询返回的实体默认只有标量列,categorytags 等关系字段为 null。必须显式调用 Models.listFieldQueryModels.fieldQuery 才会填充。这是有意为之——避免每次查询都隐式拉起整张对象图。具体用法见 关系字段的定义与查询

五、写维护为何与读填充对称又不同

读填充由 DefaultRelationReadApi 承担,写维护由 DefaultRelationWriteApi 承担,入口是 Models.saveWith(entity, fieldNames)。写侧的设计同样源自「纯应用层」这条主线,但因为涉及落库时机,它对关系类型的处理与读侧并不完全对称。

RelationWriteApi 的实现注释点明了分工:

纯应用层实现,本步覆盖两类存储维护:
  - M2O/O2O:applyToOne 从关系对象回填本表外键列(须在实体落库前调用)
  - M2M:syncM2M 按关系集合当前值与中间表现状做 diff,增删中间表行(须在实体落库后调用,依赖主键)
O2M 的存储维护落在「对端」模型的外键列上,不在本侧处理,故此处不涉及。

由此 saveWith 形成一个有先后顺序的三段编排:

三段顺序不可调换:M2O/O2O 必须在落库前回填外键(否则本表那一行缺列值),M2M 必须在落库后处理(否则没有主键去写中间表)。O2M 的外键在对端,本侧 saveWith 干脆不碰。

写维护的两个关键约束也体现了一致的「显式优先、拒绝歧义」立场:

  • M2O/O2O 支持「放对象」与「放 id」两种形态,二者并存且不一致时对象优先;若对象的被引用字段为 null 则 fail-fast 报「关系对象其被关联字段为 null」。
  • M2M 集合元素必须是对象(含仅填主键的「半对象」),不支持裸 Set<Long> 这类纯 id 集合——因为 M2M 没有本表外键标量列可作兜底。

写维护的完整用法(含 diff 增删中间表的代码示例)见使用指南篇 关系字段的定义与查询

六、设计小结

forge 的关系机制可以浓缩成几句话:

  • 关系是应用层概念,靠 relationFields / referenceFields 把虚拟关系字段与真实标量列绑定,数据库不设外键、查询不用 JOIN
  • store(false) 是贯穿建表、元数据、读写 SQL 的同一面旗子,让虚拟字段干净地不留痕迹。
  • 启动期 fail-fast:缺省补全 referenceFields 为主键,复合键直接报错,配置错误越早暴露越好。
  • 运行期统一「批量 IN + 内存索引/分组」防 N+1,SQL 条数与关系字段数挂钩、与实体数量无关;M2M 固定三步(中间表 IN → 目标 IN → 回填)。
  • 读填充与写维护按关系类型分派,写侧严格遵守「落库前回填外键、落库后维护中间表」的时序。
  • 级联删除/更新(onDelete / onUpdate)当前仅为占位声明,行为尚未实现。

想动手把这套机制用起来,移步 关系字段的定义与查询