JAVA

【MapStruct】还在用BeanUtils?不如试试MapStruct

背景


项目中经常会遇到这样的一个情况:从数据库读取到数据,并不是直接返回给前端做展示的,还需要字段的加工,例如记录的时间戳是不需要的、一些敏感数据更是不能等等。传统的做法就是创建一个新的类,然后写一堆的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);

}