第十八章. common-core 核心组件:RVP 的对象转换架构与二开实战


#第十八章. common-core 核心组件:RVP 的对象转换架构与二开实战

摘要:本章将深入剖析 RuoYi-Vue-Plus (RVP) 中基于 MapStruct-Plus (MSP) 构建的对象转换架构。我们将摒弃传统的 BeanUtils 反射拷贝模式,从 JVM 类加载与 Spring 容器初始化的底层视角,解析 MapstructUtils 的静态封装原理。随后,我们将基于 Goods 业务模块,全链路还原从前端表单提交到数据库存储(Input),以及从数据库查询到前端展示(Output)的标准流转过程,并深入探讨模块化架构下的跨模块枚举映射、复杂字段计算以及性能优化方案。

本章学习路径

  1. 架构解构:深度解析 MapstructUtils 源码,理解 RVP 如何利用 SpringUtilsApplicationContextAware 打通静态上下文与 Spring 容器。
  2. 入站流转:全解析“表单 -> Controller -> BO -> Entity”的数据写入流程,探讨 BO 的校验分组与单向转换规约。
  3. 出站流转:全解析“Entity -> VO -> 前端展示”的数据读取流程,探讨物理脱敏与批量转换的性能优势。
  4. 难点攻克:解决通用模块枚举与业务模块实体之间的跨模块映射问题,掌握 usesexpression 的高阶用法。

注意: 阅读本章之前需要提前阅读 分类: Java 三方库 | Prorise - 博客小栈 关于 MapStruct-Plus 相关内容,我们默认了您已经明白了相关的语法内容。本章专注于 RVP 框架内的最佳实践与架构解析。


18.1. RVP 的静态封装设计:MapstructUtils 原理

在 RVP 的分层架构中,对象转换(Object Mapping)是连接 Controller、Service、Dao 层的毛细血管。一个中型企业级项目可能包含数百个 BoVoEntity 对象,转换操作的频率极高。

在 RVP 引入 MapStruct-Plus (MSP) 之前,开发者往往面临两个极端的选择:要么使用性能较差但调用方便的 BeanUtils(反射机制),要么使用性能极高但注入繁琐的原生 MapStruct。RVP 的 MapstructUtils 是一种“静态门面模式”的工程化落地,它试图在性能与开发体验之间寻找完美的平衡点。

18.1.1. 痛点还原:为什么不能直接用 @Autowired

为了深刻理解 MapstructUtils 的价值,我们需要先审视在 Spring Boot 环境下直接使用 MSP 原生接口所面临的架构痛点。

MSP 的工作原理是:在编译期,通过注解处理器生成接口的实现类(Impl),并将其注册为 Spring Bean(默认是单例 Singleton)。这意味着,如果我们想在代码中使用转换功能,必须遵循 Spring 的依赖注入(DI)规范。

场景一:Service 层的注入爆炸

在一个复杂的业务 Service 中,我们可能需要处理 User、Dept、Role、Post 等多个领域的对象转换。如果采用原生注入方式,代码将变得非常臃肿:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Service
public class UserServiceImpl implements IUserService {

// 注入 User 转换器
@Autowired
private UserConverter userConverter;

// 注入 Dept 转换器(用于处理部门信息)
@Autowired
private DeptConverter deptConverter;

// 注入 Role 转换器(用于处理角色信息)
@Autowired
private RoleConverter roleConverter;

// ... 业务逻辑方法
}

这种“注入爆炸”不仅增加了代码行数,还提高了类的耦合度。Service 类应该专注于业务逻辑,而不是被各种辅助工具类的注入代码所淹没。

场景二:静态工具与 POJO 的“注入死角”

这是更致命的问题。在 Java 开发中,我们经常编写一些静态工具类(Utils)或者非 Spring 管理的普通 Java 对象(POJO/Domain Object)。

例如,我们有一个 ExcelListener(用于监听 Excel 导入事件),它通常是通过 new 关键字实例化的,不由 Spring 管理。在 ExcelListener 内部,我们读取 Excel 行数据后,需要将其转换为 Entity 进行存储。此时,由于 ExcelListener 不在 Spring 容器中,我们无法使用 @Autowired

传统解决方案的局限性

  1. 构造器传参:在创建 ExcelListener 时,手动把 Converter 传进去。这导致调用链非常繁琐。
  2. BeanUtils:退回到使用 BeanUtils.copyProperties。虽然解决了调用问题,但牺牲了性能(反射开销)和安全性(类型不安全)。

RVP 的架构决策
RVP 决定封装一个 MapstructUtils,目标是:在任何地方(Static/Service/POJO),都能以静态方法的形式调用 MapStruct 的高性能转换能力,且无需关心 Spring 容器的存在。

18.1.2. 源码深度解析:MapstructUtils

MapstructUtils 的实现依赖于 RVP 的基础设施 SpringUtils。我们需要从 JVM 类加载和 Spring 容器启动的顺序来理解这段代码的精妙之处。

文件路径ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/MapstructUtils.java

1. 静态持有与容器桥接

1
2
3
4
5
6
7
8
9
10
11
12
13
// 这个注解是Lombok提供的无参构造注解这是一个防御性编程的设计。工具类只包含静态方法,其实例化没有任何意义。
// 通过将构造函数私有化,
// 编译器会阻止任何 new MapstructUtils() 的尝试,同时也向其他开发者明确传达了“这是一个静态工具类”的语义。
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class MapstructUtils {

// 核心代码:静态持有 Converter 实例
// 这是连接“静态上下文”与“Spring 容器”的脐带。让我们回顾 `SpringUtils` 的原理:它实现了 `ApplicationContextAware` 接口。
private final static Converter CONVERTER = SpringUtils.getBean(Converter.class);

// ...
}

  1. Spring Boot 启动,初始化 ApplicationContext。
  2. Spring 扫描到 SpringUtils Bean,发现它实现了 Aware 接口。
  3. Spring 调用 SpringUtils.setApplicationContext(),注入上下文。
  4. 此时,SpringUtils 内部的静态变量 context 被赋值。
  5. 随后,当 JVM 首次加载 MapstructUtils 类时,触发静态变量 CONVERTER 的初始化。
  6. MapstructUtils 调用 SpringUtils.getBean,此时 Context 已经准备就绪,成功获取到 MSP 的 Converter 单例。

架构隐患与规避:如果 MapstructUtilsSpringUtils 初始化之前被加载(例如在某些 Bean 的构造函数中过早调用),会抛出空指针异常。RVP 通过 Spring Boot 的自动配置顺序和 Bean 加载机制,通常能保证 SpringUtils 优先就绪,但在二开时需注意:尽量不要在 Bean 的构造函数(Constructor)或静态代码块(static block)中直接调用 MapstructUtils,建议在方法内部或 @PostConstruct 中调用。

2. 单对象转换:防御性编程的典范

1
2
3
4
5
6
7
8
9
10
11
12
13
public static <T, V> V convert(T source, Class<V> desc) {
// 1. 源对象判空
if (ObjectUtil.isNull(source)) {
return null;
}
// 2. 目标类型判空
if (ObjectUtil.isNull(desc)) {
return null;
}
// 3. 执行转换
return CONVERTER.convert(source, desc);
}

深度解析

  • 为什么要做 ObjectUtil.isNull(source)
    原生 MapStruct 生成的 Impl 代码中,虽然通常包含 if (source == null) return null 的检查,但在某些复杂的嵌套映射或使用了 @Mapping 表达式的场景下,直接传 null 可能会导致不可预知的 NullPointerException。
    RVP 在工具类入口处统一拦截,确立了 “Null In, Null Out” 的绝对契约。这对业务逻辑非常重要:开发者不需要担心“如果我传了 null,它会不会给我返回一个属性全空的空对象?”——答案是明确的:不会,它会返回 null。
  • 泛型设计 <T, V> 的妙用:方法签名中的泛型设计利用了 Java 的类型推断能力。
  • T source:可以是任意类型的源对象。
  • Class<V> desc:目标类型的 Class 对象。
  • return V:返回值直接是 V 类型。这使得调用代码极其流畅:UserVo vo = MapstructUtils.convert(bo, UserVo.class);,无需 (UserVo) 强制类型转换,代码可读性大幅提升。

3. 集合转换:空列表规范

1
2
3
4
5
6
7
8
9
10
11
public static <T, V> List<V> convert(List<T> sourceList, Class<V> desc) {
if (ObjectUtil.isNull(sourceList)) {
return null;
}
// 核心规范:空列表返回空集合,而非 null
if (CollUtil.isEmpty(sourceList)) {
return CollUtil.newArrayList();
}
return CONVERTER.convert(sourceList, desc);
}

深度解析

这里体现了 RVP 对 API 友好度的考量。

  • 场景:Service 层查询数据库,结果为空列表。调用此方法转换为 VO 列表返回给 Controller,最终序列化为 JSON 返回给前端。
  • 如果不处理:返回 null。前端拿到 JSON 是 { "data": null }。前端代码必须写 if (res.data && res.data.length > 0)
  • RVP 的处理:返回 []。前端拿到 JSON 是 { "data": [] }。前端代码可以直接写 res.data.map(...)v-for,循环次数为 0,页面不渲染,且不会报错。
  • 技术细节CollUtil.newArrayList() 是 Hutool 提供的工具,创建了一个初始容量为 0 的 ArrayList,内存开销极小。

18.1.3 本节小结

  • 静态门面:RVP 通过 MapstructUtils 实现了 Spring Bean 的静态化调用,解决了依赖注入的局限性。

  • 防御编程:内置的判空逻辑确立了“Null In, Null Out”的标准,消除了 NPE 隐患。

  • 前端友好:集合转换在源为空时返回空列表,大幅降低了前端判空成本。

  • 速查代码

1
2
3
4
5
// 单对象转换
UserVo vo = MapstructUtils.convert(userBo, UserVo.class);

// 列表转换 (即使 list 为空也会返回 [])
List<UserVo> voList = MapstructUtils.convert(userList, UserVo.class);

18.2. 入站流转解析:从表单到 Entity (Input)

理解了工具类的底层原理后,我们进入实战环节。RVP 严格遵循 Controller -> Service -> Dao 的分层架构,数据流向清晰。

本节我们将以 RVP 代码生成器生成的 Goods(商品)模块为例,全链路追踪一个“新增商品”请求的数据是如何流转、校验并最终持久化的。这个过程被称为 入站流转 (Input Flow)

18.2.1. 控制层:BO 的接收与校验

Controller 层是 HTTP 请求的“国门”,它的首要职责是 参数接收合法性校验。在 RVP 中,Controller 严禁直接接触数据库实体(Entity),必须使用 BO (Business Object) 作为参数载体。

文件路径ruoyi-modules/ruoyi-demo/src/main/java/org/dromara/demo/controller/GoodsController.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
@Validated // 1. 开启类级别的校验支持
@RequiredArgsConstructor
@RestController
@RequestMapping("/demo/goods")
public class GoodsController extends BaseController {

private final IGoodsService goodsService;

/**
* 新增测试商品
* * @param bo 接收前端 JSON 数据的业务对象
* @return 响应结果
*/
@SaCheckPermission("demo:goods:add") // 权限校验
@Log(title = "测试商品", businessType = BusinessType.INSERT) // 日志切面
@RepeatSubmit() // 防重提交切面
@PostMapping()
public R<Void> add(@Validated(AddGroup.class) @RequestBody GoodsBo bo) {
// 2. 调用 Service 层进行业务处理
// 注意:Controller 层完全不知道 MapStruct 的存在,它只负责传递 BO
return toAjax(goodsService.insertByBo(bo));
}
}

18.2.2. 业务对象:BO 的单向映射配置

BO 是数据传输的核心,也是 MSP 转换规则的定义处。

文件路径ruoyi-modules/ruoyi-demo/src/main/java/org/dromara/demo/domain/bo/GoodsBo.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
@Data
@EqualsAndHashCode(callSuper = true)
// 1. @AutoMapper: MSP 核心注解
// target = Goods.class: 指定该 BO 映射的目标实体是 Goods
// reverseConvertGenerate = false: 禁止生成 Entity -> BO 的转换方法
@AutoMapper(target = Goods.class, reverseConvertGenerate = false)
public class GoodsBo extends BaseEntity {

/**
* 主键
* 新增时该字段通常为 null,由数据库自增或雪花算法生成
*/
private Long id;

/**
* 商品名称
* groups: 指定该校验规则仅在 AddGroup 或 EditGroup 激活时生效
*/
@NotBlank(message = "商品名称不能为空", groups = { AddGroup.class, EditGroup.class })
private String name;

/**
* 价格
* 这里的类型必须与 Goods 实体中的 price 类型兼容 (如 Long 对 Long)
* 如果不兼容 (如 String 对 BigDecimal),则需要 @AutoMapping 进行特殊配置
*/
@NotNull(message = "价格不能为空", groups = { AddGroup.class, EditGroup.class })
private Long price;
}

深度解析:为何配置 reverseConvertGenerate = false

在原生 MapStruct 中,@Mapper 通常会生成双向转换方法。但在 RVP 的架构规约中,我们强制要求 BO 禁用反向转换生成。原因如下:

  1. 架构洁癖:BO (Business Object) 的定位是 入参。数据流向永远是 Input -> BO -> Entity -> DB。在业务逻辑中,我们极少需要将一个从数据库查出来的 Entity 反向转换为 BO。如果需要出参,那是 VO (View Object) 的职责。
  2. 安全性:Entity 中可能包含敏感数据(如密码盐值、删除标记),如果允许反向转为 BO,且这个 BO 又不小心被 Controller 返回给了前端,就会导致数据泄露。禁用生成从根源上切断了这种可能性。
  3. 减包:生成的 Impl 类是字节码,减少一半的无用方法可以微弱地减小 Jar 包体积和 Metaspace 占用。

18.2.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
@Service
public class GoodsServiceImpl implements IGoodsService {

@Override
public Boolean insertByBo(GoodsBo bo) {
// 1. 【核心转换】调用 MapstructUtils 静态方法
// 这一步是 BO 生命周期的终点,Entity 生命周期的起点
// MSP 会执行:new Goods(); goods.setName(bo.getName()); ...
Goods add = MapstructUtils.convert(bo, Goods.class);

// 2. 【逻辑增强】此时我们操作的是 Entity
// 在这里可以进行业务级的校验(如名称判重)或字段补充
validEntityBeforeSave(add);

// 3. 【持久化】MyBatis-Plus 执行 Insert
// 此时,MyBatis-Plus 的 MetaObjectHandler 会自动填充 createTime, createBy 等字段
boolean flag = baseMapper.insert(add) > 0;

// 4. 【回填 ID】
// 这是一个实用的技巧。插入成功后,Entity 中的 ID 会被 MP 自动回填
// 我们将其赋值回 BO,因为有些 Controller 逻辑需要返回新生成的 ID
if (flag) {
bo.setId(add.getId());
}
return flag;
}
}

架构思考:转换为何在 Service 层而不在 Controller 层?

有些开发者喜欢在 Controller 层就把 BO 转成 Entity,然后传给 Service。RVP 反对这种做法,理由是:

  • 事务边界:Service 层往往包裹在 @Transactional 中。转换逻辑虽然通常不涉及数据库,但有时可能需要查询字典或配置来辅助转换,这属于业务逻辑的一部分,应当在事务管控范围内。
  • 接口复用:如果 Controller 直接传 Entity 给 Service,那么如果有其他内部模块(如 MQ 消费者、定时任务)想调用这个保存逻辑,它们也被迫去构造一个 Entity。而 BO 是更纯粹的数据契约,Service 接收 BO 意味着“无论数据从哪里来,只要符合 BO 的契约,我就能处理”。

18.2. 本节小结

  • 流转路径Request JSON -> Controller (BO 接收与 @Validated 校验) -> Service (使用 MapstructUtils 转为 Entity) -> MyBatis-Plus (持久化)。

  • 规约核心

  • BO 必须配置 reverseConvertGenerate = false,坚持单向流向。

  • 利用 Validation 分组(AddGroup)实现同一 BO 的多场景复用。

  • 转换逻辑下沉至 Service 层,保持 Controller 的轻量化。


##18.3. 出站流转解析:从 DB 到 VO (Output)
在上一节的入站流转中,我们看到 Service 层使用 MapstructUtilsBO 转换为 Entity。但在 出站流转(Output Flow) 中,RVP 采用了完全不同的策略。

通过分析 GoodsServiceImpl 的源码,我们发现查询操作并没有先查出 Entity 再转换,而是直接通过 BaseMapperPlus 返回了 VO。这种模式被称为 “直接视图投影”

18.3.1. 视图对象:VO 的定义

VO (View Object) 在 RVP 中扮演着两个角色:API 响应体MyBatis 结果映射目标

文件路径ruoyi-modules/ruoyi-demo/src/main/java/org/dromara/demo/domain/vo/GoodsVo.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
@Data
@ExcelIgnoreUnannotated // 1. EasyExcel 注解,用于导出时忽略无注解字段
@AutoMapper(target = Goods.class) // 2. MSP 注解,声明它与 Goods 实体的映射关系
public class GoodsVo implements Serializable {

@Serial
private static final long serialVersionUID = 1L;

/**
* 主键
*/
@ExcelProperty(value = "主键")
private Long id;

/**
* 商品名称
*/
@ExcelProperty(value = "商品名称")
private String name;

/**
* 价格
*/
@ExcelProperty(value = "价格")
private Long price;

// ... 其他字段
}

代码解析

  • @AutoMapper 的存在意义:虽然在标准的分页查询中没有显式调用 MSP,但这个注解依然必不可少。它用于支持 Excel 导出时的列表转换,或者在某些复杂业务场景下,开发者手动调用 MapstructUtils.convert(entity, GoodsVo.class) 时生成实现代码。
  • 字段白名单GoodsVo 中仅定义了前端需要的字段。这不仅实现了物理脱敏,也为 MyBatis 的查询映射提供了精确的目标载体。

18.3.2. 业务层:BaseMapperPlus 的“直接投影”

在查询链路中,RVP 为了极致的性能和代码简洁性,跳过了 Service 层的转换步骤,将映射逻辑下沉到了 Mapper 框架层。

文件路径ruoyi-modules/ruoyi-demo/src/main/java/org/dromara/demo/service/impl/GoodsServiceImpl.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
/**
* 分页查询测试商品列表
*/
@Override
public TableDataInfo<GoodsVo> queryPageList(GoodsBo bo, PageQuery pageQuery) {
// 1. 【构建查询条件】
// buildQueryWrapper 是 RVP 封装的通用方法,解析 BO 非空字段为 SQL WHERE 条件
LambdaQueryWrapper<Goods> lqw = buildQueryWrapper(bo);

// 2. 【核心查询】直接返回 Page<GoodsVo>
// 注意:这里调用的是 selectVoPage,而不是普通的 selectPage
Page<GoodsVo> result = baseMapper.selectVoPage(pageQuery.build(), lqw);

// 3. 【结果封装】
return TableDataInfo.build(result);
}

/**
* 不分页列表查询
*/
@Override
public List<GoodsVo> queryList(GoodsBo bo) {
LambdaQueryWrapper<Goods> lqw = buildQueryWrapper(bo);
// 同理,直接返回 List<GoodsVo>
return baseMapper.selectVoList(lqw);
}

深度解析:selectVoPage vs selectPage

  • selectPage (原生 MP):返回 Page<Entity>。如果使用此方法,我们需要在 Service 层手动遍历 List,逐个将 Goods 转换为 GoodsVo(此时才会用到 MSP)。
  • selectVoPage (RVP 扩展):返回 Page<Vo>。这是 RVP 在 ruoyi-common-mybatis 模块中通过 BaseMapperPlus 接口扩展的能力。它利用 MyBatis 的 ResultType 机制或拦截器,直接将数据库 ResultSet 映射为 VO 对象。

为什么查询不用 MSP?
对于单纯的 CRUD 查询,selectVoPage 减少了一次对象拷贝(DB -> Entity -> VO 变为 DB -> VO),降低了内存开销,且代码更精简。只有当 VO 需要经过复杂的计算(例如:数据库没有该字段,需要调用远程接口填充)时,我们才会回退到“先查 Entity,再用 MSP 转换”的模式。


18.3. 本节小结

  • 写入 (Input):使用 MapStruct-Plus (MapstructUtils.convert),确保 BO 到 Entity 的精确转换与业务校验。

  • 读取 (Output):使用 BaseMapperPlus (selectVoPage),利用 MP 的投影能力直接获取 VO,提升查询性能。

  • PageQuery:封装了分页与排序的构建逻辑,为 Mapper 层提供标准的 MP 分页对象。


##18.4. 难点攻克:跨模块枚举映射与渲染策略在 RVP 的多模块架构中,ruoyi-common(通用模块)和 ruoyi-modules(业务模块)在物理代码和编译上下文中是严格分离的。这种隔离虽然解耦了架构,但也给对象转换带来了挑战:当 VO 需要展示中文含义,而数据库仅存储了状态码时,我们应该在何时、何地、由谁来完成这个翻译工作?

这并非简单的代码实现问题,而是涉及到 带宽成本、计算压力、架构一致性 的综合考量。

在 RVP 的 Admin 后台管理系统中,后端 API 默认不负责翻译字典值。这是为了追求极致的接口响应速度和最小的网络传输载荷。

数据流转逻辑

  1. 数据库层:存储 status = "0"
  2. 传输层:后端接口返回 {"status": "0"}。数据包体积极小。
  3. 渲染层:前端浏览器负责将 “0” 渲染为 “正常”。

前端渲染机制解析

RVP 前端框架(Vue3)通过 useDict 钩子函数预加载字典缓存。在页面渲染时,利用 <dict-tag> 组件实现即时匹配。

1
2
3
4
5
6
7
8
9
<script setup>
// 1. 请求字典接口,将字典数据缓存到浏览器内存中
const { user_status } = useDict('user_status');
</script>

<template>
<dict-tag :options="user_status" :value="scope.row.status"/>
</template>

架构优势

  • 后端零计算:后端不需要进行任何查表或枚举匹配操作,CPU 开销为 0。
  • 流量节省:假设一个列表有 100 行,每行有 10 个字典字段。如果后端翻译,响应体体积可能膨胀 50%。前端渲染则完全避免了这种冗余。

适用场景:所有 Admin 内部管理页面的 列表展示详情查看


18.5. 本章总结与对象流转速查

本章我们深入解剖了 RVP 的对象转换架构,从 MapstructUtils 的静态封装原理到全链路的数据流转规约。我们摒弃了低效的反射拷贝,确立了以 MapStruct-Plus 为核心、BaseMapperPlus 为辅助的高性能转换体系。

遇到以下 3 种对象转换场景时,请直接 Copy 下方的标准代码模版:

18.5.1. 场景一:业务逻辑中的对象转换 (Service)

需求:在 Service 层将前端传来的 GoodsBo 转换为数据库实体 Goods,或在任意工具类中进行静态转换。
方案:使用 MapstructUtils.convert

1
2
3
4
5
6
7
8
9
10
11
// 1. 单对象转换
Goods goods = MapstructUtils.convert(bo, Goods.class);

// 2. 集合转换 (安全处理:若 list 为空,返回空集合 [])
List<GoodsVo> voList = MapstructUtils.convert(entityList, GoodsVo.class);

// 3. 复杂计算 (结合 Stream 流)
List<GoodsVo> activeVos = entityList.stream()
.filter(e -> e.getStatus() == 1)
.map(e -> MapstructUtils.convert(e, GoodsVo.class))
.collect(Collectors.toList());

18.5.2. 场景二:查询投影与物理脱敏 (Mapper)

需求:查询列表时,直接返回包含前端所需字段的 GoodsVo,跳过 Entity 转换步骤以提升性能,并自动过滤敏感字段。
方案:利用 BaseMapperPlusselectVoPage / selectVoList

1
2
3
4
5
6
7
8
9
10
11
@Override
public TableDataInfo<GoodsVo> queryPageList(GoodsBo bo, PageQuery pageQuery) {
// 1. 构建查询条件
LambdaQueryWrapper<Goods> lqw = buildQueryWrapper(bo);

// 2. 直接投影查询 (返回 Page<GoodsVo>)
// 底层直接将 ResultSet 映射为 VO,无 Entity 中转开销
Page<GoodsVo> result = baseMapper.selectVoPage(pageQuery.build(), lqw);

return TableDataInfo.build(result);
}

18.5.4. 核心避坑指南

在进行 RVP 二次开发时,请务必注意以下架构规约,否则可能导致转换失效或性能问题:

  1. 构造器调用陷阱

    • 禁忌:不要在任何 Bean 的构造函数(Constructor)或 static 代码块中调用 MapstructUtils.convert
    • 原因:此时 Spring 容器可能尚未完全初始化,SpringUtils 还没拿到 ApplicationContext,会导致 MapstructUtils 内部的 CONVERTER 为空,抛出 NPE。
    • 对策:请在方法内部、@PostConstruct 或实现 InitializingBean 接口后调用。
  2. BO 单向流转规约

    • 禁忌:不要在 BO 上配置 reverseConvertGenerate = true
    • 原因:BO 定位为纯入参(Input)。允许反向生成(Entity -> BO)会增加 Entity 敏感数据泄露的风险,且违反了 CQRS(命令查询职责分离)的设计原则。
  3. 泛型擦除误区

    • 禁忌:在使用 MapstructUtils.convert 处理 Page 对象时,不要试图强转。
    • 对策:RVP 的分页查询推荐直接用 baseMapper.selectVoPage。如果必须手动转换分页对象,请遵循“先转 List,再 Set 到 Page”的三步走策略(参考 8.4 节)。