找回密码
 立即注册
首页 业界区 业界 深入研究使用DozerMapper复制List<Ojbect>前后元素类型 ...

深入研究使用DozerMapper复制List<Ojbect>前后元素类型不一致的问题

擒揭 8 小时前
背景

某项目某个功能点是接受前端传参,将其存入MongoDB。这个传参的核心数据是一个二维数组List,可以放字符串、整型,也可以放null。
在测试时发现,前端明明传的是整数,查出来却变成了字符串,比如1234变成了"1234"。经过排查发现,问题出在公司内部使用的一个Bean复制工具类,这个工具类简单封装了DozerMapper,主要功能是将一个Bean复制成一个新的Bean,并且允许这两个Bean的Class不同,从而完成各种类型转换,如:VO  Model、Model  DO、DO  DTO等。
为了快速修复问题从而不影响项目进度,我手写了前端传参和MongoDB的Entity类的转换逻辑,规避了这个问题。这个工具类在公司内部的代码中大量使用,问题的根因是什么?为了搞明白,我写了一个简单的demo,通过debug这部分代码来一探究竟。
关于DozerMapper

DozerMapper有一些高级用法和对应的传参,但是日常中仅仅用到DozerBeanMapperBuilder.buildDefault()来处理。
DozerMapper的官方github,在mvnrepository上可以看到它的最新版本是7.0.0。
公司的工具类用的是6.5.2,也就是6.x的最后一个版本。经验证:

  • 7.0.0和6.5.2都有这个bug
  • 6.5.2可以运行在JDK8,7.0.0必须运行在JDK11及以上
本文基于JDK8+DozerMapper6.5.2分析。
问题简化和复现

将实际的传参简化如下。该类必须有无参数构造器,否则DozerMapper创建Bean时会报错。
  1. public  class ListObjectWrapper {
  2.     private List<Object> list;
  3.     public ListObjectWrapper() {
  4.     }
  5.     public ListObjectWrapper(List<Object> list) {
  6.         this.list = list;
  7.     }
  8.     public List<Object> getList() {
  9.         return list;
  10.     }
  11.     public void setList(List<Object> list) {
  12.         this.list = list;
  13.     }
  14. }
复制代码
对应的测试代码:
  1. public class Test {
  2.     public static void main(String[] args) {
  3.         Mapper mapper = DozerBeanMapperBuilder.buildDefault();
  4.         List<Object> list = new ArrayList<Object>();
  5.         list.add("123");
  6.         list.add(456);
  7.         list.add(null);
  8.         list.add(new Date());
  9.         ListObjectWrapper wrapper1 = new ListObjectWrapper(list);
  10.         ListObjectWrapper wrapper2 = mapper.map(wrapper1, ListObjectWrapper.class);
  11.         for (Object value : wrapper2.getList()) {
  12.             if(value == null) {
  13.                 System.out.println(value);
  14.                 continue;
  15.             }
  16.             System.out.println("type:" + value.getClass() + ", value=" + value);
  17.         }
  18.     }
  19. }
复制代码
可见,wrapper2的list里的元素全部变成了String:
1.png

问题定位

进行debug时,发现在对456调用primitiveConverter.convert()时,此时是知道该元素类型是Integer,调用的返回值却成了字符串:
2.png

深入一层,可以看到convert()做了两件事:先确认使用哪个Converter,然后由这个Converter进行实际的转换。这里暗藏了问题:取Converter时,没有用原始数据的实际类型信息,而是取的是Object(这里为什么是Object,接下来会继续深入探讨):
3.png

Object类型取不到对应的Converter,就由以下的分支判断,最后还是取不到,就是使用了StringConstructorConverter:
4.png

StringConstructorConverter的内部调用了StringConverter,实际上做的只不过是调用了toString(),因此456变成了"456"。
寻根究底

Ojbect类型是从哪里取的?

取Converter时,destFieldType=java.lang.Object,是怎么来的?直觉上,我认为是从List的类型参数上取的。再次从头debug,可以看到是addOrUpdateToList()设置的:
5.png

深入进去,可以看到在这个场景下取的是目标对象的Hint,而非原始值的类型:
6.png

目标对象的Hint是怎么生成的?

再次重新debug,回到相对上层的位置,可以看到这里设置的destHintContainer,genericType.getName()就是java.lang.Object:
7.png

跟着getGenericType()及后续的propertyDescriptor.genericType()再深入两层就可以看到,是从目标对象的写方法的入参上取到泛型的实际类型也就是java.lang.Ojbect的:
8.png

至此,原因已完整呈现。
引申1———给List褪去Bean的外衣

根据上面的分析,List如果直接做复制,应该也是有问题的?验证一把发现,确实是这样,依然有错误:
  1. public class Test1 {
  2.     public static void main(String[] args) {
  3.         Mapper mapper = DozerBeanMapperBuilder.buildDefault();
  4.         List<Object> list = new ArrayList<Object>();
  5.         list.add("123");
  6.         list.add(456);
  7.         list.add(null);
  8.         list.add(new Date());
  9.         List<Object> list2 = mapper.map(list, List.class);
  10.         for (Object value : list2) {
  11.             if(value == null) {
  12.                 System.out.println(value);
  13.                 continue;
  14.             }
  15.             System.out.println("type:" + value.getClass() + ", value=" + value);
  16.         }
  17.     }
  18. }
复制代码
9.png

公司的工具类单独写了一个List的复制方法mapList(),对List里的元素逐项调用DozerMapper。不过这个工具类里的方法仍然无法正确复制List,并且遇到null元素会报错。由于是内部的工具类,就不展开讨论了。
那么一开始为什么不直接用List做测试呢?潜意识中我给List套了一层,作为bean的成员变量复制的,回想起来可能是在上家公司养成的编程习惯。为什么这么说?可以看看后面的“替代方案调研1——BeanUtils”章节。
引申2——类的继承如何处理?

既然Object是Java里一切类的基类,一个存放了基类对象和继承类对象的容器是否能正确处理呢?根据直觉,应该是不能,实际情况也和直觉一样。读者可以用下面的代码自行验证:
  1. public class Test3 {
  2.     public static void main(String[] args) {
  3.         Mapper mapper = DozerBeanMapperBuilder.buildDefault();
  4.         List<Parent> list1 = new ArrayList<>();
  5.         list1.add(new Parent("张三"));
  6.         list1.add(new Child("张四", "张三"));
  7.         List<Parent> list2 = mapper.map(list1, List.class);
  8.         for (Parent p : list2) {
  9.             System.out.println("type:" + p.getClass() + ", name=" + p.getName());
  10.         }
  11.     }
  12.     public static class Parent {
  13.         private String name;
  14.         public Parent(String name) {
  15.             this.name = name;
  16.         }
  17.         public Parent() {
  18.         }
  19.         public String getName() {
  20.             return name;
  21.         }
  22.         public void setName(String name) {
  23.             this.name = name;
  24.         }
  25.     }
  26.     public static class Child extends Parent {
  27.         private String parentName;
  28.         public Child(String name, String parentName) {
  29.             super(name);
  30.             this.parentName = parentName;
  31.         }
  32.         public Child() {
  33.         }
  34.         public String getParentName() {
  35.             return parentName;
  36.         }
  37.         public void setParentName(String parentName) {
  38.             this.parentName = parentName;
  39.         }
  40.     }
  41. }
复制代码
执行结果提示,list2的两个对象都是Parent类型。
替代方案调研1——BeanUtils

我的前司有同事是用BeanUtils做对象复制的,同名工具类很多,这里的完整类名是org.apache.commons.beanutils.BeanUtils。
按照之前的讨论,继续做测试。BeanUtils有个麻烦的地方在于,你要手动编写它的异常处理代码:
点击查看代码
  1.         ListObjectWrapper wrapper3 = new ListObjectWrapper();
  2.         try {
  3.             BeanUtils.copyProperties(wrapper3, wrapper1);
  4.         } catch (Exception e) {
  5.             e.printStackTrace();
  6.         }
  7.         // 正确复制
  8.         System.out.println(wrapper3);
  9.         List<Object> list4 = new ArrayList<>();
  10.         try {
  11.             BeanUtils.copyProperties(list4, list);
  12.         } catch (Exception e) {
  13.             e.printStackTrace();
  14.         }
  15.         // 复制完list4是空的
  16.         System.out.println(list4);
复制代码
结果是,List本身不能直接被复制,调用后仍然是空的。但是如果它是一个bean的成员变量,就可以正确复制了。很神奇,这正好解释了我为什么在最初简化场景时要把List放在一个类中,或许是在前司工作的习惯使然?
替代方案调研2——MapStruct

在研究DozerMapper的问题和解决方案时,我看到有的文章提到MapStruct是DozerMapper的替代方案,并且速度也更快一些,因此做了一个简单的调研。
依赖处理

MapStruct官网上有一个简单的Demo,直接照搬是运行不起来的,要处理一些依赖。以maven为例,pom.xml要添加以下内容:

  • MapStruct依赖
  • 注解处理配置中添加mapstruct-processor
  • 如果项目使用了Lombok,还需要在注解处理配置中增加lombok的配置,否则可能Build失败
综合后如下:
pom.xml片段
  1. <dependencies>
  2.     <dependency>
  3.         <groupId>org.mapstruct</groupId>
  4.         mapstruct</artifactId>
  5.         <version>1.5.5.Final</version>
  6.     </dependency>
  7. </dependencies>
  8. <build>
  9.     <plugins>
  10.         <plugin>
  11.             <groupId>org.apache.maven.plugins</groupId>
  12.             maven-compiler-plugin</artifactId>
  13.             <version>3.13.0</version>
  14.             <configuration>
  15.                 <source>17</source>
  16.                 <target>17</target>
  17.                
  18.                     
  19.                     <path>
  20.                         <groupId>org.projectlombok</groupId>
  21.                         lombok</artifactId>
  22.                         <version>1.18.32</version>
  23.                     </path>
  24.                     
  25.                     <path>
  26.                         <groupId>org.mapstruct</groupId>
  27.                         mapstruct-processor</artifactId>
  28.                         <version>1.5.5.Final</version>
  29.                     </path>
  30.                 </annotationProcessorPaths>
  31.             </configuration>
  32.         </plugin>
  33.     </plugins>
  34. </build>
复制代码
依赖是否配置正确,可以通过以下两步验证:

  • 编译是否通过
  • 编译完成后,target/gnerated-souces/annotations下是否有编写的接口对应的实现
Mapper和测试代码

由于被测的类比较简单,不需要做转换前后的字段映射,因此对应Mapper也很简单:
点击查看ListObjectWrapperMapper
  1. @Mapper
  2. public interface ListObjectWrapperMapper {
  3.     ListObjectWrapperMapper INSTANCE = Mappers.getMapper(ListObjectWrapperMapper.class);
  4.     ListObjectWrapper map(ListObjectWrapper wrapper);
  5. }
复制代码
对应地,测试代码如下:
点击查看代码
  1. public class Test2 {
  2.     public static void main(String[] args) {
  3.         List<Object> list = new ArrayList<>();
  4.         list.add("123");
  5.         list.add(456);
  6.         list.add(null);
  7.         list.add(new Date());
  8.         ListObjectWrapper wrapper1 = new ListObjectWrapper(list);
  9.         ListObjectWrapper wrapper2 = ListObjectWrapperMapper.INSTANCE.map(wrapper1);
  10.         for (Object value : wrapper2.getList()) {
  11.             if(value == null) {
  12.                 System.out.println(value);
  13.                 continue;
  14.             }
  15.             System.out.println("type:" + value.getClass() + ", value=" + value);
  16.         }
  17.     }
  18. }
复制代码
运行时可见,List中的元素按照原本的类型被复制了过去:
10.png

如果转换前后的类,字段不同名,可以用@Mapping来指定。MapStruct的编程接口是比较丰富且强大的,读者可以自行研究。
那么,参考“引申2————类的继承如何处理?”这一节,使用MapStruct是否能正确映射呢?答案是肯定的,新的List里两个元素类型分别是Parent和Child:
11.png

小结


  • 当目标容器的泛型类型参数是Object类型时,或者当容器中存放了泛型类型参数的子类对象时,DozerMapper的默认用法无法按照元素的实际类型正确地处理
  • BeanUtils可以正确复制成员变量包括List的对象,但是不能直接复制List本身;此外还要做异常处理
  • MapStruct作为DozerMapper的替换时,可以正确处理第一种情况的转换,不过用法显然不如DozerMapper简单:必须编写转换接口、明确入参和返回值的转换方法。但优点是有丰富而强大的相关注解,可以通过注解指定不同名和类型的字段映射。







来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
您需要登录后才可以回帖 登录 | 立即注册