欢迎你来读这篇博客,这篇博客主要是关于 Java 对象拷贝。
其中包括了关于对象拷贝工具选型、浅拷贝与深拷贝、Spring BeanUtils、Apache BeanUtils、Hutool BeanUtil、CGLIB BeanCopier、MapStruct、MapStructPlus 的使用思考和实践建议。
序言 在 Java 后端开发中,对象拷贝几乎每天都会遇到。
比如:
Entity 转 DTO
DTO 转 VO
Request 转 Command
DO 转 BO
第三方接口对象转内部业务对象
更新接口中只覆盖部分字段
查询结果批量转换成前端展示对象
对象拷贝看起来只是“字段搬运”,但它很容易埋坑。
比如字段名一致但类型不一致、null 值覆盖已有数据、集合只是浅拷贝、嵌套对象没有真正转换、字段新增后忘记同步、运行时才发现拷贝失败等。
说白了,对象拷贝不是简单的“偷懒工具”,它其实是系统分层边界的一部分。
如果边界设计清楚,对象转换就是隔离层;如果边界设计混乱,对象转换就是 bug 搬运工,而且还是加班版。
正文 chapter 1:对象拷贝到底在解决什么问题? 对象拷贝的本质,是把一个对象中的数据转换到另一个对象中。
常见场景如下:
1 2 3 4 5 6 UserEntity entity = userRepository.getById(id);UserVO vo = new UserVO (); vo.setId(entity.getId()); vo.setUsername(entity.getUsername()); vo.setAge(entity.getAge());
如果字段少,手写 get/set 很清晰。
但是字段多了以后,代码会变成这样:
1 2 3 4 5 target.setA(source.getA()); target.setB(source.getB()); target.setC(source.getC()); target.setD(source.getD()); target.setE(source.getE());
这类代码没有太多业务含义,但又不能完全不要,于是各种对象拷贝工具就出现了。
不过在使用工具之前,要先分清几个概念。
1. 浅拷贝 浅拷贝只复制对象的属性值。
如果属性是基本类型、包装类型、字符串,一般问题不大。
如果属性是对象、集合、数组,浅拷贝复制的可能只是引用。
例如:
1 source.getAddress() == target.getAddress()
如果结果是 true,说明两个对象内部指向的是同一个 Address 对象。
这时候修改目标对象中的 address.city,可能会影响源对象。
2. 深拷贝 深拷贝不仅复制当前对象,还会复制对象内部的嵌套对象、集合元素等。
例如:
1 source.getAddress() != target.getAddress()
并且内部字段值一致。
深拷贝更安全,但成本更高,也更需要明确的业务语义。
对象拷贝工具大多数默认都是浅拷贝。不要看到 copyProperties 就默认它是深拷贝,这个误会挺常见,坑也挺深。
3. 同名字段拷贝 大多数工具默认依赖字段名或 JavaBean getter/setter 名称。
例如:
1 2 3 4 5 6 7 class User { private String username; }class UserVO { private String username; }
这种可以自动拷贝。
但如果字段名不同:
1 2 3 4 5 6 7 class User { private String username; }class UserVO { private String name; }
普通 BeanUtils.copyProperties 就不会自动知道 username 应该映射到 name。
这时候就需要手动写代码,或者用 MapStruct 的 @Mapping 显式指定。
4. 类型转换 字段名一样,但类型不一样,也可能拷贝失败或者结果不符合预期。
例如:
1 2 3 4 5 6 7 class User { private Integer age; }class UserVO { private String age; }
有些工具会跳过,有些工具会尝试转换,有些工具会抛异常。
越“自动”的工具,越要警惕黑盒行为。
chapter 2:常见对象拷贝方案对比 常见方案大概可以分成几类:
方案
实现方式
优点
缺点
适合场景
手写 get/set
人工编码
最清晰、性能最好、可控性最高
字段多时繁琐
核心业务转换、字段少的转换
Spring BeanUtils
反射/内省
Spring 项目自带,使用简单
复杂转换弱,字段静默忽略
简单同名字段拷贝
Apache BeanUtils
反射/内省 + 类型转换
功能较全,支持部分动态属性能力
性能通常较差,异常处理麻烦
动态 Bean、老项目兼容
Hutool BeanUtil
封装型工具
API 友好,支持 CopyOptions、Map/Bean 转换
仍偏运行时工具,复杂映射不够显式
工具类项目、后台管理、快速开发
CGLIB BeanCopier
动态生成字节码
性能好,适合大量简单对象拷贝
要缓存 BeanCopier,不适合复杂深拷贝
高性能、字段结构简单
MapStruct
编译期生成代码
快、类型安全、可读、编译期暴露问题
需要写 Mapper 接口
中大型项目、长期维护项目
MapStructPlus
基于 MapStruct 增强
更少样板代码,使用更快
复杂业务仍要理解 MapStruct 思想
快速开发、希望少写 Mapper 的项目
我的整体建议是:
简单临时拷贝可以用 BeanUtils;高频大量简单拷贝可以用 BeanCopier;正式业务分层转换优先用 MapStruct;想减少 MapStruct 样板代码可以考虑 MapStructPlus。
chapter 3:Spring BeanUtils Spring 项目里最常见的写法是:
1 2 3 4 import org.springframework.beans.BeanUtils;UserVO vo = new UserVO (); BeanUtils.copyProperties(userEntity, vo);
注意参数顺序:
1 BeanUtils.copyProperties(source, target);
也就是:
1 BeanUtils.copyProperties(源对象, 目标对象);
这个顺序和 Apache BeanUtils 不一样,写反了很容易出事。
忽略字段 如果不想拷贝某些字段,可以这样:
1 BeanUtils.copyProperties(userEntity, userVO, "password" , "deleted" , "createTime" );
适合屏蔽敏感字段:
1 2 3 4 5 public UserVO toVO (UserEntity entity) { UserVO vo = new UserVO (); BeanUtils.copyProperties(entity, vo, "password" , "salt" ); return vo; }
忽略 null 字段 Spring BeanUtils 默认会把 null 也拷贝过去。
更新场景容易出问题。
例如:
1 2 3 4 5 UserUpdateDTO dto = new UserUpdateDTO (); dto.setNickname("Mario" ); dto.setAvatar(null ); BeanUtils.copyProperties(dto, userEntity);
如果 avatar 是 null,就可能把数据库中原本有值的头像覆盖掉。
可以自己封装一个忽略 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 import org.springframework.beans.BeanUtils;import org.springframework.beans.BeanWrapper;import org.springframework.beans.BeanWrapperImpl;import java.beans.PropertyDescriptor;import java.util.HashSet;import java.util.Set;public class SpringBeanCopyUtils { public static void copyNonNullProperties (Object source, Object target) { BeanUtils.copyProperties(source, target, getNullPropertyNames(source)); } private static String[] getNullPropertyNames(Object source) { BeanWrapper beanWrapper = new BeanWrapperImpl (source); PropertyDescriptor[] descriptors = beanWrapper.getPropertyDescriptors(); Set<String> emptyNames = new HashSet <>(); for (PropertyDescriptor descriptor : descriptors) { Object value = beanWrapper.getPropertyValue(descriptor.getName()); if (value == null ) { emptyNames.add(descriptor.getName()); } } return emptyNames.toArray(new String [0 ]); } }
使用:
1 SpringBeanCopyUtils.copyNonNullProperties(updateDTO, userEntity);
Spring BeanUtils 适合什么? 适合:
字段名一致
类型基本一致
简单 DTO/VO 转换
不追求复杂映射
不想额外引依赖
不适合:
复杂嵌套对象转换
字段名不一致
大量高频对象转换
需要强类型编译期校验
希望字段遗漏时构建失败
Spring BeanUtils 最大的问题不是不能用,而是太容易“顺手就用”。
顺手多了,就会让对象转换变成黑盒。黑盒爽一时,排查火葬场。
chapter 4:Apache Commons BeanUtils Apache BeanUtils 也有 copyProperties:
1 2 3 import org.apache.commons.beanutils.BeanUtils; BeanUtils.copyProperties(target, source);
注意,它的参数顺序是:
1 BeanUtils.copyProperties(目标对象, 源对象);
这点和 Spring BeanUtils 正好相反。
Spring:
1 BeanUtils.copyProperties(source, target);
Apache:
1 BeanUtils.copyProperties(dest, orig);
这就是为什么有时候我不建议项目中同时大量混用两个 BeanUtils。
一个不小心,代码就变成“字段反向搬家”,像搬家公司把你的沙发搬到邻居家,还说流程没问题。
Apache BeanUtils 的特点是功能比较多,支持一些类型转换、动态属性、嵌套属性处理能力。
但它的问题也比较明显:
性能通常不如 Spring BeanUtils、BeanCopier、MapStruct
异常处理更烦
方法参数顺序容易和 Spring 混淆
现代 Spring Boot 项目里多数场景没必要优先选它
除非项目历史原因已经大量使用,或者确实需要它的动态 Bean 能力,否则新项目里我不建议优先使用 Apache BeanUtils。
Hutool 的 BeanUtil 是国内 Java 项目中很常见的工具类。
简单拷贝:
1 2 3 import cn.hutool.core.bean.BeanUtil;UserVO vo = BeanUtil.copyProperties(userEntity, UserVO.class);
拷贝到已有对象:
1 BeanUtil.copyProperties(userDTO, userEntity);
忽略空值:
1 2 3 4 5 6 7 import cn.hutool.core.bean.copier.CopyOptions;CopyOptions options = CopyOptions.create() .setIgnoreNullValue(true ) .setIgnoreError(true ); BeanUtil.copyProperties(updateDTO, userEntity, options);
集合转换:
1 List<UserVO> voList = BeanUtil.copyToList(userEntityList, UserVO.class);
Map 转 Bean:
1 User user = BeanUtil.toBean(map, User.class);
Hutool BeanUtil 的优点是 API 很顺手,尤其是后台管理系统、脚本类代码、内部工具项目,用起来很省事。
但它依然属于运行时工具,复杂映射不如 MapStruct 清晰。
我的建议:
简单后台系统:可以用
临时工具代码:可以用
核心领域对象转换:谨慎用
长期演进的 DTO/VO 映射:更推荐 MapStruct
chapter 6:CGLIB BeanCopier CGLIB BeanCopier 是一个性能比较好的对象拷贝工具。
Spring 中已经包含了 repackaged 的 CGLIB 类,因此 Spring 项目里可以直接使用:
1 2 3 4 5 6 import org.springframework.cglib.beans.BeanCopier;BeanCopier copier = BeanCopier.create(UserEntity.class, UserVO.class, false );UserVO vo = new UserVO (); copier.copy(userEntity, vo, null );
第三个参数 useConverter 表示是否使用自定义转换器。
1 BeanCopier.create(sourceClass, targetClass, useConverter);
如果设置为 false,要求源对象和目标对象的属性名称、读写方法、类型基本匹配。
如果字段类型不一致,通常不会自动帮你处理。
使用 Converter 例如源对象是 Integer,目标对象是 String:
1 2 3 4 5 6 7 8 9 10 11 BeanCopier copier = BeanCopier.create(UserEntity.class, UserVO.class, true ); copier.copy(userEntity, userVO, (value, targetType, methodName) -> { if (value == null ) { return null ; } if (targetType == String.class) { return String.valueOf(value); } return value; });
不过 Converter 是全局进入每个字段的转换逻辑,写复杂了可读性会下降。
如果字段转换规则已经很复杂,不如直接上 MapStruct。
BeanCopier 一定要缓存 不要每次都这样写:
1 2 BeanCopier copier = BeanCopier.create(source.getClass(), target.getClass(), false ); copier.copy(source, target, null );
create 本身有成本,高频调用时要缓存。
可以封装一个工具类:
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 import org.springframework.cglib.beans.BeanCopier;import java.util.Map;import java.util.Objects;import java.util.concurrent.ConcurrentHashMap;import java.util.function.Supplier;public class BeanCopierUtils { private static final Map<String, BeanCopier> CACHE = new ConcurrentHashMap <>(); public static <T> T copy (Object source, Supplier<T> targetSupplier) { if (source == null ) { return null ; } T target = targetSupplier.get(); copy(source, target); return target; } public static void copy (Object source, Object target) { Objects.requireNonNull(source, "source must not be null" ); Objects.requireNonNull(target, "target must not be null" ); String key = buildKey(source.getClass(), target.getClass(), false ); BeanCopier copier = CACHE.computeIfAbsent( key, k -> BeanCopier.create(source.getClass(), target.getClass(), false ) ); copier.copy(source, target, null ); } private static String buildKey (Class<?> sourceClass, Class<?> targetClass, boolean useConverter) { return sourceClass.getName() + "->" + targetClass.getName() + "#" + useConverter; } }
使用:
1 UserVO vo = BeanCopierUtils.copy(userEntity, UserVO::new );
BeanCopier 的优点
性能好
适合大量简单字段拷贝
不需要手写大量 get/set
Spring 项目可直接使用
BeanCopier 的缺点
默认浅拷贝
不支持复杂嵌套对象自动深拷贝
不支持默认忽略 null
字段类型不一致时处理不够优雅
Converter 写复杂后可读性下降
不缓存会影响性能
BeanCopier 适合“结构稳定、字段同名、类型一致、大量拷贝”的场景。
chapter 7:MapStruct MapStruct 是我更推荐在正式业务项目中使用的对象映射方案。
它不是运行时反射工具,而是在编译期生成实现类。
也就是说,你写一个接口:
1 2 3 4 5 @Mapper public interface UserMapper { UserVO toVO (UserEntity entity) ; }
编译之后,它会生成类似这样的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class UserMapperImpl implements UserMapper { @Override public UserVO toVO (UserEntity entity) { if (entity == null ) { return null ; } UserVO userVO = new UserVO (); userVO.setId(entity.getId()); userVO.setUsername(entity.getUsername()); userVO.setAge(entity.getAge()); return userVO; } }
本质上还是普通 get/set。
所以 MapStruct 的优点很明显:
性能接近手写
生成代码可读
编译期检查字段问题
支持复杂映射
支持集合映射
支持嵌套对象映射
支持 Spring 注入
支持更新已有对象
支持忽略 null
支持自定义表达式和转换方法
Maven 依赖 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 <properties > <mapstruct.version > 1.6.3</mapstruct.version > </properties > <dependencies > <dependency > <groupId > org.mapstruct</groupId > <artifactId > mapstruct</artifactId > <version > ${mapstruct.version}</version > </dependency > </dependencies > <build > <plugins > <plugin > <groupId > org.apache.maven.plugins</groupId > <artifactId > maven-compiler-plugin</artifactId > <version > 3.13.0</version > <configuration > <source > 17</source > <target > 17</target > <annotationProcessorPaths > <path > <groupId > org.mapstruct</groupId > <artifactId > mapstruct-processor</artifactId > <version > ${mapstruct.version}</version > </path > </annotationProcessorPaths > </configuration > </plugin > </plugins > </build >
如果项目使用 Lombok,建议额外加入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 <properties > <lombok.version > 1.18.32</lombok.version > <lombok-mapstruct-binding.version > 0.2.0</lombok-mapstruct-binding.version > </properties > <annotationProcessorPaths > <path > <groupId > org.projectlombok</groupId > <artifactId > lombok</artifactId > <version > ${lombok.version}</version > </path > <path > <groupId > org.projectlombok</groupId > <artifactId > lombok-mapstruct-binding</artifactId > <version > ${lombok-mapstruct-binding.version}</version > </path > <path > <groupId > org.mapstruct</groupId > <artifactId > mapstruct-processor</artifactId > <version > ${mapstruct.version}</version > </path > </annotationProcessorPaths >
Spring Boot 中使用 1 2 3 4 5 6 7 8 9 10 11 12 13 import org.mapstruct.Mapper;import org.mapstruct.Mapping;import org.mapstruct.ReportingPolicy;@Mapper( componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.ERROR ) public interface UserMapper { @Mapping(target = "name", source = "username") UserVO toVO (UserEntity entity) ; }
这样生成的 Mapper 会交给 Spring 管理,可以直接注入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Service public class UserService { private final UserMapper userMapper; public UserService (UserMapper userMapper) { this .userMapper = userMapper; } public UserVO getUser (Long id) { UserEntity entity = getById(id); return userMapper.toVO(entity); } }
字段名不一致 1 2 3 4 5 6 7 @Mapper(componentModel = "spring") public interface UserMapper { @Mapping(target = "name", source = "username") @Mapping(target = "registerTime", source = "createTime") UserVO toVO (UserEntity entity) ; }
类型转换 1 2 3 4 5 6 @Mapper(componentModel = "spring") public interface UserMapper { @Mapping(target = "ageText", expression = "java(entity.getAge() == null ? null : String.valueOf(entity.getAge()))") UserVO toVO (UserEntity entity) ; }
也可以写默认方法:
1 2 3 4 5 6 7 8 9 10 @Mapper(componentModel = "spring") public interface UserMapper { @Mapping(target = "ageText", source = "age") UserVO toVO (UserEntity entity) ; default String integerToString (Integer value) { return value == null ? null : String.valueOf(value); } }
集合转换 1 2 3 4 5 6 7 @Mapper(componentModel = "spring") public interface UserMapper { UserVO toVO (UserEntity entity) ; List<UserVO> toVOList (List<UserEntity> entityList) ; }
MapStruct 会为集合元素逐个调用 toVO。
更新已有对象,并忽略 null 更新接口里很常见:
1 2 3 4 5 6 @Mapper(componentModel = "spring") public interface UserMapper { @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) void updateEntity (UserUpdateDTO source, @MappingTarget UserEntity target) ; }
使用:
1 2 3 4 5 UserEntity entity = userRepository.getById(dto.getId()); userMapper.updateEntity(dto, entity); userRepository.updateById(entity);
这样 dto 中为 null 的字段不会覆盖 entity 中已有字段。
推荐打开 unmappedTargetPolicy 我比较建议这样配置:
1 2 3 4 5 6 @Mapper( componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.ERROR ) public interface UserMapper { }
这样目标对象新增字段时,如果没有处理,编译期就能发现。
如果不想那么严格,也可以用:
1 unmappedTargetPolicy = ReportingPolicy.WARN
但在核心业务里,我更推荐 ERROR。
字段遗漏越早发现越好。编译期报错是朋友,线上报错才是敌人。
chapter 8:MapStructPlus MapStruct 很强,但有一个小缺点:Mapper 接口写多了以后,也会有样板代码。
MapStructPlus 就是基于 MapStruct 的增强工具,它希望进一步减少手写 Mapper 的成本。
它的典型思路是:
在类上加 @AutoMapper
编译期自动生成转换接口和实现类
使用统一的 Converter 执行转换
Spring Boot 依赖 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 <properties > <mapstruct-plus.version > 使用当前最新稳定版本</mapstruct-plus.version > </properties > <dependencies > <dependency > <groupId > io.github.linpeilie</groupId > <artifactId > mapstruct-plus-spring-boot-starter</artifactId > <version > ${mapstruct-plus.version}</version > </dependency > </dependencies > <build > <plugins > <plugin > <groupId > org.apache.maven.plugins</groupId > <artifactId > maven-compiler-plugin</artifactId > <configuration > <annotationProcessorPaths > <path > <groupId > io.github.linpeilie</groupId > <artifactId > mapstruct-plus-processor</artifactId > <version > ${mapstruct-plus.version}</version > </path > </annotationProcessorPaths > </configuration > </plugin > </plugins > </build >
简单使用 1 2 3 4 5 6 7 8 9 10 11 12 13 import io.github.linpeilie.annotations.AutoMapper;@AutoMapper(target = UserVO.class) public class UserEntity { private Long id; private String username; private Integer age; }
然后注入 Converter:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import io.github.linpeilie.Converter;import org.springframework.stereotype.Service;@Service public class UserService { private final Converter converter; public UserService (Converter converter) { this .converter = converter; } public UserVO getUser (Long id) { UserEntity entity = getById(id); return converter.convert(entity, UserVO.class); } }
集合转换:
1 List<UserVO> voList = converter.convert(userEntityList, UserVO.class);
转换到已有对象:
1 converter.convert(updateDTO, userEntity);
Map 转对象:
1 UserVO vo = converter.convert(map, UserVO.class);
MapStructPlus 适合什么? 适合:
想使用 MapStruct 的性能和编译期生成能力
又不想写太多 Mapper 接口
项目中对象转换很多
快速开发 DTO/VO/Entity 转换
Spring Boot 项目希望注入统一 Converter
不适合:
对转换规则要求极强的核心领域模型
希望每个 Mapper 都非常显式
团队还没有理解 MapStruct 生成代码机制
项目对依赖数量控制非常严格
我的建议是:
MapStructPlus 可以提升开发效率,但团队至少要理解 MapStruct 的基本原理。否则出了问题,只会觉得“注解玄学又发作了”。
chapter 9:怎么选型? 我个人会按下面这个规则选:
1. 字段少、逻辑重要:手写 1 2 3 4 UserVO vo = new UserVO (); vo.setId(entity.getId()); vo.setName(entity.getUsername()); vo.setDisplayName(entity.getNickname() + "(" + entity.getUsername() + ")" );
这种代码虽然啰嗦,但业务含义很清楚。
适合后台管理、简单 DTO 转换、临时代码。
1 BeanUtils.copyProperties(source, target);
或者:
1 BeanUtil.copyProperties(source, target);
3. 大量简单对象转换:BeanCopier / MapStruct 如果只是批量转换几十万条结构相同的数据,BeanCopier 加缓存可以考虑。
但如果这个转换是业务长期存在的,优先 MapStruct。
4. 正式业务分层转换:MapStruct 例如:
Entity 转 VO
DTO 转 Entity
Command 转 Domain
第三方接口对象转内部对象
订单、结算、财务等重要业务对象转换
这种场景我更推荐 MapStruct。
因为它有编译期检查,字段变化时更容易暴露问题。
5. 想少写 Mapper:MapStructPlus 如果团队能接受 MapStructPlus 的注解风格,可以用它减少样板代码。
但是核心复杂映射仍然建议显式写清楚。
chapter 10:对象拷贝常见坑 坑 1:把浅拷贝当深拷贝 1 target.setAddress(source.getAddress());
这只是复制引用。
如果 Address 会被修改,就要单独转换:
1 target.setAddress(addressMapper.toVO(source.getAddress()));
坑 2:null 覆盖已有数据 更新接口尤其常见:
1 BeanUtils.copyProperties(updateDTO, entity);
如果 updateDTO.nickname == null,可能把 entity.nickname 覆盖成 null。
解决方式:
Spring BeanUtils 自己封装忽略 null
Hutool 使用 CopyOptions.setIgnoreNullValue(true)
MapStruct 使用 NullValuePropertyMappingStrategy.IGNORE
坑 3:字段静默忽略 Spring BeanUtils 遇到目标对象没有的字段,会静默忽略。
这对简单场景很方便,但对重要业务很危险。
例如源对象新增字段:
1 private BigDecimal settlementAmount;
目标对象忘记加字段,或者字段名不一致,运行时可能没有任何提示。
MapStruct 配合 ReportingPolicy.ERROR 可以在编译期暴露这类问题。
坑 4:字段名一致但语义不一致 例如:
1 2 source.status target.status
看起来都叫 status,但一个是订单状态,一个是结算状态。
这种不能直接自动拷贝。
字段名一样,不代表语义一样。
这类字段建议手写或显式 @Mapping。
坑 5:Apache BeanUtils 和 Spring BeanUtils 参数顺序相反 Spring:
1 BeanUtils.copyProperties(source, target);
Apache:
1 BeanUtils.copyProperties(target, source);
建议项目里统一一个工具,不要两个都随便用。
坑 6:BeanCopier 不缓存 1 BeanCopier.create(source.getClass(), target.getClass(), false );
如果每次调用都创建,性能优势会被吃掉。
高频场景一定要缓存。
坑 7:复杂转换过度依赖工具 如果转换逻辑已经包含:
枚举转换
金额单位转换
状态机转换
多字段合并
嵌套集合过滤
权限字段脱敏
那就不要假装这是简单 copy。
该写业务转换就写业务转换。
chapter 11:推荐实践 1. Controller 入参不要直接拷贝到 Entity 不建议:
1 BeanUtils.copyProperties(request, entity);
更推荐:
1 2 UserCommand command = userMapper.toCommand(request); userAppService.update(command);
这样边界更清楚。
2. Entity 不要随便暴露给前端 不建议:
更推荐:
1 return userMapper.toVO(userEntity);
VO 可以做字段脱敏、字段裁剪、展示格式转换。
3. 核心业务 Mapper 单独维护 例如:
1 2 3 4 5 6 7 @Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.ERROR) public interface SettlementBillMapper { SettlementBillVO toVO (SettlementBill bill) ; SettlementBillDetailVO toDetailVO (SettlementBillDetail detail) ; }
这比到处散落 BeanUtils.copyProperties 更容易维护。
4. 工具类不要滥用 可以封装:
1 BeanCopierUtils.copy(source, Target::new );
但不要让所有业务都依赖一个万能 copy。
万能工具类通常最后都会变成“万坑工具类”。
5. 性能测试要用 JMH 不要简单用:
1 2 3 4 5 long start = System.currentTimeMillis();for (...) { copy(); } System.out.println(System.currentTimeMillis() - start);
这种测试容易受 JVM 预热、JIT、GC、逃逸分析、死代码消除影响。
如果真的要比较性能,建议用 JMH。
不过大多数业务系统中,对象拷贝不是第一性能瓶颈。
先选可维护性,再考虑性能。
参考资料
Spring学习笔记(二)〖CGLIB浅拷贝BeanCopier的使用和详解〗
Java对象属性拷贝工具对比分析
MapStructPlus 快速开始
MapStruct 1.5.5.Final 中文文档
详解 MapStruct Plus
MapStruct 官方 Reference Guide
Spring Framework BeanUtils Javadoc
Apache Commons BeanUtils Javadoc
Hutool BeanUtil 官方文档
OpenJDK JMH 官方项目
启示录 对象拷贝不是越自动越好。
自动化的价值,是减少重复劳动;但自动化的风险,是把问题藏起来。
在简单场景里,工具类确实能提高效率;在复杂业务里,显式映射反而更安全。
工程里很多 bug 并不是因为代码不够高级,而是因为边界不够清楚。
对象转换就是边界的一部分。
该偷懒的时候偷懒,该显式的时候显式。别让一个 copyProperties,悄悄复制走你的周末。
富贵岂由人,时会高志须酬。
能成功于千载者,必以近察远。