Bootstrap

异步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);
    }
}

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

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

@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, 32s

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

完整代码实现可参考: ,欢迎交流设计思路