跳转到内容

26. MapStruct 映射框架


MapStruct 是一个 Java Bean 映射框架,通过注解在编译期自动生成类型转换代码,零反射、零运行时开销。

Java 项目里经常要在不同层之间转换对象:

Entity → DTO → VO

手写 get/set 又累又容易漏字段。常见方案对比:

方案实现原理性能类型检查
手写属性赋值直接编写代码最高编译期安全
BeanUtils.copyProperties基于反射较低运行时检查
ModelMapper反射 + 规则推断较低运行时检查
MapStruct编译期生成代码接近手写性能编译期检查

MapStruct 的核心优势:生成的代码就是普通的 get/set,可以直接去 target/generated-sources 里看到它

<properties>
<mapstruct.version>1.5.5.Final</mapstruct.version>
</properties>
<dependencies>
<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>
<configuration>
<annotationProcessorPaths>
<!-- ⚠️ 同时用 Lombok 时,Lombok 必须排在 MapStruct 前面 -->
<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>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>

⚠️ Lombok 的注解处理器必须排在 MapStruct 前面,否则 MapStruct 处理时还没有 getter/setter,会报找不到属性的错误。

// 实体类
public class User {
private Long id;
private String username;
private String email;
// getter/setter...
}
// DTO
public class UserDTO {
private Long id;
private String username;
private String email;
// getter/setter...
}
// 定义 Mapper 接口,加上 @Mapper 注解就够了
@Mapper
public interface UserMapper {
UserMapper INSTANCE = Mappers.getMapper(UserMapper.class);
UserDTO toDTO(User user);
User toEntity(UserDTO dto);
}
// 使用
User user = new User(1L, "张三", "zhangsan@example.com");
UserDTO dto = UserMapper.INSTANCE.toDTO(user);

字段名和类型相同时,MapStruct 自动映射,不需要任何额外配置。

// User.username → UserDTO.name
@Mapper
public interface UserMapper {
@Mapping(source = "username", target = "name")
UserDTO toDTO(User user);
@Mapping(source = "name", target = "username")
User toEntity(UserDTO dto);
}
// 敏感字段不映射到 DTO
@Mapping(target = "password", ignore = true)
UserDTO toDTO(User user);
@Mapping(source = "username", target = "name")
@Mapping(source = "email", target = "emailAddress")
@Mapping(target = "password", ignore = true)
UserDTO toDTO(User user);
// 固定值:不管 source 是什么,target 都是这个值
@Mapping(target = "dataSource", constant = "SYSTEM")
// 默认值:source 字段为 null 时才使用
@Mapping(source = "status", target = "status", defaultValue = "ACTIVE")
public class Order {
private Long id;
private User user; // 嵌套对象
}
public class User {
private Long id;
private String userName; // 想取 user.username
}
@Mapper
public interface OrderMapper {
// 用 "." 访问嵌套属性
@Mapping(source = "user.username", target = "userName")
OrderDTO toDTO(Order order);
}

MapStruct 内置了常见类型的自动转换:

源类型目标类型说明
int / IntegerString自动 toString
Stringint / Integer自动 parseInt
BigDecimalString自动转换
DateString需指定 dateFormat
@Mapping(source = "createTime", target = "createTimeStr", dateFormat = "yyyy-MM-dd HH:mm:ss")
UserDTO toDTO(User user);

当内置转换不满足需求时,在接口里写 default 方法并用 @Named 标记:

@Mapper
public interface UserMapper {
@Mapping(source = "status", target = "statusDesc", qualifiedByName = "statusToDesc")
UserDTO toDTO(User user);
@Named("statusToDesc")
default String statusToDesc(Integer status) {
if (status == null) return "";
return switch (status) {
case 0 -> "禁用";
case 1 -> "正常";
default -> "未知";
};
}
}

多个 Mapper 都需要同一个转换时,提取到独立类并通过 uses 引用:

// 公共转换类
@Component
public class DateConverter {
public String dateToString(Date date) {
if (date == null) return null;
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date);
}
}
// Mapper 引用
@Mapper(componentModel = "spring", uses = DateConverter.class)
public interface UserMapper {
UserDTO toDTO(User user); // Date 字段自动调用 DateConverter
}

单对象映射方法写好之后,集合方法会自动调用它,无需重复写映射规则:

@Mapper(componentModel = "spring")
public interface UserMapper {
UserDTO toDTO(User user);
List<UserDTO> toDTOList(List<User> users); // 自动基于 toDTO 生成
Set<UserDTO> toDTOSet(Set<User> users);
Map<String, UserDTO> toDTOMap(Map<String, User> users);
}

实际项目里不用 Mappers.getMapper(),让 Spring 来管理:

// componentModel = "spring":生成的实现类会带上 @Component
@Mapper(componentModel = "spring")
public interface UserMapper {
UserDTO toDTO(User user);
User toEntity(UserDTO dto);
}
// 直接 @Autowired 注入
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
public UserDTO getUser(Long id) {
User user = userRepository.findById(id).orElseThrow();
return userMapper.toDTO(user);
}
}

配置了 componentModel = "spring" 后,就不需要 INSTANCE 常量了。

不创建新对象,把数据”打入”一个已有对象(典型场景:更新操作):

@Mapper(componentModel = "spring")
public interface UserMapper {
// 把 dto 的值更新到已有的 user 对象里,不替换整个对象
void updateEntityFromDTO(UserDTO dto, @MappingTarget User user);
}
// 更新场景
User user = userRepository.findById(id).orElseThrow();
userMapper.updateEntityFromDTO(updateDTO, user); // 只覆盖 dto 里有的字段
userRepository.save(user);

这样 createTimecreateBy 等不在 DTO 里的字段不会被清空。

把两个对象的字段合并成一个目标对象:

@Mapper(componentModel = "spring")
public interface OrderMapper {
@Mapping(source = "order.id", target = "orderId")
@Mapping(source = "user.username", target = "userName")
@Mapping(source = "user.email", target = "userEmail")
OrderDetailVO toDetailVO(Order order, User user);
}

正向映射和反向映射规则对称时,避免重复写:

@Mapper
public interface UserMapper {
@Mapping(source = "username", target = "name")
@Mapping(source = "email", target = "emailAddress")
UserDTO toDTO(User user);
// 自动继承上面的 @Mapping 并反转 source/target
@InheritInverseConfiguration
User toEntity(UserDTO dto);
}

同方向的两个方法想共享映射规则:

@Mapping(source = "username", target = "name")
UserDTO toDTO(User user);
@InheritConfiguration // 复用 toDTO 的规则
void updateDTOFromUser(User user, @MappingTarget UserDTO dto);
@Mapper(
componentModel = "spring",
// SET_TO_NULL(默认):目标字段设为 null
// IGNORE:目标字段保持原值(配合 @MappingTarget 做部分更新非常实用)
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE
)
public interface UserMapper {
void updateEntityFromDTO(UserDTO dto, @MappingTarget User user);
}

IGNORE 策略的典型场景:前端只传了要修改的字段,其余字段传 null,不希望把数据库里的值清掉。

// ALWAYS:映射前先检查整个 source 对象是否为 null,为 null 直接返回 null
@Mapper(nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS)
public interface UserMapper {
UserDTO toDTO(User user);
}

枚举默认按名字匹配(不是 ordinal),名字不同时用 @ValueMapping

public enum GenderCode { MALE, FEMALE }
public enum GenderEnum { M, F }
@Mapper
public interface GenderMapper {
@ValueMapping(source = "MALE", target = "M")
@ValueMapping(source = "FEMALE", target = "F")
GenderEnum toGenderEnum(GenderCode code);
}

所有 Mapper 共享同一套默认配置,避免每个 Mapper 都重复写:

// 定义全局配置
@MapperConfig(
componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.ERROR, // 有未映射字段时编译报错
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE
)
public interface BaseMapperConfig {
}
// 各 Mapper 引用
@Mapper(config = BaseMapperConfig.class)
public interface UserMapper {
UserDTO toDTO(User user);
}

MapStruct 是编译期生成,build 之后可以在这里看到实现类:

target/
generated-sources/
annotations/
com/yourpackage/mapper/
UserMapperImpl.java ← 生成的实现

生成的代码大概长这样:

@Component
public class UserMapperImpl implements UserMapper {
@Override
public UserDTO toDTO(User user) {
if (user == null) return null;
UserDTO userDTO = new UserDTO();
userDTO.setName(user.getUsername());
userDTO.setEmailAddress(user.getEmail());
return userDTO;
}
}

就是普通的 get/set,没有任何魔法,这也是它性能好的原因。

  • Lombok 和 MapStruct 处理器顺序错了
<!-- 错误:MapStruct 先执行,这时 Lombok 还没生成 getter/setter -->
<annotationProcessorPaths>
<path>mapstruct-processor</path>
<path>lombok</path>
</annotationProcessorPaths>
<!-- 正确:Lombok 必须在前 -->
<annotationProcessorPaths>
<path>lombok</path>
<path>mapstruct-processor</path>
</annotationProcessorPaths>
  • 字段不兼容,但没有任何报错。

MapStruct 找不到合适的转换时,默认静默跳过,目标字段为 null,运行时才发现。

解决方案:开启严格模式,编译期就报错:

@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface UserMapper { ... }

建议在 @MapperConfig 里全局配置。

  • 多源映射时字段来源不明确
// 不明确:MapStruct 自己猜来源
@Mapping(source = "id", target = "orderId")
// 明确:指定来自哪个参数
@Mapping(source = "order.id", target = "orderId")
@Mapping(source = "user.username", target = "userName")
OrderDetailVO toDetailVO(Order order, User user);
  • 改了接口但没重新编译

MapStruct 是编译期生成,改了 Mapper 接口后必须重新编译,否则运行的还是旧的实现类。IDEA 有时需要手动 Build → Rebuild Project。