Java实现Excel通用异步导出框架

博主最近要做一个需求,就是设计一个通用的异步导出框架,如果数据量小于5W那么直接进行导出,数据大于5W则创建异步导出任务,并且前端可以刷新该任务进行,然后后面所有相关功能的导出都使用此框架进行操作,思来想去实现后,想着该需求应该符合大部分实际工作的场景,特此分享!

请注意!请注意!请注意!(代码中很多实体类需要自己创建哈  比如前端提交的参数 比如数据库实体类,当前文章是面向一定开发经验的选手,CV是没用的)

审题:

        1. 小于5W直接导出,大于5W则需要创建任务

        2. 异步导出

        3. 框架

分析1:

        小于5W直接导出,所以我们框架需要从使用者手里知道  本次导出的数据总量是多少

分析2:

        既然是异步导出,所以我们不能让主线程去执行导出操作,所以主线程只管将导出任务的相关信息交给其他线程即可

分析3:

        既然是框架,那么我们不能关心具体的导出实现逻辑,例如怎么获取表的数据,怎么查询总量,但是我们需要关心一些通用的逻辑,例如创建文件,使用导出组件进行导出,等等

好了,现在开始实现。

一。创建任务表,记录任务的状态,进度,以及任务的类型

-- public.export_task definition

-- Drop table

-- DROP TABLE public.export_task;

CREATE TABLE public.export_task (
	id text NOT NULL,
	export_key text NOT NULL,  -- 任务类型
	params text NOT NULL, -- 任务所需要的参数
	status int2 NOT NULL, -- 任务状态
	progress text NOT NULL, -- 任务进度
	create_user text NOT NULL, -- 创建人
	gmt_create text NOT NULL, -- 创建时间
	single_export_num int4 NOT NULL DEFAULT 50000, -- 单次导出条数
	file_name text NOT NULL, -- 导出的文件名称
	file_path text NULL, -- 导出的文件路径
	complete_time text NULL, -- 导出完成时间
	CONSTRAINT export_task_pkey PRIMARY KEY (id) -- 主键
);

-- 索引
CREATE INDEX export_task_export_key_index ON public.export_task (export_key text_ops);

二。有了任务表后,就可以开始考虑怎么执行任务了。

我的思路是:新增任务 -> 必要校验完成 -> 通过任务类型(也就是上表中的export_key)找到需要处理的处理类 -> 调用获取总量的方法,判断总量是否大于5W

总量大于5W -> 将当前任务插入数据库 -> 然后将当前任务丢给队列,等待被消费处理

总量小于5W -> 调用处理类的导出方法直接进行导出

上代码:controller类,新增任务(公司代码里面有记录日志的逻辑你们不能复用)

 @POST
    @Path("/task-manage")
    @Produces({ MediaType.APPLICATION_JSON })
    @ApiOperation(value = "新建导出任务", notes = "新建导出任务", httpMethod = "POST", tags = {"新建导出任务"})
    public void taskCreate(TaskInfoDTO condition, @Context HttpServletResponse response) throws Exception {
        ServiceBaseInfoBean infoBean = new ServiceBaseInfoBean(UserThreadLocal.getuserName(),
                UserThreadLocal.getRemoteHost(),UserThreadLocal.getLanguageOption());

        ExportTaskMgr.Operation operation = ExportTaskMgr.Operation.DIRECT_EXPORT;
        ExportTaskMgr.initOperlogBean(infoBean, operation);
        String detailZh = String.format(operation.getDetailZh(), condition.getFileName());
        String detailEn = String.format(operation.getDetailEn(), condition.getFileName());
        try{
            condition.checkFileName();
            Long allDataTotal = taskCommandApplicationService.exportOrCreateTask(condition, response);
            if(allDataTotal > GlobalConstants.EXPORT_DEFAULT_COUNT){
                operation = ExportTaskMgr.Operation.CREATE_EXPORT_TASK;
                ExportTaskMgr.initOperlogBean(infoBean, operation);
            }
            detailZh = String.format(operation.getDetailZh(), condition.getFileName());
            detailEn = String.format(operation.getDetailEn(), condition.getFileName());
            ExportTaskMgr.refreshSuccessDetail(infoBean,operation, detailZh,detailEn);
        }
        catch (Exception e){
            ResponseUtils.resetContentType(response);
            ExportTaskMgr.refreshFailDetail(infoBean,operation, detailZh,detailEn,e);
            throw e;
        }
        finally {
            // 发送操作记录日志
            String operlogMsg = JSON.toJSONString(infoBean.getOperlogBean());
            msgSenderService.sendMsgAsync(OperlogBean.IMOP_LOG_MANAGE_TOPIC, operlogMsg);
        }
    }

service: 通过当前任务类型  调用工厂找到处理类 然后... 看注释把

 @Override
    public Long exportOrCreateTask(TaskInfoDTO condition, HttpServletResponse response) throws Exception {
        BiFunction<ExportTaskPO, Map<String, Future<?>>, ExportHandler> handlerFuc = ExportHandlerFactory
                .getHandlerByExportKey(condition.getExportKey());
        if(null == handlerFuc){
            throw new MonitorException(-1, I18nConstants.UNKNOWN_EXPORT_TYPE);
        }

        // 新建任务相关信息 暂不入库
        ExportTaskPO taskPO = new ExportTaskPO();
        taskPO.setId(UUID.randomUUID().toString());
        taskPO.setProgress(GlobalConstants.DEFAULT_VALUE);
        taskPO.setStatus(ExportStatusEnums.INIT.getStatus());
        taskPO.setCreateUser(UserThreadLocal.getuserName());
        taskPO.setFileName(condition.getFileName());
        taskPO.setExportKey(condition.getExportKey());
        taskPO.setParams(condition.getParams());
        taskPO.setGmtCreate(dateTimeService.getCurrentTime());
        taskPO.setSingleExportNum(GlobalConstants.EXPORT_DEFAULT_COUNT);
        taskPO.setFilePath(GlobalConstants.DEFAULT_VALUE);
        taskPO.setCompleteTime(GlobalConstants.DEFAULT_VALUE);

        // 构建handler
        ExportHandler handler = handlerFuc.apply(taskPO, new HashMap<>());

        // 获取total 如果大于单次导出条数则创建任务 然后后台异步导出
        Long total = handler.getTotal();
        if(total > taskPO.getSingleExportNum()){
            exportTaskRepository.save(taskPO);
            ExportHandlerFactory.addTaskToQue(new ArrayList<>(Collections.singletonList(taskPO)));
        }
        // 满足单次导出 则直接导出
        else {
            handler.directExport(response);
        }

        return total;
    }

工厂类的具体实现:

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.BiFunction;

@Slf4j
@Component
public class ExportHandlerFactory {
    private static final ExportTaskRepository EXPORT_TASK_REPOSITORY = SpringUtil.getBean(ExportTaskRepository.class);
    /**
     * 线程池  3个线程消息导出任务
     */
    private static final ExecutorService THREAD_POOL = Executors.newFixedThreadPool(3);

    /**
     * 记录执行中的线程
     */
    private static final Map<String, Future<?>> RUNNING_THREAD = new ConcurrentHashMap<>();

    /**
     * 任务队列 轮训消费此队列
     */
    private static final Queue<ExportTaskPO> TASK_QUE = new ConcurrentLinkedQueue<>();

    /**
     * 定义处理器 例如DEMO类型导出任务由DEMO_HANDLER进行处理
     */
    private static final Map<String, BiFunction<ExportTaskPO,Map<String, Future<?>>,ExportHandler>> HANDLER_MAP = new ConcurrentHashMap<>();
    static {
        HANDLER_MAP.put(ExportTypeEnums.DEMO_EXPORT.getExportKey(), DemoExportHandler::new);
    }

    @PostConstruct
    @SuppressWarnings("InfiniteLoopStatement")
    public static void init() {
        Runnable runnable = () -> {
            while (true) {
                try {
                    // 每隔50ms拉取一次导出任务 如果已经消费完 则不再执行
                    TimeUnit.MILLISECONDS.sleep(50L);
                    ExportTaskPO taskInfo = TASK_QUE.poll();
                    if(taskInfo == null) {
                        continue;
                    }

                    // 根据导出类型key 找到handler 开始处理
                    BiFunction<ExportTaskPO,Map<String, Future<?>>,ExportHandler> biFunction =
                            HANDLER_MAP.get(taskInfo.getExportKey());
                    log.info("begin consume taskInfo={},biFunction={}",taskInfo,biFunction);

                    if(biFunction != null) {
                        // 设置任务信息 以及记录运行状态的map 得到处理者
                        ExportHandler exportHandler = biFunction.apply(taskInfo, RUNNING_THREAD);
                        log.info("begin consume exportHandler={}",exportHandler);
                        // 执行任务
                        Future<Boolean> exceuteFuture = THREAD_POOL.submit(exportHandler);
                        // 将正在执行的任务放入map中
                        RUNNING_THREAD.put(taskInfo.getId(),exceuteFuture);
                    }
                }
                catch (Exception e) {
                    log.error("consume export task error.",e);
                }
            }
        };

        Executors.newFixedThreadPool(1).execute(runnable);
    }

    public static void stopTaskByTaskId(String taskId){
        // 从运行中的map 拿到执行中的线程
        Future<?> future = RUNNING_THREAD.get(taskId);

        // 如果存在  直接停止运行
        if(future != null){
            try{
                future.cancel(Boolean.TRUE);
            }
            catch (Exception e){
                log.debug("destroy thread error",e);
            }
            finally {
                RUNNING_THREAD.remove(taskId);
            }
        }
    }

    public static BiFunction<ExportTaskPO,Map<String, Future<?>>,ExportHandler> getHandlerByExportKey(String ExportKey){
        // 根据导出类型key 找到handler 开始处理
        return HANDLER_MAP.get(ExportKey);
    }


    public static void addTaskToQue(List<ExportTaskPO> needExecuteTask){
        if(CollectionUtils.isNotEmpty(needExecuteTask)){
            TASK_QUE.addAll(needExecuteTask);
        }
    }

    public static void initTaskWhenAppRun(){
        try{
            List<ExportTaskPO> needExecuteTask = EXPORT_TASK_REPOSITORY
                    .lambdaQuery()
                    .in(ExportTaskPO::getStatus, Arrays.asList(ExportStatusEnums.INIT.getStatus(), ExportStatusEnums.RUNNING.getStatus()))
                    .orderByAsc(ExportTaskPO::getGmtCreate)
                    .list();
            log.info("initAllWaitExecuteTask={}",needExecuteTask);
            addTaskToQue(needExecuteTask);
        }
        catch (Exception e){
            log.error("initAllWaitExecuteTask error",e);
        }
    }
}

 工厂类定了轮训消费,定义了哪个类型的任务由哪个处理器处理,定义了执行中的任务以便删除任务时立刻任务执行,等等

工厂类相当于总管,但是具体的实现是由ExportHandler这个类来完成处理,那么这个类又做了什么些事情呢~~~

import javax.servlet.http.HttpServletResponse;
import java.io.BufferedOutputStream;
import java.io.File;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.*;
import java.util.concurrent.Callable;
import java.util.concurrent.Future;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

@Slf4j
public abstract class ExportHandler implements Callable<Boolean> {
    public final ExportTaskPO taskInfo;
    private final Map<String, Future<?>> runMap;
    private static final ExportTaskRepository EXPORT_TASK_REPOSITORY = SpringUtil.getBean(ExportTaskRepository.class);
    private static final FileConfig FILE_CONFIG = SpringUtil.getBean(FileConfig.class);
    private static final DateTimeService DATE_TIME_SERVICE = SpringUtil.getBean(DateTimeService.class);

    public ExportHandler(ExportTaskPO taskInfo,Map<String,Future<?>> runMap){
        this.taskInfo = taskInfo;
        this.runMap = runMap;
    }

    /**
     * 数据小于5w条时直接导出
     * @param response 导出response
     * @throws Exception 异常时直接抛出
     */
    public void directExport(HttpServletResponse response) throws Exception {
        FileUtils.checkFileName(taskInfo.getFileName());

        // 设置响应类型以及文件名
        response.reset();
        response.setContentType(GlobalConstants.CONTENT_TYPE_XLSX);
        String encodeName = URLEncoder.encode(taskInfo.getFileName(),GlobalConstants.ENCODER_UTF8);
        response.setHeader(GlobalConstants.CONTENT_DISPOSITION_KEY, GlobalConstants.CONTENT_DISPOSITION_FILE_PREFIX.concat(encodeName));

        // 写入数据
        EasyExcel
                .write(response.getOutputStream())
                .excelType(ExcelTypeEnum.XLSX)
                .charset(StandardCharsets.UTF_8)
                .sheet(FileNameUtil.mainName(taskInfo.getFileName()))
                .head(getHeader())
                .doWrite(getData(null,null));
    }

    /**
     * 默认的数据大于5W时的异步导出处理逻辑 默认单sheet页 简单实现更新任务进度
     * 如果有复杂的多sheet页导出 请在你的handler里面重写此方法
     */
    public void asyncExport(){
        ZipOutputStream zipOut = null;
        File file = null;
        // 执行导出逻辑
        try {
            // 任务执行中
            log.info("begin export,taskId:{}",taskInfo.getId());
            updateTaskInfo(BigDecimal.ZERO + GlobalConstants.PER_CENT,ExportStatusEnums.RUNNING.getStatus(),null);

            // 检查文件名称 获取没有后缀的文件名
            FileUtils.checkFileName(taskInfo.getFileName());
            String fileMainName = FileNameUtil.mainName(taskInfo.getFileName());

            // 先在服务器生成文件,得到输出流
            String filePath = FileUtils.getDateFileName(fileMainName, GlobalConstants.FILE_TYPE_ZIP);
            file = FileUtils.getAbsoluteFile(FILE_CONFIG.getExportPath(), filePath);
            zipOut = new ZipOutputStream(new BufferedOutputStream(Files.newOutputStream(file.toPath())));

            // 根据总量数据以及分页每条的数据  得到要导出多少次并且遍历 开始导入数据
            long exportCount = calculateExportCount(getTotal(),taskInfo.getSingleExportNum());

            // 遍历导出的次数 每次生成一个excel文件
            dealExport(exportCount,zipOut);

            // 导出完成后更新文件路径,完成时间以及状态
            taskInfo.setFilePath(filePath);
            updateTaskInfo(null,ExportStatusEnums.SUCCESS.getStatus(),DATE_TIME_SERVICE.getCurrentTime());
            log.info("end export,taskId:{}",taskInfo.getId());
        }
        // 异常打印异常信息 更新任务进度为-- 状态为失败  如果生成了文件 还需要把文件删除
        catch (Exception e) {
            log.error("export error,taskId:{}",taskInfo.getId(),e);
            updateTaskInfo(GlobalConstants.DEFAULT_VALUE,ExportStatusEnums.FAILED.getStatus(),DATE_TIME_SERVICE.getCurrentTime());

            if(file != null){
                boolean delete = file.delete();
                log.info("export error,taskId:{},file delete:{}",taskInfo.getId(),delete);
            }
        }
        // 最终从执行中的map里移除当前任务
        finally {
            if(zipOut != null){
                FileUtils.safeClose(zipOut);
            }
        }
    }

    private void dealExport(long exportCount,ZipOutputStream zipOut) throws Exception{
        // 遍历导出的次数 每次生成一个excel文件
        for (long i = 1; i <= exportCount; i++) {
            // 新建zip中的其中一个文件
            String eachName = String.format("%02d", i) + ExcelTypeEnum.XLSX.getValue();
            zipOut.putNextEntry(new ZipEntry(new String(eachName.getBytes(StandardCharsets.UTF_8))));

            // 新建好了后往zip写入数据
            List<List<String>> singleData = getData(i, taskInfo.getSingleExportNum());
            EasyExcel
                    .write(zipOut)
                    .autoCloseStream(Boolean.FALSE)
                    .excelType(ExcelTypeEnum.XLSX)
                    .charset(StandardCharsets.UTF_8)
                    .sheet()
                    .head(getHeader())
                    .doWrite(singleData);

            // 关闭entry 代表zip当前的其中一个文件已经写入结束
            zipOut.closeEntry();

            // 更新进度
            updateTaskInfo(calculateProgress(i,exportCount),ExportStatusEnums.RUNNING.getStatus(),null);
            singleData.clear();
        }
    }

    private void updateTaskInfo(String progress,Integer status,String completeTime){
        Optional.ofNullable(progress).ifPresent(taskInfo::setProgress);
        Optional.ofNullable(status).ifPresent(taskInfo::setStatus);
        Optional.ofNullable(completeTime).ifPresent(taskInfo::setCompleteTime);
        EXPORT_TASK_REPOSITORY.updateById(taskInfo);
    }

    private Long calculateExportCount(Long total,Long singleExportNum){
        long exportCount = total / singleExportNum;
        long mod = total % singleExportNum;
        if(mod != 0){
            exportCount += 1;
        }

        return exportCount;
    }

    private String calculateProgress(Long thisCount,Long exportCount){
        BigDecimal progress = BigDecimal.valueOf(thisCount)
                .divide(BigDecimal.valueOf(exportCount), 2, RoundingMode.HALF_UP)
                .multiply(new BigDecimal(GlobalConstants.PROGRESS_OVER))
                .setScale(BigDecimal.ZERO.intValue(),RoundingMode.HALF_UP);

        return progress + GlobalConstants.PER_CENT;
    }

    @Override
    public Boolean call(){
        try{
            asyncExport();
            return Boolean.TRUE;
        }
        catch (Exception e){
            log.error("export task:{} error",taskInfo.getId(),e);
            return Boolean.FALSE;
        }
        finally {
            if(taskInfo != null && runMap != null){
                runMap.remove(taskInfo.getId());
            }
        }
    }

    /**
     * 需要实现的查询total的方法
     */
    public abstract Long getTotal() throws Exception;

    /**
     * 需要实现的组装表头的方法
     */
    public abstract List<List<String>> getHeader();

    /**
     * 需要实现的组装表体数据的方法
     */
    public abstract List<List<String>> getData(Long pageNo,Long pageSize) throws Exception;
}

 噢,原来是一个抽象类,里面把需要用到的一些逻辑,例如生成文件,导入等都已经实现了,那么所有用到该架子导出的人  只需要继承当前类,实现里面的getTotal getHeader getData三个方法就可以啦,比如我们工厂类里面写的这段代码,demo_export由DemoExportHandler进行处理,现在最后来看看这个DemoExportHandler怎么实现的把

static {
        HANDLER_MAP.put(ExportTypeEnums.DEMO_EXPORT.getExportKey(), DemoExportHandler::new);
    }

DemoExportHandler代码:

import java.util.*;
import java.util.concurrent.Future;
import java.util.stream.Collectors;

@Slf4j
public class DemoExportHandler extends ExportHandler {

    private static final StandardPointRepository STANDARD_POINT_REPOSITORY = SpringUtil.getBean(StandardPointRepository.class);
    private static final JsonService JSON_SERVICE = SpringUtil.getBean(JsonService.class);
    private static final I18nUtilService I_18_N_UTIL_SERVICE = SpringUtil.getBean(I18nUtilService.class);

    public DemoExportHandler(ExportTaskPO taskInfo, Map<String, Future<?>> runMap) {
        super(taskInfo, runMap);
    }

    @Override
    @SuppressWarnings("unchecked")
    public Long getTotal() throws Exception{

        // 导出参数 反序列化为自己想要的类型的对象
        Map<String,Object> paramMap = getParamObj(taskInfo.getParams());

        // 取出自己想要的数据,根据条件进行导出
        List<String> pointList = (List<String>)paramMap.get("pointList");

        // 此处demo使用 total请根据自己的逻辑查询
        List<PointInfoBean> pointBeans = STANDARD_POINT_REPOSITORY.selectPointByIdList(pointList);
        return (long) pointBeans.size();
    }

    @Override
    public List<List<String>> getHeader() {
        // 组装表头
        return Arrays.asList(Collections.singletonList("测点id"),
                Collections.singletonList("测点名称"),Collections.singletonList("测点单位"));
    }

    @Override
    @SuppressWarnings("unchecked")
    public List<List<String>> getData(Long pageNo, Long pageSize) throws Exception {
        // 模拟执行导出逻辑执行20S
        Thread.sleep(20000L);

        // 导出参数 反序列化为自己想要的类型的对象
        Map<String,Object> paramMap = getParamObj(taskInfo.getParams());

        // 取出自己想要的数据,根据条件进行导出
        String lang = (String)paramMap.get("language-option");
        List<String> pointList = (List<String>)paramMap.get("pointList");

        // 查询数据
        List<PointInfoBean> pointBeans = STANDARD_POINT_REPOSITORY.selectPointByIdList(pointList);
        List<List<String>> allData = pointBeans
                .stream()
                .map(bean -> Arrays.asList(bean.getId(), I_18_N_UTIL_SERVICE
                        .getMapFieldByLanguageOption(bean.getNameI18n(), lang), bean.getUnit()))
                .collect(Collectors.toList());

        // 分页或者不分页返回
        if(pageNo == null || pageSize == null){
            return allData;
        }

        return PageUtils.getPageList(allData,pageNo.intValue(),pageSize.intValue());
    }

    @SuppressWarnings("unchecked")
    private Map<String,Object> getParamObj(String params) throws UedmException {
        Map<String,Object> paramMap = JSON_SERVICE.jsonToObject(params,Map.class);
        log.debug("getParamObj params={},obj={}",params,paramMap);

        return paramMap;
    }
}

 

  • 2
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值