第四章. MapStruct:高级映射逻辑与自定义扩展

第四章. MapStruct:高级映射逻辑与自定义扩展

摘要:前三章我们解决了 80% 的标准映射场景。但在复杂的业务系统中,我们经常面临:“字段转换依赖数据库查询”、“转换后需要计算冗余字段”、“多个源对象合并为一个 DTO” 或 “部分更新已有对象” 等需求。本章将深入 MapStruct 的 抽象类模式生命周期回调表达式注入,掌握处理这剩余 20% 复杂场景的终极武器。

本章学习路径

  1. 表达式注入:在注解中直接嵌入 Java 代码,处理简单的动态逻辑(如时间戳生成)。
  2. 限定符策略:解决“多个转换方法签名冲突”的问题,通过 @Named 精确指定映射逻辑。
  3. 抽象类模式:打破接口限制,通过 abstract class 注入 Spring Service,实现“转换时查库”。
  4. 生命周期回调:利用 @AfterMapping 实现复杂的后置处理(如 VIP 等级计算)。
  5. 增量更新:掌握 @MappingTarget,实现 RESTful PATCH 接口的标准更新模式。

4.1. Java 表达式与限定符

4.1.1. Java 表达式注入 (expression)

有时候,字段的转换逻辑非常简单,写一个专门的工具方法显得多余,但又无法通过简单的 source 映射完成。例如:生成当前的系统时间戳、生成随机 UUID,或者进行简单的字符串拼接。

MapStruct 允许通过 expression 属性直接注入 Java 代码片段。

实战场景:在 UserVO 中增加一个 serverTime 字段,记录接口返回时的服务器时间;增加一个 welcomeMessage,拼接 “Hello, {username}”。

修改 Mapper 接口src/main/java/com/example/demo/convert/UserMapper.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 1. 导入 System 类,以便在表达式中使用
@Mapper(componentModel = "spring", imports = {System.class})
public interface UserMapper {

@Mappings({
// 2. 注入 Java 代码:调用 System.currentTimeMillis()
@Mapping(target = "serverTime", expression = "java(System.currentTimeMillis())"),

// 3. 引用 source 参数:直接使用 source.getUsername()
// 注意:这里的 "entity" 是方法参数名
@Mapping(target = "welcomeMessage", expression = "java(\"Hello, \" + entity.getUsername())")
})
UserVO toVO(UserEntity entity);
}

生成的代码审计

1
2
3
// UserMapperImpl.java
userVO.setServerTime( System.currentTimeMillis() );
userVO.setWelcomeMessage( "Hello, " + entity.getUsername() );

注意expression 中的代码是不受编译器检查的(它是字符串)。如果拼写错误,只有在生成代码阶段(mvn compile)才会报错。因此,仅建议用于极简单的逻辑

4.1.2. 限定符解决冲突 (@Named)

当我们在 Mapper 中定义了多个“类型相同”但“逻辑不同”的转换方法时,MapStruct 会陷入困惑,报 Ambiguous mapping methods 错误。

实战场景:我们需要两个 String -> String 的转换方法:

  1. 普通转换:不做处理。
  2. 脱敏转换:手机号中间 4 位变 *。

Mapper 接口配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Mapper(componentModel = "spring")
public interface UserMapper {

// 1. 定义一个具名方法:使用 @Named 标记
@Named("maskPhone")
default String maskPhone(String phone) {
if (phone == null || phone.length() != 11) return phone;
return phone.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
}

@Mappings({
// 2. 指定使用 "maskPhone" 规则
@Mapping(source = "phoneNumber", target = "phoneNumber", qualifiedByName = "maskPhone"),

// 3. 未指定 qualifiedByName,默认使用直连赋值
@Mapping(source = "username", target = "username")
})
UserVO toVO(UserEntity source);
}

4.2. 自定义方法与抽象类模式

接口(Interface)最大的局限性在于无法持有状态(无法定义成员变量)。但在企业级开发中,我们经常需要在转换过程中查询数据库(例如:将 deptId 转换为 deptName)。这时,我们需要将 @Mapper 标记在 抽象类 (abstract class) 上。

4.2.1. 抽象类注入 Service

需求UserEntity 中只有 deptId,但 UserVO 需要展示 deptNamedeptName 需要调用 DeptService 查询。

步骤 1:定义 Service 模拟

1
2
3
4
5
6
@Service
public class DeptService {
public String getDeptName(Long deptId) {
return "技术部-" + deptId; // 模拟查库返回
}
}

步骤 2:改造 Mapper 为抽象类

文件路径src/main/java/com/example/demo/convert/UserMapper.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 1. 改为 abstract class
@Mapper(componentModel = "spring")
public abstract class UserMapper {

// 2. 注入 Service (使用 Autowired)
@Autowired
protected DeptService deptService;

// 3. 定义抽象方法,交给 MapStruct 实现基础映射
// 使用 expression 调用内部方法
@Mapping(target = "deptName", expression = "java(convertDeptName(entity.getDeptId()))")
public abstract UserVO toVO(UserEntity entity);

// 4. 自定义转换逻辑:调用 Service
protected String convertDeptName(Long deptId) {
if (deptId == null) return "未知部门";
return deptService.getDeptName(deptId);
}
}

生成的代码审计

1
2
3
4
5
6
7
8
9
10
11
@Component
public class UserMapperImpl extends UserMapper {
@Override
public UserVO toVO(UserEntity entity) {
// ...
// 生成的代码直接调用了父类的 convertDeptName 方法
// 而父类通过 Spring 注入持有了 DeptService
userVO.setDeptName( convertDeptName(entity.getDeptId()) );
return userVO;
}
}

4.2.2. 生命周期回调 (@AfterMapping)

有时候,我们需要在 MapStruct 完成所有自动赋值 之后,再执行一些复杂的逻辑。比如建立双向关联,或者计算一些依赖于多个字段的属性。

实战场景
UserEntity 转换为 UserVO 后,需要根据 balance (余额) 计算 vipLevel (VIP 等级)。这个逻辑写在 expression 里太乱,适合用后置处理。

在抽象类 UserMapper 中增加

1
2
3
4
5
6
7
8
@AfterMapping // 映射完成后自动回调
protected void calculateVipLevel(UserEntity source, @MappingTarget UserVO target) {
if (source.getBalance() != null && source.getBalance().doubleValue() > 10000) {
target.setVipLevel("DIAMOND");
} else {
target.setVipLevel("NORMAL");
}
}

生成的代码审计

1
2
3
4
5
6
7
8
9
public UserVO toVO(UserEntity entity) {
UserVO userVO = new UserVO();
// ... 自动映射逻辑 ...

// 最后一步:调用后置处理
calculateVipLevel( entity, userVO );

return userVO;
}

4.3. 多源参数与对象更新

4.3.1. 多对一映射 (Multi-Source)

有时候,一个 VO 的数据来源不只是一个 Entity,而是来自多个对象。MapStruct 支持在方法中传入多个参数。

实战场景
UserDetailVO 需要包含 UserEntity 的基础信息,以及 AccountEntity 的账户信息。

1
2
3
4
5
@Data
public class AccountEntity {
private String bankCard;
private BigDecimal creditScore;
}

Mapper 接口配置

1
2
3
4
5
6
7
8
@Mappings({
// 1. 指定 source 参数名:user.id -> id
@Mapping(source = "user.id", target = "id"),
// 2. 指定 source 参数名:account.bankCard -> cardNo
@Mapping(source = "account.bankCard", target = "cardNo")
})
// 传入两个源对象:user 和 account
public abstract UserDetailVO toDetailVO(UserEntity user, AccountEntity account);

注意:当参数超过一个时,@Mapping 中的 source 必须指定参数名称前缀(如 user.account.),否则 MapStruct 不知道去哪个对象里找属性。

4.3.2. 对象更新模式 (@MappingTarget)

通常我们是 toVO(创建新对象)。但在 “修改用户信息” 的接口中,我们通常是先从数据库查出 UserEntity(旧对象),然后用前端传来的 UserDTO(新数据)去 更新 这个旧对象,而不是 new 一个新的。

这就需要用到 @MappingTarget

实战场景updateUser(UserDTO dto, UserEntity entity)。将 DTO 中非空的字段更新到 Entity 中。

1
2
3
4
5
6
7
8
9
10
@Mappings({
@Mapping(target = "id", ignore = true), // ID 不允许修改
@Mapping(target = "createTime", ignore = true) // 创建时间不允许修改
})
// 1. @BeanMapping(nullValuePropertyMappingStrategy = IGNORE)
// 关键配置:DTO 中为 null 的字段,不要覆盖 Entity 中的旧值
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)

// 2. @MappingTarget 标记这是要被更新的目标对象,而不是源对象
public abstract void updateEntityFromDto(UserDTO dto, @MappingTarget UserEntity entity);

生成的代码审计

1
2
3
4
5
6
7
8
public void updateEntityFromDto(UserDTO dto, UserEntity entity) {
if ( dto == null ) return;

if ( dto.getUsername() != null ) { // 自动生成了 null 判断
entity.setUsername( dto.getUsername() );
}
// ...
}

4.4. Web 层闭环验证

我们更新 MapStructTestController,验证上述高级功能。

4.4.1. 更新 Controller

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ... 注入 UserMapper

@PutMapping("/update/{id}")
public UserEntity testUpdate(@PathVariable Long id, @RequestBody UserDTO dto) {
// 1. 模拟查库 (Old State)
UserEntity entity = new UserEntity();
entity.setId(id);
entity.setUsername("old_name");
entity.setEmailAddress("old@test.com");

// 2. 增量更新 (只更新 dto 中不为 null 的字段)
userMapper.updateEntityFromDto(dto, entity);

// 3. 返回更新后的对象
return entity;
}

4.4.2. Postman 验证

请求PUT /test/mapstruct/update/100
Body

1
2
3
4
{
"username": "new_name"
// 注意:没有传 emailAddress,预期旧值保留
}

响应

1
2
3
4
5
{
"id": 100,
"username": "new_name", // 被更新
"emailAddress": "old@test.com" // 旧值保留 (nullValuePropertyMappingStrategy = IGNORE 生效)
}

4.5. 本章总结与高阶场景速查

本章我们突破了“纯字段映射”的限制,掌握了如何将 Spring 容器、复杂计算以及生命周期管理融入 MapStruct。

遇到以下 5 种高阶场景时,请直接 Copy 下方的标准代码模版:

4.5.1. 场景一:注入 Java 代码 (简单逻辑)

需求:不写额外方法,直接在注解里调用 System.currentTimeMillis() 或生成 UUID
方案:使用 expression="java(...)"

1
2
3
4
5
6
7
8
9
10
@Mapper(componentModel = "spring", imports = {UUID.class}) // 1. 记得导入类
public interface UserMapper {

@Mappings({
// 2. 直接写 Java 代码 (注意:编译器不检查字符串内容的语法)
@Mapping(target = "uuid", expression = "java(UUID.randomUUID().toString())"),
@Mapping(target = "ts", expression = "java(System.currentTimeMillis())")
})
UserVO toVO(UserEntity entity);
}

4.5.2. 场景二:解决多意图冲突 (@Named)

需求:同一个字段(如手机号),在后台需要“明文展示”,在前台需要“脱敏展示”。
方案:使用 @Named 定义别名,并在 @Mapping 中通过 qualifiedByName 指定。

1
2
3
4
5
6
7
8
// 1. 定义两个不同逻辑的方法,用 @Named 区分
@Named("mask")
default String mask(String str) {
return str == null ? null : str.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
}
// 2. 在映射时明确指定使用哪一个
@Mapping(source = "mobile", target = "mobile", qualifiedByName = "mask")
UserVO toMaskedVO(UserEntity entity);

4.5.3. 场景三:注入 Spring Service (查库映射)

需求:转换过程中需要查数据库(例如:根据 deptId 查询 deptName)。
方案:使用 抽象类 (abstract class) 替代接口,并利用 @Autowired 注入 Bean。

1
2
3
4
5
6
7
8
9
@Mapper(componentModel = "spring")
public abstract class UserMapper { // 1. 必须是抽象类

@Autowired
protected DeptService deptService; // 2. 注入 Service (必须是 protected)

@Mapping(target = "deptName", expression = "java(deptService.getName(entity.getDeptId()))")
public abstract UserVO toVO(UserEntity entity);
}

4.5.4. 场景四:复杂后置处理 (@AfterMapping)

需求:字段 A 的值依赖于字段 B 和 C 的运算结果(例如:根据余额计算 VIP 等级),无法通过简单的 source 搞定。
方案:使用生命周期回调,在自动转换完成后执行自定义逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public abstract class UserMapper {

public abstract UserVO toVO(UserEntity entity);

// 自动映射完成后,MapStruct 会自动调用此方法
@AfterMapping
protected void after(@MappingTarget UserVO vo, UserEntity entity) {
if (entity.getBalance() > 10000) {
vo.setVipLevel("DIAMOND");
} else {
vo.setVipLevel("NORMAL");
}
}
}

4.5.5. 场景五:增量更新 (Patch 接口)

需求:前端只传了修改过的字段(其他为 null),后端更新数据库时,不能把数据库里原有的值覆盖为 null。
方案:使用 @MappingTarget 配合 IGNORE 策略。

1
2
3
4
// 1. 核心策略:源字段为 null 时,忽略赋值 (即保留目标对象原值)
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
// 2. @MappingTarget 标记 entity 是被更新的对象
void updateEntity(UserDTO dto, @MappingTarget UserEntity entity);

4.5.6. 核心避坑指南

在使用上述高级特性时,请务必注意以下三点,否则编译必报错:

  1. 抽象类注入陷阱
    • 注入的 Service 变量必须使用 protected 修饰符。如果用 private,MapStruct 生成的子类(Impl)无法访问该变量,导致空指针或编译错误。
  2. 多源参数命名陷阱
    • 当方法有多个入参时(如 toVO(User u, Account a)),@Mapping必须 指定参数前缀(如 source = "u.id")。如果不指定前缀,MapStruct 不知道去哪个对象找 id
  3. 表达式导包陷阱
    • expression 中使用 UUIDLocalDate 等非 java.lang 包下的类时,必须在 @Mapper(imports = {UUID.class}) 中显式注册,或者在表达式里写全限定名(java.util.UUID...)。