MapStruct 从入门到精通:完整教程(初级 → 高级,含所有核心用法与实战示例)

在 Java 开发中,对象之间的属性拷贝(DTO ↔ VO ↔ Entity ↔ BO) 几乎是每个项目的”日常刚需”。手写 getter/setter 既枯燥又容易漏字段,用 BeanUtils(Spring/Apache)又有性能问题(反射)和类型不安全的隐患。

MapStruct 是目前业界公认的”最佳对象映射框架”:它在编译期通过注解处理器生成纯 Java 的 getter/setter 调用代码,无反射、零运行时开销、类型安全,同时支持极其灵活的自定义映射规则。

这篇教程覆盖 MapStruct 从 初级 → 中级 → 高级 的全部核心用法,每个特性都配完整可运行的示例,读完即可在项目中落地。


0)MapStruct 是什么 & 为什么选它

0.1 核心定位

MapStruct 是一个编译期代码生成器(JSR 269 Annotation Processor),专门用于简化 Java Bean 之间的映射。

它的工作原理非常直白:

  1. 你定义一个映射接口/抽象类,用 @Mapper 注解标注。
  2. 在接口中声明映射方法(如 UserDTO toDTO(User entity))。
  3. 编译时,MapStruct 自动生成这个接口的实现类,代码就是纯手工风格的 getter/setter 调用
  4. 运行时直接调用生成的实现类,没有反射,没有动态代理

0.2 常见映射方案对比

方案原理性能类型安全灵活性推荐度
手写 getter/setter纯 Java⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐字段少还行,多了想死
Spring BeanUtils反射⭐⭐❌(运行时才抛错)不推荐生产
Apache BeanUtils反射(更慢)绝对不推荐
Dozer / Orika反射 / 字节码⭐⭐⭐⚠️⭐⭐⭐⭐老项目维护
MapStruct编译期生成代码⭐⭐⭐⭐⭐✅ 编译期报错⭐⭐⭐⭐⭐✅ 首选
Selma编译期生成⭐⭐⭐⭐⭐⭐⭐⭐小众,社区弱

一句话结论:新项目直接上 MapStruct,它就是目前的”标准答案”。


1)快速开始:5 分钟跑通第一个 Demo

1.1 环境准备:Maven / Gradle 依赖

Maven(最常用) — 在 pom.xml 中加入:

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
<properties>
<mapstruct.version>1.6.2</mapstruct.version>
<lombok.version>1.18.34</lombok.version>
</properties>

<dependencies>
<!-- MapStruct 核心依赖(运行时需要) -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>

<!-- Lombok(可选,但几乎必装,减少样板代码) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>17</source>
<target>17</target>
<!-- 关键:配置注解处理器 -->
<annotationProcessorPaths>
<!-- MapStruct 处理器 -->
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
<!-- Lombok 处理器(和 MapStruct 一起用必须加) -->
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<!-- Lombok + MapStruct 绑定桥(1.18.16+ 需要) -->
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.2.0</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>

⚠️ 重点提醒:如果同时用 Lombok 和 MapStruct,lombok-mapstruct-binding 这个桥接依赖必须加,否则 MapStruct 看不到 Lombok 生成的 getter/setter,会报错找不到属性。

Gradle(简洁版)

1
2
3
4
5
6
7
8
dependencies {
implementation 'org.mapstruct:mapstruct:1.6.2'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.2'

compileOnly 'org.projectlombok:lombok:1.18.34'
annotationProcessor 'org.projectlombok:lombok:1.18.34'
annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
}

1.2 第一步:准备源对象和目标对象

先来两个最简单的 POJO,故意取几个相同字段名不同字段名

1
2
3
4
5
6
7
8
9
10
// 源对象:数据库实体
@Data
public class User {
private Long id;
private String username;
private String password; // 注意:DTO 里不想要这个字段
private String email;
private Integer age;
private LocalDateTime createTime;
}
1
2
3
4
5
6
7
8
9
// 目标对象:前端返回的 DTO
@Data
public class UserDTO {
private Long id;
private String name; // 和 User.username 名字不同
private String email;
private Integer age;
private String createTimeStr; // 类型也不同,想要格式化后的字符串
}

1.3 第二步:定义 Mapper 接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.factory.Mappers;

@Mapper // 核心注解:告诉 MapStruct 这是一个映射器
public interface UserMapper {

// 获取 Mapper 实例(方式一:工厂方式,最简单)
UserMapper INSTANCE = Mappers.getMapper(UserMapper.class);

/**
* User -> UserDTO
* source = 源字段名,target = 目标字段名
*/
@Mapping(source = "username", target = "name")
UserDTO toDTO(User user);
}

1.4 第三步:编译 & 查看生成的代码

执行 mvn compile 后,MapStruct 会在 target/generated-sources/annotations/ 下生成实现类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 自动生成的代码(和你手写的一模一样!)
public class UserMapperImpl implements UserMapper {

@Override
public UserDTO toDTO(User user) {
if (user == null) {
return null;
}
UserDTO userDTO = new UserDTO();
userDTO.setName(user.getUsername()); // 我们配置的映射
userDTO.setId(user.getId()); // 同名自动映射
userDTO.setEmail(user.getEmail()); // 同名自动映射
userDTO.setAge(user.getAge()); // 同名自动映射
// password 没映射,忽略,正确
// createTimeStr 还没处理,后续高级章节再说
return userDTO;
}
}

1.5 第四步:使用 Mapper

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class DemoTest {
public static void main(String[] args) {
User user = new User();
user.setId(1L);
user.setUsername("zhangsan");
user.setPassword("123456"); // 不会被拷贝到 DTO
user.setEmail("zhangsan@example.com");
user.setAge(25);

// 直接通过 INSTANCE 调用
UserDTO dto = UserMapper.INSTANCE.toDTO(user);

System.out.println(dto);
// UserDTO(id=1, name=zhangsan, email=zhangsan@example.com, age=25, createTimeStr=null)
}
}

搞定!第一个 MapStruct 程序跑通了。小结一下核心机制:

  • 同名同类型字段自动映射id, email, age
  • 不同名字段用 @Mapping(source, target) 指定usernamename
  • 没有配置的字段自动忽略password 没进 DTO,createTimeStr 保持 null)

2)初级篇:80% 场景够用的基础用法

2.1 多字段映射 & 忽略字段(@Mapping 完整参数)

@Mapping 是 MapStruct 最常用的注解,完整参数列表:

1
2
3
4
5
6
7
8
9
10
11
@Mapping(
source = "源字段名", // 源对象中的属性名
target = "目标字段名", // 目标对象中的属性名
ignore = true, // 设为 true 表示忽略这个字段(不映射)
defaultValue = "默认值", // 当源字段为 null 时使用的默认值
constant = "常量值", // 直接给目标字段赋常量,不管源是什么
dateFormat = "yyyy-MM-dd HH:mm:ss", // 日期格式化字符串
numberFormat = "#.00", // 数字格式化字符串
expression = "java(...)", // 自定义 Java 表达式
defaultExpression = "java(...)" // 源为 null 时执行的表达式
)

实战示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Data
public class Order {
private Long orderId;
private String orderNo;
private BigDecimal amount;
private LocalDateTime payTime;
private String remark; // 不想传给前端
private String internalCode; // 内部编码,绝对不能外泄
}

@Data
public class OrderDTO {
private Long id;
private String orderNo;
private String amountStr; // 格式化后的金额
private String payTimeStr; // 格式化后的时间
private String remark;
private String channel = "OFFICIAL"; // 希望固定值
}
1
2
3
4
5
6
7
8
9
10
11
12
@Mapper
public interface OrderMapper {
OrderMapper INSTANCE = Mappers.getMapper(OrderMapper.class);

@Mapping(source = "orderId", target = "id")
@Mapping(source = "amount", target = "amountStr", numberFormat = "#,##0.00")
@Mapping(source = "payTime", target = "payTimeStr", dateFormat = "yyyy-MM-dd HH:mm:ss")
@Mapping(target = "internalCode", ignore = true) // 明确忽略源中的敏感字段
@Mapping(target = "channel", constant = "OFFICIAL")
@Mapping(target = "remark", defaultValue = "该订单暂无备注")
OrderDTO toDTO(Order order);
}

测试:

1
2
3
4
5
6
7
8
9
10
11
Order order = new Order();
order.setOrderId(1001L);
order.setOrderNo("ORD20260803001");
order.setAmount(new BigDecimal("1288.5"));
order.setPayTime(LocalDateTime.of(2026, 8, 3, 14, 30, 0));
// remark 和 internalCode 不设值

OrderDTO dto = OrderMapper.INSTANCE.toDTO(order);
// 输出:
// OrderDTO(id=1001, orderNo=ORD20260803001, amountStr=1,288.50,
// payTimeStr=2026-08-03 14:30:00, remark=该订单暂无备注, channel=OFFICIAL)

⚠️ 小贴士:ignore = true 常用于”目标对象有这个字段但我不想让 MapStruct 管”,也常用于避免多余属性未映射的警告

2.2 集合映射(List / Set / Map)

MapStruct 会自动帮你处理集合,只要单个元素的映射定义好了,集合映射直接声明方法即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Mapper
public interface UserMapper {
UserMapper INSTANCE = Mappers.getMapper(UserMapper.class);

@Mapping(source = "username", target = "name")
UserDTO toDTO(User user);

// ✅ 集合映射:自动循环调用上面的 toDTO
List<UserDTO> toDTOList(List<User> users);

Set<UserDTO> toDTOSet(Set<User> users);

// 甚至 Map 也行(键相同的场景少,但支持)
Map<String, UserDTO> toDTOMap(Map<String, User> userMap);
}

生成的代码:

1
2
3
4
5
6
7
8
9
10
@Override
public List<UserDTO> toDTOList(List<User> users) {
if (users == null) return null;

List<UserDTO> list = new ArrayList<>(users.size());
for (User user : users) {
list.add(toDTO(user)); // 复用单个映射
}
return list;
}

就是这么省心!不需要你写任何循环

2.3 获取 Mapper 实例的三种方式

MapStruct 提供了三种获取 Mapper 实例的方式,适应不同项目风格:

方式一:Mappers.getMapper()(工厂模式,最简单)

适合非 Spring 项目或简单项目:

1
2
3
4
5
6
7
8
@Mapper
public interface UserMapper {
UserMapper INSTANCE = Mappers.getMapper(UserMapper.class);
// ...
}

// 使用
UserDTO dto = UserMapper.INSTANCE.toDTO(user);

方式二:Spring 注入(推荐 SpringBoot 项目)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Mapper(componentModel = "spring")  // ✅ 加这个参数,生成的实现类会加 @Component
public interface UserMapper {
@Mapping(source = "username", target = "name")
UserDTO toDTO(User user);
}

// 使用:直接 @Autowired
@Service
public class UserService {
@Autowired
private UserMapper userMapper;

public UserDTO getUser(Long id) {
User user = userRepository.findById(id);
return userMapper.toDTO(user);
}
}

生成的实现类会自动加上 Spring 的 @Component 注解:

1
2
@Component
public class UserMapperImpl implements UserMapper { ... }

方式三:CDI(Jakarta EE 项目)

1
2
3
4
@Mapper(componentModel = "cdi")
public interface UserMapper {
// ... 生成的实现类会加 @Dependent
}

💡 最佳实践:SpringBoot 项目统一用 componentModel = "spring",不要混用工厂模式和注入模式。

2.4 多个源对象映射到一个目标对象

非常常见的场景:比如返回的 DTO 需要同时从 User 和 Address 里取字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Data
public class Address {
private Long addressId;
private String province;
private String city;
private String detail;
}

@Data
public class UserDetailDTO {
private Long userId;
private String userName;
private String fullAddress; // 省+市+详情拼接
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Mapper
public interface UserDetailMapper {
UserDetailMapper INSTANCE = Mappers.getMapper(UserDetailMapper.class);

/**
* 两个源参数:user 和 address
* 同名字段要指定是从哪个源取
*/
@Mapping(source = "user.id", target = "userId")
@Mapping(source = "user.username", target = "userName")
@Mapping(
target = "fullAddress",
expression = "java( address.getProvince() + address.getCity() + address.getDetail() )"
)
UserDetailDTO toDetailDTO(User user, Address address);
}

使用:

1
2
3
4
5
User user = new User(1L, "zhangsan", "123", "z@x.com", 25, null);
Address addr = new Address(10L, "浙江省", "杭州市", "西湖区文三路 1 号");

UserDetailDTO dto = UserDetailMapper.INSTANCE.toDetailDTO(user, addr);
// UserDetailDTO(userId=1, userName=zhangsan, fullAddress=浙江省杭州市西湖区文三路 1 号)

技巧:当方法有多个参数时,所有 @Mappingsource 建议都写成 “参数名.字段名” 的形式,避免歧义。

2.5 嵌套对象的”扁平化”映射(点号路径)

源对象里嵌套了另一个对象,想把嵌套字段”拍平”到目标对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Data
public class Employee {
private Long empId;
private String empName;
private Department department; // 嵌套对象
}

@Data
public class Department {
private Long deptId;
private String deptName;
private String deptCode;
}

@Data
public class EmployeeDTO {
private Long empId;
private String empName;
private Long deptId; // 想直接拿到 department.deptId
private String deptName; // 想直接拿到 department.deptName
}
1
2
3
4
5
6
7
8
@Mapper
public interface EmployeeMapper {
EmployeeMapper INSTANCE = Mappers.getMapper(EmployeeMapper.class);

@Mapping(source = "department.deptId", target = "deptId") // ✅ 点号访问嵌套字段
@Mapping(source = "department.deptName", target = "deptName")
EmployeeDTO toDTO(Employee emp);
}

生成的代码会自动做 null 检查:

1
2
3
4
if (emp.getDepartment() != null) {
employeeDTO.setDeptId(emp.getDepartment().getDeptId());
employeeDTO.setDeptName(emp.getDepartment().getDeptName());
}

完美!不会因为 department == null 而 NPE。


3)中级篇:项目里一定会遇到的进阶用法

3.1 自定义类型转换:@Qualifier + 自定义方法

dateFormat / numberFormat 搞不定时(比如枚举转中文、字典翻译、复杂计算),可以写自定义转换方法,通过 @Qualifier@Named 精准指定。

场景示例:性别枚举 → 中文描述 + 字典编码翻译

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 性别枚举
public enum Gender {
MALE, FEMALE, UNKNOWN
}

@Data
public class Person {
private Long id;
private String name;
private Gender gender;
private String status; // 字典码:0=禁用,1=启用
}

@Data
public class PersonDTO {
private Long id;
private String name;
private String genderDesc; // 想要:男 / 女 / 未知
private String statusDesc; // 想要:禁用 / 启用
}

方式一:用 @Named 给方法起名字,最清晰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import org.mapstruct.Named;
import org.mapstruct.Qualifier;

// 自定义转换器(也可以直接写在 Mapper 接口里的 default 方法)
public class GenderAndStatusConverter {

@Named("genderToDesc") // 起个名字,映射时引用
public static String genderToDesc(Gender gender) {
if (gender == null) return "未知";
return switch (gender) {
case MALE -> "男";
case FEMALE -> "女";
default -> "未知";
};
}

@Named("statusToDesc")
public static String statusToDesc(String status) {
return "1".equals(status) ? "启用" : "禁用";
}
}

在 Mapper 里 uses = ... 引入转换器,然后用 qualifiedByName 指定:

1
2
3
4
5
6
7
8
@Mapper(uses = GenderAndStatusConverter.class)  // ✅ 引入自定义转换器
public interface PersonMapper {
PersonMapper INSTANCE = Mappers.getMapper(PersonMapper.class);

@Mapping(source = "gender", target = "genderDesc", qualifiedByName = "genderToDesc")
@Mapping(source = "status", target = "statusDesc", qualifiedByName = "statusToDesc")
PersonDTO toDTO(Person person);
}

方式二:直接在 Mapper 里写 default 方法(更内聚,小项目推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Mapper
public interface PersonMapper {
PersonMapper INSTANCE = Mappers.getMapper(PersonMapper.class);

@Mapping(source = "gender", target = "genderDesc", qualifiedByName = "genderToDesc")
@Mapping(source = "status", target = "statusDesc", qualifiedByName = "statusToDesc")
PersonDTO toDTO(Person person);

// 直接写在接口里作为 default 方法,省去外部类
@Named("genderToDesc")
default String genderToDesc(Gender gender) {
if (gender == null) return "未知";
return switch (gender) {
case MALE -> "男";
case FEMALE -> "女";
default -> "未知";
};
}

@Named("statusToDesc")
default String statusToDesc(String status) {
return "1".equals(status) ? "启用" : "禁用";
}
}

两种方式生成的代码一致:

1
2
personDTO.setGenderDesc(genderToDesc(person.getGender()));
personDTO.setStatusDesc(statusToDesc(person.getStatus()));

3.2 全局类型转换:@Mapper + 抽象类 Mapper

有时候你希望所有用到 LocalDateTime → String 的地方都用同一种格式,不想每个字段都写 dateFormat。这时可以用抽象类 Mapper + 自定义通用方法

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

// 统一的时间格式:所有 LocalDateTime → String 都会走这个方法
public String localDateTimeToString(LocalDateTime time) {
return time == null ? null : time.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}

// 反向也可以定义
public LocalDateTime stringToLocalDateTime(String str) {
return str == null ? null : LocalDateTime.parse(str, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}

// BigDecimal → String 统一格式
public String bigDecimalToString(BigDecimal value) {
return value == null ? null : value.setScale(2, RoundingMode.HALF_UP).toPlainString();
}
}

然后你的业务 Mapper 继承这个 BaseMapper,就自动获得了这些全局转换能力:

1
2
3
4
5
@Mapper(componentModel = "spring")
public abstract class OrderMapper extends BaseMapper {
// 不需要再写 dateFormat,自动使用 BaseMapper 中的方法
public abstract OrderDTO toDTO(Order order);
}

💡 继承 vs uses 的选择

  • 继承 BaseMapper:适合”全局通用的转换”(日期、数字格式化等)
  • uses = XxxConverter.class:适合”特定场景才用的转换”(字典翻译、特殊枚举等)

3.3 反向映射(DTO → Entity)与 @InheritInverseConfiguration

实际项目中,你往往需要双向映射:Entity → DTO(查询返回)和 DTO → Entity(保存/更新)。

MapStruct 提供了 @InheritInverseConfiguration 让你不用重复写两遍 @Mapping

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

// 正向:User → UserDTO
@Mapping(source = "username", target = "name")
@Mapping(source = "createTime", target = "createTimeStr", dateFormat = "yyyy-MM-dd HH:mm:ss")
UserDTO toDTO(User user);

// ✅ 反向:UserDTO → User,继承正向配置并自动反转
@InheritInverseConfiguration(name = "toDTO")
@Mapping(target = "password", ignore = true) // 反向时 password 字段要忽略
User toEntity(UserDTO dto);
}

name = "toDTO" 表示”继承 toDTO 方法上的 @Mapping 配置并反向”。如果只有一个正向方法,name 可以省略。

3.4 映射继承:@InheritConfiguration

当你有多个方法需要复用同一套 @Mapping 配置(比如 toDTO 和 updateDTO 都需要同样的字段映射),用 @InheritConfiguration

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

// 基准方法:定义一套完整映射
@Mapping(source = "username", target = "name")
@Mapping(source = "createTime", target = "createTimeStr", dateFormat = "yyyy-MM-dd HH:mm:ss")
UserDTO toDTO(User user);

// ✅ 复用 toDTO 的全部映射配置
@InheritConfiguration(name = "toDTO")
@Mapping(target = "createTimeStr", ignore = true) // 还可以在继承基础上覆盖/追加
UserDTO toSimpleDTO(User user);

// ✅ 甚至可以继承到更新方法上:用 dto 更新 entity
@InheritConfiguration(name = "toDTO")
@Mapping(target = "id", ignore = true)
void updateEntity(UserDTO dto, @MappingTarget User entity);
}

3.5 更新已有对象(@MappingTarget

不是所有映射都要 new 一个新对象。场景:从 DTO 接收参数,更新数据库查出来的 Entity(而不是重新 new):

1
2
3
4
5
6
7
8
9
10
@Mapper(componentModel = "spring")
public interface UserMapper {

/**
* 用 dto 的值更新已有的 entity 对象
* @MappingTarget 标注的参数表示"被更新的目标对象"
*/
@Mapping(source = "username", target = "name")
void updateEntity(UserDTO dto, @MappingTarget User entity);
}

生成的代码(关键点:没有 new User!):

1
2
3
4
5
6
7
8
9
@Override
public void updateEntity(UserDTO dto, User entity) {
if (dto == null) return;

entity.setUsername(dto.getName());
entity.setEmail(dto.getEmail());
entity.setAge(dto.getAge());
// entity.setId(...) 如果有配置也会更新
}

实战用法:

1
2
3
4
5
6
7
8
// 1. 从数据库查出旧数据
User dbUser = userRepository.findById(1L).orElseThrow();

// 2. 用前端传过来的 DTO 更新它(只更新需要的字段)
userMapper.updateEntity(userDTO, dbUser);

// 3. 保存回去
userRepository.save(dbUser); // 只会更新被修改的字段

3.6 null 值策略:nullValueMappingStrategy

MapStruct 提供了好几个控制”null 怎么处理”的配置,一次讲透:

配置注解 / 参数作用可选值
@Mapper(nullValueMappingStrategy = ...)源为 null 时怎么映射到目标RETURN_NULL(默认,返回 null) / SET_TO_DEFAULT(new 空对象/空集合)
@Mapping(nullValuePropertyMappingStrategy = ...)属性为 null 时,目标属性要不要设 nullSET_TO_NULL(默认) / IGNORE(保持目标原值)
@Mapping(nullValueCheckStrategy = ...)什么时候做 null 检查ON_IMPLICIT_CONVERSION(默认,只有需要转换才检查) / ALWAYS / NEVER

最常用场景:更新时不要把 null 刷到数据库

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

/**
* ✅ 核心参数:nullValuePropertyMappingStrategy = IGNORE
* 含义:如果 dto 的某个属性是 null,就不动 entity 对应的值
*/
@Mapping(
target = "id",
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE
)
void updateEntity(UserDTO dto, @MappingTarget User entity);
}

测试效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 数据库查到的旧数据
User dbUser = new User();
dbUser.setId(1L);
dbUser.setUsername("zhangsan");
dbUser.setEmail("old@example.com");
dbUser.setAge(25);

// 前端只传了 name(想改用户名),age 没传是 null
UserDTO dto = new UserDTO();
dto.setName("张三_新名字");
// dto.getAge() 是 null,希望保持 dbUser.getAge() = 25

userMapper.updateEntity(dto, dbUser);

// 结果:
// dbUser.getUsername() = "张三_新名字" ✅ 改了
// dbUser.getAge() = 25 ✅ 没被刷成 null
// dbUser.getEmail() = "old@example.com" ✅ 保持

💡 如果想全局生效这个策略(所有映射都是 null 忽略),直接放在 @Mapper 上:

1
2
3
4
@Mapper(
componentModel = "spring",
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE
)

4)高级篇:复杂场景 & 性能优化

4.1 @Context:在映射方法间传递”上下文参数”

有时候你需要传递一些不属于源对象,但会影响映射结果的动态参数。比如:不同用户看到的金额精度不同、翻译时要带当前语言等。用 @Context 可以优雅解决:

1
2
3
4
5
@Data
public class OrderContext {
private String currency; // 货币单位,拼接金额后缀
private boolean includeTax; // 是否含税计算
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Mapper(componentModel = "spring")
public interface OrderMapper {

@Mapping(
target = "amountWithCurrency",
expression = "java( formatAmount(order.getAmount(), context) )"
)
OrderDTO toDTO(Order order, @Context OrderContext context);

// ✅ 注意:自定义方法里也可以用 @Context 接收
default String formatAmount(BigDecimal amount, @Context OrderContext context) {
if (amount == null) return null;
BigDecimal value = context.isIncludeTax()
? amount.multiply(new BigDecimal("1.13")) // 加 13% 税
: amount;
return value.setScale(2, RoundingMode.HALF_UP) + " " + context.getCurrency();
}
}

使用:

1
2
3
4
5
6
OrderContext ctx = new OrderContext();
ctx.setCurrency("CNY");
ctx.setIncludeTax(true);

OrderDTO dto = orderMapper.toDTO(order, ctx);
// 比如 amountStr = "1456.01 CNY" (含税)

生成的代码中 context 会一路透传下去,不会作为映射的源

4.2 @BeforeMapping / @AfterMapping:映射前后钩子

如果需要在映射开始前结束后做一些额外处理(比如前置校验、后置字段计算、Spring Bean 调用等),用这两个注解:

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
@Mapper(componentModel = "spring")
public abstract class UserMapper {

// ✅ 映射开始前:比如校验、默认值填充
@BeforeMapping
protected void beforeMapping(User user) {
if (user.getUsername() == null || user.getUsername().isBlank()) {
user.setUsername("默认用户名");
}
}

// ✅ 映射结束后:比如计算一些 DTO 专属字段
@AfterMapping
protected void afterMapping(User user, @MappingTarget UserDTO dto) {
// 比如根据年龄打标签
if (dto.getAge() != null && dto.getAge() < 18) {
dto.setAgeTag("未成年");
} else if (dto.getAge() != null && dto.getAge() < 35) {
dto.setAgeTag("青年");
} else {
dto.setAgeTag("中年及以上");
}
}

public abstract UserDTO toDTO(User user);
}

注意:钩子方法必须写在抽象类里(接口里的 default 方法也行,但 @BeforeMapping 写在抽象类更常见),参数类型要能匹配到具体映射方法的源/目标参数。

4.3 在 Mapper 中调用 Spring Bean(@Autowired 注入)

当你用 componentModel = "spring" 时,生成的类是 @Component,因此你可以在抽象类 Mapper 里 @Autowired 任何 Spring Bean(比如字典服务、用户上下文等):

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
@Mapper(componentModel = "spring")
public abstract class OrderMapper {

// ✅ 注入 Spring 管理的 Bean
@Autowired
protected DictService dictService;

@Autowired
protected UserContext userContext;

@Mapping(
target = "statusDesc",
expression = "java( dictService.translate(\"ORDER_STATUS\", order.getStatus()) )"
)
@Mapping(
target = "amountStr",
expression = "java( formatByUserCurrency(order.getAmount()) )"
)
public abstract OrderDTO toDTO(Order order);

// 自定义方法里直接用注入的 Bean
protected String formatByUserCurrency(BigDecimal amount) {
String currency = userContext.getCurrentUserCurrency();
return amount.setScale(2, RoundingMode.HALF_UP) + " " + currency;
}
}

只能用抽象类才能注入 Bean(接口没有字段),这是为什么复杂场景推荐抽象类而不是接口的原因。

4.4 @BeanMapping:单个方法级别的细粒度控制

@BeanMapping 放在映射方法上,专门控制”这一个方法”的行为,优先级比 @Mapper 上的全局配置高:

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

/**
* @BeanMapping 常用参数:
* - ignoreByDefault = true:默认忽略所有字段,只映射显式声明的
* - resultType = 指定返回的具体实现类型(比如子类)
*/
@BeanMapping(ignoreByDefault = true) // ✅ 默认所有字段都不映射
@Mapping(source = "id", target = "id")
@Mapping(source = "username", target = "name")
UserDTO toSimpleDTO(User user); // 只会映射 id + name 两个字段,其他全忽略
}

ignoreByDefault = true 非常适合”只暴露部分字段”的场景(比如给第三方系统的精简 DTO,确保不会误传敏感字段)。

4.5 映射枚举:@ValueMapping

专门处理枚举之间映射的注解,支持默认值和异常抛出:

1
2
3
4
5
6
7
8
public enum OrderStatus {
CREATED, PAID, SHIPPED, DELIVERED, CANCELLED
}

// 假设另一个系统枚举名字不一样
public enum ExternalOrderStatus {
NEW, PAYED, IN_TRANSIT, COMPLETED, ABORTED, UNKNOWN
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Mapper
public interface OrderStatusMapper {
OrderStatusMapper INSTANCE = Mappers.getMapper(OrderStatusMapper.class);

@ValueMapping(source = "CREATED", target = "NEW")
@ValueMapping(source = "PAID", target = "PAYED")
@ValueMapping(source = "SHIPPED", target = "IN_TRANSIT")
@ValueMapping(source = "DELIVERED", target = "COMPLETED")
@ValueMapping(source = "CANCELLED", target = "ABORTED")
ExternalOrderStatus toExternal(OrderStatus status);

/**
* 反向映射,ANY_REMAINING = "剩下所有没映射的都走这里"
* MappingConstants.NULL = 映射为 null
* 也可以写成 target = "UNKNOWN" 映射到具体枚举值
*/
@InheritInverseConfiguration
@ValueMapping(source = MappingConstants.ANY_REMAINING, target = MappingConstants.NULL)
OrderStatus fromExternal(ExternalOrderStatus status);
}

4.6 批量工厂 + 通用基类 Mapper(大型项目架构)

真实项目里往往有几十上百个 Mapper,我们可以做一层统一基类减少重复:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 通用基础 Mapper,定义所有 Mapper 都会用到的 5 个标准方法
* @param <E> Entity 类型
* @param <D> DTO 类型
* @param <Q> Query/QueryDTO 类型
*/
public interface BaseMapper<E, D, Q> {

D toDTO(E entity);

E toEntity(D dto);

List<D> toDTOList(List<E> entityList);

List<E> toEntityList(List<D> dtoList);

void updateEntity(D dto, @MappingTarget E entity);
}

具体业务 Mapper 直接继承即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Mapper(componentModel = "spring",
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
public interface UserMapper extends BaseMapper<User, UserDTO, UserQuery> {

@Override
@Mapping(source = "username", target = "name")
@Mapping(source = "createTime", target = "createTimeStr", dateFormat = "yyyy-MM-dd HH:mm:ss")
UserDTO toDTO(User user);

@Override
@InheritInverseConfiguration(name = "toDTO")
@Mapping(target = "password", ignore = true)
User toEntity(UserDTO dto);

// 其他三个方法(集合 + update)自动继承,自动应用忽略 null 策略
}

这样整个项目的 Mapper 结构统一、策略统一,代码审查也轻松。

4.7 查看 & 调试生成的代码

MapStruct 生成的代码路径:

  • Maven:target/generated-sources/annotations/你的包路径/XxxMapperImpl.java
  • Gradle:build/generated/sources/annotationProcessor/java/main/你的包路径/XxxMapperImpl.java

💡 IDE 调试小技巧:直接在生成的 XxxMapperImpl.java 里打断点,调试时可以一步步看到每个字段是怎么赋值的,排错神器。


5)常见坑 & 排障指南

5.1 ❌ 报错:”Can’t find mapper implementation” 或 NPE

场景UserMapper.INSTANCE.toDTO(...) 空指针,或者 Spring 注入时 NoSuchBeanDefinitionException

排查清单

  1. 是不是只加了 mapstruct 依赖,没加 mapstruct-processor(注解处理器)?
  2. Maven 里是不是忘了在 maven-compiler-plugin<annotationProcessorPaths> 里配置?
  3. 有没有执行过 mvn compile?(target 目录下没生成实现类当然找不到)
  4. Spring 模式是不是忘了加 componentModel = "spring"

5.2 ❌ 报错:”Unknown property ‘xxx’ in return type” / “No property named ‘xxx’”

场景:编译时就报找不到字段。

99% 的原因

  1. Lombok 和 MapStruct 同时用,但没加 lombok-mapstruct-binding
  2. 字段名拼写错了(注意大小写,或者是不是把 source/target 写反了)。
  3. 用了 Java Record 但 Lombok 版本太老。

解决:参考 1.1 节的依赖配置,把三个 annotationProcessorPath 都配齐。

5.3 ❌ 明明忽略了字段,IDE 还警告”未映射的 target property”

这个警告是 MapStruct 提醒你”目标对象有字段没配置映射”。两个解决办法:

方法一(针对单个字段):显式写 @Mapping(target = "xxx", ignore = true)

方法二(全局消除警告,不推荐新手用):在 @Mapper 上加:

1
2
3
4
5
@Mapper(
componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.IGNORE, // 目标有未映射字段不警告
unmappedSourcePolicy = ReportingPolicy.WARN // 源有未映射字段建议保留警告,防止漏字段
)

⚠️ 新手建议保留默认的 WARN,等对映射流程完全熟练了再调。

5.4 ❌ 日期/数字格式化不生效

  • LocalDateTime 要用 dateFormat,但要确保源是 LocalDateTime,目标是 String(反过来也一样)。
  • 如果你用了全局转换方法(比如 BaseMapper 里的),它的优先级比 dateFormat 高。
  • Java 8 时间类型(LocalDate/Time)确保项目里有 jsr310 相关支持(JDK 8+ 自带)。

5.5 ❌ 和 Hutool BeanUtil / Spring BeanUtils 混用导致的 Bug

这不是 MapStruct 的问题,而是团队成员习惯不统一。建议:项目里统一用 MapStruct,禁止再出现任何 BeanUtils.copyProperties。原因:

  1. 反射 BeanUtils 不会调用 MapStruct 的自定义转换,映射出来的数据不一致。
  2. 性能退化且没有类型安全保障。
  3. 出现问题排查链路不清晰。

6)实战:一个完整的”User 增删改查”场景

把前面的知识点串起来,做一个 CRUD 全流程。

6.1 实体与 DTO 定义

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
@Data
@Entity
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private String username;
private String password;
private String email;
private Integer age;
private Gender gender;
private String status; // 0=禁用 1=启用
private LocalDateTime createTime;
private LocalDateTime updateTime;
}

@Data
public class UserCreateDTO { // 新增入参
@NotBlank private String username;
@NotBlank private String password;
private String email;
private Integer age;
private Gender gender;
}

@Data
public class UserUpdateDTO { // 修改入参
@NotNull private Long id;
private String email;
private Integer age;
private Gender gender;
private String status;
}

@Data
public class UserVO { // 返回给前端
private Long id;
private String username;
private String email;
private Integer age;
private String genderDesc; // 男/女/未知
private String statusDesc; // 启用/禁用
private String createTimeStr;
}

6.2 Mapper 定义(抽象类 + Spring Bean)

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
@Mapper(
componentModel = "spring",
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE
)
public abstract class UserMapper {

// ========== UserCreateDTO → User(新增) ==========
public abstract User toEntity(UserCreateDTO dto);

// ========== UserUpdateDTO → User(更新,核心策略:null 不覆盖) ==========
public abstract void updateEntity(UserUpdateDTO dto, @MappingTarget User user);

// ========== User → UserVO(查询返回,含字典翻译) ==========
@Mapping(source = "gender", target = "genderDesc", qualifiedByName = "genderToDesc")
@Mapping(source = "status", target = "statusDesc", qualifiedByName = "statusToDesc")
@Mapping(source = "createTime", target = "createTimeStr", dateFormat = "yyyy-MM-dd HH:mm:ss")
public abstract UserVO toVO(User user);

public abstract List<UserVO> toVOList(List<User> list);

// ========== 内部转换方法 ==========
@Named("genderToDesc")
String genderToDesc(Gender gender) {
if (gender == null) return "未知";
return switch (gender) {
case MALE -> "男";
case FEMALE -> "女";
};
}

@Named("statusToDesc")
String statusToDesc(String status) {
return "1".equals(status) ? "启用" : "禁用";
}
}

6.3 Service 层调用

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
@Service
@RequiredArgsConstructor
public class UserService {

private final UserRepository userRepository;
private final UserMapper userMapper; // ✅ 注入 Mapper
private final PasswordEncoder passwordEncoder;

// 新增
@Transactional
public UserVO create(UserCreateDTO dto) {
User user = userMapper.toEntity(dto);
user.setPassword(passwordEncoder.encode(dto.getPassword()));
user.setStatus("1"); // 默认启用
user.setCreateTime(LocalDateTime.now());
User saved = userRepository.save(user);
return userMapper.toVO(saved);
}

// 修改
@Transactional
public UserVO update(UserUpdateDTO dto) {
User user = userRepository.findById(dto.getId())
.orElseThrow(() -> new RuntimeException("用户不存在"));
// ✅ 用 updateEntity:只更新 DTO 中非 null 的字段
userMapper.updateEntity(dto, user);
user.setUpdateTime(LocalDateTime.now());
return userMapper.toVO(userRepository.save(user));
}

// 查询单个
public UserVO getById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new RuntimeException("用户不存在"));
return userMapper.toVO(user);
}

// 列表
public List<UserVO> list() {
return userMapper.toVOList(userRepository.findAll());
}
}

整个流程零手写 getter/setter,所有字段转换逻辑全部集中在 UserMapper,清晰可维护。


7)总结:MapStruct 学习路线 & 心智模型

最后,给一张”从 0 到熟练”的学习阶梯图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
✅ 第 1 层(入门):同名自动映射 + @Mapping 改名
└─ 能应对 50% 的简单场景

✅ 第 2 层(基础):集合映射 + List/Set + componentModel=spring
└─ 能应对 70% 的日常开发

✅ 第 3 层(熟练):多源对象 + @MappingTarget 更新 + @InheritInverseConfiguration
└─ 能应对 90% 的 CRUD 项目

✅ 第 4 层(进阶):@Named/@Qualifier 自定义转换 + @Context + 空值策略
└─ 能搞定复杂业务规则

✅ 第 5 层(专家):@Before/@AfterMapping + Spring Bean 注入 + 抽象基类架构
└─ 能主导大型项目的映射层设计

一句话心法

MapStruct 本质上就是一个 “帮你手写 getter/setter 的代码模板引擎”。你越理解”它生成的代码和你手写的一样”,用起来就越轻松。遇到任何问题,第一反应去看生成的 XxxMapperImpl.java,90% 的疑问都能秒解。


如果这篇教程对你有帮助,建议收藏 + 在项目里立刻练起来。任何问题欢迎在评论区交流 👇


MapStruct 从入门到精通:完整教程(初级 → 高级,含所有核心用法与实战示例)
https://www.pcboy.com.cn/2026/08/03/MapStruct-从入门到精通完整教程/
作者
chituer
发布于
2026年8月3日
许可协议