JPA和Hibernate - 多对多关系的优雅实现
2025-01-22 08:19:30    1.8k 字   
This post is also available in English and alternative languages.

之前都是使用 MyBatis 方式对数据进行持久化。现在项目中是通过注解方式(JPA/Hibernate) 进行对象关系映射,涉及 一对多多对多

以下通过一个实际案例,剖析如何使用 JPA/Hibernate 优雅地实现多对多关系。


1. Demo

案例涉及三个主要实体类:ShopAreaShopAreaCategoryShopAreaCategoryShopArea。这些类分别代表商店区域、商店区域类别以及它们之间的关联。

1.1. Entity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
@Data
@Entity
@EntityListeners(AuditingEntityListener.class)
@GenericGenerator(name = "entityIdGenerator", strategy = "com.yxcheng.example22.util.EntityIdGenerator")
public class ShopArea implements AbstractEntity {
/**
* 主键id
*/
@GeneratedValue(generator = "entityIdGenerator")
@Id
private Long id;
private Integer companyId;
/**
* 商店区域名称
*/
private String name;
/**
* 创建时间
*/
@CreationTimestamp
private Date createTime;
/**
* 更新时间
*/
@UpdateTimestamp
private Date updateTime;

@ManyToMany(fetch = FetchType.EAGER)
@Fetch(FetchMode.SUBSELECT)
@JoinTable(name = "shop_area_category_shop_area",
joinColumns = {@JoinColumn(name = "shopAreaId", referencedColumnName = "id", insertable = false, updatable = false)},
inverseJoinColumns = {@JoinColumn(name = "categoryId", referencedColumnName = "id", insertable = false,updatable = false)})
private List<ShopAreaCategory> shopAreaCategories;
}

@Data
@Entity
@Accessors(chain = true)
@EntityListeners(AuditingEntityListener.class)
@GenericGenerator(name = "entityIdGenerator", strategy = "com.yxcheng.example22.util.EntityIdGenerator")
public class ShopAreaCategory implements AbstractEntity {
@javax.persistence.Id
@GeneratedValue(generator = "entityIdGenerator")
private Long id;
/**
* 公司id
*/
private Integer companyId;
/**
* 商店区域分类名称
*/
private String name;
@CreatedDate
private Date createTime;
@LastModifiedDate
private Date updateTime;
}

@Data
@Entity
@IdClass(ShopAreaCategoryShopArea.Id.class)
public class ShopAreaCategoryShopArea {
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class Id implements Serializable {
private static final long serialVersionUID = 1L;
private Long categoryId;
private Long shopAreaId;
}
/**
* 分类ID
*
* @see ShopAreaCategory
*/
@javax.persistence.Id
private Long categoryId;
/**
* 商店区域id
*
* @see ShopArea
*/
@javax.persistence.Id
private Long shopAreaId;
}

1.2. Dao

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@Repository
public class ShopAreaCategoryDao {
@Resource
private JPAQueryFactory jpaQueryFactory;
@Resource
private EntityManager entityManager;

QShopAreaCategory qShopAreaCategory = QShopAreaCategory.shopAreaCategory;

public ShopAreaCategory save(ShopAreaCategory shopAreaCategory) {
entityManager.persist(shopAreaCategory);
return shopAreaCategory;
}

public List<ShopAreaCategory> findByCompanyId(Integer companyId) {
return jpaQueryFactory.selectFrom(qShopAreaCategory).where(qShopAreaCategory.companyId.eq(companyId)).fetch();
}
}

@Repository
public class ShopAreaDao {
@Autowired
private JPAQueryFactory jpaQueryFactory;
@Autowired
private EntityManager entityManager;

QShopArea qShopArea = QShopArea.shopArea;

public ShopArea save(ShopArea shopArea) {
entityManager.persist(shopArea);
return shopArea;
}

public List<ShopArea> findByCompanyId(Integer companyId) {
return jpaQueryFactory.selectFrom(qShopArea)
.where(qShopArea.companyId.eq(companyId))
.fetch();
}

public ShopArea findByCompanyIdAndName(Integer companyId, String name) {
return jpaQueryFactory.selectFrom(qShopArea)
.where(qShopArea.companyId.eq(companyId))
.where(qShopArea.name.eq(name)).fetchFirst();
}
}

1.3. Service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Slf4j
@Service
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED)
public class ShopAreaService {
@Resource
private ShopAreaDao shopAreaDao;

public ShopArea save(ShopAreaSaveDTO reqDTO) {
ShopArea existShopArea = shopAreaDao.findByCompanyIdAndName(reqDTO.getCompanyId(), reqDTO.getName());
if (Objects.nonNull(existShopArea)) {
throw new BusinessException("商店区域名称重复");
}
ShopArea shopAreaEntity = new ShopArea();
BeanUtils.copyProperties(reqDTO, shopAreaEntity);
shopAreaEntity.setShopAreaCategories(assembleCategoryByIds(reqDTO.getCategoryIds()));
return shopAreaDao.save(shopAreaEntity);
}

private List<ShopAreaCategory> assembleCategoryByIds(List<Long> ids) {
if (CollectionUtils.isEmpty(ids)) {
return new ArrayList<>();
}
return ids.stream().map(i -> new ShopAreaCategory().setId(i)).collect(Collectors.toList());
}

public List<ShopArea> find(Integer companyId) {
return shopAreaDao.findByCompanyId(companyId);
}
}

@Slf4j
@Service
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED)
public class ShopAreaCategoryService {
@Autowired
private ShopAreaCategoryDao shopAreaCategoryDao;

public ShopAreaCategory save(ShopAreaCategory shopAreaCategory) {
return shopAreaCategoryDao.save(shopAreaCategory);
}

public List<ShopAreaCategory> findByCompanyId(Integer companyId) {
return shopAreaCategoryDao.findByCompanyId(companyId);
}
}


1.4. Test

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Slf4j
@SpringBootTest
@RunWith(SpringRunner.class)
public class ApplicationSpringBootExampleTest22 {

@Resource
private ShopAreaCategoryService shopAreaCategoryService;

@Resource
private ShopAreaService shopAreaService;

@Test
public void saveShopAreaCategory() {
ShopAreaCategory shopAreaCategory1 = new ShopAreaCategory().setCompanyId(9527).setName("区域分类1");
ShopAreaCategory shopAreaCategory2 = new ShopAreaCategory().setCompanyId(9527).setName("区域分类2");
ShopAreaCategory shopAreaCategory3 = new ShopAreaCategory().setCompanyId(9527).setName("区域分类3");
ArrayList<ShopAreaCategory> shopAreaCategories = CollectionUtil.newArrayListInstance(shopAreaCategory1, shopAreaCategory2, shopAreaCategory3);
for (ShopAreaCategory shopAreaCategory : shopAreaCategories) {
shopAreaCategoryService.save(shopAreaCategory);
}
}

@Test
public void findShopAreaCategory() {
List<ShopAreaCategory> shopAreaCategories = shopAreaCategoryService.findByCompanyId(9527);
shopAreaCategories.forEach(shopAreaCategory -> log.info("shopAreaCategory:{}", JsonUtil.beanToJson(shopAreaCategory)));
}

@Test
public void saveShopArea() {
List<Long> categoryIds = shopAreaCategoryService.findByCompanyId(9527).stream().map(ShopAreaCategory::getId)
.collect(Collectors.toList());

ShopAreaSaveDTO shopAreaSaveDTO = new ShopAreaSaveDTO();
shopAreaSaveDTO.setName("商店区域名称1");
shopAreaSaveDTO.setCompanyId(9527);
shopAreaSaveDTO.setCategoryIds(categoryIds);
shopAreaService.save(shopAreaSaveDTO);
}

@Test
public void testQueryShopArea() {
List<ShopArea> shopAreas = shopAreaService.find(9527);
shopAreas.forEach(shopArea -> log.info("shopArea:{}", JsonUtil.beanToJson(shopArea)));
}
}

1.5. 建表SQL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
create table shop_area
(
id bigint unsigned not null comment '主键id' primary key,
company_id int not null comment '公司id',
name varchar(50) null comment '名称',
create_time datetime not null comment '创建时间',
update_time datetime not null comment '更新时间'
) comment '商店区域';

create table shop_area_category
(
id bigint not null comment '主键id' primary key,
company_id int not null comment '公司id',
name varchar(50) null comment '商店区域分类名称',
create_time datetime default CURRENT_TIMESTAMP null comment '创建时间',
update_time datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP comment '更新时间'
) comment '商店区域分类';

create table shop_area_category_shop_area
(
shop_area_id bigint not null comment '商店区域id',
category_id bigint not null comment '商店区域分类id',
primary key (category_id, shop_area_id)
) comment '商店区域分类_商店区域_关联表';

2. 注解

运行 saveShopArea 方法,在 entity 中设置 categoryIds 属性后,对应的数据会自动保存到 shop_area_category_shop_area 关系表中。

运行 testQueryShopArea 方法,输出 ShopArea 对象中对应关联的 ShopAreaCategory 也一起输出了。

这就是通过注解方式(Hibernate) 进行对象关系映射,一对多、多对多 关系的映射和查询。


2.1. ShopArea Entity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Data
@Entity
@EntityListeners(AuditingEntityListener.class)
@GenericGenerator(name = "entityIdGenerator", strategy = "com.yxcheng.example22.util.EntityIdGenerator")
public class ShopArea implements AbstractEntity {

@GeneratedValue(generator = "entityIdGenerator")
@Id
private Long id;

// ...

@ManyToMany(fetch = FetchType.EAGER)
@Fetch(FetchMode.SUBSELECT)
@JoinTable(name = "shop_area_category_shop_area",
joinColumns = {@JoinColumn(name = "shopAreaId", referencedColumnName = "id", insertable = false,
updatable = false)},
inverseJoinColumns = {@JoinColumn(name = "categoryId", referencedColumnName = "id", insertable = false,
updatable = false)})
private List<ShopAreaCategory> shopAreaCategories;

}
  • @Entity: 表明该类是一个实体类,并且与数据库中的表对应。
  • @EntityListeners(AuditingEntityListener.class): 启用实体审计功能,自动管理创建时间和更新时间。
  • @GenericGenerator: 自定义 ID 生成策略。
  • @GeneratedValue: 指定 ID 使用自定义生成器生成。
  • @Id: 标识该字段为主键。
  • @ManyToMany(fetch = FetchType.EAGER): 定义与 ShopAreaCategory 的多对多关系,并且立即加载相关数据。
  • @Fetch(FetchMode.SUBSELECT): 使用子查询来获取相关实体。
  • @JoinTable: 指定多对多关系的连接表和连接列。

2.2. ShopAreaCategory Entity

1
2
3
4
5
6
7
8
9
10
11
12
@Data
@Entity
@Accessors(chain = true)
@EntityListeners(AuditingEntityListener.class)
@GenericGenerator(name = "entityIdGenerator", strategy = "com.yxcheng.example22.util.EntityIdGenerator")
public class ShopAreaCategory implements AbstractEntity {
@javax.persistence.Id
@GeneratedValue(generator = "entityIdGenerator")
private Long id;

// ...
}

ShopAreaCategory Entity 中使用的注解与 ShopArea 中的相似。


2.3. ShopAreaCategoryShopArea Entity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Data
@Entity
@IdClass(ShopAreaCategoryShopArea.Id.class)
public class ShopAreaCategoryShopArea {

@Data
@NoArgsConstructor
@AllArgsConstructor
public static class Id implements Serializable {
private static final long serialVersionUID = 1L;
private Long categoryId;
private Long shopAreaId;
}

@javax.persistence.Id
private Long categoryId;

@javax.persistence.Id
private Long shopAreaId;
}
  • @IdClass: 指定该实体的复合主键类。
  • 内部静态类 Id 用于表示复合主键。
  • @Id: 标识这些字段为复合主键的一部分。

3. 实体关联分析

3.1. 保存操作

执行 saveShopArea 方法,保存ShopArea 实体时:

  1. 主实体会被直接保存到对应的表中。
  2. 设置了多对多关系,Hibernate 会自动处理中间表 shop_area_category_shop_area 的插入操作。

Hibernate会执行以下操作:

  1. shop_area 表插入新记录。
  2. shop_area_category_shop_area 表插入关联记录。

3.2. 查找操作

执行 testQueryShopArea 方法,查找ShopArea实体时:

  1. 由于使用了 FetchType.EAGER,关联的 ShopAreaCategory 实体会被立即加载。
  2. @Fetch(FetchMode.SUBSELECT) 优化了加载过程,使用子查询而不是多次单独查询。

Hibernate会执行一个主查询和一个子查询:

  1. shop_area 表获取主实体。
  2. 使用子查询从 shop_area_categoryshop_area_category_shop_area 表获取关联的类别。

4. 性能考虑

  • 立即加载(EAGER)可能会在某些场景下导致性能问题,特别是当关联实体数量很大时。在这种情况下,可以考虑使用延迟加载(LAZY)并结合其他查询优化技术。
  • @Fetch(FetchMode.SUBSELECT)在处理大量数据时能显著提升性能,但也要注意可能带来的内存压力。