本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
引言
关于EasyExcel这项技术,迄今为止已经使用了四章的篇幅来讲述:
- 《EasyExcel初相识:从零全面掌握核心API》
- 《EasyExcel实践篇:封装通用工具与多案例实操》
- 《EasyExcel实战篇:百万级大报表导出与优化》
- 《EasyExcel实战篇:百万级大报表导入与优化》
其实一开始,只是想着分享”百万级大报表处理“这个话题,可转念一想,或许在其中使用EasyExcel时,有些小伙伴还未曾接触过。也正因如此,才使用了前面两章篇幅,先去讲述它常用的核心API,以及封装通用的工具类、监听器与多个案例实操。
掌握EasyExcel框架的核心API与常见用法后,然后才开始着手撰写起初规划的百万级大报表读写,这类场景的特点是 接口响应时间慢、服务资源占用高,这也是许多并发场景下的通病。面对此类问题时,如何去改善性能、怎么去控制资源,这两点经验尤为重要。
所以,在百万级读写的两篇内容中,我们结合了异步回调、流式处理、多线程并发、Reactor线程模型等多种技术来兼顾性能、资源这两个问题,最终完美驾驭了性能、资源问题,报表的处理性能也好,资源的占用情况也罢,都可以通过调整参数来灵活控制,以此满足不同业务场景下的动态需求。
可是面对复杂报表时,比如系统需支持自动导出花里胡哨的合并表格,前面的知识显然无法满足。因此,为了有始有终,本文则会来聊聊EasyExcel如何应对复杂报表导出的场景。
PS:个人编写的《技术人求职指南》小册已完结,其中从技术总结开始,到制定期望、技术突击、简历优化、面试准备、面试技巧、谈薪技巧、面试复盘、选
Offer方法、新人入职、进阶提升、职业规划、技术管理、涨薪跳槽、仲裁赔偿、副业兼职……,为大家打造了一套“从求职到跳槽”的一条龙服务,同时也为诸位准备了七折优惠码:3DoleNaE,近期需要找工作的小伙伴可以点击:s.juejin.cn/ds/USoa2R3/了解详情!
一、EasyExcel模板填充技术
回想之前的内容,不管是导入还是导出,我们都是基于数据模型类,来映射Java对象与excel数据的关系。成也萧何败也萧何,这种模式虽然用起来很方便,但天然就无法支持复杂报表的场景,毕竟我们没有办法通过数据模型类,来描述一个毫无规则可言的复杂报表。
那么,面对复杂报表需求EasyExcel会如何应对呢?答案是基于已有的Excel模板去填充数据,这就有点像编写前端页面,为了将美观的界面和动态的数据相结合,前端页面代码通常会使用各种占位符,在真正拿到数据时再渲染上去,EasyExcel里的模板填充技术也很类似。
当然,EasyExcel的模板填充,实际上还是对POI的封装,只是简化了填充的使用成本罢了,下面一起来看看模板填充的基础用法,比如我想导出一个这样的报表:
如果想要导出这样一个熊猫基本信息的报表,那么首先咱们得定义对应的模板,如下:
在这个模板里,需要动态填充的数据,会通过{}占位符来表示,中间则是对应的变量名字,下面来看具体用法:
public static void main(String[] args) {
// 创建要填充的数据对象
Panda panda = new Panda();
panda.setName("竹子");
panda.setNickname("小竹");
panda.setUniqueCode("P888888");
panda.setAddress("地球村888号");
panda.setHeight(new BigDecimal("188.88"));
panda.setMotto("今天的事能拖就拖,明天的事明天再说!");
// 声明模板的位置、生成的excel文件名,开始正式填充数据
String templatePath = "excel/simple_template.xlsx";
String fileName1 = "panda_entity_info.xlsx";
EasyExcelFactory.write(fileName1).withTemplate(templatePath).sheet().doFill(panda);
}
其实十分简单,首先创建一个Panda对象,然后给对应的字段赋值,接着通过EasyExcelFactory来生成excel文件,这里通过withTemplate()来声明是基于模板生成,最后调用doFill()方法开始填充即可。
PS:
withTemplate()支持多种方式指定excel模板,可以通过输入流、文件路径、File对象三种方式指定模板。
将上述这段代码一执行,就能得到前面给出的示例效果。当然,除开可以基于实体类去填充数据外,还可以直接通过Map来传递要填充的目标数据,如:
public static void main(String[] args){
Map<String, Object> dataMap=new HashMap<>();
dataMap.put("name","竹子");
dataMap.put("nickname","小竹");
dataMap.put("uniqueCode","P888888");
dataMap.put("address","地球村888号");
dataMap.put("height",new BigDecimal("188.88"));
dataMap.put("motto","今天的事能拖就拖,明天的事明天再说!");
String templatePath = "excel/simple_template.xlsx";
String fileName2="panda_map_info.xlsx";
EasyExcelFactory.write(fileName2).withTemplate(templatePath).sheet().doFill(dataMap);
}
这段代码的效果和上面的没有区别,只不过当传递的数据是Map时,填充数据会根据占位符名称去Map里面get数据;而传递是实体类时,则会通过实体类的字段名称,与占位符匹配获取数据。
二、模板填充功能详解
经过前面这个案例,我们快速熟悉了一下EasyExcel框架的模板填充机制,相较于POI原生的模板填充机制简单了许多,不过使用时有两点要注意:
①填充数据时,会以提前声明的占位符进行匹配,所以实体类的字段名、或
Map的键名一定要对应,否则无法填充会导致空数据出现;
②模板中声明占位符的语法是{xxx},如果内容正文中就包含{、}这两个特殊字符,那么一定要手动将其转义,即\{、\}这么去写,否则会导致对应的正文为空。
搞清楚这两个注意点后,下面我们再来详细看看EasyExcel提供的模板填充功能。
2.1、填充列表数据
前面提到,要填充的数据通过{xxx}占位符的形式来指定,那假设现在我有多条数据需要填充呢?难道定义模板的时候就声明多条吗?可是这也不合适啊,因为可以从数据库里查询,每次查询得到的行数并不确定。
EasyExcel专门针对列表类型的数据,又推出了一种新的占位符,即{.xxx},比普通占位符前面多了个.点。在填充数据时,如果读取到这类占位符,则会自动将其识别为列表类型的数据进行填充,下面来看例子:
那如何将列表数据填充到这个模板呢?其实很简单:
private static void listFillV1() {
List<Panda> pandas = new ArrayList<>();
for (int i = 1; i <= 5; i++) {
Panda panda = new Panda();
panda.setName("竹子" + i + "号");
panda.setUniqueCode("P" + i);
panda.setBirthday(new Date());
panda.setAddress("地球村" + i + "号");
pandas.add(panda);
}
String fileName = "pandas_v1.xlsx";
String templatePath = "excel/list_template.xlsx";
EasyExcelFactory.write(fileName).withTemplate(templatePath).sheet().doFill(pandas);
}
和简单填充的代码完全一样,只不过调用doFill()方法时,传入的数据从一个对象变为了一个List集合罢了,来看效果:
效果十分明显,的确是按列表形式将数据填充到了模板里,当然,这种方式是一次性将所有数据都填充到模板,如果你数据量较大,想要分批填充是否可以呢?可以,来看实现:
private static void listFillV2() {
String fileName = "pandas_v2.xlsx";
String templatePath = "excel/list_template.xlsx";
// 先创建一个Excel工作簿写对象、Sheet写对象
ExcelWriter excelWriter = EasyExcelFactory.write(fileName).withTemplate(templatePath).build();
WriteSheet writeSheet = EasyExcelFactory.writerSheet().build();
// 模拟多次填充(填充五次,每次填充一条数据)
for (int i = 1; i <= 5; i++) {
List<Panda> pandas = new ArrayList<>();
Panda panda = new Panda();
panda.setName("竹子" + i + "号");
panda.setUniqueCode("P" + i);
panda.setBirthday(new Date());
panda.setAddress("地球村" + i + "号");
pandas.add(panda);
// 开始触发数据填充
excelWriter.fill(pandas, writeSheet);
}
// 手动创建的写对象记得手动关
excelWriter.finish();
}
注意看代码,这里是手动先创建了写入对象,然后再在循环内部去多次触发填充动作,这种适用于数据量较大的填充场景(效果和前面的一样,就不贴图了)。
2.2、混合式填充场景
前面出现了两种占位符,即普通占位符和列表占位符,那么如果一个表格里,既需要填充普通数据,也需要填充列表数据时咋整呢?来看例子,先定义模板:
正如这个模板所示,目前需要导出一个熊猫信息统计表,整个表格由三部分组成:
- ①第二行有两个合并单元格,左侧为统计人姓名,右侧为统计时间;
- ②第四行开始的熊猫列表数据,包含名称、编码、身高、地址四个列;
- ③熊猫列表数据后面跟着的统计行,
D列包含的平均身高和总和。
这里注意,①合并了多个单元格,③则是在一个单元格内需要填充多个数据,这能正常填充吗?拭目以待,上代码:
private static void mixedFill() {
// 导出的位置、模板的位置
String fileName = "mixed_data.xlsx";
String templatePath = "excel/mixed_template.xlsx";
// 提前创建excel写对象
ExcelWriter excelWriter = EasyExcelFactory.write(fileName).withTemplate(templatePath).build();
WriteSheet writeSheet = EasyExcelFactory.writerSheet().build();
FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
// 初始化熊猫列表数据
List<Panda> pandas = new ArrayList<>();
BigDecimal total = BigDecimal.ZERO;
for (int i = 1; i <= 5; i++) {
Panda panda = new Panda();
panda.setName("竹子" + i + "号");
panda.setUniqueCode("P" + i);
// 对熊猫身高求和
BigDecimal height = new BigDecimal(i * 15.5);
total = total.add(height);
panda.setHeight(height);
panda.setAddress("地球村" + i + "号");
pandas.add(panda);
}
// 组装普通的数据(这里用了Map,不嫌麻烦也可以定义单独的类)
BigDecimal avg = total.divide(new BigDecimal(pandas.size()), RoundingMode.HALF_UP);
Map<String, Object> dataMap = new HashMap<>();
dataMap.put("username", "竹子爱熊猫");
dataMap.put("date", new Date());
dataMap.put("totalHeight", total);
dataMap.put("avgHeight", avg);
// 写入熊猫集合数据、普通数据
excelWriter.fill(pandas, fillConfig, writeSheet);
excelWriter.fill(dataMap, writeSheet);
excelWriter.finish();
}
代码相较于之前,看起来长了不少,但其实特别简单,创建对象、初始化数据这些一眼能看明白的就不啰嗦了,重点看这里出现了一个FillConfig类。
这个类从名字上就能看出作用,主要就是用于开启一些填充数据时的配置项,比如上述案例中的forceNewRow(Boolean.TRUE),代表开启自动创建新行,为啥要这么设置呢?来看官方的说法:
forceNewRow代表在填充列表数据时,不管列表下面有没有空行,都会创建一行,然后下面的数据往后移动。默认是false,会直接使用下一行,如果没有则创建。
啥意思呢?有点迷糊是不?其实简单的说,就是当模板需要填充列表数据,并且list占位符不在最后一行,就必须将forceNewRow设置为true。
结合前面定义的模板来看,列表占位符定义在第四行,可是在第五行还有身高统计的占位符,这说明在列表填充完成后,下面还有数据需要填充,如果不开启自动创建新行,就会导致“身高统计行”被覆盖掉。
好了,搞明白这个配置项的作用后,剩余的代码没啥含金量,来看填充效果:
显然,尽管这个报表里列表占位符、普通占位符都有,并且还有单元格合并的情况,以及一个单元格需要换行填充多个数据的情况,EasyExcel照样能够正常填充数据~
2.3、列表横向填充
前面的列表填充,是以纵向形式、从上至下填充数据,那如果想横着填充呢?先来定义一个模板:
这是一个经典的值班表,通常值班信息会以横向展示,咋实现?先来定义一个对应的实体类:
@Data
public class DutyPanda {
private String week;
private String name;
}
这个类就两个字段,如果嫌定义一个类麻烦,完全可以直接用List<Map>类型来代替,下面来看代码实现:
private static void transverseListFill() {
String fileName = "transverse_list_data.xlsx";
String templatePath = "excel/transverse_list_template.xlsx";
// 构建需要填充的数据
List<DutyPanda> dutyPandas = new ArrayList<>();
for (int i = 1; i <= 7; i++) {
DutyPanda dutyPanda = new DutyPanda();
dutyPanda.setWeek("周" + i);
dutyPanda.setName("竹子" + i + "号");
dutyPandas.add(dutyPanda);
}
Map<String, Object> dataMap = new HashMap<>();
dataMap.put("date", new Date());
// 提前创建excel写对象
ExcelWriter excelWriter = EasyExcelFactory.write(fileName).withTemplate(templatePath).build();
WriteSheet writeSheet = EasyExcelFactory.writerSheet().build();
// 设置填充模式为水平填充
FillConfig fillConfig = FillConfig.builder().direction(WriteDirectionEnum.HORIZONTAL).build();
excelWriter.fill(dutyPandas, fillConfig, writeSheet);
excelWriter.fill(dataMap, writeSheet);
excelWriter.finish();
}
这里横向填充列表数据,又用到了FillConfig这个类,这个类提供的可选项总共只有四个:
direction:填充的方向,默认为VERTICAL垂直方向,HORIZONTAL水平方向;forceNewRow:十分自动创建新行填充数据,默认关闭,某些场景下会造成数据覆盖;autoStyle:是否开启样式自动适配,开启后会根据模板里的样式填充数据,默认开启;hasInit:是否允许默认初始化,关闭后每个选项没有默认值,必须手动指定才生效。
因为咱们需要横向填充数据,所以得将direction选项设置为HORIZONTAL水平方向,其余的代码则与之前没有太大差异,来看最终效果:
从结果图来看,这显然满足了咱们的预期效果,所以当你需要面对这种横向填充的需求时,就可以使用这种方式来实现。
2.4、多列表数据填充
掌握列表数据怎么横向填充后,现在来想一个问题,列表占位符是通过{.xxx}这种形式声明,那如果一个报表中,存在多个列表需要填充,这该怎么办?
如果两个集合都有name字段,这时该填充到哪儿去?对于这种场景,如果再依靠前面那种占位符,显然会导致数据乱套。EasyExcel为了解决这类填充场景,推出了新的占位符写法:{xx.xxx},也就是前面再新增一个变量名,来看模板定义:
这个模板中,需要填充两个列表数据,所以定义占位符时用list1、list2两个变量名来区分,那填充数据时,如何指定Java集合数据与占位符的对应关系呢?来看代码:
private static void manyListFill() {
String fileName = "many_list_data.xlsx";
String templatePath = "excel/many_list_template.xlsx";
// 构建需要填充的数据
List<DutyPanda> dutyPandas = new ArrayList<>();
List<Panda> pandas = new ArrayList<>();
for (int i = 1; i <= 10; i++) {
String name = "竹子" + i + "号";
if (i <= 5) {
DutyPanda dutyPanda = new DutyPanda();
dutyPanda.setWeek("周" + i);
dutyPanda.setName(name);
dutyPandas.add(dutyPanda);
} else {
Panda panda = new Panda();
panda.setName(name);
panda.setUniqueCode("P" + i);
panda.setAddress("地球村" + i + "号");
pandas.add(panda);
}
}
Map<String, Object> dataMap = new HashMap<>();
dataMap.put("date", new Date());
dataMap.put("username", "竹子爱熊猫");
dataMap.put("remark", "最终解释权归分配人所有!");
// 提前创建excel写对象
ExcelWriter excelWriter = EasyExcelFactory.write(fileName).withTemplate(templatePath).build();
WriteSheet writeSheet = EasyExcelFactory.writerSheet().build();
// 设置填充模式为水平填充
FillConfig config1 = FillConfig.builder().direction(WriteDirectionEnum.HORIZONTAL).build();
// 设置自动创建新行填充
FillConfig config2 = FillConfig.builder().forceNewRow(true).build();
excelWriter.fill(new FillWrapper("list1", dutyPandas), config1, writeSheet);
excelWriter.fill(new FillWrapper("list2", pandas), config2, writeSheet);
excelWriter.fill(dataMap, writeSheet);
excelWriter.finish();
}
代码中大多数都是熟悉的类,只不过出现了一个FillWrapper类,这个类就是用来指定Java集合,和模板中列表占位符的映射关系。上述代码中,dutyPandas对应模板里的list1,pandas对应list2,来看最终效果:
很明显,最终填充的数据并未出现错乱,这种方式就能很好的满足多列表填充场景。
三、复杂报表导出实战
OK,前面已经将EasyExcel提供的填充功能做了全面讲述,下面来结合真正的业务场景,来看下复杂报表的导出实战案例。
首先,对于提前定义的Excel模板,最好统一去做管理,而不是以硬编码的形式分散在各个业务代码处。可以选择枚举类的形式来管理,但更好的方式是设计一张excel模板表,用于动态维护模板的信息。
这里我就使用枚举类形式了,对应的枚举类如下:
@Getter
@AllArgsConstructor
public enum ExcelTemplate {
Panda_Statistics(1, "excel/panda_statistics_template.xlsx", "熊猫统计模板");
private final Integer code;
private final String path;
private final String desc;
}
如果你要将模板信息放在数据库维护,就是将这个枚举类的字段抽象成表结构即可,还可以增加一个excel_type模板类型字段,从而使得表结构更加灵活~
好了,有了这个枚举类后,下面来看看本次的复杂报表导出需求,对应的模板如下:
这个报表也是多数场景下的需求,即系统自动统计并导出各项数据,如何实现呢?关于这点先不着急,咱们先来封装下模板填充导出的公用方法:
/*
* 初始化模板填充导出所需的写对象
* */
public static ExcelWriter initExportFillExcel(String fileName, ExcelTypeEnum excelType,
ExcelTemplate template, HttpServletResponse response) throws IOException {
setResponse(fileName, excelType, response);
return EasyExcelFactory.write(response.getOutputStream())
.excelType(excelType)
.withTemplate(template.getPath()).build();
}
/*
* 设置通用的响应头信息
* */
public static void setResponse(String fileName, ExcelTypeEnum excelType, HttpServletResponse response) {
// 对文件名进行UTF-8编码、拼接文件后缀名
try {
fileName = URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20") + excelType.getValue();
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
switch (excelType) {
case XLS:
response.setContentType(XLS_CONTENT_TYPE);
break;
case XLSX:
response.setContentType(XLSX_CONTENT_TYPE);
break;
case CSV:
response.setContentType(CSV_CONTENT_TYPE);
break;
}
response.setCharacterEncoding("utf-8");
response.setHeader("Content-Disposition", "attachment;filename*=utf-8''" + fileName);
}
这里的两个方法一眼就能看明白,setResponse()方法的作用是设置响应头信息,如文件名、响应数据格式、编码格式等。而initExportFillWriter()方法则是创建一个ExcelWriter对象,只不过是一个已经初始化Excel类型、模板、输出流等属性的对象罢了。
下面正式来看如何实现前面的导出场景,接口如下:
@PostMapping("/export/v7")
public void exportExcelV7(HttpServletResponse response) {
pandaService.exportPandaStatisticsData(response);
}
这个接口定义非常简单,重点来看看service层的导出逻辑:
@Override
public void exportPandaStatisticsData(HttpServletResponse response) {
// 查询熊猫集合数据
List<Panda> pandas = baseMapper.selectAll();
// 初始化各统计项变量
String statisticsUser = "竹子爱熊猫";
Date statisticsDate = new Date();
int maleNumber=0, femaleNumber=0, unknownNumber=0, totalNumber=0,
weekIncreaseNumber=0, monthIncreaseNumber=0, yearIncreaseNumber=0;
BigDecimal avgHeight, maxHeight=null, minHeight=null, sumHeight=BigDecimal.ZERO;
Calendar today = Calendar.getInstance();
int currentYear = today.get(Calendar.YEAR);
int currentMonth = today.get(Calendar.MONTH) + 1;
int currentWeek = today.get(Calendar.WEEK_OF_YEAR);
for (Panda panda : pandas) {
// 统计各项性别数据
Sex sex = Sex.ofCode(panda.getSex());
switch (sex) {
case MALE:
maleNumber++;
break;
case FEMALE:
femaleNumber++;
break;
default:
unknownNumber++;
break;
}
// 统计各项身高数据
BigDecimal height = panda.getHeight();
sumHeight = sumHeight.add(height);
if (null == maxHeight) {
maxHeight = height;
}
if (null == minHeight) {
minHeight = height;
}
if (minHeight.compareTo(height) < 0) {
minHeight = height;
}
if (maxHeight.compareTo(height) > 0) {
maxHeight = height;
}
// 统计本年、本月、本周新增人数
Calendar createCal = Calendar.getInstance();
createCal.setTime(panda.getCreateTime());
int year = createCal.get(Calendar.YEAR);
int month = createCal.get(Calendar.MONTH) + 1;
int week = createCal.get(Calendar.WEEK_OF_YEAR);
if (year == currentYear) {
yearIncreaseNumber++;
if (month == currentMonth) {
monthIncreaseNumber++;
if (week == currentWeek) {
weekIncreaseNumber++;
}
}
}
}
totalNumber = pandas.size();
avgHeight = sumHeight.divide(new BigDecimal(totalNumber), RoundingMode.HALF_UP);
Map<String, Object> dataMap = new HashMap<>(13);
dataMap.put("statisticsUser", statisticsUser);
dataMap.put("statisticsDate", statisticsDate);
dataMap.put("maleNumber", maleNumber);
dataMap.put("femaleNumber", femaleNumber);
dataMap.put("unknownNumber", unknownNumber);
dataMap.put("weekIncreaseNumber", weekIncreaseNumber);
dataMap.put("monthIncreaseNumber", monthIncreaseNumber);
dataMap.put("yearIncreaseNumber", yearIncreaseNumber);
dataMap.put("avgHeight", avgHeight);
dataMap.put("maxHeight", maxHeight);
dataMap.put("minHeight", minHeight);
dataMap.put("totalNumber", totalNumber);
dataMap.put("illustrate", "以上数据来自熊猫表");
String fileName = "熊猫数据统计报表";
try {
// 获取一个已设置基础参数的ExcelWriter对象
ExcelWriter excelWriter = ExcelUtil.initExportFillWriter(fileName,
ExcelTypeEnum.XLSX, ExcelTemplate.PANDA_STATISTICS, response);
WriteSheet writeSheet = EasyExcelFactory.writerSheet().build();
// 基于模板填充数据
FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
excelWriter.fill(pandas, fillConfig, writeSheet);
excelWriter.fill(dataMap, writeSheet);
excelWriter.finish();
}
catch (IOException e) {
throw new BusinessException("熊猫统计报表导出失败!");
}
}
上面这段代码看着不算短,但其实很简单,整体就做了几件事:
- ①从数据库里将整张熊猫表的数据查询出来;
- ②提前初始化各统计项的变量,并循环熊猫集合统计数据;
- ③在循环内部分别统计模板所需的性别、身高、新增等熊数;
- ④调用封装的通用方法,并将列表数据、统计数据填充并返回。
这就是整段代码逻辑的流程,最后调用下接口试试看:
来看最终的结果图,不管是上面的各个统计项,还是中间的熊猫列表数据,又或者最下面的备注项,数据都正常填充了进去,达到了咱们所希望的效果。
四、总结
作为EasyExcel系列的封篇之作,看到这里也就走进了尾声,其实本文的内容并不难理解,这篇的目的也仅是为了知识全面性,确保大家在面对复杂报表导出需求时,也仍然能够游刃有余。
相较于EasyExcel的普通写入模式,填充模式能更好的满足各种复杂报表需求,该模式基于提前设计好的Excel模板来导出文件,对应的模板中可以包含复杂的格式、公式、图表,以及花哨的Excel样式和布局,而我们只需关注数据的填充,就能导出各种花哨的报表了。
填充模式尽管能满足复杂的报表场景,但对于有些特殊的情况它仍然无法处理,这时还得上原生的POI,因为原生POI的操作性更强。除了无法适用于某些特殊场景外,填充模式也并不适用于大数据场景!
普通写入模式可以以流式去写入excel数据,尽管面对的数据量级大的场景,只要优化妥当,处理起来可以说是毫不费力。反观填充模式却不太行,尤其是填充列表数据时,手动开启了forceNewRow=true选项,这会导致所有数据全放在内存处理,从而导致大量资源的消耗。
综上所述,什么时候选择普通写入模式,什么时候选择模板填充模式,又或者什么时候使用原生的POI,这要视场景而定,每种方式都有各自的优劣势,充分了解各自的特性后,结合具体的业务场景才能选出最合适的方案。
所有文章已开始陆续同步至微信公众号:竹子爱熊猫,想在手机上便捷阅读的小伙伴可搜索关注~