这件事每个 Java 项目都遇到
任何一个稍有规模的 Java 项目都会有这种代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // 数据库实体
public class UserEntity {
private Long id;
private String username;
private String email;
private LocalDateTime createTime;
private Integer status;
}
// 给前端的 VO
public class UserVO {
private String id; // 需要 toString
private String username;
private String email;
private String createTime; // 需要格式化成 yyyy-MM-dd HH:mm:ss
private String statusName; // 需要根据 status 翻译成中文
}
|
转换逻辑写起来无比啰嗦:
1
2
3
4
5
6
7
8
9
| public UserVO toVO(UserEntity e) {
UserVO vo = new UserVO();
vo.setId(e.getId().toString());
vo.setUsername(e.getUsername());
vo.setEmail(e.getEmail());
vo.setCreateTime(e.getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
vo.setStatusName(StatusEnum.fromCode(e.getStatus()).getName());
return vo;
}
|
加一个字段就要改这种模板代码两次(一次实体,一次转换)。这是最容易出 bug 又最浪费时间的代码——人懒了一定会漏字段。
社区有几种主流解法:
| 方案 | 性能 | 类型安全 | 字段增减提示 |
|---|
| 手写 | 高 | ✓ | 编译期 |
BeanUtils.copyProperties | 低(反射) | ✗ | 无 |
BeanCopier (cglib) | 中高 | ✗ | 无 |
| MapStruct(编译期生成) | 高(接近手写) | ✓ | 编译期 |
MapStruct 是目前这件事最成熟的方案。 它不是运行时反射,而是在编译时由 APT 生成转换代码——既类型安全,又性能接近手写。
一、最小可运行 Demo
1. 引入依赖
1
2
3
4
5
| <dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.5.5.Final</version>
</dependency>
|
maven-compiler-plugin 配置(关键):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| <plugin>
<artifactId>maven-compiler-plugin</artifactId>
<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 的话必须加 lombok-mapstruct-binding——否则 Lombok 生成的 getter/setter 在 MapStruct 跑 APT 时还看不到,会编译失败。
2. 写映射接口
1
2
3
4
5
6
7
| @Mapper(componentModel = "spring")
public interface UserMapper {
UserVO toVO(UserEntity entity);
List<UserVO> toVOList(List<UserEntity> entities);
}
|
接口不需要写实现,编译时会自动生成 UserMapperImpl。
业务里直接注入:
1
2
3
4
5
6
7
8
| @Service
public class UserService {
@Autowired UserMapper userMapper;
public UserVO get(Long id) {
return userMapper.toVO(userRepo.findById(id).orElseThrow());
}
}
|
componentModel = "spring" 让 Mapper 自动成为 Spring Bean,可以 @Autowired。
3. 看一眼生成的代码
target/generated-sources/annotations/.../UserMapperImpl.java:
1
2
3
4
5
6
7
8
9
10
11
12
13
| @Component
public class UserMapperImpl implements UserMapper {
@Override
public UserVO toVO(UserEntity entity) {
if (entity == null) return null;
UserVO vo = new UserVO();
vo.setId(entity.getId() == null ? null : entity.getId().toString());
vo.setUsername(entity.getUsername());
vo.setEmail(entity.getEmail());
// ...
return vo;
}
}
|
这就是手写代码——只是不用你写。
二、字段名/类型不匹配的处理
字段名不一致:@Mapping
1
2
3
4
5
6
7
| @Mapper(componentModel = "spring")
public interface OrderMapper {
@Mapping(target = "orderNo", source = "code")
@Mapping(target = "userName", source = "user.name") // 嵌套属性
@Mapping(target = "amount", source = "money", numberFormat = "#,##0.00")
OrderVO toVO(OrderEntity entity);
}
|
支持几个常用配置:
source / target:源字段、目标字段dateFormat:日期格式化numberFormat:数字格式化defaultValue:源为 null 时的默认值expression:自定义表达式ignore = true:跳过这个字段
类型转换:自动 + 自定义
MapStruct 已经内置了大量类型转换:
- 基本类型互转
- 包装类 ↔ 基本类型
String ↔ 日期/数字- 枚举 ↔ String/Integer
- 集合 ↔ 集合(递归调用同包的其他 mapper)
复杂转换可以用自定义方法:
1
2
3
4
5
6
7
8
9
10
11
12
| @Mapper(componentModel = "spring", uses = StatusConverter.class)
public interface UserMapper {
@Mapping(target = "statusName", source = "status")
UserVO toVO(UserEntity entity);
}
@Component
public class StatusConverter {
public String toStatusName(Integer status) {
return StatusEnum.fromCode(status).getName();
}
}
|
uses 把转换器注册进来,MapStruct 会自动按方法签名匹配——不需要在每个 @Mapping 上指定。
三、反向映射:@InheritInverseConfiguration
实体到 VO 写过映射后,VO 到实体的反向映射通常是镜像的:
1
2
3
4
5
6
7
8
9
| @Mapper(componentModel = "spring")
public interface UserMapper {
@Mapping(target = "id", source = "userId")
UserVO toVO(UserEntity entity);
@InheritInverseConfiguration // 自动反向:userId → id
UserEntity toEntity(UserVO vo);
}
|
减少重复声明。
四、更新现有对象:@MappingTarget
修改场景下,前端只传变更字段,要把 DTO 合并到现有实体:
1
2
3
4
5
6
| @Mapper(componentModel = "spring")
public interface UserMapper {
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
void updateEntity(UserUpdateDTO dto, @MappingTarget UserEntity entity);
}
|
1
2
3
| UserEntity entity = userRepo.findById(id).orElseThrow();
userMapper.updateEntity(dto, entity); // 只覆盖非 null 字段
userRepo.save(entity);
|
NullValuePropertyMappingStrategy.IGNORE 让 null 不参与覆盖——这就是"只更新前端传了的字段"语义。
五、集合与分页转换
集合转换默认就支持,注意点是集合元素的类型也要有对应映射:
1
2
3
4
5
6
7
| @Mapper(componentModel = "spring")
public interface OrderMapper {
OrderVO toVO(OrderEntity entity);
List<OrderVO> toVOList(List<OrderEntity> entities);
OrderItemVO toItemVO(OrderItemEntity item); // 子元素的映射
}
|
集合内的元素 MapStruct 会自动调对应的 toItemVO。
分页对象(Page<T>)通常这样写:
1
2
3
| default Page<UserVO> toVOPage(Page<UserEntity> page) {
return page.map(this::toVO);
}
|
default 方法允许在接口里写实现,方便混合自定义逻辑。
六、几种实用进阶用法
1. 一次返回带多源对象的合并
订单 VO 里既要订单字段又要用户字段:
1
2
3
4
| @Mapping(target = "orderNo", source = "order.orderNo")
@Mapping(target = "userName", source = "user.name")
@Mapping(target = "amount", source = "order.amount")
OrderVO toVO(OrderEntity order, UserEntity user);
|
2. 表达式 / 常量
1
2
3
| @Mapping(target = "version", constant = "1.0")
@Mapping(target = "fullName", expression = "java(entity.getFirstName() + \" \" + entity.getLastName())")
UserVO toVO(UserEntity entity);
|
3. 嵌套对象的子映射
1
2
3
| @Mapping(target = "addressVO.street", source = "address.street")
@Mapping(target = "addressVO.city", source = "address.city")
UserVO toVO(UserEntity entity);
|
4. 通用配置:@MapperConfig
很多 Mapper 共用相同设置,抽出一个配置接口:
1
2
3
4
5
6
7
8
9
10
| @MapperConfig(
componentModel = "spring",
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE
)
public interface CentralMapperConfig {}
@Mapper(config = CentralMapperConfig.class)
public interface UserMapper {
UserVO toVO(UserEntity entity);
}
|
5. 测试 Mapper 也很简单
1
2
3
4
5
6
7
8
9
10
11
| class UserMapperTest {
UserMapper mapper = Mappers.getMapper(UserMapper.class);
@Test
void shouldMapToVO() {
UserEntity entity = new UserEntity(1L, "张三", "z@x.com");
UserVO vo = mapper.toVO(entity);
assertEquals("1", vo.getId());
assertEquals("张三", vo.getUsername());
}
}
|
不依赖 Spring,单元测试很快。
七、性能对比(简单基准)
| QPS | 相对手写 |
|---|
| 手写 | 26,000,000 | 1× |
| MapStruct | 25,000,000 | 0.96× |
| BeanCopier (cglib) | 19,000,000 | 0.73× |
| Spring BeanUtils | 4,500,000 | 0.17× |
| Apache Commons BeanUtils | 1,200,000 | 0.05× |
MapStruct 几乎等于手写——这是因为它本来就是手写代码(编译时生成)。BeanUtils 那种反射方案在大数据量场景下性能差距非常显著。
八、踩坑提醒
- Lombok 一起用要加
lombok-mapstruct-binding——否则编译期看不到 Lombok 生成的 getter - 生成的代码要每次 build 一次——IDE 看不见
UserMapperImpl 是因为没编译过;Rebuild Project 一下 - 接口方法不要互相覆盖——同一个接口里两个方法签名相同,MapStruct 不知道生成哪个
uses = ... 的转换器要可被注入——自定义转换器需要 @Component 才能在 spring 模式下工作- 空集合 vs null:默认源为 null 会返回 null,很多业务希望返回空集合——加
nullValueMappingStrategy = NullValueMappingStrategy.RETURN_DEFAULT - 不要在转换器里写复杂业务——转换器应该是纯映射,不要做 DB 查询或 RPC 调用,否则 MapStruct 的"性能 + 简洁"双优势就崩了
九、一份生产级模板
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| @Mapper(
componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.WARN, // 漏字段编译警告
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
uses = { CommonConverter.class }
)
public interface UserMapper {
@Mapping(target = "id", source = "id", defaultValue = "0L")
@Mapping(target = "createTime", source = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
@Mapping(target = "statusName", source = "status")
UserVO toVO(UserEntity entity);
List<UserVO> toVOList(List<UserEntity> entities);
@InheritInverseConfiguration
@Mapping(target = "createTime", ignore = true) // 实体的时间不让前端覆盖
@Mapping(target = "id", ignore = true)
UserEntity toEntity(UserCreateDTO dto);
void updateEntity(UserUpdateDTO dto, @MappingTarget UserEntity entity);
}
|
小结
把全文压一句:
MapStruct 把『写转换代码』这件事从『反复劳动』变成『声明意图』——你只描述映射规则,编译器替你写实现,性能接近手写。
工程上几条建议:
- 新项目优先用 MapStruct,别再在生产代码里
BeanUtils.copyProperties - 配
unmappedTargetPolicy = WARN 让编译警告帮你抓漏字段 - 复杂业务用自定义 Converter,但 Converter 里不要写 RPC/DB
- Lombok 同时用一定要加 binding 依赖
把这件每天写、每天烦的事自动化掉,是工具的真正价值。