Java 对象拷贝全景:从 BeanUtils、BeanCopier 到 MapStructPlus

欢迎你来读这篇博客,这篇博客主要是关于 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);

如果 avatarnull,就可能把数据库中原本有值的头像覆盖掉。

可以自己封装一个忽略 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。

chapter 5:Hutool BeanUtil

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;

// getter/setter
}

然后注入 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() + ")");

这种代码虽然啰嗦,但业务含义很清楚。

2. 字段同名、简单拷贝:Spring BeanUtils / Hutool BeanUtil

适合后台管理、简单 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 userEntity;

更推荐:

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,悄悄复制走你的周末。

富贵岂由人,时会高志须酬。

能成功于千载者,必以近察远。


Java 对象拷贝全景:从 BeanUtils、BeanCopier 到 MapStructPlus
https://allendericdalexander.github.io/2026/06/03/java_copy_framework/
作者
AtLuoFu
发布于
2026年6月3日
许可协议