第七章. MapStruct-Plus:多态视图(VO)与精细化输出
发表于更新于
字数总计:2.7k阅读时长:10分钟阅读量: 广东
第七章. MapStruct-Plus:多态视图(VO)与精细化输出
摘要:在上一章,我们完成了数据从业务层 (BO) 到持久层 (PO) 的双向流转。本章我们将视角转向“输出端”。在实际业务中,BO 处理完的数据往往需要转换为 VO (View Object) 才能返回给前端。本章我们将重点讲解如何利用 @AutoMappers 实现“一个 BO 对应多个 VO”,并利用 targetClass 精确控制 单向输出 时的特殊逻辑(如枚举转中文、手机号脱敏),避免产生不必要的双向映射冗余。
本章学习路径
- 架构回顾:明确 DTO(入) -> BO(核) -> PO(存) 与 PO(取) -> BO(核) -> VO(出) 的单向数据流。
- 多态配置:使用
@AutoMappers 定义 BO 到 PO/VO 的多路映射。 - 精准输出:利用
targetClass 实现仅针对 VO 的单向格式化逻辑(Enum -> String),拒绝过度设计。 - 闭环验证:验证数据库读取数据后,分别输出为“详情视图”和“列表视图”的效果。
7.1. 视图层设计:VO 只是“显示器”
在开始映射之前,我们需要明确 VO 的定位:它只是数据的“显示器”,只负责出,不负责进。因此,我们在设计 VO 映射时,只需要关注 BO -> VO 的正向过程,不需要考虑 VO -> BO 的逆向过程。
在绝大多数标准的业务架构中,VO (View Object) 仅作为 输出对象 回显给前端,前端提交数据时使用的是 DTO (Input Object)。
- 入站 (Write):
前端 (DTO) -> Controller -> Service (DTO转BO) -> BO (业务处理) -> Mapper (BO转PO) -> 数据库- 关注点:BO 到 PO 的转换(如 Map 转 JSON 串,Enum 转 int)。
- 出站 (Read):
数据库 -> Mapper -> PO -> Service (PO转BO) -> BO (数据加工) -> Controller (BO转VO) -> 前端 (VO)- 关注点:PO 到 BO 的还原(JSON 串转 Map),以及 BO 到 VO 的修饰(Enum 转中文描述,手机号脱敏)。
结论:
- PO <-> BO:必须是 双向 的(存进去,查出来)。
- BO -> VO:通常是 单向 的(只负责展示)。
7.1.1. 定义详情视图 (DetailVO)
详情页需要展示状态的中文含义,以及完整的扩展信息。
文件路径:src/main/java/com/example/demo/interfaces/vo/UserDetailVO.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| package com.example.demo.interfaces.vo;
import com.example.demo.domain.bo.UserBO; import io.github.linpeilie.annotations.AutoMapper; import lombok.Data; import java.util.Map;
@Data @AutoMapper(target = UserBO.class) public class UserDetailVO { private String id; private String username; private String phone; private String statusDesc; private Map<String, Object> extra; }
|
7.1.2. 定义列表视图 (ListVO)
列表页需要对敏感数据进行脱敏。
文件路径:src/main/java/com/example/demo/interfaces/vo/UserListVO.java
1 2 3 4 5 6 7 8 9 10
| package com.example.demo.interfaces.vo;
import lombok.Data;
@Data public class UserListVO { private String username; private String phoneMask; }
|
7.2. 核心映射:多态与规则隔离
这是本章的重点。UserBO 处于架构的核心位置,它左手连接数据库(PO),右手连接前端展示(VO)。
我们需要在 UserBO 上配置三种规则:
- 对 PO (双向):JSON 字符串与 Map 的互转(存取必备)。
- 对 DetailVO (单向):提取枚举的中文描述。
- 对 ListVO (单向):手机号脱敏。
文件路径:src/main/java/com/example/demo/domain/bo/UserBO.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 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
| package com.example.demo.domain.bo;
import cn.hutool.core.util.DesensitizedUtil; import cn.hutool.json.JSONUtil; import com.example.demo.domain.enums.UserStatus; import com.example.demo.infrastructure.po.UserPO; import com.example.demo.interfaces.vo.UserDetailVO; import com.example.demo.interfaces.vo.UserListVO; import io.github.linpeilie.annotations.AutoMapper; import io.github.linpeilie.annotations.AutoMappers; import io.github.linpeilie.annotations.AutoMapping; import io.github.linpeilie.annotations.ReverseAutoMapping; import lombok.Data; import java.util.Map;
@Data
@AutoMappers({ // PO 映射:需要 JSONUtil 支持 @AutoMapper(target = UserPO.class, imports = {JSONUtil.class, Map.class}), // DetailVO 映射:不需要特殊 import,标准 getter 即可 @AutoMapper(target = UserDetailVO.class), // ListVO 映射:需要 DesensitizedUtil 脱敏 @AutoMapper(target = UserListVO.class, imports = {DesensitizedUtil.class}) }) public class UserBO {
private Long id; private String username;
@AutoMapping( target = "extraInfo", expression = "java(JSONUtil.toJsonStr(source.getExtra()))", targetClass = UserPO.class // 正向:仅对 PO 生效 ) @ReverseAutoMapping( target = "extra", expression = "java(JSONUtil.toBean(source.getExtraInfo(), Map.class))", targetClass = UserPO.class // 反向:仅从 PO 读数据时生效 ) private Map<String, Object> extra;
@AutoMapping( target = "phoneMask", expression = "java(DesensitizedUtil.mobilePhone(source.getPhone()))", targetClass = UserListVO.class // 仅对 ListVO 生效 ) private String phone;
private String source;
@AutoMapping( target = "statusDesc", source = "status.desc", // 自动生成 userBO.getStatus().getDesc() targetClass = UserDetailVO.class // 仅对 DetailVO 生效 ) private UserStatus status; }
|
7.2.1. 代码精简解析
经过优化,现在的代码逻辑非常清晰:
- 去除了冗余的反向映射:对于
phone 和 status,我们只配置了 @AutoMapping(去 VO),删除了 @ReverseAutoMapping。这符合 VO 只读的架构特性,代码量减少了一半。 - 保留了必要的双向映射:对于
extra 字段,因为它是要存入数据库并读出来的,所以必须保留 PO 维度的双向转换(JSON <-> Map)。 targetClass 的精准控制:status.desc 的提取只会在生成 UserDetailVO 时发生。DesensitizedUtil 的调用只会在生成 UserListVO 时发生。JSONUtil 的调用只会在生成 UserPO 时发生。- 三者互不干扰,彻底解决了“多目标转换时的字段冲突”问题。
本节小结
- 架构先行:代码是为架构服务的。明确了 VO 仅用于输出的定位后,我们可以大胆砍掉 VO -> BO 的反向映射代码。
- 隔离原则:在
@AutoMappers 场景下,习惯性地 为每一个 @AutoMapping 加上 targetClass 属性,是防止编译报错和逻辑混淆的最佳实践。 - 级联取值:
source = "status.desc" 是处理“对象转字符串”(如枚举转中文、关联对象转名称)的神器,它能省去在 BO 中编写专门 Getter 方法的麻烦。
速查代码:
1 2 3
| @AutoMapping(target = "voField", source = "boField.property", targetClass = TargetVO.class) private FieldType boField;
|
7.3. 表现层实战:模拟不同接口
有了安全的映射规则,我们将在 Controller 层模拟两个不同的接口,验证数据是否按预期“变形”。
7.3.1. 编写 Controller
文件路径:src/main/java/com/example/demo/controller/UserViewController.java
我们将构造一个包含完整隐私数据的 BO,验证它在通过不同视图输出时,是否做到了“该藏的藏,该显的显”。
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
| package com.example.demo.controller;
import cn.hutool.core.map.MapUtil; import com.example.demo.domain.bo.UserBO; import com.example.demo.domain.enums.UserStatus; import com.example.demo.interfaces.vo.UserDetailVO; import com.example.demo.interfaces.vo.UserListVO; import io.github.linpeilie.Converter; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController;
@RestController @RequestMapping("/users") @RequiredArgsConstructor public class UserViewController {
private final Converter converter;
private UserBO mockServiceReturn() { UserBO bo = new UserBO(); bo.setId(10086L); bo.setUsername("Linus"); bo.setPhone("13800138000"); bo.setStatus(UserStatus.ENABLE); bo.setExtra(MapUtil.of("vipLevel", "SVIP")); return bo; }
@GetMapping("/list-item") public UserListVO getUserListItem() { UserBO bo = mockServiceReturn(); UserListVO vo = converter.convert(bo, UserListVO.class); System.out.println(">>> 列表视图: " + vo); return vo; }
@GetMapping("/detail") public UserDetailVO getUserDetail() { UserBO bo = mockServiceReturn(); UserDetailVO vo = converter.convert(bo, UserDetailVO.class); System.out.println(">>> 详情视图: " + vo); return vo; } }
|
7.3.2. 验证结果
启动项目,访问接口并观察控制台输出。
请求 1:列表视图
GET /users/list-item
控制台输出:
1
| >>> 列表视图: UserListVO(username=Linus, phoneMask=138****8000)
|
- 分析:
phone 成功转换为 phoneMask 并脱敏。extra 字段因 ListVO 中不存在而被自动忽略(且因为我们限制了 JSON 转换规则只对 PO 生效,所以不会报错)。
请求 2:详情视图
GET /users/detail
控制台输出:
1
| >>> 详情视图: UserDetailVO(id=10086, username=Linus, phone=13800138000, statusDesc=启用, extra={vipLevel=SVIP})
|
- 分析:
status 枚举被成功提取为中文 “启用”。phone 保持原样(因为没有命中 ListVO 的脱敏规则)。extra Map 原样传递。
7.4. 本章总结与视图映射速查
本章我们构建了应用层的“最后一公里”,解决了 BO(业务对象)如何根据不同场景(列表 vs 详情)输出不同 VO(视图对象)的问题。核心在于理解 VO 的单向性 以及如何利用 targetClass 实现映射规则的物理隔离。
遇到以下 3 种视图层映射场景时,请直接 Copy 下方的标准代码模版:
7.4.1. 场景一:一源多配 (Polymorphism)
需求:一个 UserBO 需要同时映射给 UserPO (存库)、UserDetailVO (详情展示)、UserListVO (列表展示)。
方案:使用 @AutoMappers 数组包裹多个 @AutoMapper。
1 2 3 4 5 6 7 8 9 10 11 12
| @Data @AutoMappers({ // 1. 映射到数据库 PO (需导入 JSONUtil) @AutoMapper(target = UserPO.class, imports = {JSONUtil.class, Map.class}), // 2. 映射到详情 VO @AutoMapper(target = UserDetailVO.class), // 3. 映射到列表 VO (需导入 DesensitizedUtil) @AutoMapper(target = UserListVO.class, imports = {DesensitizedUtil.class}) }) public class UserBO { }
|
7.4.2. 场景二:级联取值 (Enum -> String)
需求:BO 中是 UserStatus 枚举对象,VO 中只需要展示它的中文描述 desc。
方案:使用 source 属性进行链式调用,配合 targetClass 限制生效范围。
1 2 3 4 5 6 7 8 9 10
| private UserStatus status;
@AutoMapping( target = "statusDesc", // VO 中的字段名 source = "status.desc", // 级联获取属性 targetClass = UserDetailVO.class // 关键:限定规则仅对 DetailVO 生效 ) private UserStatus status;
|
7.4.3. 场景三:数据脱敏 (String -> String)
需求:列表页展示手机号时需要打码(如 138 **** 0000),详情页展示明文。
方案:使用 expression 调用 Hutool 工具类,配合 targetClass 隔离逻辑。
1 2 3 4 5 6 7 8 9 10
| private String phone;
@AutoMapping( target = "phoneMask", expression = "java(DesensitizedUtil.mobilePhone(source.getPhone()))", targetClass = UserListVO.class ) private String phone;
|