异步excel导出组件设计和实现
设计原则
支持大数据量场景下的excel导出。采用异步导出方式
降低excel导出时的内存消耗。基于easyExcel再次封装,支持excel定制化
统一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 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 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 void export(String filename, Class clazz, List 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
*
*
* 使用list数据导出。适用于数据量比较小的导出,推荐1W行下使用
* 注意,如果需要查询的数据量较大,会增大获取sid接口耗时
*
*
* @param fileName 文件名
* @param clazz excel对象类型
* @param data 要导出的数据
* @param excel对象类
* @return sid 导出唯一标识
*/
public String exportForSid(final String fileName, Class clazz, List data) {
return exportForSid(fileName, sid -> generateExcelFile(sid, fileName, clazz, data));
}
/**
* 导出并获取excel的sid
*
*
* 根据查询列表的方法导出excel。
* 因为传入查询方法后内部会异步去查询,故该方式不会产生接口超时的问题
* 该方式可复用列表查询的方法,推荐老项目改造时使用该方式
*
*
* @param fileName 文件名。无需拼接.xlsx后缀
* @param clazz excel对象类型
* @param supplier 查询数据列表的方法
* @param excel对象类
* @return sid 导出唯一标识
*/
public String exportForSid(final String fileName, Class clazz, Supplier> supplier) {
return exportForSid(fileName, sid -> generateExcelFile(sid, fileName, clazz, supplier.get()));
}
/*
*************** private start ***************
*/
/**
* 导出并获取excel的sid
*
*
* 根据分页查询的方法导出excel
* 注意该分页查询的方法入参必须继承 {@code BizPageRequest},且返回值类型为 {@code BizPageInfo}
* 该方式可复用列表查询的方法,推荐新项目接入时采用该方式
*
*
* @param fileName 文件名。无需拼接.xlsx后缀
* @param clazz excel对象类型
* @param request 查询参数
* @param supplier 分页查询方法
* @param excel对象类
* @return sid 导出唯一标识
*/
public String exportForSid(final String fileName, Class clazz, BizPageRequest request, Supplier> supplier) {
return exportForSid(fileName, sid -> generateExcelFile(sid, fileName, clazz, request, supplier));
}
/**
* 导出并获取excel的sid
*
* @param fileName 文件名
* @param generateFileFunction 生成excel文件的方法
* @return sid 导出唯一标识
*/
private String exportForSid(final String fileName, Function 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 File generateExcelFile(String sid, String sheetName, Class clazz, List 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 File generateExcelFile(String sid, String sheetName, Class clazz, BizPageRequest request, Supplier> 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 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;
}
}
业务方接入指南
cn.waynechu
biz-spring-cloud-api-starter
@ApiOperation("导出项目原型列表")
@PostMapping("/export")
public BizResponse export(@RequestBody SearchArchetypeRequest request) {
String sid = archetypeService.export(request);
return BizResponse.success(sid);
}
@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 包下的转换器实现
方式一:直接使用list数据导出
该方式推荐在数据量比较小的情况下使用。如果要查询的数据量很大,会增大获取sid接口的耗时。
@Service
public class ArchetypeServiceImpl implements ArchetypeService {
@Autowired
private ExcelHelper excelHelper;
@Override
public List list(SearchArchetypeRequest request) {
····
}
@Override
public String export(SearchArchetypeRequest request) {
// 查询导出数据
List data = this.listArchetypes(request);
// 导出excel
return excelHelper.exportForSid("原型列表", SearchArchetypeResponse.class, data);
}
}
方式二
因为传入的查询方法在内部会异步去处理,故该方式不会出现导出超时的情况。推荐老的项目改造时使用该方式。
@Service
public class ArchetypeServiceImpl implements ArchetypeService {
@Autowired
private ExcelHelper excelHelper;
@Override
public List 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 search(SearchArchetypeRequest request) {
····
}
@Override
public String export(SearchArchetypeRequest request) {
return excelHelper.exportForSid("原型列表", SearchArchetypeResponse.class, request, () -> search(request));
}
}
-1 (导出失败): 具体失败原因可通过requestId查询0 (生成中): 该状态下,前端轮询调用获取导出结果直到状态发生变更。轮询间隔建议从1秒开始指数型递增。如: 1s, 2s, 4s, 8s, 16s, 32s1 (生成成功): 成功状态下,取返回的url即为导出的excel文件地址