目录导航

  • 一、常用的对象拷贝工具基本介绍
    • 1.1 Apache BeanUtils
    • 1.2 Spring BeanUtils
    • 1.3 cglib BeanCopier
    • 1.4 HuTool BeanUtils
    • 1.5 MapStruct
    • 1.6 getter & setter
    • 1.7 总结
  • 二、使用介绍
    • 2.1 准备工作
    • 2.2 映射
      • 2.2.1 基本映射
      • 2.2.2 不同属性名映射
      • 2.2.3 不同个数属性映射
      • 2.2.4 多个源合并映射
      • 2.2.5 子对象映射
      • 2.2.6 集合属性映射
      • 2.2.7 枚举映射
      • 2.2.8 集合映射
    • 2.3 转换
      • 2.3.1 类型转换
      • 2.3.2 格式转换
    • 2.4 高级特性
      • 2.4.1 依赖注入
      • 2.4.2 设置默认值
      • 2.4.3 使用表达式
      • 2.4.4 前置及后置方法
  • 三、参考文献
  • 四、补充填坑

一、常用的对象拷贝工具基本介绍

属性拷贝工具有很多,也许你用过如下的一些:

  • Apache commons-beanutils
  • Spring BeanUtils
  • cglib BeanCopier
  • HuTool BeanUtils
  • MapStruct
  • getter & setter

这些属性拷贝工具各自有什么特点和区别?在日常开发使用中,我们该如何做出选择?

1.1 Apache BeanUtils

  • 参数顺序和其它的工具正好相反,导致使用不顺手,容易产生问题;
  • 阿里巴巴代码扫描插件会给出明确的告警;
  • 基于反射实现,性能较差;
  • 不推荐使用;

1.2 Spring BeanUtils

  • 基于内省+反射,借助getter/setter方法实现属性拷贝,性能比apache高;
  • 在简单的属性拷贝场景下推荐使用;

1.3 cglib BeanCopier

  • 通过动态代理的方式来实现属性拷贝;
  • 性能高效;
  • 在简单的属性拷贝场景下推荐使用;

1.4 HuTool BeanUtils

  • 性能介于apache和Spring之间;
  • 需要额外引入HuTool的依赖;

1.5 MapStruct

  • 基于getter/setter方法实现属性拷贝,在编译时自动生成实现类的代码;
  • 性能媲美getter & setter;
  • 强大的功能可以实现深度拷贝;
  • 缺点是需要声明bean的转换接口类;

1.6 getter & setter

  • 性能最高,但是需要手动拷贝;

1.7 总结

经过第三方的对比结果,总的下来,推荐使用顺序为:

apache < HuTool < Spring < cglib < Mapstruct

二、使用介绍

2.1 准备工作

<!-- 导入MapStruct的核心注释 -->
<dependencies><dependency><groupId>org.mapstruct</groupId><artifactId>mapstruct</artifactId><version>${org.mapstruct.version}</version></dependency>
</dependencies>
...
<!-- MapStruct在编译时工作,并且会集成到像Maven和Gradle这样的构建工具上,我们还必须在<build中/>标签中添加一个插件maven-compiler-plugin,并在其配置中添加annotationProcessorPaths,该插件会在构建时生成对应的代码。 -->
<build><plugins><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.5.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>

2.2 映射

2.2.1 基本映射

我们现在需要实现一个场景,Car是一个domain层的对象实例,在从数据库读取出来后传递给service层需要转换为CarDTO,这两个实例的所有属性全部相同,现在需要使用mapstruct来完成这个目标。

public class Car {private String brand;private Double price;private Boolean onMarket;...// setters + getters + toString
}
public class CarDTO {private String brand;private Double price;private Boolean onMarket;...// setters + getters + toString
}

我们需要新建一个Mapper接口,来映射这两个对象之间的属性。

@Mapper
public interface CarMapper {CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);CarDTO carToCarDTO(Car car);
}

然后就可以进行测试了:

@Test
public void test1(){Car wuling = new Car();wuling.setBrand("wuling");wuling.setPrice(6666.66);wuling.setOnMarket(true);CarDTO wulingDTO = CarMapper.INSTANCE.carToCarDTO(wuling);// 结果为:Car{brand='wuling', price=6666.66, onMarket=true}System.out.println("结果为:" + wulingDTO);
}

可以看到,mapstruct很好地完成了我们的目标,那么它是如何做到的呢?我们查看CarMapper.INSTANCE.carToCarDTO(wuling)的实现类,可以看到在编译过程中自动生成了如下内容的接口实现类:

public class CarMapperImpl implements CarMapper {@Overridepublic CarDTO carToCarDTO(Car car) {if ( car == null ) {return null;}CarDTO carDTO = new CarDTO();carDTO.setBrand( car.getBrand() );carDTO.setPrice( car.getPrice() );carDTO.setOnMarket( car.getOnMarket() );return carDTO;}
}

所以,mapstruct并没有使用反射的机制,而是使用了普通的set和get方法来进行属性拷贝的,因此要求我们的对象也一定要有set和get方法。

2.2.2 不同属性名映射

在如上示例中,我们源对象和目标对象的属性名称全都一致,但是在很多的场景下,源对象和目标对象的同一个字段很可能名称是不同的,这种情况下,只需要在映射接口类中指定即可:

public class Car {...private Boolean onMarket;...// setters + getters + toString
}
public class CarDTO {...private Boolean onSale;...// setters + getters + toString
}
@Mapper
public interface CarMapper {CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);@Mapping(source = "car.onMarket", target = "onSale")CarDTO carToCarDTO(Car car);
}

如此,生成的接口实现类如下:

@Override
public CarDTO carToCarDTO(Car car) {if ( car == null ) {return null;}CarDTO carDTO = new CarDTO();carDTO.setOnSale( car.getOnMarket() );carDTO.setBrand( car.getBrand() );carDTO.setPrice( car.getPrice() );return carDTO;
}

2.2.3 不同个数属性映射

我们假设Car和CarDTO各有一个对方没有的属性,那么在进行对象拷贝时会发生什么?

public class Car {...private Date birthdate;// setters + getters + toString
}
public class CarDTO {...private String owner;// setters + getters + toString
}
@Test
public void test1(){Car wuling = new Car();wuling.setBrand("wuling");wuling.setPrice(6666.66);wuling.setOnMarket(true);wuling.setBirthdate(new Date());CarDTO wulingDTO = CarMapper.INSTANCE.carToCarDTO(wuling);System.out.println("结果为:" + wulingDTO);
}

然后我们执行如上转换的案例,发现并没有报错,从Car拷贝属性到CarDTO时,CarDTO由于没有birthdate属性,则不会赋值;同时,CarDTO的owner因为Car中没有,因此也不会被赋值,生成的接口实现类如下:

@Override
public CarDTO carToCarDTO(Car car) {if ( car == null ) {return null;}CarDTO carDTO = new CarDTO();carDTO.setOnSale( car.getOnMarket() );carDTO.setBrand( car.getBrand() );carDTO.setPrice( car.getPrice() );return carDTO;
}

因此,mapstruct只会对共有的交集属性进行拷贝操作。

2.2.4 多个源合并映射

我们新增一个Person类,其中的name属性对应CarDTO中的owner属性。

public class Person {private String name;private String age;// setters + getters + toString
}
@Mapper
public interface CarMapper {CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);@Mapping(source = "car.onMarket", target = "onSale")@Mapping(source = "person.name", target = "owner")CarDTO carToCarDTO(Car car, Person person);
}
public class TestMapper1 {@Testpublic void test1(){Car wuling = new Car();wuling.setBrand("wuling");wuling.setPrice(6666.66);wuling.setOnMarket(true);wuling.setBirthdate(new Date());Person jack = new Person();jack.setName("jack");jack.setAge("22");CarDTO wulingDTO = CarMapper.INSTANCE.carToCarDTO(wuling, jack);// 结果为:CarDTO{brand='wuling', price=6666.66, onSale=true, owner='jack'}System.out.println("结果为:" + wulingDTO);}

自动生成的接口实现类如下:

@Override
public CarDTO carToCarDTO(Car car, Person person) {if ( car == null && person == null ) {return null;}CarDTO carDTO = new CarDTO();if ( car != null ) {carDTO.setOnSale( car.getOnMarket() );carDTO.setBrand( car.getBrand() );carDTO.setPrice( car.getPrice() );}if ( person != null ) {carDTO.setOwner( person.getName() );}return carDTO;
}

2.2.5 子对象映射

如果需要转换的Car对象中的某个属性不是基本数据类型,而是一个对象怎么处理呢。

public class Person {private String name;private String age;// setters + getters + toString
}public class PersonDTO {private String name;private String age;// setters + getters + toString
}
public class Car {private String brand;private Double price;private Boolean onMarket;private Person owner;// setters + getters + toString
}public class CarDTO {private String brand;private Double price;private Boolean onMarket;private PersonDTO owner;// setters + getters + toString
}
@Mapper
public interface PersonMapper {PersonMapper INSTANCE = Mappers.getMapper(PersonMapper.class);PersonDTO personToPersonDTO(Person person);
}
@Mapper(uses = {PersonMapper.class})
public interface CarMapper {CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);@Mapping(source = "onMarket", target = "onSale")CarDTO carToCarDTO(Car car);
}
@Test
public void test1(){Person jack = new Person();jack.setName("jack");jack.setAge("22");Car wuling = new Car();wuling.setBrand("wuling");wuling.setPrice(6666.66);wuling.setOnMarket(true);wuling.setOwner(jack);CarDTO wulingDTO = CarMapper.INSTANCE.carToCarDTO(wuling);// 结果为:CarDTO{brand='wuling', price=6666.66, onSale=true, owner=Person{name='jack', age='22'}}System.out.println("结果为:" + wulingDTO);
}

这里最重要的是:

  • 需要增加PersonMapper接口,让mapstruct能够对Person和PersonDTO进行转换;
  • CarMapper中需要引入PersonMapper,如果存在多个对象属性,此处就要引入多个对象属性的Mapper接口;

2.2.6 集合属性映射

如果需要转换的Car对象中的某个属性不是基本数据类型,而是一个集合类型该怎么处理?

public class Car {private String brand;private Double price;private Boolean onMarket;private List<Person> ownerList;// setters + getters + toString
}

同2.2.5内容。

2.2.7 枚举映射

枚举映射的工作方式与字段映射相同。MapStruct会对具有相同名称的枚举进行映射。

public enum PayType {CASH,ALIPAY,WEPAY,DIGITAL_CASH,CARD_VISA,CARD_CREDIT;
}public enum PayTypeNew {CASH,ALIPAY,WEPAY,DIGITAL_CASH,CARD_VISA,CARD_CREDIT;
}
@Mapper
public interface PayTypeMapper {PayTypeMapper INSTANCE = Mappers.getMapper(PayTypeMapper.class);PayTypeNew payTypeToPayTypeNew(PayType payType);
}
@Test
public void test2(){PayType p1 = PayType.ALIPAY;PayTypeNew p2 = PayTypeMapper.INSTANCE.payTypeToPayTypeNew(p1);// 结果为:ALIPAYSystem.out.println("结果为:" + p2);
}

但是在更多的场景下,源枚举和目标枚举并不是一一对应的,比如目标枚举如下:

public enum PayTypeNew {CASH,NETWORK,CARD;
}

此时,我们就需要手动指定源枚举和目标枚举之间的对应关系:

@Mapper
public interface PayTypeMapper {PayTypeMapper INSTANCE = Mappers.getMapper(PayTypeMapper.class);@ValueMappings({@ValueMapping(source = "ALIPAY", target = "NETWORK"),@ValueMapping(source = "WEPAY", target = "NETWORK"),@ValueMapping(source = "DIGITAL_CASH", target = "CASH"),@ValueMapping(source = "CARD_VISA", target = "CARD"),@ValueMapping(source = "CARD_CREDIT", target = "CARD")})PayTypeNew payTypeToPayTypeNew(PayType payType);
}

如果对应CARD的场景比较多,手动一个个地对应会比较繁琐,因此还有一种方式能实现相同的效果,而且比较简洁:

@Mapper
public interface PayTypeMapper {PayTypeMapper INSTANCE = Mappers.getMapper(PayTypeMapper.class);@ValueMappings({@ValueMapping(source = "ALIPAY", target = "NETWORK"),@ValueMapping(source = "WEPAY", target = "NETWORK"),@ValueMapping(source = "DIGITAL_CASH", target = "CASH"),@ValueMapping(source = MappingConstants.ANY_REMAINING, target = "CARD")})PayTypeNew payTypeToPayTypeNew(PayType payType);
}

MappingConstants.ANY_REMAINING表示剩下其它的源枚举和目标枚举对应不上的全部映射为指定的枚举对象。

还有一种方式,使用MappingConstants.ANY_UNMAPPED表示所有未显示指定目标枚举的都会被映射为CARD:

@Mapper
public interface PayTypeMapper {PayTypeMapper INSTANCE = Mappers.getMapper(PayTypeMapper.class);@ValueMappings({@ValueMapping(source = "ALIPAY", target = "NETWORK"),@ValueMapping(source = "WEPAY", target = "NETWORK"),@ValueMapping(source = "DIGITAL_CASH", target = "CASH"),@ValueMapping(source = MappingConstants.ANY_UNMAPPED, target = "CARD")})PayTypeNew payTypeToPayTypeNew(PayType payType);
}

2.2.8 集合映射

如果源对象和目标对象都是集合,且对象中的属性都是基本数据类型,则映射方法和之前类似,映射接口改为如下即可:

@Mapper
public interface CarMapper {CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);List<CarDTO> carToCarDTOList(List<Car> carList);Set<CarDTO> carToCarDTOSet(Set<Car> carSet);Map<String, CarDTO> carToCarDTOMap(Map<String, Car> carMap);
}

如果对象中属性不仅是基本数据类型,还有对象类型或对象类型的集合类型的话,mapstruct也是支持映射的,详情参考官网文档,此处不再赘述。

2.3 转换

2.3.1 类型转换

mapstruct提供了基本数据类型和包装数据类型、一些常见场景下的自动转换;

  • 基本类型及其对应的包装类型之间的转换;比如int和Integer、float和Float、long和Long、boolean和Boolean等;
  • 任意基本类型和任意包装类型之间的转换;比如int和long、byte和Integer等;
  • 任意基本类型、包转类型和String之间的转换;比如boolean和String、Integer和String;
  • 枚举和String;
  • 大数类型(BigInteger、BigDecimal)、基本类型、基本类型包装类型、String之间的相互转换;
  • 其它一些场景,参考MapStruct 1.4.2.Final Reference Guide;

2.3.2 格式转换

mapstruct可以对源对象的属性值进行格式化之后拷贝给目标对象的属性;

  • 日期格式转换
public class Car {private String brand;private Double price;private LocalDate marketDate;// setters + getters + toString
}public class CarDTO {private String brand;private Double price;private String saleDate;// setters + getters + toString
}
@Mapper
public interface CarMapper {CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);@Mapping(source = "marketDate", target = "saleDate", dateFormat = "dd/MM/yyyy")CarDTO carToCarDTO(Car car);
}
@Test
public void test1(){Car wuling = new Car();wuling.setBrand("wuling");wuling.setPrice(6666.66);wuling.setMarketDate(LocalDate.now());// 转换前为:Car{brand='wuling', price=6666.66, marketDate=2022-01-19}System.out.println("转换前为:" + wuling);CarDTO wulingDTO = CarMapper.INSTANCE.carToCarDTO(wuling);// 结果为:CarDTO{brand='wuling', price=6666.66, saleDate='19/01/2022'}System.out.println("结果为:" + wulingDTO);
}
  • 数字格式转换
@Mapper
public interface CarMapper {CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);@Mapping(source = "price", target = "price", numberFormat = "$#.00")CarDTO carToCarDTO(Car car);
}

2.4 高级特性

2.4.1 依赖注入

在前面例子中的Mapper映射接口中,我们都需要CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);来创建一个实例,如果我们是Spring工程的话,就可以把这个实例托管给Spring进行管理。

@Mapper(componentModel = "spring")
public interface CarMapper {List<CarDTO> carToCarDTOList(List<Car> carList);
}

然后使用的时候,从Spring中自动注入接口对象即可:

@SpringBootTest
public class TestMapper1 {@Autowiredprivate CarMapper carMapper;@Testpublic void test1(){Car wuling = new Car();wuling.setBrand("wuling");wuling.setPrice(6666.66);wuling.setMarketDate(LocalDate.now());Car changan = new Car();changan.setBrand("changan");changan.setPrice(7777.77);changan.setMarketDate(LocalDate.now());List<Car> carList = new ArrayList<>();carList.add(wuling);carList.add(changan);List<CarDTO> carDTOList = carMapper.carToCarDTOList(carList);System.out.println("结果为:" + carDTOList);}
}

2.4.2 设置默认值

  • 常量默认值

无论源对象的属性字段值是什么,目标对象的该字段都是给定的常量值。

@Mapper(componentModel = "spring")
public interface PersonMapper {@Mapping(target = "name", constant = "zhangsan")PersonDTO personToPersonDTO(Person person);
}
  • 空值默认值

如果源对象的属性字段值为空,那么就使用指定的默认值。

@Mapper(componentModel = "spring")
public interface PersonMapper {@Mapping(source = "name", target = "name", defaultValue = "unknown")PersonDTO personToPersonDTO(Person person);
}

2.4.3 使用表达式

mapstruct甚至允许在对象属性映射中使用java表达式:

@Mapper(componentModel = "spring", imports = {UUID.class, LocalDateTime.class})
public interface PersonMapper {@Mapping(target = "id", expression = "java(UUID.randomUUID().toString())")@Mapping(source = "birthdate", target = "birthdate", defaultExpression = "java(LocalDateTime.now())")PersonDTO personToPersonDTO(Person person);
}

或者等价写法为:

@Mapper(componentModel = "spring")
public interface PersonMapper {@Mapping(target = "id", expression = "java(java.util.UUID.randomUUID().toString())")@Mapping(source = "birthdate", target = "birthdate", defaultExpression = "java(java.time.LocalDateTime.now())")PersonDTO personToPersonDTO(Person person);
}

2.4.4 前置及后置方法

@Mapper(componentModel = "spring")
public abstract class PersonMapper {@BeforeMappingpublic void before(Person person){System.out.println("前置处理!!!");if(ObjectUtils.isEmpty(person.getName())){System.out.println("Person的name不能为空!");return;}}@Mapping(target = "id", expression = "java(java.util.UUID.randomUUID().toString())")@Mapping(source = "birthdate", target = "birthdate", defaultExpression = "java(java.time.LocalDateTime.now())")public abstract PersonDTO personToPersonDTO(Person person);@AfterMappingpublic void after(@MappingTarget PersonDTO personDTO){System.out.println("后置处理:" + personDTO.getName() + "!!!");}
}

三、参考文献

MapStruct 1.4.2.Final Reference Guide

MapStruct使用指南 - 知乎 (zhihu.com)

常见Bean拷贝框架使用姿势及性能对比 - 知乎 (zhihu.com)

四、补充填坑

在正文的实例中,我们对于Bean对象都是使用手动写getter、setter、toString方法的,但是在真实开发中,大家都是采用了Lombok插件,如果不做特殊配置,就会出现mapstruct运行时lombok不生效的问题,只需要在pom配置中增加如下内容:

<annotationProcessorPaths><path><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>${lombok.version}</version></path>
</annotationProcessorPaths>

具体原理和详情可以参考:当 Lombok 遇见了 MapStruct の「坑」 - 知乎 (zhihu.com)

【Java生态圈技术总结】之深度剖析MapStruct对象拷贝工具相关推荐

  1. [Java并发包学习八]深度剖析ConcurrentHashMap

    转载----http://qifuguang.me/2015/09/10/[Java并发包学习八]深度剖析ConcurrentHashMap/ HashMap是非线程安全的,并发情况下使用,可能会导致 ...

  2. 《JAVA生态圈技术总结》之 微服务架构蓝图总览

    目录 一.微服务定义 1.1 定义一 1.2 定义二 二.微服务利弊 2.1 优点 2.2 缺点 三.微服务的适用性 3.1 康威法则 3.2 生产力 3.3 架构演进 四.服务分层 五.服务注册发现 ...

  3. java中对JVM的深度解析、调优工具、垃圾回收

    jdk自带的JVM调优工具 jvm监控分析工具一般分为两类,一种是jdk自带的工具,一种是第三方的分析工具.jdk自带工具一般在jdk bin目录下面,以exe的形式直接点击就可以使用,其中包含分析工 ...

  4. 一文详解java中对JVM的深度解析、调优工具、垃圾回收

    2019独角兽企业重金招聘Python工程师标准>>> jvm监控分析工具一般分为两类,一种是jdk自带的工具,一种是第三方的分析工具.jdk自带工具一般在jdk bin目录下面,以 ...

  5. 深度剖析ConcurrentHashMap

    在阅读Spring IOC源码的时候,发现了里面的map是ConcurrentHashMap.有些疑问,我们平时都使用HashMap和HashTable,我们也知道,HashMap是线程不安全的,但是 ...

  6. 2、深度剖析ConcurrentHashMap

    原文地址:qifuguang.me/2015/09/10/[Java并发包学习八]深度剖析ConcurrentHashMap/ 1 ConcurrentHashMap的目的 多线程环境下,使用Hash ...

  7. Java_深度剖析ConcurrentHashMap

    本文基于Java 7的源码做剖析. ConcurrentHashMap的目的 多线程环境下,使用Hashmap进行put操作会引起死循环,导致CPU利用率接近100%,所以在并发情况下不能使用Hash ...

  8. 深度剖析ConcurrentHashMap(转)

    概述 还记得大学快毕业的时候要准备找工作了,然后就看各种面试相关的书籍,还记得很多面试书中都说到: HashMap是非线程安全的,HashTable是线程安全的. 那个时候没怎么写Java代码,所以根 ...

  9. 互联网Java架构技术精品视频(全栈)

    概述 本文找的所有资源分为几种,免费,收费以及有限制的免费.有限制的免费主要是需要你通过一些额外途径才能获取想要的技术视频资料. 技术社区 这里推荐几个非常好的在线技术视频社区 Java学习者论坛 J ...

最新文章

  1. mui用ajax上拉加载更多,mui上拉加载更多的使用
  2. java实现数据库内容修改_数据库更改到Java环境中实现可持续和平
  3. oracle last_day比较,Oracle的LAST_DAY函数
  4. python入门必备知识总结
  5. Python进阶(五)模块、包详解
  6. 专业书籍阅读-Earth System Science Data Resources
  7. 开发Connext DDS传输插件不用求人,看这一篇就够了
  8. win10安装vc2015失败,尝试解决方案,目前有效
  9. ListView刷新指定位置的item
  10. 全国各地电信DNS服务器地址:
  11. vue 中 axios的post请求,415错误
  12. linux防火墙reject,linux 防火墙配置与REJECT导致没有生效问题
  13. css实现图片在页面中的动画特效
  14. oracle 拼音首字母查询,使用ORACLE函数实现按汉字拼音首字母查询
  15. 修改webbrower中浏览器版本
  16. webrtc thread introduce
  17. JPA实现领域驱动设计(DDD) 中值对象的持久化
  18. 2 第二章 集群环境搭建(kubeadm 方式)
  19. 转:条件型 CORS 响应下因缺失 Vary: Origin 导致的缓存错乱问题
  20. 大数据在保险界的应用

热门文章

  1. 小米笔记本 Pro 2022官宣发布时间 将于7月4日正式发布
  2. PostGresql中日期转时间戳
  3. Windows客户端开发面经(2021-03)
  4. 《百万在线 大型游戏服务端开发》前两章概念笔记
  5. 月薪一万在上海的生活IT真实版
  6. CTF新手练习之Misc
  7. html if 隐藏元素,jquery判断元素是否隐藏?
  8. Excel连通数据库,供应链进度追踪表效率倍增
  9. Linux入门基础之 中
  10. 基于EasyX图形库的C/C++实战项目——西南大学大一C语言程序设计|课程设计《多功能应用平台》