搜索 K
Appearance
Appearance
宠物商店里几乎每个实体都不是孤岛:宠物属于某个分类、归某位主人、配一份健康档案,还能贴上多个标签。forge 用 @Field.Relation 把这四类关系(O2O、M2O、O2M、M2M)声明成实体里的「虚拟字段」,再由 Models 门面统一负责预加载与保存维护。本篇带你把这套关系全部声明出来,并用一份可直接复制的代码跑通读、写两条链路。
你将得到什么
读完本篇,你能写出带四类关系的 Pet、Owner 实体,用 Models.fieldQuery / Models.listFieldQuery 预加载关联对象,用 Models.saveWith 一次性维护外键与中间表,并在控制台看到批量 IN 查询日志(证明没有 N+1)。关系处理的内部原理见 关系机制设计。
先建立直觉:关系字段本身不落库(store=false 的虚拟字段),真正落库的是与之对称的外键标量列或一张中间表。
| 关系 | 注解 | 宠物商店里的例子 | 外键落在哪 |
|---|---|---|---|
| 多对一 M2O | @Field.M2O | Pet.category → Category | 本表 categoryId |
| 多对一 M2O | @Field.M2O | Pet.owner → Owner | 本表 ownerId |
| 一对一 O2O | @Field.O2O | Pet.profile → PetProfile | 本表 profileId |
| 一对多 O2M | @Field.O2M | Owner.pets → Pet | 对端表 ownerId |
| 多对多 M2M | @Field.M2M | Pet.tags ↔ Tag | 中间表 PetTag |
关系字段是虚拟字段,外键标量列要自己声明
M2O / O2O 把外键存在本表,所以你必须为每个关系显式声明一个外键标量列(如 categoryId、profileId),并用 relationFields 把关系字段指向它。框架不会替你凭空造列——这是最常见的踩坑点,详见文末反例。
四类关系里被指向的目标实体本身就是普通 @Model。先把 Category、Owner、PetProfile、Tag 写出来。
package com.demo.petshop.model;
import cn.cvking.forge.metadata.annotation.Field;
import cn.cvking.forge.metadata.annotation.Model;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@Model(name = "pet.Category", displayName = "分类")
public class Category extends IdModel<Category> {
@Field(displayName = "名称")
@Field.String(length = 64)
private String name;
@Field(displayName = "编码")
@Field.String(length = 64)
private String code;
}@Data
@EqualsAndHashCode(callSuper = true)
@Model(name = "pet.PetProfile", displayName = "宠物档案")
public class PetProfile extends IdModel<PetProfile> {
@Field(displayName = "疫苗")
@Field.String(length = 128)
private String vaccine;
@Field(displayName = "体重")
@Field.BigDecimal(precision = 10, scale = 2)
private BigDecimal weight;
}@Data
@EqualsAndHashCode(callSuper = true)
@Model(name = "pet.Tag", displayName = "标签")
public class Tag extends IdModel<Tag> {
@Field(displayName = "名称")
@Field.String(length = 32)
private String name;
}Owner 是 O2M 的「一」端,关系字段在它身上声明,下文「声明 O2M」一节给出完整写法。
M2O 与 O2O 都属于 to-one:本表持有外键,关系对象通过 relationFields 关联到这个外键列。referenceFields 缺省时指向目标模型的主键,所以指向 Category、Owner、PetProfile 的主键 id 时可以省略不写。
// Pet 片段:M2O 指向分类、主人;O2O 指向档案
// 外键标量列:显式声明,与关系字段对称
@Field(displayName = "分类ID")
@Field.Long
private Long categoryId;
@Field(displayName = "主人ID")
@Field.Long
private Long ownerId;
@Field(displayName = "档案ID")
@Field.Long
private Long profileId;
// 关系字段(虚拟,store=false 不落库)
@Field.M2O
@Field.Relation(relationFields = "categoryId")
@EqualsAndHashCode.Exclude @ToString.Exclude
private Category category;
@Field.M2O
@Field.Relation(relationFields = "ownerId")
@EqualsAndHashCode.Exclude @ToString.Exclude
private Owner owner;
@Field.O2O
@Field.Relation(relationFields = "profileId")
@EqualsAndHashCode.Exclude @ToString.Exclude
private PetProfile profile;双向关系必须 Exclude,否则 StackOverflow
Pet 持有 Owner,Owner 又持有 List<Pet>,@Data 生成的 equals / hashCode / toString 会沿双向引用无限递归。务必给所有关系字段加上 @EqualsAndHashCode.Exclude 与 @ToString.Exclude。
O2M 是「一对多」,外键不在本表——它落在对端的外键列上。Owner.pets 的 referenceFields 指向 Pet 侧的外键字段 ownerId。
@Data
@EqualsAndHashCode(callSuper = true)
@Model(name = "pet.Owner", displayName = "主人")
public class Owner extends IdModel<Owner> {
@Field(displayName = "名称")
@Field.String(length = 64)
private String name;
@Field(displayName = "电话")
@Field.String(length = 32)
private String phone;
// O2M:本端被引用(主键),对端 Pet 持有外键 ownerId
@Field.O2M
@Field.Relation(referenceFields = "ownerId")
@EqualsAndHashCode.Exclude @ToString.Exclude
private List<Pet> pets;
}为什么 O2M 不需要在 Owner 这边维护外键
O2M 的存储维护落在对端 Pet 的 ownerId 列上,不在「一」端处理。也就是说,给宠物挂主人是通过设置 Pet.owner(M2O)或直接给 Pet.ownerId 赋值完成的;Owner.pets 只用于读填充。
M2M 必须经过一张显式建模的中间表。中间表模型要 extends RelationModel,并自行声明指向两端的两个外键标量列。
package com.demo.petshop.model;
import cn.cvking.forge.metadata.annotation.Field;
import cn.cvking.forge.metadata.annotation.Model;
import cn.cvking.forge.data.model.RelationModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@Model(name = "pet.PetTag", displayName = "宠物标签关联")
public class PetTag extends RelationModel {
@Field(displayName = "宠物ID")
@Field.Long
private Long petId;
@Field(displayName = "标签ID")
@Field.Long
private Long tagId;
}中间表声明好后,在 Pet 上声明 M2M 关系字段,用 throughModel 指中间表、throughRelationFields 指中间表里指向本模型(Pet)的外键列、throughReferenceFields 指中间表里指向目标模型(Tag)的外键列。
@Field.M2M
@Field.Relation(throughModel = PetTag.class,
throughRelationFields = "petId",
throughReferenceFields = "tagId")
@EqualsAndHashCode.Exclude @ToString.Exclude
private List<Tag> tags;中间表为什么要 extends RelationModel
继承 RelationModel 让中间表获得 id 主键与 createTime / updateTime 审计字段,并复用既有的逻辑删除、审计填充能力,使中间表与普通 STORE 模型享有一致的元数据、DDL 与存储待遇。普通 POJO 当中间表是建不出表的。
把四类关系拼到一起,这就是宠物商店的主角:
package com.demo.petshop.model;
import cn.cvking.forge.metadata.annotation.Field;
import cn.cvking.forge.metadata.annotation.Model;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import java.math.BigDecimal;
import java.util.List;
@Data
@EqualsAndHashCode(callSuper = true)
@Model(name = "pet.Pet", displayName = "宠物")
public class Pet extends IdModel<Pet> {
@Field(displayName = "名称")
@Field.String(length = 64)
private String name;
@Field(displayName = "状态")
@Field.Enum
private PetStatus status;
@Field(displayName = "售价")
@Field.BigDecimal(precision = 10, scale = 2)
private BigDecimal price;
// 外键标量列:显式声明,与关系字段对称
@Field(displayName = "分类ID")
@Field.Long
private Long categoryId;
@Field(displayName = "主人ID")
@Field.Long
private Long ownerId;
@Field(displayName = "档案ID")
@Field.Long
private Long profileId;
// 关系字段(虚拟,store=false 不落库)
@Field.M2O
@Field.Relation(relationFields = "categoryId")
@EqualsAndHashCode.Exclude @ToString.Exclude
private Category category;
@Field.M2O
@Field.Relation(relationFields = "ownerId")
@EqualsAndHashCode.Exclude @ToString.Exclude
private Owner owner;
@Field.O2O
@Field.Relation(relationFields = "profileId")
@EqualsAndHashCode.Exclude @ToString.Exclude
private PetProfile profile;
@Field.M2M
@Field.Relation(throughModel = PetTag.class,
throughRelationFields = "petId",
throughReferenceFields = "tagId")
@EqualsAndHashCode.Exclude @ToString.Exclude
private List<Tag> tags;
}PetStatus 是落库枚举,须实现 ValueEnum<Integer>,写法见 枚举字段(如另有枚举专篇请以其为准)。
public enum PetStatus implements ValueEnum<Integer> {
ON_SALE(1, "在售"),
SOLD(2, "已售"),
OFF_SHELF(3, "下架");
// value / displayName / helper 实现略
}查出实体后,关系字段默认是空的(虚拟字段不参与本表查询)。用 Models.fieldQuery 填单个实体、Models.listFieldQuery 填一个列表。两者都会就地填充并返回原对象。
// 单个实体:填充全部关系字段
Pet pet = Models.origin(Pet.class).queryById(1L);
Models.fieldQuery(pet); // category/owner/profile/tags 全部填充
// 只填指定字段
Models.fieldQuery(pet, "category", "tags");
// 列表:一次性为所有元素预加载,避免 N+1
List<Pet> pets = Models.origin(Pet.class).queryList();
Models.listFieldQuery(pets, "category", "owner", "tags");不传字段名 = 填充全部关系字段
fieldQuery(pet) 与 listFieldQuery(pets) 不带 fieldNames 时填充全部关系字段;传了就只填指定的几个。entity 为 null、列表为空时原样返回,无需自己判空。
在 application.yml 打开 MyBatis-Plus 的 SQL 日志:
logging:
level:
com.demo.petshop: debug
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl对一个 3 条宠物的列表调用 listFieldQuery(pets, "category", "owner", "tags"),控制台打印的是按字段聚合的批量 IN 查询——每类关系一条(M2M 两条),而不是每条宠物各发一次:
==> Preparing: SELECT * FROM category WHERE id IN ( ? , ? , ? )
==> Preparing: SELECT * FROM owner WHERE id IN ( ? , ? )
==> Preparing: SELECT * FROM pet_tag WHERE pet_id IN ( ? , ? , ? )
==> Preparing: SELECT * FROM tag WHERE id IN ( ? , ? , ? , ? )M2M 的 tags 走两步:先按宠物主键查中间表 pet_tag,再用收集到的 tagId 批量查 tag 表。无论列表多长,每类关系的查询条数都是固定的,这就是防 N+1 的关键。
Models.saveWith 把「回填外键 → 落库本表 → 维护中间表」三步串起来,有事务管理器时整体事务包裹。
Category category = Models.origin(Category.class).queryById(10L);
Owner owner = Models.origin(Owner.class).queryById(20L);
Tag t1 = Models.origin(Tag.class).queryById(100L);
Tag t2 = Models.origin(Tag.class).queryById(101L);
Pet pet = new Pet();
pet.setName("旺财");
pet.setStatus(PetStatus.ON_SALE);
pet.setPrice(new BigDecimal("1999.00"));
pet.setCategory(category); // M2O:放对象,外键自动回填
pet.setOwner(owner); // M2O:放对象
pet.setTags(List.of(t1, t2)); // M2M:集合元素必须是对象
// 维护全部关系字段:回填 categoryId/ownerId → INSERT pet → 同步 pet_tag
Models.saveWith(pet);
// 或只维护指定关系:Models.saveWith(pet, "category", "tags");saveWith 的内部顺序是固定的,理解它能帮你排查问题:
INSERT / UPDATE,并为新实体分配主键。M2O 的两种保存形态
你可以「放对象」——设置 pet.setCategory(category),框架从对象读被引用值回填 categoryId;也可以「放 id」——不设对象、直接 pet.setCategoryId(10L)。两者并存且不一致时对象优先。
给一只已有标签的宠物重新设置 tags 后调用 saveWith,控制台会先查中间表现状,再按差异删旧增新:
==> Preparing: SELECT * FROM pet_tag WHERE pet_id = ?
==> Preparing: INSERT INTO pet_tag ( pet_id, tag_id ) VALUES ( ?, ? )把 tags 设为空集合(List.of())会清空该宠物的全部标签关联;设为 null 则表示「未设置」,中间表不变更——这点区别很重要。
现象:M2O / O2O 关系字段声明了,但建表后没有对应的 categoryId 列,saveWith 回填外键时也无处可写。 原因:关系字段是 store=false 的虚拟字段,本身不生成数据库列;外键标量列必须自己显式声明。 修复:为每个 to-one 关系补上对称的标量列(如 @Field.Long private Long categoryId;),并让 relationFields 指向它。
现象:调用 toString / 放进 HashSet / 比较实体时抛 StackOverflowError。 原因:Pet.owner 与 Owner.pets 双向引用,@Data 生成的 equals / hashCode / toString 沿引用无限递归。 修复:给所有关系字段加 @EqualsAndHashCode.Exclude 与 @ToString.Exclude。
现象:fieldQuery 不报错,但关系对象始终是 null,怎么查都填不上。 原因:外键标量列用了 Integer,而目标主键是 Long,IN 查询按值建索引时类型对不上,命中失败却不会抛异常。 修复:外键标量列类型与目标被引用字段(通常是主键 id,Long)保持完全一致,统一用 @Field.Long。
现象:M2M 启动期建不出中间表,或 saveWith 同步中间表时找不到主键 / 审计列。 原因:中间表当成普通 POJO 写,缺少 id 主键与审计字段,无法享受 STORE 模型的元数据与 DDL 待遇。 修复:让中间表 extends RelationModel,只额外声明两个外键标量列(如 petId / tagId)。
现象:pet.setTags(...) 用 Set<Long> 之类的裸主键集合,saveWith 同步中间表时报错或无效。 原因:M2M 无外键标量列兜底,syncM2M 需要从集合元素读被引用字段值,元素必须是目标对象(或仅含被引用字段 / 主键的「半对象」)。 修复:先把 id 包装成对象集合(如设置每个 Tag 的 id)再赋给 tags。
现象:relationFields / referenceFields 写了多个字段,启动期抛「复合键……当前版本暂未支持,请使用单字段关联键」。 原因:当前版本关系字段仅支持单字段关联键。 修复:改用单字段关联键;确实需要复合键的场景暂以应用层逻辑替代。
想知道 fieldQuery 的批量 IN 是怎么聚合的、saveWith 的事务降级与 M2M diff 算法细节,以及级联策略(onDelete / onUpdate)为何当前是占位,请移步 关系机制设计。关于启动期包扫描如何发现 @Model 与中间表,见 构建与环境。