搜索 K
Appearance
Appearance
读填充把数据「拉进来」,写维护则把内存里的关系对象图「落回去」。本篇解释 Models.saveWith 为什么拆成三步、事务边界画在哪里、为什么对象优先于裸 id,以及级联 onDelete / onUpdate 为何当前只占位不实现。需要可复制的代码请转 关系写维护使用指南。
关系字段在 Pet 上是虚拟的(store=false),数据库里并没有 category 这一列,真正落库的是 categoryId 这类外键标量列。当业务代码写下 pet.setOwner(owner) 时,框架必须在某个时刻把 owner 对象身上的被引用值搬到 pet.ownerId 上;而 M2M 的 tags 集合更没有任何标量列可落,只能去维护中间表 PetTag 的行。
这件事如果交给业务代码手写,每次保存都要记得「先回填外键、再存主表、最后同步中间表」,顺序错一步就会脏数据。写维护层(RelationWriteApi)把这套固定编排收敛成一个入口 Models.saveWith,业务只需要把关系对象挂在实体上。
TIP
写维护是纯应用层实现,不依赖数据库外键约束、不依赖触发器,全部靠 IN 查询与 diff 完成。这意味着它对运行时数据库中立——同一套逻辑在不同方言下行为一致。
Models.saveWith(entity, fieldNames) 的内部实现是 doSaveWith,固定三步(Models.java:150-166):
// doSaveWith 内部顺序
write.applyToOne(entity, fieldNames); // 1. 落库前:回填 M2O/O2O 外键
of(entity).save(); // 2. 落本表:INSERT/UPDATE 得主键
write.syncM2M(entity, fieldNames); // 3. 落库后:按 diff 增删中间表行三步的先后不是随意排列,而是被数据依赖锁死的:
| 步骤 | 时机 | 为什么必须在这个位置 |
|---|---|---|
applyToOne | 本表落库前 | 它要写的是本表的外键列(如 ownerId),这些列必须在 INSERT 语句拼出来之前就被赋值,否则落库时外键为空 |
save | 中间 | 只有本表落库后,自增主键 id 才被分配;M2M 中间表的本端外键依赖这个主键 |
syncM2M | 本表落库后 | 中间表行需要本模型主键作为 petId,主键在 save 之前尚不存在 |
这条「前-本-后」的依赖链正是 RelationWriteApi 注释里写明的分工(RelationWriteApi.java:5-11):
M2O/O2O:applyToOne 从关系对象回填本表外键列(须在实体落库前调用);M2M:syncM2M 按关系集合当前值与中间表现状做 diff,增删中间表行(须在实体落库后调用,依赖主键)。
而 O2M(如 Owner.pets)的存储维护落在「对端」Pet 的 ownerId 列上,不在 Owner 这一侧处理,因此 saveWith 对 O2M 不做任何写动作。
三步分别操作不同的表(本表 + 中间表),任何一步失败而前面已落库,都会留下脏数据:外键回填了主表却 INSERT 失败、或主表存了但中间表同步到一半异常。
saveWith 的解决方式是把 doSaveWith 整体塞进 TransactionTemplate.execute(Models.java:150-166):
// 有事务管理器:事务包裹
return tx.execute(status -> doSaveWith(entity, fieldNames));
// 无事务管理器:降级为非事务顺序执行
return doSaveWith(entity, fieldNames);WARNING
没有配置事务管理器时,saveWith 降级为非事务顺序执行——三步仍按序跑,但中途失败不会回滚。生产环境务必确保 Spring 事务管理器存在,否则 M2M diff 出错可能留下半同步的中间表。
@Transactional | 选项 | 选择 | 理由 |
|---|---|---|
声明式 @Transactional | 否 | Models 是静态门面,方法非 Spring Bean 代理对象,注解事务不生效 |
编程式 TransactionTemplate | 是 | 静态门面里能显式拿事务模板包裹回调,事务边界清晰且可控 |
| 不要事务、靠调用方自己保证 | 否 | 三步天然原子,把原子性责任推给业务等于把坑留给每个调用点 |
降级路径(无事务管理器时直接执行)保证了「没装事务」的轻量场景仍可用,只是放弃原子性保证——这是一个明确的、有日志可循的退化,而非静默失败。
M2O 与 M2O 的回填看似简单,难点在于「实体上既挂了关系对象、又直接给外键列赋了值」时听谁的。RelationWriteApi 给出的语义是对象优先(RelationWriteApi.java:15-19):
放对象:从对象读被引用值回填外键列;放 id:不设对象、直接给外键标量列赋值,本方法跳过;二者并存且不一致时对象优先。
这里有两个关键决策:
第一,两种保存形态并存。业务可以 pet.setOwner(ownerObj)(放对象),也可以 pet.setOwnerId(5L)(放 id)。放对象时框架从对象身上读被引用值回填;放 id 时关系字段为 null,applyToOne 直接跳过,外键保留调用方赋的值。半对象(仅填了被引用字段或主键的对象)也算放对象,照样能回填。
第二,外键为 null 时 fail-fast。如果挂了关系对象,但对象的被引用字段(通常是主键)是 null,框架不会静默把 null 写进外键,而是立刻抛异常「关系对象其被关联字段为 null」。
| 选项 | 选择 | 理由 |
|---|---|---|
| 静默把 null 写进外键 | 否 | 等于悄悄断开关系,数据脏了也没人知道 |
| 跳过、保留外键原值 | 否 | 调用方明明设了对象,期望是「以对象为准」,跳过会让对象设置无声失效 |
| fail-fast 抛异常 | 是 | 设了对象却取不到被引用值,多半是没存对端就来存本端,早抛早暴露 |
TIP
M2O/O2O 之所以敢 fail-fast,是因为它们有外键标量列可作兜底——真要「不绑关系」,业务直接用放 id 形态、不挂对象即可。这与 M2M 形成对照(见下文)。
M2M 没有外键标量列,tags 集合的状态只能映射到中间表 PetTag 的行集合。最朴素的做法是「删光本 owner 的全部中间行、再按集合全量插入」,但这会让无变化的关联也经历一次删除+插入,既浪费又会打断审计字段。
syncM2M 选择 diff(DefaultRelationWriteApi.java:87-154):查出中间表现状,与期望集合做差集,只删多余、只插缺失。
集合的三种状态语义不同,必须区分:
null:表示「这次不碰 M2M」,保持中间表原样。| 选项 | 选择 | 理由 |
|---|---|---|
接受 Set<Long> 裸 id 集合 | 否 | M2M 无外键标量列兜底,纯 id 集合在「放对象/放 id」二元里没有 id 落点 |
| 要求元素为目标对象或半对象 | 是 | 统一从对象读被引用值,与 to-one 的对象优先语义一致 |
RelationWriteApi.java:28-32 的注释把这点写得很直接:
集合元素须为目标对象(完整对象,或仅含被引用字段/主键的「半对象」),不支持裸主键值集合(如
Set<Long>);M2M 无外键标量列兜底,纯 id 集合须先包装为对象集合再保存。
此外 syncM2M 要求本模型主键非空(pet.id 必须已分配),否则抛「M2M 需主键非空」——这正是它被排在 save 之后的原因。中间表行的删除走 deleteByIds,因此当 PetTag(extends RelationModel)启用逻辑删除时,「删多余关联」实际是逻辑删除而非物理 DELETE。
@Field.Relation 上有 onDelete 与 onUpdate 两个 CascadeType 参数(取值 NONE / SET_NULL / CASCADE / RESTRICT),但当前版本仅声明不实现(Field.java:176-177, 181-183):
级联删除策略(fixme 占位,当前版本仅声明不实现行为)。
这是一个有意识的「先建模、后实现」决策,而非遗漏。原因有三:
第一,写入路径已闭环,删除级联是另一条链路。本篇的 saveWith 解决的是保存时的关系落库;级联删除发生在删除时,需要沿关系图反向遍历、判断 to-one/to-many 的删除传播方向,复杂度与保存维护不在一个量级。把它和保存维护混在一起会让 saveWith 失去内聚。
第二,语义先固化在注解里。onDelete/onUpdate 先以 CascadeType 落进 FieldDefinition,意味着模型作者现在就能声明意图,元数据也会记录下来;等行为实现补齐时,已有声明无需改动即可生效。这比「等实现好了再加注解」更平滑。
第三,避免给出半成品的危险行为。删除级联一旦实现不完整(比如只删一层、漏判 RESTRICT),会造成数据丢失这类不可逆后果。占位但不执行,比「执行了但不对」安全得多。
WARNING
当前若在 @Field.Relation 上配置 onDelete = CascadeType.CASCADE,框架不会真的级联删除——它只是被记录进元数据。删除关联数据仍需业务代码显式处理。
写维护与读填充(见 关系读填充设计)在结构上是镜像的:读填充按 relationFields → referenceFields 的方向做 IN 查询把对象拉进来,写维护按反方向把对象的被引用值回填到外键、或同步进中间表。两者都遵守同一条约束——当前版本仅支持单字段关联键,复合键直接 fail-fast(DefaultRelationWriteApi.java:20-21):
关系字段 {fieldName} 的 {role} 为复合键,当前版本暂未支持,请使用单字段关联键。
把读填充和写维护放在同一套 relationFields / referenceFields / throughRelationFields / throughReferenceFields 元数据上,保证了「怎么读进来就怎么写回去」,避免两条路径各自解释关系定义而漂移。
saveWith 三步「前-本-后」由数据依赖锁死:applyToOne 必须在主表落库前回填外键,syncM2M 必须在主表落库后(拿到主键)维护中间表。TransactionTemplate 包裹保证原子性;无事务管理器时降级非事务执行,放弃原子性但仍可用。onDelete / onUpdate 仅占位,语义先固化进元数据,行为留待后续实现。