在两个bean之间进行数据传递的解决方案
简介
在日常开发中,在两个bean之间进行数据传递是常见的操作,例如在日常开发中,将数据从VO类转移到DO类等。在两个bean之间进行数据传递,最常见的解决方案,就是手动复制,但是它比较繁琐,充斥着大量的样板代码,这种高度规则化的代码很容易想到使用工具来解决。java中确实有不少框架来解决这个问题,这里统一做个对比。
解决方案
需求:在两个bean之间进行数据传递
// 第一个类:vo类
public class PersonVO {private Integer id;private String username;private String birthday;// getter、setter
}// 第二个类:领域模型
public class Person {private Integer id;private String name;private Date birthday;// getter、setter
}
方案1:手动转换
案例:手动编写vo类和领域模型之间的转换逻辑
public static PersonVO convertPersonToVO(Person person) {PersonVO vo = new PersonVO();vo.setId(person.getId());vo.setUsername(person.getName());vo.setBirthday(DateTimeUtil.convertDateWithDefaultPattern(person.getBirthday()));return vo;
}
这是最原始的方式,它最简单、最稳定,接下来的几种方式都是对于这种方式的优化
方案2:spring提供的BeanUtils
案例:
第一步:添加依赖
<dependency><groupId>org.springframework</groupId><artifactId>spring-beans</artifactId><version>5.2.20.RELEASE</version>
</dependency>
第二步:编写转换逻辑
public static PersonVO convertPersonToVO(Person person) {PersonVO vo = new PersonVO();// spring提供的工具类,它可以在名称相同、类型匹配的属性上进行值传递,如果找不到匹配的属性,也不会报错,// 随后需要用户手动处理无法匹配的属性。BeanUtils.copyProperties(person, vo);// 手动处理无法匹配的属性vo.setUsername(person.getName());vo.setBirthday(DateTimeUtil.convertDateWithDefaultPattern(person.getBirthday()));return vo;
}
spring提供的工具类,在实际开发中是用的比较多的
方案3:apache提供的BeanUtils
案例:
第一步:添加依赖
<dependency><groupId>commons-beanutils</groupId><artifactId>commons-beanutils</artifactId><version>1.9.4</version>
</dependency>
第二步:编写转换逻辑
public static PersonVO convertPersonToVO(Person person) {PersonVO vo = new PersonVO();// apache提供的工具类,和spring提供的几乎一样try {BeanUtils.copyProperties(vo, person);} catch (Exception e) {throw new RuntimeException(e);}vo.setUsername(person.getName());vo.setBirthday(DateTimeUtil.convertDateWithDefaultPattern(person.getBirthday()));return vo;
}
apache提供的工具类,也是实际开发中用的比较多的
方案4:mapstruct
一个框架,用户可以通过注解来指定数据传递的逻辑。它基于java提供的注解处理器API,可以在编译时按照用户指定的规则,生成在两个bean之间进行数据传递的代码。因为是在编译时动态生成代码,避免了运行时的反射调用,所有它的执行效率和手动编写的转换逻辑几乎没有区别。
案例:
第一步:添加依赖
<!--面向用户的api,指定转换逻辑-->
<dependency><groupId>org.mapstruct</groupId><artifactId>mapstruct</artifactId><version>1.4.1.Final</version>
</dependency>
<!--在编译时动态生成字节码的组件,只需要在编译时使用,所以它的依赖范围是provided-->
<dependency><groupId>org.mapstruct</groupId><artifactId>mapstruct-processor</artifactId><version>1.4.1.Final</version><scope>provided</scope>
</dependency>
第二步:编写转换逻辑
// 指定当前接口是一个用于在两个bean之间进行数据传递的接口,
// mapstruct在编译时会处理这个注解。不要和mybatis中的混淆。
@Mapper
public interface PersonMapper {// mapstruct会在编译时动态生成当前接口的实现类,这里通过这种方式在运行时获取到实现类的实例,// 方便外部调用PersonMapper INSTANCE = Mappers.getMapper(PersonMapper.class);// 这里指定领域模型到vo类的转换逻辑@Mappings({// 指定两个bean之间类型匹配但是名称不一致的属性@Mapping(source = "name", target = "username"), // 指定不需要进行数据传递的属性@Mapping(target = "birthday", ignore = true)})PersonVO toPersonVO(Person person);// 用户自定义数据转换的逻辑,如果某些属性需要使用java代码来计算,可以通过这种方式来指定@AfterMappingdefault void toPersonVOAfterMapping(Person person, @MappingTarget PersonVO vo) {vo.setBirthday(formatDate(person.getBirthday()));}default String formatDate(Date date) {return DateTimeUtil.convertDateWithDefaultPattern(date);}
}// 在外部调用上面的mapper接口
public static PersonVO convertPersonToVO(Person person) {return PersonMapper.INSTANCE.toPersonVO(person);
}
第三步:查看编译时生成的代码
public class PersonMapperImpl implements PersonMapper {public PersonMapperImpl() {}// 根据之前的注解,生成转换逻辑public PersonVO toPersonVO(Person person) {if (person == null) {return null;} else {PersonVO personVO = new PersonVO();personVO.setUsername(person.getName());personVO.setId(person.getId());this.toPersonVOAfterMapping(person, personVO);return personVO;}}
}
总结:mapstruct的使用比较复杂,这里只做一个简单的展示,并且不深入讲解它的逻辑,总之,它类似于lombok,在编译时根据注解动态生成字节码,注解中指定了转换逻辑。因为是编译时生成字节码,避免了反射调用,所以它的执行效率和手动编写转换逻辑几乎一致。
其它:dozer、orika、modelMapper等框架
相较于手动编写转换逻辑的代码而言,这些框架的使用都过于复杂了,不推荐使用,这里就不展示它们的使用案例了。
解决方案对比
首先,排除掉dozer、orika等,因为相较于手动编写转换逻辑而言,它们的使用都比较复杂,相当于为了避免一件麻烦事,引入另一件更麻烦的事。
其次,基于反射的工具类也不推荐,因为如果bean中的某个属性转换失败,它们不会报错,而是忽略,如果在开发过程中修改了属性名称,造成的数据传递失败不会在编译时被发现,要到运行时才能看出来。
最后,最推荐的,是手动编写转换逻辑或mapstruct。手动编写转换逻辑是最简单、最安全的方式,推荐把转换逻辑放到一个工具类中,实现代码的复用。mapstruct有一定的复杂度,但是它的功能强大,完全可以满足需求,而且如果修改了属性的名称,在编译时就会报错,而且它是编译时生成代码,效率上也满足要求。
具体使用哪一种,取决于项目组的要求。如果追求稳定,推荐手动编写转换逻辑,如果追求开发速度,推荐引入mapstruct,不推荐使用基于反射的BeanUtils,因为如果在开发中修改了属性名,基于BeanUtils的转换逻辑不会感知到这种修改,可能会导致某些错误要到运行时才能被发现。
不同解决方案的性能对比:之前提到基于反射的转换逻辑,执行起来比较慢,但是性能上具体表现如何,这里对不同解决方案的性能做一个对比,具体方法是,上面案例中每种转换方案都执行几万次,观察执行时间,对比它们的效率。
执行一百万次耗费的时间(毫秒) | 执行一千万次耗费的时间(毫秒) | |
---|---|---|
手动编写转换逻辑 | 634 | 4768 |
spring提供的BeanUtils | 771 | 5674 |
apache提供的BeanUtils | 4198 | 40389 |
mapstruct | 607 | 4807 |
可以看到,apache提供的工具类明显比spring提供的工具类要慢,手动编写转换逻辑和mapstruct在执行速度上差别不大。
源码分析
这里对上面提到的几种解决方案做一个深入介绍。
spring提供的BeanUtils
整体流程
源码:
// 参数1:源对象
// 参数2:目标对象
// 参数3:目标对象的类对象,可以不传
// 参数4:要被忽略的属性,可以不传
private static void copyProperties(Object source, Object target, @Nullable Class<?> editable,@Nullable String... ignoreProperties) throws BeansException {Assert.notNull(source, "Source must not be null");Assert.notNull arget, "Target must not be null");Class<?> actualEditable = target.getClass();if (editable != null) {if (!editable.isInstance(target)) {throw new IllegalArgumentException("Target class [" + target.getClass().getName() +"] not assignable to Editable class [" + editable.getName() + "]");}actualEditable = editable;}// 获取目标对象的中所有属性,这里的PropertyDescriptor,包含属性名、属性的getter方法/setter方法实例PropertyDescriptor[] targetPds = getPropertyDescriptors(actualEditable);List<String> ignoreList = (ignoreProperties != null ? Arrays.asList(ignoreProperties) : null);for (PropertyDescriptor targetPd : targetPds) {// 获取目标属性的setter方法Method writeMethod = targetPd.getWriteMethod();// 如果目标属性有setter方法并且没有被指定为需要忽略if (writeMethod != null && (ignoreList == null || !ignoreList.contains(targetPd.getName()))) {// 根据目标属性的属性名,获取源对象中指定属性的getter方法PropertyDescriptor sourcePd = getPropertyDescriptor(source.getClass(), targetPd.getName());if (sourcePd != null) {// 如果可以获取到目标属性的getter方法Method readMethod = sourcePd.getReadMethod();if (readMethod != null &&// 这里判断getter方法的返回值和setter方法的参数是否匹配ClassUtils.isAssignable(writeMethod.getParameterTypes()[0], readMethod.getReturnType())) {try {if (!Modifier.isPublic(readMethod.getDeclaringClass().getModifiers())) {readMethod.setAccessible(true);}// 如果匹配,把getter方法的返回值传递给setter方法Object value = readMethod.invoke(source);if (!Modifier.isPublic(writeMethod.getDeclaringClass().getModifiers())) {writeMethod.setAccessible(true);}writeMethod.invoke(target, value);}catch (Throwable ex) {throw new FatalBeanException("Could not copy property '" + targetPd.getName() + "' from source to target", ex);}}}}}
}
总结:BeanUtils的基本逻辑,就是获取目标对象中的所有属性,然后根据属性名获取源对象中的getter方法,如果getter方法的返回值和setter方法的参数相匹配,就进行数据传递。这里也可以解释,为什么属性名不一致或数据类型不一致无法进行属性传递。
深入了解
上面的代码中提到了PropertyDescriptor,它存储了一个属性和它的getter、setter方法,这里深入了解这些信息是如何被解析出来的。
相关组件
PropertyDescriptor:存储了一个属性和它的getter、setter方法,它是java.beans包下的api
// PropertyDescriptor
public class PropertyDescriptor extends FeatureDescriptor {private Reference<? extends Class<?>> propertyTypeRef;// getter方法private final MethodRef readMethodRef = new MethodRef();// setter方法private final MethodRef writeMethodRef = new MethodRef();private Reference<? extends Class<?>> propertyEditorClassRef;private boolean bound;private boolean constrained;// 属性名首字母大写// The base name of the method name which will be prefixed with the// read and write method. If name == "foo" then the baseName is "Foo"private String baseName;private String writeMethodName;private String readMethodName;
}// PropertyDescriptor的父类
public class FeatureDescriptor {// 类对象的引用private Reference<? extends Class<?>> classRef;
}// 方法引用,可以看到,Method对象被放到了软引用之中。
final class MethodRef {private String signature;private SoftReference<Method> methodRef;private WeakReference<Class<?>> typeRef;
}
相关组件和整体流程
整体流程:spring调用java提供的内省器,解析类对象,内省器会缓存解析结果,spring本身也提供了缓存机制。
涉及到的组件:
- java提供的内省器 Introspector,它负责通过反射解析类对象中的信息,把解析结果存储到BeanInfo、PropertyDescriptor中
- CachedIntrospectionResults:缓存内省器的解析结果,这个组件是spring提供的。
这里以组件为核心来介绍整体流程。
java提供的内省器 Introspector
Introspector:java提供的内省器,通过反射来解析类对象中的数据。
基本结构:
public class Introspector {// 类对象,一个内省器实例负责解析一个类对象private Class<?> beanClass;// 父类的bean信息private BeanInfo superBeanInfo;// 方法信息,key是方法名,值是解析出的方法信息private Map<String, MethodDescriptor> methods;// 属性信息,key是属性名,值是属性信息,这里还包括属性的getter、setter方法private Map<String, PropertyDescriptor> properties;// 类中方法名的前缀static final String ADD_PREFIX = "add";static final String REMOVE_PREFIX = "remove";static final String GET_PREFIX = "get";static final String SET_PREFIX = "set";static final String IS_PREFIX = "is";
}
整体流程:
1、创建内省器的实例,解析bean信息:
public static BeanInfo getBeanInfo(Class<?> beanClass)throws IntrospectionException
{if (!ReflectUtil.isPackageAccessible(beanClass)) {return (new Introspector(beanClass, null, USE_ALL_BEANINFO)).getBeanInfo();}// 缓存解析结果ThreadGroupContext context = ThreadGroupContext.getContext();BeanInfo beanInfo;synchronized (declaredMethodCache) {beanInfo = context.getBeanInfo(beanClass);}if (beanInfo == null) {// 为指定的类对象创建一个内省器实例,然后解析类对象中的信息beanInfo = new Introspector(beanClass, null, USE_ALL_BEANINFO).getBeanInfo();synchronized (declaredMethodCache) {context.putBeanInfo(beanClass, beanInfo);}}return beanInfo;
}
可以看到,内行器会缓存解析出的结果,把它缓存到ThreadGroupContext中。ThreadGroupContext有两层缓存,第一层缓存,线程组实例和ThreadGroupContext实例,第二层缓存,类对象和BeanInfo,第二层缓存使用WeakHashMap来实现,key是弱引用,垃圾回收运行时会回收弱引用,这里可以理解为缓存的失效机制,保证内存安全。
2、解析类对象中的bean信息:整体流程
private BeanInfo getBeanInfo() throws IntrospectionException {// 解析基本的bean信息BeanDescriptor bd = getTargetBeanDescriptor();// 解析方法信息MethodDescriptor mds[] = getTargetMethodInfo();// 解析事件相关的方法,这个很少用到EventSetDescriptor esds[] = getTargetEventInfo();// 解析属性信息,包括属性的getter、setter方法,这里重点关注PropertyDescriptor pds[] = getTargetPropertyInfo();int defaultEvent = getTargetDefaultEventIndex();int defaultProperty = getTargetDefaultPropertyIndex();return new GenericBeanInfo(bd, esds, defaultEvent, pds,defaultProperty, mds, explicitBeanInfo);}
3、解析属性信息和属性信息对应的getter、setter方法,这是需要重点关注的方法
private PropertyDescriptor[] getTargetPropertyInfo() {// 用户手动指定的bean信息,这个特性已经用的很少了// Check if the bean has its own BeanInfo that will provide// explicit information.PropertyDescriptor[] explicitProperties = null;if (explicitBeanInfo != null) {explicitProperties = getPropertyDescriptors(this.explicitBeanInfo);}if (explicitProperties == null && superBeanInfo != null) {// We have no explicit BeanInfo properties. Check with our parent.addPropertyDescriptors(getPropertyDescriptors(this.superBeanInfo));}for (int i = 0; i < additionalBeanInfo.length; i++) {addPropertyDescriptors(additionalBeanInfo[i].getPropertyDescriptors());}if (explicitProperties != null) {// Add the explicit BeanInfo data to our results.addPropertyDescriptors(explicitProperties);} else {// Apply some reflection to the current class.// 重点看这里,获取当前类和父类中所有的公共方法// First get an array of all the public methods at this levelMethod methodList[] = getPublicDeclaredMethods(beanClass);// Now analyze each method.for (int i = 0; i < methodList.length; i++) {Method method = methodList[i];if (method == null) {continue;}// 跳过静态方法// skip static methods.int mods = method.getModifiers();if (Modifier.isStatic(mods)) {continue;}// 获取方法的基本信息String name = method.getName();Class<?>[] argTypes = method.getParameterTypes();Class<?> resultType = method.getReturnType();int argCount = argTypes.length;PropertyDescriptor pd = null;if (name.length() <= 3 && !name.startsWith(IS_PREFIX)) {// Optimization. Don't bother with invalid propertyNames.continue;}try {// 这里就是解析getter、setter方法if (argCount == 0) { // 参数个数为0,证明是getter方法if (name.startsWith(GET_PREFIX)) {// 如果方法是get开头pd = new PropertyDescriptor(this.beanClass, name.substring(3), method, null);} else if (resultType == boolean.class && name.startsWith(IS_PREFIX)) {// 如果方法是is开头并且返回值类型是boolean,例如,属性类型是boolean时,getter方法是isXXX// Boolean getterpd = new PropertyDescriptor(this.beanClass, name.substring(2), method, null);}} else if (argCount == 1) { // 参数个数为1if (int.class.equals(argTypes[0]) && name.startsWith(GET_PREFIX)) {pd = new IndexedPropertyDescriptor(this.beanClass, name.substring(3), null, null, method, null);} else if (void.class.equals(resultType) && name.startsWith(SET_PREFIX)) {// 返回值是void并且方法以set开头,证明是setter方法// Simple setterpd = new PropertyDescriptor(this.beanClass, name.substring(3), null, method);if (throwsException(method, PropertyVetoException.class)) {pd.setConstrained(true);}}} else if (argCount == 2) { // 参数个数为2,带下标的setter方法if (void.class.equals(resultType) && int.class.equals(argTypes[0]) && name.startsWith(SET_PREFIX)) {pd = new IndexedPropertyDescriptor(this.beanClass, name.substring(3), null, null, null, method);if (throwsException(method, PropertyVetoException.class)) {pd.setConstrained(true);}}}} catch (IntrospectionException ex) {// This happens if a PropertyDescriptor or IndexedPropertyDescriptor// constructor fins that the method violates details of the deisgn// pattern, e.g. by having an empty name, or a getter returning// void , or whatever.pd = null;}if (pd != null) {// If this class or one of its base classes is a PropertyChange// source, then we assume that any properties we discover are "bound".if (propertyChangeSource) {pd.setBound(true);}// 保存解析结果addPropertyDescriptor(pd);}}}// 处理解析好的方法processPropertyDescriptors();// 返回解析结果PropertyDescriptor result[] =properties.values().toArray(new PropertyDescriptor[properties.size()]);// Set the default index.if (defaultPropertyName != null) {for (int i = 0; i < result.length; i++) {if (defaultPropertyName.equals(result[i].getName())) {defaultPropertyIndex = i;}}}return result;
}
这段代码看起来很多,但是有许多特性已经被淘汰了,几乎不使用,例如,方法开头的explicitBeanInfo,它是用户手动指定的bean信息,这个特性几乎不会被用到,以及解析方法的过程中遇到的带下标信息的getter、setter方法,这些也不会被用到。
上面的逻辑只展示了内省器是如何解析属性对应的方法,它会遍历所有的方法,根据方法的名称、参数,来决定方法是哪个属性的getter或setter方法,然后把属性名和方法放到一个map中,key是属性名,value是一个list,存储了一个属性的getter、setter方法。随后会处理属性的getter、setter方法,把它们放到一个PropertyDescriptor中。
要注意,内省器中处理方法的核心原则是遍历所有的方法,它不会根据属性名来拼接方法名,然后直接获取方法的实例,这可能是为了兼容一些以前的特性,例如带下标的getter、setter方法,这些特性现在已经不用了,但是内省器中还是会处理这些方法。
缓存内行器执行结果的组件 CachedIntrospectionResults
CachedIntrospectionResults:缓存内省器的解析结果
基本结构:
public final class CachedIntrospectionResults {// 基于强引用的缓存,注意,缓存中的value也是当前类的实例。static final ConcurrentMap<Class<?>, CachedIntrospectionResults> strongClassCache =new ConcurrentHashMap<>(64);// 基于弱引用的缓存static final ConcurrentMap<Class<?>, CachedIntrospectionResults> softClassCache =new ConcurrentReferenceHashMap<>(64);// 类对象的解析结果private final BeanInfo beanInfo;private final Map<String, PropertyDescriptor> propertyDescriptors;}
整体流程:
1、调用内省器并缓存它的执行结果
static CachedIntrospectionResults forClass(Class<?> beanClass) throws BeansException {// 从缓存中获取数据CachedIntrospectionResults results = strongClassCache.get(beanClass); // 从强缓存中获取if (results != null) {return results;}results = softClassCache.get(beanClass); // 从弱缓存中获取if (results != null) {return results;}// 这里会调用内省器来解析类对象,解析结果存放到当前类的实例中,然后把当前类的实例放到缓存中results = new CachedIntrospectionResults(beanClass);ConcurrentMap<Class<?>, CachedIntrospectionResults> classCacheToUse;// 这里的判断,决定使用强缓存还是弱缓存。// 如果要被解析的类的类加载器,和CachedIntrospectionResults的累加器是同一个,使用强缓存if (ClassUtils.isCacheSafe(beanClass, CachedIntrospectionResults.class.getClassLoader()) ||isClassLoaderAccepted(beanClass.getClassLoader())) {classCacheToUse = strongClassCache;}else {if (logger.isDebugEnabled()) {logger.debug("Not strongly caching class [" + beanClass.getName() + "] because it is not cache-safe");}classCacheToUse = softClassCache;}// 把解析结果存入缓存CachedIntrospectionResults existing = classCacheToUse.putIfAbsent(beanClass, results);return (existing != null ? existing : results);
}
2、缓存的清除机制:清除指定类加载器下的所有解析结果
public static void clearClassLoader(@Nullable ClassLoader classLoader) {acceptedClassLoaders.removeIf(registeredLoader ->isUnderneathClassLoader(registeredLoader, classLoader));// 清除强引用缓存strongClassCache.keySet().removeIf(beanClass ->isUnderneathClassLoader(beanClass.getClassLoader(), classLoader));// 清除弱引用缓存softClassCache.keySet().removeIf(beanClass ->isUnderneathClassLoader(beanClass.getClassLoader(), classLoader));
}
总结:CachedIntrospectionResults是一个工厂类,它负责管理缓存,并且它管理的缓存就是它自己的实例,每一个实例对应一个类对象的解析结果。
问题:为什么Introspector中提供了缓存机制,但是spring还要额外提供一个CachedIntrospectionResults来缓存解析结果?spring提供的缓存性能更高,Introspector中使用的缓存通过synchronized关键字保证线程安全,spring提供的缓存通过分段锁和cas操作来保证线程安全,性能更高。
总结
上面就是spring的BeanUtils是如何获取PropertyDescriptor实例,它主要是通过java提供的内省器来完成的,同时spring提供了缓存机制。
apache提供的BeanUtils
相较于spring提供的BeanUtils,它的功能更强大,但是执行效率也更低,它的内部同样是基于java提供的内省器,同时,它还提供了多种转换器,用于对数据格式进行转换。
mapstruct
基本原理
mapstruct是为java语言开发的代码生成器,可以通过注解的方式自动生成java bean之间的转换代码
mapstruct类似于lombok,基于java提供的注解处理器技术,来扫描和处理指定注解,在编译时生成转换代码。由于它是在编译时生成代码,不需要在运行时特殊处理或者反射,所以它的执行效率和原生代码一样。
java代码的编译过程,大致可以分为三个阶段:
- 将源代码转换为抽象语法树(Abstract Syntax Tree),抽象语法树用于描述源代码的语法结构,语法树中的每一个节点都代表着源代码中的一个元素,例如包、运算符、返回值、代码注释等。
- 编译器调用注解处理器,注解处理器会找到自己要处理的注解,然后修改语法树,生成新的代码
- 编译器使用修改后的语法树来生成字节码文件。
抽象语法树:编译器在编译过程中生成的,用于表示源码的语法结构
注解处理器:编译器提供的扩展,用户可以编写自己的注解处理器,在编译时调用,处理特定的注解。
JSR-269规范:定义了java编译时的注解处理器(Pluggable Annotation Processing 插件式注解处理器),提供了一种标准化的方式让开发者在编译时处理java代码中的注解。
注解处理器的使用步骤:
- 自定义注解
- 继承一个特定的抽象类 AbstractProcessor,实现自己处理注解的逻辑
- 基于SPI机制,注册自己的注解处理器,在META-INF/services目录下创建一个特定的文件,文件中是自定义注解处理器的全限定名。
- 完成,此时,在编译源代码时,就会调用注解处理器
在mapstruct的核心原理中,注解处理器的定义和代码的生成是最核心的两部分,mapstruct使用freemarker模板引擎来生成java代码,用户还可以尝试自定义注解处理器来了解注解处理器的工作机制。
优缺点
类似于lombok,这种基于注解处理器的框架可以生成一些样板代码,减少代码量,但是它会增加源码的复杂度,而且不方便调试。相较于手动编写转换逻辑,是不推荐使用的。