是时候丢掉 BeanUtils 了!

我是程序汪

共 11300字,需浏览 23分钟

 ·

2024-07-23 09:30

来源:cnblogs.com/jtea/p/17592696.html

前言

为了更好的进行开发和维护,我们都会对程序进行分层设计,例如常见的三层,四层,每层各司其职,相互配合。也随着分层,出现了 VO,BO,PO,DTO,每层都会处理自己的数据对象,然后向上传递,这就避免不了经常要将一个对象的属性拷贝给另一个对象。

例如我有一个 User 对象和一个 UserVO 对象,要将 User 对象的10个属性赋值个 UserVO 的同名属性:

  • 一种方式是手写,一个属性一个属性赋值,相信大家最开始学习时都是这么干的,这种方式就是太低效了。
  • 在 idea 中可以安装插件帮我们快速生成 set 属性代码,虽然还是逐个属性赋值,但比一个个敲,效率提高了很多。

上面两种方式虽然最原始,做起来很麻烦,容易出错,但程序运行效率是最高的,现在仍有不少公司要求这么做,一是这样运行效率高,二是不需要引入其它的组件,避免出现其它问题。

但对于我们来说,这种操作要是多了,开发效率和代码可维护性都会受到影响,这种赋值属性代码很长,看起来很不舒服,所以有了下面几种方式。

bean copier

apache 的 BeanUtils,内部使用了反射,效率很低,在《阿里java开发规范中》明令禁止使用,这里就不过多讨论。

图片

spring的BeanUtils,对 apache BeanUtils 做了优化,运行效率较高,可以使用。

BeanUtils.copyProperties(source, target);
BeanUtils.copyProperties(source, target, "id""createTime"); //不拷贝指定的字段

cglib 的 BeanCopier,使用动态技术代替反射,在运行时生成一个子类,只有在第一次动态生成类时慢,后面基本就本接近原始的set,所以呀运行效率比上面两种要高很多。

BeanCopier beanCopier = BeanCopier.create(SourceData.class, TargetData.class, false); 
beanCopier.copy(source, target, null);

我们使用的是Spring BeanUtils,至少出现过两次问题:

  • 一次是拷贝一方的对象类型变了,由int变成long,source.id int 拷贝到 target.id long 结果是空,因为类型不匹配,BeanUtils 不会拷贝。由于是使用反射,所以当时修改类型时,只修改了编译报错的地方,忘记这种方式,导致结果都是空,这也很难怪开发,这种方式太隐蔽了。同样如果属性重命名,也会得到一个空,并且只能在运行时发现。
  • 另一次拷贝的时候会把所有属性都拷过去,漏掉忽略主键 id,结果在插入的时候报了唯一索引冲突。我们的场景比较特殊,idcreateTimeupdateTime 这三个字段是表必须有的,通常也是不能被拷贝的,如果每个地方都手写忽略,代码比较麻烦也容易忘记。

上面3种方式都非常简单,意味着功能非常有限,如果你有一些复杂场景的拷贝,它们就无法支持,例如深拷贝,拷贝一个 List。

另外一个最重要的点是:它们都是运行时的,这意味着你无法在编译时得到任何帮助,无法提前发现问题。

从标题可以看出我们本篇要讲的是另一个 copier:MapStruct,接下来就看下它是如何解决我们问题的。

MapStruct

MapStruct 是一个基于 Java 注解处理器,用于生成类型安全且高性能的映射器。总结一下它有以下优点:

  • 高性能。 使用普通方法赋值,而非反射,MapStruct 会在编译期间生成类,使用原生的 set 方法进行赋值,所以效率和手写 set 基本是一样的。
  • 类型安全。 MapStruct 是编译时的,所以一旦有类型、名称等不匹配问题,就可以提前编译报错。
  • 功能丰富。 MapStruct 的功能非常丰富,例如支持深拷贝,指定各种拷贝行为。
  • 使用简单。 你所需要做的就是定义接口和拷贝的行为,MapStruct 会在编译期生成实现类。
示例

和学习其它组件一样,我们先用起来,准备两个类,SourceDataTargetData 属性完全一样,其中 TestData 是另一个类。

public class SourceData {

    private String id;
    private String name;
    private TestData data;
    private Long createTime;

    public String getId() {
            return id;
    }
    public void setId(String id) {
            this.id = id;
    }
    public String getName() {
            return name;
    }
    public void setName(String name) {
            this.name = name;
    }
    public TestData getData() {
            return data;
    }
    public void setData(TestData data) {
            this.data = data;
    }
    public Long getCreateTime() {
            return createTime;
    }
    public void setCreateTime(Long createTime) {
            this.createTime = createTime;
    }
}
导入包 pom
<dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct</artifactId>
    <version>${org.mapstruct.version}</version>
</dependency>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.8.1</version>
            <configuration>
                <source>1.8</source>
                <target>1.8</target>
                <annotationProcessorPaths>
                    <path>
                        <groupId>org.mapstruct</groupId>
                        <artifactId>mapstruct-processor</artifactId>
                        <version>${org.mapstruct.version}</version>
                    </path>
                </annotationProcessorPaths>
            </configuration>
        </plugin>
    </plugins>
</build>
定义接口

这里的 Mapper 是 MapStruct 的,可不是 Mybatis 的。

@Mapper
public interface BeanMapper {
    BeanMapper INSTANCE = Mappers.getMapper(BeanMapper.class);
    TargetData map(SourceData source);
}
使用
SourceData source = new SourceData();
source.setId("123");
source.setName("abc");
source.setCreateTime(System.currentTimeMillis());
TestData testData = new TestData();
testData.setId("123");

TargetData target = BeanMapper.INSTANCE.map(source);
System.out.println(target.getId() + ":" + target.getName() + ":" + target.getCreateTime());
//true
System.out.println(source.getData() == target.getData());

可以看到使用非常简单,默认情况下 MapStruct 是浅拷贝,所以看到最后一个输出是 true。编译后我们可以在 target 目录下找到帮我们生成的一个接口实现类 BeanMapperImpl,如下:

图片
深拷贝

可以看到它也是帮生成 set 代码,且默认是浅拷贝,所以上面最后一个输出是 true。如果想变成深拷贝,在 map 方法上标记一下 DeepClone 即可:

@Mapping(target = "data", mappingControl = DeepClone.class) 
TargetData map(SourceData source);

重新编译一下,看到生成的代码变成如下,这次是深拷贝了。

图片
集合拷贝

支持,新增一个接口方法即可。

List<TestData> map(List<TestData> source);   
类型不一致

如果我将 TargetData 的 createTime 改成 int 类型,再编译一下,生成代码如下:

图片

可以看到它会默认帮我们转换,但这是个隐藏的问题,如果我希望它能在编译时就提示,那么可以在 Mapper 注解上指定一些类型转换的策略是报错,如下:

@Mapper(typeConversionPolicy = ReportingPolicy.ERROR)

重新编译会提示错误:

java: Can't map property "Long createTime". It has a possibly lossy conversion from Long to Integer.
禁止隐式转换

如果我将类型改成 String 呢,编译又正常了,生成代码如下:

图片

对于 String 和其它基础类型的包装类,它会隐式帮我们转换,这也是个隐藏问题,如果我希望它能在编译时就提示,可以定义一个注解,并在 Mapper 中指定它,如下:

@Retention(RetentionPolicy.CLASS)
@MappingControl(MappingControl.Use.DIRECT)
@MappingControl(MappingControl.Use.MAPPING_METHOD)
@MappingControl(MappingControl.Use.COMPLEX_MAPPING)
public @interface ConversationMapping {
}

@Mapper(typeConversionPolicy = ReportingPolicy.ERROR, mappingControl = ConversationMapping.class)

重新编译会提示报错:

java: Can't map property "Long createTime" to "String createTime". Consider to declare/implement a mapping method: "String map(Long value)".

这个可以参见 issus 上的讨论:issus1428  issus3186

忽略指定字段

忽略字段可以使用 Mapping 注解的 ignore 属性,如下:

@Mapping(target = "id", ignore = true)

如果我想忽略某些字段,并且复用起来,就像我们的场景应用,可以定义一个IgnoreFixedField注解,然后打在方法上

@Mapping(target = "id", ignore = true)
@Mapping(target = "createTime", ignore = true)
@Mapping(target = "updateTime", ignore = true)
@Target(METHOD)
@Retention(RUNTIME)
@Documented
@interface IgnoreFixedField {
}

@IgnoreFixedField
@Mapping(target = "data", mappingControl = DeepClone.class)
TargetData map(SourceData source);

这样只要打上这个注解,这3个字段就不会拷贝了。

与 lombok 集成

如果你的项目使用了 lombok,上面的代码可能没法正常工作。需要在 maven 对 lombok 也做下配置,在上面的 annotationProcessorPaths 加入如下配置即可。

<path>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.24</version>
</path>

上面只是结合本人的实际场景的一些例子,MapStruct 还有更多的功能,参见官方文档。

总结

会用之后我们可以学习一下它的原理了,这也是我们平时学习一个新的东西的习惯,别一下子就扎到原理,源码里头,这样会严重打击学习热情,要先跑起来先,看到成果后你会更有激情学习下去。

其实 MapStruct 的原理和 lombok 是一样的,都是在编译期间生成代码,而不会影响运行时。例如我们最常见的 @Data 注解,查看源文件你会发现 getter/setter 生成了,源文件的类不会有 @Data 注解。

java 代码编译和执行的整个过程包含三个主要机制:

  1. java源码编译机制
  2. 类加载机制
  3. 类执行机制。

其中 java 源码编译由3个过程组成:

  1. 分析和输入到符号表
  2. 注解处理
  3. 语义分析和生成class文件。

如下:

图片

其中 annotation processing 就是注解处理,jdk7 之前采用 APT技术,之后的版本使用了 JSR 269 API。

JSR 是什么?java Specification Requests,Java 规范提案,是指向 JCP(Java Community Process)提出新增一个标准化技术规范的正式请求。jsr 269 是什么?在这里[1]

注解我们非常熟悉,其实java里的注解有两种,一种是运行时注解,如常用 @Resource@Autowired,另一种是编译时注解,如 lombok 的 @Data

编译时注解主要作用是在编译期间生成代码,这样就可以避免在运行时使用反射。编译时注解处理核心接口是 Processor,它有一个抽象实现类 AbstractProcessor 封装了许多功能,如果要实现继承它即可。

知道原理后,我们完全可以模仿 lombok 写一个简单的生成器。

关于性能,知道原理后其实你也知道根本不用担心mapstruct的性能问题了,可以参考这个:benchmark[2]

如果要说它的缺点,就是得为了这个简单的拷贝功能导这个包,如果你的程序只有很少的拷贝,那手动写一下也未尝不可,如果有大量拷贝需求,那就推荐使用了。

程序汪接私活项目目录,2023年总结

Java项目分享  最新整理全集,找项目不累啦 07版

程序汪10万接的无线共享充电宝项目,开发周期3个月

程序汪1万接的企业官网项目,开发周期15天

程序汪8万接的共享口罩项目,开发周期1个月

程序汪8万块的饮水机物联网私活项目经验分享

程序汪接的4万智慧餐饮项目

程序汪接的酒店在线开房项目,另外一个好听的名字叫智慧酒店


欢迎添加程序汪个人微信 itwang008  进粉丝群或围观朋友圈

浏览 283
2点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
2点赞
评论
收藏
分享

手机扫一扫分享

举报