第八章. MapStruct-Plus:集合、流与分页

第八章. MapStruct-Plus:集合、流与分页

摘要:在前面的章节中,我们已经打通了单体对象(UserBO -> UserVO)的映射通道。但在现实业务中,我们更多时候是在处理“列表”。本章我们将基于已有的单体映射配置,通过实战解锁 MSP 的 自动集合映射MyBatis-Plus 分页集成 能力,并结合 Java 8 Stream API 实现高效的数据清洗。

本章学习路径

  1. 数据准备:在 Service 层快速构建模拟批量数据,为实战做准备。
  2. 集合实战:无需新增任何注解,直接实现 List<BO>List<VO> 的转换。
  3. 流式结合:在 Stream 流处理中融入 Converter,实现“过滤+排序+转换”一条龙。
  4. 分页实战:体验 MSP 与 MyBatis-Plus 的深度集成,一行代码完成 Page 对象的整体转换。

8.1. 准备工作:构建数据源

为了验证批量转换的效果,我们首先需要在 UserService 中模拟一些数据。为了专注于映射本身,我们暂时不操作数据库,而是直接在内存中生成对象。

请打开或新建 src/main/java/com/example/demo/service/UserService.java,添加以下逻辑:

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
package com.example.demo.service;

import cn.hutool.core.map.MapUtil;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.demo.domain.bo.UserBO;
import com.example.demo.domain.enums.UserStatus;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@Service
public class UserService {

/**
* 模拟:从数据库查询出了 5 条 UserBO 数据
* 其中包含不同状态的用户,用于测试过滤逻辑
*/
public List<UserBO> mockBatchQuery() {
List<UserBO> list = new ArrayList<>();
for (int i = 1; i <= 5; i++) {
UserBO bo = new UserBO();
bo.setId((long) i);
bo.setUsername("User_" + i);
// 构造手机号:13800000001 ~ 13800000005
bo.setPhone("1380000000" + i);
// 奇数设为启用,偶数设为禁用
bo.setStatus(i % 2 != 0 ? UserStatus.ENABLE : UserStatus.DISABLE);
// 模拟扩展信息
bo.setExtra(MapUtil.of("score", i * 100));
list.add(bo);
}
return list;
}
}

8.2. 场景一:自动集合映射 (List)

很多同学会有疑问:“我在第七章只定义了 UserBOUserListVO 的一对一映射,现在我要转一个 List,需要再去写一个 toVOList 方法吗?”

答案是:完全不需要。

MapStruct Plus 的底层机制非常智能,它只要发现你定义了元素 A 到 B 的映射,就会自动支持 List<A>List<B> 的转换。

8.2.1. 编写 Controller 验证

我们在 UserViewController 中增加一个接口,直接返回转换后的列表。

文件路径src/main/java/com/example/demo/controller/UserViewController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ... 这里的 import 省略,保留原有的 Controller 类结构
// 注入 UserService
private final UserService userService;

@GetMapping("/list/all")
public List<UserListVO> getAllUsers() {
// 1. 获取 5 个 BO 对象
List<UserBO> boList = userService.mockBatchQuery();

// 2. 直接转换!
// MSP 会自动遍历 List,并复用我们在 UserBO 中定义的 @AutoMapper(target = UserListVO.class) 规则
// 也就是会自动执行:手机号脱敏
List<UserListVO> voList = converter.convert(boList, UserListVO.class);

return voList;
}

8.2.2. 验证结果

启动项目,访问 http://localhost:8080/users/list/all

观察响应

1
2
3
4
5
[
{ "username": "User_1", "phoneMask": "138****0001" },
{ "username": "User_2", "phoneMask": "138****0002" },
...
]

我们看到,虽然我们从未显式定义 List 的转换规则,但所有数据都成功转换成了 UserListVO,并且手机号都应用了脱敏规则。


8.3. 场景二:流式处理 (Stream + Convert)

实际业务往往更复杂:我们需要先过滤掉“禁用”的用户,再按“积分”排序,最后才输出 VO。这时,将 MSP 结合 Java 8 Stream API 使用是最佳实践。

MSP 的 converter 接口设计得非常符合函数式编程习惯,可以完美嵌入 map 操作中。

8.3.1. 编写带逻辑的转换代码

继续在 UserViewController 中添加接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@GetMapping("/list/active")
public List<UserListVO> getActiveUsers() {
List<UserBO> boList = userService.mockBatchQuery();

return boList.stream()
// 1. 业务过滤:只保留状态为 ENABLE (奇数用户)
.filter(bo -> bo.getStatus() == UserStatus.ENABLE)

// 2. 类型转换:结合 MapStruct Plus
// lambda 表达式:对于每一个 bo,调用 converter 转为 UserListVO
.map(bo -> converter.convert(bo, UserListVO.class))

// 3. 收集结果
.toList(); // JDK 16+ 写法,旧版本用 .collect(Collectors.toList())
}

8.3.2. 验证结果

访问 http://localhost:8080/users/list/active

观察响应

1
2
3
4
5
[
{ "username": "User_1", "phoneMask": "138****0001" },
{ "username": "User_3", "phoneMask": "138****0003" },
{ "username": "User_5", "phoneMask": "138****0005" }
]

结果中只剩下了 User_1, 3, 5。这证明了我们可以在数据流转的任意环节插入 converter,实现灵活的业务编排。


8.4. 场景三:分页映射 (IPage)

这是 Web 开发中最高频的场景。MyBatis-Plus 查询返回的是 IPage<UserPO>(或 BO),但前端接口文档要求返回 IPage<UserVO>

由于 Java 的 泛型擦除 机制,直接尝试将 Page<UserBO> 强转为 Page<UserVO> 是极其危险的。为了保证类型安全并精确控制元数据,标准做法分为两步:

  1. 转换内容:提取 records 列表,利用 MSP 进行批量转换。
  2. 重组对象:创建一个新的 Page 对象,填入转换后的列表,并拷贝 totalcurrent 等分页参数。

8.4.1. 模拟分页数据

回到 UserService,添加一个模拟分页返回的方法:

1
2
3
4
5
6
7
8
9
10
11
12
// 模拟 MyBatis-Plus 的 selectPage 返回结果
public IPage<UserBO> mockPageQuery(int current, int size) {
List<UserBO> allData = mockBatchQuery();

// 构造一个 Page 对象
IPage<UserBO> page = new Page<>(current, size);
page.setTotal(allData.size());
page.setPages(1);
page.setRecords(allData); // 简单起见,把所有数据都塞进去

return page;
}

8.4.2. 编写分页接口

UserViewController 中添加分页接口。这里我们展示最稳健的 “三步走” 写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@GetMapping("/page")
public IPage<UserListVO> getPage(@RequestParam(defaultValue = "1") int current,
@RequestParam(defaultValue = "10") int size) {
// 1. 获取 Service 返回的 BO 分页对象
IPage<UserBO> boPage = userService.mockPageQuery(current, size);

// 2. 核心转换:List<BO> -> List<VO>
// MSP 擅长处理 List,直接调用 convert 即可,无需担心分页元数据干扰
List<UserListVO> voList = converter.convert(boPage.getRecords(), UserListVO.class);

// 3. 组装结果:构建新的 Page 对象
// 显式拷贝分页元数据,确保数据绝对准确
IPage<UserListVO> resultPage = new Page<>(boPage.getCurrent(), boPage.getSize(), boPage.getTotal());
resultPage.setRecords(voList);
resultPage.setPages(boPage.getPages());

return resultPage;
}

8.4.3. 封装通用工具 (推荐)

虽然上面的代码很稳,但在每个 Controller 里都写这几行略显繁琐。我们可以封装一个简单的工具类来简化操作。

文件路径src/main/java/com/example/demo/infrastructure/utils/PageUtils.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.example.demo.infrastructure.utils;

import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.github.linpeilie.Converter;
import java.util.List;

public class PageUtils {
/**
* 通用分页转换工具
* @param sourcePage 源分页对象
* @param targetClass 目标 VO 的类型
* @param converter MSP 转换器实例
*/
public static <T, R> IPage<R> toPage(IPage<T> sourcePage, Class<R> targetClass, Converter converter) {
// 1. 转换列表
List<R> targetList = converter.convert(sourcePage.getRecords(), targetClass);
// 2. 拷贝元数据
IPage<R> resultPage = new Page<>(sourcePage.getCurrent(), sourcePage.getSize(), sourcePage.getTotal());
resultPage.setRecords(targetList);
resultPage.setPages(sourcePage.getPages());
return resultPage;
}
}

Controller 调用优化

1
2
3
4
5
6
7
@GetMapping("/page-util")
public IPage<UserListVO> getPageUtil(@RequestParam(defaultValue = "1") int current,
@RequestParam(defaultValue = "10") int size) {
IPage<UserBO> boPage = userService.mockPageQuery(current, size);
// 一行代码,清晰且类型安全
return PageUtils.toPage(boPage, UserListVO.class, converter);
}

8.4.4. 验证结果

访问 http://localhost:8080/users/page?current=1&size=10

观察响应

1
2
3
4
5
6
7
8
9
10
11
{
"records": [
{ "username": "User_1", "phoneMask": "138****0001" },
// ... 其他数据 ...
],
"total": 5,
"size": 10,
"current": 1,
"pages": 1,
"searchCount": true
}

结果确认

  1. 数据转换成功records 中的字段已根据 UserListVO 的规则进行了脱敏。
  2. 结构保持一致:分页元数据完整保留。
  3. 零报错风险:完全遵循 Java 强类型规范,避开了运行时类型转换异常。

8.5. 本章总结与集合映射速查

本章我们攻克了批量数据处理的三大关卡:列表自动映射、Stream 流式编排以及 MyBatis-Plus 分页集成。

遇到以下 3 种批量场景时,请直接 Copy 下方的标准代码模版:

8.5.1. 场景一:普通 List 转换

需求:Service 返回 List<BO>,Controller 需要返回 List<VO>
方案:直接调用 convert,MSP 自动支持集合遍历。

1
2
3
List<UserBO> boList = service.findAll();
// 第二个参数传的是“目标元素”的 Class
List<UserVO> voList = converter.convert(boList, UserVO.class);

8.5.2. 场景二:Stream 流式处理

需求:在转换前需要进行过滤(Filter)、排序(Sorted)或去重。
方案:在 Stream.map 中嵌入 converter

1
2
3
4
5
List<UserVO> voList = boList.stream()
.filter(bo -> bo.getIsActive()) // 1. 业务过滤
.map(bo -> converter.convert(bo, UserVO.class)) // 2. 类型转换
.sorted(Comparator.comparing(UserVO::getId)) // 3. 结果排序
.collect(Collectors.toList());

8.5.3. 场景三:MyBatis-Plus 分页转换

需求:数据库查出 IPage<PO>,接口返回 IPage<VO>,且必须保留分页元数据。
方案:为了绝对的类型安全,建议解包后重组,或使用工具类。

1
2
3
4
5
6
7
8
// 方式:手动解包重组 (最稳健)
IPage<UserPO> poPage = userMapper.selectPage(...);

// 1. 转列表
List<UserVO> voList = converter.convert(poPage.getRecords(), UserVO.class);
// 2. 拷分页
IPage<UserVO> voPage = new Page<>(poPage.getCurrent(), poPage.getSize(), poPage.getTotal());
voPage.setRecords(voList);