第四章. MapStruct:高级映射逻辑与自定义扩展
摘要:前三章我们解决了 80% 的标准映射场景。但在复杂的业务系统中,我们经常面临:“字段转换依赖数据库查询”、“转换后需要计算冗余字段”、“多个源对象合并为一个 DTO” 或 “部分更新已有对象” 等需求。本章将深入 MapStruct 的 抽象类模式、生命周期回调 及 表达式注入,掌握处理这剩余 20% 复杂场景的终极武器。
本章学习路径
- 表达式注入:在注解中直接嵌入 Java 代码,处理简单的动态逻辑(如时间戳生成)。
- 限定符策略:解决“多个转换方法签名冲突”的问题,通过
@Named 精确指定映射逻辑。 - 抽象类模式:打破接口限制,通过
abstract class 注入 Spring Service,实现“转换时查库”。 - 生命周期回调:利用
@AfterMapping 实现复杂的后置处理(如 VIP 等级计算)。 - 增量更新:掌握
@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
| @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
| userVO.setServerTime( System.currentTimeMillis() ); userVO.setWelcomeMessage( "Hello, " + entity.getUsername() );
|
注意:expression 中的代码是不受编译器检查的(它是字符串)。如果拼写错误,只有在生成代码阶段(mvn compile)才会报错。因此,仅建议用于极简单的逻辑。
4.1.2. 限定符解决冲突 (@Named)
当我们在 Mapper 中定义了多个“类型相同”但“逻辑不同”的转换方法时,MapStruct 会陷入困惑,报 Ambiguous mapping methods 错误。
实战场景:我们需要两个 String -> String 的转换方法:
- 普通转换:不做处理。
- 脱敏转换:手机号中间 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 {
@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 需要展示 deptName。deptName 需要调用 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
| @Mapper(componentModel = "spring") public abstract class UserMapper {
@Autowired protected DeptService deptService;
@Mapping(target = "deptName", expression = "java(convertDeptName(entity.getDeptId()))") public abstract UserVO toVO(UserEntity entity);
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) { 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") })
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) // 创建时间不允许修改 })
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
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 ) { 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
|
@PutMapping("/update/{id}") public UserEntity testUpdate(@PathVariable Long id, @RequestBody UserDTO dto) { UserEntity entity = new UserEntity(); entity.setId(id); entity.setUsername("old_name"); entity.setEmailAddress("old@test.com"); userMapper.updateEntityFromDto(dto, entity); return entity; }
|
4.4.2. Postman 验证
请求:PUT /test/mapstruct/update/100
Body:
1 2 3 4
| { "username": "new_name" }
|
响应:
1 2 3 4 5
| { "id": 100, "username": "new_name", "emailAddress": "old@test.com" }
|
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}) 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
| @Named("mask") default String mask(String str) { return str == null ? null : str.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$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 {
@Autowired protected DeptService deptService;
@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);
@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
| @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
void updateEntity(UserDTO dto, @MappingTarget UserEntity entity);
|
4.5.6. 核心避坑指南
在使用上述高级特性时,请务必注意以下三点,否则编译必报错:
- 抽象类注入陷阱:
- 注入的 Service 变量必须使用
protected 修饰符。如果用 private,MapStruct 生成的子类(Impl)无法访问该变量,导致空指针或编译错误。
- 多源参数命名陷阱:
- 当方法有多个入参时(如
toVO(User u, Account a)),@Mapping 中 必须 指定参数前缀(如 source = "u.id")。如果不指定前缀,MapStruct 不知道去哪个对象找 id。
- 表达式导包陷阱:
- 在
expression 中使用 UUID、LocalDate 等非 java.lang 包下的类时,必须在 @Mapper(imports = {UUID.class}) 中显式注册,或者在表达式里写全限定名(java.util.UUID...)。