Skip to content

关系字段 O2O/M2O/O2M/M2M

宠物商店里几乎每个实体都不是孤岛:宠物属于某个分类、归某位主人、配一份健康档案,还能贴上多个标签。forge 用 @Field.Relation 把这四类关系(O2O、M2O、O2M、M2M)声明成实体里的「虚拟字段」,再由 Models 门面统一负责预加载与保存维护。本篇带你把这套关系全部声明出来,并用一份可直接复制的代码跑通读、写两条链路。

你将得到什么

读完本篇,你能写出带四类关系的 PetOwner 实体,用 Models.fieldQuery / Models.listFieldQuery 预加载关联对象,用 Models.saveWith 一次性维护外键与中间表,并在控制台看到批量 IN 查询日志(证明没有 N+1)。关系处理的内部原理见 关系机制设计

四类关系一览

先建立直觉:关系字段本身不落库(store=false 的虚拟字段),真正落库的是与之对称的外键标量列或一张中间表。

关系注解宠物商店里的例子外键落在哪
多对一 M2O@Field.M2OPet.categoryCategory本表 categoryId
多对一 M2O@Field.M2OPet.ownerOwner本表 ownerId
一对一 O2O@Field.O2OPet.profilePetProfile本表 profileId
一对多 O2M@Field.O2MOwner.petsPet对端表 ownerId
多对多 M2M@Field.M2MPet.tagsTag中间表 PetTag

关系字段是虚拟字段,外键标量列要自己声明

M2O / O2O 把外键存在本表,所以你必须为每个关系显式声明一个外键标量列(如 categoryIdprofileId),并用 relationFields 把关系字段指向它。框架不会替你凭空造列——这是最常见的踩坑点,详见文末反例。

第一步:声明被引用的实体

四类关系里被指向的目标实体本身就是普通 @Model。先把 CategoryOwnerPetProfileTag 写出来。

java
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;
}
java
@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;
}
java
@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(外键在本表)

M2O 与 O2O 都属于 to-one:本表持有外键,关系对象通过 relationFields 关联到这个外键列。referenceFields 缺省时指向目标模型的主键,所以指向 CategoryOwnerPetProfile 的主键 id 时可以省略不写。

java
// 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 持有 OwnerOwner 又持有 List<Pet>@Data 生成的 equals / hashCode / toString 会沿双向引用无限递归。务必给所有关系字段加上 @EqualsAndHashCode.Exclude@ToString.Exclude

第三步:声明 O2M(外键在对端)

O2M 是「一对多」,外键不在本表——它落在对端的外键列上。Owner.petsreferenceFields 指向 Pet 侧的外键字段 ownerId

java
@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 的存储维护落在对端 PetownerId 列上,不在「一」端处理。也就是说,给宠物挂主人是通过设置 Pet.owner(M2O)或直接给 Pet.ownerId 赋值完成的;Owner.pets 只用于读填充。

第四步:声明 M2M(经中间表)

M2M 必须经过一张显式建模的中间表。中间表模型要 extends RelationModel,并自行声明指向两端的两个外键标量列。

java
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)的外键列。

java
@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 当中间表是建不出表的。

第五步:完整的 Pet 主类

把四类关系拼到一起,这就是宠物商店的主角:

java
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>,写法见 枚举字段(如另有枚举专篇请以其为准)。

java
public enum PetStatus implements ValueEnum<Integer> {
    ON_SALE(1, "在售"),
    SOLD(2, "已售"),
    OFF_SHELF(3, "下架");
    // value / displayName / helper 实现略
}

读:用 fieldQuery / listFieldQuery 预加载

查出实体后,关系字段默认是空的(虚拟字段不参与本表查询)。用 Models.fieldQuery 填单个实体、Models.listFieldQuery 填一个列表。两者都会就地填充并返回原对象。

java
// 单个实体:填充全部关系字段
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 时填充全部关系字段;传了就只填指定的几个。entitynull、列表为空时原样返回,无需自己判空。

验证结果:批量 IN 查询,而非 N+1

application.yml 打开 MyBatis-Plus 的 SQL 日志:

yaml
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 两条),而不是每条宠物各发一次:

text
==>  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 的关键。

写:用 saveWith 维护外键与中间表

Models.saveWith 把「回填外键 → 落库本表 → 维护中间表」三步串起来,有事务管理器时整体事务包裹。

java
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 的内部顺序是固定的,理解它能帮你排查问题:

  1. 回填 M2O / O2O 外键:从关系对象读被引用值,写入本表外键列(落库前)。
  2. 落库本表:执行 INSERT / UPDATE,并为新实体分配主键。
  3. 维护 M2M 中间表:按集合当前值与中间表现状做 diff,增删中间表行(落库后,依赖主键)。

M2O 的两种保存形态

你可以「放对象」——设置 pet.setCategory(category),框架从对象读被引用值回填 categoryId;也可以「放 id」——不设对象、直接 pet.setCategoryId(10L)。两者并存且不一致时对象优先。

验证结果:M2M diff 的中间表 SQL

给一只已有标签的宠物重新设置 tags 后调用 saveWith,控制台会先查中间表现状,再按差异删旧增新:

text
==>  Preparing: SELECT * FROM pet_tag WHERE pet_id = ?
==>  Preparing: INSERT INTO pet_tag ( pet_id, tag_id ) VALUES ( ?, ? )

tags 设为空集合(List.of())会清空该宠物的全部标签关联;设为 null 则表示「未设置」,中间表不变更——这点区别很重要。

常见反例与排查

反例 1:忘了声明外键标量列

现象:M2O / O2O 关系字段声明了,但建表后没有对应的 categoryId 列,saveWith 回填外键时也无处可写。 原因:关系字段是 store=false 的虚拟字段,本身不生成数据库列;外键标量列必须自己显式声明。 修复:为每个 to-one 关系补上对称的标量列(如 @Field.Long private Long categoryId;),并让 relationFields 指向它。

反例 2:双向 @Data 导致 StackOverflow

现象:调用 toString / 放进 HashSet / 比较实体时抛 StackOverflowError。 原因:Pet.ownerOwner.pets 双向引用,@Data 生成的 equals / hashCode / toString 沿引用无限递归。 修复:给所有关系字段加 @EqualsAndHashCode.Exclude@ToString.Exclude

反例 3:外键类型与主键类型不一致,填充静默失败

现象:fieldQuery 不报错,但关系对象始终是 null,怎么查都填不上。 原因:外键标量列用了 Integer,而目标主键是 Long,IN 查询按值建索引时类型对不上,命中失败却不会抛异常。 修复:外键标量列类型与目标被引用字段(通常是主键 idLong)保持完全一致,统一用 @Field.Long

反例 4:中间表没有 extends RelationModel

现象:M2M 启动期建不出中间表,或 saveWith 同步中间表时找不到主键 / 审计列。 原因:中间表当成普通 POJO 写,缺少 id 主键与审计字段,无法享受 STORE 模型的元数据与 DDL 待遇。 修复:让中间表 extends RelationModel,只额外声明两个外键标量列(如 petId / tagId)。

反例 5:M2M 集合放裸 id 而非对象

现象:pet.setTags(...)Set<Long> 之类的裸主键集合,saveWith 同步中间表时报错或无效。 原因:M2M 无外键标量列兜底,syncM2M 需要从集合元素读被引用字段值,元素必须是目标对象(或仅含被引用字段 / 主键的「半对象」)。 修复:先把 id 包装成对象集合(如设置每个 Tagid)再赋给 tags

反例 6:复合关联键暂不支持

现象:relationFields / referenceFields 写了多个字段,启动期抛「复合键……当前版本暂未支持,请使用单字段关联键」。 原因:当前版本关系字段仅支持单字段关联键。 修复:改用单字段关联键;确实需要复合键的场景暂以应用层逻辑替代。


想知道 fieldQuery 的批量 IN 是怎么聚合的、saveWith 的事务降级与 M2M diff 算法细节,以及级联策略(onDelete / onUpdate)为何当前是占位,请移步 关系机制设计。关于启动期包扫描如何发现 @Model 与中间表,见 构建与环境