1. 从手动“搬砖”到优雅映射:为什么我们需要 MapStruct
如果你写过 Java 后端服务,尤其是涉及分层架构(Controller-Service-DAO)或者微服务间数据交互的项目,那么“对象映射”这个活儿你一定没少干。想象一下这个场景:你从数据库查询出了一个UserEntity对象,里面有几十个字段,现在需要把它转换成一个给前端用的UserVO对象。于是你开始写:
UserVO vo = new UserVO(); vo.setId(entity.getId()); vo.setUsername(entity.getUsername()); vo.setEmail(entity.getEmail()); vo.setCreateTime(entity.getCreateTime()); // ... 还有地址、电话、头像等十几个字段这种代码,我称之为“属性搬运工”或“Getter/Setter 流水线”。写起来枯燥,看起来冗长,维护起来更是噩梦——一旦实体类或VO增减字段,你就得在多个地方同步修改,极易出错。更复杂的情况是,两个对象字段名不完全一致(比如createTime对应gmtCreate),或者类型需要转换(比如Date转String,或者List<Entity>转List<DTO>),手写代码的复杂度会直线上升。
这时候,对象映射框架就该登场了。你可能听说过 Apache BeanUtils、Spring BeanUtils、Dozer,或者 ModelMapper。它们通过反射机制在运行时进行属性拷贝,确实省去了手写代码。但反射带来的性能损耗,在追求极致效率的高并发场景下,是不得不考虑的代价。而且,这些工具在复杂映射、类型转换上的配置往往比较晦涩,出错时调试也不够直观。
MapStruct 的出现,就是为了解决这些痛点。它不是一个运行时通过反射工作的框架,而是一个Java 注解处理器。简单说,它在你编译代码的时候(mvn compile或javac),就会根据你写的映射接口和注解,自动生成出完整、高效、纯手写风格的映射实现类。这个生成的类里,就是一堆直接的getter和setter调用,没有任何反射,因此其性能与手写代码几乎无异,甚至因为避免了人为错误而更可靠。
所以,MapStruct 的核心价值在于:用接近声明式的简洁配置,换取运行时零开销的高性能映射代码。它特别适合对性能有要求、映射逻辑复杂且需要长期维护的中大型项目。接下来,我们就深入拆解它的核心用法和那些“教科书”里不会写的实战技巧。
2. 核心设计理念与工作原理解析
要玩转 MapStruct,不能只停留在“怎么用”的层面,理解其设计理念和工作原理,能让你在遇到复杂场景时游刃有余。
2.1 注解处理器:编译时生成代码的魔法
MapStruct 的核心机制是 Java 的注解处理器(Annotation Processor)。这是 Java 编译器(javac)提供的一个钩子,允许你在编译过程中读取源代码中的特定注解,并生成新的源代码文件。
当你定义一个使用了@Mapper注解的接口,并编写了映射方法后,MapStruct 的注解处理器会在编译阶段被触发。它会:
- 扫描所有带有
@Mapper注解的接口。 - 解析接口中每个方法的签名(源类型、目标类型)以及方法上附加的映射配置注解(如
@Mapping)。 - 根据这些信息,在内存中构建出一个映射逻辑的抽象语法树(AST)。
- 将这个逻辑树“翻译”成标准的 Java 代码,生成一个以
Impl为后缀的实现类(例如UserMapperImpl)。 - 将这个生成的
.java文件输出到指定的目录(通常是target/generated-sources/annotations),并参与后续的编译。
这个过程完全发生在编译期。最终打包进你 Jar 包的,是那个生成的UserMapperImpl.class文件,里面是如假包换的“手写代码”。因此,MapStruct 没有任何运行时依赖,生成的代码效率极高。
2.2 约定优于配置与显式配置的平衡
MapStruct 遵循“约定优于配置”的原则。当源对象(Source)和目标对象(Target)的属性名和类型完全一致时,你甚至不需要写任何额外的@Mapping注解,MapStruct 会自动帮你映射。
public class UserEntity { private Long id; private String name; private String email; // getters and setters } public class UserDTO { private Long id; private String name; private String email; // getters and setters } @Mapper public interface UserMapper { UserDTO toDTO(UserEntity entity); // 无需注解,自动按名称映射 }但是,现实世界很少如此理想。当遇到名称不一致、类型不一致、或需要复杂转换时,你就需要用到“显式配置”。MapStruct 提供了丰富的注解(主要是@Mapping)来应对这些情况。这种设计使得简单映射极其简洁,复杂映射也有清晰的表达路径,不会让配置变得一团乱麻。
2.3 映射策略:浅拷贝与深拷贝的抉择
这是对象映射中一个关键但容易被忽视的概念,MapStruct 的默认行为需要你心中有数。
- 浅拷贝(Shallow Copy):MapStruct 的默认行为。当映射一个对象时,对于其内部的引用类型属性(如另一个对象、List、Map),MapStruct 生成的是直接的赋值语句
target.setAddress(source.getAddress())。这意味着源对象和目标对象将共享同一个引用。修改目标对象中的Address,会影响到源对象中的Address。 - 深拷贝(Deep Copy):需要为目标对象的引用属性创建新的实例并进行属性拷贝。MapStruct 本身不自动进行深拷贝,因为这通常意味着递归地创建新对象,在对象图复杂时可能引发性能问题或循环引用。
注意:对于集合类型(
List,Set,Map),MapStruct 的默认行为是创建目标集合的新实例,但集合中的元素仍是浅拷贝。例如,将List<UserEntity>映射为List<UserDTO>,会生成一个新的ArrayList,但list.get(0)这个UserDTO对象和UserEntity对象是共享内部引用属性(如Address)的。这一点务必清楚。
如果你的业务场景要求完全的深拷贝,你有几种选择:
- 为嵌套的对象也定义映射方法,MapStruct 会自动调用。
- 在
@Mapping中使用expression或qualifiedByName来调用自定义的深拷贝方法。 - 在映射完成后,手动进行深拷贝(例如使用序列化/反序列化)。
理解默认的浅拷贝行为,能避免很多因对象状态意外共享而导致的诡异 Bug。
3. 从入门到精通:核心注解与复杂映射实战
掌握了原理,我们来看具体怎么用。我会从最简单的场景开始,逐步深入到那些真正体现 MapStruct 威力的复杂映射。
3.1 基础搭建与简单映射
首先,在 Maven 项目中引入依赖。注意,因为 MapStruct 是编译时生成代码,所以需要两个依赖:核心注解包和注解处理器。
<dependencies> <dependency> <groupId>org.mapstruct</groupId> <artifactId>mapstruct</artifactId> <version>1.5.5.Final</version> <!-- 请使用最新版本 --> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.11.0</version> <configuration> <annotationProcessorPaths> <path> <groupId>org.mapstruct</groupId> <artifactId>mapstruct-processor</artifactId> <version>1.5.5.Final</version> </path> <!-- 如果你使用了 Lombok,必须将其处理器也加上,且顺序在 MapStruct 之前 --> <path> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.30</version> <!-- 匹配你的 Lombok 版本 --> </path> </annotationProcessorPaths> </configuration> </plugin> </plugins> </build>实操心得:与 Lombok 的兼容性是新手最常见的坑。Lombok 和 MapStruct 都是注解处理器,且 Lombok 需要在 MapStruct 之前运行,因为它要先生成 Getter/Setter,MapStruct 才能看到它们。上述配置中的顺序至关重要。如果你用 IntelliJ IDEA,还需要确保在
Settings -> Build, Execution, Deployment -> Compiler -> Annotation Processors中勾选了Enable annotation processing。
定义你的第一个 Mapper:
import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.factory.Mappers; @Mapper // 标记这是一个 MapStruct 映射器接口 public interface CarMapper { // 声明一个单例实例获取方式(这是可选的,另一种方式是通过依赖注入) CarMapper INSTANCE = Mappers.getMapper(CarMapper.class); // 基础映射:字段名一致,自动映射 CarDTO carToCarDTO(Car car); // 带自定义映射:指定不同字段名的对应关系 @Mapping(source = "numberOfSeats", target = "seatCount") @Mapping(target = "price", constant = "100000.00") // 固定值 CarDTO carToCarDTOWithCustom(Car car); // 忽略某个字段不进行映射 @Mapping(target = "id", ignore = true) CarDTO carToCarDTOIgnoreId(Car car); }编译项目后,你会在target/generated-sources/annotations下找到CarMapperImpl.java,里面就是生成的代码。
3.2 处理字段名与类型差异
这是@Mapping注解的主战场。
public class OrderEntity { private Long orderId; private BigDecimal amount; private Date createTime; private CustomerEntity customer; // 嵌套对象 private List<ItemEntity> items; // 嵌套集合 } public class OrderDTO { private String id; // 类型从 Long 变为 String private String totalAmount; // 字段名和类型都变了 private String createTimeStr; // Date -> String private CustomerDTO buyer; // 嵌套对象,字段名也变了 private List<ItemDTO> productList; // 嵌套集合,字段名变了 } @Mapper(uses = {DateMapper.class, CustomerMapper.class, ItemMapper.class}) // 使用其他映射器 public interface OrderMapper { @Mapping(source = "orderId", target = "id") @Mapping(source = "amount", target = "totalAmount") @Mapping(source = "createTime", target = "createTimeStr") @Mapping(source = "customer", target = "buyer") @Mapping(source = "items", target = "productList") OrderDTO toDTO(OrderEntity entity); // 反向映射也经常需要 @InheritInverseConfiguration(name = "toDTO") // 继承 toDTO 的配置,但方向相反 OrderEntity toEntity(OrderDTO dto); }这里有几个关键点:
- 类型转换:
orderId (Long) -> id (String)和amount (BigDecimal) -> totalAmount (String),MapStruct 会自动调用String.valueOf()和BigDecimal.toString()。对于基本类型、包装类型和 String 之间的常见转换,MapStruct 内置了处理。 - 日期格式化:
Date -> String需要自定义逻辑。我通过uses = DateMapper.class引入了一个自定义的转换器。 - 嵌套映射:
customer -> buyer,items -> productList。MapStruct 会尝试寻找将CustomerEntity转为CustomerDTO,将ItemEntity转为ItemDTO的方法。如果在本 Mapper 中没找到,就会去uses指定的类里找。这就是为什么OrderMapper的@Mapper注解里要声明uses。
3.3 自定义类型转换与格式化
对于内置转换无法处理的类型,你需要提供自定义方法。
场景一:自定义日期格式化
public class DateMapper { // 定义一个线程安全的 DateTimeFormatter private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); public String asString(Date date) { if (date == null) { return null; } Instant instant = date.toInstant(); LocalDateTime localDateTime = LocalDateTime.ofInstant(instant, ZoneId.systemDefault()); return FORMATTER.format(localDateTime); } public Date asDate(String dateStr) { if (dateStr == null || dateStr.isEmpty()) { return null; } LocalDateTime localDateTime = LocalDateTime.parse(dateStr, FORMATTER); return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant()); } }然后在主 Mapper 的@Mapper(uses = DateMapper.class)中引用它。当 MapStruct 需要将Date转为String或反之时,就会自动调用DateMapper中签名匹配的方法。
场景二:使用表达式(Expression)适用于简单的、一行代码能搞定的转换,尤其是调用静态方法。
@Mapping(target = "statusText", expression = "java(convertStatus(entity.getStatus()))") OrderDTO toDTO(OrderEntity entity); // 这个方法不需要在接口中声明,MapStruct 会直接把它写入生成的代码。 default String convertStatus(Integer statusCode) { switch (statusCode) { case 1: return "待支付"; case 2: return "已发货"; case 3: return "已完成"; default: return "未知"; } }注意:
expression中的java(...)是固定写法,里面是纯 Java 代码。虽然灵活,但过度使用会降低代码的可读性和类型安全性,建议优先使用下面介绍的qualifiedByName方式。
场景三:使用@Named注解与qualifiedByName这是更优雅、可复用的自定义转换方式。
public class MoneyMapper { @Named("yuanToFen") // 给这个方法起个名字 public Integer yuanToFen(BigDecimal yuan) { return yuan == null ? null : yuan.multiply(new BigDecimal("100")).intValue(); } @Named("fenToYuan") public BigDecimal fenToYuan(Integer fen) { return fen == null ? null : new BigDecimal(fen).divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP); } } // 在 OrderMapper 中 @Mapper(uses = {DateMapper.class, MoneyMapper.class}) public interface OrderMapper { @Mapping(source = "amount", target = "amountInFen", qualifiedByName = "yuanToFen") OrderDTO toDTO(OrderEntity entity); }这种方式将转换逻辑封装在独立的方法中,并通过一个语义化的名字引用,清晰且可复用。
3.4 集合映射与嵌套映射
集合映射是 MapStruct 的强项,它非常智能。
@Mapper(uses = ItemMapper.class) public interface OrderMapper { // 自动映射 List<ItemEntity> 到 List<ItemDTO> // 前提是 ItemMapper 提供了 ItemEntity 到 ItemDTO 的映射方法 List<ItemDTO> toDTOList(List<ItemEntity> entities); // 同样支持 Set, Map, 数组等 Set<ItemDTO> toDTOSet(Set<ItemEntity> entities); Map<String, ItemDTO> toDTOMap(Map<String, ItemEntity> entityMap); }对于嵌套映射,只要为嵌套的类型定义了对应的映射方法(在本 Mapper 内或通过uses引入),MapStruct 就能自动处理多层级的对象图映射。
3.5 多源参数映射与条件映射
多源参数映射:有时你需要将多个源对象的属性合并到一个目标对象中。
public class DeliveryAddress { private String province; private String city; private String detail; } public class ContactInfo { private String receiverName; private String phone; } public class OrderDTO { private String receiverName; private String phone; private String fullAddress; } @Mapper public interface OrderMapper { @Mapping(source = "contactInfo.receiverName", target = "receiverName") @Mapping(source = "contactInfo.phone", target = "phone") @Mapping(source = "address.province", target = "fullAddress") // 这里只映射了一个省 OrderDTO mergeToDTO(DeliveryAddress address, ContactInfo contactInfo); // 更复杂的合并:使用表达式拼接地址 @Mapping(target = "fullAddress", expression = "java(address.getProvince() + address.getCity() + address.getDetail())") OrderDTO mergeToDTOWithExpression(DeliveryAddress address, ContactInfo contactInfo); }条件映射:只有满足条件时才进行映射。
@Mapper public interface UserMapper { @Mapping(target = "email", source = "email", conditionQualifiedByName = "nonEmptyString") UserDTO toDTO(UserEntity entity); @Named("nonEmptyString") default boolean isNotEmpty(String value) { return value != null && !value.trim().isEmpty(); } }当entity.getEmail()不为空且非空字符串时,才会执行映射。这对于避免用空值覆盖目标对象的默认值非常有用。
4. 高级特性与集成实践
当项目变得复杂,你需要 MapStruct 提供更强大的组织能力和集成能力。
4.1 组件模型与依赖注入
在大型应用中,你肯定不希望用Mapper.INSTANCE这种单例模式,而是希望 Mapper 能像 Spring Bean 一样被管理、注入和测试。MapStruct 完美支持这一点。
通过设置componentModel参数,你可以让 MapStruct 生成适合特定依赖注入框架的代码。
与 Spring 集成(最常用):
import org.mapstruct.Mapper; import org.springframework.stereotype.Component; @Mapper(componentModel = "spring") // 关键在这里 public interface UserMapper { UserDTO toDTO(UserEntity entity); }编译后,生成的UserMapperImpl会带上@Component注解。这样你就可以在 Service 里直接@Autowired注入UserMapper了。
其他组件模型:
componentModel = "cdi": 用于 Jakarta CDI (Contexts and Dependency Injection)。componentModel = "jsr330": 生成@javax.inject.Named和@Singleton注解,适用于 Google Guice 或 Spring 的 JSR-330 支持。- 不指定或
componentModel = "default": 生成普通的类,你需要通过Mappers.getMapper(...)获取实例。
4.2 继承与配置共享
你可以创建一个基础 Mapper 来定义公共的配置或方法,然后让其他 Mapper 继承它。
// 1. 使用 @MapperConfig 定义配置 @MapperConfig( unmappedTargetPolicy = ReportingPolicy.IGNORE, // 忽略未映射的目标属性 nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE, // 忽略源为null的属性 uses = {DateMapper.class} // 公共的转换器 ) public interface CentralConfig { } // 2. 具体的 Mapper 继承这个配置 @Mapper(config = CentralConfig.class, uses = {MoneyMapper.class}) // 可以叠加自己的配置 public interface OrderMapper extends CentralConfig { // 这里自动继承了 CentralConfig 的配置 }更强大的是方法继承:
public interface BaseMapper<S, T> { T toTarget(S source); S toSource(T target); List<T> toTargetList(List<S> sources); } @Mapper public interface UserMapper extends BaseMapper<UserEntity, UserDTO> { // 可以覆盖或添加额外的方法 @Mapping(source = "birthday", target = "age", qualifiedByName = "calculateAge") UserDTO toTarget(UserEntity source); @Named("calculateAge") default Integer calculateAge(Date birthday) { // ... 计算年龄逻辑 return age; } }这样,所有实体-DTO对的通用映射方法(如列表转换)都可以在基接口中定义,极大减少重复代码。
4.3 处理枚举映射和默认值
枚举映射很常见,比如数据库存的是数字或代码,前端需要显示文字。
public enum OrderStatus { PENDING(1, "待处理"), SHIPPED(2, "已发货"), DELIVERED(3, "已送达"); private final int code; private final String desc; // constructor, getters } public class OrderEntity { private Integer statusCode; // 存的是 1,2,3 } public class OrderDTO { private String statusDesc; // 需要显示 “待处理”,“已发货” } @Mapper public interface OrderMapper { @Mapping(target = "statusDesc", source = "statusCode") OrderDTO toDTO(OrderEntity entity); default String statusCodeToDesc(Integer code) { if (code == null) return null; for (OrderStatus status : OrderStatus.values()) { if (status.getCode() == code) { return status.getDesc(); } } throw new IllegalArgumentException("未知状态码: " + code); } }MapStruct 发现源类型Integer和目标类型String不匹配,且没有内置转换,就会在 Mapper 里寻找一个签名匹配的方法String methodName(Integer)来调用。我们提供了statusCodeToDesc方法,它就会被自动使用。
设置默认值:
@Mapping(target = "priority", source = "priority", defaultValue = "NORMAL") @Mapping(target = "quantity", source = "quantity", defaultExpression = "java(java.util.Optional.ofNullable(quantity).orElse(1))") OrderDTO toDTO(OrderEntity entity);defaultValue用于当源属性为null时,赋予目标属性一个常量字符串。defaultExpression则更灵活,可以写 Java 表达式。
5. 性能调优、问题排查与最佳实践
即使工具强大,用不好也会踩坑。这部分是我在实际项目中积累的血泪经验。
5.1 性能考量与优化建议
MapStruct 生成的代码性能极高,但使用不当仍有优化空间。
- 避免循环引用:如果两个类互相引用(如
Order里有List<Item>,Item里又有Order),在映射时如果不加处理,会导致栈溢出。解决方案是使用@Mapping的ignore属性在某一方向上打断循环,或者使用@Context参数来传递一个“已映射”的对象缓存。 - 谨慎使用
BeanMapping的nullValueMappingStrategy:
当源列表为@BeanMapping(nullValueMappingStrategy = NullValueMappingStrategy.RETURN_DEFAULT) List<UserDTO> toDTOList(List<UserEntity> entities);null时,此策略会返回一个空集合而不是null。这可以避免 NPE,但你需要清楚这个行为。我更推荐在业务逻辑层处理空值,保持 Mapper 的纯粹性。 - 批量映射 vs 单个映射:对于列表映射,MapStruct 会循环调用单个对象的映射方法。确保你的自定义转换器(
uses中的类)是无状态的、线程安全的,这样效率最高。 - 编译时间:对于有几百个 Mapper 的超大型项目,编译时注解处理可能会稍微增加编译时间。可以考虑将 Mapper 接口模块化,或者使用增量编译。
5.2 常见问题与排查技巧
问题1:编译失败,提示“No property named "xxx" exists in source parameter(s).”
- 原因:
@Mapping(source = "xxx")中指定的属性在源对象中不存在。 - 排查:
- 检查源对象类是否有
getXxx()或isXxx()方法。 - 如果使用了 Lombok,确保 Lombok 注解处理器在 MapStruct 之前运行(见 3.1 的 Maven 配置)。
- 如果是嵌套属性(
source = "a.b.c"),检查整个路径上的每个属性是否存在。
- 检查源对象类是否有
问题2:生成的实现类没有在 target/generated-sources 目录下,导致 IDE 报错。
- 原因:IDE 没有启用注解处理,或者路径没有被标记为源码目录。
- 排查:
- IntelliJ IDEA:确保
Settings -> Build, Execution, Deployment -> Compiler -> Annotation Processors已勾选Enable annotation processing,并且Generated sources directory的路径是target/generated-sources/annotations(对于 Maven)。然后,右键点击target/generated-sources/annotations目录,选择Mark Directory as -> Generated Sources Root。 - Eclipse:项目右键 ->
Maven -> Update Project...,勾选Clean projects和Refresh workspace。确保Project -> Properties -> Java Compiler -> Annotation Processing是启用的。 - 执行一次
mvn clean compile命令,强制重新生成。
- IntelliJ IDEA:确保
问题3:映射结果中某些字段为 null,但明明源对象有值。
- 原因:
- 最常见的可能是字段名不匹配,且没有用
@Mapping指定。 - 类型不匹配,且没有提供合适的转换方法。
- 使用了
NullValuePropertyMappingStrategy.IGNORE且源属性为null,导致目标属性未被更新(如果你希望用null覆盖,需使用SET_TO_NULL)。
- 最常见的可能是字段名不匹配,且没有用
- 排查:查看生成的实现类!这是最有效的调试手段。打开
target/generated-sources/annotations下的*Impl.java文件,直接看生成的代码逻辑,一眼就能看出问题所在。
问题4:与 Lombok、Jackson 等库的 Getter/Setter 命名冲突。
- 场景:Lombok 生成的 Getter 是
getActive(),但 Jackson 反序列化期望字段是active,而 MapStruct 默认按 Getter/Setter 来映射。 - 解决:在
@Mapper注解中配置访问策略。
也可以考虑使用@Mapper(config = CentralConfig.class) public interface MyMapper { // 或者使用 @BeanMapping 在方法级别覆盖 } @MapperConfig(collectionMappingStrategy = CollectionMappingStrategy.TARGET_IMMUTABLE, accessorNamingStrategy = AccessorNamingStrategy.BEAN) // 使用标准的 Bean 命名约定 public interface CentralConfig { }@BeanMapping的nullValuePropertyMappingStrategy和mappingInheritanceStrategy进行更精细的控制。
5.3 项目中的最佳实践总结
- 一对象一Mapper原则:不要试图创建一个
GodMapper来映射所有对象。为每个领域聚合或功能模块创建独立的 Mapper 接口,职责清晰,便于维护。 - 善用
@MapperConfig进行全局配置:将unmappedTargetPolicy(建议设为WARN而非ERROR,便于渐进式重构)、nullValueMappingStrategy、公共的uses转换器等放在一个中央配置中,让所有 Mapper 继承,保持风格统一。 - 自定义转换器保持无状态和可测试:
uses中引用的类(如DateMapper,MoneyMapper)应该是工具类,包含静态方法或线程安全的实例方法。最好为它们编写单元测试。 - 为复杂映射编写单元测试:不要假设 MapStruct 生成的代码永远正确。为那些包含复杂
@Mapping、表达式或自定义转换器的映射方法编写单元测试,确保业务逻辑的准确性。这也能在重构实体或 DTO 时快速发现断裂的映射。 - 版本管理:在
pom.xml中用mapstruct.version属性统一管理 MapStruct 和 MapStruct Processor 的版本,确保一致。 - IDE 支持:安装 MapStruct 插件(IntelliJ IDEA 和 Eclipse 都有),它能提供代码导航(从接口跳转到实现)、错误提示和部分自动补全功能,极大提升开发体验。
MapStruct 不是一个“银弹”,但它绝对是 Java 对象映射领域最锋利、最趁手的工具之一。从繁琐的手动赋值中解放出来,将精力集中在真正的业务逻辑上,这正是它带来的最大价值。开始尝试在下一个新项目或重构模块中引入 MapStruct,你会立刻感受到那种代码变得清晰、简洁的愉悦。