Featured image of post Java MapStruct 实战:替你写完所有 BO/DTO/VO 转换

Java MapStruct 实战:替你写完所有 BO/DTO/VO 转换

BeanUtils.copyProperties 的取代品——编译期生成转换代码、类型安全、性能接近手写,本文讲透 MapStruct 的核心用法

这件事每个 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
MapStruct25,000,0000.96×
BeanCopier (cglib)19,000,0000.73×
Spring BeanUtils4,500,0000.17×
Apache Commons BeanUtils1,200,0000.05×

MapStruct 几乎等于手写——这是因为它本来就是手写代码(编译时生成)。BeanUtils 那种反射方案在大数据量场景下性能差距非常显著。


八、踩坑提醒

  1. Lombok 一起用要加 lombok-mapstruct-binding——否则编译期看不到 Lombok 生成的 getter
  2. 生成的代码要每次 build 一次——IDE 看不见 UserMapperImpl 是因为没编译过;Rebuild Project 一下
  3. 接口方法不要互相覆盖——同一个接口里两个方法签名相同,MapStruct 不知道生成哪个
  4. uses = ... 的转换器要可被注入——自定义转换器需要 @Component 才能在 spring 模式下工作
  5. 空集合 vs null:默认源为 null 会返回 null,很多业务希望返回空集合——加 nullValueMappingStrategy = NullValueMappingStrategy.RETURN_DEFAULT
  6. 不要在转换器里写复杂业务——转换器应该是纯映射,不要做 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 依赖

把这件每天写、每天烦的事自动化掉,是工具的真正价值。

使用 Hugo 构建
主题 StackJimmy 设计