第九章. MapStruct-Plus:自定义转换器与生命周期回调

第九章. MapStruct-Plus:自定义转换器与生命周期回调

摘要:在前面的章节中,我们依靠 @AutoMappingexpression 解决了很多字段映射问题。但在面对复杂的业务逻辑时(例如:根据身份证号计算年龄、调用 Redis 补充数据、依赖多字段的联合判断),在注解里写 Java 代码会变得极难维护。本章我们将引入 MapStruct 的 自定义装饰器 (Decorator) 模式,利用 uses 属性和 @AfterMapping 生命周期钩子,以最优雅的 Java 原生代码方式解决复杂的转换需求。

本章学习路径

  1. 痛点分析:理解为什么 expression 不适合处理超过 1 行的复杂逻辑。
  2. 装饰器模式:定义一个独立的 Spring Bean 作为转换辅助类,支持依赖注入。
  3. 生命周期挂载:使用 @AfterMapping 在自动转换完成后“补刀”,执行自定义逻辑。
  4. 实战演练:通过身份证号(BO 字段)自动计算出年龄、性别和星座(VO 字段)。

9.1. 突破注解的局限

在第六章和第七章中,我们使用了类似 expression = "java(JSONUtil.toJsonStr(...))" 的写法。这对于单行静态调用非常完美,但当遇到以下场景时,这种写法就变成了噩梦:

  1. 逻辑复杂:包含 if-else 分支、循环或异常处理。
  2. 依赖注入:转换过程中需要查询数据库或 Redis(例如:把 userId 转为 userName)。
  3. 多字段联动:目标字段的值依赖源对象中的多个属性计算得出。

这时,我们需要将逻辑剥离到专门的 Java 类中,而不是塞在字符串里。


9.2. 引入自定义映射类 (Mapper Uses)

MapStruct Plus 完全兼容 MapStruct 原生的 uses 特性。我们可以定义一个普通的 Java 类(甚至可以是 Spring Bean),然后在 @AutoMapper 中引用它。

9.2.1. 定义需求

假设 UserBO 中有一个身份证号字段 idCard。在转为 UserVO 时,我们需要自动计算出:

  • age (年龄)
  • genderText (性别中文)
  • constellation (星座)

这些字段在 BO 中都不存在,且计算逻辑较复杂,适合使用 HutoolIdcardUtil

9.2.2. 定义辅助类 (CustomMapper)

这是一个普通的 Spring 组件。注意,为了方便 MapStruct 调用,方法的参数需要遵循特定规则。

文件路径src/main/java/com/example/demo/infrastructure/converter/UserCustomMapper.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
package com.example.demo.infrastructure.converter;

import cn.hutool.core.util.IdcardUtil;
import com.example.demo.domain.bo.UserBO;
import com.example.demo.interfaces.vo.UserDetailVO;
import org.mapstruct.AfterMapping;
import org.mapstruct.MappingTarget;
import org.springframework.stereotype.Component;

@Component // 1. 注册为 Spring Bean,支持依赖注入其他 Service
public class UserCustomMapper {

/**
* 生命周期钩子:@AfterMapping
* 此时,基础字段(username, phone 等)已经由 MSP 自动转换完成。
* 我们只需要对 target (VO) 进行补充赋值。
*
* @param source 源对象 (BO)
* @param target 目标对象 (VO),使用 @MappingTarget 标记
*/
@AfterMapping
public void calcIdCardInfo(UserBO source, @MappingTarget UserDetailVO target) {
String idCard = source.getIdCard();

// 1. 安全校验
if (!IdcardUtil.isValidCard(idCard)) {
return; // 身份证非法则不处理
}

// 2. 复杂计算逻辑 (利用 Hutool)
int age = IdcardUtil.getAgeByIdCard(idCard);
String gender = (IdcardUtil.getGenderByIdCard(idCard) == 1) ? "男" : "女";
String constellation = IdcardUtil.getConstellationByIdCard(idCard);

// 3. 填充到 VO
target.setAge(age);
target.setGenderText(gender);
target.setConstellation(constellation);

System.out.println(">>> 自定义转换逻辑执行完毕,计算结果:[年龄:" + age + ", 性别:" + gender + "]");
}
}

关键点解析

  • @AfterMapping:这是 MapStruct 的核心注解,表示该方法会在主转换逻辑执行之后被调用。
  • @MappingTarget:标记哪个参数是“转换结果”。在这里,target 是已经被 MSP 填充了一半的 VO 对象。

9.3. 配置 BO 关联辅助类

现在我们有了 UserCustomMapper,需要告诉 UserBO:“在转换时,请带上这个帮手”。

我们需要修改 UserBO,在 @AutoMapper 中添加 uses 属性。

文件路径src/main/java/com/example/demo/domain/bo/UserBO.java

我们需要先在 UserBO 中添加 idCard 字段,并在 UserDetailVO 中添加对应的展示字段。

步骤 1:更新 UserDetailVO

1
2
3
4
5
6
7
8
9
10
// src/main/java/com/example/demo/interfaces/vo/UserDetailVO.java
@Data
public class UserDetailVO {
// ... 原有字段 ...

// 新增字段,BO 中没有,全靠 UserCustomMapper 计算
private Integer age;
private String genderText;
private String constellation;
}

步骤 2:更新 UserBO 并配置 uses

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.domain.bo;

import com.example.demo.infrastructure.converter.UserCustomMapper; // 导入辅助类
// ... 其他导入 ...

@Data
@AutoMappers({
// ... 其他映射 ...

// 核心修改:在 DetailVO 的映射配置中,使用 uses 引用辅助类
// MSP 会自动把 UserCustomMapper 注入到生成的 MapperImpl 中
@AutoMapper(
target = UserDetailVO.class,
uses = {UserCustomMapper.class}
)
})
public class UserBO {
// ... 原有字段 ...

// 新增身份证字段
private String idCard;
}

9.4. 实战验证:计算逻辑生效

我们更新 Controller,模拟一个带有身份证号的 BO,验证 VO 中是否自动生成了年龄和性别。

文件路径src/main/java/com/example/demo/controller/UserDecoratorController.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
package com.example.demo.controller;

import com.example.demo.domain.bo.UserBO;
import com.example.demo.interfaces.vo.UserDetailVO;
import io.github.linpeilie.Converter;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class UserDecoratorController {

private final Converter converter;

@GetMapping("/users/calc-info")
public UserDetailVO testCalcInfo() {
// 1. 模拟 BO 数据
UserBO bo = new UserBO();
bo.setUsername("Identity_Test");
// 这里填写一个符合规范的测试身份证 (示例为 2000年1月1日出生,男性)
// 注意:生产环境请勿使用真实身份证
bo.setIdCard("110101200001011017");

// 2. 执行转换
// MSP 会自动触发 UserCustomMapper.calcIdCardInfo
return converter.convert(bo, UserDetailVO.class);
}
}

运行结果预期

访问 http://localhost:8080/users/calc-info

控制台输出

1
>>> 自定义转换逻辑执行完毕,计算结果:[年龄:24, 性别:男]

(注:年龄会根据当前年份自动变化)

HTTP 响应

1
2
3
4
5
6
{
"username": "Identity_Test",
"age": 24,
"genderText": "男",
"constellation": "摩羯座"
}

可以看到,虽然 UserBO 里只有一串冷冰冰的数字字符串,但 UserDetailVO 里却展现出了丰富的结构化信息。


9.5. 本章总结与自定义逻辑速查

本章我们突破了注解开发的最后一道防线,掌握了 MapStruct 强大的 Decorator(装饰器)模式。通过引入外部 Java 类和生命周期钩子,我们让映射过程具备了处理复杂业务(如身份证计算、数据库反查)的能力。

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

9.5.1. 场景一:复杂计算与填充 (@AfterMapping)

需求:转换完成后,需要根据 BO 的 idCard 字段,自动计算并填充 VO 的 agegender 字段。逻辑太长,不适合写在 expression 里。
方案:定义 @Component 类,使用 @AfterMapping 钩子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 1. 定义辅助类 (必须注册为 Bean)
@Component
public class UserDecorator {

// 2. 编写补全逻辑
// @MappingTarget 标记转换后的目标对象 (VO)
@AfterMapping
public void calc(UserBO bo, @MappingTarget UserVO vo) {
if (StrUtil.isNotBlank(bo.getIdCard())) {
vo.setAge(IdcardUtil.getAgeByIdCard(bo.getIdCard()));
vo.setGender(IdcardUtil.getGenderByIdCard(bo.getIdCard()) == 1 ? "男" : "女");
}
}
}

// 3. 在 BO 中引用辅助类
@AutoMapper(target = UserVO.class, uses = UserDecorator.class)
public class UserBO { ... }

9.5.2. 场景二:注入 Spring Service (查库映射)

需求:BO 中只有 deptId,VO 需要展示 deptName。需要调用 DeptService 查询数据库。
方案:在辅助类中注入 Service。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Component
@RequiredArgsConstructor
public class DeptDecorator {

private final DeptService deptService; // 1. 注入业务组件

@AfterMapping
public void fillDeptName(UserBO bo, @MappingTarget UserVO vo) {
// 2. 执行数据库查询 (注意性能,批量场景需谨慎)
String name = deptService.getNameById(bo.getDeptId());
vo.setDeptName(name);
}
}