浅显理解 Bean 映射的原理与选择

为什么需要bean映射?

在分层架构中,我们通常会看到DTO、VO、PO、DO等多种对象,他们目的只有一个:通过分层和对象隔离,实现系统解耦,让不同层的变化不会相互影响。不同层使用不同的对象:
DTO(Data Transfer Object):用于前端与后端之间的数据传输
VO(View Object):用于后端返回给前端的展示数据
DO(Domain Object):用于承载业务逻辑
PO(Persistence Object):用于与数据库表结构映射
如下图所示
image

解耦之后,某一层的变化不会直接影响其他层,前端字段变化 → 只影响 DTO / VO,DO和PO不一定需要加字段,由于不同层对象结构不完全一致,就需要进行转换

如何做bean映射?

Bean映射的本质就是对不同层的对象进行数据转换,我们假设这里有两个对象:

// 1. 前端传来的请求 —— DTO
public class UserCreateDTO {
    private String username;
    private String password;
    private String email;
    private String phone;
}

// 2. Service 层用的 —— DO
public class UserDO {
    private Long userId;
    private String username;
    private String encryptedPassword;
    private String email;
    private String phone;
    private Integer status;       // DTO 里没有,需要默认值
    private String createTime;    // DTO 里没有,需要当前时间
}

第一种方式是手写转换器实现bean之间的转换:

// 手写      DTO-->DO
 public static UserDO toDO(UserCreateDTO dto) {
      if (dto == null) {
        return null;
      }
      UserDO userDO = new UserDO();
      userDO.setUsername(dto.getUsername());
      // 密码加密,业务逻辑
      userDO.setEncryptedPassword(md5Encrypt(dto.getPassword()));
      userDO.setEmail(dto.getEmail());
      userDO.setPhone(dto.getPhone());
      // DTO 没有的字段,业务层自己填
      userDO.setStatus(1);
      userDO.setCreateTime(LocalDateTime.now().toString());
      return userDO;
  }

第二种方式是Spring Beanutils,如果字段不一致,会直接跳过的,需要手动补充。

public UserDO toDO(UserCreateDTO dto) {
    if (dto == null) return null;
    
    UserDO userDO = new UserDO();
    BeanUtils.copyProperties(dto, userDO);  // 同名字段自动拷过去

    userDO.setEncryptedPassword(md5(dto.getPassword()));
    userDO.setStatus(1);
    userDO.setCreateTime(LocalDateTime.now().toString());
    return userDO;
}

第三种,也是最推荐的一种方式是MapStruct:

@Mapper
public interface UserConverter {

    @Mapping(source = "password", target = "encryptedPassword")
    @Mapping(target = "userId", ignore = true)
    @Mapping(target = "status", constant = "1")
    @Mapping(target = "createTime", expression = "java(java.time.LocalDateTime.now().toString())")
    UserDO toDO(UserCreateDTO dto);
}
// 编译后会自动生成一个实现类
public class UserConverterImpl implements UserConverter {

    @Override
    public UserDO toDO(UserCreateDTO dto) {
        if (dto == null) return null;

        UserDO userDO = new UserDO();

        // 同名字段,自动拷
        userDO.setUsername(dto.getUsername());
        userDO.setEmail(dto.getEmail());
        userDO.setPhone(dto.getPhone());

        // 字段名不一样 —— @Mapping 指定的
        userDO.setEncryptedPassword(dto.getPassword());

        // 源没有的字段
        userDO.setStatus(1);
        userDO.setCreateTime(LocalDateTime.now().toString());

        return userDO;
    }
}

原理和区别

这里只探讨BeanUtils和MapStruct。BeanUtils 提供开箱即用的对象拷贝能力,通过运行时反射实现字段匹配,使用简单但映射是隐式的。MapStruct 则要求显式定义映射接口,通过编译期生成代码实现转换逻辑,使映射关系变得可见、可控。

还记得mapstruct我们怎么使用的吗,只需要编写接口规范,然后编译器就会自动生成实现类,如何做到的呢?
首先MapStruct的本质就是一个注解处理器,我们平时javac编译的时候,javac也是一个程序,他会调用一系列注解处理器,javac程序有这一步的。我们导入MapStrcut之后,通过SPI机制,javac会发现这个注解处理器,然后调用他的逻辑,就会去生成对应的实现类。所以MapStruct是在编译期生成实现类的。
大概流程如下:

写 @Mapper 接口
    ↓
javac 编译时触发注解处理器
    ↓
MappingProcessor 分析接口定义
    ↓
FreeMarker 模板渲染生成 Impl.java
    ↓
生成的代码就是 target.setXxx(source.getXxx())
    ↓
和手写一样,没有用到反射,没有额外性能消耗

那么Spring BeanUtils呢,他是通过反射技术来实现bean的映射的,大概流程如下:

调用 BeanUtils.copyProperties(source, target)
    ↓
获取 source 的所有 getter 方法
    ↓
遍历,找到 target 中同名同类型的 setter
    ↓
反射调用:target.setXxx(source.getXxx())

用到反射,虽然很灵活,但是反射带来的性能损失也是不能忽略的,并且spring beanutils只能映射同名的属性。

这里再提一点容易踩的坑,springutils和mapstcut默认都是是浅拷贝,但是MapStruct可以通过@Mapping指定拷贝方式: @Mapping(target = "address", source = "address", qualifiedByName = "deepCopy")

posted @ 2026-07-06 21:14  布鲁si  阅读(5)  评论(0)    收藏  举报