异步 excel 导出组件设计和实现

用户头像
Wayne Chu
关注
发布于: 2020 年 10 月 15 日
异步excel导出组件设计和实现

设计原则

  • 支持大数据量场景下的excel导出。采用异步导出方式

  • 降低excel导出时的内存消耗。基于easyExcel再次封装,支持excel定制化

  • 统一excel导出规范。后端导出接口统一化、前端导出交互组件化,简化开发流程

  • 封装公共导出方法,管理导出的整个生命周期。接入方只用关心业务逻辑,且代码复用性高

  • 使用自定义线程池异步处理,避免导出占用大量的服务器资源,影响业务接口正常响应

  • 引入文件系统。避免因导出文件过大、导出逻辑耗时过长带来的请求超时等的问题

设计思路

  1. 公共服务提供统一的excel导出接口,前端通过统一的接口来导出。统一导出接口会根据导出的url参数转发请求到各业务方的接口实现上,并带上导出参数

  2. 公共组件包中提供通用异步导出方法。该方法封装查库、数据写入excel、excel文件上传、同步导出状态等逻辑

  3. 公共服务提供统一的获取导出excel文件下载地址的方法,支持异步轮询下载导出文件

扩展点: 可在通用导出服务中添加导出审计日志,记录重要敏感数据的导出情况。并且可以添加导出权限控制、异常告警等扩展功能。

组件实施方案

1. 公共服务提供统一接口

@RestController
@Api(tags = "Excel导出服务")
@RequestMapping("/excels")
public class ExcelController {
@Autowired
private ExcelService excelService;
@ApiOperation(value = "获取sid", notes = "sid为当前excel导出请求唯一标识,用于异步获取导出状态")
@PostMapping("/export/sid")
public BizResponse<String> sid(@RequestParam String url, @ApiParam("导出查询参数") @RequestBody JSONObject params) {
String sid = excelService.getSid(url, params);
return BizResponse.success(sid);
}
@ApiOperation(value = "查询导出状态", notes = "轮询该接口来获取最新的导出状态,轮询时间推荐从1s开始指数型递增")
@PostMapping("/export/status")
public BizResponse<ExportResultResponse> status(@RequestParam String sid) {
ExportResultResponse response = excelService.status(sid);
return BizResponse.success(response);
}
}

2. 公共组件包提供统一导出方法

传统的同步导出方法直接在响应流中返回excel数据。当导出数据很大,或者导出数据依赖外部服务时,导出会出现请求超时、网关熔断等情况。

public class ExcelUtil {
...
/**
* 导出excel
*
* @param list 数据列表
* @param clazz 导出模型类
* @param filename 导出文件名
* @param response httpServletResponse
* @deprecated 已废弃。推荐使用 {@code exportForSid} 异步导出方法
*/
@Deprecated
public static <T> void export(String filename, Class<T> clazz, List<T> list, HttpServletResponse response) {
try {
filename = URLEncoder.encode(filename, "UTF-8");
} catch (UnsupportedEncodingException e) {
// do nothing here.
}
try (ServletOutputStream outputStream = response.getOutputStream()) {
response.setContentType("application/vnd.ms-excel");
response.setCharacterEncoding("utf-8");
response.addHeader("Content-Disposition", "attachment; filename=" + filename + EXPORT_UPLOAD_EXTENSIONS);
EasyExcel.write(outputStream, clazz).sheet(filename).doWrite(list);
} catch (IOException e) {
throw new BizException(BizErrorCodeEnum.OPERATION_FAILED, filename + "导出失败");
}
}
}

而异步导出方式支持大数据量导出的场景,封装查库、数据写入excel、excel文件上传、同步导出状态等逻辑。并且提供多种导出方式供业务方使用。

public class ExcelHelper {
...
/**
* 导出并获取excel的sid
*
* <pre>
* 使用list数据导出。适用于数据量比较小的导出,推荐1W行下使用
* 注意,如果需要查询的数据量较大,会增大获取sid接口耗时
* </pre>
*
* @param fileName 文件名
* @param clazz excel对象类型
* @param data 要导出的数据
* @param <T> excel对象类
* @return sid 导出唯一标识
*/
public <T> String exportForSid(final String fileName, Class<T> clazz, List<T> data) {
return exportForSid(fileName, sid -> generateExcelFile(sid, fileName, clazz, data));
}
/**
* 导出并获取excel的sid
*
* <pre>
* 根据查询列表的方法导出excel。
* 因为传入查询方法后内部会异步去查询,故该方式不会产生接口超时的问题
* 该方式可复用列表查询的方法,推荐老项目改造时使用该方式
* </pre>
*
* @param fileName 文件名。无需拼接.xlsx后缀
* @param clazz excel对象类型
* @param supplier 查询数据列表的方法
* @param <T> excel对象类
* @return sid 导出唯一标识
*/
public <T> String exportForSid(final String fileName, Class<T> clazz, Supplier<List<T>> supplier) {
return exportForSid(fileName, sid -> generateExcelFile(sid, fileName, clazz, supplier.get()));
}
/*
*************** private start ***************
*/
/**
* 导出并获取excel的sid
*
* <pre>
* 根据分页查询的方法导出excel
* 注意该分页查询的方法入参必须继承 {@code BizPageRequest},且返回值类型为 {@code BizPageInfo}
* 该方式可复用列表查询的方法,推荐新项目接入时采用该方式
* </pre>
*
* @param fileName 文件名。无需拼接.xlsx后缀
* @param clazz excel对象类型
* @param request 查询参数
* @param supplier 分页查询方法
* @param <T> excel对象类
* @return sid 导出唯一标识
*/
public <T> String exportForSid(final String fileName, Class<T> clazz, BizPageRequest request, Supplier<BizPageInfo<T>> supplier) {
return exportForSid(fileName, sid -> generateExcelFile(sid, fileName, clazz, request, supplier));
}
/**
* 导出并获取excel的sid
*
* @param fileName 文件名
* @param generateFileFunction 生成excel文件的方法
* @return sid 导出唯一标识
*/
private <T> String exportForSid(final String fileName, Function<String, File> generateFileFunction) {
final String sid = UUID.randomUUID().toString();
this.syncExportResult(sid, fileName, ExportStatusEnum.GENERATED, null);
executor.execute(() -> {
File tempFile = null;
ExportStatusEnum exportStatus = ExportStatusEnum.FAIL;
String url = null;
try {
// 生成excel
tempFile = generateFileFunction.apply(sid);
// 上传excel
url = this.uploadExcelFile(tempFile);
if (StringUtil.isNotEmpty(url)) {
exportStatus = ExportStatusEnum.SUCCESS;
}
} catch (Exception e) {
log.error("导出失败", e);
} finally {
// 同步导出状态
this.syncExportResult(sid, fileName, exportStatus, url);
// 删除临时excel文件
if (tempFile != null && tempFile.exists()) {
// noinspection ResultOfMethodCallIgnored
tempFile.delete();
}
}
});
return sid;
}
/**
* 生成excel文件
*
* @param sid 导出唯一标识
* @param sheetName 导出sheet名称
* @param clazz excel对象模型
* @param data 导出的数据
* @return excel文件
*/
private <T> File generateExcelFile(String sid, String sheetName, Class<T> clazz, List<T> data) {
File tempFile;
try {
tempFile = File.createTempFile(sid, EXPORT_UPLOAD_EXTENSIONS);
} catch (IOException e) {
throw new RuntimeException(e);
}
ExcelWriter excelWriter = EasyExcel.write(tempFile, clazz).build();
sheetName = this.encodeFileName(sheetName);
WriteSheet writeSheet = EasyExcel.writerSheet(sheetName)
// 添加 java8 时间类库支持
.registerConverter(new LocalTimeConvert())
.registerConverter(new LocalDateConvert())
.registerConverter(new LocalDateTimeConvert())
.build();
excelWriter.write(data, writeSheet);
excelWriter.finish();
return tempFile;
}
/**
* 生成excel文件
*
* @param sid 导出唯一标识
* @param sheetName 导出sheet名称
* @param clazz excel对象模型
* @param request 查询参数
* @param supplier 分页查询方法
* @return excel文件
*/
private <T> File generateExcelFile(String sid, String sheetName, Class<T> clazz, BizPageRequest request, Supplier<BizPageInfo<T>> supplier) {
File tempFile;
try {
tempFile = File.createTempFile(sid, EXPORT_UPLOAD_EXTENSIONS);
} catch (IOException e) {
throw new RuntimeException(e);
}
ExcelWriter excelWriter = EasyExcel.write(tempFile, clazz).build();
// 使用table方式写入,设置sheet不需要头
sheetName = this.encodeFileName(sheetName);
WriteSheet writeSheet = EasyExcel.writerSheet(sheetName).needHead(Boolean.FALSE)
// 添加 java8 时间类库支持
.registerConverter(new LocalTimeConvert())
.registerConverter(new LocalDateConvert())
.registerConverter(new LocalDateTimeConvert())
.build();
WriteTable writeTable;
BizPageInfo<T> bizPageInfo;
int pageIndex = 1;
// 默认从第一页开始查
request.setPageNum(1);
// 限制单次查询条数
request.setPageSize(QUERY_LIMIT);
do {
// 第一次写入会创建头,后面直接写入数据
writeTable = EasyExcel.writerTable(pageIndex).needHead(pageIndex == 1).build();
// 循环分页查询
request.setPageNum(pageIndex);
bizPageInfo = supplier.get();
excelWriter.write(bizPageInfo.getList(), writeSheet, writeTable);
pageIndex++;
} while (bizPageInfo.getHasNextPage() && pageIndex < PageLoopHelper.PAGE_LOOP_LIMIT);
excelWriter.finish();
return tempFile;
}
}

业务方接入指南

  1. 引入公共组件包,其中包含了封装的通用导出方法

<dependency>
<groupId>cn.waynechu</groupId>
<artifactId>biz-spring-cloud-api-starter</artifactId>
</dependency>
  1. 在本地项目中添加导出接口,返回 sid (即导出唯一标识)

@ApiOperation("导出项目原型列表")
@PostMapping("/export")
public BizResponse<String> export(@RequestBody SearchArchetypeRequest request) {
String sid = archetypeService.export(request);
return BizResponse.success(sid);
}
  1. 定义导出的excel数据模型

@Data
@ApiModel
public class SearchArchetypeResponse {
@ExcelProperty("原型id")
@ApiModelProperty("原型id")
private Long id;
@ExcelIgnore
@ApiModelProperty("项目类型: 0Service 1SDK")
private Integer appType;
@ExcelIgnore
@ApiModelProperty("状态: 0生成中 1成功 2失败")
private Integer statusCode;
@ExcelProperty(value = "上传git", converter = BooleanConvert.class)
@ApiModelProperty("上传git: 0否 1是")
private Boolean gitUploadType;
@ExcelProperty("创建人")
@ApiModelProperty("创建人")
private String createdUser;
@ExcelProperty("创建时间")
@ApiModelProperty("创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm", timezone = "GMT+8")
private LocalDateTime createdTime;
...
}

相关注解说明:

  • @ExcelProperty 自定义导出的标题头

  • @ExcelIgnore 导出时忽略该属性字段

  • @ColumnWidth 自定义导出的列宽度。加在类上全局生效,否则单列生效

  • @HeadRowHeight 自定义标题列的高度。只能加在类上

  • @ContentRowHeight 自定义内容列的高度。只能加在类上

  • @DateTimeFormat("yyyy年MM月dd日") 自定义时间字段格式

  • @NumberFormat("#.##%") 自定义数值类型格式。如加上百分号等

注:如果想要自定义类型转化器,可参考 cn.waynechu.springcloud.common.excel.convert 包下的转换器实现



  1. 使用导出工具类导出

方式一:直接使用list数据导出

该方式推荐在数据量比较小的情况下使用。如果要查询的数据量很大,会增大获取sid接口的耗时。

@Service
public class ArchetypeServiceImpl implements ArchetypeService {
@Autowired
private ExcelHelper excelHelper;
@Override
public List<SearchArchetypeResponse> list(SearchArchetypeRequest request) {
····
}
@Override
public String export(SearchArchetypeRequest request) {
// 查询导出数据
List<SearchArchetypeResponse> data = this.listArchetypes(request);
// 导出excel
return excelHelper.exportForSid("原型列表", SearchArchetypeResponse.class, data);
}
}

方式二(推荐)使用查询list数据的方法导出

因为传入的查询方法在内部会异步去处理,故该方式不会出现导出超时的情况。推荐老的项目改造时使用该方式。

@Service
public class ArchetypeServiceImpl implements ArchetypeService {
@Autowired
private ExcelHelper excelHelper;
@Override
public List<SearchArchetypeResponse> listArchetypes(SearchArchetypeRequest request) {
····
}
@Override
public String export(SearchArchetypeRequest request) {
return excelHelper.exportForSid("原型列表", SearchArchetypeResponse.class, () -> listArchetypes(request));
}
}

方式三(推荐)使用列表查询分页数据的方法导出

使用该方式需满足分页查询规范,入参继承 BizPageRequest 类、出参返回 BizPageInfo 类型。

该方式可复用列表查询的方法,推荐新项目接入时使用该方式。

@Service
public class ArchetypeServiceImpl implements ArchetypeService {
@Autowired
private ExcelHelper excelHelper;
@Override
public BizPageInfo<SearchArchetypeResponse> search(SearchArchetypeRequest request) {
····
}
@Override
public String export(SearchArchetypeRequest request) {
return excelHelper.exportForSid("原型列表", SearchArchetypeResponse.class, request, () -> search(request));
}
}



  1. 前后端联调

  2. 前端请求通用导出地址获取sid。接口地址为: POST /service-utility/excels/export/sid

  3. 通用导出根据地址转发请求到业务放项目的导出接口上。导出地址格式为 项目名 + 接口路径

  4. 前端拿到sid之后调用通用服务接口获取导出结果。 接口地址为: POST /service-utility/excels/export/status



  1. 导出状态status说明

  • -1 (导出失败): 具体失败原因可通过requestId查询

  • 0 (生成中): 该状态下,前端轮询调用获取导出结果直到状态发生变更。轮询间隔建议从1秒开始指数型递增。如: 1s, 2s, 4s, 8s, 16s, 32s

  • 1 (生成成功): 成功状态下,取返回的url即为导出的excel文件地址



完整代码实现可参考: biz-parent微服务开发脚手架,欢迎交流设计思路

发布于: 2020 年 10 月 15 日 阅读数: 43
用户头像

Wayne Chu

关注

还未添加个人签名 2020.07.20 加入

还未添加个人简介

评论

发布
暂无评论
异步excel导出组件设计和实现