背景
项目中经常会遇到这样的一个情况:从数据库读取到数据,并不是直接返回给前端做展示的,还需要字段的加工,例如记录的时间戳是不需要的、一些敏感数据更是不能等等。传统的做法就是创建一个新的类,然后写一堆的get/set方法进行赋值,如果字段很多的话,那简直是噩梦,有时候还担心会漏掉等等。
MapStruct 介绍
MapStruct是一个Java注解处理器,它可以简化Java bean之间的转换。它使用基于生成器的方法创建类型安全的映射代码,这些代码在编译时生成,并且比反射更快、更可靠。使用MapStruct可以避免手动编写大量重复的转换代码,从而提高生产力和代码质量。
MapStruct通过使用注解,在源代码中指定映射规则,MapStruct可以自动生成转换器代码。MapStruct支持各种转换场景,包括简单类型、集合、继承、日期、枚举、嵌套映射等等。同时,它还能够与Spring和CDI等IoC容器无缝集成,方便地将MapStruct转换器注入到应用程序中。
MapStruct的官网:MapStruct – Java bean mappings, the easy way!
在开发中比较常用的用来实现JavaBean之间的转换应该就是org.springframework.beans.BeanUtils
,它俩有以下区别:
- 编译时生成代码 vs 运行时反射:MapStruct生成的映射代码是在编译时生成的,而BeanUtils则是在运行时使用反射机制实现转换。
- 性能和可扩展性:由于MapStruct生成的代码是类型安全的,因此可以比使用反射更加高效和可靠。同时,MapStruct还能够自定义转换逻辑并支持扩展,使得它更加灵活和可扩展。
- 集成方式:MapStruct可以无缝集成到Spring中,也可以与其他IoC容器结合使用;而BeanUtils是Spring框架自带的工具类。
- 映射规则的定义方式:MapStruct使用基于注解的方式在源代码中定义映射规则,而BeanUtils则需要手动编写复杂的转换方法。
MapStruct的转换代码是在编译时生成的,查看编译生成的代码可以发现其中已经加入了@Component
注解并实现了相应的Mapper接口
为什么使用MapStruct
在一些高并发的场景,性能是开发者十分重视的,BeanUtils虽然也可以方便地完成JavaBean之间的转换,但是由于其底层是基于反射实现的,在高并发场景下难免会出现大规模的数据处理和转换操作,这时候还是用BeanUtils会导致接口响应速度有所下降。
这时候,最最最高效的方法就是手动get/set,但是这种需要反复写大量重复的转换代码,并且这些代码难以被反复利用,于是就考虑使用MapStruct。
MapStruct是一种基于注解的代码生成器,它通过生成优化的映射代码来实现高性能的Bean映射。与BeanUtils相比,MapStruct在生成的映射代码中使用了更少的反射调用,并且在类型转换时可以直接使用Javac编译器已经提供的类型转换逻辑,从而避免了额外的性能开销。此外,由于MapStruct是基于注解的,它还可以提供更好的类型检查和编译时错误提示。
以下是当前比较常用的JavaBean之间转化的工具的性能对比:
可见,随着转化次数的增加只有MapStruct
的性能最接近get/set的效率。
因此,在高并发场景下,使用MapStruct可以更有效地利用系统资源,提高系统的吞吐量和响应速度。
如何使用MapStruct
接下来用一个案例来说说如何使用MapStruct
,更多详细的用法可以查看官方文档。
先引入依赖:
需要引入 mapstruct 和 mapstruct-processor,同时 scope 设置为 provided ,即它只影响到编译,测试阶段。
<dependency> <groupId>org.mapstruct</groupId> <artifactId>mapstruct</artifactId> <version>1.5.0.Final</version> <scope>provided</scope> </dependency> <dependency> <groupId>org.mapstruct</groupId> <artifactId>mapstruct-processor</artifactId> <version>1.5.0.Final</version> <scope>provided</scope> </dependency>
这边演示的是一般项目中,从数据库读取到数据,到返回前端展示的过程。
假设我们有一个student表,实体字段信息如下。
/** * <p> * 学生表 * </p> * * @author Liurb * @since 2022-11-13 */ @Getter @Setter @TableName("demo_student") public class Student implements Serializable { private static final long serialVersionUID = 1L; @TableId(value = "id", type = IdType.AUTO) private Integer id; /** * 学生名称 */ @TableField("`name`") private String name; /** * 学生年龄 */ @TableField("age") private Integer age; /** * 学生性别 */ @TableField("sex") private String sex; /** * 创建时间 */ @TableField("created_at") private LocalDateTime createdAt; }
但是前端页面展示的时候,某些字段需要调整。例如,学生信息需要展示在首页和列表页,他们的数据模型字段名称是不一致的。
学生首页展示vo 需要调整学生的 id 为 userId, 学生名称为 userName 。
/** * 学生首页展示vo * * * @Author Liurb * @Date 2022/11/13 */ @Data public class StudentHomeVo { private Integer userId; private String userName; private Integer age; private String sex; }
学生分页展示vo 需要调整学生的性别为 gender 。
/** * 学生分页展示vo * * * @Author Liurb * @Date 2022/11/13 */ @Data public class StudentPageVo { private Integer id; private String name; private Integer age; private String gender; }
创建 学生实体的mapper,由于要区分 mybatis-plus 的底层mapper,所以这里的命名以 StructMapper 结尾,尽量避免重名的情况。所以注意 @Mapper 注解也要使用 org.mapstruct 包下的。
/** * 学生实体转换接口 * * 定义这是一个MapStruct对象属性转换接口,在这个类里面规定转换规则 * * @Author Liurb * @Date 2022/11/13 */ @Mapper public interface StudentStructMapper { /** * 获取该类自动生成的实现类的实例 * */ StudentStructMapper INSTANCES = Mappers.getMapper(StudentStructMapper.class); /** * 这个方法就是用于实现对象属性复制的方法 * * @Mapping 注解 用于定义属性复制规则 * source 指定源对象属性 * target指定目标对象属性 * * @param student 这个参数就是源对象,也就是需要被复制的对象 * @return 返回的是目标对象,就是最终的结果对象 */ @Mappings({ @Mapping(source = "id", target = "userId"), @Mapping(source = "name", target = "userName") }) StudentHomeVo toStudentHomeVo(Student student); /** * 也可以实现多个复制方法,一般将一个实体源对象的转换写在一起 * * @param student * @return */ @Mapping(source = "sex", target = "gender") StudentPageVo toStudentPageVo(Student student); }
测试
我们创建一个controller,模拟一般项目的接口请求。
/** * mapstruct实例控制器 * * @Author Liurb * @Date 2022/11/13 */ @RestController @RequestMapping("/demo_api/mapstruct") public class MapStructController { @Resource StudentService studentService; @GetMapping("/home/{id}") public StudentHomeVo home(@PathVariable("id")Integer id) { Student student = studentService.getById(id); StudentHomeVo studentHomeVo = StudentStructMapper.INSTANCES.toStudentHomeVo(student); return studentHomeVo; } @GetMapping("/page") public List<StudentPageVo> page() { List<Student> students = studentService.list(); List<StudentPageVo> studentPageVos = students.stream().map(item -> { StudentPageVo studentPageVo = StudentStructMapper.INSTANCES.toStudentPageVo(item); return studentPageVo; }).collect(Collectors.toList()); return studentPageVos; } }
数据表的记录如下
调用首页展示接口的情况如下,可以看到,返回的新字段已经成功赋值。
接下来,看一下分页的数据,新字段 性别 gender 也同样赋值成功。
遇到的坑
1、java.lang.NoSuchMethodError
如果现在我们将学生首页vo类的 age 字段,调整为 userAge,运行项目,在请求一次接口,你会发现这时候会报错,提示找不到 setAge 方法。
为什么会这样呢?其实原因在于上面说的 MapStruct 工作原理,这时候查看转换接口的实现就可以知道是什么情况了。
实现类还是调用的 setAge 方法进行赋值,但是我们的 StudentHomeVo 已经被我们改过,没有这个方法了,所以运行时候就会报错了。
那么这种情况如何解决了,其实也很简单,重新编译一次项目就可以了。如果发现调整了字段,或者改过转换mapper的东西后,出现奇奇怪怪的情况,一种重新编译一下项目就能解决。
2、复制出现空值
这是一个隐藏很深的坑,以至于运行后出现 Null 异常才发现,为什么会出现复制失败的情况呢,明明字段名称都是一样的。
其实这跟我们使用了 lombok 有关,至于它是个什么东西有什么用,笔者就不在这里阐述,但是有一点很重要,它也是工作在编译阶段的。
我们先看看出问题的 MapStruct 实现类是怎么样的,如下图:
public class StudentStructMapperImpl implements StudentStructMapper { public StudentStructMapperImpl() { } public StudentHomeVo toStudentHomeVo(Student student) { if (student == null) { return null; } else { StudentHomeVo studentHomeVo = new StudentHomeVo(); return studentHomeVo; } } public StudentPageVo toStudentPageVo(Student student) { if (student == null) { return null; } else { StudentPageVo studentPageVo = new StudentPageVo(); return studentPageVo; } } }
MapStruct 只是做了实例化 Vo 的操作,并没有进行赋值!
我们来看看正常情况,如下图:
public class StudentStructMapperImpl implements StudentStructMapper { public StudentStructMapperImpl() { } public StudentHomeVo toStudentHomeVo(Student student) { if (student == null) { return null; } else { StudentHomeVo studentHomeVo = new StudentHomeVo(); studentHomeVo.setUserId(student.getId()); studentHomeVo.setUserName(student.getName()); studentHomeVo.setAge(student.getAge()); studentHomeVo.setSex(student.getSex()); return studentHomeVo; } } public StudentPageVo toStudentPageVo(Student student) { if (student == null) { return null; } else { StudentPageVo studentPageVo = new StudentPageVo(); studentPageVo.setGender(student.getSex()); studentPageVo.setId(student.getId()); studentPageVo.setName(student.getName()); studentPageVo.setAge(student.getAge()); return studentPageVo; } } }
那为什么 MapStruct 没有帮我们进行赋值呢?因为它并没有找到复制字段对应的 get/set 方法啊!
那为什么没有找到呢,明明编译好的 Vo 类里面有的啊!所以这里就涉及到工作顺序的问题,必要要让 lombok 的工作在前面,让它将 Vo 类的 get/set 方法生成了,再让 MapStruct 帮我们进行复制。
所以最终解决方法,调整 pom 文件的依赖加载,必要让 lombok 在 MapStruct 的前面。
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> <dependency> <groupId>org.mapstruct</groupId> <artifactId>mapstruct</artifactId> <scope>compile</scope> </dependency> <dependency> <groupId>org.mapstruct</groupId> <artifactId>mapstruct-processor</artifactId> <scope>compile</scope> </dependency>
MapStruct同样支持Map映射到DTO
import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.factory.Mappers; import UserDto; @Mapper public interface MapStructMapper { MapStructMapper INSTANCE = Mappers.getMapper(MapStructMapper.class); // 自定义 Object -> String 映射方法 default String map(Object value) { return value != null ? value.toString() : null; } @Mapping(target = "userId", source = "user_id") @Mapping(target = "userName", source = "user_name") @Mapping(target = "age", source = "age") UserDto mapToDTO(Map<String, Object> map); }