Java对象拷贝
概述
在Java开发中,我们会看到各种各样的对象(实体)类,包括:
- POJO:Plain Old Java Object,普通Java对象
- VO:View Object,视图对象,返回给前端用于展示层,将某个页面(组件)的全部(或部分)数据封装到一个对象里,有些公司将其定义为
**Resp
对象,; - VO:另有一说是Value Object,值对象
- QO:Query Object,查询对象,一般用@RequestBody注解的对象,有些公司将其定义为
**Req
对象,如OrderDetailReq用于查询订单详情信息的请求体 - DO:Domain Object,领域对象,常见于DDD(Domain Driven Development)开发中,用于表示从真实业务场景中抽象出来的业务实体对象
- BO:Business Object,业务对象,业务计算层的对象,可能会增加金额,汇率,等业务逻辑字段
- PO:Persistent Object,持久化对象,一般情况下,严格对应数据库表,一个数据表对应一个PO类,数据表里一个字段对应PO类的一个属性
- DAO:Data Access Object,数据访问对象,用于查询数据库的DAO模式
- DTO:Data Transfer Object,数据传输对象,用于在各个层之间传输数据,尤其适用于展示层和服务层
反思
可能有很多人会有一个疑问,为啥要搞这么多对象?
事实上,我自己也会有这个顾虑,这不没事找事么。一个对象从前端传输过来,使用的是QO,即查询对象;然后我在业务层来处理、转换这个对象,并用BO来承载加以封装;然后处理逻辑来到领域层,又需要转换为DO;随后,来到数据库交互层,进行CRUD,即增查改删操作,需要转换为PO;如果需要把数据再返回给前端,上述4个对象,很可能还需要反过来再封装一次,从PO到DO,到BO,再到VO。
这些对象基本上没有太大的差别,字段几乎都是一样的。PO一般比VO多一个逻辑删除字段,毕竟前端才不在乎你数据库的删除概念,前端能看到的数据,就是还没进行逻辑删除的数据。
可能还有人会问,能不能不要搞这么多对象,QO、VO、和PO用一个对象不香么?能少写很多类,少写很多类转换方法(即所谓的getter then setter)。
(个人观点)只能说,可行可不行,看公司编码规范。前端用不上的字段,后端也给前端返回,一来会给前端造成困扰(这个字段啥意思,用于展示或渲染什么数据),二来多余的字段参与网络传输,会降低性能(其实影响真的微乎其微)
之所以会产生这么多对象,一般都是因为随着业务逐渐发展,项目越来越庞大,前后端分离,业务分层势在必行,然后每一层都会定义很多POJO。
但是对于新项目,不应该过度设计,应该根据项目发展的具体情况来适当分层重构。
Lombok
前面提到,业务开发中,可能会碰到各种各样的对象。事实上,哪怕没有这么多对象,几十张表也够烦的,需要定义getter,setter,构造器方法,重写equals和hashcode方法等。
对于JDK 14之前的版本,可考虑使用Lombok提高生产力,参考Java开发工具–Lombok深入实战。
JDK 14版本后,除Lombok,可考虑使用JDK Record类型,功能等价于Lombok。
MapStruct
对于对象之间的属性拷贝及转换,可考虑使用MapStruct,功能很强大,参考Java对象拷贝MapStruct。
拷贝
对象拷贝(Object Copy),将一个对象的属性拷贝到另一个有着相同类类型的对象中去。主要有浅拷贝与深拷贝。Shallow Copy,可翻译为浅拷贝,浅复制,浅克隆。Deep Copy,可翻译为深拷贝,深复制,深克隆。
另外还有延迟拷贝(Lazy Copy)。
浅拷贝与深拷贝
关于浅拷贝:
- 被复制对象的所有变量都含有与原来的对象相同的值,而所有的对其他对象的引用仍然指向原来的对象
- 浅复制仅仅复制所考虑的对象,而不复制它所引用的对象
- 只复制某个对象的指针,而不复制对象本身,两个引用指针指向被复制对象的同一块内存地址
关于深拷贝:
- 被复制对象的所有变量都含有与原来的对象相同的值,除去那些引用其他对象的变量。那些引用其他对象的变量将指向被复制过的新对象,而不再是原有的那些被引用的对象。深复制把要复制的对象所引用的对象都复制一遍。
- 深拷贝会拷贝所有属性,并且拷贝属性指向的动态分配的内存。当对象和它所引用的对象一起拷贝时即发生深拷贝。
- 需要创建被拷贝类的一个一模一样的新对象,新对象和老对象不共享内存,对新对象的修改不会影响老对象的价值
对比
无论是深拷贝还是浅拷贝,都需要实现Cloneable接口并且重写clone方法。
深拷贝相比于浅拷贝速度较慢并且花销较大。
两者主要区别在于是否支持引用类型的属性拷贝。
java.lang.Object
的clone()方法
clone方法将对象复制一份并返回给调用者。一般而言clone()
方法满足:
- 对任何对象x,都有
x.clone() !=x; // 克隆对象与原对象不是同一个对象
- 对任何对象x,都有
x.clone().getClass() == x.getClass(); // 克隆对象与原对象的类型一样
- 如果对象x的equals()方法定义恰当,则
x.clone().equals(x)
成立
值传递与引用传递
Java中对于基本型变量采用的是值传递;而对于对象传递采用的是引用传递,即地址传递,实际上是对对象作浅拷贝。
方法调用(call by),根据参数传递的情况又分为值调用(call by value)和引用调用(call by reference)。传递值的是值调用,传递地址的是引用调用。Java的方法对象参数传递仍然是值调用。
序列化
实现深拷贝的方式:
- 实现Cloneable接口,在
clone()
方法里面重写克隆逻辑,对克隆对象内部的引用变量再进行一次克隆 - 序列化:将整个对象图写入到一个持久化存储文件中并且当需要时把它读取回来,这意味着当你需要把它读取回来时你需要整个对象图的一个拷贝。这就是当你深拷贝一个对象时真正需要的东西。必须确保对象图中所有类都是可序列化的
序列化的限制和问题:
- transient:无法序列化(拷贝)transient变量
- 性能问题:创建socket,序列化对象,通过socket传输,然后反序列化它,性能比较差
延迟拷贝
两种的组合,实际上很少会使用。当最开始拷贝一个对象时,会使用速度较快的浅拷贝,还会使用一个计数器来记录有多少对象共享这个数据。当程序想要修改原始的对象时,它会决定数据是否被共享(通过检查计数器)并根据需要进行深拷贝。
延迟拷贝看起来就是深拷贝,但是只要有可能它就会利用浅拷贝的速度。当原始对象中的引用不经常改变的时候可以使用延迟拷贝。由于存在计数器,效率下降很高,但只是常量级的开销。而且在某些情况下,循环引用会导致一些问题。
如何选择
如果对象的属性全是基本类型的,可以使用浅拷贝,但是如果对象有引用属性,那就要基于具体的需求来选择。如果对象引用任何时候都不会被改变,那么没必要使用深拷贝,只需要使用浅拷贝就行。如果对象引用经常改变,就要使用深拷贝。
继承自java.lang.Object类的clone()方法是浅复制,除非加入
工具
上面提到深拷贝,需要拷贝所有依赖的引用对象。而对象引用关系往往非常复杂,形成引用链(或叫对象图)
Apache BeanUtils
使用org.apache.commons.beanutils.BeanUtils
进行对象深入复制时,主要通过向BeanUtils框架注入新的类型转换器,BeanUtils对复杂对象的复制默认是引用。
Apache PropertyUtils
org.apache.commons.beanutils.PropertyUtils.copyProperties()
方法几乎与BeanUtils.copyProperties()
相同,主要区别在于后者提供类型转换功能,即发现两个JavaBean的同名属性为不同类型时,在支持的数据类型范围内进行转换,PropertyUtils不支持这个功能,所以说BeanUtils使用更普遍一点,犯错的风险更低一点。而且它仍然属于浅拷贝。
Apache提供SerializationUtils.clone(T)
,T对象需要实现Serializable接口,属于深克隆。
Spring BeanUtils
Spring中的BeanUtils,对两个对象中相同名字的属性进行简单get/set,仅检查属性的可访问性。
成员变量赋值是基于目标对象的成员列表,并且会跳过ignore的以及在源对象中不存在的,所以这个方法是安全的,不会因为两个对象之间的结构差异导致错误,但是必须保证同名的两个成员变量类型相同。
Apache提供的BeanUtils和Spring的BeanUtils中拷贝方法的原理都是先用JDK中java.beans.Introspector
类的getBeanInfo()
方法获取对象的属性信息及属性get/set方法,接着使用反射(Method. invoke(Objectobj, Object...args)
方法)进行赋值。Apache支持名称相同但类型不同的属性的转换,Spring支持忽略某些属性不进行映射,都设置缓存保存已解析过的BeanInfo信息。
CGLib BeanCopier
CGLib的BeanCopier原理:不是利用反射对属性进行赋值,而是直接使用ASM的MethodVisitor直接编写各属性的get/set方法(具体过程可见BeanCopier类的generateClass(ClassVisitor)方法)生成class文件,然后进行执行。由于是直接生成字节码执行,所以BeanCopier的性能较采用反射的BeanUtils有较大提高。
Dozer
Dozer基于反射来实现对象深拷贝,反射调用set/get或直接对成员变量赋值。该方式通过invoke执行赋值,实现时一般会采用beanutil,Javassist等开源库。
Dozer支持简单属性映射、复杂类型映射、双向映射、隐式映射以及递归映射,支持定制化的属性字段映射,可使用xml或注解进行映射的配置,支持自动类型转换。
Orika
深拷贝,不用担心原始类和克隆类指向同一个对象的问题。Orika底层采用javassist类库生成Bean映射的字节码,之后直接加载执行生成的字节码文件,因此在速度上比使用反射进行赋值会快很多。
JSON
对象拷贝可使用序列化来实现,真实业务开发中,有很大一部分时间是和前端打交道,而不仅仅是提供一个微服务应用(或SOA服务),提供给其他的微服务(SOA)调用(API Call,or Service Call)。现在前后端几乎都是使用JSON来传输数据,因此后端经常需要将JSON Object(POJO对象)转换成JSON String或从JSON String反序列化得到JSON Object。
此时可以使用的工具类就不要太多:FastJson,Jackson,Gson等
自研工具类
很多公司都有自研工具的习惯(传统),自研有不少好处,如稳定性和性能。因为会根据公司或团队的具体项目的业务需求,不用考虑一套大而全的脚手架,只实现简单的转换模板。
性能Benchmark
TODO
对比与选型
工具这么多,怎么选?
主要考虑两点:
- 稳定性,文档丰富度,GitHub活跃度,未关闭的issue多少
- 性能对比,不过绝大多数情况下真的没有必要,一是没有大对象,二是对于消耗的时间真的没有那么敏感。