MapStruct 映射框架

MapStruct是一款基于JSR-269标准实现的JavaBean映射框架,它用于生成类型安全、高性能且无依赖的Bean映射代码,在大型企业级项目中比较常用。MapStruct最初在大约2012年由德国工程师Gunnar Morling创建,项目以Apache 2.0协议开源,后来由开源社区共同维护和迭代。
为什么需要MapStruct
在Java开发中,对象之间的互相映射转换是极其常见的需求,例如我们通常会区分领域对象实体类Entity、业务传输对象DTO、视图对象VO等,这些对象虽然结构相似但它们代表着不同层次的关注点,不能混为一谈,然而分层也意味着互相之间需要进行转换,这些转换如果手写代码量极大,容易出错而且不利于代码持续维护。解决这个问题有几种常见方案。
手写转换代码:最朴素的方式,也是最繁琐的方式。
反射工具类:像Spring框架本身其实就提供了BeanUtils工具类,它能基于反射在两个对象中自动映射相同名字的字段。这种实现虽然写法简单,但缺点也很明显,首先就是功能太简陋了,字段名不同无法自动映射,而且基于反射实现也带来了性能损耗,在底层计算开销要远远大于手写转换代码。除了Spring BeanUtils还有功能更强大的Dozer框架,但它也是基于运行时反射实现的,性能不如手写转换代码。
MapStruct:在编译期通过注解处理器生成映射代码,在底层本质上和手写转换代码一样,几乎没有额外的运行时性能损耗,但实际编码中又无需开发者手动写转换代码,而是通过声明式的字段映射配置转换规则,既满足了性能要求又不失简洁性。当然,代价是又得多学一个框架,增加了心智负担。
MapStruct环境搭建
我们这里先以一个普通Java控制台工程为例,介绍MapStruct的用法,我们这里使用的是JDK 21版本。
在项目的pom.xml中,MapStruct除了要引入自身依赖,还需要在Maven的Compiler插件中配置注解处理器,此外如果同时使用MapStruct和Lombok,二者还有一个处理顺序问题,我们需要让Lombok先执行,然后是MapStruct,这里我们直接给出一套完整配置。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.gacfox.demo</groupId>
<artifactId>demo-mapstruct</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<lombok.version>1.18.46</lombok.version>
<mapstruct.version>1.6.3</mapstruct.version>
</properties>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>21</source>
<target>21</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.2.0</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>
MapStruct使用
这里我们直接看一个实际例子,我们有实体类User和DTO类UserDto,代码如下。
package com.gacfox.demo.entity;
import lombok.Data;
import java.util.Date;
@Data
public class User {
private Long id;
private String username;
private Integer age;
private String email;
private Date createdTime;
private Date updatedTime;
}
package com.gacfox.demo.dto;
import lombok.Data;
import java.util.Date;
@Data
public class UserDto {
private Long id;
private String username;
private Integer age;
private String email;
private Date createdTime;
private Date updatedTime;
}
现在我们要实现二者互转,我们定义MapStruct转换接口UserConverter。
package com.gacfox.demo.converter;
import com.gacfox.demo.dto.UserDto;
import com.gacfox.demo.entity.User;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.factory.Mappers;
@Mapper
public interface UserConverter {
UserConverter INSTANCE = Mappers.getMapper(UserConverter.class);
@Mapping(source = "id", target = "id")
@Mapping(source = "username", target = "username")
@Mapping(source = "age", target = "age")
@Mapping(source = "email", target = "email")
@Mapping(source = "createdTime", target = "createdTime")
@Mapping(source = "updatedTime", target = "updatedTime")
UserDto toDto(User user);
@Mapping(source = "id", target = "id")
@Mapping(source = "username", target = "username")
@Mapping(source = "age", target = "age")
@Mapping(source = "email", target = "email")
@Mapping(source = "createdTime", target = "createdTime")
@Mapping(source = "updatedTime", target = "updatedTime")
User toEntity(UserDto userDto);
}
代码中,接口被@Mapper注解标注,声明它是一个MapStruct转换接口,接口中的INSTANCE是转换器实例的标准写法,作为接口的成员变量,它实际是public static final的,这个INSTANCE用于外部获取自动生成的类型转换实现类,这个实现类会被用于做对象转换。
接口中我们还定义了两个转换方法,toDto()和toEntity(),它们的参数和返回值互相倒置,两个方法用于实体类和DTO类的互相转换。接口上标注了许多@Mapping注解,注解的source和target指定了映射的源和目标字段名。我们这里为所有字段都显式标注了@Mapping注解,这其实不是必要的,当源和目标字段名相同时@Mapping注解其实可以省略,这里显式写出则比较直观。
下面的Main类中,我们调用上面定义的类型转换器,实现对象之间的互转。
package com.gacfox.demo;
import com.gacfox.demo.converter.UserConverter;
import com.gacfox.demo.dto.UserDto;
import com.gacfox.demo.entity.User;
import java.util.Date;
public class Main {
public static void main(String[] args) {
User user = new User();
user.setId(1L);
user.setUsername("gacfox");
user.setAge(25);
user.setEmail("gacfox@example.com");
user.setCreatedTime(new Date());
user.setUpdatedTime(new Date());
// User -> UserDto
UserDto userDto = UserConverter.INSTANCE.toDto(user);
System.out.println("=== User -> UserDto ===");
System.out.println(userDto);
// UserDto -> User
User userBack = UserConverter.INSTANCE.toEntity(userDto);
System.out.println("=== UserDto -> User ===");
System.out.println(userBack);
}
}
我们可以执行Maven编译命令,观察target/generated-sources下是否生成了UserConverter的实现类。如果一切正常,我们会观察到这里出现了一个UserConverterImpl类,它的实际内容就是我们之前声明的对象互转方法的实现。
mvn clean compile
字段映射配置
字段名映射
默认情况下,MapStruct会自动匹配源类和目标类中名称相同、类型兼容的字段,无需任何额外配置。前面例子中实体类User和DTO类UserDto的字段都是一模一样的,因此,实际上前面的UserConverter我们可以写成下面这样,所有的@Mapping都可以省略。
package com.gacfox.demo.converter;
import com.gacfox.demo.dto.UserDto;
import com.gacfox.demo.entity.User;
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;
@Mapper
public interface UserConverter {
UserConverter INSTANCE = Mappers.getMapper(UserConverter.class);
UserDto toDto(User user);
User toEntity(UserDto userDto);
}
如果有不同的字段名则必须显式写出。
@Mapping(source = "updatedAt", target = "updatedTime")
UserDto toDto(User user);
忽略字段
如果目标对象中某个字段不需要映射,使用ignore = true排除它,下面例子即使源对象中password字段有值,目标对象中的对应字段也不会被MapStruct处理。
@Mapping(target = "password", ignore = true)
UserDto toDto(User user);
嵌套对象映射
假设我们的对象结构是User实体类中嵌套了Role类型的role字段,但DTO中需要对应设置String类型的roleName,MapStruct也是支持这种情况的。MapStruct的source和target都支持点号操作符,我们将源字段设置为role.roleName,目标字段设置为roleName即可。
@Mapping(source = "role.roleName", target = "roleName")
UserDto toDto(User user);
常量字段
MapStruct支持为目标字段设置固定常量值。下面例子中,字段source被固定赋值WEB。
@Mapping(target = "source", constant = "WEB")
UserDto toDto(User user);
字段默认值
映射时,如果源字段为null,MapStruct也支持为目标字段赋予defaultValue声明的默认值。
@Mapping(source = "source", target = "source", defaultValue = "WEB")
UserDto toDto(User user);
多源对象合并
MapStruct中源对象可以是多个。
@Mapping(source = "user.id", target = "id")
@Mapping(source = "user.username", target = "username")
@Mapping(source = "user.age", target = "age")
@Mapping(source = "user.email", target = "email")
@Mapping(source = "user.createdTime", target = "createdTime")
@Mapping(source = "user.updatedTime", target = "updatedTime")
@Mapping(source = "address.province", target = "province")
@Mapping(source = "address.city", target = "city")
UserDto toDto(User user, Address address);
当有多个源对象时,@Mapping中的source必须带上参数名前缀,例如user.username,否则MapStruct不知道从哪个参数取值。
集合映射
MapStruct自动支持List、Set等集合类型的映射,不需要手写循环。
@Mapping(source = "id", target = "id")
@Mapping(source = "username", target = "username")
@Mapping(source = "age", target = "age")
@Mapping(source = "email", target = "email")
@Mapping(source = "createdTime", target = "createdTime")
@Mapping(source = "updatedTime", target = "updatedTime")
List<UserDto> toVOList(List<User> users);
未映射字段处理方式
实际开发中,我们可能遇到源对象中的字段目标里没有,或反过来目标对象里有的字段源对象里没有的情况,MapStruct中支持通过unmappedSourcePolicy和unmappedTargetPolicy控制相关行为。该配置可以帮我们检查避免出现类似实体类中新增了字段,DTO中忘记添加的情况。
下面例子中,我们设置unmappedTargetPolicy属性为ReportingPolicy.ERROR,这意味着当目标对象中有未映射字段时将编译失败,注意这个检查是编译期的,不是运行时抛出异常。
@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface UserConverter {
}
unmappedSourcePolicy和unmappedTargetPolicy有以下取值可选。
| 配置 | 结果 |
|---|---|
| ReportingPolicy.WARN | 编译警告,默认值 |
| ReportingPolicy.ERROR | 编译失败 |
| ReportingPolicy.IGNORE | 无提示 |
类型转换
MapStruct支持隐式类型转换,当源字段和目标字段类型不同时,框架会根据内置的隐式规则判断并添加转换代码,例如int会被用String.valueOf()封装转为目标String类型。我们这里介绍一些常用的隐式转换规则。
基本类型隐式转换
下面例子中,实体类Order中的id字段是Long类型,DTO类OrderDto中的id是String类型,它们其实是可以隐式转换的,我们不需要任何额外处理。你可以编译如下代码,观察OrderConverterImpl中是否存在String.valueOf()和Long.parseLong()。
package com.gacfox.demo.converter;
import com.gacfox.demo.dto.OrderDto;
import com.gacfox.demo.entity.Order;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.factory.Mappers;
@Mapper
public interface OrderConverter {
OrderConverter INSTANCE = Mappers.getMapper(OrderConverter.class);
@Mapping(source = "id", target = "id")
OrderDto toDto(Order order);
@Mapping(source = "id", target = "id")
Order toEntity(OrderDto orderDto);
}
日期类型隐式转换
当源类型是Date或LocalDateTime,目标类型是String时,可以通过dateFormat参数指定格式。
package com.gacfox.demo.converter;
import com.gacfox.demo.dto.OrderDto;
import com.gacfox.demo.entity.Order;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.factory.Mappers;
@Mapper
public interface OrderConverter {
OrderConverter INSTANCE = Mappers.getMapper(OrderConverter.class);
@Mapping(source = "createdTime", target = "createdTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
OrderDto toDto(Order order);
@Mapping(source = "createdTime", target = "createdTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
Order toEntity(OrderDto orderDto);
}
数字格式隐式转换
下面例子中,源类型是BigDecimal,目标类型是String,我们使用numberFormat指定了数字格式,这个格式遵循Java的DecimalFormat规范,0.00表示转字符串时小数不足2位的补零。
package com.gacfox.demo.converter;
import com.gacfox.demo.dto.OrderDto;
import com.gacfox.demo.entity.Order;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.factory.Mappers;
@Mapper
public interface OrderConverter {
OrderConverter INSTANCE = Mappers.getMapper(OrderConverter.class);
@Mapping(source = "price", target = "price", numberFormat = "0.00")
OrderDto toDto(Order order);
@Mapping(source = "price", target = "price")
Order toEntity(OrderDto orderDto);
}
自定义类型转换方法
如果MapStruct的内置规则不能满足我们的要求,我们也可以自定义类型转换方法,这需要在转换接口类中添加default方法,这个方法名是随便起的,MapStruct并不读取方法名,框架只看入参和返回值类型。下面例子中,实体类Task中有Integer类型的status字段,但TaskDto中的status字段是String类型,我们定义了两个转换方法实现这两个类型之间的转换。
package com.gacfox.demo.converter;
import com.gacfox.demo.dto.TaskDto;
import com.gacfox.demo.entity.Task;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Named;
import org.mapstruct.factory.Mappers;
@Mapper
public interface TaskConverter {
TaskConverter INSTANCE = Mappers.getMapper(TaskConverter.class);
@Mapping(source = "status", target = "status", qualifiedByName = "statusToString")
TaskDto toDto(Task task);
@Mapping(source = "status", target = "status", qualifiedByName = "stringToStatus")
Task toEntity(TaskDto taskDto);
@Named("statusToString")
default String statusToString(Integer status) {
if (status == null) {
return null;
}
return switch (status) {
case 0 -> "待处理";
case 1 -> "进行中";
case 2 -> "已完成";
default -> "未知";
};
}
@Named("stringToStatus")
default Integer stringToStatus(String status) {
if (status == null) {
return null;
}
return switch (status) {
case "待处理" -> 0;
case "进行中" -> 1;
case "已完成" -> 2;
default -> throw new IllegalArgumentException("未知状态: " + status);
};
}
}
代码中,@Named类似于给转换方法起个名字,@Mapping中我们使用qualifiedByName属性明确指定使用哪个名字的转换方法,这是为了避免有多组入参返回值相同的转换方法时,MapStruct框架不知道用哪个。实际上,如果相同入参返回值规则的转换方法只有一个,@Named是可以省略的,但我们这里仍显式写出,避免依赖太多隐式规则让代码变得困惑和难以理解。
更新已有对象
有时我们不希望创建新对象,而是想把源对象的字段更新到一个已存在的目标对象上,例如数据库中查出的实体对象需要根据用户的修改请求更新部分字段,此时可以使用@MappingTarget注解。下面例子中,我们将userDto中的字段映射到实体类user上,目标对象被@MappingTarget注解标注,不过这里我们不希望映射id、createdTime、updatedTime字段,它们被特别标注为忽略。
package com.gacfox.demo.converter;
import com.gacfox.demo.dto.UserDto;
import com.gacfox.demo.entity.User;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
import org.mapstruct.factory.Mappers;
@Mapper
public interface UserConverter {
UserConverter INSTANCE = Mappers.getMapper(UserConverter.class);
@Mapping(source = "username", target = "username")
@Mapping(source = "age", target = "age")
@Mapping(source = "email", target = "email")
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdTime", ignore = true)
@Mapping(target = "updatedTime", ignore = true)
void updateEntity(UserDto userDto, @MappingTarget User user);
}
映射前置和后置处理
MapStruct支持使用@AfterMapping或@BeforeMapping在映射前后插入自定义逻辑,我们可以在这里打印日志或执行更复杂的自定义映射逻辑。
package com.gacfox.demo.converter;
import com.gacfox.demo.dto.OrderDto;
import com.gacfox.demo.entity.Order;
import org.mapstruct.AfterMapping;
import org.mapstruct.BeforeMapping;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
import org.mapstruct.factory.Mappers;
@Mapper
public interface OrderConverter {
OrderConverter INSTANCE = Mappers.getMapper(OrderConverter.class);
@Mapping(source = "id", target = "id")
@Mapping(source = "createdTime", target = "createdTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
@Mapping(source = "price", target = "price", numberFormat = "0.00")
OrderDto toDto(Order order);
@Mapping(source = "id", target = "id")
@Mapping(source = "createdTime", target = "createdTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
@Mapping(source = "price", target = "price")
Order toEntity(OrderDto orderDto);
@BeforeMapping
default void beforeToDto(Order order) {
System.out.println("[BeforeMapping] 准备转换 Order -> OrderDto, source: " + order);
}
@AfterMapping
default void afterToDto(Order order, @MappingTarget OrderDto orderDto) {
System.out.println("[AfterMapping] 转换完成 Order -> OrderDto, target: " + orderDto);
}
@BeforeMapping
default void beforeToEntity(OrderDto orderDto) {
System.out.println("[BeforeMapping] 准备转换 OrderDto -> Order, source: " + orderDto);
}
@AfterMapping
default void afterToEntity(OrderDto orderDto, @MappingTarget Order order) {
System.out.println("[AfterMapping] 转换完成 OrderDto -> Order, target: " + order);
}
}
在Spring环境中使用MapStruct
在Spring环境下,建议将MapStruct转换器类注册为Spring Bean,我们不需要手动注册,在@Mapper注解上添加以下属性即可。
@Mapper(componentModel = "spring")
public interface OrderConverter {
// ...
}
此时,这个转换器类就可以被Spring托管和自由的依赖注入了。