第二章. MapStruct:字段级映射策略与数据处理

第二章. MapStruct:字段级映射策略与数据处理

摘要:在完成了环境搭建与基础映射后,本章我们将进入 MapStruct 的深水区。面对真实业务中复杂的“脏数据”和严格的格式要求,我们将深入掌握空值防御、常量注入、Java 表达式嵌入以及高精度的数值/日期格式化。同时,我们将构建真实的 Web 接口,通过 Postman 实测来验证反向映射与敏感字段脱敏策略,彻底解决“DTO 到 Entity”的数据回流难题。

本章学习路径

  1. 空值防御体系:通过 defaultValueconstantexpression 构建三级防御机制,杜绝 NPE(空指针异常)。
  2. 高级表达式:在映射中嵌入 Java 代码与依赖导入,解决动态值生成问题。
  3. 格式化与隐式转换:掌握 BigDecimal 货币精度控制与 LocalDateTime 的双向格式化,剖析框架内部的隐式转换表。
  4. 反向继承实战:利用 @InheritInverseConfiguration 实现高效的“数据回流”,并配合 ignore 完成安全脱敏。
  5. 闭环验证:编写 Controller 接口,结合 Postman 与源码断点,验证所有策略的运行时表现。

2.1. 默认值与常量控制策略

在上一章的实战中,我们留下了两个悬念:UserVO 中的 statusDesc 为 null,且我们需要为前端返回一个固定的业务来源标识。在企业级开发中,数据库中的数据往往是不完整的,或者需要根据业务规则强制覆盖某些字段。

MapStruct 提供了三个维度的控制属性,它们的优先级和触发时机各不相同,混用时极易产生误解。

2.1.1. 三级赋值策略详解

我们需要在 UserMapper 中通过具体的场景来区分这三个属性:

  1. defaultValue (兜底策略):仅当 源字段为 null 时生效。
    • 场景:如果用户未设置邮箱,显示 “暂无邮箱”。
  2. constant (强制策略)无视源字段,始终强制赋值。
    • 场景:API 接口版本号、固定的业务类型标识。
  3. defaultExpression (动态兜底):当 源字段为 null 时,执行一段 Java 代码。
    • 场景:如果数据库中 trace_id 为空,自动生成一个 UUID。

2.1.2. 增强实体类与 VO

为了演示这些特性,我们需要先对 UserEntityUserVO 进行微调,增加测试字段。

修改文件src/main/java/com/example/demo/entity/UserEntity.java

1
2
3
4
5
6
7
@Data
public class UserEntity {
// ... 原有字段
private String emailAddress; // 可能为 null
private String traceId; // 可能为 null
private String source; // 数据库中可能有旧值,但我们需要强制覆盖
}

修改文件src/main/java/com/example/demo/vo/UserVO.java

1
2
3
4
5
6
7
@Data
public class UserVO {
// ... 原有字段
private String email;
private String traceId;
private String source; // 这是一个固定值字段
}

2.1.3. 编写进阶 Mapper 配置

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

这里我们需要特别注意 expression 的写法。由于 MapStruct 生成代码时不知道类的包路径,如果我们在表达式中使用了 java.util.UUID 等类,要么写全限定名,要么在 @Mapper 注解中配置 imports

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
package com.example.demo.convert;

import com.example.demo.entity.UserEntity;
import com.example.demo.vo.UserVO;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Mappings;
import java.util.UUID; // 1. 导入需要使用的类

@Mapper(componentModel = "spring", imports = {UUID.class}) // 2. 注册导入
public interface UserMapper {

@Mappings({
// 策略 1 (defaultValue): 源 emailAddress 为空时,使用兜底值
@Mapping(source = "emailAddress", target = "email", defaultValue = "no-email@example.com"),

// 策略 2 (constant): 强制覆盖,不看 entity 中是否有 source
@Mapping(target = "source", constant = "PC_BROWSER"),

// 策略 3 (defaultExpression): traceId 为空时,调用 Java 代码生成
// 注意:因为上面 imports 引入了 UUID,这里可以直接写 UUID.randomUUID()
@Mapping(source = "traceId", target = "traceId", defaultExpression = "java(UUID.randomUUID().toString())"),

// 保持之前的深度映射
@Mapping(source = "address.city", target = "cityName")
})
UserVO toVO(UserEntity entity);
}

2.1.4. 生成代码深度审计

执行 mvn compile,打开 target/generated-sources/.../UserMapperImpl.java。我们要验证 MapStruct 是否按照预期的逻辑生成了 if-else 代码。

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
@Override
public UserVO toVO(UserEntity entity) {
if ( entity == null ) {
return null;
}
UserVO userVO = new UserVO();

// 1. defaultValue 的实现:标准的判空分支
if ( entity.getEmailAddress() != null ) {
userVO.setEmail( entity.getEmailAddress() );
}
else {
userVO.setEmail( "no-email@example.com" );
}

// 2. defaultExpression 的实现:嵌入了我们写的 Java 代码
if ( entity.getTraceId() != null ) {
userVO.setTraceId( entity.getTraceId() );
}
else {
// 直接调用了 UUID.randomUUID()
userVO.setTraceId( UUID.randomUUID().toString() );
}

// 3. constant 的实现:完全没有读取 entity.getSource(),直接赋值
userVO.setSource( "PC_BROWSER" );

// ... 其他逻辑
return userVO;
}

从源码中可以清晰看到:constant 的优先级最高,完全忽略源数据;而 defaultValuedefaultExpression 则是互斥的,都是在 else 分支中生效。


2.2. 类型转换与高精度格式化

在金融或报表系统中,数据格式化是重灾区。前端需要 yyyy-MM-dd 格式的日期,或者带有两位小数的金额字符串。如果交给前端处理,可能会出现时区不一致或精度丢失问题,因此后端转换是最佳实践。

2.2.1. 隐式类型转换机制

MapStruct 强大之处在于其内置的“隐式转换表”。在未配置任何注解的情况下,以下转换会自动发生:

  1. 基本类型 <-> 包装类int <-> Integer (自动判空与拆装箱)。
  2. 基本类型 <-> Stringlong <-> String (调用 String.valueOf)。
  3. 枚举 <-> String:调用枚举的 name() 方法。

但是,对于 DateLocalDateTimeBigDecimal,我们需要显式控制格式。

2.2.2. 日期与数值格式化实战

我们在 UserEntity 中增加金额字段,并在 Mapper 中配置格式化规则。

修改 Entity:增加 BigDecimal balance
修改 VO:增加 String balanceStrString createTimeStr

UserMapper.java 配置更新

1
2
3
4
5
6
7
8
9
10
11
12
@Mappings({
// ... 前面的配置保持不变

// 1. 日期格式化:LocalDateTime -> String
@Mapping(source = "createTime", target
= "createTimeStr", dateFormat = "yyyy-MM-dd HH:mm:ss"),

// 2. 数字格式化:BigDecimal -> String
// 使用 DecimalFormat 模式:保留两位小数,不足补 0
@Mapping(source = "balance", target = "balanceStr", numberFormat = "#0.00")
})
UserVO toVO(UserEntity entity);

2.2.3. 源码验证

观察生成的代码,重点关注 DateTimeFormatterDecimalFormat 的使用:

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
// UserMapperImpl.java

// 1. 日期类型转换:LocalDateTime -> 格式化字符串(线程安全+自定义格式)
if ( entity.getCreateTime() != null ) {
// 核心优势:使用 DateTimeFormatter(JDK8+ 推荐),相比 SimpleDateFormat 具备线程安全特性
// 无需手动创建全局静态实例,MapStruct 自动生成局部格式化器,既安全又避免并发问题
// 自定义格式 "yyyy-MM-dd HH:mm:ss",满足业务场景下的日期展示需求(如页面显示、接口返回)
userVO.setCreateTimeStr( DateTimeFormatter.ofPattern( "yyyy-MM-dd HH:mm:ss" )
.format( entity.getCreateTime() ) );
}

// 2. 金额类型转换:数字类型(如 BigDecimal/Double)-> 格式化字符串(保留两位小数)
if ( entity.getBalance() != null ) { // 空值防御:避免对 null 金额执行格式化操作
// 调用自定义工具方法创建 DecimalFormat,统一管理金额格式化规则
// 格式 "#0.00" 表示:强制保留两位小数,整数部分无前置零(如 100.00、2.50、0.80)
userVO.setBalanceStr( createDecimalFormat( "#0.00" ).format( entity.getBalance() ) );
}

/**
* 金额格式化工具方法(MapStruct 自动生成,可复用)
* 作用:统一配置 DecimalFormat 的核心参数,保证格式化逻辑一致性
*/
private DecimalFormat createDecimalFormat( String numberFormat ) {
DecimalFormat df = new DecimalFormat( numberFormat ); // 传入自定义格式模板
// 关键配置:设置解析时将数字解析为 BigDecimal 类型,避免浮点数精度丢失
// 尤其适用于金额、汇率等对精度要求极高的场景
df.setParseBigDecimal( true );
return df;
}

2.3. 反向映射与敏感数据脱敏

开发中常见的场景是:查询时 Entity 转 VO,保存时 VO 转 Entity。这两个过程通常是“镜像”的,但有两个核心区别:

  1. 反向:源和目标颠倒。
  2. 脱敏/忽略:前端传来的 VO 不应该包含 id(数据库自增)、createTime(自动生成)或 password(不应明文传输),或者后端在转换回 Entity 时必须忽略这些字段以防止被恶意覆盖。

2.3.1. @InheritInverseConfiguration 继承配置

我们不需要把 @Mapping 注解再反着写一遍。MapStruct 提供了“继承反向配置”的功能。

UserMapper.java 新增方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ... 正向 toVO 方法 ...

/**
* 反向转换:VO -> Entity
* 场景:用户修改个人信息
*/
// 1. 自动继承 toVO 的配置并反转 (如 email -> emailAddress)
// MapStruct 会自动反转逻辑:包括属性名的反转,以及 dateFormat 的反转 (String -> LocalDateTime)
@InheritInverseConfiguration(name = "toVO")

// 2. 覆盖配置:在这里配置的属性,会覆盖掉“继承”来的逻辑
@Mappings({
@Mapping(target = "id", ignore = true), // 防止恶意修改 ID
@Mapping(target = "password", ignore = true), // 密码通过单独接口修改

// 3. 覆盖 CreateTime:
// 虽然 toVO 中配置了 dateFormat,但我们不希望前端修改创建时间,所以直接 ignore
@Mapping(target = "createTime", ignore = true),

// 4. 特殊处理:前端传来的 source 是 "PC_BROWSER",但数据库我们想存 "USER_SUBMIT"
@Mapping(target = "source", constant = "USER_SUBMIT"),
})
UserEntity toEntity(UserVO vo);

关键原理解析

  • 继承机制@InheritInverseConfiguration(name = "toVO") 会自动查找 toVO 方法的配置。
    • 它发现 toVO 中有 emailAddress -> email,于是它自动生成 email -> emailAddress
    • 它发现 toVO 中有 createTime 的格式化,于是它本该自动生成 String 解析为 LocalDateTime 的代码。
  • 覆盖机制:我们在 @Mappings 中手动写了 createTime ignore = true
    • 规则:手动配置优先级 > 继承配置。
    • 结果:MapStruct 放弃了自动生成的日期解析代码,转而直接忽略该字段。

常见报错警示:如果你看到 Target property "xxx" must not be mapped more than once,说明你在同一个 @Mappings 数组里对同一个字段写了两行配置(比如一行写格式化,一行写忽略),请务必删除其中一行,保留你最终想要的那一个。


2.4. Web 层闭环验证

光看代码不够,我们需要启动 Spring Boot,通过真实的 HTTP 请求来验证这一切。

2.4.1. 搭建测试 Controller

我们在 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
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
package com.example.demo.controller;

import com.example.demo.convert.UserMapper;
import com.example.demo.entity.Address;
import com.example.demo.entity.UserEntity;
import com.example.demo.vo.UserVO;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@RestController
@RequestMapping("/test/mapstruct")
@RequiredArgsConstructor // 使用构造器注入 UserMapper
public class MapStructTestController {

private final UserMapper userMapper;

/**
* 验证:Entity -> VO
* 测试点:空值兜底、格式化、常量注入
*/
@GetMapping("/toVO")
public UserVO testToVO() {
// 1. 模拟一个 "脏" 数据:很多字段是 null
UserEntity entity = new UserEntity();
entity.setId(1001L);
entity.setUsername("admin");
entity.setCreateTime(LocalDateTime.now());
entity.setBalance(new BigDecimal("1234.567")); // 测试精度截断
// 注意:emailAddress, traceId, source 都是 null

// 模拟嵌套对象
Address addr = new Address();
addr.setCity("Beijing");
entity.setAddress(addr);

return userMapper.toVO(entity);
}

/**
* 验证:VO -> Entity
* 测试点:反向映射、字段忽略
*/
@PostMapping("/toEntity")
public UserEntity testToEntity(@RequestBody UserVO vo) {
// 模拟前端传入 VO,观察转换后的 Entity
return userMapper.toEntity(vo);
}
}

2.4.2. 验证场景一:正向转换与格式化

启动项目,使用浏览器或 Postman 访问 GET http://localhost:8080/test/mapstruct/toVO

预期响应结果

1
2
3
4
5
6
7
8
9
10
{
"id": 1001,
"username": "admin",
"email": "no-email@example.com", // 验证 defaultValue 生效
"cityName": "Beijing", // 验证 深度映射 生效
"balanceStr": "1234.57", // 验证 numberFormat 四舍五入生效
"createTimeStr": "2025-12-07 10:30:00",// 验证 dateFormat 生效
"source": "PC_BROWSER", // 验证 constant 生效
"traceId": "d9e8f7..." // 验证 defaultExpression 生成了 UUID
}

分析

  • balance 原值 1234.567 被格式化为 1234.57,符合 #0.00 的四舍五入规则。
  • email 原值为 null,正确回退到了默认值。
  • traceId 成功生成了随机串。

2.4.3. 验证场景二:反向转换与脱敏

使用 Postman 发送 POST http://localhost:8080/test/mapstruct/toEntity

请求 Body (JSON)

1
2
3
4
5
6
7
{
"id": 9999,
"username": "hacker",
"email": "hacker@test.com",
"cityName": "Shanghai",
"balanceStr": "500.00"
}

预期响应结果

1
2
3
4
5
6
7
8
9
{
"id": null, // 验证 ignore 生效,前端传的 9999 被丢弃
"username": "hacker",
"emailAddress": "hacker@test.com", // 验证 反向映射 name 自动对应
"address": {
"city": "Shanghai" // 验证 自动创建了 Address 对象并赋值
},
"source": "USER_SUBMIT" // 验证 反向 constant 生效
}

分析

  • 即使前端恶意传递了 id: 9999,转换后的 Entity 中 id 依然为 null,保证了数据库自增 ID 的安全。
  • cityName 成功被还原到了嵌套的 Address 对象中,证明了 MapStruct 在反向转换时的智能对象实例化能力。

2.5. 本章总结与进阶语法速查

本章我们攻克了 MapStruct 最硬核的“数据清洗”与“格式化”难题。为了方便大家在实际开发中直接 Copy 代码,我们将本章的核心技巧浓缩为一份 “场景化速查手册”

2.5.1. 进阶语法速查手册

遇到以下业务需求时,请直接参考本表代码:

业务场景核心方案代码示例
强制赋值
(如:设置固定版本号)
constant@Mapping(target = "version", constant = "v1.0")
(注:完全忽略源字段)
空值兜底
(如:为空时显示 “未知”)
defaultValue@Mapping(source = "name", target = "name", defaultValue = "未知用户")
(注:仅 source 为 null 时生效)
动态生成
(如:为空时生成 UUID)
defaultExpression@Mapping(source = "id", target = "id", defaultExpression = "java(java.util.UUID.randomUUID().toString())")
(注:需配合 imports 或全限定名)
日期格式化
(Date/Time ↔ String)
dateFormat@Mapping(source = "createTime", target = "timeStr", dateFormat = "yyyy-MM-dd HH:mm")
金额格式化
(BigDecimal ↔ String)
numberFormat@Mapping(source = "price", target = "priceStr", numberFormat = "#0.00")
(注:自动处理四舍五入)
反向继承
(VO 转回 Entity)
@InheritInverseConfiguration@InheritInverseConfiguration(name = "toVO")
UserEntity toEntity(UserVO vo);
安全过滤
(如:不修改密码/ID)
ignore = true@Mapping(target = "password", ignore = true)
引入依赖
(配合表达式使用)
imports@Mapper(componentModel = "spring", imports = {UUID.class, LocalDateTime.class})

2.5.2. 核心避坑指南

在运用上述高级特性时,有三条 “铁律” 必须遵守,否则 Bug 极难排查:

  1. 优先级铁律constant > expression > source
    • 如果你配置了 constant,MapStruct 会直接无视你的 source 属性,哪怕源字段有值也不会用。
  2. 反向覆盖铁律@InheritInverseConfiguration 是全量继承。
    • 如果正向转换有“日期格式化”,反向也会自动生成“日期解析”。
    • 必须 手动添加 ignore = true 来覆盖那些你不希望前端修改的敏感字段(如 ID、创建时间)。
  3. 表达式导包铁律
    • expressiondefaultExpression 中写 Java 代码时,MapStruct 不会自动导包。
    • 要么写全限定名(java.util.UUID),要么在 @Mapper(imports = {UUID.class}) 中显式声明。