基于easyExcel灵活动态导出的实现

1,011 阅读7分钟

背景

前段时间公司准备做一个新系统,类似于工业ERP的SAAS系统,系统中大量存在导出的业务。趁着这个机会,原来的导出服务用起来确实不怎么样,于是重新写了一套公共导出的逻辑。

设计需求

1.基本要求是一要满足组内人的开发习惯,可以兼容简单的easyExcel导出(写一个实体类加注解)的方式,二是要满足动态表头的导出
2.需要同时满足同步导出和异步导出,统一导出逻辑,不要每个业务都写一个专属的导出接口(原来的异步导出就是如此,每个导出业务需要写一个创建任务的接口,然后一发服务至少三个)
3.业务实现简单,不然上手是个问题
4.方便扩展,不论是业务扩展也好还是性能扩展也好

上手

syncExport同步导出和asyncExport异步导出,同步导出返回文件地址,异步导出返回任务id,主要实现逻辑在export方法。异步导出任务那一块的主要实现逻辑用的是前面提到的补偿逻辑,其实这也是一种异步操作的逻辑,具体在这篇文章里(如何利用补偿机制减少事务使用频次),以下是主要实现类:

package com.lvchuan.export;

import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.builder.ExcelWriterBuilder;
import com.alibaba.excel.write.handler.WriteHandler;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.alibaba.fastjson.JSONObject;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lvchuan.aysnc.AsyncBizCode;
import com.lvchuan.aysnc.AsyncEnable;
import com.lvchuan.aysnc.AsyncProxyUtil;
import com.lvchuan.enums.AsyncExportTaskStatusEnum;
import com.lvchuan.export.style.HeadCellWriteWeightStyleHandler;
import com.lvchuan.model.AsyncExportTask;
import com.lvchuan.page.PageRequest;
import com.lvchuan.utils.ClassUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.ReflectionUtils;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.lang.reflect.Field;
import java.util.*;

/**
 * @description: 动态导出工厂
 * @author: lvchuan
 * @createTime: 2023-12-07 13:49
 */
@Slf4j
@Component
@AsyncEnable
public class DynamicExportFactory {
    @Autowired
    private List<IDynamicExportHandler> dynamicExportHandlerList;
    @Autowired
    private ObjectMapper objectMapper;
    @Autowired
    private AsyncProxyUtil asyncProxyUtil;

    /**
     * 导出文件最大条数
     */
    @Value("${file.export.max.num: 500000}")
    private Integer fileExportMaxNum;

    /**
     * 获取导出处理类
     * @param bizType 业务类型
     * @return 请求参数
     */
    private IDynamicExportHandler getDynamicExportHandler(ExportTemplateBizTypeEnum bizType) {
        for (IDynamicExportHandler handler : dynamicExportHandlerList) {
            if (Objects.equals(bizType, handler.bizType())) {
                return handler;
            }
        }
        throw new RuntimeException("动态导出未获取到对应实现类");
    }

    /**
     * 获取文件名称
     * @param handler 业务处理器
     * @param param 参数
     * @return 文件名称
     */
    private String getFileName(IDynamicExportHandler handler, Object param) {
        String fileName = handler.fileName(param);
        Date currentDate = new Date();
        try {
            fileName = fileName + ".xlsx";
        } catch (Exception e) {
            log.error("动态导出转换文件名称错误:{},{}", fileName, e);
            throw new RuntimeException("动态导出转换文件名称错误");
        }
        return fileName;
    }

    /**
     * 构建sheet名称
     * @param writerBuilder 写入构造器
     * @param handler 业务处理器
     * @param param 请求参数
     * @return sheet类
     */
    private WriteSheet buildSheetName(ExcelWriterBuilder writerBuilder,
                                 IDynamicExportHandler handler,
                                 Object param) {
        String sheetName = handler.sheetName(param);
        if (StrUtil.isNotBlank(sheetName)) {
            writerBuilder.sheet(sheetName);
        } else {
            sheetName = "sheet";
        }
        return EasyExcel.writerSheet(sheetName).build();
    }

    /**
     * 构造表头
     * @param writerBuilder 写入构造器
     * @param handler 业务处理器
     * @param param 请求参数
     * @return 是否是自定义表头
     */
    private LinkedHashMap<String, String> buildHead(ExcelWriterBuilder writerBuilder,
                           IDynamicExportHandler handler,
                           Object param) {
        LinkedHashMap<String, String> headMap = handler.headMap(param);
        if (CollUtil.isNotEmpty(headMap)) {
            List<List<String>> head = new ArrayList<>();
            for(Map.Entry<String, String> entry : headMap.entrySet()) {
                List<String> headList = new ArrayList<>();
                headList.add(entry.getValue());
                head.add(headList);
            }
            writerBuilder.head(head);
            return headMap;
        } else {
            Class headClass = handler.headClass(param);
            if (Objects.isNull(headClass)) {
                throw new RuntimeException("请配置表头");
            }
            writerBuilder.head(headClass);
            return null;
        }
    }

    /**
     * 构建风格构造器
     *
     * @param writerBuilder 写入构造器
     * @param handler 业务处理器
     * @param param 参数
     */
    private void buildRegisterWriteHandler(ExcelWriterBuilder writerBuilder,
                                           IDynamicExportHandler handler,
                                           Object param) {
        List<WriteHandler> writeHandlerList = handler.writeHandlerList(param);
        if (CollUtil.isNotEmpty(writeHandlerList)) {
            for (WriteHandler temp : writeHandlerList) {
                writerBuilder.registerWriteHandler(temp);
            }
        }
        //添加默认分割配置
        //自适应宽度
        writerBuilder.registerWriteHandler(new HeadCellWriteWeightStyleHandler());
    }

    /**
     * 写入数据
     * @param excelWriter 写入处理类
     * @param writeSheet sheet类
     * @param handler 业务处理器
     * @param param 请求参数
     * @param customizeHeadMap 自定义表头
     */
    private void writeData(ExcelWriter excelWriter,
                           WriteSheet writeSheet,
                           IDynamicExportHandler handler,
                           Object param,
                           LinkedHashMap<String, String> customizeHeadMap) {
        try {
            PageRequest pageRequest = new PageRequest();
            pageRequest.setSize(500);
            pageRequest.setCurrent(1);
            int dataSize = 0;
            while (true) {
                List<Object> dataList = handler.queryData(param, pageRequest);
                if (CollUtil.isEmpty(dataList)) {
                    if (pageRequest.getCurrent() == 1) {
                        //没有数据,写入空文件
                        excelWriter.write(null, writeSheet);
                    }
                    break;
                } else  {
                    dataSize += dataList.size();
                    if (dataSize > fileExportMaxNum) {
                        break;
                    }
                }
                if (CollUtil.isNotEmpty(customizeHeadMap)) {
                    excelWriter.write(this.customizeHeadBuildData(dataList, customizeHeadMap), writeSheet);
                } else {
                    excelWriter.write(dataList, writeSheet);
                }
                pageRequest.setCurrent(pageRequest.getCurrent() + 1);
            }
        } catch (Exception e) {
            log.error("导出数据失败:{}", e);
            throw new RuntimeException("导出数据失败");
        } finally {
            if (Objects.nonNull(excelWriter)) {
                excelWriter.finish();
            }
        }
    }

    /**
     * 自定义表头构造数据
     * @param customizeHeadMap 自定义表头配置
     * @param dataList 业务数据
     * @return 处理后的数据
     */
    private List<List<Object>> customizeHeadBuildData(List<Object> dataList,
                                                      LinkedHashMap<String, String> customizeHeadMap) throws IllegalAccessException {
        List<String> headFieldList = new ArrayList<>();
        for(Map.Entry<String, String> entry : customizeHeadMap.entrySet()) {
            headFieldList.add(entry.getKey());
        }
        List<List<Object>> result = new ArrayList<>();
        for (Object temp : dataList) {
            Field[] fieldList = temp.getClass().getDeclaredFields();
            Object[] currentDataList = new Object[headFieldList.size()];
            for (Field field : fieldList) {
                if (!headFieldList.contains(field.getName())) {
                    continue;
                }
                int idx = headFieldList.indexOf(field.getName());
                if (idx < 0 || idx > headFieldList.size() - 1) {
                    continue;
                }
                ReflectionUtils.makeAccessible(field);
                currentDataList[idx] = field.get(temp);
            }
            result.add(Arrays.asList(currentDataList));
        }
        return result;
    }

    /**
     * 构建临时文件路径
     * @return 临时文件路径
     */
    private String buildTempFilePath() {
        String dir = System.getProperty("user.dir");
        return dir;
    }

    /**
     * 同步导出
     * @param paramJson 请求参数
     * @param bizType 业务类型
     * @return 文件地址
     */
    public String syncExport(JSONObject paramJson, ExportTemplateBizTypeEnum bizType) {
        return this.export(paramJson, bizType);
    }

    /**
     * 同步导出
     * @param paramJson 请求参数
     * @param bizType 业务类型
     * @return 导出任务id
     */
    public Long asyncExport(JSONObject paramJson, ExportTemplateBizTypeEnum bizType) {
        Date currentDate = new Date();
        //todo 获取id策略
        Long id = 1L;
        AsyncExportTask asyncExportTask = new AsyncExportTask();
        asyncExportTask.setId(id);
        asyncExportTask.setStatus(AsyncExportTaskStatusEnum.WAIT);
        asyncExportTask.setBizType(bizType);
        asyncExportTask.setGmtCreate(currentDate);
        asyncExportTask.setTaskName(bizType.getDesc());
        asyncExportTask.setGmtModified(currentDate);
        //todo 保存异步导出信息
        asyncProxyUtil.proxy(this).dealAsyncExportTask(id, paramJson);
        return id;
    }

    /**
     * 处理异步导出任务
     * @param taskId 任务id
     * @param paramJson 参数
     */
    @AsyncEnable("异步导出任务")
    public void dealAsyncExportTask(@AsyncBizCode Long taskId, JSONObject paramJson) {
        //todo 查询数据库导出信息
        AsyncExportTask asyncExportTask = new AsyncExportTask();
        if (Objects.isNull(asyncExportTask)) {
            log.error("未获取到异步导出任务:{}", taskId);
            return;
        }
        String url;
        try {
            url = this.export(paramJson, asyncExportTask.getBizType());
            String fileName = null;
            if (StrUtil.isNotBlank(url)) {
                fileName = url.substring(url.lastIndexOf("/") + 1);
            }
            asyncExportTask.setFileUrl(url);
            asyncExportTask.setFileName(fileName);
            asyncExportTask.setStatus(AsyncExportTaskStatusEnum.FINISH);
        } catch (Exception e) {
            log.error("导出任务异常:{},{}", taskId, e);
            asyncExportTask.setStatus(AsyncExportTaskStatusEnum.ERROR);
        }
        asyncExportTask.setGmtModified(new Date());
        //todo 更新异步导出信息
    }

    /**
     * 获取业务参数
     * @param handler 处理器
     * @return 业务参数
     */
    private Object getParam(IDynamicExportHandler handler, JSONObject paramJson)
            throws JsonProcessingException {
        if (Objects.isNull(paramJson)) {
            return null;
        }
        Class objClass = ClassUtils.getGenericInterfacesClass(handler, 0);
        if (objClass == null) {
            return null;
        }
        return objectMapper.readValue(paramJson.toJSONString(), objClass);
    }

    /**
     * 导出主要逻辑
     */
    public String export(JSONObject paramJson, ExportTemplateBizTypeEnum bizType) {
        ExcelWriter excelWriter;
        File file = null;
        FileInputStream fis = null;
        ByteArrayOutputStream bos = null;
        try {
            IDynamicExportHandler handler = this.getDynamicExportHandler(bizType);
            //获取请求参数
            Object param = this.getParam(handler, paramJson);
            //获取文件名称
            String fileName = this.getFileName(handler, param);
            //获取临时文件地址
            String dir = this.buildTempFilePath();
            file = FileUtil.file(dir, fileName);
            ExcelWriterBuilder writerBuilder = EasyExcel.write(file)
                    .autoCloseStream(Boolean.FALSE);
            //构造sheet
            WriteSheet writeSheet = this.buildSheetName(writerBuilder, handler, param);
            //构造表头
            LinkedHashMap<String, String> customizeHeadMap = this.buildHead(writerBuilder, handler, param);
            //构造自定义处理器
            this.buildRegisterWriteHandler(writerBuilder, handler, param);
            //创建导出类
            excelWriter = writerBuilder.build();
            //处理业务数据
            this.writeData(excelWriter, writeSheet, handler, param, customizeHeadMap);
            // 获取文件流
            fis = new FileInputStream(file);
            bos = new ByteArrayOutputStream();
            byte[] b = new byte[1024];
            int n;
            while ((n = fis.read(b)) != -1) {
                bos.write(b, 0, n);
            }
            byte[] fileData = bos.toByteArray();
            // 文件服务,如oss等,返回文件地址
            return "https://123.jpg";
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException("导出文件出错");
        } finally {
            try {
                if (Objects.nonNull(fis)) {
                    fis.close();
                }
                if (Objects.nonNull(bos)) {
                    bos.close();
                }
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
            if (Objects.nonNull(file)) {
                file.delete();
            }
        }
    }
}

然后加两个接口,一个异步导出一个同步导出,传参一致:

@RestController
@RequestMapping("/export")
public class ExportController {
    @Autowired
    private DynamicExportFactory dynamicExportFactory;

    @PostMapping("/syncExport")
    public String syncExport(@RequestBody @Validated ExportReq exportReq) {
        return dynamicExportFactory.syncExport(exportReq.getParam(), exportReq.getBizType());
    }

    @PostMapping("/asyncExport")
    public Long asyncExport(@RequestBody @Validated ExportReq exportReq) {
        return dynamicExportFactory
                .asyncExport(exportReq.getParam(), exportReq.getBizType());
    }
}

请求实体两个参数,bizType标识是哪个业务,param是对应业务的请求参数:

@Data
public class ExportReq {
    /**
     * 业务类型
     */
    @JsonDeserialize(using = BaseEnumDeserializer.class)
    @JsonSerialize(using = BaseEnumSerializer.class)
    private ExportTemplateBizTypeEnum bizType;

    /**
     * 查询参数
     */
    private JSONObject param;
}

然后是简单的业务实现类:

/**
 * @description: 任务处理器
 * @author: lvchuan
 * @createTime: 2023-12-08 10:41
 */
@Component
@Slf4j
public class TestExportHandler implements IDynamicExportHandler<QueryPageReq> {

    private static final String FILE_NAME = "溯源信息导出";

    @Override
    public ExportTemplateBizTypeEnum bizType() {
        return ExportTemplateBizTypeEnum.TEST;
    }

    @Override
    public String fileName(QueryPageReq param) {
        Date currentDate = new Date();
        return FILE_NAME + "-" + DateUtil.format(currentDate, "yyyyMMdd") + "_" + currentDate.getTime();
    }

    @Override
    public LinkedHashMap<String, String> headMap(QueryPageReq param) {
        /**
         * headClass 或 headMap必须实现其中一个方法,同时实现优先用headMap方法的表头
         */
        //todo 获取自定义表头信息
        List<ExportTemplateConfig> configList = new ArrayList<>();
        LinkedHashMap<String, String> headMap = new LinkedHashMap<>();
        for (ExportTemplateConfig temp : configList) {
            //fieldName 字段名称与实体对齐,显示名称暂时不用管,主要与配置项相关,这里只有导出逻辑
            headMap.put(temp.getFieldName(), temp.getShowName());
        }
        return headMap;
    }

    @Override
    public Class headClass(QueryPageReq param) {
        /**
         * headClass 或 headMap必须实现其中一个方法,同时实现优先用headMap方法的表头
         * 这里返回class即可,会根据easy excel默认的方式去处理表头
         */
        return TestExportModel.class;
    }

    @Override
    public List<TestExportModel> queryData(QueryPageReq param, PageRequest pageRequest) {
        /**
         * 这里是具体业务查询数据的实现
         * param 为查询条件
         * pageRequest 为分页查询参数,主要传入当前页和每页查询条数
         * return 查询出来的数据,如果为空,将生成一个空的excel(包含表头)。循环分页查询直至没有数据为止
         */
        return new ArrayList<>();
    }
}

其他

git项目地址

一点题外话

最近心情不怎么好,有点不想干活了,一是要过年了,二是上个月底我们公司也迎来了裁员的大刀。产研被裁了一半多,我们组后端开发的基本都是想走的(补偿是N+1,听说年终也会发)。但不幸的是我被留下来了,导致心情不是很好。我们组走的几个听说有两个已经在新公司上班了,其中一个还是无缝连接。另外一个也比较乐观,想耍到开年。目前大环境确实不好,也有些伤感,前一天还在改bug的同事,今天就让决定去与留。不过看情况市场还是有的,也不必过于悲观。