数据分页异步后台导出excel

数据分页异步后台导出excel

异步导出设计

下面根据如上设计列出具体方案(没有文件服务器,直接从后台服务下载)

表设计

复制代码
DROP TABLE IF EXISTS `task`;

CREATE TABLE `task` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `type` varchar(2) NOT NULL DEFAULT '1' COMMENT '1:导出',
  `status` varchar(100) DEFAULT NULL COMMENT '状态',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
  `start_time` datetime DEFAULT NULL,
  `end_time` datetime DEFAULT NULL,
  `err_msg` text,
  `url` varchar(1000) DEFAULT NULL COMMENT '文件地址',
  `progress` decimal(3,2) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=107 DEFAULT CHARSET=utf8;

异步导出类

Java 复制代码
package com.async_export_demo.export;

import com.async_export_demo.config.MyExportConfig;
import com.async_export_demo.model.Task;
import com.async_export_demo.service.TaskService;
import com.async_export_demo.util.SpringContextUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.Getter;
import lombok.extern.java.Log;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.MathContext;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ThreadPoolExecutor;

@Component
@Log
public class AsyncExportService {

    private static final ThreadPoolTaskExecutor EXECUTOR = new ThreadPoolTaskExecutor();

    /**
     * 进度,保留两位小数
     */
    private static MathContext MATH_CONTEXT = new MathContext(2, RoundingMode.HALF_UP);

    static {
        //设置核心线程数
        EXECUTOR.setCorePoolSize(2);
        //设置最大线程数
        EXECUTOR.setMaxPoolSize(4);
        //设置线程被回收的空闲时长
        EXECUTOR.setKeepAliveSeconds(6);
        //设置队列容量
        EXECUTOR.setQueueCapacity(2);
        //设置线程前缀
        EXECUTOR.setThreadNamePrefix("export-");
        //设置拒绝策略
        EXECUTOR.setRejectedExecutionHandler(new AbortPolicy());
        //初始化线程池
        EXECUTOR.initialize();
    }

    @Autowired
    private TaskService taskService;

    @Autowired
    private Map<ExportEnum, DataProvider> dataProviderMap;

    @Autowired
    private MyExportConfig myExportConfig;

    // 导出配置类
    public static class ExportConfig {
        private int pageSize = 1000;  // 每页数据量
        private int maxRetry = 3;     // 失败重试次数
        private String fileType = "xlsx"; // 文件类型(csv/xlsx)
        private boolean compress = true; // 是否压缩
    }

    public static class ExportContext {
        private final Map<String, Object> params = new HashMap<>();

        public void put(String key, Object value) {
            params.put(key, value);
        }

        public Object get(String key) {
            return params.get(key);
        }
    }

    /**
     * 因为我想让线程池执行拒绝策略时可以拿到 taskId,以便可以更新状态,所以新定义了一个Runnable
     */
    public static class ExportRunnable implements Runnable {

        @Getter
        private final BigInteger taskId;

        private final Runnable runnable;

        public ExportRunnable(BigInteger taskId, Runnable runnable) {
            this.taskId = taskId;
            this.runnable = runnable;
        }

        @Override
        public void run() {
            runnable.run();
        }

    }

    /**
     * 线程池拒绝策略
     */
    public static class AbortPolicy implements RejectedExecutionHandler {
        /**
         * Creates an {@code AbortPolicy}.
         */
        public AbortPolicy() {
        }

        /**
         * Always throws RejectedExecutionException.
         *
         * @param r the runnable task requested to be executed
         * @param e the executor attempting to execute this task
         * @throws RejectedExecutionException always
         */
        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
            RejectedExecutionException ex = new RejectedExecutionException("Task " + r.toString() +
                    " rejected from " +
                    e.toString());
            if (r instanceof ExportRunnable) {
                ExportRunnable r2 = (ExportRunnable) r;
                Task entity = new Task();
                entity.setId(r2.getTaskId());
                entity.setStatus("FAILED");
                entity.setErrMsg(ex.getMessage());
                TaskService taskService1 = SpringContextUtil.getApplicationContext().getBean(TaskService.class);
                taskService1.updateExportTask(entity);
            }
            throw ex;
        }
    }

    public BigInteger asyncExport(ExportEnum exportEnum) {
        // 超时时长毫秒
        long timeout = myExportConfig.getTimeoutUnit().toMillis(myExportConfig.getTimeout());
        DataProcessor dataProcessor = new DefaultDataProcessor(myExportConfig, exportEnum.getClz());
        return asyncExport(null, dataProviderMap.get(exportEnum), dataProcessor, timeout);
    }

    public BigInteger asyncExport(ExportConfig config, DataProvider<?> dataProvider, DataProcessor dataProcessor, long timeout) {
        BigInteger taskId = taskService.addExportTask();
        EXECUTOR.execute(new ExportRunnable(taskId, () -> {
            try {
                updateTaskStart(taskId);
                executeExport(taskId, dataProvider, dataProcessor, timeout);
                updateTaskEnd(taskId, dataProcessor.getUrl());
            } catch (Exception e) {
                updateTaskFail(taskId, e.getMessage());
            }
        }));

        /*try {
            updateTaskStart(taskId);
            executeExport(taskId, dataProvider, dataProcessor);
            updateTaskEnd(taskId);
        } catch (Exception e) {
            updateTaskFail(taskId, e.getMessage());
        }*/

        return taskId;
    }

    private <T> void executeExport(BigInteger taskId, DataProvider<T> dataProvider, DataProcessor dataProcessor, long timeout) {
        long start = System.currentTimeMillis();
        long currentPage = 1;
        Page<T> page = null;
        // 新建上下文对象,如需传递参数可以放在里面
        ExportContext context = new ExportContext();
        do {
            // todo 有没其他方法判断是否超时?ScheduledExecutorService?
            // 超时
            if (System.currentTimeMillis() - start > timeout) {
                throw new RejectedExecutionException("Task " + taskId + " rejected,系统执行任务超时");
            }
            page = dataProvider.getPageData(currentPage++, 1000, context);
            if (!Objects.isNull(page) && Objects.nonNull(page.getRecords()) && !page.getRecords().isEmpty()) {
                dataProcessor.processData(page.getRecords());
            }
            // 更新导出进度
            updateTaskProgress(taskId, page);
        } while (page.hasNext());
        dataProcessor.end();
        log.info("executeExport cost:" + (System.currentTimeMillis() - start));
    }

    private void updateTaskFail(BigInteger taskId, String message) {
        Task entity = new Task();
        entity.setId(taskId);
        entity.setStatus("FAILED");
        entity.setErrMsg(message);
        taskService.updateExportTask(entity);
    }

    private void updateTaskStart(BigInteger taskId) {
        Task entity = new Task();
        entity.setId(taskId);
        entity.setStatus("导出中");
        entity.setStartTime(LocalDateTime.now());
        taskService.updateExportTask(entity);
    }

    private void updateTaskEnd(BigInteger taskId, String url) {
        Task entity = new Task();
        entity.setId(taskId);
        entity.setStatus("SUCCESS");
        entity.setErrMsg(null);
        entity.setEndTime(LocalDateTime.now());
        entity.setUrl(url);
        taskService.updateExportTask(entity);
    }

    // todo
    // 暂时将进度更新到数据库,可优化
    private void updateTaskProgress(BigInteger taskId, Page<?> page) {
        Task entity = new Task();
        entity.setId(taskId);
        if (page.getTotal() <= 0) {
            entity.setProgress(new BigDecimal(1));
        } else {
            entity.setProgress(new BigDecimal(page.getCurrent()).divide(new BigDecimal(page.getPages()), MATH_CONTEXT));
        }
        log.info("current:" + page.getCurrent() + " totalPage:" + page.getPages() + " progress " + entity.getProgress().toString());
        taskService.updateExportTask(entity);
    }

}

主要方法为:

Java 复制代码
public BigInteger asyncExport(ExportConfig config, DataProvider<?> dataProvider, DataProcessor dataProcessor, long timeout) {
}

DataProvider 数据提供接口

可根据分页分批提供数据

Java 复制代码
package com.async_export_demo.export;

import com.baomidou.mybatisplus.extension.plugins.pagination.Page;

/**
 * 数据提供器接口(不同业务实现)
 */
public interface DataProvider<T> {
    Page<T> getPageData(long currentPage, long pageSize, AsyncExportService.ExportContext context);

    ExportEnum getType();
}

DataProvider 实现类

Java 复制代码
package com.async_export_demo.export;

import com.async_export_demo.mapper.OrderMapper;
import com.async_export_demo.model.Order;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
public class OrderDataProvider implements DataProvider<Order> {

    @Autowired
    private OrderMapper orderMapper;

    @Override
    public Page<Order> getPageData(long currentPage, long pageSize, AsyncExportService.ExportContext context) {
        Page<Order> queryPage = new Page<>(currentPage, pageSize);
        if (currentPage > 1) {
            // 分页总数只需要查询一次
            queryPage.setSearchCount(false);
        }
        QueryWrapper<Order> queryWrapper = new QueryWrapper<>();
        // 只导出前100万
        queryWrapper.le("id", 4000000);
        Page<Order> result = orderMapper.selectPage(queryPage, queryWrapper);
        if (currentPage <= 1) {
            context.put("total", result.getTotal());
        } else {
            result.setTotal((Long) context.get("total"));
        }
        return result;
    }

    @Override
    public ExportEnum getType() {
        return ExportEnum.ORDER;
    }

}

DataProcessor 数据处理接口

Java 复制代码
package com.async_export_demo.export;

import java.util.List;

/**
 * 数据处理器接口(不同业务实现)
 */
public interface DataProcessor {
    void processData(List<?> data);
    void end();
    String getUrl();
}

DataProcessor数据处理实现类

Java 复制代码
package com.async_export_demo.export;

import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.async_export_demo.config.MyExportConfig;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedList;
import java.util.List;

public class DefaultDataProcessor implements DataProcessor {

    private static final DateTimeFormatter DTF = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS");

    /**
     * 一个sheet页最多允许的数据条数
     */
    private static final long SHEET_MAX_SIZE = 200000;

    private String absoluteFileName;

    private String fileName;

    private long processedDataCount = 0;

    private ExcelWriter excelWriter;

    private MyExportConfig exportConfig;

    private final LinkedList<WriteSheet> writeSheets = new LinkedList<>();

    public DefaultDataProcessor(MyExportConfig exportConfig, Class<?> head) {
        this.exportConfig = exportConfig;
        this.absoluteFileName = getFileName(exportConfig);
        this.excelWriter = EasyExcel.write(absoluteFileName, head).build();
    }

    @Override
    public void processData(List<?> data) {
        WriteSheet writeSheet = getWriteSheet(data.size());
        this.excelWriter.write(data, writeSheet);
        processedDataCount += data.size();
    }

    @Override
    public void end() {
        this.excelWriter.close();
    }

    @Override
    public String getUrl() {
        // todo
        // 实际可能需要将本地文件推送到文件服务器,最终从文件服务器下载,此处直接返回本地地址
        return exportConfig.getDomain() + "/" + fileName;
    }

    private String getFileName(MyExportConfig exportConfig) {
        String fileName = LocalDateTime.now().format(DTF) + ".xlsx";
        this.fileName = fileName;
        return exportConfig.getRootPath() + "\\" + fileName;
    }

    private WriteSheet getWriteSheet(long currentProcessCount) {
        if (processedDataCount == 0) {
            WriteSheet writeSheet = EasyExcel.writerSheet(0, "模板").build();
            writeSheets.add(writeSheet);
        } else {
            long count = processedDataCount + currentProcessCount - SHEET_MAX_SIZE * (writeSheets.size() - 1);
            if (count > SHEET_MAX_SIZE) {
                writeSheets.add(EasyExcel.writerSheet(writeSheets.size(), "模板" + writeSheets.size()).build());
            }
        }
        return writeSheets.getLast();
    }

}

Controller

Java 复制代码
package com.async_export_demo.controller;

import com.async_export_demo.export.AsyncExportService;
import com.async_export_demo.export.ExportEnum;
import com.async_export_demo.export.SyncExportService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.math.BigInteger;
import java.net.URLEncoder;

@RestController
@RequestMapping(value = "/export")
public class ExportController {

    @Autowired
    private AsyncExportService asyncExportService;

    @Autowired
    private SyncExportService syncExportService;

    @GetMapping(value = "/exportAsync")
    public String exportData(@RequestParam("type") ExportEnum exportEnum) {
        BigInteger taskId = asyncExportService.asyncExport(exportEnum);
        return taskId.toString();
    }

}

前端

html 复制代码
<!DOCTYPE html>
<!-- saved from url=(0018) -->
<html class="wide wow-animation desktop landscape rd-navbar-fullwidth-linked" lang="en">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
    <title></title>
    <style>
        table, th, td {
            border: 1px solid black;
            border-collapse: collapse; /* 可选,用于合并相邻单元格的边框 */
        }

        th, td {
            padding: 10px; /* 可选,设置单元格内边距 */
        }
    </style>
</head>
<body>
<!-- Page-->
<div id="root">
    <button onclick="exportData()">导出</button>
    <table>
        <thead>
        <tr>
            <th style="width: 100px;text-align: left">任务id</th>
            <th style="width: 100px;text-align: left">类型</th>
            <th style="width: 100px;text-align: left">状态</th>
            <th style="width: 200px;text-align: left">创建时间</th>
            <th style="width: 200px;text-align: left">开始时间</th>
            <th style="width: 200px;text-align: left">结束时间</th>
            <th style="width: 200px;text-align: left">错误信息</th>
            <th style="width: 200px;text-align: left">下载地址</th>
            <th>进度</th>
        </tr>
        </thead>
        <tbody>
        <tr th:each="task ,taskIndex: ${tasks}">
            <td th:text="${task.id}">任务id</td>
            <td th:text="${task.type}">类型</td>
            <td th:text="${task.status}">状态</td>
            <td th:text="${task.createTime}">创建时间</td>
            <td th:text="${task.startTime}">开始时间</td>
            <td th:text="${task.endTime}">结束时间</td>
            <td th:text="${task.errMsg}">错误信息</td>
            <td>
                <a th:href="${task.url}" th:text="${task.url}">下载地址</a>
            </td>
            <td th:text="${task.progress}">进度</td>
        </tr>
        </tbody>
    </table>
</div>
<script>
    function exportData() {
        var xhr = new XMLHttpRequest();
        xhr.open('GET', '/export/exportAsync?type=ORDER', true);
        xhr.onload = function () {
            if (this.status >= 200 && this.status < 300) {
                console.log(this.responseText); // 处理响应数据
            } else {
                console.error('Request failed:', this.statusText);
            }
        };
        xhr.onerror = function () {
            console.error('Request error');
        };
        xhr.send();
    }

    function refresh() {
        // 你的刷新逻辑
        console.log('刷新一次');
        location.reload();
        // 例如,你可以在这里进行页面局部刷新或者数据更新
        setTimeout(refresh, 4000); // 1000毫秒后再次调用refresh函数
    }

    // demo, 暴力刷新。。。。
    setTimeout(refresh, 4000); // 初始调用一次以开始循环
</script>

</body>
</html>

测试

代码地址:https://gitee.com/husong_zone/async_export_demo

相关推荐
杰克尼19 分钟前
Java基础-stream流的使用
java·windows·python
超级小忍21 分钟前
深入解析 Apache Tomcat 配置文件
java·tomcat·apache
终是蝶衣梦晓楼40 分钟前
HiC-Pro Manual
java·开发语言·算法
泉城老铁1 小时前
EasyPoi实现百万级数据导出的性能优化方案
java·后端·excel
贰拾wan1 小时前
抛出自定义异常
java
weisian1511 小时前
Prometheus-3--Prometheus是怎么抓取Java应用,Redis中间件,服务器环境的指标的?
java·redis·prometheus
界面开发小八哥1 小时前
「Java EE开发指南」如何用MyEclipse创建企业应用项目?(二)
java·ide·java-ee·开发工具·myeclipse
CF14年老兵1 小时前
📝 如何在 MySQL 中创建存储过程:从基础到实战
java·sql·trae
泉城老铁1 小时前
Spring Boot 整合 EasyPoi 实现复杂多级表头 Excel 导出的完整方案
java·后端·excel
Pocker_Spades_A1 小时前
从 0 到 1 开发图书管理系统:飞算 JavaAI 让技术落地更简单
java·开发语言·java开发·飞算javaai炫技赛