前端vue3+后端spring boot导出数据

发布于:2025-08-18 ⋅ 阅读:(18) ⋅ 点赞:(0)

有个项目需要提供数据导出功能。

该项目前端用vue3编写,后端是spring boot 2,数据库是mysql8。

工作流程是:

1)前端请求数据导出
2)后端接到请求后,开启一个数据导出线程,然后立刻返回信息到前端
3)前端定期轮询,看导出是否已完成
4)后端的数据导出线程,将数据导出,生成文件,存放在后端
5)前端获知导出完成,请求下载文件
6)后端读取文件内容,以流的方式传输给前端,供前端下载

这里面可以看出,数据导出采用了异步方式。为什么采用异步方式,主要是数据量比较大,差不多200万条。同步方式的话,前端必超时。而且200万条记录,后端导出,生成文件,也不能一次性将200万条记录取出,然后生成文件,而是采用分页的方式,比如每次拿一万条,循环提取,直至取完。另外,数据导出也不应该占用主线程,避免其他业务受影响。

下面是详细介绍。

一、前端

前端一共请求3个接口。一个请求导出,一个导出状态查询,一个下载。首先向后端请求导出,由于是异步的,请求发出后,立即返回;此后定期查询导出状态;发现导出状态已完成后,即向后端请求下载。

1、请求数据导出

点击按钮“开始导出”

<el-button v-if="exportState.ready" type="primary" plain class="float-right"
    @click="startExport">开始导出</el-button>
import { start as startApi, checkStatus as checkStatusApi, exportCsv } from "@/modules/api/sensor/export.js";

async function startExport() {
    const valid = await form1.value.validate(); // 等待表单验证通过
    if (valid) {
        startApi(formState).then((res) => {
            waiting();
            const taskId = res.data;
            checkExportStatus(taskId);//查询导出状态
        });
    }
}

2、查询导出状态

import { saveAs } from 'file-saver'; // 或者自己写 blob 下载逻辑

function checkExportStatus(taskId) {
	//使用定时器
    const timer1 = setInterval(async () => {
        try {
            const res = await checkStatusApi(taskId);
            const { status, filename } = res.data;
            if (status === 'DONE') {
                clearInterval(timer1);
                const response = await exportCsv(filename);//向后端发出下载请求
                const blob = new Blob([response], { type: 'text/csv;charset=utf-8' });
                saveAs(blob, getFileName());//保存文件,一个第三方组件
                done();
            } else if (status === 'ERROR') {
                clearInterval(timer1);
                over();
                ElMessage.error('导出失败: ' + filename);
            }
        } catch (err) {
            clearInterval(timer1);
            over();
            ElMessage.error('导出失败: ' + err.message || '网络异常');
        }
    }, 1000);
}

3、下载

上面代码中的exportCsv。

4、向后端请求的API

import { request, requestBlob } from "@/request";

const prefix = "/export";

export const exportCsv = (filename) => {
    return requestBlob({
        url: prefix + "/download/" + filename,
        method: "get",
    });
};
export const start = (params) => {
    console.log(params);
    return request({
        url: prefix + "/start",
        params,
        method: "post",
    });
};
export const checkStatus = (taskId) => {
    return request({
        url: prefix + "/status/" + taskId,
        method: "get",
    });
};

二、后端

后端需要做比较多的工作。为了支持可能数量巨大的数据的下载请求,不致影响主线程性能,同时也避免客户端因为等待超时而断连,需要开辟新线程、异步方式来处理数据导出,因此需要引入线程池和任务管理。

后端的处理导出的流程是,接收到前端的请求后,从数据库中获取数据,如果数据量特别大,还要分页,采用循环多次查找;然后将数据输出到csv格式的文件中,文件保存在服务器。当前端侦察到导出完成,即请求下载,后端就将文件内容读出,以二进制流的形式返回给前端。前端侦察导出状态时,后端会将文件名返回给前端。为什么后端要先生成文件,貌似多此一举呢?原因是整个导出过程是异步的,后端没有办法一步到位将流返回给前端。

1、线程池

首先要注册一个线程池。

@Configuration
@EnableAsync
public class AsyncConfig {
    @Bean
    public TaskExecutor executor(){
        ThreadPoolTaskExecutor executor=new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10); //核心线程数
        executor.setMaxPoolSize(20);  //最大线程数
        executor.setQueueCapacity(1000); //队列大小
        executor.setKeepAliveSeconds(300); //线程最大空闲时间
        executor.setThreadNamePrefix("fsx-Executor-"); //指定用于新创建的线程名称的前缀。
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize(); // ✅ 加上这一行
        return executor;
    }
}

2、任务管理

@Component
public class TaskManager {
    // 任务状态:PENDING, DONE, ERROR
    private final Map<String, String> taskStatusMap = new ConcurrentHashMap<>();
    private final Map<String, String> taskResultMap = new ConcurrentHashMap<>();

    public void markTaskDone(String taskId, String fileName) {
        taskStatusMap.put(taskId, "DONE");
        taskResultMap.put(taskId, fileName);
    }

    public void markTaskFailed(String taskId, String errorMsg) {
        taskStatusMap.put(taskId, "ERROR");
        taskResultMap.put(taskId, errorMsg);
    }

    public String getStatus(String taskId) {
        return taskStatusMap.getOrDefault(taskId, "PENDING");
    }

    public String getResult(String taskId) {
        return taskResultMap.get(taskId);
    }

    public void clearTask(String taskId) {
        taskStatusMap.remove(taskId);
        taskResultMap.remove(taskId);
    }
}

3、控制器

@RestController
@RequestMapping("/export")
public class ExportController {
    @Autowired
    SensorDataService sensorDataService;
    @Autowired
    private TaskManager taskManager;//任务管理

    @Value("${export.path}")
    private String exportPath;

	//请求导出
    @PostMapping("/start")
    @ResponseBody
    public Result startExport(ExportParam paramObj) {
        String taskId = UUID.randomUUID().toString();
        sensorDataService.asyncExportData(taskId, paramObj); // 异步执行
        return Result.ok().put("data",taskId);
    }

	//查询导出状态
    @GetMapping("/status/{taskId}")
    @ResponseBody
    public Result checkStatus(@PathVariable String taskId) {
        String status = taskManager.getStatus(taskId);
        String filename = taskManager.getResult(taskId);

        Map<String, String> data = new HashMap<>();
        data.put("taskId", taskId);
        data.put("status", status);
        data.put("filename", filename);//文件名(不含路径)

        return Result.ok().put("data",data);
    }

	//下载导出文件
    @GetMapping(value = "/download/{fileName:.+}")
    public void exportFile(@PathVariable String fileName,HttpServletResponse response) {
        try {
            // 2. 构建文件路径(确保与写入时一致)
            String filePath = exportPath + fileName;

            // 3. 设置响应头
            response.setContentType("text/csv");
            response.setCharacterEncoding("utf-8");
            response.setHeader("Content-Disposition", "attachment; filename=" + URLEncoder.encode(fileName, "UTF-8"));

            // 4. 读取文件内容并写入响应输出流
            try (InputStream inputStream = new FileInputStream(filePath)) {
                byte[] buffer = new byte[4096];
                int bytesRead;
                while ((bytesRead = inputStream.read(buffer)) != -1) {
                    response.getOutputStream().write(buffer, 0, bytesRead);
                }
                response.getOutputStream().flush();
            }
        } catch (Exception e) {
            try {
                response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "文件下载失败:" + e.getMessage());
            } catch (IOException ex) {
                ex.printStackTrace();
            }
            e.printStackTrace();
        }
    }
}

4、service

@Service
public class SensorDataServiceImpl implements SensorDataService {
    @Value("${export.path}")
    private String exportPath;
    @Value("${export.page-size:10000}")
    private Integer exportPageSize;//每页多少条记录

	@Override
    @Async("executor")  // 指定使用定义的线程池
    public void asyncExportData(String taskId, ExportParam param) {
        System.out.println("当前线程: " + Thread.currentThread().getName());
        try {
            // 执行导出逻辑
            exportDataToFile(taskId, param);
        } catch (Exception e) {
            System.err.println("导出数据时发生异常:");
            e.printStackTrace();
        }
    }

    // 数据导出主方法
    private void exportDataToFile(String taskId, ExportParam param) throws Exception {
        // 1. 定义文件路径(请确保该目录存在且有写权限)
        String exportDir = exportPath;
        String fileName = getDownloadDataFileName(param);
        String filePath = exportDir + fileName;

        // 2. 创建 CSV 文件并写入表头
        try (CSVWriter writer = new CSVWriter(new FileWriter(filePath))) {
            // 获取表头(根据 param 可以动态生成)
            String[] headers = getHeaders(param);
            writer.writeNext(headers);

            // 3. 分页查询数据
            int pageNumber = 0;
            int pageSize = exportPageSize; // 每页查询 5000 条
            boolean hasMore = true;

            while (hasMore) {
                String sql = getSqlWithPagination(param, pageSize, pageNumber);
                List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql);

                if (rows.isEmpty()) {
                    hasMore = false;
                } else {
                    for (Map<String, Object> row : rows) {
                        String[] rowData = formatRow(row, headers);
                        writer.writeNext(rowData);
                    }
                    writer.flush(); // 及时刷新,避免内存积压
                    pageNumber++;
                }
            }

            // 4. 导出完成后记录任务状态和文件路径
            taskManager.markTaskDone(taskId, fileName);
        } catch (Exception e) {
            // 记录错误信息
            taskManager.markTaskFailed(taskId, e.getMessage());
            throw e;
        }
    }
    // 构建带分页的 SQL
    private String getSqlWithPagination(ExportParam paramObj, int pageSize, int pageNumber) {
        String baseSql = getSql(paramObj);
        return (baseSql.length() > 0) ? baseSql + " LIMIT " + pageSize + " OFFSET " + (pageNumber * pageSize) : "";
    }
}

三、效果

1、组件全貌

在这里插入图片描述

2、点击开始导出

在这里插入图片描述

3、导出成功

在这里插入图片描述

四、小结

有的表数据量特别巨大,一个月有记录几百万条。按分页查找,每页5万条记录处理,下载一个月数据需要2、3分钟。


网站公告

今日签到

点亮在社区的每一天
去签到