刚学 Java 的时候谁没写过这样的代码UserDTO userDTO new UserDTO(); userDTO.setName(userEntity.getName()); userDTO.setAge(userEntity.getAge()); userDTO.setEmail(userEntity.getEmail()); // ... 省略 20 行 get/set写了几次之后你肯定烦了。这时候有人告诉你用 BeanUtils 啊一行搞定。于是你的代码变成了这样BeanUtils.copyProperties(userEntity, userDTO);爽不爽太爽了。一行代码属性全拷过去了。Spring 自带的、Apache Commons 的随便选一个就能用。说实话在项目初期、流量不大的时候BeanUtils 完全没有问题。代码简洁开发效率高谁用谁知道。没必要一上来就追求“最优解”先跑起来比什么都重要。但随着业务发展问题开始冒出来了BeanUtils 的缺陷显露项目跑了一段时间流量上来了。某天你突然发现接口响应变慢了。你开始排查——数据库很快缓存命中也高那问题出在哪CPU 在忙什么打开火焰图一看好家伙30% 的 CPU 时间都耗在了 BeanUtils.copyProperties() 的反射调用上。为什么会这样BeanUtils 底层用的是Java 反射机制。每次调用它都要做这些事情去查源对象有哪些字段去查目标对象有哪些字段匹配同名的通过反射调用 getter 和 setter做一大堆安全检查和类型检查这个过程相当于什么呢想象一下你每天上班都要从柜子里拿文件。一开始你随手翻翻就能找到。但后来文件越来越多你每次都要从头翻一遍目录、找到位置、再拿出来——反射就是在做这个“翻目录”的活。而 MapStruct 的做法是提前把“哪个文件在哪个位置”记在脑子里直接走过去就拿。更麻烦的是BeanUtils 是浅拷贝。对深拷贝浅拷贝不熟悉的同学可以先看看这篇文章 Java 浅拷贝与深拷贝详解-CSDN博客如果你的对象里有嵌套对象它只会复制引用不会复制里面的内容。改了一个地方另一个地方也跟着变了这种 bug 特别难查。接下来用具体的案例来演示一下。在写代码之前先给项目加上依赖因为我在代码演示时是纯粹的 JavaSE 环境脱离 SpringBoot所以没法直接用 BeanUtils所以需要单独引入。dependency groupIdcommons-beanutils/groupId artifactIdcommons-beanutils/artifactId version1.11.0/version scopecompile/scope /dependency !-- 辅助工具lombok不熟悉的朋友可以看我这边对于lombok的讲解【https://blog.csdn.net/Z_ZX19/article/details/162731989】 -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId version1.18.30/version scopeprovided/scope /dependency假设用户里面有个收货地址Address 对象。import lombok.Data; Data class User { private String name; private Address address; // 引用类型 }import lombok.AllArgsConstructor; import lombok.Data; Data AllArgsConstructor NoArgsConstructor class Address { private String city; }然后我们在main函数里用BeanUtils.copyProperties拷贝一下import org.apache.commons.beanutils.BeanUtils; import java.lang.reflect.InvocationTargetException; public class Main { public static void main(String[] args) throws InvocationTargetException, IllegalAccessException { // 原始数据小王住在北京 Address address new Address(北京); User user1 new User(); user1.setName(小王); user1.setAddress(address); // 开始拷贝 User user2 new User(); BeanUtils.copyProperties(user2, user1); System.out.println(拷贝后user2 的城市 user2.getAddress().getCity()); // 输出北京 // ----- 关键操作我只修改 user1 的地址 ----- user1.getAddress().setCity(上海); System.out.println(修改 user1 后user2 的城市 user2.getAddress().getCity()); } }从上图中可以看到在测试浅拷贝时发现直接都拷贝不成功了user2 都还是 null通过一顿折腾发现了原因先看我的代码结构结合看我写的 User 类和 Address 类你会发现我这两个类没有加 public 修饰符但是因为都是一个包里所以调用是正常的但是拷贝是失败的因为BeanUtils的copyProperties方法不能复制非public声明类的属性我猜测是 BeanUtils 使用反射进行拷贝时有权限访问问题导致的加上 public 就正常了。看一下输出结果直接出现浅拷贝问题 因为 BeanUtils 拷贝 user1 时看到address是个对象它想都没想直接把 user1 的 address 内存地址引用复制给了 user2。这种 Bug 在并发环境或者做数据脱敏时极其隐蔽查得你怀疑人生。MapStruct 实现拷贝接下来我通过 MapStruct来实现依旧先引入依赖dependency groupIdorg.mapstruct/groupId artifactIdmapstruct/artifactId version1.6.3/version scopecompile/scope /dependency dependency groupIdorg.mapstruct/groupId artifactIdmapstruct-processor/artifactId version1.6.3/version scopeprovided/scope /dependency接着模拟真实场景创建两个属性一致的 UserDTO 类和 AddressDTO 类这里我继续故意不写 public连同原先的 User 和 Address 的 public 也一起去掉看看 MapStruct 中是否也有影响Data class UserDTO { private String name; private Address address; // 引用类型 }import lombok.AllArgsConstructor; import lombok.Data; Data AllArgsConstructor NoArgsConstructor class AddressDTO { private String city; }接下来写了个两个接口Mapper(uses {AddressMapper.class}) // 引用子 Mapper public interface UserMapper { /** * UserMapper 接口 */ UserMapper INSTANCE Mappers.getMapper(UserMapper.class); /** * User 转 UserDTO */ UserDTO toDTO(User user); }Mapper public interface AddressMapper { /** * AddressMapper 接口 */ AddressMapper INSTANCE Mappers.getMapper(AddressMapper.class); /** * Address 转 AddressDTO */ AddressDTO toDTO(Address address); }接下来写一个测试代码public class Main { public static void main(String[] args) { Address addr new Address(); addr.setCity(北京); User user1 new User(); user1.setName(小王); user1.setAddress(addr); // MapStruct 转换 UserDTO userDTO UserMapper.INSTANCE.toDTO(user1); // 修改原对象 user1 的地址 user1.getAddress().setCity(上海); System.out.println(原对象 user1 城市 user1.getAddress().getCity()); System.out.println(DTO 对象城市 userDTO.getAddress().getCity()); } }发现是可以正常运行的并且也是深拷贝成功规避了风险运行起来之后我们还能在 target 包中发现了两个实现类这就是 MapStruct 帮我偷懒注意这两个文件是.java结尾的并非.class这里的代码是生成的上面我们引入依赖时多引入了一个叫 mapstruct-processor 的依赖这个代码就是它来生成的它是“代码生成器”的引擎当你写了Mapper接口时普通的编译器javac是不认识这个注解的。mapstruct-processor的作用就是作为一个插件挂载到编译器上。它在编译时扫描到你的Mapper注解然后自动为你生成XxxMapperImpl类。如果没有它编译器就不会生成任何实现类。从代码也可以看到 Generated 注解爆红了引入失败了是因为我当前使用的是Java 17。在 Java 9 之前javax.annotation.Generated是 JDK 自带的但从 Java 9 开始Java 进行了模块化改造这个注解被移到了javax.annotation.processing.Generated而原有的javax.annotation包被从 JDK 核心中移除了。但是这里爆红是可以不处理的不影响运行因为Generated这个注解的作用仅仅是给程序员或代码分析工具看的它的意思是“注意啦这段代码不是人手写的是机器自动生成的”。它本身没有任何控制程序流程、注入依赖或修改业务逻辑的功能。并且这个注解仅仅存在于.java源码中在编译成.class字节码文件时它会被编译器直接丢弃。如果你觉得代码爆红不美观看着很难受你可以手动引入依赖dependency groupIdjavax.annotation/groupId artifactIdjavax.annotation-api/artifactId version1.3.2/version /dependency也可以手动修改生成的代码如下图但不建议这样做不然每次生成新的都需要重新手动添加很麻烦有开发者用 JMHJava 微基准测试框架做了对比测试。结果是工具性能表现MapStruct接近手写 get/set性能最高Spring BeanUtils比 MapStruct 慢4-5 倍Apache BeanUtils比 MapStruct 慢10 倍以上MapStruct 的耗时基本不随属性数量增加而增长而 BeanUtils 的耗时跟属性个数成正比。翻译成人话属性越多、调用越频繁MapStruct 的优势就越明显。MapStruct为什么快我们来看一张图左边是 BeanUtils 的流程每次调用都要走一遍反射性能损耗肉眼可见。右边是 MapStruct 的流程编译时生成代码运行时直接调用没有反射没有额外开销。那是不是所有场景都要用 MapStruct也不是。BeanUtils 依然有它的用武之地简单的脚本工具、一次性任务对性能不敏感的后台管理系统快速原型验证MapStruct 更适合高并发、高频调用的 API 接口分层架构中 DTO 与 Entity 的频繁转换字段多、嵌套深、映射规则复杂的业务长期维护的大型项目简单来说小项目、低流量BeanUtils 够用大项目、高并发优先 MapStruct。技术选型这件事没有绝对的“最好”只有“最适合”。BeanUtils 帮助我们快速完成了早期项目的开发它是个好工具只是不适合所有场景。而 MapStruct 的出现也不是要“消灭”谁它只是在我们遇到性能瓶颈和复杂映射需求时提供了一个更优的解决方案。MapStruct 的写法看到这里有没有人跟我一样觉得 MapStruct 的写法有点“反人类”。BeanUtils 多爽啊一行BeanUtils.copyProperties(源, 目标)像把大象塞冰箱一样简单粗暴。而 MapStruct 突然让你写接口、写INSTANCE、写Mapping这感觉就像BeanUtils 是“点外卖”你给老板工具类打个电话说“我要吃的”老板直接给你送来了但你不知道他怎么做的。MapStruct 是“请私厨”你得先签合同定义接口、列菜单写方法、指定食材怎么切写Mapping然后厨师在开饭前编译期就咔咔咔全备好了。我们接下来一起来拆解一下MapStruct的写法。注在 SpringBoot 环境使用 Mapper 结尾有歧义一般集成了 MyBatis 系列的框架Mapper 结尾都是操作数据库的接口或者类所以可以根据自己团队的规范对 MapStruct 的接口进行命名就好。并且在使用Mapper注解时也注意不要导错包。第一块为什么是 interface接口而不是 class类你可能会想“我直接写个class UserMapper里面放个toDTO方法不香吗非得整个接口”核心原因因为 MapStruct 要替你写实现类。如果你写的是class那代码就定死了。但如果你写的是interfaceMapStruct 的注解处理器在编译时就能“偷偷”帮你生成一个UserMapperImpl实现类。打个响指这就像 MyBatis 里的 Mapper 接口你只写接口MyBatis 给你生成 SQL 执行代码MapStruct 也是一个道理——你只管定义“我要什么”编译器帮你生成“怎么做”的代码。第二块这个诡异的 INSTANCE 到底是几个意思看这行代码UserMapper INSTANCE Mappers.getMapper(UserMapper.class);很多新手心里犯嘀咕“我干嘛非要多写这一行直接new UserMapper()不行吗”答案因为接口不能new。这行代码的本质是利用工厂模式拿到刚才提到的那个“隐藏的实现类”。Mappers.getMapper(UserMapper.class)干了两件事在 classpath 里找到编译时生成的UserMapperImpl类。用单例模式把实例给你所以它一般叫INSTANCE你想叫别的也行确保全局只有一个。在 SpringBoot 的环境中就不用这样写了推荐下面这种写法第一步Mapper 接口加上componentModel springimport org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.factory.Mappers; // 关键加上 componentModel spring Mapper(componentModel spring) public interface UserMapper { // 注意不再需要 INSTANCE 了 // UserMapper INSTANCE Mappers.getMapper(UserMapper.class); // 这行删掉 Mapping(source userName, target name) UserDTO toDTO(UserEntity entity); }就这么一个改动。MapStruct 在编译时会自动在生成的UserMapperImpl类上加上Component注解这样 Spring 就能扫描到它了第二步在 Service 里直接Resource注入使用Service public class UserService { Resource private UserMapper userMapper; // 直接注入像注入普通 Service 一样 public UserDTO getUserDTO(UserEntity entity) { return userMapper.toDTO(entity); // 直接调用 } }注在 SpringBoot 环境使用 Mapper 结尾有歧义一般集成了 MyBatis 系列的框架Mapper 结尾都是操作数据库的接口或者类所以可以根据自己团队的规范对 MapStruct 的接口进行命名就好。并且在使用Mapper注解时也注意不要导错包。第三块Mapping 为什么这么写这括号里的东西是啥最“怪”的地方尤其是source和target的写法Mapping(source user_name, target name) UserVO toVO(UserEntity entity);把它翻译成大白话“把来源对象UserEntity里的 user_name 字段给我装到目标对象UserVO的 name 字段里去。”这其实就是做了一次“字段名映射配置”。我们对比一下 BeanUtils 的做法BeanUtils 的逻辑“我把两个对象的所有字段名都扫一遍名字一样我就拷不一样我就当没看见。”MapStruct 的逻辑“我不靠猜我只信你写的配置。你让我把 A 给 B我就给你按指令干活。你没配的字段我默认当你不想要直接忽略。”你觉得怪是因为它把“暗号”字段映射关系摆在了明面上。但这也意味着——如果user_name写成了user_Name大小写错编译直接报错不可能偷偷丢数据。并且这个注解可以堆叠一一指明对应关系Mapper public interface OrderMapper { OrderMapper INSTANCE Mappers.getMapper(OrderMapper.class); // 堆叠多个 Mapping一一指明对应关系 Mapping(source ordId, target orderId) Mapping(source cstNm, target customerName) Mapping(source prdCd, target productCode) Mapping(source ordDt, target orderDate) Mapping(source paySt, target payStatus) Mapping(source shipAddr, target shippingAddress) OrderVO toVO(OrderEntity entity); }如果字段实在是太多太多了比如 50 个字段全不一样怎么办这种情况其实很少见但如果真遇到了MapStruct 还有一个大招BeanMapping(ignoreByDefault true)。这个注解的意思是“我让你忽略所有字段只有我明确写了 Mapping 的你才给我映射。”这样做的好处是强制精确匹配。万一你漏配了一个字段编译直接报错绝对不会出现“某个字段因为名字没对上静默丢失数据”的情况。Mapper public interface HugeMapper { HugeMapper INSTANCE Mappers.getMapper(HugeMapper.class); BeanMapping(ignoreByDefault true) // 开启强制精确模式 Mapping(source a, target A) Mapping(source b, target B) Mapping(source c, target C) // ... 哪怕堆 50 行 Mapping 也没问题 TargetVO toVO(SourceEntity entity); }第四块它凭什么能处理嵌套对象usesMapper(uses {AddressMapper.class}) public interface UserMapper { UserDTO toDTO(User user); }这个注解这相当于什么相当于你跟主厨UserMapper说“我今天要做全羊宴UserDTO里面有个羊腿Address我搞不定我专门请了个做羊腿的副厨AddressMapper他负责处理这块你到时候喊他帮忙就行。”当你调用UserMapper.toDTO()时生成的代码内部逻辑其实是// 这是 MapStruct 自动生成的 UserMapperImpl 里面的伪代码 public UserDTO toDTO(User user) { UserDTO dto new UserDTO(); dto.setName(user.getName()); // 注意这一行它没有直接复制引用而是调用了副厨的 INSTANCE dto.setAddress( AddressMapper.INSTANCE.toDTO(user.getAddress()) ); return dto; }看到了吗它帮你做了“组合”。你只要把副厨子Mapper通过uses引进来主厨干活时自动会去调用它。这个写法虽然乍看多了一道工序但实际上比 BeanUtils 那种“懒洋洋的一把抓”要严谨得多。并且可以无限叠加想塞多少个就塞多少个。写法也很直观用逗号隔开就行了Mapper(uses {AddressMapper.class, OrderMapper.class, ProductMapper.class, UserInfoMapper.class}) public interface UserMapper { UserDTO toDTO(User user); }第五块为什么说它“怪得有价值”假设你要把一个Order转成OrderVO其中字段名有 3 个不一样还要格式化日期。用 BeanUtils写起来爽跑起来慌OrderVO vo new OrderVO(); BeanUtils.copyProperties(order, vo); // 拷贝完了但日期没格式化字段名不一样的丢了数据你还得手动补下面两行 vo.setCreateTime( sdf.format(order.getCreateTime()) ); vo.setTotalAmount( order.getAmount().toString() ); // 写到这里你还不如一开始就全手写BeanUtils 只帮你干了半拉活儿。用 MapStruct写起来麻烦但写完就解脱Mapper public interface OrderMapper { OrderMapper INSTANCE Mappers.getMapper(OrderMapper.class); Mapping(source amount, target totalAmount) Mapping(source createTime, target createTime, dateFormat yyyy-MM-dd) OrderVO toVO(Order order); } // 调用一行搞定全自动且编译后是硬编码的 get/set飞快。 OrderVO vo OrderMapper.INSTANCE.toVO(order);你只需要把那些“不对付”的字段一次性配好后面的活全交给编译器。下次你再改字段名编译不通过立刻就知道哪里要改根本不用等到线上出 Bug。聊完了常用的内容咱们来看看 MapStruct 还有哪些“进阶玩法”。进阶玩法一、值怎么赋有时候源字段是null或者你压根不想从源对象取值想直接写死一个值或者动态算一个值。这时候下面这几个功能就派上用场了。默认值 (defaultValue)当源字段为null时用一个备用的值代替。Mapping(source discount, target finalDiscount, defaultValue 0.0) OrderDTO toDTO(Order order);如果order.getDiscount()是nullfinalDiscount就直接赋值为0.0不会出现空指针。常量 (constant)不管源字段是什么目标字段永远等于你指定的常量。Mapping(target status, constant ACTIVE) OrderDTO toDTO(Order order);无论Order里有没有status字段生成的OrderDTO的status永远都是ACTIVE。表达式 (expression)这是最灵活的一个。你可以直接在注解里写一段Java 代码MapStruct 会把它原封不动地复制到生成的实现类里。Mapping(target createTime, expression java(new java.util.Date())) OrderDTO toDTO(Order order);每次转换时createTime都会被赋值为当前时间。你甚至可以调用静态方法、创建对象能干的事非常多。默认表达式 (defaultExpression)它是defaultValue和expression的结合体。只有当源字段为null时才执行表达式来生成值。Mapping(source userId, target userId, defaultExpression java(java.util.UUID.randomUUID().toString())) UserDTO toDTO(User user);如果user.getUserId()是null就自动生成一个 UUID 作为userId。二、条件映射有时候你希望只有满足某个条件时才进行映射。比如只有年龄大于 18 岁才映射isAdult字段。这时候可以用Condition注解。你可以在 Mapper 接口里定义一个条件方法MapStruct 在生成代码时会去调用它根据返回的true/false决定是否执行映射。Mapper public interface UserMapper { // 条件方法判断 age 字段是否存在且 18 Condition default boolean isAdult(User user) { return user ! null user.getAge() 18; } // 只有当 isAdult 返回 true 时才会映射 adult 字段 Mapping(target adult, source user) AdultInfo toAdultInfo(User user); }这个功能在处理复杂的业务校验时非常有用能把映射逻辑和校验逻辑清晰地分离。三、集合映射与 Map 映射前面我们提到了集合映射但 MapStruct 对集合的支持远不止“循环调用”那么简单。IterableMapping用于更精细地控制集合元素的转换。比如你可以为集合里的每个元素统一指定日期格式IterableMapping(dateFormat yyyy-MM-dd) ListLocalDate stringListToLocalDateList(ListString dates);MapMapping专门用来映射Map类型。MapMapping(keyDateFormat yyyyMMdd, valueDateFormat yyyy-MM-dd HH:mm:ss) MapString, String mapDateToString(MapDate, Date sourceMap);你可以分别指定 Key 和 Value 的转换规则非常灵活。四、多源对象合并MapStruct 支持将多个源对象的属性合并到一个目标对象里。Mapper public interface UserMapper { Mapping(source user.name, target userName) Mapping(source address.city, target city) UserDetailDTO toUserDetailDTO(User user, Address address); }UserDetailDTO里的userName来自user对象city来自address对象。这个功能在组装复杂 VO 时特别实用。五、更新已有对象前面我们都是把源对象“转换”成一个新的目标对象。但有时候你希望更新一个已经存在的对象而不是新建一个。Mapper public interface UserMapper { // 注意 MappingTarget 注解 void updateUser(MappingTarget UserEntity entity, UserDTO dto); }调用updateUser时MapStruct 不会创建新的UserEntity而是把dto里的值直接 set 到传入的entity对象里。这在做数据更新Update操作时非常方便。六、BeforeMapping与AfterMapping如果需要在映射开始前或结束后执行一些自定义逻辑比如日志、校验、额外的计算可以用这两个注解。Mapper public abstract class UserMapper { BeforeMapping protected void beforeMapping(User user) { // 映射开始前执行比如打个日志 System.out.println(开始映射用户: user.getName()); } AfterMapping protected void afterMapping(MappingTarget UserDTO dto) { // 映射结束后执行比如给 dto 补一个默认值 if (dto.getRole() null) { dto.setRole(USER); } } public abstract UserDTO toDTO(User user); }等等等...看到这里你会发现MapStruct 早已不是一个简单的“属性拷贝工具”而是一个功能完备的“对象映射框架”。切记以上的内容不用死记硬背特别是进阶玩法这些开发工具类的知识只需要认真看一遍下来留个好印象即可有机会用到了直接问 ai 具体怎么写就行。对于深度 vibecoing 的开发者只需要在提示词中写好让 ai 使用MapStruct操作即可剩下的 ai 自有分寸。好了今天的内容就到这里。如果这篇文章对你有帮助欢迎关注、点赞、收藏。咱们下期见
从 BeanUtils 到 MapStruct:一个关于“偷懒”的技术演进故事
发布时间:2026/7/13 21:51:28
刚学 Java 的时候谁没写过这样的代码UserDTO userDTO new UserDTO(); userDTO.setName(userEntity.getName()); userDTO.setAge(userEntity.getAge()); userDTO.setEmail(userEntity.getEmail()); // ... 省略 20 行 get/set写了几次之后你肯定烦了。这时候有人告诉你用 BeanUtils 啊一行搞定。于是你的代码变成了这样BeanUtils.copyProperties(userEntity, userDTO);爽不爽太爽了。一行代码属性全拷过去了。Spring 自带的、Apache Commons 的随便选一个就能用。说实话在项目初期、流量不大的时候BeanUtils 完全没有问题。代码简洁开发效率高谁用谁知道。没必要一上来就追求“最优解”先跑起来比什么都重要。但随着业务发展问题开始冒出来了BeanUtils 的缺陷显露项目跑了一段时间流量上来了。某天你突然发现接口响应变慢了。你开始排查——数据库很快缓存命中也高那问题出在哪CPU 在忙什么打开火焰图一看好家伙30% 的 CPU 时间都耗在了 BeanUtils.copyProperties() 的反射调用上。为什么会这样BeanUtils 底层用的是Java 反射机制。每次调用它都要做这些事情去查源对象有哪些字段去查目标对象有哪些字段匹配同名的通过反射调用 getter 和 setter做一大堆安全检查和类型检查这个过程相当于什么呢想象一下你每天上班都要从柜子里拿文件。一开始你随手翻翻就能找到。但后来文件越来越多你每次都要从头翻一遍目录、找到位置、再拿出来——反射就是在做这个“翻目录”的活。而 MapStruct 的做法是提前把“哪个文件在哪个位置”记在脑子里直接走过去就拿。更麻烦的是BeanUtils 是浅拷贝。对深拷贝浅拷贝不熟悉的同学可以先看看这篇文章 Java 浅拷贝与深拷贝详解-CSDN博客如果你的对象里有嵌套对象它只会复制引用不会复制里面的内容。改了一个地方另一个地方也跟着变了这种 bug 特别难查。接下来用具体的案例来演示一下。在写代码之前先给项目加上依赖因为我在代码演示时是纯粹的 JavaSE 环境脱离 SpringBoot所以没法直接用 BeanUtils所以需要单独引入。dependency groupIdcommons-beanutils/groupId artifactIdcommons-beanutils/artifactId version1.11.0/version scopecompile/scope /dependency !-- 辅助工具lombok不熟悉的朋友可以看我这边对于lombok的讲解【https://blog.csdn.net/Z_ZX19/article/details/162731989】 -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId version1.18.30/version scopeprovided/scope /dependency假设用户里面有个收货地址Address 对象。import lombok.Data; Data class User { private String name; private Address address; // 引用类型 }import lombok.AllArgsConstructor; import lombok.Data; Data AllArgsConstructor NoArgsConstructor class Address { private String city; }然后我们在main函数里用BeanUtils.copyProperties拷贝一下import org.apache.commons.beanutils.BeanUtils; import java.lang.reflect.InvocationTargetException; public class Main { public static void main(String[] args) throws InvocationTargetException, IllegalAccessException { // 原始数据小王住在北京 Address address new Address(北京); User user1 new User(); user1.setName(小王); user1.setAddress(address); // 开始拷贝 User user2 new User(); BeanUtils.copyProperties(user2, user1); System.out.println(拷贝后user2 的城市 user2.getAddress().getCity()); // 输出北京 // ----- 关键操作我只修改 user1 的地址 ----- user1.getAddress().setCity(上海); System.out.println(修改 user1 后user2 的城市 user2.getAddress().getCity()); } }从上图中可以看到在测试浅拷贝时发现直接都拷贝不成功了user2 都还是 null通过一顿折腾发现了原因先看我的代码结构结合看我写的 User 类和 Address 类你会发现我这两个类没有加 public 修饰符但是因为都是一个包里所以调用是正常的但是拷贝是失败的因为BeanUtils的copyProperties方法不能复制非public声明类的属性我猜测是 BeanUtils 使用反射进行拷贝时有权限访问问题导致的加上 public 就正常了。看一下输出结果直接出现浅拷贝问题 因为 BeanUtils 拷贝 user1 时看到address是个对象它想都没想直接把 user1 的 address 内存地址引用复制给了 user2。这种 Bug 在并发环境或者做数据脱敏时极其隐蔽查得你怀疑人生。MapStruct 实现拷贝接下来我通过 MapStruct来实现依旧先引入依赖dependency groupIdorg.mapstruct/groupId artifactIdmapstruct/artifactId version1.6.3/version scopecompile/scope /dependency dependency groupIdorg.mapstruct/groupId artifactIdmapstruct-processor/artifactId version1.6.3/version scopeprovided/scope /dependency接着模拟真实场景创建两个属性一致的 UserDTO 类和 AddressDTO 类这里我继续故意不写 public连同原先的 User 和 Address 的 public 也一起去掉看看 MapStruct 中是否也有影响Data class UserDTO { private String name; private Address address; // 引用类型 }import lombok.AllArgsConstructor; import lombok.Data; Data AllArgsConstructor NoArgsConstructor class AddressDTO { private String city; }接下来写了个两个接口Mapper(uses {AddressMapper.class}) // 引用子 Mapper public interface UserMapper { /** * UserMapper 接口 */ UserMapper INSTANCE Mappers.getMapper(UserMapper.class); /** * User 转 UserDTO */ UserDTO toDTO(User user); }Mapper public interface AddressMapper { /** * AddressMapper 接口 */ AddressMapper INSTANCE Mappers.getMapper(AddressMapper.class); /** * Address 转 AddressDTO */ AddressDTO toDTO(Address address); }接下来写一个测试代码public class Main { public static void main(String[] args) { Address addr new Address(); addr.setCity(北京); User user1 new User(); user1.setName(小王); user1.setAddress(addr); // MapStruct 转换 UserDTO userDTO UserMapper.INSTANCE.toDTO(user1); // 修改原对象 user1 的地址 user1.getAddress().setCity(上海); System.out.println(原对象 user1 城市 user1.getAddress().getCity()); System.out.println(DTO 对象城市 userDTO.getAddress().getCity()); } }发现是可以正常运行的并且也是深拷贝成功规避了风险运行起来之后我们还能在 target 包中发现了两个实现类这就是 MapStruct 帮我偷懒注意这两个文件是.java结尾的并非.class这里的代码是生成的上面我们引入依赖时多引入了一个叫 mapstruct-processor 的依赖这个代码就是它来生成的它是“代码生成器”的引擎当你写了Mapper接口时普通的编译器javac是不认识这个注解的。mapstruct-processor的作用就是作为一个插件挂载到编译器上。它在编译时扫描到你的Mapper注解然后自动为你生成XxxMapperImpl类。如果没有它编译器就不会生成任何实现类。从代码也可以看到 Generated 注解爆红了引入失败了是因为我当前使用的是Java 17。在 Java 9 之前javax.annotation.Generated是 JDK 自带的但从 Java 9 开始Java 进行了模块化改造这个注解被移到了javax.annotation.processing.Generated而原有的javax.annotation包被从 JDK 核心中移除了。但是这里爆红是可以不处理的不影响运行因为Generated这个注解的作用仅仅是给程序员或代码分析工具看的它的意思是“注意啦这段代码不是人手写的是机器自动生成的”。它本身没有任何控制程序流程、注入依赖或修改业务逻辑的功能。并且这个注解仅仅存在于.java源码中在编译成.class字节码文件时它会被编译器直接丢弃。如果你觉得代码爆红不美观看着很难受你可以手动引入依赖dependency groupIdjavax.annotation/groupId artifactIdjavax.annotation-api/artifactId version1.3.2/version /dependency也可以手动修改生成的代码如下图但不建议这样做不然每次生成新的都需要重新手动添加很麻烦有开发者用 JMHJava 微基准测试框架做了对比测试。结果是工具性能表现MapStruct接近手写 get/set性能最高Spring BeanUtils比 MapStruct 慢4-5 倍Apache BeanUtils比 MapStruct 慢10 倍以上MapStruct 的耗时基本不随属性数量增加而增长而 BeanUtils 的耗时跟属性个数成正比。翻译成人话属性越多、调用越频繁MapStruct 的优势就越明显。MapStruct为什么快我们来看一张图左边是 BeanUtils 的流程每次调用都要走一遍反射性能损耗肉眼可见。右边是 MapStruct 的流程编译时生成代码运行时直接调用没有反射没有额外开销。那是不是所有场景都要用 MapStruct也不是。BeanUtils 依然有它的用武之地简单的脚本工具、一次性任务对性能不敏感的后台管理系统快速原型验证MapStruct 更适合高并发、高频调用的 API 接口分层架构中 DTO 与 Entity 的频繁转换字段多、嵌套深、映射规则复杂的业务长期维护的大型项目简单来说小项目、低流量BeanUtils 够用大项目、高并发优先 MapStruct。技术选型这件事没有绝对的“最好”只有“最适合”。BeanUtils 帮助我们快速完成了早期项目的开发它是个好工具只是不适合所有场景。而 MapStruct 的出现也不是要“消灭”谁它只是在我们遇到性能瓶颈和复杂映射需求时提供了一个更优的解决方案。MapStruct 的写法看到这里有没有人跟我一样觉得 MapStruct 的写法有点“反人类”。BeanUtils 多爽啊一行BeanUtils.copyProperties(源, 目标)像把大象塞冰箱一样简单粗暴。而 MapStruct 突然让你写接口、写INSTANCE、写Mapping这感觉就像BeanUtils 是“点外卖”你给老板工具类打个电话说“我要吃的”老板直接给你送来了但你不知道他怎么做的。MapStruct 是“请私厨”你得先签合同定义接口、列菜单写方法、指定食材怎么切写Mapping然后厨师在开饭前编译期就咔咔咔全备好了。我们接下来一起来拆解一下MapStruct的写法。注在 SpringBoot 环境使用 Mapper 结尾有歧义一般集成了 MyBatis 系列的框架Mapper 结尾都是操作数据库的接口或者类所以可以根据自己团队的规范对 MapStruct 的接口进行命名就好。并且在使用Mapper注解时也注意不要导错包。第一块为什么是 interface接口而不是 class类你可能会想“我直接写个class UserMapper里面放个toDTO方法不香吗非得整个接口”核心原因因为 MapStruct 要替你写实现类。如果你写的是class那代码就定死了。但如果你写的是interfaceMapStruct 的注解处理器在编译时就能“偷偷”帮你生成一个UserMapperImpl实现类。打个响指这就像 MyBatis 里的 Mapper 接口你只写接口MyBatis 给你生成 SQL 执行代码MapStruct 也是一个道理——你只管定义“我要什么”编译器帮你生成“怎么做”的代码。第二块这个诡异的 INSTANCE 到底是几个意思看这行代码UserMapper INSTANCE Mappers.getMapper(UserMapper.class);很多新手心里犯嘀咕“我干嘛非要多写这一行直接new UserMapper()不行吗”答案因为接口不能new。这行代码的本质是利用工厂模式拿到刚才提到的那个“隐藏的实现类”。Mappers.getMapper(UserMapper.class)干了两件事在 classpath 里找到编译时生成的UserMapperImpl类。用单例模式把实例给你所以它一般叫INSTANCE你想叫别的也行确保全局只有一个。在 SpringBoot 的环境中就不用这样写了推荐下面这种写法第一步Mapper 接口加上componentModel springimport org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.factory.Mappers; // 关键加上 componentModel spring Mapper(componentModel spring) public interface UserMapper { // 注意不再需要 INSTANCE 了 // UserMapper INSTANCE Mappers.getMapper(UserMapper.class); // 这行删掉 Mapping(source userName, target name) UserDTO toDTO(UserEntity entity); }就这么一个改动。MapStruct 在编译时会自动在生成的UserMapperImpl类上加上Component注解这样 Spring 就能扫描到它了第二步在 Service 里直接Resource注入使用Service public class UserService { Resource private UserMapper userMapper; // 直接注入像注入普通 Service 一样 public UserDTO getUserDTO(UserEntity entity) { return userMapper.toDTO(entity); // 直接调用 } }注在 SpringBoot 环境使用 Mapper 结尾有歧义一般集成了 MyBatis 系列的框架Mapper 结尾都是操作数据库的接口或者类所以可以根据自己团队的规范对 MapStruct 的接口进行命名就好。并且在使用Mapper注解时也注意不要导错包。第三块Mapping 为什么这么写这括号里的东西是啥最“怪”的地方尤其是source和target的写法Mapping(source user_name, target name) UserVO toVO(UserEntity entity);把它翻译成大白话“把来源对象UserEntity里的 user_name 字段给我装到目标对象UserVO的 name 字段里去。”这其实就是做了一次“字段名映射配置”。我们对比一下 BeanUtils 的做法BeanUtils 的逻辑“我把两个对象的所有字段名都扫一遍名字一样我就拷不一样我就当没看见。”MapStruct 的逻辑“我不靠猜我只信你写的配置。你让我把 A 给 B我就给你按指令干活。你没配的字段我默认当你不想要直接忽略。”你觉得怪是因为它把“暗号”字段映射关系摆在了明面上。但这也意味着——如果user_name写成了user_Name大小写错编译直接报错不可能偷偷丢数据。并且这个注解可以堆叠一一指明对应关系Mapper public interface OrderMapper { OrderMapper INSTANCE Mappers.getMapper(OrderMapper.class); // 堆叠多个 Mapping一一指明对应关系 Mapping(source ordId, target orderId) Mapping(source cstNm, target customerName) Mapping(source prdCd, target productCode) Mapping(source ordDt, target orderDate) Mapping(source paySt, target payStatus) Mapping(source shipAddr, target shippingAddress) OrderVO toVO(OrderEntity entity); }如果字段实在是太多太多了比如 50 个字段全不一样怎么办这种情况其实很少见但如果真遇到了MapStruct 还有一个大招BeanMapping(ignoreByDefault true)。这个注解的意思是“我让你忽略所有字段只有我明确写了 Mapping 的你才给我映射。”这样做的好处是强制精确匹配。万一你漏配了一个字段编译直接报错绝对不会出现“某个字段因为名字没对上静默丢失数据”的情况。Mapper public interface HugeMapper { HugeMapper INSTANCE Mappers.getMapper(HugeMapper.class); BeanMapping(ignoreByDefault true) // 开启强制精确模式 Mapping(source a, target A) Mapping(source b, target B) Mapping(source c, target C) // ... 哪怕堆 50 行 Mapping 也没问题 TargetVO toVO(SourceEntity entity); }第四块它凭什么能处理嵌套对象usesMapper(uses {AddressMapper.class}) public interface UserMapper { UserDTO toDTO(User user); }这个注解这相当于什么相当于你跟主厨UserMapper说“我今天要做全羊宴UserDTO里面有个羊腿Address我搞不定我专门请了个做羊腿的副厨AddressMapper他负责处理这块你到时候喊他帮忙就行。”当你调用UserMapper.toDTO()时生成的代码内部逻辑其实是// 这是 MapStruct 自动生成的 UserMapperImpl 里面的伪代码 public UserDTO toDTO(User user) { UserDTO dto new UserDTO(); dto.setName(user.getName()); // 注意这一行它没有直接复制引用而是调用了副厨的 INSTANCE dto.setAddress( AddressMapper.INSTANCE.toDTO(user.getAddress()) ); return dto; }看到了吗它帮你做了“组合”。你只要把副厨子Mapper通过uses引进来主厨干活时自动会去调用它。这个写法虽然乍看多了一道工序但实际上比 BeanUtils 那种“懒洋洋的一把抓”要严谨得多。并且可以无限叠加想塞多少个就塞多少个。写法也很直观用逗号隔开就行了Mapper(uses {AddressMapper.class, OrderMapper.class, ProductMapper.class, UserInfoMapper.class}) public interface UserMapper { UserDTO toDTO(User user); }第五块为什么说它“怪得有价值”假设你要把一个Order转成OrderVO其中字段名有 3 个不一样还要格式化日期。用 BeanUtils写起来爽跑起来慌OrderVO vo new OrderVO(); BeanUtils.copyProperties(order, vo); // 拷贝完了但日期没格式化字段名不一样的丢了数据你还得手动补下面两行 vo.setCreateTime( sdf.format(order.getCreateTime()) ); vo.setTotalAmount( order.getAmount().toString() ); // 写到这里你还不如一开始就全手写BeanUtils 只帮你干了半拉活儿。用 MapStruct写起来麻烦但写完就解脱Mapper public interface OrderMapper { OrderMapper INSTANCE Mappers.getMapper(OrderMapper.class); Mapping(source amount, target totalAmount) Mapping(source createTime, target createTime, dateFormat yyyy-MM-dd) OrderVO toVO(Order order); } // 调用一行搞定全自动且编译后是硬编码的 get/set飞快。 OrderVO vo OrderMapper.INSTANCE.toVO(order);你只需要把那些“不对付”的字段一次性配好后面的活全交给编译器。下次你再改字段名编译不通过立刻就知道哪里要改根本不用等到线上出 Bug。聊完了常用的内容咱们来看看 MapStruct 还有哪些“进阶玩法”。进阶玩法一、值怎么赋有时候源字段是null或者你压根不想从源对象取值想直接写死一个值或者动态算一个值。这时候下面这几个功能就派上用场了。默认值 (defaultValue)当源字段为null时用一个备用的值代替。Mapping(source discount, target finalDiscount, defaultValue 0.0) OrderDTO toDTO(Order order);如果order.getDiscount()是nullfinalDiscount就直接赋值为0.0不会出现空指针。常量 (constant)不管源字段是什么目标字段永远等于你指定的常量。Mapping(target status, constant ACTIVE) OrderDTO toDTO(Order order);无论Order里有没有status字段生成的OrderDTO的status永远都是ACTIVE。表达式 (expression)这是最灵活的一个。你可以直接在注解里写一段Java 代码MapStruct 会把它原封不动地复制到生成的实现类里。Mapping(target createTime, expression java(new java.util.Date())) OrderDTO toDTO(Order order);每次转换时createTime都会被赋值为当前时间。你甚至可以调用静态方法、创建对象能干的事非常多。默认表达式 (defaultExpression)它是defaultValue和expression的结合体。只有当源字段为null时才执行表达式来生成值。Mapping(source userId, target userId, defaultExpression java(java.util.UUID.randomUUID().toString())) UserDTO toDTO(User user);如果user.getUserId()是null就自动生成一个 UUID 作为userId。二、条件映射有时候你希望只有满足某个条件时才进行映射。比如只有年龄大于 18 岁才映射isAdult字段。这时候可以用Condition注解。你可以在 Mapper 接口里定义一个条件方法MapStruct 在生成代码时会去调用它根据返回的true/false决定是否执行映射。Mapper public interface UserMapper { // 条件方法判断 age 字段是否存在且 18 Condition default boolean isAdult(User user) { return user ! null user.getAge() 18; } // 只有当 isAdult 返回 true 时才会映射 adult 字段 Mapping(target adult, source user) AdultInfo toAdultInfo(User user); }这个功能在处理复杂的业务校验时非常有用能把映射逻辑和校验逻辑清晰地分离。三、集合映射与 Map 映射前面我们提到了集合映射但 MapStruct 对集合的支持远不止“循环调用”那么简单。IterableMapping用于更精细地控制集合元素的转换。比如你可以为集合里的每个元素统一指定日期格式IterableMapping(dateFormat yyyy-MM-dd) ListLocalDate stringListToLocalDateList(ListString dates);MapMapping专门用来映射Map类型。MapMapping(keyDateFormat yyyyMMdd, valueDateFormat yyyy-MM-dd HH:mm:ss) MapString, String mapDateToString(MapDate, Date sourceMap);你可以分别指定 Key 和 Value 的转换规则非常灵活。四、多源对象合并MapStruct 支持将多个源对象的属性合并到一个目标对象里。Mapper public interface UserMapper { Mapping(source user.name, target userName) Mapping(source address.city, target city) UserDetailDTO toUserDetailDTO(User user, Address address); }UserDetailDTO里的userName来自user对象city来自address对象。这个功能在组装复杂 VO 时特别实用。五、更新已有对象前面我们都是把源对象“转换”成一个新的目标对象。但有时候你希望更新一个已经存在的对象而不是新建一个。Mapper public interface UserMapper { // 注意 MappingTarget 注解 void updateUser(MappingTarget UserEntity entity, UserDTO dto); }调用updateUser时MapStruct 不会创建新的UserEntity而是把dto里的值直接 set 到传入的entity对象里。这在做数据更新Update操作时非常方便。六、BeforeMapping与AfterMapping如果需要在映射开始前或结束后执行一些自定义逻辑比如日志、校验、额外的计算可以用这两个注解。Mapper public abstract class UserMapper { BeforeMapping protected void beforeMapping(User user) { // 映射开始前执行比如打个日志 System.out.println(开始映射用户: user.getName()); } AfterMapping protected void afterMapping(MappingTarget UserDTO dto) { // 映射结束后执行比如给 dto 补一个默认值 if (dto.getRole() null) { dto.setRole(USER); } } public abstract UserDTO toDTO(User user); }等等等...看到这里你会发现MapStruct 早已不是一个简单的“属性拷贝工具”而是一个功能完备的“对象映射框架”。切记以上的内容不用死记硬背特别是进阶玩法这些开发工具类的知识只需要认真看一遍下来留个好印象即可有机会用到了直接问 ai 具体怎么写就行。对于深度 vibecoing 的开发者只需要在提示词中写好让 ai 使用MapStruct操作即可剩下的 ai 自有分寸。好了今天的内容就到这里。如果这篇文章对你有帮助欢迎关注、点赞、收藏。咱们下期见