前端vue3+后端spring boot导出数据
有个项目需要提供数据导出功能。
该项目前端用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 {@Beanpublic 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, ERRORprivate 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 {@AutowiredSensorDataService sensorDataService;@Autowiredprivate TaskManager taskManager;//任务管理@Value("${export.path}")private String exportPath;//请求导出@PostMapping("/start")@ResponseBodypublic Result startExport(ExportParam paramObj) {String taskId = UUID.randomUUID().toString();sensorDataService.asyncExportData(taskId, paramObj); // 异步执行return Result.ok().put("data",taskId);}//查询导出状态@GetMapping("/status/{taskId}")@ResponseBodypublic 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;}}// 构建带分页的 SQLprivate 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分钟。