前言

平时开发中,我们经常会遇到对象属性复制的场景。比如,阿里的 Java 开发手册中介绍了如下几种分层领域模型规约。

  • DO(Data Object):此对象与数据库表结构一一对应,通过 DAO 层向上传输数据源对象。
  • DTO(Data Transfer Object):数据传输对象,Service 或 Manager 向外传输的对象。
  • BO(Business Object):业务对象,可以由 Service 层输出的封装业务逻辑的对象。
  • Query:数据查询对象,各层接收上层的查询请求。 注意超过 2 个参数的查询封装,禁止使用 Map 类来传输。
  • VO(View Object):显示层对象,通常是 Web 向模板渲染引擎层传输的对象。

在不同的层级,我们经常需要转换对象,比如将数据传输对象(DTO)转换成数据源对象(DO),然后保存到数据库中。

当业务简单的时候,我们手写代码,通过 getter/setter 复制对象属性,十分简单。但是一旦业务变的复杂,对象属性变得很多,那么手写代码就会成为程序员的噩梦。

目前市面上出现了一些对象属性复制工具类,使用起来非常方便,代替了手写 getter/setter 方法。虽然这些工具类的核心功能都是一样的,但是还是存在很多差异。新手看到可能会一脸懵逼,不知道如何选择。

所以本章介绍一下市面上常用的工具类:

  • Apache BeanUtils
  • Spring BeanUtils
  • Cglib BeanCopier
  • Dozer
  • orika
  • MapStruct

在介绍这些工具类之前,我们来看下一个好用的属性复制工具类,需要有哪些特性:

  • 基本属性复制,这个是基本功能。
  • 不同类型的属性赋值,比如基本类型与其包装类型等。
  • 不同字段名属性赋值,当然字段名应该尽量保持一致,但是实际业务中,由于不同开发人员,或者笔误拼错单词,这些原因都可能导致会字段名不一致的情况。
  • 浅拷贝 / 深拷贝,浅拷贝会引用同一对象,如果稍微不慎,同时改动对象,就会踩到意想不到的坑。

    正文

为了演示工具类的效果,我们引入两个测试类:StudentDTO 和 StudentDO。
对象复制工具类简介 - 图1

Apache BeanUtils

引入工具类的依赖:

  1. <dependency>
  2. <groupId>commons-beanutils</groupId>
  3. <artifactId>commons-beanutils</artifactId>
  4. <version>1.9.4</version>
  5. </dependency>

比如,我们需要将 DTO 对象转化成 DO 对象,只需要简单调用 BeanUtils#copyProperties 方法就可以完成对象属性的复制。

  1. StudentDTO studentDTO = new StudentDTO();
  2. studentDTO.setName("阿粉");
  3. studentDTO.setAge(18);
  4. studentDTO.setNo("6666");
  5. List<String> subjects = new ArrayList<>();
  6. subjects.add("math");
  7. subjects.add("english");
  8. studentDTO.setSubjects(subjects);
  9. studentDTO.setCourse(new Course("CS-1"));
  10. studentDTO.setCreateDate("2020-08-08");
  11. StudentDO studentDO = new StudentDO();
  12. BeanUtils.copyProperties(studentDO, studentDTO);

不过,上面的代码如果你这么写,我们会碰到第一个问题,BeanUtils 默认不支持 String 类型转成 Date 类型。
对象复制工具类简介 - 图2
为了解决这个问题,我们需要自己构造一个 Converter 转换类,然后使用 ConvertUtils 注册,使用方法如下:

  1. ConvertUtils.register(new Converter() {
  2. @SneakyThrows
  3. @Override
  4. public <Date> Date convert(Class<Date> type, Object value) {
  5. if (value == null) {
  6. return null;
  7. }
  8. if (value instanceof String) {
  9. String str = (String) value;
  10. return (Date) DateUtils.parseDate(str, "yyyy-MM-dd");
  11. }
  12. return null;
  13. }
  14. }, Date.class);

此时,我们观察 studentDO 与 studentDTO 对象属性值:
对象复制工具类简介 - 图3
从上面的图我们可以得出 BeanUtils 一些结论:

  • 字段名不一致,属性无法复制。
  • 引用类型字段,目标对象与源对象一样都指向同一个对象,即是浅拷贝。
  • 类型不一致的字段,将会进行类型转化。

虽然 BeanUtils 使用起来很方便,不过其底层源码为了追求完美,加了过多的包装,使用了很多反射,做了很多校验,所以导致性能较差,所以并阿里巴巴开发手册上强制规定避免使用 Apache BeanUtils。
image.png

Spring BeanUtils

Spring 属性复制工具类类名与 Apache 一样,基本用法也差不多。我先来看下 Spring BeanUtils 基本用法。

同样,我们先引入依赖,从名字我们可以看出,BeanUtils 位于 Spring-Beans 模块。

  1. <dependency>
  2. <groupId>org.springframework</groupId>
  3. <artifactId>spring-beans</artifactId>
  4. <version>5.2.8.RELEASE</version>
  5. </dependency>

复用上面的例子,将工具类改成 Spring BeanUtils。

  1. ......
  2. BeanUtils.copyProperties(studentDTO, studentDO);

注意一下,Spring BeanUtils 与 Apache BeanUtils 有一个最大的不同,两者源对象与目标对象参数位置不一样。

对比 studentDO 与 studentDTO 对象:
对象复制工具类简介 - 图5
从上面的对比图我们可以得到一些结论:

  • 字段名不一致,属性无法复制。
  • 类型不一致,属性无法复制。但是如果类型为基本类型及其包装类,这种可以复制。
  • 引用类型字段,目标对象与源对象一样都指向同一个对象,即是浅拷贝。

除了这个方法之外,Spring BeanUtils 还提供了一个重载方法:

  1. public static void copyProperties(Object source, Object target, String... ignoreProperties)

使用这个方法,我们可以忽略某些不想被复制过去的属性:

  1. BeanUtils.copyProperties(studentDTO, studentDO, "name");

这样,name 属性就不会被复制到 DO 对象中。
对象复制工具类简介 - 图6
虽然 Spring BeanUtils 与 Apache BeanUtils 功能差不多,但是在性能上 Spring BeanUtils 还是完爆 Apache BeanUtils。

主要原因还是在于 Spring 并没有与 Apache 一样使用反射做了过多校验,另外 Spring BeanUtils 内部使用了缓存,加快转换的速度。

所以两者选择,还是推荐使用 Spring BeanUtils。

Cglib BeanCopier

引入 Cglib 依赖:

  1. <dependency>
  2. <groupId>cglib</groupId>
  3. <artifactId>cglib</artifactId>
  4. <version>3.3.0</version>
  5. </dependency>

测试代码如下:

  1. // 省略赋值语句
  2. StudentDO studentDO = new StudentDO();
  3. BeanCopier beanCopier = BeanCopier.create(StudentDTO.class, StudentDO.class, false);
  4. beanCopier.copy(studentDTO, studentDO, null);

对比 BeanUtils,BeanCopier 多了一步。对比 studentDO 与 studentDTO 对象:
对象复制工具类简介 - 图7
从上面可以得到与 Spring BeanUtils 基本一致的结论:

  • 字段名不一致,属性无法复制。
  • 类型不一致,属性无法复制。需要注意的是,即使是基本类型及其包装类也不能复制。
  • 引用类型字段,目标对象与源对象一样都指向同一个对象,即是浅拷贝。

上面我们使用 Spring BeanUtils,遇到这种字段名,类型不一致的这种情况,我们没有什么好办法,只能手写硬编码。

不过在 BeanCopier 下,我们可以引入转换器,进行类型转换。

  1. // 注意最后一个属性设置为 true
  2. BeanCopier beanCopier = BeanCopier.create(StudentDTO.class, StudentDO.class, true);
  3. // 自定义转换器
  4. beanCopier.copy(studentDTO, studentDO, new Converter() {
  5. @Override
  6. public Object convert(Object source, Class target, Object context) {
  7. if (source instanceof Integer) {
  8. Integer num = (Integer) source;
  9. return num.toString();
  10. }
  11. return null;
  12. }
  13. });

不过转换器的使用也很鸡肋,一旦我们使用了转换器,所有属性复制都需要我们自己实现。

比如上面的例子中,我们只处理当源对象字段类型为 Integer 的这种情况,其他都没处理。我们得到 DO 对象将会只有 age 属性才会被复制。
对象复制工具类简介 - 图8
基于转换器的使用效果,就不推荐使用转换器了。

Cglib BeanCopier 的原理与上面两个 BeanUtils 原理不太一样,其主要使用字节码技术动态生成一个代理类,代理类实现 get 和 set 方法。代理类的生成过程存在一定开销,但是一旦生成,我们可以缓存起来重复使用,所有 Cglib 性能比以上两种 BeanUtils 性能都要好。

Dozer

Dozer 中文直译为挖土机,这是一个重量级属性复制工具类,相比于上面介绍三种工具类,Dozer 具有很多强大的功能。

引入 Dozer 依赖:

  1. <dependency>
  2. <groupId>net.sf.dozer</groupId>
  3. <artifactId>dozer</artifactId>
  4. <version>5.4.0</version>
  5. </dependency>

使用方法如下:

  1. // 省略属性的代码
  2. DozerBeanMapper mapper = new DozerBeanMapper();
  3. StudentDO studentDO = mapper.map(studentDTO, StudentDO.class);
  4. System.out.println(studentDO);

Dozer 需要我们新建一个 DozerBeanMapper,这个类作用等同与 BeanUtils,负责对象之间的映射,属性复制。

生成 DozerBeanMapper 实例需要加载配置文件,随意生成代价比较高。在我们应用程序中,应该使用单例模式,重复使用 DozerBeanMapper。

如果属性都是一些简单基本类型,那我们只要使用上面代码,可以快速完成属性复制。

不过很不幸,我们的代码中有字符串与 Date 类型转化,如果我们直接使用上面的代码,程序运行将会抛出异常。
对象复制工具类简介 - 图9
所以这里我们要用到 Dozer 强大的配置功能,Dozer 提供了三种配置方式:XML,API,注解。

其中,API 的方式比较繁琐,目前大部分使用 XML 进行,另外注解功能的是在 Dozer 5.3.2 之后增加的新功能,不过功能相较于 XML 来说较弱。

XML 方式

下面我们使用 XML 配置方式,配置 DTO 与 DO 关系,首先我们新建一个 dozer/dozer-mapping.xml 文件:

  1. <?xml version="1.0" encoding="UTF-8"?>
  2. <mappings xmlns="http://dozer.sourceforge.net" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  3. xsi:schemaLocation="http://dozer.sourceforge.net
  4. http://dozer.sourceforge.net/schema/beanmapping.xsd">
  5. <!-- 类级别的日期转换,默认使用这个格式转换 -->
  6. <mapping date-format="yyyy-MM-dd HH:mm:ss">
  7. <class-a>com.just.doone.example.domain.StudentDTO</class-a>
  8. <class-b>com.just.doone.example.domain.StudentDO</class-b>
  9. <!-- 在下面指定字段名不一致的映射关系 -->
  10. <field>
  11. <a>no</a>
  12. <b>number</b>
  13. </field>
  14. <field>
  15. <!-- 字段级别的日期转换,将会覆盖字段上的转换 -->
  16. <a date-format="yy-MM-dd">createDate</a>
  17. <b>createDate</b>
  18. </field>
  19. </mapping>
  20. </mappings>

然后修改我们的 Java 代码,增加读取 Dozer 的配置文件:

  1. DozerBeanMapper mapper = new DozerBeanMapper();
  2. List<String> mappingFiles = new ArrayList<>();
  3. // 读取配置文件
  4. mappingFiles.add("dozer/dozer-mapping.xml");
  5. mapper.setMappingFiles(mappingFiles);
  6. StudentDO studentDO = mapper.map(studentDTO, StudentDO.class);
  7. System.out.println(studentDO);

运行之后,对比 studentDO 与 studentDTO 对象:
对象复制工具类简介 - 图10
从上面的图我们可以发现:

  • 部分类型不一致,支持自动转换。
  • 引用类型字段,目标对象与源对象指向的不是同一个对象,也就是深拷贝。
  • 通过配置字段名的映射关系,不一样字段的属性也被复制。

除了上述这些相对简单的属性以外,Dozer 还支持很多额外的功能,比如枚举属性复制,Map 等集合属性复制等。
对象复制工具类简介 - 图11
有些小伙伴刚看到 Dozer 的用法,可能觉得这个工具类比较繁琐,不像 BeanUtils 工具类一样一行代码就可以复制属性。

其实 Dozer 可以很好跟 Spring 框架整合,我们可以在 Spring 配置文件提前配置,后续我们只要引用 Dozer 的相应的 Bean,使用方式也是一行代码。

Dozer 与 Spring 整合,我们可以使用其 DozerBeanMapperFactoryBean,配置如下:

  1. <bean class="org.dozer.spring.DozerBeanMapperFactoryBean">
  2. <property name="mappingFiles"
  3. value="classpath*:/*mapping.xml"/>
  4. <!-- 自定义转换器 -->
  5. <property name="customConverters">
  6. <list>
  7. <bean class=
  8. "org.dozer.converters.CustomConverter"/>
  9. </list>
  10. </property>
  11. </bean>

DozerBeanMapperFactoryBean 支持设置属性比较多,可以自定义设置类型转换,还可以设置其他属性。

另外还有一种简单的方法,我们可以在 XML 中配置 DozerBeanMapper:

  1. <bean id="org.dozer.Mapper" class="org.dozer.DozerBeanMapper">
  2. <property name="mappingFiles">
  3. <list>
  4. <value>dozer/dozer-Mapperpping.xml</value>
  5. </list>
  6. </property>
  7. </bean>

Spring 配置完成之后,我们在代码中可以直接注入:

  1. @Autowired
  2. Mapper mapper;
  3. public void objMapping(StudentDTO studentDTO) {
  4. // 直接使用
  5. StudentDO studentDO = mapper.map(studentDTO, StudentDO.class);
  6. }

注解方式

Dozer 注解方式相比 XML 配置来说功能很弱,只能完成字段名不一致的映射。

上面的代码中,我们可以在 DTO 的 no 字段上使用 @Mapping 注解,这样我们在使用 Dozer 完成转换时,该字段属性将会被复制。

  1. @Data
  2. public class StudentDTO {
  3. private String name;
  4. private Integer age;
  5. @Mapping("number")
  6. private String no;
  7. private List<String> subjects;
  8. private Course course;
  9. private String createDate;
  10. }

虽然目前注解功能有点薄弱,不过后看版本官方可能增加新的注解功能,另外 XML 与注解可以一起使用。

最后 Dozer 底层本质上还是使用了反射完成属性的复制,所以执行速度并不是那么理想。

Orika

Orika 也是一个跟 Dozer 类似的重量级属性复制工具类,也提供和 Dozer 类似的功能。但是 Orika 无需使用繁琐 XML 配置,它自身提供一套非常简洁的 API 用法,非常容易上手。

首先我们引入依赖:

  1. <dependency>
  2. <groupId>ma.glasnost.orika</groupId>
  3. <artifactId>orika-core</artifactId>
  4. <version>1.5.4</version>
  5. </dependency>

基本使用方法如下:

  1. // 省略其他设值代码
  2. // 这里先不要设值时间
  3. // studentDTO.setCreateDate("2020-08-08");
  4. MapperFactory mapperFactory = new DefaultMapperFactory.Builder().build();
  5. MapperFacade mapper = mapperFactory.getMapperFacade();
  6. StudentDO studentDO = mapper.map(studentDTO, StudentDO.class);

这里我们引入两个类 MapperFactory 与 MapperFacade,其中 MapperFactory 可以用于字段映射,配置转换器等,而 MapperFacade 的作用就与 BeanUtils 一样,用于负责对象的之间的映射。

上面的代码中,我们注释了 DTO 对象中的 createDate 时间属性的设值,因为默认情况下,如果没有设置时间类型的转换器,上面的代码将会抛错。

另外,上面的代码中,对于字段名不一致的属性,也不会复制,需要单独设置。

下面我们就设置一个时间转换器,并且指定一下字段名:

  1. MapperFactory mapperFactory = new DefaultMapperFactory.Builder().build();
  2. ConverterFactory converterFactory = mapperFactory.getConverterFactory();
  3. converterFactory.registerConverter(new DateToStringConverter("yyyy-MM-dd"));
  4. mapperFactory.classMap(StudentDTO.class, StudentDO.class)
  5. .field("no", "number")
  6. // 一定要调用下 byDefault
  7. .byDefault()
  8. .register();
  9. MapperFacade mapper = mapperFactory.getMapperFacade();
  10. StudentDO studentDO = mapper.map(studentDTO, StudentDO.class);

上面的代码中,首先我们需要在 ConverterFactory 注册一个时间类型的转换器,其次我们还需要在 MapperFactory 中指定不同字段名之间的映射关系。

这里我们要注意,在我们使用 classMap 之后,如果想要相同字段名属性默认被复制,那么一定调用 byDefault 方法。

简单对比一下 DTO 与 DO 对象:
对象复制工具类简介 - 图12
上图可以发现 Orika 的一些特性:

  • 部分类型不一致,支持自动转换。
  • 支持深拷贝。
  • 指定不同字段名的映射关系,属性可以被成功复制。

另外 Orika 还支持集合映射:

  1. MapperFactory mapperFactory = new DefaultMapperFactory.Builder().build();
  2. List<Person> persons = new ArrayList<>();
  3. List<PersonDto> personDtos = mapperFactory.getMapperFacade().mapAsList(persons, PersonDto.class);

最后聊下 Orika 的实现原理,Orika 与 Dozer 的底层原理不太一样,Orika 的底层使用了 javassist 生成字段属性映射的字节码,然后直接动态加载,执行字节码文件,相比于 Dozer 的这种使用反射的工具类,速度上会快很多。
对象复制工具类简介 - 图13

MapStruct

上面介绍的这些工具类,不管使用反射,还是使用字节码技术,这些都需要在代码运行期间动态执行,所以相对于手写硬编码这种方式,上面这些工具类执行速度都会慢很多。

那有没有一个工具类的运行速度与硬编码这种方式差不多呢?

这就要介绍 MapStruct 这个工具类,这个工具类之所以运行速度与硬编码差不多,这是因为他在编译期间就生成了 Java Bean 属性复制的代码,运行期间就无需使用反射或者字节码技术,所以确保了高性能。

另外,由于编译期间就生成了代码,所以如果有任何问题,编译期间就可以提前暴露,这对于开发人员来讲就可以提前解决问题,而不用等到代码应用上线了,运行之后才发现错误。

下面我们来看下,怎么使用这个工具类,首先我们先引入这个依赖:

  1. <dependency>
  2. <groupId>org.mapstruct</groupId>
  3. <artifactId>mapstruct</artifactId>
  4. <version>1.3.1.Final</version>
  5. </dependency>

其次,由于 MapStruct 需要在编译器期间生成代码,所以我们需要 maven-compiler-plugin 插件中配置:

  1. <plugin>
  2. <groupId>org.apache.maven.plugins</groupId>
  3. <artifactId>maven-compiler-plugin</artifactId>
  4. <version>3.8.1</version>
  5. <configuration>
  6. <source>1.8</source> <!-- depending on your project -->
  7. <target>1.8</target> <!-- depending on your project -->
  8. <annotationProcessorPaths>
  9. <path>
  10. <groupId>org.mapstruct</groupId>
  11. <artifactId>mapstruct-processor</artifactId>
  12. <version>1.3.1.Final</version>
  13. </path>
  14. <!-- other annotation processors -->
  15. </annotationProcessorPaths>
  16. </configuration>
  17. </plugin>

接下来我们需要定义映射接口,代码如下:

  1. @Mapper
  2. public interface StudentMapper {
  3. StudentMapper INSTANCE = Mappers.getMapper(StudentMapper.class);
  4. @Mapping(source = "no", target = "number")
  5. @Mapping(source = "createDate", target = "createDate", dateFormat = "yyyy-MM-dd")
  6. StudentDO dtoToDo(StudentDTO studentDTO);
  7. }

我们需要使用 MapStruct 注解 @Mapper 定义一个转换接口,这样定义之后,StudentMapper 的功能就与 BeanUtils 等工具类一样了。

其次,由于我们 DTO 与 DO 对象中存在字段名不一致的情况,所以我们还在在转换方法上使用 @Mapping 注解指定字段映射。另外我们 createDate 字段类型不一致,这里我们还需要指定时间格式化类型。

上面定义完成之后,我们就可以直接使用 StudentMapper 一行代码搞定对象转换。

  1. // 忽略其他代码
  2. StudentDO studentDO = StudentMapper.INSTANCE.dtoToDo(studentDTO);

如果我们对象使用 Lombok 的话,使用 @Mapping 指定不同字段名,编译期间可能会抛出如下的错误:
对象复制工具类简介 - 图14
这个原因主要是因为 Lombok 也需要编译期间自动生成代码,这就可能导致两者冲突,当 MapStruct 生成代码时,还不存在 Lombok 生成的代码。

解决办法可以在 maven-compiler-plugin 插件配置中加入 Lombok,如下:

  1. <plugin>
  2. <groupId>org.apache.maven.plugins</groupId>
  3. <artifactId>maven-compiler-plugin</artifactId>
  4. <version>3.8.1</version>
  5. <configuration>
  6. <source>1.8</source> <!-- depending on your project -->
  7. <target>1.8</target> <!-- depending on your project -->
  8. <annotationProcessorPaths>
  9. <path>
  10. <groupId>org.mapstruct</groupId>
  11. <artifactId>mapstruct-processor</artifactId>
  12. <version>1.3.1.Final</version>
  13. </path>
  14. <path>
  15. <groupId>org.projectlombok</groupId>
  16. <artifactId>lombok</artifactId>
  17. <version>1.18.12</version>
  18. </path>
  19. <!-- other annotation processors -->
  20. </annotationProcessorPaths>
  21. </configuration>
  22. </plugin>

输出 DO 与 DTO 如下:对象复制工具类简介 - 图15
从上图中我们可以得到一些结论:

  • 部分类型不一致,支持自动转换。
  • 支持深拷贝。
  • 指定不同字段名的映射关系,属性可以被成功复制。

上面的例子介绍了一些简单字段映射,如果小伙伴在工作中还碰到其他的场景,可以先查看一下这个工程,有没有解决办法:https://github.com/mapstruct/mapstruct-examples

上面我们已经知道 MapStruct 在编译期间就生成了代码,下面我们来看下自动生成代码:

  1. public class StudentMapperImpl implements StudentMapper {
  2. public StudentMapperImpl() {
  3. }
  4. public StudentDO dtoToDo(StudentDTO studentDTO) {
  5. if (studentDTO == null) {
  6. return null;
  7. } else {
  8. StudentDO studentDO = new StudentDO();
  9. studentDO.setNumber(studentDTO.getNo());
  10. try {
  11. if (studentDTO.getCreateDate() != null) {
  12. studentDO.setCreateDate((new SimpleDateFormat("yyyy-MM-dd")).parse(studentDTO.getCreateDate()));
  13. }
  14. } catch (ParseException var4) {
  15. throw new RuntimeException(var4);
  16. }
  17. studentDO.setName(studentDTO.getName());
  18. if (studentDTO.getAge() != null) {
  19. studentDO.setAge(String.valueOf(studentDTO.getAge()));
  20. }
  21. List<String> list = studentDTO.getSubjects();
  22. if (list != null) {
  23. studentDO.setSubjects(new ArrayList(list));
  24. }
  25. studentDO.setCourse(studentDTO.getCourse());
  26. return studentDO;
  27. }
  28. }
  29. }

从生成的代码来看,里面并没有什么黑魔法,MapStruct 自动生成了一个实现类 StudentMapperImpl,里面实现了 dtoToDo,方法里面调用 getter/setter设值。

从这个可以看出,MapStruct 作用就相当于帮我们手写getter/setter设值,所以它的性能会很好。

总结

第一:不建议使用 Apache BeanUtils 工具

第二:参考各个工具类的功能和复制性能,选择适合当前项目的工具类。各个工具类的性能比较如下图所示:
对象复制工具类简介 - 图16
对象复制工具类简介 - 图17
可以看到 MapStruct 的性能可以说还是相当优秀。那么如果你的业务对于性能,响应等要求比较高,或者你的业务存在大数据量导入/导出的场景,而这个代码存在对象转化,那就切勿使用 Apache Beanutils,Dozer 这两个工具类。

第三:其实很大一部分应用是没有很高的性能的要求,只要工具类能提供足够的便利,就可以接受。如果你的业务中没有很复杂的的需求,那么直接使用 Spring BeanUtils 就好了,毕竟 Spring 的大部分应用都在使用,我们都无需导入其他包了。

如果业务存在不同类型,不同的字段名,那么可以考虑使用 Orika 等这种重量级工具类。

转载

七种对象复制工具类,阿粉该 Pick 谁?

作者:殷建卫 链接:https://www.yuque.com/yinjianwei/vyrvkf/gmi88f 来源:殷建卫 - 开发笔记 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。