程序员scholar 程序员scholar
首页
  • Java 基础

    • JavaSE
    • JavaIO
    • JavaAPI速查
  • Java 高级

    • JUC
    • JVM
    • Java新特性
    • 设计模式
  • Web 开发

    • Servlet
    • Java网络编程
  • 数据结构
  • HTTP协议
  • HTTPS协议
  • 计算机网络
  • Linux常用命令
  • Windows常用命令
  • SQL数据库

    • MySQL
    • MySQL速查
  • NoSQL数据库

    • Redis
    • ElasticSearch
  • 数据库

    • MyBatis
    • MyBatis-Plus
  • 消息中间件

    • RabbitMQ
  • 服务器

    • Nginx
  • Python 基础

    • Python基础
  • Python 进阶

    • 装饰器与生成器
    • 异常处理
    • 标准库精讲
    • 模块与包
    • pip包管理工具
  • Spring框架

    • Spring6
    • SpringMVC
    • SpringBoot
    • SpringSecurity
  • SpringCould微服务

    • SpringCloud基础
    • 微服务之DDD架构思想
  • 日常必备

    • 开发常用工具包
    • Hutoll工具包
    • IDEA常用配置
    • 开发笔记
    • 日常记录
    • 项目部署
    • 网站导航
    • 产品学习
    • 英语学习
  • 代码管理

    • Maven
    • Git教程
    • Git小乌龟教程
  • 运维工具

    • Docker
    • Jenkins
    • Kubernetes
前端 (opens new window)
  • 算法笔记

    • 算法思想
    • 刷题笔记
  • 面试问题常见

    • 十大经典排序算法
    • 面试常见问题集锦
关于
GitHub (opens new window)
首页
  • Java 基础

    • JavaSE
    • JavaIO
    • JavaAPI速查
  • Java 高级

    • JUC
    • JVM
    • Java新特性
    • 设计模式
  • Web 开发

    • Servlet
    • Java网络编程
  • 数据结构
  • HTTP协议
  • HTTPS协议
  • 计算机网络
  • Linux常用命令
  • Windows常用命令
  • SQL数据库

    • MySQL
    • MySQL速查
  • NoSQL数据库

    • Redis
    • ElasticSearch
  • 数据库

    • MyBatis
    • MyBatis-Plus
  • 消息中间件

    • RabbitMQ
  • 服务器

    • Nginx
  • Python 基础

    • Python基础
  • Python 进阶

    • 装饰器与生成器
    • 异常处理
    • 标准库精讲
    • 模块与包
    • pip包管理工具
  • Spring框架

    • Spring6
    • SpringMVC
    • SpringBoot
    • SpringSecurity
  • SpringCould微服务

    • SpringCloud基础
    • 微服务之DDD架构思想
  • 日常必备

    • 开发常用工具包
    • Hutoll工具包
    • IDEA常用配置
    • 开发笔记
    • 日常记录
    • 项目部署
    • 网站导航
    • 产品学习
    • 英语学习
  • 代码管理

    • Maven
    • Git教程
    • Git小乌龟教程
  • 运维工具

    • Docker
    • Jenkins
    • Kubernetes
前端 (opens new window)
  • 算法笔记

    • 算法思想
    • 刷题笔记
  • 面试问题常见

    • 十大经典排序算法
    • 面试常见问题集锦
关于
GitHub (opens new window)
npm

(进入注册为作者充电)

  • Java常用开发工具包

    • Jackson(JSON处理库)
    • FastJson2(JSON处理库)
    • Gson(JSON处理库)
    • BeanUtils(对象复制工具)
    • MapStruct(对象转换工具)
      • 一、MapStruct 简介与应用背景
        • 1.1 为何需要对象映射?分层架构下的必然选择
        • 1.2 MapStruct 的核心优势:编译时生成,类型安全,高性能
      • 二、MapStruct 与 BeanUtils 的对比
        • 2.1 BeanUtils 的局限性分析
        • 2.2 MapStruct 如何解决这些问题
      • 三、环境配置与依赖管理
        • 3.1 Maven 依赖配置
        • 3.2 Gradle 依赖配置
        • 3.3 验证配置是否成功
      • 四、MapStruct 基础用法
        • 4.1 @Mapper 注解与组件模型 (Component Model)
        • 4.2 定义源对象 (Source) 与目标对象 (Target)
        • 4.3 创建基础映射器接口
        • 4.4 字段数量不一致的映射处理
        • 4.5 如何在代码中使用映射器
        • 4.6 查看生成的映射器实现代码
      • 五、处理复杂映射场景
        • 5.1 不同字段名称映射 (@Mapping)
        • 5.2 不同类型字段映射
        • 5.3 枚举类型映射
      • 六、高级功能与实践技巧
        • 6.1 集合类型映射 (List, Set, Map)
        • 6.2 使用表达式 (expression) 进行复杂转换
        • 6.3 生命周期回调 (@BeforeMapping, @AfterMapping)
        • 6.4 继承与组合映射器 (extends, uses)
      • 七、性能优化与最佳实践
        • 7.1 MapStruct 性能优势分析
        • 7.2 编码最佳实践
        • 7.3 避免常见陷阱
      • 八、与 Spring Boot 项目集成实战
        • 8.1 依赖配置与组件扫描
        • 8.2 在 Service 和 Controller 中使用依赖注入
        • 8.3 设计请求/响应专用 DTO
        • 8.4 单元测试与集成测试
        • 8.5 常见问题与解决方案
    • Guava(开发工具包)
    • ThreadLocal(本地线程变量)
    • SLF4j(日志框架)
    • Lombok (注解插件)
  • 开发工具包
  • Java常用开发工具包
scholar
2024-08-26
目录

MapStruct(对象转换工具)

# MapStruct(对象映射工具)

前言

在现代 Java 企业级应用开发,尤其是遵循分层架构(如常见的 Controller-Service-Repository 模式)的项目中,数据在不同层级间的流转和转换是不可避免的核心环节。我们常常需要将数据库实体对象(Entity/POJO)、服务间传输的数据传输对象(DTO)、以及最终呈现给用户的视图对象(VO)进行相互映射。如果完全依赖手动编写 getter/setter 来完成这些对象的属性复制,代码将变得极其冗余、枯燥且极易出错,后期维护成本也会居高不下。

MapStruct 应运而生,它是一款基于 编译时代码生成 的 Java Bean 映射框架。它通过定义简单的接口和使用注解,就能自动生成高效、类型安全的映射代码,从而将开发者从繁琐、易错的对象转换工作中解放出来,极大地提高了开发效率和代码质量。与基于反射的工具(如 BeanUtils)相比,MapStruct 在性能和类型安全方面具有显著优势。

# 一、MapStruct 简介与应用背景

# 1.1 为何需要对象映射?分层架构下的必然选择

在典型的分层架构中,每一层都有其特定的数据表示形式:

  • 领域层/数据访问层 (Domain/Entity): 通常与数据库表结构直接对应,包含所有持久化字段,位于 domain 或 entity 包中。这些对象可能包含复杂的关联关系和持久化相关的注解(如 JPA 注解)。
  • 服务层/业务逻辑层 (Service/DTO): 使用数据传输对象(Data Transfer Object, DTO)在不同服务或模块间传递数据。DTO 通常只包含业务逻辑所需的字段子集,可能对实体数据进行聚合或裁剪,目的是减少不必要的数据传输和解耦。
  • 表现层/控制层 (Controller/VO): 使用视图对象(View Object, VO)或表示模型(Representation Model)来封装需要展示给前端的数据。VO 的结构完全服务于 UI 展示逻辑,可能包含格式化后的数据、计算字段或与特定视图相关的状态。

由于各层职责和数据需求的不同,这些对象之间必须进行转换。例如:

  • 从 Repository 查询 User Entity 后,需要转换为 UserDTO 供 Service 处理。
  • Service 处理完业务逻辑后,可能将 UserDTO 转换为 UserVO 返回给 Controller,以便前端展示。
  • Controller 接收到前端传入的 UserCreationRequest DTO,需要将其转换为 User Entity 以便持久化。

手动实现这些转换逻辑,即使对于简单的对象,也会产生大量重复且易错的 getter/setter 代码。

// 手动转换示例 (繁琐且易错)
public UserDTO convertUserToDTO(User user) {
    if (user == null) {
        return null;
    }
    UserDTO dto = new UserDTO();
    dto.setId(user.getId());
    dto.setUsername(user.getUsername());
    dto.setEmail(user.getEmail());
    // ... 其他几十个字段 ...
    // 容易漏掉字段或在类型转换时出错
    if (user.getCreateTime() != null) {
        dto.setCreateTimeFormatted(
            user.getCreateTime().format(DateTimeFormatter.ISO_DATE_TIME)
        );
    }
    return dto;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 1.2 MapStruct 的核心优势:编译时生成,类型安全,高性能

MapStruct 通过一种优雅的方式解决了手动映射的痛点:

  1. 编译时代码生成 (Compile-time Code Generation): MapStruct 是一个注解处理器 (Annotation Processor)。它在 Java 编译期间扫描带有 @Mapper 注解的接口,并根据接口中定义的映射方法和 @Mapping 等注解,自动生成实现这些接口的具体 Java 类。这些生成的类包含了所有必需的 getter/setter 调用,完全是原生的 Java 代码。
  2. 类型安全 (Type Safety): 由于映射逻辑在编译时确定并生成代码,Java 编译器可以对生成的代码进行完整的类型检查。如果源类型和目标类型不兼容,或者映射配置有误(如字段名拼写错误),编译过程就会失败,从而将错误扼杀在开发阶段,而不是运行时。
  3. 高性能 (High Performance): 因为生成的代码是直接的 getter/setter 调用,没有任何运行时的反射或动态代理开销,MapStruct 的性能几乎等同于手动编写映射代码,远超基于反射的工具(如 Apache Commons BeanUtils, ModelMapper)。
  4. 易用性与声明式配置 (Ease of Use & Declarative Configuration): 开发者只需定义一个简单的 Java 接口,并使用直观的注解(主要是 @Mapper 和 @Mapping)来声明映射规则,MapStruct 会负责生成具体的实现细节。
  5. 高度可定制 (Highly Customizable): 支持处理各种复杂映射场景,包括:
    • 不同名称字段的映射。
    • 不同类型字段的映射(内置多种类型转换,并支持自定义转换方法)。
    • 常量值、表达式映射。
    • 集合、Map、嵌套对象的映射。
    • 枚举类型的映射。
    • 映射前/后回调方法。
    • 映射器组合与继承。
  6. 与构建工具和 IDE 良好集成: 可以轻松集成到 Maven、Gradle 等构建流程中,并在 IntelliJ IDEA、Eclipse 等 IDE 中获得良好的代码提示和导航支持(需要安装相应插件)。

# 二、MapStruct 与 BeanUtils 的对比

在选择对象映射工具时,经常会与 Spring Framework 自带的 BeanUtils.copyProperties() 进行比较。虽然 BeanUtils 使用简单,但在功能和性能上与 MapStruct 存在显著差异。

# 2.1 BeanUtils 的局限性分析

Spring 的 BeanUtils 主要依赖运行时反射来复制属性。

import org.springframework.beans.BeanUtils;
import lombok.Data;
import java.time.LocalDateTime;
import java.time.format.DateTimeParseException;

// 假设 User 和 UserDTO 类定义如前文
@Data class User { private Integer id; private String name; private String createTime; /* yyyy-MM-dd HH:mm:ss */ private LocalDateTime updateTime; }
@Data class UserDTO { private Integer id; private String name; private String createTime; private LocalDateTime updateTime; }

/**
 * BeanUtils 在处理类型不匹配时的局限性示例
 */
public class BeanUtilsLimitationExample {
    public static void main(String[] args) {
        // 源对象: createTime 是 String
        User user = new User();
        user.setId(1);
        user.setName("张三");
        user.setCreateTime("2023-09-01 10:00:00"); // String 类型
        user.setUpdateTime(LocalDateTime.now());

        // 目标对象: createTime 也是 String, updateTime 是 LocalDateTime
        UserDTO userDTO = new UserDTO();

        // 使用 BeanUtils 复制属性
        // 它会成功复制 id, name, createTime (同名同类型 String)
        // 它也会成功复制 updateTime (同名同类型 LocalDateTime)
        try {
            BeanUtils.copyProperties(user, userDTO);
            System.out.println("BeanUtils 复制成功 (同类型): " + userDTO);
        } catch (Exception e) {
            System.err.println("BeanUtils 复制时出错: " + e.getMessage());
        }

        // 尝试将包含日期字符串的 UserDTO 复制回 User (假设 User 的 createTime 需要是 Date 类型)
        @Data class UserWithDate { private Integer id; private String name; private java.util.Date createTime; }
        UserDTO dtoWithStrDate = new UserDTO(1, "李四", "2023-10-01 12:00:00", null);
        UserWithDate userWithDate = new UserWithDate();

        try {
            // BeanUtils 无法自动将 String "2023-10-01 12:00:00" 转换为 java.util.Date
            // 这里不会抛出异常,但 createTime 字段会是 null
            BeanUtils.copyProperties(dtoWithStrDate, userWithDate);
            System.out.println("\nBeanUtils 尝试 String -> Date 复制后: " + userWithDate);
            // 输出: BeanUtils 尝试 String -> Date 复制后: UserWithDate(id=1, name=李四, createTime=null)
        } catch (Exception e) {
             System.err.println("BeanUtils 在 String -> Date 复制时出错: " + e.getMessage());
        }
    }
}
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

BeanUtils 的主要问题:

  1. 仅支持同名属性复制: 无法处理源和目标字段名称不一致的情况(如 userId vs id)。
  2. 有限的类型转换: 仅支持 Java 内置的兼容类型(如 int <-> Integer)。无法自动处理复杂的类型转换,例如 String 到 Date/LocalDateTime,或者枚举到字符串/代码。通常会导致属性被忽略或在运行时失败(如果目标是基本类型且源为 null)。
  3. 性能较低: 基于运行时反射,涉及查找方法、调用方法等操作,相比直接方法调用有显著性能开销。
  4. 非类型安全: 属性名称以字符串形式传递(如 ignoreProperties),无法在编译时检查拼写错误。类型不匹配的问题也只能在运行时发现。

# 2.2 MapStruct 如何解决这些问题

MapStruct 通过其设计机制优雅地解决了 BeanUtils 的局限。

import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Mappings;
import org.mapstruct.factory.Mappers;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import lombok.Data;

// 假设 User 类如前: id(Int), name(Str), createTime(Str "yyyy-MM-dd HH:mm:ss"), updateTime(LDT)
// 假设 UserDTO 类如前: id(Int), name(Str), createTime(Str), updateTime(LDT)

// 定义一个目标 DTO,需要将 String 类型的 createTime 转换为 LocalDateTime
@Data class UserDtoWithLDT { private Integer id; private String name; private LocalDateTime creationTimestamp; private LocalDateTime updateTime; }

/**
 * MapStruct 映射器接口,演示解决 BeanUtils 局限性
 */
@Mapper // 标记为 MapStruct 映射器
public interface UserMapperAdvanced {

    UserMapperAdvanced INSTANCE = Mappers.getMapper(UserMapperAdvanced.class); // 获取实例的方式

    /**
     * 将 User 映射到 UserDtoWithLDT
     * 处理不同名称 (createTime -> creationTimestamp)
     * 处理不同类型 (String -> LocalDateTime)
     *
     * @param user 源 User 对象
     * @return 映射后的 UserDtoWithLDT 对象
     */
    @Mappings({
        // 1. 处理不同名称: source 指定源字段名,target 指定目标字段名
        @Mapping(source = "createTime", target = "creationTimestamp", dateFormat = "yyyy-MM-dd HH:mm:ss"),
        // 2. 处理不同类型: 使用 dateFormat 属性,MapStruct 会自动生成 String 到 LocalDateTime 的转换代码
        // 3. 同名同类型字段 (id, name, updateTime) 会被自动映射
    })
    UserDtoWithLDT userToUserDtoWithLDT(User user);

    /**
     * 将 UserDtoWithLDT 映射回 User
     * 处理不同名称 (creationTimestamp -> createTime)
     * 处理不同类型 (LocalDateTime -> String)
     *
     * @param dto 源 UserDtoWithLDT 对象
     * @return 映射后的 User 对象
     */
    @Mappings({
        @Mapping(source = "creationTimestamp", target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
        // 使用 dateFormat 将 LocalDateTime 格式化为 String
    })
    User userDtoWithLDTToUser(UserDtoWithLDT dto);
}

// --- 使用示例 ---
public class MapStructAdvantageDemo {
    public static void main(String[] args) {
        User user = new User(1, "王五", "2024-01-15 09:45:00", LocalDateTime.now());

        // 使用 MapStruct 进行转换
        UserDtoWithLDT dto = UserMapperAdvanced.INSTANCE.userToUserDtoWithLDT(user);

        System.out.println("MapStruct 转换 User -> UserDtoWithLDT:");
        System.out.println(dto);
        // 输出: UserDtoWithLDT(id=1, name=王五, creationTimestamp=2024-01-15T09:45, updateTime=...)

        User userConvertedBack = UserMapperAdvanced.INSTANCE.userDtoWithLDTToUser(dto);
        System.out.println("\nMapStruct 转换 UserDtoWithLDT -> User:");
        System.out.println(userConvertedBack);
        // 输出: User(id=1, name=王五, createTime=2024-01-15 09:45:00, updateTime=...)
    }
    // 假设 User 和 UserDtoWithLDT 类已定义并包含 Lombok 注解
    @Data @AllArgsConstructor @NoArgsConstructor static class User { private Integer id; private String name; private String createTime; private LocalDateTime updateTime; }
    @Data @AllArgsConstructor @NoArgsConstructor static class UserDtoWithLDT { private Integer id; private String name; private LocalDateTime creationTimestamp; private LocalDateTime updateTime; }

}
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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75

MapStruct 的优势体现:

  1. 支持不同名称字段映射: 通过 @Mapping(source = "...", target = "...") 轻松实现。
  2. 强大的类型转换:
    • 内置了大量常用类型转换(基本类型、包装类型、日期时间 API、集合等)。
    • 通过 dateFormat 属性方便地处理日期字符串与日期对象的转换。
    • 支持通过 default 方法或引入其他 Mapper (uses 属性) 实现自定义复杂转换逻辑。
  3. 高性能: 生成的代码是直接的方法调用,无运行时反射开销。
  4. 编译时类型安全: 所有映射配置在编译期验证,错误提前暴露。

# 三、环境配置与依赖管理

要在项目中使用 MapStruct,需要正确配置构建工具(如 Maven 或 Gradle)以包含核心库和注解处理器。

# 3.1 Maven 依赖配置

在 pom.xml 中添加以下配置:

<properties>
    <!-- 统一管理版本号 -->
    <java.version>1.8</java.version> <!-- 或更高版本 -->
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <mapstruct.version>1.5.5.Final</mapstruct.version> <!-- 使用 MapStruct 最新稳定版本 -->
    <lombok.version>1.18.30</lombok.version> <!-- 如果使用 Lombok,指定其版本 -->
</properties>

<dependencies>
    <!-- MapStruct 核心库,包含 @Mapper, @Mapping 等注解 -->
    <dependency>
        <groupId>org.mapstruct</groupId>
        <artifactId>mapstruct</artifactId>
        <version>${mapstruct.version}</version>
    </dependency>

    <!-- Lombok (可选,但常用,用于简化 POJO) -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>${lombok.version}</version>
        <optional>true</optional> <!-- 标记为可选,因为它是编译时工具 -->
    </dependency>

    <!-- Spring Boot 相关依赖 (如果是在 Spring Boot 项目中) -->
    <!-- <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency> -->
</dependencies>

<build>
    <plugins>
        <!-- Maven 编译器插件 -->
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.8.1</version> <!-- 或更新版本 -->
            <configuration>
                <source>${java.version}</source>
                <target>${java.version}</target>
                <!-- 配置注解处理器路径 -->
                <annotationProcessorPaths>
                    <!-- MapStruct 注解处理器,用于生成映射实现类 -->
                    <path>
                        <groupId>org.mapstruct</groupId>
                        <artifactId>mapstruct-processor</artifactId>
                        <version>${mapstruct.version}</version>
                    </path>
                    <!-- Lombok 注解处理器 (如果使用 Lombok) -->
                    <path>
                        <groupId>org.projectlombok</groupId>
                        <artifactId>lombok</artifactId>
                        <version>${lombok.version}</version>
                    </path>
                    <!-- Lombok 与 MapStruct 集成的绑定器 (关键!) -->
                    <!-- 这个绑定器确保 Lombok 先运行,MapStruct 后运行,以便 MapStruct 能看到 Lombok 生成的方法 -->
                    <path>
                        <groupId>org.projectlombok</groupId>
                        <artifactId>lombok-mapstruct-binding</artifactId>
                        <version>0.2.0</version> <!-- 使用与 Lombok/MapStruct 兼容的版本 -->
                    </path>
                </annotationProcessorPaths>
                <!-- (可选) 如果希望 MapStruct 生成的类作为 Spring 组件 -->
                <!-- <compilerArgs>
                    <compilerArg>
                        -Amapstruct.defaultComponentModel=spring
                    </compilerArg>
                </compilerArgs> -->
            </configuration>
        </plugin>
    </plugins>
</build>
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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73

关键配置解释:

  • mapstruct: 核心注解库。
  • mapstruct-processor: 注解处理器,必须添加,否则 MapStruct 不会生成代码。通常 scope 设置为 provided 或在 <annotationProcessorPaths> 中指定。
  • lombok (可选): 如果使用 Lombok 简化代码,需要添加。
  • lombok-mapstruct-binding (如果同时使用 Lombok): 极其重要,用于协调 Lombok 和 MapStruct 注解处理器的执行顺序,确保 MapStruct 能正确访问 Lombok 生成的 getter/setter。

# 3.2 Gradle 依赖配置

在 build.gradle 或 build.gradle.kts 中添加类似配置:

// build.gradle (Groovy DSL)
plugins {
    id 'java'
    // id 'org.springframework.boot' version '...' // 如果是 Spring Boot
    // id 'io.spring.dependency-management' version '...' // 如果是 Spring Boot
}

repositories {
    mavenCentral()
}

ext { // 统一管理版本
    mapstructVersion = '1.5.5.Final'
    lombokVersion = '1.18.30'
}

dependencies {
    implementation "org.mapstruct:mapstruct:${mapstructVersion}"
    // implementation "org.springframework.boot:spring-boot-starter" // Spring Boot

    // Lombok (可选)
    compileOnly "org.projectlombok:lombok:${lombokVersion}"
    annotationProcessor "org.projectlombok:lombok:${lombokVersion}"

    // MapStruct 注解处理器
    annotationProcessor "org.mapstruct:mapstruct-processor:${mapstructVersion}"

    // Lombok 与 MapStruct 集成绑定器 (如果使用 Lombok)
    annotationProcessor "org.projectlombok:lombok-mapstruct-binding:0.2.0"

    // testImplementation ...
}

// (可选) 全局配置 MapStruct 生成 Spring 组件
/*
tasks.withType(JavaCompile) {
    options.compilerArgs = [
        '-Amapstruct.defaultComponentModel=spring'
    ]
}
*/
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

# 3.3 验证配置是否成功

配置完成后,执行项目的编译命令(如 mvn clean compile 或 gradle build)。编译成功后,检查项目的 target/generated-sources/annotations (Maven) 或 build/generated/sources/annotationProcessor/java/main (Gradle) 目录下是否生成了以 Impl 结尾的映射器实现类。如果生成了,说明 MapStruct 配置成功。

# 四、MapStruct 基础用法

掌握 MapStruct 的基础用法是高效利用该工具的第一步。

# 4.1 @Mapper 注解与组件模型 (Component Model)

@Mapper 是 MapStruct 的入口注解,用于标记一个接口为映射器接口。MapStruct 处理器会为带有此注解的接口生成实现类。

import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers; // 用于 default 模式获取实例

// 示例 1: 默认组件模型 (default)
@Mapper // 没有指定 componentModel,默认为 default
public interface DefaultUserMapper {
    // 需要通过 Mappers 工具类手动获取实例
    DefaultUserMapper INSTANCE = Mappers.getMapper(DefaultUserMapper.class);

    UserDTO userToDto(User user); // 映射方法定义
}

// 示例 2: Spring 组件模型 (spring)
@Mapper(componentModel = "spring") // 指定为 spring 模型
public interface SpringUserMapper {
    // MapStruct 会为生成的实现类添加 @Component 注解
    // 可以直接在 Spring 应用中使用 @Autowired 或构造器注入来获取实例
    UserDTO userToDto(User user);
}

// 示例 3: 其他组件模型
// @Mapper(componentModel = "cdi") // 用于 CDI 环境
// @Mapper(componentModel = "jsr330") // 使用 JSR 330 注解 (@Named, @Singleton)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

componentModel 属性详解:

  • default: 默认值。生成的实现类不带任何特定框架的注解。需要通过 Mappers.getMapper(YourMapper.class) 静态方法获取 Mapper 实例。适用于不依赖特定 DI 框架的场景。
  • spring: 推荐在 Spring/Spring Boot 项目中使用。生成的实现类会添加 @Component 注解,使其成为一个 Spring Bean,可以通过 @Autowired 或构造器注入到其他 Spring 组件(如 Service, Controller)中使用。
  • cdi: 生成的实现类会添加 @ApplicationScoped 注解,适用于 Jakarta EE/CDI 环境。
  • jsr330: 生成的实现类会添加 @Named 和 @Singleton 注解(来自 javax.inject 或 jakarta.inject),适用于支持 JSR-330 注入规范的框架。

选择合适的 componentModel 可以让 MapStruct 生成的 Mapper 实例的生命周期和获取方式与你的应用程序框架保持一致。

# 4.2 定义源对象 (Source) 与目标对象 (Target)

在使用 MapStruct 进行映射前,需要明确定义源对象和目标对象的 Java 类。这些类通常是 POJO 或 JavaBean。

import lombok.Data; // 使用 Lombok 简化
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import lombok.Builder;
import java.time.LocalDateTime;

/**
 * 用户实体类 (Source Object) - 通常对应数据库表
 */
@Data                  // 自动生成 getter/setter/toString/equals/hashCode
@NoArgsConstructor     // 自动生成无参构造
@AllArgsConstructor    // 自动生成全参构造
@Builder               // 启用 Builder 模式创建对象
public class User {
    private Integer id;           // 用户 ID
    private String username;      // 用户名 (假设数据库字段是 username)
    private String emailAddress;  // 邮箱地址 (假设数据库字段是 email_address)
    private String passwordHash;  // 密码哈希 (通常不暴露给 DTO/VO)
    private LocalDateTime createdAt; // 创建时间
    private LocalDateTime lastLogin; // 最后登录时间
    private boolean isActive;     // 账户是否激活
}

/**
 * 用户数据传输对象 (Target Object) - 用于 Service 层或 API 响应
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserDTO {
    private Integer id;           // 用户 ID
    private String username;      // 用户名
    private String email;         // 邮箱 (注意:字段名与 User 不同)
    private String creationDate;  // 创建日期 (注意:类型和名称都与 User 不同)
    private boolean active;       // 激活状态 (注意:名称与 User 不同)
}
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

在这个例子中,User 是源对象,UserDTO 是目标对象。它们之间存在字段名称、类型、数量上的差异,这些都需要在 MapStruct 映射器中处理。

# 4.3 创建基础映射器接口

对于简单场景(大部分字段同名同类型),只需定义一个包含映射方法的接口。

import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Mappings;

/**
 * 用户映射器接口 - 定义 User 与 UserDTO 之间的转换规则
 */
@Mapper(componentModel = "spring") // 使用 Spring 组件模型
public interface UserBaseMapper {

    /**
     * 将 User 实体映射到 UserDTO 对象。
     * MapStruct 会自动处理同名同类型的字段 (id, username)。
     * 对于名称或类型不同的字段,需要使用 @Mapping 注解进行配置。
     *
     * @param user 源 User 对象
     * @return 映射后的 UserDTO 对象
     */
    @Mappings({
        // 将 User.emailAddress 映射到 UserDTO.email
        @Mapping(source = "emailAddress", target = "email"),
        // 将 User.createdAt (LocalDateTime) 映射到 UserDTO.creationDate (String)
        // 使用 dateFormat 指定格式
        @Mapping(source = "createdAt", target = "creationDate", dateFormat = "yyyy-MM-dd"),
        // 将 User.isActive 映射到 UserDTO.active (注意这里名称不同,但 MapStruct 可能会智能匹配 is/get 前缀)
        // 如果智能匹配失败,需要显式 @Mapping(source = "isActive", target = "active")
         @Mapping(source = "active", target = "active"), // 显式指定以确保映射
        // User.passwordHash 在 UserDTO 中不存在,会被自动忽略
        // User.lastLogin 在 UserDTO 中不存在,会被自动忽略
    })
    UserDTO userToUserDTO(User user);

    /**
     * 将 UserDTO 对象映射回 User 实体。
     * 用于例如从请求 DTO 创建实体。
     *
     * @param userDTO 源 UserDTO 对象
     * @return 映射后的 User 对象
     */
    @Mappings({
        // 将 UserDTO.email 映射回 User.emailAddress
        @Mapping(source = "email", target = "emailAddress"),
        // 将 UserDTO.creationDate (String) 映射回 User.createdAt (LocalDateTime)
        @Mapping(source = "creationDate", target = "createdAt", dateFormat = "yyyy-MM-dd"),
        // 将 UserDTO.active 映射回 User.isActive
        @Mapping(source = "active", target = "active"),
        // User.passwordHash 通常在创建时不直接从 DTO 映射,需要单独处理(例如加密)
        @Mapping(target = "passwordHash", ignore = true), // 明确忽略 passwordHash 的反向映射
        // User.lastLogin 通常不由 DTO 设置
        @Mapping(target = "lastLogin", ignore = true) // 明确忽略 lastLogin 的反向映射
    })
    User userDTOToUser(UserDTO userDTO);
}
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

说明:

  • @Mapper(componentModel = "spring"): 标记接口,并指定生成 Spring Bean。
  • @Mappings({...}): 当有多个 @Mapping 注解时,用 @Mappings 包裹。
  • @Mapping(source = "...", target = "..."): 用于指定名称不同的字段映射关系。
  • @Mapping(..., dateFormat = "..."): 用于在 String 和日期/时间类型之间转换时指定格式。
  • @Mapping(target = "...", ignore = true): 用于显式忽略某个目标字段的映射。

MapStruct 会根据这些注解生成一个 UserBaseMapperImpl 类,包含具体的转换逻辑。

# 4.4 字段数量不一致的映射处理

当目标对象的字段数量少于源对象时,MapStruct 默认会自动忽略目标对象中不存在的字段,无需任何额外配置。

import lombok.Data;
import lombok.Builder;

/**
 * 用户视图对象 (VO) - 只包含部分字段,用于前端展示
 */
@Data
@Builder
class UserVO {
    private Integer id;
    private String username;
    private String registrationDate; // 对应 User.createdAt,可能格式不同
}

// 在 UserBaseMapper 接口中添加新方法:
@Mapper(componentModel = "spring")
public interface UserBaseMapper {
    // ... (之前的 userToUserDTO 和 userDTOToUser 方法) ...

    /**
     * 将 User 实体映射到字段更少的 UserVO 对象。
     * MapStruct 会自动只映射 id 和 username。
     * createdAt 需要显式映射到 registrationDate 并指定格式。
     * emailAddress, passwordHash, lastLogin, isActive 会被忽略。
     *
     * @param user 源 User 对象
     * @return 映射后的 UserVO 对象
     */
    @Mapping(source = "createdAt", target = "registrationDate", dateFormat = "yyyy/MM/dd")
    UserVO userToUserVO(User user);
}
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

生成的 UserBaseMapperImpl 中的 userToUserVO 方法只会包含 id, username, 和 registrationDate 的设置代码。

# 4.5 如何在代码中使用映射器

根据 @Mapper 注解中 componentModel 的设置,获取和使用 Mapper 实例的方式不同。

1. 使用 Spring 依赖注入 (componentModel = "spring"):

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime; // 需要引入
import lombok.extern.slf4j.Slf4j; // 引入日志库

/**
 * 示例 Service 类,通过构造器注入 UserBaseMapper
 */
@Slf4j // Lombok 日志注解
@Service
public class UserProcessingService {

    private final UserBaseMapper userMapper; // 声明为 final,推荐构造器注入
    // private final UserRepository userRepository; // 假设有 UserRepository

    @Autowired // Spring 自动注入
    public UserProcessingService(UserBaseMapper userMapper /*, UserRepository userRepository*/) {
        this.userMapper = userMapper;
        // this.userRepository = userRepository;
        log.info("UserProcessingService initialized with UserBaseMapper implementation: {}",
                 userMapper.getClass().getName()); // 打印 Mapper 实现类名
    }

    /**
     * 业务方法,演示使用注入的 Mapper
     * @param userId 用户 ID
     * @return 用户 DTO
     */
    public UserDTO processAndGetUserDTO(Integer userId) {
        // 模拟获取 User 实体
        User userEntity = findUserById(userId);
        if (userEntity == null) {
            // 处理用户不存在的情况
            return null;
        }
        log.debug("Found user entity: {}", userEntity);

        // 使用注入的 userMapper 进行转换
        UserDTO userDTO = userMapper.userToUserDTO(userEntity);
        log.debug("Converted to UserDTO: {}", userDTO);

        // 可以进行其他业务处理...

        return userDTO;
    }

    // 模拟根据 ID 查找用户的方法
    private User findUserById(Integer userId) {
        // 实际应从数据库查询
        if (userId == 1) {
            return User.builder()
                    .id(1)
                    .username("demoUser")
                    .emailAddress("demo@example.com")
                    .createdAt(LocalDateTime.now().minusDays(10))
                    .isActive(true)
                    .build();
        }
        return null;
    }
}
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
57
58
59
60
61

2. 使用 Mappers 工厂类 (componentModel = "default"):

import org.mapstruct.factory.Mappers;
import java.time.LocalDateTime; // 需要引入

/**
 * 示例转换工具类,使用 Mappers 工厂获取 Mapper 实例
 */
public class UserConversionUtil {

    // 获取 Mapper 实例 (线程安全,可以作为静态常量)
    private static final UserBaseMapper MAPPER = Mappers.getMapper(UserBaseMapper.class);

    /**
     * 静态方法:将 User 转换为 UserDTO
     * @param user 源 User 对象
     * @return 转换后的 UserDTO 对象
     */
    public static UserDTO convertToDto(User user) {
        if (user == null) {
            return null;
        }
        return MAPPER.userToUserDTO(user); // 使用静态实例调用映射方法
    }

    /**
     * 静态方法:将 UserDTO 转换为 User
     * @param dto 源 UserDTO 对象
     * @return 转换后的 User 对象
     */
    public static User convertToUser(UserDTO dto) {
        if (dto == null) {
            return null;
        }
        return MAPPER.userDTOToUser(dto); // 使用静态实例调用映射方法
    }

    // --- 使用示例 ---
    public static void main(String[] args) {
        User user = User.builder().id(2).username("staticUser").emailAddress("static@example.com").createdAt(LocalDateTime.now()).isActive(false).build();
        UserDTO dto = UserConversionUtil.convertToDto(user);
        System.out.println("Static conversion User -> DTO: " + dto);

        User convertedUser = UserConversionUtil.convertToUser(dto);
        System.out.println("Static conversion DTO -> User: " + convertedUser);
    }

    // 假设 User 和 UserDTO 类已定义
    // @Data @Builder @NoArgsConstructor @AllArgsConstructor static class User { ... }
    // @Data @Builder @NoArgsConstructor @AllArgsConstructor static class UserDTO { ... }
}
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

选择哪种方式取决于你的项目是否使用了 Spring 或其他 DI 框架。在 Spring Boot 项目中,强烈推荐使用 componentModel = "spring"。

# 4.6 查看生成的映射器实现代码

理解 MapStruct 如何工作的一个好方法是查看它生成的实现代码。在配置了注解处理器后,编译项目时会在 target (Maven) 或 build (Gradle) 目录下生成 YourMapperNameImpl.java 文件。

例如,对于上面的 UserBaseMapper,生成的 UserBaseMapperImpl.java 可能类似于(已简化并添加注释):

// package com.example.mapper; // 包名取决于你的项目结构

import com.example.dto.UserDTO; // 假设 DTO 在此包
import com.example.model.User; // 假设 Entity 在此包
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import javax.annotation.processing.Generated; // 表明是生成的代码
import org.springframework.stereotype.Component; // 如果 componentModel="spring"

@Generated( // JSR-269 生成代码的标准注解
    value = "org.mapstruct.ap.MappingProcessor",
    date = "...", // 生成时间
    comments = "..." // 版本信息等
)
@Component // 因为我们设置了 componentModel="spring"
public class UserBaseMapperImpl implements UserBaseMapper {

    @Override // 实现接口方法
    public UserDTO userToUserDTO(User user) {
        if (user == null) { // 处理 null 输入
            return null;
        }

        UserDTO userDTO = new UserDTO(); // 创建目标对象

        // --- 属性复制逻辑 ---
        userDTO.setId(user.getId()); // 直接复制同名同类型 id
        userDTO.setUsername(user.getUsername()); // 直接复制同名同类型 username

        // 处理 @Mapping(source = "emailAddress", target = "email")
        userDTO.setEmail(user.getEmailAddress());

        // 处理 @Mapping(source = "createdAt", target = "creationDate", dateFormat = "yyyy-MM-dd")
        if (user.getCreatedAt() != null) {
            // 使用指定的 DateTimeFormatter 进行格式化
            userDTO.setCreationDate(DateTimeFormatter.ofPattern("yyyy-MM-dd").format(user.getCreatedAt()));
        }

        // 处理 @Mapping(source = "active", target = "active")
        userDTO.setActive(user.isActive()); // 注意调用的是 isActive()

        return userDTO; // 返回结果
    }

    @Override // 实现接口方法
    public User userDTOToUser(UserDTO userDTO) {
        if (userDTO == null) { // 处理 null 输入
            return null;
        }

        User user = new User(); // 创建目标对象

        // --- 属性复制逻辑 ---
        user.setId(userDTO.getId());
        user.setUsername(userDTO.getUsername());

        // 处理 @Mapping(source = "email", target = "emailAddress")
        user.setEmailAddress(userDTO.getEmail());

        // 处理 @Mapping(source = "creationDate", target = "createdAt", dateFormat = "yyyy-MM-dd")
        if (userDTO.getCreationDate() != null) {
            try {
                // 使用指定的 DateTimeFormatter 进行解析,注意这里需要处理可能的异常
                // MapStruct 通常会生成更健壮的解析代码,或依赖转换方法
                user.setCreatedAt(LocalDateTime.parse(userDTO.getCreationDate() + " 00:00:00", // 假设需要补全时间
                                        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
            } catch (DateTimeParseException e) {
                // 处理解析异常,例如记录日志或抛出包装后的异常
                throw new RuntimeException("无法解析日期字符串: " + userDTO.getCreationDate(), e);
            }
        }

        // 处理 @Mapping(source = "active", target = "active") -> User.setActive()
        user.setActive(userDTO.isActive());

        // passwordHash 和 lastLogin 因为 ignore=true 或 DTO 中不存在而被忽略

        return user; // 返回结果
    }

    // userToUserVO 方法的实现会类似地生成...
}
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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83

查看生成的代码有助于:

  • 理解 MapStruct 如何处理你的映射配置。
  • 调试映射问题。
  • 学习 MapStruct 的内部工作机制。

# 五、处理复杂映射场景

MapStruct 提供了丰富的注解和功能来应对各种复杂的映射需求。

# 5.1 不同字段名称映射 (@Mapping)

这是最常用的功能之一,通过 @Mapping(source = "源字段名", target = "目标字段名") 来指定。

import lombok.Data;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;

/**
 * 源对象:包含下划线命名的字段
 */
@Data
class LegacySystemData {
    private int user_id;
    private String user_name;
    private String contact_email;
}

/**
 * 目标对象:使用驼峰命名
 */
@Data
class ModernUserDTO {
    private int id;
    private String name;
    private String email;
}

/**
 * 映射器:处理不同命名风格的字段
 */
@Mapper(componentModel = "spring")
public interface NamingConventionMapper {

    /**
     * 将下划线命名的源对象映射到驼峰命名的目标对象
     * @param source LegacySystemData 实例
     * @return ModernUserDTO 实例
     */
    @Mapping(source = "user_id", target = "id")
    @Mapping(source = "user_name", target = "name")
    @Mapping(source = "contact_email", target = "email")
    ModernUserDTO legacyToModern(LegacySystemData source);

    /**
     * 反向映射:将驼峰命名映射回下划线命名
     * @param target ModernUserDTO 实例
     * @return LegacySystemData 实例
     */
    @Mapping(source = "id", target = "user_id")
    @Mapping(source = "name", target = "user_name")
    @Mapping(source = "email", target = "contact_email")
    LegacySystemData modernToLegacy(ModernUserDTO target);
}
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

# 5.2 不同类型字段映射

MapStruct 能够自动处理许多内置类型转换,对于无法自动处理的,可以通过 dateFormat、numberFormat 或自定义方法解决。

示例 1: 日期与字符串转换 (dateFormat)

import lombok.Data;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import java.time.LocalDate;
import java.util.Date; // 使用 java.util.Date

@Data class EventEntity { private Long id; private String eventName; private Date eventTimestamp; }
@Data class EventDTO { private Long id; private String name; private String eventDate; /* yyyy/MM/dd */ private String eventTime; /* HH:mm */ }

@Mapper(componentModel = "spring")
public interface DateTimeTypeMapper {
    @Mapping(source = "eventName", target = "name") // 不同名称
    @Mapping(source = "eventTimestamp", target = "eventDate", dateFormat = "yyyy/MM/dd") // Date -> String (只取日期部分)
    @Mapping(source = "eventTimestamp", target = "eventTime", dateFormat = "HH:mm") // Date -> String (只取时间部分)
    EventDTO entityToDto(EventEntity entity);

    // 反向映射时,需要将两个 String 合并或只使用一个来构造 Date,可能需要自定义方法或表达式
    // @Mapping(source = "eventDate", target = "eventTimestamp", dateFormat = "yyyy/MM/dd") // 这种方式只能映射日期部分
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

示例 2: 数字与字符串转换 (numberFormat)

import lombok.Data;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import java.math.BigDecimal;

@Data class ProductEntity { private int id; private String name; private BigDecimal price; }
@Data class ProductVO { private String id; private String name; private String formattedPrice; /* $###,##0.00 */ }

@Mapper(componentModel = "spring")
public interface NumberTypeMapper {
    @Mapping(source = "id", target = "id") // int -> String (自动转换)
    @Mapping(source = "price", target = "formattedPrice", numberFormat = "$###,##0.00") // BigDecimal -> String (带格式)
    ProductVO entityToVo(ProductEntity entity);

    // 反向映射:String -> BigDecimal 可能需要自定义方法或表达式处理货币符号和逗号
    // @Mapping(source = "formattedPrice", target = "price", ???)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

示例 3: 自定义类型转换方法 (default 方法)

当内置转换或格式化不满足需求时,可以在 Mapper 接口中定义 default 方法来实现自定义转换逻辑。

import lombok.Data;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import java.util.Base64; // 用于 Base64 示例
import java.nio.charset.StandardCharsets; // 用于 Base64 示例

@Data class ConfigEntity { private String key; private byte[] sensitiveValue; } // 值是 byte 数组
@Data class ConfigDTO { private String key; private String encodedValue; } // 值是 Base64 编码的字符串

@Mapper(componentModel = "spring")
public interface CustomTypeConversionMapper {

    /**
     * 映射方法,使用自定义的 default 方法进行 byte[] 和 String 之间的转换
     */
    @Mapping(source = "sensitiveValue", target = "encodedValue") // MapStruct 会自动查找类型匹配的 default 方法
    ConfigDTO entityToDto(ConfigEntity entity);

    @Mapping(source = "encodedValue", target = "sensitiveValue") // 反向映射
    ConfigEntity dtoToEntity(ConfigDTO dto);

    /**
     * 自定义转换方法:byte[] 转换为 Base64 String
     * 方法签名需要匹配源类型和目标类型
     * @param value byte 数组源值
     * @return Base64 编码的字符串
     */
    default String encodeBase64(byte[] value) {
        if (value == null) {
            return null;
        }
        return Base64.getEncoder().encodeToString(value);
    }

    /**
     * 自定义转换方法:Base64 String 转换为 byte[]
     * @param encodedValue Base64 编码的字符串源值
     * @return 解码后的 byte 数组
     */
    default byte[] decodeBase64(String encodedValue) {
        if (encodedValue == null) {
            return null;
        }
        try {
            return Base64.getDecoder().decode(encodedValue.getBytes(StandardCharsets.UTF_8));
        } catch (IllegalArgumentException e) {
            // 处理无效的 Base64 字符串,例如返回 null 或抛出异常
            System.err.println("无效的 Base64 字符串: " + encodedValue);
            return null;
        }
    }
}
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

MapStruct 会在生成的代码中调用这些 default 方法。

# 5.3 枚举类型映射

MapStruct 可以很好地处理枚举类型与字符串、代码或其他枚举类型之间的映射。

import lombok.Data;
import lombok.AllArgsConstructor;
import lombok.Getter; // 使用 Getter
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.ValueMapping; // 用于枚举常量映射
import org.mapstruct.ValueMappings; // 用于多个 ValueMapping

/**
 * 订单状态枚举
 */
@Getter // Lombok 注解,生成 getter
@AllArgsConstructor
public enum OrderStatus {
    PENDING("P", "待处理"),
    PROCESSING("PROC", "处理中"),
    SHIPPED("S", "已发货"),
    DELIVERED("D", "已送达"),
    CANCELLED("C", "已取消");

    private final String code; // 状态代码
    private final String description; // 状态描述

    // 可选:提供从代码查找枚举的方法
    public static OrderStatus fromCode(String code) {
        for (OrderStatus status : values()) {
            if (status.code.equals(code)) {
                return status;
            }
        }
        return null; // 或抛出异常
    }
}

/**
 * 订单实体类,状态是枚举类型
 */
@Data class OrderEntity { private Long id; private String orderNumber; private OrderStatus status; }

/**
 * 订单 DTO,状态是字符串代码
 */
@Data class OrderDTO { private Long id; private String orderNumber; private String statusCode; }

/**
 * 枚举映射器
 */
@Mapper(componentModel = "spring")
public interface OrderEnumMapper {

    // --- 方法 1: 使用内置转换 (如果目标是 String,默认调用 toString()) ---
    // @Mapping(source = "status", target = "statusCode") // 会将 status.toString() 赋给 statusCode

    // --- 方法 2: 使用表达式获取枚举的属性 ---
    // @Mapping(source = "status.code", target = "statusCode") // 直接访问枚举的 code 属性
    // @Mapping(source = "status.description", target = "statusDesc") // 访问 description

    // --- 方法 3: 使用 @ValueMapping (适用于枚举常量之间的映射) ---
    // 假设有另一个状态枚举 OrderState { NEW, IN_PROGRESS, DONE, FAILED }
    /*
    @ValueMappings({
        @ValueMapping(source = "PENDING", target = "NEW"),
        @ValueMapping(source = "PROCESSING", target = "IN_PROGRESS"),
        @ValueMapping(source = "SHIPPED", target = "DONE"),
        @ValueMapping(source = "DELIVERED", target = "DONE"),
        @ValueMapping(source = "CANCELLED", target = "FAILED")
    })
    OrderState orderStatusToOrderState(OrderStatus status);
    */

    // --- 方法 4: 使用自定义方法 (最灵活) ---
    @Mapping(source = "status", target = "statusCode") // MapStruct 会查找 OrderStatus -> String 的方法
    OrderDTO entityToDto(OrderEntity entity);

    @Mapping(source = "statusCode", target = "status") // MapStruct 会查找 String -> OrderStatus 的方法
    OrderEntity dtoToEntity(OrderDTO dto);

    /**
     * 自定义方法:将 OrderStatus 枚举转换为 String 代码
     * @param status 源枚举
     * @return 状态代码字符串
     */
    default String orderStatusToCode(OrderStatus status) {
        return status != null ? status.getCode() : null;
    }

    /**
     * 自定义方法:将 String 代码转换为 OrderStatus 枚举
     * @param code 状态代码字符串
     * @return 对应的枚举,如果找不到返回 null 或抛异常
     */
    default OrderStatus codeToOrderStatus(String code) {
        return OrderStatus.fromCode(code); // 调用枚举类中的静态查找方法
    }
}
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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95

推荐使用自定义方法(方法 4)或表达式(方法 2),因为它们更清晰且灵活。@ValueMapping 主要用于两个不同枚举类型之间的常量映射。

# 六、高级功能与实践技巧

MapStruct 还提供了一些高级功能,帮助应对更复杂的场景和优化开发流程。

# 6.1 集合类型映射 (List, Set, Map)

MapStruct 对集合映射提供了开箱即用的支持。只需在 Mapper 接口中定义接受和返回集合类型的方法,MapStruct 会自动生成遍历和逐个元素映射的代码。

import org.mapstruct.Mapper;
import java.util.List;
import java.util.Set;
import java.util.Map;

// 假设 User 和 UserDTO 类已定义

/**
 * 集合映射器示例
 */
@Mapper(componentModel = "spring")
public interface CollectionMappingMapper {

    // --- List 映射 ---

    /**
     * 将 List<User> 映射到 List<UserDTO>
     * MapStruct 会自动调用 userToDto(User user) 方法来映射每个元素
     * @param userList 源列表
     * @return 目标列表
     */
    List<UserDTO> mapUserListToDtoList(List<User> userList);

    // 假设存在 userToDto(User user) 方法

    // --- Set 映射 ---

    /**
     * 将 Set<User> 映射到 Set<UserDTO>
     * @param userSet 源 Set
     * @return 目标 Set
     */
    Set<UserDTO> mapUserSetToDtoSet(Set<User> userSet);

    // --- Map 映射 ---

    /**
     * 将 Map<Integer, User> 映射到 Map<Integer, UserDTO>
     * Key 保持不变,Value 通过 userToDto 进行映射
     * @param userMap 源 Map
     * @return 目标 Map
     */
    Map<Integer, UserDTO> mapUserMapToDtoMap(Map<Integer, User> userMap);

    /**
     * 将 Map<String, User> 映射到 Map<String, UserDTO>
     * Key 是 String 类型
     * @param userMap 源 Map
     * @return 目标 Map
     */
    Map<String, UserDTO> mapStringKeyUserMapToDtoMap(Map<String, User> userMap);

    // --- 需要定义的单个元素映射方法 ---
    UserDTO userToDto(User user); // MapStruct 会使用这个方法来映射集合中的元素
}
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

注意: MapStruct 在进行集合映射时,会查找并复用当前 Mapper 接口或其 uses 的其他 Mapper 中定义的单个元素的映射方法(如 userToDto)。

# 6.2 使用表达式 (expression) 进行复杂转换

当映射逻辑不仅仅是简单的字段复制或类型转换时,可以使用 @Mapping 注解的 expression 属性,直接嵌入 Java 代码片段。

import lombok.Data;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit; // 用于计算时间差
import java.util.List;
import java.util.stream.Collectors; // 用于 Stream API

@Data class Order { private Long id; private List<Item> items; private Customer customer; }
@Data class Item { private String name; private int quantity; private double price; }
@Data class Customer { private String firstName; private String lastName; }

@Data class OrderSummaryDTO { private Long orderId; private String customerFullName; private int totalItems; private double totalPrice; }

@Mapper(componentModel = "spring")
public interface OrderExpressionMapper {

    @Mapping(source = "id", target = "orderId")
    // 使用 expression 拼接客户全名
    @Mapping(target = "customerFullName", expression = "java(order.getCustomer().getFirstName() + \" \" + order.getCustomer().getLastName())")
    // 使用 expression 计算订单项总数
    @Mapping(target = "totalItems", expression = "java(order.getItems().size())")
    // 使用 expression 计算订单总价 (调用 default 方法)
    @Mapping(target = "totalPrice", expression = "java(calculateTotalPrice(order.getItems()))")
    OrderSummaryDTO orderToSummaryDTO(Order order);

    /**
     * default 方法用于计算订单总价,供 expression 调用
     * @param items 订单项列表
     * @return 总价格
     */
    default double calculateTotalPrice(List<Item> items) {
        if (items == null) {
            return 0.0;
        }
        return items.stream()
                    .mapToDouble(item -> item.getPrice() * item.getQuantity())
                    .sum();
    }
}
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

表达式使用要点:

  • 表达式内容必须是合法的 Java 代码片段,可以访问源对象的 getter 方法(如 order.getCustomer().getFirstName())或调用当前 Mapper 中的 default 方法(如 calculateTotalPrice(...))。
  • 使用 java(...) 将代码包裹起来。
  • 表达式应返回目标字段所需的类型。
  • 优点: 灵活性高,可以实现任意复杂的逻辑。
  • 缺点:
    • 将业务逻辑嵌入到映射层,可能违反职责分离原则。
    • 表达式是字符串,缺乏编译时检查和 IDE 的良好支持。
    • 过度复杂的表达式会降低代码可读性。
  • 建议: 对于非常简单的逻辑(如字段拼接、简单计算)可以使用表达式;对于复杂的业务逻辑,优先考虑在 Mapper 中定义 default 方法或引入专门的 Service 类(通过 uses 属性)。

# 6.3 生命周期回调 (@BeforeMapping, @AfterMapping)

允许在映射执行之前或之后插入自定义逻辑。

import lombok.Data;
import org.mapstruct.*; // 引入 @BeforeMapping, @AfterMapping, @MappingTarget
import java.time.LocalDateTime;

@Data class User { private Integer id; private String name; }
@Data class UserDTO { private Integer id; private String name; private String generatedInfo; }

@Mapper(componentModel = "spring")
public interface LifecycleCallbackMapper {

    /**
     * 映射方法
     */
    UserDTO userToDtoWithCallbacks(User user);

    /**
     * 在映射执行之前调用。
     * 可以用于验证、日志记录或预处理源对象。
     * 方法可以有源对象参数。
     * @param user 源 User 对象
     */
    @BeforeMapping
    default void beforeMapping(User user) {
        System.out.println("开始映射 User: " + user.getName());
        if (user.getId() == null) {
            System.out.println("警告: User ID 为空,将生成默认 ID。");
            // user.setId(generateDefaultId()); // 不推荐在 BeforeMapping 中修改源对象
        }
    }

    /**
     * 在映射执行之后调用。
     * 可以用于后处理、补充数据、日志记录等。
     * 方法可以有源对象和/或目标对象参数。
     * 使用 @MappingTarget 注解目标对象参数,允许在方法内部修改目标对象。
     * @param user    源 User 对象
     * @param dto     目标 UserDTO 对象 (使用 @MappingTarget 标记)
     */
    @AfterMapping
    default void afterMapping(User user, @MappingTarget UserDTO dto) {
        System.out.println("映射 User '" + user.getName() + "' 完成。");
        // 在目标对象中添加额外生成的信息
        dto.setGeneratedInfo("Mapped at " + LocalDateTime.now());
        if (user.getId() != null && user.getId() < 0) {
            dto.setName(dto.getName() + " [Invalid ID]"); // 根据源对象修改目标对象
        }
    }
}
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

回调方法要点:

  • 必须是 default 方法或定义在 uses 的类中的方法。
  • @BeforeMapping: 方法参数可以是源对象类型、@MappingTarget 目标对象类型(用于更新映射)、或 @TargetType Class 对象。
  • @AfterMapping: 方法参数可以是源对象类型、@MappingTarget 目标对象类型、或 @TargetType Class 对象。
  • 使用 @MappingTarget 注解的参数允许在回调方法内部修改目标对象。

# 6.4 继承与组合映射器 (extends, uses)

对于大型项目,可以通过继承和组合来复用和组织 Mapper。

1. 继承 (extends): 一个 Mapper 接口可以继承另一个 Mapper 接口,从而继承其所有映射方法和配置。

import org.mapstruct.Mapper;

/**
 * 基础 Mapper,包含通用转换逻辑或配置
 */
@Mapper(componentModel = "spring")
public interface BaseMapperConfig {
    // 可能包含通用的 @Mapper 配置,如 unmappedTargetPolicy
    // 或通用的 default 转换方法
    default String trimString(String input) { return input != null ? input.trim() : null; }
}

/**
 * User Mapper 继承 BaseMapperConfig
 * 会继承 BaseMapperConfig 中的配置和 default 方法
 */
@Mapper(componentModel = "spring")
public interface UserInheritanceMapper extends BaseMapperConfig {
    @Mapping(target = "name", qualifiedByName = "trimString") // 可以使用继承来的 default 方法
    UserDTO userToDto(User user);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

2. 组合 (uses): 一个 Mapper 可以通过 @Mapper(uses = {...}) 属性来引用其他 Mapper 或包含自定义方法的类。MapStruct 在生成代码时,如果需要某个映射逻辑或自定义转换方法,会首先在当前 Mapper 中查找,如果找不到,则会去 uses 引用的类中查找。

import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import java.util.Date;
import java.time.Instant;
import java.time.ZoneId;

/**
 * 专门处理日期转换的工具类或 Mapper
 */
@Mapper(componentModel = "spring") // 也可以是普通类,只要方法是 public static 或 default (如果 uses 在接口上)
public interface DateMapper {
    default LocalDateTime dateToLocalDateTime(Date date) {
        return date != null ? Instant.ofEpochMilli(date.getTime()).atZone(ZoneId.systemDefault()).toLocalDateTime() : null;
    }
    default Date localDateTimeToDate(LocalDateTime localDateTime) {
        return localDateTime != null ? Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant()) : null;
    }
}

/**
 * Order Mapper,使用 DateMapper 来处理日期转换
 */
@Mapper(componentModel = "spring", uses = {DateMapper.class}) // 引用 DateMapper
public interface OrderCompositionMapper {
    // MapStruct 会自动查找 DateMapper 中的 dateToLocalDateTime 方法
    @Mapping(source = "orderDate", target = "orderDateTime")
    OrderDTO orderToDto(OrderEntity entity);

    // MapStruct 会自动查找 DateMapper 中的 localDateTimeToDate 方法
    @Mapping(source = "orderDateTime", target = "orderDate")
    OrderEntity dtoToEntity(OrderDTO dto);
}

// 假设 OrderEntity 使用 java.util.Date, OrderDTO 使用 LocalDateTime
@Data class OrderEntity { private Long id; private Date orderDate; /* 其他字段 */ }
@Data class OrderDTO { private Long id; private LocalDateTime orderDateTime; /* 其他字段 */ }
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

继承 vs 组合:

  • 继承: 更强的耦合,子 Mapper 隐式获得父 Mapper 的所有配置和方法。适用于共享通用配置或基础转换。
  • 组合 (uses): 更松散的耦合,显式引用需要的辅助 Mapper 或工具类。更符合“组合优于继承”的原则,推荐优先使用。一个 Mapper 可以 uses 多个其他类。

# 七、性能优化与最佳实践

虽然 MapStruct 本身性能优异,但在使用中遵循一些最佳实践可以进一步提升效率和代码质量。

# 7.1 MapStruct 性能优势分析

回顾一下 MapStruct 高性能的原因:

  1. 编译时生成代码: 避免了运行时的反射调用、字节码操作或动态代理。
  2. 直接方法调用: 生成的代码是简单的 getter/setter 调用,接近手动编写的性能。
  3. 无运行时开销: 映射逻辑在编译后固定,运行时没有额外的查找、匹配或转换决策过程。

与其他工具的性能对比总结:

工具 性能级别 主要特点
MapStruct 极高 (接近手动) 编译时生成,无反射
手动编写 最高 基准线
CGLib BeanCopier 非常高 运行时生成字节码,性能接近手动
Spring BeanUtils 较好 反射,但有缓存
Apache PropertyUtils 较好 反射,无类型转换,有缓存
Apache BeanUtils 较差 反射,复杂类型转换,开销大
ModelMapper 中等偏慢 反射,运行时配置,有缓存
Dozer 较慢 反射,XML/API 配置,功能丰富

因此,在性能敏感的应用中,MapStruct 是兼顾开发效率和运行效率的理想选择。

# 7.2 编码最佳实践

  1. 保持 Mapper 接口简洁、职责单一:

    • 每个 Mapper 接口应专注于特定领域或特定类型的映射(如 UserMapper, OrderMapper)。
    • 避免创建包含所有映射逻辑的“上帝 Mapper”。
    • 使用 uses 组合功能,而不是让单个 Mapper 过于庞大。
  2. 使用清晰、一致的命名:

    • 映射方法名应清晰表达转换方向,如 toDto(), toEntity(), entityListToDtoList()。
    • 自定义转换方法 (default 或 static) 名称应明确其功能,如 encodePassword(), dateToStringUTC()。
  3. 为复杂映射添加 Javadoc 注释:

    • 解释 @Mapping 注解中 expression, qualifiedByName, dateFormat 等的用途。
    • 说明自定义转换方法 (default) 的逻辑和前提条件。
    • 标注忽略的字段及其原因。
    @Mapper(componentModel = "spring")
    public interface WellDocumentedMapper {
        /**
         * 将包含原始价格和折扣率的用户订单实体映射到包含最终价格的 DTO。
         *
         * @param orderEntity 包含订单详情和客户信息的源实体
         * @param discountRate 当前适用的折扣率 (例如 0.1 表示 10% 折扣)
         * @return 包含计算后最终价格的订单 DTO
         */
        @Mapping(target = "finalPrice", expression = "java(orderEntity.getOriginalPrice() * (1 - discountRate))")
        OrderPriceDTO calculateFinalPrice(OrderEntity orderEntity, double discountRate);
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
  4. 合理组织自定义逻辑:

    • 简单的、与特定映射紧密相关的逻辑可以使用 expression 或当前 Mapper 的 default 方法。
    • 通用的、可在多个 Mapper 中复用的转换逻辑(如日期格式化、货币计算、数据脱敏),应提取到单独的工具类或专门的 Mapper 中,并通过 uses 引用。
    • 复杂的业务计算逻辑不应放在 Mapper 的 expression 或 default 方法中,而应委托给专门的 Service 或 Helper 类。

# 7.3 避免常见陷阱

  1. 循环依赖 (Circular Dependencies):

    • 问题: Mapper A uses Mapper B,同时 Mapper B uses Mapper A。这会导致编译失败。
    • 场景: 常见于双向关联的对象映射,如 Order 包含 Customer,Customer 包含 List<Order>。
    • 解决方案:
      • 合并 Mapper: 将双向映射逻辑放在同一个 Mapper 中。
      • 使用 @Context: 将一个 Mapper 作为上下文传递给另一个 Mapper 的方法(适用于更复杂的场景)。
      • 通过 @AfterMapping 处理: 在一个方向映射完成后,手动设置另一个方向的关联(需要 @MappingTarget)。
    // 使用 @Context 解决循环依赖示例
    @Mapper(componentModel = "spring")
    public interface OrderCycleMapper {
        @Mapping(target = "customer", expression = "java(customerCycleMapper.toDto(order.getCustomer(), this))") // 传递 this 作为上下文
        OrderDTO toDto(Order order, @Context CustomerCycleMapper customerCycleMapper);
    }
    
    @Mapper(componentModel = "spring")
    public interface CustomerCycleMapper {
        @Mapping(target = "orders", expression = "java(orderCycleMapper.toDtoList(customer.getOrders(), this))") // 传递 this 作为上下文
        CustomerDTO toDto(Customer customer, @Context OrderCycleMapper orderCycleMapper);
        // 假设还有 toDtoList 方法
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
  2. Null 值处理不当:

    • 问题: 源对象或其嵌套属性为 null 时,访问 getter 或在 expression 中操作可能导致 NullPointerException。
    • 解决方案:
      • MapStruct 默认生成的代码通常会进行 null 检查。
      • 在 expression 中务必添加 null 判断:java(source.getNested() != null ? source.getNested().getProperty() : null)。
      • 在自定义 default 方法中处理 null 输入。
      • 考虑使用 NullValuePropertyMappingStrategy (如 IGNORE) 或 NullValueCheckStrategy 配置 Mapper 行为。
  3. 过度依赖表达式 (expression):

    • 问题: 将复杂的业务逻辑、外部服务调用或数据库查询放入 expression 会导致 Mapper 职责不清、难以测试和维护。
    • 解决方案: 保持 expression 简单,复杂逻辑移至 Service 层或专门的 Helper 类(可通过 uses 引入 Helper 类)。

# 八、与 Spring Boot 项目集成实战

在典型的 Spring Boot Web 应用中,MapStruct 主要用于 Controller、Service 和 Repository 层之间的数据转换。

# 8.1 依赖配置与组件扫描

如前文 3.1 和 3.3 所述,确保 pom.xml 或 build.gradle 中包含 mapstruct, mapstruct-processor (以及 Lombok 相关依赖,如果使用),并配置好注解处理器路径。

在 @SpringBootApplication 或专门的配置类中使用 @ComponentScan 确保包含 Mapper 接口所在的包路径,以便 Spring 能够发现并注册 @Mapper(componentModel = "spring") 生成的 Bean。

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;

@SpringBootApplication
// 扫描主应用包以及包含 Mapper 接口的包
@ComponentScan(basePackages = {"com.example.myapp", "com.example.myapp.mapper"})
public class MySpringBootApplication {
    public static void main(String[] args) {
        SpringApplication.run(MySpringBootApplication.class, args);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

# 8.2 在 Service 和 Controller 中使用依赖注入

通过构造器注入(推荐)或 @Autowired 字段注入,在 Service 和 Controller 中使用 MapStruct Mapper 实例。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import org.springframework.http.ResponseEntity; // 引入 ResponseEntity
import org.springframework.http.HttpStatus; // 引入 HttpStatus

// 假设 User, UserDTO, UserMapper, UserRepository 已定义

@Service
public class UserManagementService {

    private final UserRepository userRepository;
    private final UserMapper userMapper; // 注入 Mapper

    @Autowired
    public UserManagementService(UserRepository userRepository, UserMapper userMapper) {
        this.userRepository = userRepository;
        this.userMapper = userMapper;
    }

    public UserDTO findUserById(Integer id) {
        User user = userRepository.findById(id).orElse(null); // 简化处理
        return userMapper.userToUserDTO(user); // 使用 Mapper 转换
    }

    public List<UserDTO> findAllUsers() {
        List<User> users = userRepository.findAll();
        return userMapper.usersToUserDTOs(users); // 使用 Mapper 批量转换
    }

    public UserDTO saveUser(UserDTO userDTO) {
        User user = userMapper.userDTOToUser(userDTO); // DTO -> Entity
        User savedUser = userRepository.save(user);
        return userMapper.userToUserDTO(savedUser); // Entity -> DTO
    }
}

@RestController
@RequestMapping("/api/v1/users")
public class UserApiController {

    private final UserManagementService userService;

    @Autowired
    public UserApiController(UserManagementService userService) {
        this.userService = userService;
    }

    @GetMapping("/{id}")
    public ResponseEntity<UserDTO> getUser(@PathVariable Integer id) {
        UserDTO dto = userService.findUserById(id);
        if (dto == null) {
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(dto);
    }

    @GetMapping
    public List<UserDTO> listUsers() {
        return userService.findAllUsers();
    }

    @PostMapping
    public ResponseEntity<UserDTO> createUser(@RequestBody UserDTO userDTO) {
        UserDTO savedDto = userService.saveUser(userDTO);
        // 通常创建成功返回 201 Created 和资源 URI
        return ResponseEntity.status(HttpStatus.CREATED).body(savedDto);
    }
}
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
57
58
59
60
61
62
63
64
65
66
67
68
69
70

# 8.3 设计请求/响应专用 DTO

为了 API 的清晰和安全,通常会为 Controller 的请求 (@RequestBody) 和响应 (@ResponseBody 或 ResponseEntity) 设计专门的 DTO 类,而不是直接使用内部 DTO 或实体类。MapStruct 非常适合在这些专用 DTO 与内部模型之间进行转换。

import lombok.Data;
import javax.validation.constraints.*; // JSR 380 Bean Validation 注解

/**
 * 创建用户的 API 请求体 DTO
 * 包含验证注解
 */
@Data
class UserCreationRequest {
    @NotBlank(message = "用户名不能为空")
    @Size(min = 3, max = 50)
    private String username;

    @NotBlank @Email
    private String email;

    @NotBlank @Size(min = 8)
    private String password;

    private String fullName;
}

/**
 * 更新用户的 API 请求体 DTO (部分更新)
 */
@Data
class UserUpdateRequest {
    @Email // 邮箱可选,但如果提供,必须格式正确
    private String email;

    private String fullName;

    private Boolean active; // 允许更新状态
}

/**
 * API 响应的用户信息 DTO (不含敏感信息)
 */
@Data
class UserApiResponse {
    private Integer id;
    private String username;
    private String email;
    private String fullName;
    private String registrationDate; // 格式化日期
    private boolean active;
}

// 在 Mapper 接口中添加这些 DTO 的映射方法
@Mapper(componentModel = "spring")
public interface UserApiMapper {
    // 将 API 请求 DTO 转换为内部 User DTO 或 Entity
    @Mapping(target = "id", ignore = true) // 创建时忽略 ID
    @Mapping(target = "status", constant = "PENDING") // 设置默认状态
    User internalDtoFromCreationRequest(UserCreationRequest request);

    // 将内部 User DTO 或 Entity 转换为 API 响应 DTO
    @Mapping(source = "createTime", target = "registrationDate", dateFormat = "yyyy-MM-dd")
    UserApiResponse internalDtoToApiResponse(UserDTO internalDto);

    // 用于部分更新的方法 (更新 Entity)
    // target 不能是 DTO,必须是 Entity 或可变对象
    void updateInternalUserFromUpdateRequest(UserUpdateRequest request, @MappingTarget User internalUser);
}

// Controller 中使用 UserApiMapper
@RestController
@RequestMapping("/api/v2/users")
class UserApiV2Controller {
    // ... 注入 UserApiMapper 和 Service ...

    @PostMapping
    public ResponseEntity<UserApiResponse> createUser(@Valid @RequestBody UserCreationRequest request) {
        User internalUser = userApiMapper.internalDtoFromCreationRequest(request);
        // ... 调用 service 保存 internalUser ...
        UserDTO savedInternalDto = userService.create(internalUser); // 假设 service 返回内部 DTO
        UserApiResponse response = userApiMapper.internalDtoToApiResponse(savedInternalDto);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

    @PatchMapping("/{id}")
    public ResponseEntity<UserApiResponse> updateUser(@PathVariable Integer id, @RequestBody UserUpdateRequest request) {
        User existingInternalUser = userService.findInternalUserById(id); // 假设 service 返回内部 User
        userApiMapper.updateInternalUserFromUpdateRequest(request, existingInternalUser);
        // ... 调用 service 更新 existingInternalUser ...
        UserDTO updatedInternalDto = userService.update(existingInternalUser);
        UserApiResponse response = userApiMapper.internalDtoToApiResponse(updatedInternalDto);
        return ResponseEntity.ok(response);
    }
}
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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90

# 8.4 单元测试与集成测试

为 MapStruct Mapper 编写测试是保证映射逻辑正确性的关键。

单元测试 Mapper 接口:

由于 MapStruct 生成的是具体实现类,可以直接对 Mapper 接口进行单元测试(如果使用 componentModel="spring",需要 Spring 测试环境;如果用 default,可以直接 Mappers.getMapper)。

import org.junit.jupiter.api.Test;
import org.mapstruct.factory.Mappers;
import static org.junit.jupiter.api.Assertions.*;
import java.time.LocalDateTime;

// 假设 User, UserDTO, UserBaseMapper (default model) 定义如前

/**
 * UserBaseMapper 的单元测试 (使用 default model)
 */
class UserBaseMapperTest {

    // 获取 Mapper 实例
    private final UserBaseMapper mapper = Mappers.getMapper(UserBaseMapper.class);

    @Test
    void testUserToUserDTO() {
        // Arrange: 准备源对象
        User user = User.builder()
            .id(1)
            .username("test")
            .emailAddress("test@example.com")
            .createdAt(LocalDateTime.of(2024, 5, 10, 12, 0, 0))
            .isActive(true)
            .build();

        // Act: 执行映射
        UserDTO dto = mapper.userToUserDTO(user);

        // Assert: 验证结果
        assertNotNull(dto);
        assertEquals(user.getId(), dto.getId());
        assertEquals(user.getUsername(), dto.getUsername());
        assertEquals(user.getEmailAddress(), dto.getEmail()); // 验证 emailAddress -> email
        assertEquals("2024-05-10", dto.getCreationDate()); // 验证 createdAt -> creationDate (格式 yyyy-MM-dd)
        assertEquals(user.isActive(), dto.isActive());
    }

    @Test
    void testUserDTOToUser() {
        // Arrange: 准备源 DTO
        UserDTO dto = UserDTO.builder()
            .id(2)
            .username("dtoUser")
            .email("dto@example.com")
            .creationDate("2024-06-15") // String 类型日期
            .active(false)
            .build();

        // Act: 执行映射
        User user = mapper.userDTOToUser(dto);

        // Assert: 验证结果
        assertNotNull(user);
        assertEquals(dto.getId(), user.getId());
        assertEquals(dto.getUsername(), user.getUsername());
        assertEquals(dto.getEmail(), user.getEmailAddress()); // 验证 email -> emailAddress
        assertNotNull(user.getCreatedAt());
        assertEquals(2024, user.getCreatedAt().getYear());
        assertEquals(6, user.getCreatedAt().getMonthValue());
        assertEquals(15, user.getCreatedAt().getDayOfMonth()); // 验证 creationDate -> createdAt
        assertEquals(dto.isActive(), user.isActive());
        assertNull(user.getPasswordHash()); // 验证 passwordHash 被忽略
    }

    // 可以添加更多测试用例,覆盖 null 值、边界情况等
}
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
57
58
59
60
61
62
63
64
65
66
67

Spring Boot 集成测试 Controller:

在集成测试中,可以验证从 Controller 接收请求、Service 处理、Mapper 转换到最终响应的整个流程。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import com.fasterxml.jackson.databind.ObjectMapper; // Jackson 用于序列化请求体

// 假设 MySpringBootApplication, UserApiController, UserCreationRequest 等已定义

/**
 * UserApiController 的集成测试
 * 使用 MockMvc 模拟 HTTP 请求,验证端到端的流程
 */
@SpringBootTest // 加载完整的 Spring Boot 应用上下文
@AutoConfigureMockMvc // 自动配置 MockMvc
class UserApiControllerIntegrationTest {

    @Autowired
    private MockMvc mockMvc; // 用于执行模拟 HTTP 请求

    @Autowired
    private ObjectMapper objectMapper; // 用于将请求对象序列化为 JSON

    // @Autowired private UserRepository userRepository; // 可以注入 Repository 来准备/验证数据

    @Test
    void testCreateUserApi() throws Exception {
        // Arrange: 准备请求体 DTO
        UserCreationRequest request = new UserCreationRequest();
        request.setUsername("integrationTestUser");
        request.setEmail("integration@example.com");
        request.setPassword("password1234");
        request.setFullName("Integration Test");

        // Act & Assert: 执行 POST 请求并验证响应
        mockMvc.perform(post("/api/v2/users") // 目标 API 端点
                .contentType(MediaType.APPLICATION_JSON) // 设置请求类型为 JSON
                .content(objectMapper.writeValueAsString(request))) // 将请求对象序列化为 JSON 字符串
                .andExpect(status().isCreated()) // 验证 HTTP 状态码为 201 Created
                .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) // 验证响应类型
                .andExpect(jsonPath("$.id").exists()) // 验证响应体中包含 id 字段 (jsonPath 表达式)
                .andExpect(jsonPath("$.username").value(request.getUsername())) // 验证 username
                .andExpect(jsonPath("$.email").value(request.getEmail())) // 验证 email
                .andExpect(jsonPath("$.fullName").value(request.getFullName()))
                .andExpect(jsonPath("$.registrationDate").exists()) // 验证日期存在
                .andExpect(jsonPath("$.active").value(true)); // 假设创建后默认是 active
    }

    // 可以添加 GET, PATCH, DELETE 等其他端点的集成测试
}
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

# 8.5 常见问题与解决方案

  1. Mapper Bean 未注入 (NullPointerException):

    • 检查 @Mapper 注解: 确保接口上有 @Mapper(componentModel = "spring")。
    • 检查组件扫描: 确保 @SpringBootApplication 或 @ComponentScan 的 basePackages 包含了 Mapper 接口所在的包。
    • 检查编译: 确保项目已成功编译,并且 MapStruct 生成了 Impl 类。清理并重新构建项目 (mvn clean install 或 gradle clean build)。
    • 检查 Lombok 兼容性: 如果使用了 Lombok,确保添加了 lombok-mapstruct-binding 依赖并正确配置了 maven-compiler-plugin 的 annotationProcessorPaths 顺序。
  2. 循环依赖 (Circular Dependency):

    • 场景: OrderMapper uses CustomerMapper,同时 CustomerMapper uses OrderMapper。
    • 解决方案:
      • 合并: 将两个 Mapper 合并成一个,如 BusinessMapper。
      • 使用 @Context: 在需要循环调用的方法参数中加入 @Context 注解的其他 Mapper 实例。
      • 重构设计: 审视对象模型和 DTO 设计,是否能避免双向强依赖。
  3. 无法找到合适的映射方法:

    • 检查方法签名: 确保源类型和目标类型与 Mapper 中定义的方法匹配。
    • 检查 uses: 如果依赖其他 Mapper 或工具类的方法,确保已通过 uses = {...} 引入。
    • 检查自定义方法: 如果使用 default 或 static 方法,确保方法签名(参数类型、返回类型)正确。
    • 检查 qualifiedByName: 如果使用 @Named 和 qualifiedByName,确保名称匹配且方法唯一。
  4. 复杂逻辑难以实现:

    • 拆分逻辑: 不要试图在一个 @Mapping 或 expression 中完成所有事情。
    • 使用 default 方法: 将复杂转换逻辑封装在 Mapper 接口的 default 方法中。
    • 引入 Helper 类: 对于非常复杂的业务逻辑或需要外部依赖(如 Service),创建一个 Helper 类,在 Mapper 中通过 uses 引入,并在 @Mapping(expression = "java(myHelper.doComplexLogic(source.getField()))") 中调用。
  5. 更新映射 (@MappingTarget) 不生效:

    • 检查 @MappingTarget: 确保更新方法的目标参数(通常是实体对象)使用了 @MappingTarget 注解。
    • 检查 ignore = true: 确保没有在 @Mapping 中意外地将需要更新的字段设置为 ignore = true。
    • 检查 Setter 方法: 确保目标对象的字段有公开的 setter 方法。

核心总结

MapStruct 作为一款顶级的 Java Bean 映射框架,通过其编译时代码生成的核心机制,完美地结合了高性能、类型安全和易用性。它有效解决了手动映射和基于反射的映射工具(如 BeanUtils)的诸多痛点,显著提高了开发效率和代码质量。

关键优势:

  • 性能卓越: 接近手动编写 getter/setter 的性能。
  • 类型安全: 编译时检查,错误早发现。
  • 功能强大: 支持名称/类型不匹配、集合/嵌套对象、枚举、自定义逻辑等复杂场景。
  • 声明式配置: 通过注解定义映射规则,代码清晰。
  • 框架集成: 与 Spring Boot 等 DI 框架无缝集成。

适用场景:

  • 任何需要进行对象间属性复制的 Java 项目。
  • 分层架构中的 Entity, DTO, VO 转换。
  • 微服务间的数据模型转换。
  • 对性能和类型安全有较高要求的场景。

掌握 MapStruct 不仅能让你摆脱繁琐的对象转换代码,更能提升你构建高质量、易维护 Java 应用的能力。随着其社区的活跃和功能的不断完善,MapStruct 无疑是现代 Java 开发工具箱中不可或缺的一员。

编辑此页 (opens new window)
上次更新: 2025/04/06, 10:15:45
BeanUtils(对象复制工具)
Guava(开发工具包)

← BeanUtils(对象复制工具) Guava(开发工具包)→

Theme by Vdoing | Copyright © 2019-2025 程序员scholar
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式