Easy-mapper – 一个灵活可扩展的高性能Bean mapping类库

1 背景

做Java开发都避免不了和各种Bean打交道,包括POJO、BO、VO、PO、DTO等,而Java的应用非常讲究分层的架构,因此就会存在对象在各个层次之间作为参数或者输出传递的过程,这里转换的工作往往非常繁琐。
 
这里举个例子,做过Java的都会深有体会,下面代码的set/get看起来不那么优雅 🙁
ElementConf ef = new ElementConf();
ef.setTplConfId(tplConfModel.getTplConfIdKey());
ef.setTemplateId(tplConfModel.getTemplateId());
ef.setBlockNo(input.getBlockNo());
ef.setElementNo(input.getElementNo());
ef.setElementName(input.getElementName());
ef.setElementType(input.getElementType());
ef.setValue(input.getValue());
ef.setUseType(input.getUseType());
ef.setUserId(tplConfModel.getUserId());
为此业界有很多开源的解决方案,列出一些常见的如下:
 
这些框架在使用中或多或少都会存在一些问题:
1、扩展性不高,例如自定义的属性转换往往不太方便。
2、属性名相同、类型不匹配或者类型匹配、属性名不同,不能很好的支持。
3、不支持Java8的lambda表达式。
4、一些框架性能不佳,例如Apache的两个和Dozer(BeanCopier使用ASM字节码生成技术,性能会非常好)。
5、对象的clone拷贝往往并不是使用者需要的,一般场景引用拷贝即可满足要求。
 
那么,为了解决或者优化这些问题,类库easy-mapper就应运而生。
 
 

2 Easy-mapper特点

1、扩展性强。基于SPI技术,对于各种类型之间的转换提供默认的策略,使用者可自行添加。
2、性能高。使用Javassist字节码增强技术,在运行时动态生成mapping过程的源代码,并且使用缓存技术,一次生成后续直接使用。默认策略为基于引用拷贝,因此在Java分层的架构中可以避免对象拷贝的代价,当然这有违背于函数式编程的不可变特性,easy-mapper赞同不可变,这里只不过提供了一种选择而已,请开放兼并。
3、映射灵活。源类型和目标类型属性名可以指定,支持Java8 lambda表达式的转换函数,支持排除属性,支持全局的自定义mapping。
4、代码可读高。基于Fluent式API,链式风格。惰性求值的方式,可随意注册映射关系,最后再统一做映射。
 
 

3 获取Easy-mapper

项目托管在github上,地址点此https://github.com/neoremind/easy-mapper。使用Apache2 License开源。
最新发布的Jar包可以在maven中央仓库找到,地址点此
 
 

4 上手

4.1 引入依赖

Maven:
<dependency>
    <groupId>com.baidu.unbiz</groupId>
    <artifactId>easy-mapper</artifactId>
    <version>1.0.4</version>
</dependency>
Gradle:
compile 'com.baidu.unbiz:easy-mapper:1.0.4'
注:最新release请及时参考github
 

4.2 开发Java Bean

POJO如下:
public class Person {
    private String firstName;
    private String lastName;
    private List<String> jobTitles;
    private long salary;
    // getter and setter...
}
DTO(Data Transfer Object)如下:
public class PersonDto {
    private String firstName;
    private String lastName;
    private List<String> jobTitles;
    private long salary;
    // getter and setter...
}

4.3 映射之Helloworld

从POJO到DTO的映射如下, 

Person p = new Person();
p.setFirstName("NEO");
p.setLastName("jason");
p.setJobTitles(Lists.newArrayList("abc", "dfegg", "iii"));
p.setSalary(1000L);
PersonDto dto = MapperFactory.getCopyByRefMapper()
			    .mapClass(Person.class, PersonDto.class)
			    .registerAndMap(p, PersonDto.class);
System.out.println(dto);

 

 

5 深入实践

5.1 注册和映射分开

helloworld中使用了registerAndMap(..)方法,其实可以分开使用,register只是让easy-mapper去解析属性并生成代码,一旦生成即缓存,然后随时map。 
PersonDto dto = MapperFactory.getCopyByRefMapper().mapClass(Person.class, PersonDto.class)
                .register()
                .map(p, PersonDto.class);
先注册,拿到mapper,再映射。
PersonDto dto = MapperFactory.getCopyByRefMapper().mapClass(Person.class, PersonDto.class)
                .register()
Mapper mapper = MapperFactory.getCopyByRefMapper();
PersonDto dto = mapper.map(p, PersonDto.class);
先注册,拿到mapper直接映射。

PersonDto dto = MapperFactory.getCopyByRefMapper().mapClass(Person.class, PersonDto.class)
                .register()
Mapper mapper = MapperFactory.getCopyByRefMapper().map(p, PersonDto.class);

5.2 指定属性名称

PersonDto dto = MapperFactory.getCopyByRefMapper().mapClass(Person.class, PersonDto.class)
                .field("salary", "salary")
                .register()
                .map(p, PersonDto.class);

5.3 忽略某个属性

从源类型中排查某个属性,不做映射。
PersonDto dto = MapperFactory.getCopyByRefMapper().mapClass(Person.class, PersonDto.class)
                .exclude("lastName")
                .register()
                .map(p, PersonDto.class);

5.4 自定义属性转换

使用Transformer接口。
PersonDto6 dto = new PersonDto6();
MapperFactory.getCopyByRefMapper().mapClass(Person6.class, PersonDto6.class)
        .field("jobTitles", "jobTitles", new Transformer<List<String>, List<Integer>>() {
            @Override
            public List<Integer> transform(List<String> source) {
                return Lists.newArrayList(1, 2, 3, 4);
            }
        })
        .register()
        .map(p, dto);
Java8的lambda表达式使用方式如下。
PersonDto dto = MapperFactory.getCopyByRefMapper().mapClass(Person.class, PersonDto.class)
                    .field("firstName", "firstName", (String s) -> s.toLowerCase())
                    .register()
                    .map(p, PersonDto.class);
Java8的stream方式如下。
PersonDto dto = MapperFactory.getCopyByRefMapper().mapClass(Person.class, PersonDto.class)
                    .field("jobTitles", "jobTitleLetterCounts",
                            (List<String> s) -> s.stream().map(String::length).toArray(Integer[]::new))
                    .register()
                    .map(p, PersonDto.class);
如果指定了属性了类型,那么lambda表达式则不用写类型,Java编译器可以推测。
MapperFactory.getCopyByRefMapper().mapClass(Person.class, PersonDto.class)
                    .field("firstName", "firstName", String.class, String.class, s -> s.toLowerCase())
                    .register()
                    .map(p, PersonDto.class);

5.5 自定义额外的全局转换

AtoBMapping接口做源对象到目标对象的转换。
PersonDto6 dto = new PersonDto6();
MapperFactory.getCopyByRefMapper().mapClass(Person6.class, PersonDto6.class)
        .customMapping((a, b) -> b.setLastName(a.getLastName().toUpperCase()))
        .register()
        .map(p, dto);

5.6 映射已经新建的对象

registerAndMap和map方法的第二个参数支持Class,同时也支持已经新建好的对象。如果传入Class,则使用反射新建一个对象再赋值,目标对象可以没有默认构造方法,框架会努力寻找一个最合适的构造方法构造。
PersonDto dto = new PersonDto();
MapperFactory.getCopyByRefMapper().mapClass(Person.class, PersonDto.class).registerAndMap(p, dto);

5.7 源属性为空是否映射

如果源属性为空,那么默认则不映射到目标属性,可以强制赋空。 
PersonDto dto = MapperFactory.getCopyByRefMapper().mapClass(Person.class, PersonDto.class)
                    .mapOnNull(true)
                    .register()
                    .map(p, PersonDto.class);

5.8 级联映射

如果Person类型中有Address,而PersonDto类型中有Address2,那么需要首先映射下,如下所示。 
MapperFactory.getCopyByRefMapper().mapClass(Address.class, Address2.class).register();
Person p = getPerson(); 
p.setAddress(new Address("beverly hill", 10086));
PersonDto dto = MapperFactory.getCopyByRefMapper()
					.mapClass(Person.class, PersonDto.class)
					.register()
					.map(p, PersonDto.class);
如果没有提前注册,那么会抛出如下异常:
com.baidu.unbiz.easymapper.exception.MappingException: No class map found for (Address, Address2), make sure type or nested type is registered beforehand

5.9 输出生产的源代码

可指定log的level为debug,则会在console输出生成的源代码。
另外,可在环境变量中指定如下参数,输出源代码或者编译后的class文件到本地文件系统。 
-Dcom.baidu.unbiz.easymapper.enableWriteSourceFile=true 
-Dcom.baidu.unbiz.easymapper.writeSourceFileAbsolutePath="..."
-Dcom.baidu.unbiz.easymapper.enableWriteClassFile=true 
-Dcom.baidu.unbiz.easymapper.writeClassFileAbsolutePath="..."
 

6 框架映射规则

默认使用SPI技术加载框架预置的属性处理器。
在META-INF/services/com.baidu.unbiz.easymapper.mapping.MappingHandler文件中,规则优先级由高到低如下:
1、指定了Transformer,则用自定义的transformer。
2、属性类型相同,则直接按引用拷贝赋值;primitive以及wrapper类型,直接使用“=”操作符赋值。
3、如果目标属性类型是String,那么尝试源对象直接调用toString()方法映射。
4、如果源属性是目标属性的子类,则直接引用拷贝。
5、如果是其他情况,则级联的调用mapper.map(..),注意框架未处理dead cycle的情况。
 
最后,如果5仍然不能完成映射,那么框架会抛出如下异常:
com.baidu.unbiz.easymapper.exception.MappingCodeGenerationException: No appropriate mapping strategy found for FieldMap[jobTitles(List<string>)-->jobTitles(List<integer>)] 
... 
com.baidu.unbiz.easymapper.exception.MappingException: Generating mapping code failed for ClassMap([A]:Person6, [B]:PersonDto6), this should not happen, probably the framework could not handle mapping correctly based on your bean.
 
注意,关于目标类实例的新建,默认会优先选择无参构造函数,但是如果目标类没有默认无参构造函数,那么就会选择通过反射方法得到的第一个构造函数,如果是primitive类型就赋值默认值,如果是复杂类型,就赋值null。
 
 

7、初始化

 默认无需任何初始工作,但是如果遇到如下异常:

 

Caused by: com.baidu.unbiz.easymapper.exception.MappingException: No class map found for (String, String), make sure type or nested type is registered beforehand

那是因为easy-mapper依赖于SPI Service Provider技术,这个是非线程安全的,所以在第一次初始化失败以后就不会恢复了。长期来看,需要替换掉SPI,但是短期的workaround是在程序初始化的时候执行, ServiceLoaderHelper.getInstance(); 例如,在Spring初始化的时候执行。

public class CustomContextLoaderListener extends ContextLoaderListener {
    static {
        ServiceLoaderHelper.getInstance();
    }
}

 

8、框架依赖类库

+- org.slf4j:slf4j-api:jar:1.7.7:compile
+- org.slf4j:slf4j-log4j12:jar:1.7.7:compile
|  \- log4j:log4j:jar:1.2.17:compile
+- org.javassist:javassist:jar:3.18.1-GA:compile
  

9、性能测试报告

以下测试基于Oracal Hotspot JVM,参数如下:
java version "1.8.0_51"
Java(TM) SE Runtime Environment (build 1.8.0_51-b16)
Java HotSpot(TM) 64-Bit Server VM (build 25.51-b03, mixed mode)

-Xmx512m -Xms512m -XX:MetaspaceSize=256m
 
首先充分预热,各个框架,各调用一次,然后再进行benchmark。
测试机器配置如下:
CPU: Intel(R) Core(TM) i5-4278U CPU @ 2.60GHz
MEM: 8G
 
测试代码见链接BenchmarkTest.java
-------------------------------------
| Create object number:   10000      |
-------------------------------------
|     Framework     |    time cost   |
-------------------------------------
|      Pure get/set |      11ms      |
|       Easy mapper |      44ms      |
|  Cglib beancopier |       7ms      |
|         BeanUtils |     248ms      |
|     PropertyUtils |     129ms      |
|  Spring BeanUtils |      95ms      |
|             Dozer |     772ms      |
-------------------------------------
-------------------------------------
| Create object number:  100000      |
-------------------------------------
|     Framework     |    time cost   |
-------------------------------------
|      Pure get/set |      56ms      |
|       Easy mapper |     165ms      |
|  Cglib beancopier |      30ms      |
|         BeanUtils |     921ms      |
|     PropertyUtils |     358ms      |
|  Spring BeanUtils |     152ms      |
|             Dozer |    1224ms      |
-------------------------------------
-------------------------------------
| Create object number: 1000000      |
-------------------------------------
|     Framework     |    time cost   |
-------------------------------------
|      Pure get/set |     189ms      |
|       Easy mapper |     554ms      |
|  Cglib beancopier |      48ms      |
|         BeanUtils |    4210ms      |
|     PropertyUtils |    4386ms      |
|  Spring BeanUtils |     367ms      |
|             Dozer |    6319ms      |
-------------------------------------
 
结论:
首先基于大量的反射技术的Apache的两个工具BeanUtils和PropertyUtils性能均不理想,Dozer的性能则更为不好。
其次,基于ASM字节码增强技术的Cglib库真是经久不衰,性能在各个场景下均表现非常突出,甚至好于纯手写的get/set。
最后,在调用10,000次时,easy-mapper好于Spring的BeanUtils,100,000次时持平,但是达到1,000,000次时,则落后。由于Spring BeanUtils非常的简单,采用了反射技术Method.invoke(..)做赋值处理,一般现代编译器都会对“热点”代码做优化,如R神的《关于反射调用方法的一个log》提到的,可以看出超过一定调用次数后,基于profiling信息,JIT同样可以对反射做自适应的代码优化,这里对Method.invoke(..)在调动超过一定次数时会转为代理类来做实现,而不是调用native方法,因此JIT就可以做很多dereflection的事情优化性能,因此Spring的BeanUtils性能也不差。
可以看出相比于老派的框架,easy-mapper性能非常优秀,虽然和Cglib BeanCopier有差距,这也可以看出使用Javassist的source level的API来做字节码操作性能肯定不会优于直接用ASM,但是easy-mapper的特点在于灵活、可扩展性、良好的编程体验方面,因此从这个tradeoff来看,easy-mapper非常适用于生产环境和工业界,而Cglib可用于一些对性能非常考究的框架内使用。
 
 

10、与高阶函数搭配使用

guava一起使用做集合的转换。 
MapperFactory.getCopyByRefMapper().mapClass(Address.class, Address2.class).register();
MapperFactory.getCopyByRefMapper().mapClass(Person.class, PersonDto.class).register();
List<Person> personList = getPersonList();
Collection<PersonDto> personDtoList = Collections2.transform(personList,
        p -> MapperFactory.getCopyByRefMapper().map(p, PersonDto.class));
System.out.println(personDtoList);
functional java一起使用做集合的转换。
MapperFactory.getCopyByRefMapper().mapClass(Address.class, Address2.class).register();
MapperFactory.getCopyByRefMapper().mapClass(Person.class, PersonDto.class).register();
List<Person> personList = getPersonList();
fj.data.List<PersonDto> personDtoList = fj.data.List.fromIterator(personList.iterator()).map(
        person -> MapperFactory.getCopyByRefMapper().map(person, PersonDto.class));
personDtoList.forEach(e -> System.out.println(e));
和Java8的stream API的配合做map。
MapperFactory.getCopyByRefMapper().mapClass(Address.class, Address2.class).register();
MapperFactory.getCopyByRefMapper().mapClass(Person.class, PersonDto.class).register();
List<Person> personList = getPersonList();
List<PersonDto> personDtoList = personList.stream().map(p -> MapperFactory.getCopyByRefMapper().map(p,
        PersonDto.class)).collect(Collectors.toList());
在Scala中使用
object EasyMapperTest {
 
  def main(args: Array[String]) {
    MapperFactory.getCopyByRefMapper.mapClass(classOf[Person], classOf[PersonDto]).register
    val personList = List(
      new Person("neo1", 100),
      new Person("neo2", 200),
      new Person("neo3", 300)
    )
    val personDtoList = personList.map(p => MapperFactory.getCopyByRefMapper.map(p, classOf[PersonDto]))
    personDtoList.foreach(println)
  }
 
}
 
转载时请注明转自neoremind.com

25 Comments on this Post.

  1. Xukun

    非常酷的mapper解决方案。

  2. dishuiGit

    忽略null属性的拷贝,相当有用

  3. “No class map found for (String, Integer), make sure type or nested type is registered beforehand”
    这个提示,能不能改的更友好一点,说明是在mapper 哪个字段的时候?我现在有一个很复杂的类,都找不到是哪个字段出了问题了。

  4. neo

    好的,谢谢指出问题,我会考虑改进的。

  5. doit

    你好,我看了看beanutils和spring beanutils的源码,发现他们最终都是采用了反射技术Method.invoke(..)做赋值处理,为什么beanutils比spring beanutils慢很多呢?

  6. ccnucj2016

    您好,我报这个错,请问是什么原因?
    java.lang.IllegalAccessError: tried to access class com.chinatelecom.auth.utils.A from class com.baidu.unbiz.generated.EasyMapper_A_TO_B_Mapper25785806586882$0

  7. pauliz

    不能映射复制对象啊,persona.aa->personb.bb.cc,怎么弄

  8. neo

    能提供更多上下文吗?粗看这种情况可以通过使用custom mapper某个某字段解决。

  9. neo

    可以提供测试用例和提供stack trace吗?

  10. neo

    你好,可以看看他们的源代码,spring的非常简单,代码量很小,是spring内部使用的一个小feature,而beanutil是一个大而全的类库,里面的逻辑更加复杂。

  11. pauliz

    哦,我说的是复杂对象,不能简单的使用类似这样的语句,custom mapper太复杂,和自己写getset没啥区别了
    PersonDto dto = MapperFactory.getCopyByRefMapper().mapClass(Person.class, PersonDto.class)
    .field(“name.chineseName.xing”, “lastName”)
    .field(“name.chineseName.ming”, “firstName”)
    .register()
    .map(p, PersonDto.class);

  12. 安利一个基于EasyMapper的工具包,提供更简单的使用方式,项目中有完整的使用教程和单元测试
    https://github.com/drtrang/Copiers

  13. neo

    欢迎欢迎!

  14. 请问map转bean怎么调用

  15. neo

    你好,map转bean的话可以考虑apache util的bean utils,或者其他类库,easy-mapper适用于bean到bean的场景。

  16. jim

    你好,请问下同一个方法里:
    TestDomain testDomain = MapperFactory.getCopyByRefMapper().mapClass(TestInvo.class, TestDomain.class)
    .exclude(“test1″,”test2″,”test3”)
    .register()
    .map(test, TestDomain.class);
    TestDomain testDomain2 = MapperFactory.getCopyByRefMapper().mapClass(TestInvo.class, TestDomain.class)
    .register()
    .map(test, TestDomain.class);
    testDomain2取到的也是testDomain(去除了部分属性)的值,这个要如何处理呢?

  17. neo

    你的问题应该是“testDomain2取到的也是test(去除了部分属性)的值”吧?因为easy-mapper是copy by ref,所以出deep copy的类库不太一样,可以参考https://github.com/neoremind/easy-mapper#5-benchmark 部分的其他深拷贝类库使用,easy-mapper暂时不处理这种类型的映射。

  18. dw

    请问何时能够支持Enum

  19. neo

    你好,目前是支持enum的,可以参考test目录下的测试用例(在README中有包含。)

  20. https://github.com/orika-mapper/orika 这个性能也不错. btw, 性能测试没有考虑预热吧, 为什么不用JMH来做呢?

  21. neo

    借鉴了orika做的,所以是个补充精华。性能测试经过了预热。当时没用JMH。

  22. 我们公司经常遇到一个实体类后面因为业务需要增加一个或多个字段,一旦有地方使用了BeanCopy之后,就往往会很难维护那些被Copy后的目标类,经常会导致各种问题,因而公司禁止使用BeanCopy之类的工具类,改由一个一个get/set。请教大家有没好的解决思路?

  23. neo

    没有太get到问题的点,可以详细解释下吗?

  24. ly

    你这个作品的授权是非商业授权的,也就是只能用于学习或开源产品了?这样的话,其实没多少人能用了吧

  25. neo

    Apache License 2.0协议

Leave a Comment.