在学习阿里巴巴java开发手册的时候,第一次遇见MapStruct,我个人非常开心,因为于我内心的想法不谋而合。
按照日常开发习惯,对于不同领域层使用不同JavaBean对象传输数据,避免相互影响,因此基于数据库实体对象User衍生出比如UserDto、UserVo等对象,于是在不同层之间进行数据传输时,不可避免地需要将这些对象进行互相转换操作。
常见的转换方式有:
- 调用getter/setter方法进行属性赋值
- 调用BeanUtil.copyPropertie进行反射属性赋值
第一种方式不必说,属性多了就需要写一大坨getter/setter代码。第二种方式比第一种方式要简便很多,但是坑巨多,比如sources与target写反,难以定位某个字段在哪里进行的赋值,同时因为用到反射,导致性能也不佳。
鉴于此,今天写一写第三种对象转换方式,本文使用的是 MapStruct 工具进行转换,MapStruct 原理也很简单,就是在代码编译阶段生成对应的赋值代码,底层原理还是调用getter/setter方法,但是这是由工具替我们完成,MapStruct在不影响性能的情况下,解决了前面两种方式弊端,很赞~
对于代码中 JavaBean
之间的转换, 一直是困扰我很久的事情。 在开发的时候我看到业务代码之间有很多的 JavaBean
之间的相互转化, 非常的影响观感, 却又不得不存在。 我后来想的一个办法就是通过反射, 或者自己写很多的转换器。
第一种通过反射的方法确实比较方便, 但是现在无论是 BeanUtils
, BeanCopier
等在使用反射的时候都会影响到性能。 虽然我们可以进行反射信息的缓存来提高性能。 但是像这种的话, 需要类型和名称都一样才会进行映射, 有很多时候, 由于不同的团队之间使用的名词不一样, 还是需要很多的手动 set/get 等功能。
第二种的话就是会很浪费时间, 而且在添加新的字段的时候也要进行方法的修改。 不过, 由于不需要进行反射, 其性能是很高的。
MapStruct
带来的改变MapSturct
是一个生成类型安全, 高性能且无依赖的 JavaBean 映射代码的注解处理器(annotation processor)。
抓一下重点:
JavaBean
之间那的映射代码从字面的理解, 我们可以知道, 该工具可以帮我们实现 JavaBean
之间的转换, 通过注解的方式。
同时, 作为一个工具类,相比于手写, 其应该具有便捷, 不容易出错的特点。
MapStruct
入门入门很简单。 我是基于 Maven
来进行项目 jar 包管理的。
<properties>
<org.mapstruct.version>1.3.0.Finalorg.mapstruct.version>
properties>
<dependency>
<groupId>org.mapstructgroupId>
<artifactId>mapstruct-jdk8artifactId>
<version>${org.mapstruct.version}version>
dependency>
<dependency>
<groupId>org.mapstructgroupId>
<artifactId>mapstruct-processorartifactId>
<version>${org.mapstruct.version}version>
dependency>
该类是从 github 某个订单系统里面拿下来的部分。
@Data
public class Order {
/**
*订单id
*/
private Long id;
/**
* 订单编号
*/
private String orderSn;
/**
* 收货人姓名/号码
*/
private String receiverKeyword;
/**
* 订单状态:0->待付款;1->待发货;2->已发货;3->已完成;4->已关闭;5->无效订单
*/
private Integer status;
/**
* 订单类型:0->正常订单;1->秒杀订单
*/
private Integer orderType;
/**
* 订单来源:0->PC订单;1->app订单
*/
private Integer sourceType;
}
对应的查询参数
@Data
public class OrderQueryParam {
/**
* 订单编号
*/
private String orderSn;
/**
* 收货人姓名/号码
*/
private String receiverKeyword;
/**
* 订单状态:0->待付款;1->待发货;2->已发货;3->已完成;4->已关闭;5->无效订单
*/
private Integer status;
/**
* 订单类型:0->正常订单;1->秒杀订单
*/
private Integer orderType;
/**
* 订单来源:0->PC订单;1->app订单
*/
private Integer sourceType;
}
Mapper
即映射器, 一般来说就是写 xxxMapper
接口。 当然, 不一定是以 Mapper
结尾的。 只是官方是这么写的。 在本入门例子中,对应的接口如下
import com.homejim.mapstruct.dto.OrderQueryParam;
import com.homejim.mapstruct.entity.Order;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper
public interface OrderMapper {
OrderQueryParam entity2queryParam(Order order);
}
简单的映射(字段和类型都匹配), 只有一个要求, 在接口上写 @Mapper
注解即可。 然后方法上, 入参对应要被转化的对象, 返回值对应转化后的对象, 方法名称可任意。
写一个测试类测试一下。
@Test
public void entity2queryParam() {
Order order = new Order();
order.setId(12345L);
order.setOrderSn("orderSn");
order.setOrderType(0);
order.setReceiverKeyword("keyword");
order.setSourceType(1);
order.setStatus(2);
OrderMapper mapper = Mappers.getMapper(OrderMapper.class);
OrderQueryParam orderQueryParam = mapper.entity2queryParam(order);
assertEquals(orderQueryParam.getOrderSn(), order.getOrderSn());
assertEquals(orderQueryParam.getOrderType(), order.getOrderType());
assertEquals(orderQueryParam.getReceiverKeyword(), order.getReceiverKeyword());
assertEquals(orderQueryParam.getSourceType(), order.getSourceType());
assertEquals(orderQueryParam.getStatus(), order.getStatus());
}
测试通过, 没有任何的问题。
上面中, 我写了3个步骤来实现了从 Order
到 OrderQueryParam
的转换。
那么, 作为一个注解处理器, 通过MapStruct
生成的代码具有怎么样的优势呢?
这是相对反射来说的, 反射需要去读取字节码的内容, 花销会比较大。 而通过 MapStruct
来生成的代码, 其类似于人手写。 速度上可以得到保证。
前面例子中生成的代码可以在编译后看到。 在 target/generated-sources/annotations 里可以看到。
对应的代码
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2019-08-02T00:29:49+0800",
comments = "version: 1.3.0.Final, compiler: javac, environment: Java 11.0.2 (Oracle Corporation)"
)
public class OrderMapperImpl implements OrderMapper {
@Override
public OrderQueryParam entity2queryParam(Order order) {
if ( order == null ) {
return null;
}
OrderQueryParam orderQueryParam = new OrderQueryParam();
orderQueryParam.setOrderSn( order.getOrderSn() );
orderQueryParam.setReceiverKeyword( order.getReceiverKeyword() );
orderQueryParam.setStatus( order.getStatus() );
orderQueryParam.setOrderType( order.getOrderType() );
orderQueryParam.setSourceType( order.getSourceType() );
return orderQueryParam;
}
}
可以看到其生成了一个实现类, 而代码也类似于我们手写, 通俗易懂。
在我们生成的代码中, 我们可以轻易的进行 debug。
在使用反射的时候, 如果出现了问题, 很多时候是很难找到是什么原因的。
如果是完全映射的, 使用起来肯定没有反射简单。 用类似 BeanUtils
这些工具一条语句就搞定了。 但是,如果需要进行特殊的匹配(特殊类型转换, 多对一转换等), 其相对来说也是比较简单的。
基本上, 使用的时候, 我们只需要声明一个接口, 接口下写对应的方法, 就可以使用了。 当然, 如果有特殊情况, 是需要额外处理的。
生成的代码是对立的, 没有运行时的依赖。
MapStruct
使用情景。在实现类的时候, 如果属性名称相同, 则会进行对应的转化。这个在之前的文章代码中已经有所体现。 通过此种方式, 我们可以快速的编写出转换的方法。
源对象类
import lombok.Data;
@Data
public class Source {
private String id;
private Integer num;
}
目标对象类
import lombok.Data;
@Data
public class Target {
private String id;
private Integer num;
}
转化类
@Mapper
public interface SourceMapper {
SourceMapper INSTANCE = Mappers.getMapper(SourceMapper.class);
Target source2target(Source source);
}
由于 Source
和 Target
需要转化的属性是完全相同的。因此, 在 Mapper
中, source2target
方法很快就可以编写出来了。 只需要确定入参和返回值即可。
@Mapping
注解进行指定转化。属性名不相同, 在需要进行互相转化的时候, 则我们可以通过 @Mapping
注解来进行转化。
在上面的 Source
类中, 增加一个属性 totalCount
@Data
public class Source {
private String id;
private Integer num;
private Integer totalCount;
}
而对应的 Target
中, 定义的属性是 count
。
@Data
public class Target {
private String id;
private Integer num;
private Integer count;
}
如果方法没做任何的改变, 那么,在转化的时候, 由于属性名称不相同, 会导致 count 属性没有值。
这时候, 可以通过 @Mappimg
的方式进行映射。
@Mapper
public interface SourceMapper {
SourceMapper INSTANCE = Mappers.getMapper(SourceMapper.class);
@Mapping(source = "totalCount", target = "count")
Target source2target(Source source);
}
仅仅是在方法上面加了一行。再次允许测试程序。
有时候, 对于某些类型, 无法通过代码生成器的形式来进行处理。 那么, 就需要自定义的方法来进行转换。 这时候, 我们可以在接口(同一个接口, 后续还有调用别的 Mapper
的方法)中定义默认方法(Java8及之后)。
在 Source
类中增加
private SubSource subSource;
对应的类
import lombok.Data;
@Data
public class SubSource {
private Integer deleted;
private String name;
}
相应的, 在 Target
中
private SubTarget subTarget;
对应的类
import lombok.Data;
@Data
public class SubTarget {
private Boolean result;
private String name;
}
然后在 SourceMapper
中添加方法及映射, 对应的方法更改后
@Mapper
public interface SourceMapper {
SourceMapper INSTANCE = Mappers.getMapper(SourceMapper.class);
@Mapping(source = "totalCount", target = "count")
@Mapping(source = "subSource", target = "subTarget")
Target source2target(Source source);
default SubTarget subSource2subTarget(SubSource subSource) {
if (subSource == null) {
return null;
}
SubTarget subTarget = new SubTarget();
subTarget.setResult(!subSource.getDeleted().equals(0));
subTarget.setName(subSource.getName()==null?"":subSource.getName()+subSource.getName());
return subTarget;
}
}
进行测试
我们在实际的业务中少不了将多个对象转换成一个的场景。 MapStruct 当然也支持多转一的操作。
有 Address
和 Person
两个对象。
import lombok.Data;
@Data
public class Address {
private String street;
private int zipCode;
private int houseNo;
private String description;
}
@Data
public class Person {
private String firstName;
private String lastName;
private int height;
private String description;
}
而在实际的使用时, 我们需要的是 DeliveryAddress
类
import lombok.Data;
@Data
public class DeliveryAddress {
private String firstName;
private String lastName;
private int height;
private String street;
private int zipCode;
private int houseNumber;
private String description;
}
其对应的信息不仅仅来自一个类, 那么, 我们也可以通过配置来实现多到一的转换。
@Mapper
public interface AddressMapper {
AddressMapper INSTANCE = Mappers.getMapper(AddressMapper.class);
@Mapping(source = "person.description", target = "description")
@Mapping(source = "address.houseNo", target = "houseNumber")
DeliveryAddress personAndAddressToDeliveryAddressDto(Person person, Address address);
}
测试
在多对一转换时, 遵循以下几个原则
@Mapping
注解来具体的指定, 以免出现歧义(不指定会报错)。 如上面的 description
属性也可以直接从传入的参数来赋值。
Mapping(source = "person.description", target = "description")
@Mapping(source = "hn", target = "houseNumber")
DeliveryAddress personAndAddressToDeliveryAddressDto(Person person, Integer hn);
在上面的例子中, hn
直接赋值给 houseNumber
。
有时候, 我们不是想返回一个新的 Bean 对象, 而是希望更新传入对象的一些属性。这个在实际的时候也会经常使用到。
在 AddressMapper
类中, 新增如下方法
/**
* Person->DeliveryAddress, 缺失地址信息
* @param person
* @return
*/
DeliveryAddress person2deliveryAddress(Person person);
/**
* 更新, 使用 Address 来补全 DeliveryAddress 信息。 注意注解 @MappingTarget
* @param address
* @param deliveryAddress
*/
void updateDeliveryAddressFromAddress(Address address,
@MappingTarget DeliveryAddress deliveryAddress);
注解 @MappingTarget
后面跟的对象会被更新。 以上的代码可以通过以下的测试。
@Test
public void updateDeliveryAddressFromAddress() {
Person person = new Person();
person.setFirstName("first");
person.setDescription("perSonDescription");
person.setHeight(183);
person.setLastName("homejim");
DeliveryAddress deliveryAddress = AddressMapper.INSTANCE.person2deliveryAddress(person);
assertEquals(deliveryAddress.getFirstName(), person.getFirstName());
assertNull(deliveryAddress.getStreet());
Address address = new Address();
address.setDescription("addressDescription");
address.setHouseNo(29);
address.setStreet("street");
address.setZipCode(344);
AddressMapper.INSTANCE.updateDeliveryAddressFromAddress(address, deliveryAddress);
assertNotNull(deliveryAddress.getStreet());
}
在上面的例子中, 我们都是通过 Mappers.getMapper(xxx.class)
的方式来进行对应 Mapper
的获取。 此种方法为通过 Mapper 工厂获取。
如果是此种方法, 约定俗成的是在接口内定义一个接口本身的实例 INSTANCE
, 以方便获取对应的实例。
@Mapperpublic interface SourceMapper { SourceMapper INSTANCE = Mappers.getMapper(SourceMapper.class); // ......}
这样在调用的时候, 我们就不需要在重复的去实例化对象了。类似下面
Target target = SourceMapper.INSTANCE.source2target(source);
对于 Web 开发, 依赖注入应该很熟悉。 MapSturct
也支持使用依赖注入, 同时也推荐使用依赖注入。
值 | 注入方式 |
---|---|
default | 默认的方式, 使用 Mappers.getMapper(Class) 来进行获取 Mapper |
cdi | Contexts and Dependency Injection. 使用此种方式, 需要使用 @Inject 来进行注入 |
spring | Spring 的方式, 可以通过 @Autowired 来进行注入 |
jsr330 | 生成的 Mapper 中, 使用 @javax.inject.Named 和 @Singleton 注解, 通过 @Inject 来注入 |
可以选择是通过构造方法或者属性注入, 默认是属性注入。
package com.immoc.sell.demo;
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;
/**
* @author: allen
* @date: 2020/3/23 10:00
* @version: 1.0
*/
@Mapper(componentModel = "spring")
public interface PersonMapper {
PersonMapper INSTANCE = Mappers.getMapper(PersonMapper.class);
/**
* 转换成DTO对象
* @param personDO DO
* @return DTO
*/
PersonDTO convert2PersonDTO(PersonDO personDO);
/**
* 转换成DO对象
* @param personDTO DTO
* @return DO
*/
PersonDO convert2PersonDO(PersonDTO personDTO);
}
类似如此使用
@RunWith(SpringRunner.class)
@SpringBootTest
public class PersonMapperTest {
@Resource
private PersonMapper personMapper;
}
为了讲解 MapStruct 工具的使用,本文使用常见的 User 类以及对应 UserDto 对象来演示。
@Data
@Accessors(chain = true)
public class User {
private Long id;
private String username;
private String password; // 密码
private Integer sex; // 性别
private LocalDate birthday; // 生日
private LocalDateTime createTime; // 创建时间
private String config; // 其他扩展信息,以JSON格式存储
private String test; // 测试字段
}
@Data
@Accessors(chain = true)
public class UserVo {
private Long id;
private String username;
private String password;
private Integer gender;
private LocalDate birthday;
private String createTime;
private List<UserConfig> config;
private String test; // 测试字段
@Data
public static class UserConfig {
private String field1;
private Integer field2;
}
}
注意观察这两个类的区别。
项目中引入 MapStruct 的依赖
<dependency>
<groupId>org.mapstructgroupId>
<artifactId>mapstructartifactId>
<version>1.3.1.Finalversion>
dependency>
<dependency>
<groupId>org.mapstructgroupId>
<artifactId>mapstruct-processorartifactId>
<version>1.3.1.Finalversion>
dependency>
因为项目中的对象转换操作基本都一样,因此抽取除了一个转换基类,不同对象如果只是简单转换可以直接继承该基类,而无需覆写基类任何方法,即只需要一个空类即可。如果子类覆写了基类的方法,则基类上的 @Mapping 会失效。
@MapperConfig
public interface BaseMapping<SOURCE, TARGET> {
/**
* 映射同名属性
*/
@Mapping(target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
TARGET sourceToTarget(SOURCE var1);
/**
* 反向,映射同名属性
*/
@InheritInverseConfiguration(name = "sourceToTarget")
SOURCE targetToSource(TARGET var1);
/**
* 映射同名属性,集合形式
*/
@InheritConfiguration(name = "sourceToTarget")
List<TARGET> sourceToTarget(List<SOURCE> var1);
/**
* 反向,映射同名属性,集合形式
*/
@InheritConfiguration(name = "targetToSource")
List<SOURCE> targetToSource(List<TARGET> var1);
/**
* 映射同名属性,集合流形式
*/
List<TARGET> sourceToTarget(Stream<SOURCE> stream);
/**
* 反向,映射同名属性,集合流形式
*/
List<SOURCE> targetToSource(Stream<TARGET> stream);
}
实现 User 与 UserVo 对象的转换器
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
public interface UserMapping extends BaseMapping<User, UserVo> {
@Mapping(target = "gender", source = "sex")
@Mapping(target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
@Override
UserVo sourceToTarget(User var1);
@Mapping(target = "sex", source = "gender")
@Mapping(target = "password", ignore = true)
@Mapping(target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
@Override
User targetToSource(UserVo var1);
default List<UserConfig> strConfigToListUserConfig(String config) {
return JSON.parseArray(config, UserConfig.class);
}
default String listUserConfigToStrConfig(List<UserConfig> list) {
return JSON.toJSONString(list);
}
}
本文示例使用的是 Spring 的方式,@Mapper 注解的 componentModel 属性值为 spring,不过应该大多数都用的此模式进行开发。
@Mapping用于配置对象的映射关系,示例中 User 对象性别属性名为 sex,而UserVo对象性别属性名为gender,因此需要配置 target 与 source 属性。
password 字段不应该返回到前台,可以采取两种方式不进行转换,第一种就是在vo对象中不出现password字段,第二种就是在@Mapping中设置该字段 ignore = true。
MapStruct 提供了时间格式化的属性 dataFormat,支持Date、LocalDate、LocalDateTime等时间类型与String的转换。示例中birthday 属性为 LocalDate 类型,可以无需指定dataFormat自动完成转换,而LocalDateTime类型默认使用的是ISO格式时间,在国内往往不符合需求,因此需要手动指定一下 dataFormat。
一般常用的类型字段转换 MapStruct都能替我们完成,但是有一些是我们自定义的对象类型,MapStruct就不能进行字段转换,这就需要我们编写对应的类型转换方法,笔者使用的是JDK8,支持接口中的默认方法,可以直接在转换器中添加自定义类型转换方法。
示例中User对象的config属性是一个JSON字符串,UserVo对象中是List类型的,这需要实现JSON字符串与对象的互转。
default List<UserConfig> strConfigToListUserConfig(String config) {
return JSON.parseArray(config, UserConfig.class);
}
default String listUserConfigToStrConfig(List<UserConfig> list) {
return JSON.toJSONString(list);
}
如果是 JDK8以下的,不支持默认方法,可以另外定义一个 转换器,然后再当前转换器的 @Mapper 中通过 uses = XXX.class 进行引用。
定义好方法之后,MapStruct当匹配到合适类型的字段时,会调用我们自定义的转换方法进行转换。
@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class MapStructTest {
@Resource
private UserMapping userMapping;
@Test
public void tetDomain2DTO() {
User user = new User()
.setId(1L)
.setUsername("zhangsan")
.setSex(1)
.setPassword("abc123")
.setCreateTime(LocalDateTime.now())
.setBirthday(LocalDate.of(1999, 9, 27))
.setConfig("[{\"field1\":\"Test Field1\",\"field2\":500}]");
UserVo userVo = userMapping.sourceToTarget(user);
log.info("User: {}", user);
// User: User(id=1, username=zhangsan, password=abc123, sex=1, birthday=1999-09-27, createTime=2020-01-17T17:46:20.316, config=[{"field1":"Test Field1","field2":500}])
log.info("UserVo: {}", userVo);
// UserVo: UserVo(id=1, username=zhangsan, gender=1, birthday=1999-09-27, createTime=2020-01-17 17:46:20, config=[UserVo.UserConfig(field1=Test Field1, field2=500)])
}
@Test
public void testDTO2Domain() {
UserConfig userConfig = new UserConfig();
userConfig.setField1("Test Field1");
userConfig.setField2(500);
UserVo userVo = new UserVo()
.setId(1L)
.setUsername("zhangsan")
.setGender(2)
.setCreateTime("2020-01-18 15:32:54")
.setBirthday(LocalDate.of(1999, 9, 27))
.setConfig(Collections.singletonList(userConfig));
User user = userMapping.targetToSource(userVo);
log.info("UserVo: {}", userVo);
// UserVo: UserVo(id=1, username=zhangsan, gender=2, birthday=1999-09-27, createTime=2020-01-18 15:32:54, config=[UserVo.UserConfig(field1=Test Field1, field2=500)])
log.info("User: {}", user);
// User: User(id=1, username=zhangsan, password=null, sex=2, birthday=1999-09-27, createTime=2020-01-18T15:32:54, config=[{"field1":"Test Field1","field2":500}])
}