对象映射三件套:Orika、MapStruct、BeanUtils.copyProperties,到底怎么选?以及那个叫"浅拷贝"的坑
对象映射三件套:Orika、MapStruct、BeanUtils.copyProperties,到底怎么选?以及那个叫"浅拷贝"的坑
写在前面:同一个需求,为什么有三种答案
做 Java 后端这几年,我先后在三个项目里见过三种完全不同的"对象映射"写法:
-
有的项目用 Spring 自带 的
BeanUtils.copyProperties -
有的项目用 Orika(
ma.glasnost.orika.MapperFacade) -
有的项目用 MapStruct(
@Mapper注解)
有意思的是,这三件事干的是同一件事:把一个对象的字段,搬到另一个对象里。可为什么会有三种答案?它们到底差在哪?更关键的是——里面有一个几乎人人都踩过、但很少有人能说清的坑:浅拷贝。
这篇文章,我想结合最近梳理的一个支付网关项目里真实的 Orika 代码,把这三件套讲透,并且把深拷贝、浅拷贝这件事一次说明白。
一、先看一段真实代码,它代表了 Orika 的典型用法
在一个支付渠道网关里,有这样一个场景:对外暴露的 API 参数对象是嵌套结构,而领域层的命令对象是扁平结构。
对外 DTO 长这样(为了 Swagger 文档分组和 JSR-303 校验,字段被塞进了子对象):
public class SubmitCustomerApplicationRequest {
private IdCard idCard; // 身份证信息(嵌套)
private SettleAccount settleAcct; // 结算账户(嵌套)
private String mobileNo;
private String password;
// ...
}
而领域命令对象长这样(直接拍平):
public class CreateCustomerApplicationCommand {
private String idNo; // 直接从 idCard 拍平过来
private String name;
private String settleBankCode; // 直接从 settleAcct 拍平过来
private String mobileNo;
// ...
}
如果手写,你得写十几行这种代码:
command.setIdNo(request.getIdCard().getIdNo());
command.setName(request.getIdCard().getName());
command.setSettleBankCode(request.getSettleAcct().getSettleBankCode());
// ... 十几行
而 Orika 的写法是一行声明:
mapperFactory.classMap(SubmitCustomerApplicationRequest.class,
CreateCustomerApplicationCommand.class)
.field("idCard.idNo", "idNo")
.field("idCard.name", "name")
.field("settleAcct.settleBankCode", "settleBankCode")
// ...
.byDefault() // 剩下同名字段自动拷
.register();
注意 .field("idCard.idNo", "idNo") 这个点路径——它把「嵌套对象 → 扁平字段」的拍平,用一行字符串声明就解决了。再配合 .byDefault() 兜底同名字段、.exclude() 排除审计字段,这就是 Orika 最核心的价值:声明式、集中配置、少写样板代码。
二、三件套,各自的定位
我们先给三者一个清晰的定位,再往下说深拷贝的事。
1. BeanUtils.copyProperties(Spring)
BeanUtils.copyProperties(source, target);
-
机制:反射,把 source 里同名、同类型的字段拷到 target。
-
优点:零依赖、零配置,Spring 项目开箱即用。
-
缺点:只能同名同类型;没有嵌套拍平;没有类型转换(基本);反射性能差;而且——是浅拷贝。
它适合的场景其实很窄:两个长得几乎一样的 DTO 之间搬字段。一旦涉及嵌套对象、字段改名、类型转换,它就力不从心了。
顺带说一句,"BeanUtils"其实不止 Spring 一家。Apache Commons BeanUtils 更老,但性能和异常处理都不如 Spring 的实现,Spring 官方文档自己都建议优先用
org.springframework.beans.BeanUtils。国内项目常用的 Hutool 也有BeanUtil.copyProperties,基于 cglib 实现,支持忽略字段(ignoreNullValue等)、类型转换,比 Spring 版功能略多。这三者本质上都是浅拷贝工具,不在上面那条"映射演进线"上。
2. Orika
-
机制:运行时反射 + 字节码缓存,声明式
classMap配置。 -
优点:
.field("a.b","x")支持嵌套拍平、byDefault()兜底、exclude()排除,配置集中在 MapperFactory 里。 -
缺点:字段名是字符串,重构改名不报错,静默变 null;错误要到运行时才暴露;项目本身已进入维护期,社区不活跃。
3. MapStruct
@Mapper
public interface CustomerMapper {
@Mapping(target = "idNo", source = "idCard.idNo")
@Mapping(target = "name", source = "idCard.name")
CreateCustomerApplicationCommand toCommand(SubmitCustomerApplicationRequest request);
}
-
机制:编译期注解处理器,直接生成手写级的 Java 代码。
-
优点:类型安全——字段改名、类型不匹配编译期直接报错;生成的代码可以单步调试;没有反射,性能最好。
-
缺点:多一个编译期插件;注解多了会稍显啰嗦。
一句话总结:BeanUtils 是"浅层搬运工",Orika 是"上一代声明式主力",MapStruct 是"当下的标准答案"。
附:三库对比速查表
|
维度 |
BeanUtils.copyProperties |
Orika |
MapStruct |
|---|---|---|---|
|
机制 |
运行时反射 |
运行时反射 + 字节码缓存 |
编译期生成代码 |
|
类型安全 |
❌ 无检查 |
❌ 字段名是字符串 |
✅ 编译期直接报错 |
|
嵌套拍平 |
❌ 不支持 |
✅ 点路径 |
✅ |
|
类型转换 |
基本不支持 |
支持(可注册 Converter) |
支持(自动 + 自定义) |
|
深/浅拷贝 |
纯浅拷贝,全共享 |
浅拷贝 + 可选递归,集合默认共享引用 |
递归生成新对象,但集合元素仍可能共享 |
|
性能 |
反射,较慢 |
反射 + 缓存,中等 |
手写级,最快 |
|
配置方式 |
零配置 |
集中 |
接口 + 注解 |
|
可调试性 |
一般 |
一般 |
生成的代码可单步调试 |
|
社区状态 |
Spring 官方,稳定 |
维护期,半停更 |
活跃,事实标准 |
|
适用场景 |
几乎相同的 DTO 搬字段 |
存量项目 |
新项目首选 |
三代演进:为什么一个公司里会同时存在三种写法
你在三个项目里同时看到 Orika、BeanUtils、MapStruct,不是巧合,而是这个赛道三代演进的缩影。看懂这条线,很多"为什么"就有了答案。
第一代:XML 配置时代(约 2010 年前后)—— 代表:Dozer
-
用 XML 描述映射关系,一个映射写一张 XML。
-
问题:XML 和 Java 分离,字段名写错只能运行时才发现,配置冗长,重构痛苦。
第二代:运行时反射 + 声明式(约 2013–2018)—— 代表:Orika、ModelMapper
-
把配置搬进 Java 注解/API,靠反射在运行时完成映射,配合字节码缓存提性能。
-
这一代解决了"XML 冗长"的问题,但留下两个顽疾:字段名是字符串(重构静默失效)、错误到运行时才暴露。
-
Orika 偏"显式声明"(
classMap+.field()),ModelMapper 偏"约定自动匹配"。
第三代:编译期生成(约 2018 至今)—— 代表:MapStruct
-
注解处理器在编译期直接生成手写级 Java 代码。
-
字段改名、类型不匹配编译期直接编译失败,可单步调试,零反射开销。
-
彻底解决了前两代"字符串字段名 + 运行时才暴露错误"的痛点,所以成为当下标准。
一句话串起来:
Dozer(XML)→ Orika / ModelMapper(反射声明式)→ MapStruct(编译期)。每一代都在解决上一代"字段名靠字符串、错误运行时才暴露"的老问题;而
BeanUtils.copyProperties从头到尾都是那个"浅拷贝工具",不参与这条映射演进线。
三、重点:深拷贝 vs 浅拷贝,这是最容易出事的地方
上面三个库,性能、类型安全那些差异都是"体验问题",但浅拷贝是能直接导致生产事故的"正确性问题"。
什么是浅拷贝
先记住一句话:
浅拷贝只复制字段的值;而引用类型字段的值,是一个引用(内存地址)。
所以浅拷贝之后,基本类型字段(如 int)确实"复制了一份值";String、BigDecimal 这类不可变对象虽然复制的也是引用,但因为不可变,效果上等同于"各有一份";真正会出问题的是可变引用类型字段(自定义对象、集合),它们复制的是同一个地址——source 和 target 指向同一个堆对象。
一张图看懂:

两个 User 各自的 address 字段,都指向同一个 Address 实例。改 u2 的地址,u1 跟着变。
看这个例子:
public class User {
private String name;
private Address address; // 引用类型
}
User u1 = new User();
u1.setAddress(new Address("北京"));
User u2 = new User();
BeanUtils.copyProperties(u1, u2);
// 浅拷贝的结果:
u2.getName().equals(u1.getName()); // true,值拷贝,没问题
u2.getAddress() == u1.getAddress(); // true!同一个对象!
关键在最后一行:u1.getAddress() 和 u2.getAddress() 是同一个 Address 实例。这意味着:
u2.getAddress().setCity("上海");
System.out.println(u1.getAddress().getCity()); // 输出"上海",不是"北京"!
你改 target 的地址,source 的地址也跟着变了。这就是浅拷贝最隐蔽、最致命的坑。
集合字段,更隐蔽的浅拷贝
比单个嵌套对象更隐蔽的是集合字段:
public class Order {
private String orderNo;
private List<Item> items; // 集合,引用类型
}
copyProperties 之后,order1.getItems() 和 order2.getItems() 是同一个 List。你对 order2 的 items 做 add、remove、改元素,order1 全都能看到。
这在真实业务里会演变成什么?举几个典型事故:
-
你从缓存里拿一个对象
copyProperties出一份"副本"准备做临时修改,结果把缓存里的原始数据一起改了; -
两个线程各自拿一份"拷贝"去处理,结果其实在操作同一个 List,产生并发脏数据;
-
你在循环里复用 source 对象,以为每次 copy 是独立的,结果上一轮残留的数据串到了下一轮。
根本原因都一样:你以为拿到的是副本,其实拿到的是引用。
那深拷贝是什么
深拷贝就是:引用类型字段也递归地创建新对象,source 和 target 之间彻底断开引用关系。改任何一个,都不影响另一个。

address 也被复制了一份,两个 User 各持有一个独立的 Address,互不影响。
User u2 = deepCopy(u1);
u2.getAddress().setCity("上海");
System.out.println(u1.getAddress().getCity()); // 还是"北京",互不影响
那 Orika、MapStruct 是深拷贝还是浅拷贝?
这里要诚实地说一个容易被忽略的点:三者没有一个是"自动全深拷贝"。要分清两个概念:
深拷贝 ≠ 递归映射。 深拷贝是"无条件沿引用图一路复制所有引用字段,直到彻底断开";而 Orika / MapStruct 做的是递归映射——只按你声明的映射规则递归,没声明到的引用就退化成共享。
沿着这个标准,三者的深浅光谱是这样的:
-
BeanUtils.copyProperties:纯浅拷贝,没有任何递归,全共享,最危险。
-
Orika:浅拷贝 + 可选递归——嵌套对象只有当你注册了该类型的
classMap时才递归;集合默认就是同一个List引用,不额外写元素映射就共享,离"深拷贝"最远。 -
MapStruct:递归生成新对象——嵌套对象有对应映射方法就
new出新的,集合会生成新容器(new ArrayList)。但容器里的元素仍可能共享:List<Item>里的每个Item只有当你给Item也写了映射方法时才被复制,没写就共享元素引用。
所以结论不是"Orika/MapStruct 就安全了",而是:只要涉及嵌套对象和集合,你就必须显式确认"这里到底是不是独立副本",不能默认。 尤其别以为 MapStruct 把 List 一拷就深拷贝了——它可能只是换了壳,壳里的东西还是原来那批对象。
MapStruct 怎么实现真正的深拷贝
想在 MapStruct 里做到"元素也一起复制"的深拷贝,核心就一条:给每个嵌套类型都补上映射方法,让 MapStruct 沿引用图一路递归。否则它只会 new 出容器,却复用元素。
// ❌ 错误:只声明了顶层映射,List<Item> 里的 Item 还是共享引用
@Mapper
public interface OrderMapper {
OrderDto toDto(Order order); // 容器是新的,元素是旧的
}
// ✅ 正确:给 Item 也写映射方法,MapStruct 会递归 new 出每个 Item
@Mapper
public interface OrderMapper {
OrderDto toDto(Order order);
ItemDto toItemDto(Item item); // 有了它,List 里的每个元素才被真正复制
}
再补一个坑:如果元素里还套元素(比如 Item 里又有 Sku),那 Sku 也要有对应方法,一层都不能断——深拷贝是"按声明递归",漏一层就浅一层。
如果你要的是"完全、彻底、不受映射声明约束"的深拷贝,那就别指望映射库了,直接上序列化(JSON 往返 / SerializationUtils)或手写深拷贝,一步到位。
怎么判断自己踩没踩浅拷贝的坑
两个简单的手段:
-
相等性判断:
System.out.println(source.getXxx() == target.getXxx()),如果是true,就是共享引用; -
改一个看另一个:改 target 的嵌套字段,打印 source 看变没变。
代码评审时,看到 copyProperties 且 source/target 里有 List、Map、自定义对象字段,就要条件反射式地多问一句:这里要不要深拷贝?
四、怎么选:一个能落地的决策框架
结合项目实际,我给一个尽量简单的选择标准:
1. 只在"两个几乎一样的 DTO"之间搬字段,且字段全是基本类型/String
→ BeanUtils.copyProperties 够用,但要接受它是浅拷贝这件事。
2. 有嵌套结构、需要拍平、字段改名、类型转换、跨层映射(API→领域→第三方)
→ 上 MapStruct。类型安全 + 编译期检查,是当下最稳的标准答案。
3. 映射过程中要夹带副作用(加密脱敏、金额换算、状态机转换)
→ 这是关键的一点,也是我从那个支付网关项目里学到的:不要让映射库去干业务活。把映射只当作"形状转换",把副作用放在映射之后的独立步骤里。那个项目里就是这么做的:
ChannelTransactionResponse channelResponse = parse(content, ...);
mapper.map(channelResponse, response); // 映射只负责同名字段
// 业务语义(渠道状态字符串 → 内部枚举)单独用 switch 补:
switch (channelResponse.getRawStatus()) {
case "succeeded": response.setStatus(SUCCEED); break;
case "failed": response.setStatus(FAILED); break;
}
映射管形状,业务管语义。 这句话值得记下来,它能让你的映射层干净、可测、可审计。这正是大厂核心支付/交易链路往往"手写 Assembler + 夹带副作用"而不是纯靠映射库的原因。
4. 老项目已经在用 Orika,要不要换?
如果只是新增映射,继续写 classMap 没问题;但如果项目在活跃迭代、经常重构字段名,建议逐步迁到 MapStruct——因为 Orika 字符串字段名这个"静默变 null"的坑,重构越频繁越危险。
五、最佳实践与典型错误(一正一反)
光有理论不够,这几个坑用代码对比最直观。
错误 1:target 在循环外复用,List 里全是同一个对象
// ❌ 错误:target 复用了,list 里 N 个元素指向同一个对象
List<User> users = new ArrayList<>();
User target = new User();
for (User source : sources) {
BeanUtils.copyProperties(source, target);
users.add(target); // 每次都 add 同一个 target
}
// 结果:users 里所有元素的值都变成最后一个 source 的
// ✅ 正确:每次循环 new 一个 target
List<User> users = new ArrayList<>();
for (User source : sources) {
User target = new User();
BeanUtils.copyProperties(source, target);
users.add(target);
}
错误 2:改"拷贝"后的嵌套字段,污染了原对象
// ❌ 错误:浅拷贝后直接改嵌套对象
User copy = new User();
BeanUtils.copyProperties(original, copy);
copy.getAddress().setCity("上海"); // original 的 address 也变成"上海"了
// ✅ 正确:需要独立副本时,显式复制嵌套对象
User copy = new User();
BeanUtils.copyProperties(original, copy);
Address addr = new Address();
BeanUtils.copyProperties(original.getAddress(), addr);
copy.setAddress(addr);
copy.getAddress().setCity("上海"); // 只改 copy,不影响 original
错误 3:让映射库去干业务活
// ❌ 反模式:把加密、金额换算、状态转换揉进"映射"里,难测试、难审计
CustomerCommand map(SubmitCustomerRequest req) {
CustomerCommand cmd = new CustomerCommand();
cmd.setName(encrypt(req.getName())); // 加密混在映射里
cmd.setAmount(convertToFen(req.getAmount())); // 金额换算混在映射里
return cmd;
}
// ✅ 最佳实践:映射只搬字段,业务副作用独立成步骤
CustomerCommand cmd = mapper.map(req); // 只搬字段
cmd.setName(encrypt(cmd.getName())); // 加密单独一步
cmd.setAmount(convertToFen(cmd.getAmount())); // 换算单独一步
### 错误 4:让 MapStruct 的 qualifiedBy 偷偷帮你查库/调 RPC
这是我在另一个项目里踩过的真实反模式:用 `@Mapper(uses = ...)` + `@Mapping(qualifiedBy = ...)` 让字段值来自查库/RPC,结果 MapStruct 对每个 `@Mapping` 独立生成一行 setter 调用、不去重——同一个下游被发了两遍,甚至 N+1,还可能让两个 Manager 各写一套几乎一样的取数分支,埋下逻辑漂移。一句话提醒:**这是设计层面的翻车,不是工具翻车——问题不在 MapStruct,而在于"把下游查询塞进了映射过程";映射该是纯字段搬运,查一次、复用结果,得靠你在调用方做好。**
这个坑藏得深、形态也多——重复 RPC、事务被拖长、隐式耦合、循环依赖、责任错配……我单独拆了一篇专讲,见《MapStruct 的 uses + qualifiedBy,是怎么把一次映射变成多次下游调用的》。
### 几条最佳实践,拿走即用
1. **映射管形状,业务管语义**——加密脱敏、金额换算、状态机转换放映射之后;
2. **跨层、嵌套拍平、类型转换** → 优先 MapStruct,编译期兜底字段改名;
3. **循环里每次 new target**,别复用;
4. **看到集合/嵌套对象字段,先问一句"要不要深拷贝"**;
5. **映射配置集中管理**(MapStruct 的 `@Mapper` / Orika 的 `classMap`),别散落各处手写 set;
6. **要 MapStruct 深拷贝,就给每个嵌套类型补映射方法**,漏一层就浅一层;要"彻底深拷贝"就别指望映射库,上序列化或手写;
7. **别用 `qualifiedBy` 让映射去查库/调 RPC**——要查就提到调用方查一次,用方法参数或 `@Context` 传结果,否则同一个下游会被映射发多遍(N+1)。
## 六、总结
把整件事压缩成四句话:
1. **BeanUtils.copyProperties 是浅拷贝**,嵌套对象和集合共享引用,这是最容易出生产事故的点,必须警惕;
2. **Orika 是上一代声明式主力**,`byDefault()` + 点路径拍平很优雅,但字符串字段名 + 维护期是硬伤;
3. **MapStruct 是当下标准答案**,编译期生成、类型安全、可调试、性能好;
4. **映射管形状,业务管语义**——副作用(加密、金额、状态)别塞进映射库,放在映射之后独立处理。
对象映射看起来是个"体力活",但选错工具、搞不清深浅拷贝,代价可能是线上的一串脏数据。希望这篇能帮你下次评审代码时,一眼看出那行 `copyProperties` 背后藏着什么。
## 写在最后:聊聊你的经历
你现在的项目里,对象映射用的是哪一套?是还在用 Orika / ModelMapper 这些"上一代"声明式库,还是已经迁到 MapStruct,又或者干脆手写 Assembler?
更想听的是"踩坑"故事:你有没有因为 `copyProperties` 的浅拷贝,把缓存里的数据、别人的订单、或者上一轮循环的残留值一起改掉的经历?集合字段悄悄共享引用、两个线程操作同一个 List……这类 bug 往往藏得很深,复盘一次就忘不了。
欢迎在评论区聊聊——你用过哪些映射框架或手段,踩过哪些坑,最后又是怎么定位和解决的?
本文首发于公众号:[习惯沉淀],转载请注明出处。
作者:习惯沉淀
如果文中有误或对本文有不同的见解,欢迎在评论区留言。
如果觉得文章对你有帮助,请点击文章右下角【推荐】一下。您的鼓励是博主的最大动力!
扫码关注一线码农的学习见闻与思考。
回复"大数据","微服务","架构师","面试总结",获取更多学习资源!

浙公网安备 33010602011771号