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>

MapStructprocessor 最新的稳定版本,请查询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 —— EmployeeEmployeeDTO.

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

这里我们需要为 DivisionDivisionDTO 之间的转换添加方法。如果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);
}

在这里,单个对象之间的转换将完全使用我们自定义的映射方法。

CollectionList 类型之间的转换,仍然交给 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 的子类 ElectricCarBioDieselCar 映射到 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 定义

编写 CarCarDTO 的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 中找到。


« 上一篇: 使用Spring JMS入门
» 下一篇: Java周报,147