1. 概述
MapStruct 是一个 Java Bean mapper,用于Java Bean 之间的转换。MapStruct 基于约定优于配置的设计思想,相较于常用的 BeanUtils.copyProperties
它更高效、优雅。
使用 MapStruct,我们只需要定义映射接口,该库在编译时会自动生成具体实现代码。
2. 为什么使用MapStruct
在软件开发中,我们通常使用多层架构设计模式,不同层次的对象模型需要相互转换,例如持久层中的 Entity 与 DTO 之间的转换。
编写此类映射代码是一项繁琐且容易出错的任务。MapStruct 旨在通过尽可能自动化来简化这项工作。
与其他框架相比,MapStruct 在编译时生成 bean 映射代码,从而确保高性能,允许开发人员快速发现并及时解决问题。
3. Maven 依赖
将下面依赖添加到Maven pom.xml
文件中
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.6.0.Beta1</version>
</dependency>
MapStruct 和 processor 最新的稳定版本,请查询Maven中央仓库。
还需要在 maven-compiler-plugin / annotationProcessorPaths 下添加配置。
mapstruct-processor 用于在 build
期间生成映射器具体实现代码。
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.6.0.Beta1</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
4. 小试牛刀
本节我们通示例,快速演示 MapStruct 的用法。
4.1. 定义 POJO
首先,我们定义两个Java类, SimpleSource
是源类,SimpleDestination
是要映射的目标类,他们具有相同的字段。
public class SimpleSource {
private String name;
private String description;
// getters and setters
}
public class SimpleDestination {
private String name;
private String description;
// getters and setters
}
4.2. Mapper 接口定义
在MapStruct中,我们将类型转换器称为 mapper
,请不要和Mybatis中的mapper混淆。
下面,我们定义mapper接口。使用@Mapper注解,告诉MapStruct这是我们的映射器
@Mapper
public interface SimpleSourceDestinationMapper {
SimpleDestination sourceToDestination(SimpleSource source);
SimpleSource destinationToSource(SimpleDestination destination);
}
注意,我们只需定义接口即可,MapStruct 会自定创建对应的实现类。
4.3. 自动生成Mapper代码
然后执行 mvn clean install 触发 MapStruct 插件自动生成代码,生成后的实现类在 /target/generated-sources/annotations/
目录下。
下面就是 MapStruct 为我们自动生成的:
public class SimpleSourceDestinationMapperImpl
implements SimpleSourceDestinationMapper {
@Override
public SimpleDestination sourceToDestination(SimpleSource source) {
if ( source == null ) {
return null;
}
SimpleDestination simpleDestination = new SimpleDestination();
simpleDestination.setName( source.getName() );
simpleDestination.setDescription( source.getDescription() );
return simpleDestination;
}
@Override
public SimpleSource destinationToSource(SimpleDestination destination){
if ( destination == null ) {
return null;
}
SimpleSource simpleSource = new SimpleSource();
simpleSource.setName( destination.getName() );
simpleSource.setDescription( destination.getDescription() );
return simpleSource;
}
}
4.4. 测试用例
OK,完成配置和代码生成后,我们可以进行转换测试:
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration("classpath:applicationContext.xml")
public class SimpleSourceDestinationMapperIntegrationTest {
@Autowired
SimpleSourceDestinationMapper simpleSourceDestinationMapper;
@Test
public void givenSourceToDestination_whenMaps_thenCorrect() {
// 创建源对象并赋值
SimpleSource simpleSource = new SimpleSource();
simpleSource.setName("SourceName");
simpleSource.setDescription("SourceDescription");
// 拷贝
SimpleDestination destination = simpleSourceDestinationMapper.sourceToDestination(simpleSource);
// 比较值是否一样
assertEquals(simpleSource.getName(), destination.getName());
assertEquals(simpleSource.getDescription(), destination.getDescription());
}
@Test
public void givenDestinationToSource_whenMaps_thenCorrect() {
SimpleDestination destination = new SimpleDestination();
destination.setName("DestinationName");
destination.setDescription("DestinationDescription");
SimpleSource source = simpleSourceDestinationMapper.destinationToSource(destination);
assertEquals(destination.getName(), source.getName());
assertEquals(destination.getDescription(), source.getDescription());
}
}
5. Spring 集成
完成POJO以mapper定义后,我们可以通过 Mappers.getMapper(YourClass.class) 获取 MapStruct Mapper实例。
另一种推荐方式是直接使用 Spring 的依赖注入功能。
MapStruct 同时支持 Spring 和 CDI
5.1. 修改 Mapper
对于Spring 应用,修改@Mapper注解,设置componentModel属性值为spring,如果是 CDI,则将其值设置为 cdi。
@Mapper(componentModel = "spring")
public interface SimpleSourceDestinationMapper
5.2. 注入 Spring 组件到 Mapper 中
反过来,我们需要在mapper中引用Spring容器中的组件该如何实现? 这种情况下,我们需要改用抽象类而非接口了:
@Mapper(componentModel = "spring")
public abstract class SimpleDestinationMapperUsingInjectedService
然后添加我们熟知的 @Autowired 注入依赖:
@Mapper(componentModel = "spring")
public abstract class SimpleDestinationMapperUsingInjectedService {
@Autowired
protected SimpleService simpleService;
@Mapping(target = "name", expression = "java(simpleService.enrichName(source.getName()))")
public abstract SimpleDestination sourceToDestination(SimpleSource source);
}
切记不要将注入的 bean 设置为private ,因为 MapStruct 需要在生成的实现类中访问该对象
6. 不同字段名映射
上例中,我们定义的两个 Bean 具有相同的字段名称,MapStruct 能够自动完成映射。如果,字段不一样呢?
下面我们创建两个新的员工Bean —— Employee 和 EmployeeDTO.
6.1. 新 POJOs
public class EmployeeDTO {
private int employeeId;
private String employeeName;
// getters and setters
}
public class Employee {
private int id;
private String name;
// getters and setters
}
6.2. Mapper 接口配置
映射字段不同时,我们需要在 @Mapping 注解中指定目标字段名。
在 MapStruct 中,我们还可以使用"."表示法来定义 bean 的成员
@Mapper
public interface EmployeeMapper {
@Mapping(target = "employeeId", source = "entity.id")
@Mapping(target = "employeeName", source = "entity.name")
EmployeeDTO employeeToEmployeeDTO(Employee entity);
@Mapping(target = "id", source = "dto.employeeId")
@Mapping(target = "name", source = "dto.employeeName")
Employee employeeDTOtoEmployee(EmployeeDTO dto);
}
6.3. 测试用例
下面进行测试
@Test
public void givenEmployeeDTOwithDiffNametoEmployee_whenMaps_thenCorrect() {
EmployeeDTO dto = new EmployeeDTO();
dto.setEmployeeId(1);
dto.setEmployeeName("John");
Employee entity = mapper.employeeDTOtoEmployee(dto);
assertEquals(dto.getEmployeeId(), entity.getId());
assertEquals(dto.getEmployeeName(), entity.getName());
}
更多测试用例可以去 GitHub 上查看源码。
7. 子对象映射
POJO 通常中不会只包含基本数据类型,其中往往会包含其它类。下面演示如何映射具有子对象引用的 bean。
7.1. 修改 POJO
向 Employee 中新增一个子对象引用
public class EmployeeDTO {
private int employeeId;
private String employeeName;
private DivisionDTO division;
// getters and setters omitted
}
public class Employee {
private int id;
private String name;
private Division division;
// getters and setters omitted
}
public class Division {
private int id;
private String name;
// default constructor, getters and setters omitted
}
7.2. 修改 Mapper
这里我们需要为 Division 与 DivisionDTO 之间的转换添加方法。如果MapStruct检测到需要转换对象类型,并且要转换的方法存在于同一个类中,它将自动使用它。
DivisionDTO divisionToDivisionDTO(Division entity);
Division divisionDTOtoDivision(DivisionDTO dto);
7.3. 测试用例
对上面的测试代码稍作修改:
@Test
public void givenEmpDTONestedMappingToEmp_whenMaps_thenCorrect() {
EmployeeDTO dto = new EmployeeDTO();
dto.setDivision(new DivisionDTO(1, "Division1"));
Employee entity = mapper.employeeDTOtoEmployee(dto);
assertEquals(dto.getDivision().getId(),
entity.getDivision().getId());
assertEquals(dto.getDivision().getName(),
entity.getDivision().getName());
}
8. 数据类型映射
MapStruct还提供了一些开箱即用的隐式类型转换。本例中,我们希望将 String 类型的日期转换为 Date 对象。
有关隐式类型转换的更多详细信息,请查看MapStruct 官方文档
8.1. 修改 Bean
向 Employee 中新增一个startDt 字段
public class Employee {
// other fields
private Date startDt;
// getters and setters
}
public class EmployeeDTO {
// other fields
private String employeeStartDt;
// getters and setters
}
8.2. 修改 Mapper
修改mapper,设置@Mapping注解的 dateFormat 参数,为startDate指定日期转换格式
@Mapping(target="employeeId", source = "entity.id")
@Mapping(target="employeeName", source = "entity.name")
@Mapping(target="employeeStartDt", source = "entity.startDt",
dateFormat = "dd-MM-yyyy HH:mm:ss")
EmployeeDTO employeeToEmployeeDTO(Employee entity);
@Mapping(target="id", source="dto.employeeId")
@Mapping(target="name", source="dto.employeeName")
@Mapping(target="startDt", source="dto.employeeStartDt",
dateFormat="dd-MM-yyyy HH:mm:ss")
Employee employeeDTOtoEmployee(EmployeeDTO dto);
8.3. 测试用例
对前面的测试代码稍作修改:
private static final String DATE_FORMAT = "dd-MM-yyyy HH:mm:ss";
@Test
public void givenEmpStartDtMappingToEmpDTO_whenMaps_thenCorrect() throws ParseException {
Employee entity = new Employee();
entity.setStartDt(new Date());
EmployeeDTO dto = mapper.employeeToEmployeeDTO(entity);
SimpleDateFormat format = new SimpleDateFormat(DATE_FORMAT);
assertEquals(format.parse(dto.getEmployeeStartDt()).toString(),
entity.getStartDt().toString());
}
@Test
public void givenEmpDTOStartDtMappingToEmp_whenMaps_thenCorrect() throws ParseException {
EmployeeDTO dto = new EmployeeDTO();
dto.setEmployeeStartDt("01-04-2016 01:00:00");
Employee entity = mapper.employeeDTOtoEmployee(dto);
SimpleDateFormat format = new SimpleDateFormat(DATE_FORMAT);
assertEquals(format.parse(dto.getEmployeeStartDt()).toString(),
entity.getStartDt().toString());
}
9. 使用抽象类自定义映射器
一些特殊场景 @Mapping 无法满足时,我们需要定制化开发,同时希望保留MapStruct自动生成代码的能力。下面我们演示如何通过创建抽象类实现。
9.1. Bean 定义
实体类定义:
public class Transaction {
private Long id;
private String uuid = UUID.randomUUID().toString();
private BigDecimal total;
//standard getters
}
DTO类:
public class TransactionDTO {
private String uuid;
private Long totalInCents;
// standard getters and setters
}
这里棘手的地方在于将 BigDecimal
类型的total美元金额,转换为 Long totalInCents
(以美分表示的总金额),这部分我们将使用自定义方法实现。
9.2. Mapper 定义
下面使用抽象类实现我们的需求:
@Mapper
abstract class TransactionMapper {
public TransactionDTO toTransactionDTO(Transaction transaction) {
TransactionDTO transactionDTO = new TransactionDTO();
transactionDTO.setUuid(transaction.getUuid());
transactionDTO.setTotalInCents(transaction.getTotal()
.multiply(new BigDecimal("100")).longValue());
return transactionDTO;
}
public abstract List<TransactionDTO> toTransactionDTO(
Collection<Transaction> transactions);
}
在这里,单个对象之间的转换将完全使用我们自定义的映射方法。
而 Collection 与 List 类型之间的转换,仍然交给 MapStruct 完成,我们只需定义接口。
9.3. 生成结果
和我们预期一样,MapStruct 自动生成了剩余的部分:
@Generated
class TransactionMapperImpl extends TransactionMapper {
@Override
public List<TransactionDTO> toTransactionDTO(Collection<Transaction> transactions) {
if ( transactions == null ) {
return null;
}
List<TransactionDTO> list = new ArrayList<>();
for ( Transaction transaction : transactions ) {
list.add( toTransactionDTO( transaction ) );
}
return list;
}
}
第12行,MapStruct 调用了我们自己实现的方法。
10. @BeforeMapping 和 @AfterMapping 注解
另外,我们可以通过 @BeforeMapping 和 @AfterMapping 注解定制化需求。显然,这两个方法是在每次映射之前和之后执行的。 也就是说,在最终的实现代码中,会在两个对象真正映射之前和之后添加并执行这两个方法。
让我们看一个例子,如何将 Car 的子类 ElectricCar 和 BioDieselCar 映射到 CarDTO 。
在映射时,我们希望将汽车的类型映射到DTO中的 FuelType 枚举字段。然后在映射完成后,我们想将DTO的name字段转为全大写字母。
10.1. 模型定义
public class Car {
private int id;
private String name;
}
Car 具有燃油车和电动车两个子类:
public class BioDieselCar extends Car {
}
public class ElectricCar extends Car {
}
CarDTO 中的 FuelType 为枚举类型,表示汽车能源类型:
public class CarDTO {
private int id;
private String name;
private FuelType fuelType;
}
public enum FuelType {
ELECTRIC, BIO_DIESEL
}
10.2. Mapper 定义
编写 Car 到 CarDTO 的mapper映射:
@Mapper
public abstract class CarsMapper {
@BeforeMapping
protected void enrichDTOWithFuelType(Car car, @MappingTarget CarDTO carDto) {
if (car instanceof ElectricCar) {
carDto.setFuelType(FuelType.ELECTRIC);
}
if (car instanceof BioDieselCar) {
carDto.setFuelType(FuelType.BIO_DIESEL);
}
}
@AfterMapping
protected void convertNameToUpperCase(@MappingTarget CarDTO carDto) {
carDto.setName(carDto.getName().toUpperCase());
}
public abstract CarDTO toCarDto(Car car);
}
@MappingTarget 是一个参数注释,在@BeforeMapping的情况下,在执行映射逻辑之前填充目标映射DTO,在@AfterMapping注释方法的情况下在执行之后填充。
10.3. 结果
生成后的代码如下:
@Generated
public class CarsMapperImpl extends CarsMapper {
@Override
public CarDTO toCarDto(Car car) {
if (car == null) {
return null;
}
CarDTO carDTO = new CarDTO();
enrichDTOWithFuelType(car, carDTO);
carDTO.setId(car.getId());
carDTO.setName(car.getName());
convertNameToUpperCase(carDTO);
return carDTO;
}
}
结果和我们定义的行为一致。
11. Lombok 插件支持
在最近版本的Mapstruct中,宣布了对Lombok的支持。 因此,我们可以轻松地使用Lombok完成对象之间的映射。
要启用Lombok支持,我们需要在 annotationProcessorPaths 中添加依赖。从Lombok版本1.18.16开始,我们还必须添加 lombok-mapstruct-binding 依赖。 下面是添加了lombok和mapstruct后的依赖:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.5.5.Final</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.2.0</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
下面使用 Lombok 注解定义实体:
@Getter
@Setter
public class Car {
private int id;
private String name;
}
同样,对于DTO:
@Getter
@Setter
public class CarDTO {
private int id;
private String name;
}
mapper 接口和前面的例子保持一样:
@Mapper
public interface CarMapper {
CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);
CarDTO carToCarDTO(Car car);
}
12. defaultExpression支持
1.3.0版本开始,我们可以使用 @Mapping 注解的 defaultExpression 参数来指定一个表达式:如果源字段为 null
,该表达式将赋予一个默认值。
源实体定义:
public class Person {
private int id;
private String name;
}
目标DTO定义:
public class PersonDTO {
private int id;
private String name;
}
如果源实体的 id
字段为 null
,我们希望生成一个随机的 id
并将其分配给目标:
@Mapper
public interface PersonMapper {
PersonMapper INSTANCE = Mappers.getMapper(PersonMapper.class);
@Mapping(target = "id", source = "person.id",
defaultExpression = "java(java.util.UUID.randomUUID().toString())")
PersonDTO personToPersonDTO(Person person);
}
代码测试:
@Test
public void givenPersonEntitytoPersonWithExpression_whenMaps_thenCorrect()
Person entity = new Person();
entity.setName("Micheal");
PersonDTO personDto = PersonMapper.INSTANCE.personToPersonDTO(entity);
assertNull(entity.getId());
assertNotNull(personDto.getId());
assertEquals(personDto.getName(), entity.getName());
}
13. 总结
本文我们介绍了MapStruct的基础映射和高级使用方法。
文中的示例源码可以在 GitHub 中找到。