第七章. MapStruct-Plus:多态视图(VO)与精细化输出

第七章. MapStruct-Plus:多态视图(VO)与精细化输出

摘要:在上一章,我们完成了数据从业务层 (BO) 到持久层 (PO) 的双向流转。本章我们将视角转向“输出端”。在实际业务中,BO 处理完的数据往往需要转换为 VO (View Object) 才能返回给前端。本章我们将重点讲解如何利用 @AutoMappers 实现“一个 BO 对应多个 VO”,并利用 targetClass 精确控制 单向输出 时的特殊逻辑(如枚举转中文、手机号脱敏),避免产生不必要的双向映射冗余。

本章学习路径

  1. 架构回顾:明确 DTO(入) -> BO(核) -> PO(存) 与 PO(取) -> BO(核) -> VO(出) 的单向数据流。
  2. 多态配置:使用 @AutoMappers 定义 BO 到 PO/VO 的多路映射。
  3. 精准输出:利用 targetClass 实现仅针对 VO 的单向格式化逻辑(Enum -> String),拒绝过度设计。
  4. 闭环验证:验证数据库读取数据后,分别输出为“详情视图”和“列表视图”的效果。

7.1. 视图层设计:VO 只是“显示器”

在开始映射之前,我们需要明确 VO 的定位:它只是数据的“显示器”,只负责出,不负责进。因此,我们在设计 VO 映射时,只需要关注 BO -> VO 的正向过程,不需要考虑 VO -> BO 的逆向过程。

在绝大多数标准的业务架构中,VO (View Object) 仅作为 输出对象 回显给前端,前端提交数据时使用的是 DTO (Input Object)

  1. 入站 (Write): 前端 (DTO) -> Controller -> Service (DTO转BO) -> BO (业务处理) -> Mapper (BO转PO) -> 数据库
    • 关注点:BO 到 PO 的转换(如 Map 转 JSON 串,Enum 转 int)。
  2. 出站 (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;

/**
* 视图对象 (View Object) - 仅用于输出,不参与反向映射
* 通过 @AutoMapper 注解明确标记为只读对象,反向映射时会忽略无法映射的字段
*/
@Data
@AutoMapper(target = UserBO.class)
public class UserDetailVO {
private String id;
private String username;
private String phone;
// 核心差异:BO 中是 Enum,PO 中是 Int,这里是 String (中文描述)
// 此字段在反向映射时会被忽略,因为无法从 String 转换为 UserStatus 枚举
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 上配置三种规则:

  1. 对 PO (双向):JSON 字符串与 Map 的互转(存取必备)。
  2. 对 DetailVO (单向):提取枚举的中文描述。
  3. 对 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
// 1. 定义一源多配:BO 连接 PO 和 两个 VO
@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;

/**
* 场景一:持久化层的双向映射 (BO <-> PO)
* 必须配置 targetClass = UserPO.class,防止规则污染 VO
*/
@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;

/**
* 场景二:列表页的单向脱敏 (BO -> ListVO)
* 这里的 source 指的是 BO 自己
*/
@AutoMapping(
target = "phoneMask",
expression = "java(DesensitizedUtil.mobilePhone(source.getPhone()))",
targetClass = UserListVO.class // 仅对 ListVO 生效
)
private String phone;

private String source;

/**
* 场景三:详情页的单向展示 (BO -> DetailVO)
* 利用 source 属性直接提取枚举中的 desc 字段
* 不需要配置 @ReverseAutoMapping,因为我们不会用 VO 反推 BO
*/
@AutoMapping(
target = "statusDesc",
source = "status.desc", // 自动生成 userBO.getStatus().getDesc()
targetClass = UserDetailVO.class // 仅对 DetailVO 生效
)
private UserStatus status;
}

7.2.1. 代码精简解析

经过优化,现在的代码逻辑非常清晰:

  1. 去除了冗余的反向映射:对于 phonestatus,我们只配置了 @AutoMapping(去 VO),删除了 @ReverseAutoMapping。这符合 VO 只读的架构特性,代码量减少了一半。
  2. 保留了必要的双向映射:对于 extra 字段,因为它是要存入数据库并读出来的,所以必须保留 PO 维度的双向转换(JSON <-> Map)。
  3. targetClass 的精准控制
    • status.desc 的提取只会在生成 UserDetailVO 时发生。
    • DesensitizedUtil 的调用只会在生成 UserListVO 时发生。
    • JSONUtil 的调用只会在生成 UserPO 时发生。
    • 三者互不干扰,彻底解决了“多目标转换时的字段冲突”问题。

本节小结

  1. 架构先行:代码是为架构服务的。明确了 VO 仅用于输出的定位后,我们可以大胆砍掉 VO -> BO 的反向映射代码。
  2. 隔离原则:在 @AutoMappers 场景下,习惯性地 为每一个 @AutoMapping 加上 targetClass 属性,是防止编译报错和逻辑混淆的最佳实践。
  3. 级联取值source = "status.desc" 是处理“对象转字符串”(如枚举转中文、关联对象转名称)的神器,它能省去在 BO 中编写专门 Getter 方法的麻烦。

速查代码

1
2
3
// 单向输出模式:仅在转为 VO 时提取属性,无需反向逻辑
@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;

// 模拟 Service 层返回的 BO 数据 (包含敏感信息)
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;
}

/**
* 场景1:列表页接口
* 预期:UserListVO (手机号脱敏,无 extra,无 status)
*/
@GetMapping("/list-item")
public UserListVO getUserListItem() {
UserBO bo = mockServiceReturn();

// 转换时指定目标为 ListVO.class
// MSP 会自动匹配 UserBO 中 targetClass = UserListVO.class 的规则
UserListVO vo = converter.convert(bo, UserListVO.class);

System.out.println(">>> 列表视图: " + vo);
return vo;
}

/**
* 场景2:详情页接口
* 预期:UserDetailVO (完整信息,status 转中文)
*/
@GetMapping("/detail")
public UserDetailVO getUserDetail() {
UserBO bo = mockServiceReturn();

// 转换时指定目标为 DetailVO.class
// MSP 会匹配 targetClass = UserDetailVO.class 的规则 (statusDesc)
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
// BO 字段
private UserStatus status;

// 映射配置:仅在转为 UserDetailVO 时,自动调用 this.getStatus().getDesc()
@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
// BO 字段
private String phone;

// 映射配置:仅在转为 UserListVO 时,执行脱敏逻辑
@AutoMapping(
target = "phoneMask",
expression = "java(DesensitizedUtil.mobilePhone(source.getPhone()))",
targetClass = UserListVO.class
)
private String phone;