Springboot实现使用断点续传优化同步导入Excel

发布于:2025-03-22 ⋅ 阅读:(17) ⋅ 点赞:(0)

需求前言

在跨境电商系统中,其中下单方式之一就是通过Excel记录多个用户该下单哪些商品实现批量下单,就需要实现导入Excel的方案,最简单的就是同步导入Excel,但同步导入大Excel文件时,网络原因抑或误刷新了页面,就需要重新上传,这就造成用户体验感不好,于是引入断点续传来优化同步导入Excel。

断点续传

断点续传就是在文件上传或下载过程中,如果中途中断了,下次可以从中断的地方继续,而不需要重新开始。这对于大文件传输特别有用,节省时间和带宽。
前端部分需要支持分片上传。也就是说,把大文件切成多个小块,每个小块单独上传。这样即使中间断了,只需要传剩下的部分。前端怎么做呢?本文使用JavaScript的File API来读取文件,然后分片。比如用File.slice方法,把文件切成多个Blob。然后每个Blob单独上传,并告诉服务器这是第几个分片,总共有多少分片,文件的唯一标识是什么,比如用MD5哈希之类的。这样服务器就知道怎么合并这些分片了。

然后,后端操作需要接收这些分片,保存起来,并且记录哪些分片已经上传了。当所有分片都上传完后,后端要把这些分片按顺序合并成完整的文件。这里可能涉及到文件存储的问题,每个分片保存为临时文件,最后合并的时候可能需要按顺序读取所有分片然后写入到一个文件中。另外,为了支持断点续传,前端在上传前可能需要先询问服务器,这个文件已经传了哪些分片,然后只传剩下的。所以后端还需要提供一个接口,让前端可以查询某个文件的上传进度。
具体步骤:
1、前端选择文件后,先计算文件的唯一标识(比如MD5),并查询服务器该文件的上传情况,获取已上传的分片列表。
2、根据分片大小(比如1MB),将文件分片。
3、遍历所有分片,对于未上传的分片,逐个上传到服务器。
4、每个分片上传时,携带分片索引、总片数、文件唯一标识等信息。
5、后端接收到分片后,保存分片文件,并记录该分片已上传。
6、当所有分片上传完毕后,前端发送一个合并请求,或者后端检测到所有分片都已上传后自动合并分片为完整文件。
7、合并完成后,删除临时分片文件。

前端实现

<input type="file" id="fileInput" accept=".xlsx,.xls" />
<button onclick="startUpload()">上传Excel</button>
<div id="progress"></div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.0/spark-md5.min.js"></script>
<script>
const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB分片

async function startUpload() {
  const file = document.getElementById('fileInput').files[0];
  if (!file || !file.name.match(/\.(xlsx|xls)$/i)) {
    alert('请选择Excel文件');
    return;
  }

  // 1. 生成文件唯一标识
  const fileId = await computeFileHash(file);
  document.getElementById('progress').innerHTML = '正在验证文件...';

  // 2. 检查文件状态
  const { exists, uploadedChunks } = await fetch(`/api/upload/check?fileId=${fileId}`)
    .then(res => res.json());

  if (exists) {
    document.getElementById('progress').innerHTML = '文件已存在,直接解析';
    triggerParse(fileId); // 触发后端解析
    return;
  }

  // 3. 分片上传
  const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
  let uploaded = uploadedChunks.length;
  
  // 上传未完成的分片(带进度显示)
  for (let i = 0; i < totalChunks; i++) {
    if (!uploadedChunks.includes(i)) {
      await uploadChunk(file, i, fileId);
      uploaded++;
      document.getElementById('progress').innerHTML = 
        `上传进度:${Math.round((uploaded / totalChunks) * 100)}%`;
    }
  }

  // 4. 合并并解析
  const { success } = await fetch(`/api/upload/merge?fileId=${fileId}`)
    .then(res => res.json());
  
  if (success) {
    triggerParse(fileId);
  } else {
    alert('文件合并失败');
  }
}

// 触发后端解析
async function triggerParse(fileId) {
  const result = await fetch(`/api/excel/parse?fileId=${fileId}`)
    .then(res => res.json());
  
  document.getElementById('progress').innerHTML = 
    `解析完成,成功导入${result.successCount}条数据`;
}

// 其他函数保持原有逻辑...
</script>

上述代码,前端在上传时还可会展示进度条,分片是在前端决定的,每次上传前都需要查询后端关于该文件的分片上传情况,由前端判断该是否上传某分片,在前端分片后,还可以使用并发发送多个分片

后端实现

@RestController
@RequestMapping("/api")
public class ExcelUploadController {

    @Value("${file.upload.temp-dir}")
    private String tempDir;
    
    @Value("${file.upload.target-dir}")
    private String targetDir;

    // 检查文件状态
    @GetMapping("/upload/check")
    public Map<String, Object> checkFile(@RequestParam String fileId) {
        File targetFile = new File(targetDir, fileId + ".xlsx");
        Map<String, Object> result = new HashMap<>();
        result.put("exists", targetFile.exists());
        
        File chunkDir = new File(tempDir, fileId);
        if (chunkDir.exists()) {
            List<Integer> chunks = Arrays.stream(chunkDir.listFiles())
                    .map(f -> Integer.parseInt(f.getName().split("_")[1]))
                    .collect(Collectors.toList());
            result.put("uploadedChunks", chunks);
        } else {
            result.put("uploadedChunks", Collections.emptyList());
        }
        return result;
    }

    // 分片上传
    @PostMapping("/upload")
    public ResponseEntity<?> uploadChunk(
            @RequestParam("file") MultipartFile file,
            @RequestParam String fileId,
            @RequestParam int chunkIndex) throws IOException {
        
        File chunkDir = new File(tempDir, fileId);
        if (!chunkDir.exists()) chunkDir.mkdirs();
        
        File chunkFile = new File(chunkDir, "chunk_" + chunkIndex);
        file.transferTo(chunkFile);
        
        return ResponseEntity.ok().build();
    }

    // 合并文件
    @GetMapping("/upload/merge")
    public Map<String, Object> mergeFile(@RequestParam String fileId) throws IOException {
        File chunkDir = new File(tempDir, fileId);
        File targetFile = new File(targetDir, fileId + ".xlsx");
        
        try (FileOutputStream fos = new FileOutputStream(targetFile)) {
            // 按顺序合并分片
            IntStream.range(0, chunkDir.listFiles().length)
                    .sorted()
                    .forEach(i -> {
                        File chunk = new File(chunkDir, "chunk_" + i);
                        try (FileInputStream fis = new FileInputStream(chunk)) {
                            byte[] buffer = new byte[1024];
                            int len;
                            while ((len = fis.read(buffer)) != -1) {
                                fos.write(buffer, 0, len);
                            }
                        } catch (IOException e) {
                            throw new RuntimeException("合并失败", e);
                        }
                    });
        }
        
        // 清理临时目录
        FileUtils.deleteDirectory(chunkDir);
        
        return Map.of("success", true);
    }

    // Excel解析接口
    @GetMapping("/excel/parse")
    public Map<String, Object> parseExcel(@RequestParam String fileId) {
        File excelFile = new File(targetDir, fileId + ".xlsx");
        
        // 使用EasyExcel解析
        ExcelDataListener listener = new ExcelDataListener();
        try {
            EasyExcel.read(excelFile, ExcelData.class, listener).sheet().doRead();
        } catch (Exception e) {
            return Map.of(
                "success", false,
                "error", "解析失败: " + e.getMessage(),
                "successCount", listener.getSuccessCount()
            );
        }
        
        return Map.of(
            "success", true,
            "successCount", listener.getSuccessCount(),
            "errors", listener.getErrors()
        );
    }
}

// Excel数据模型
@Data
public class ExcelData {
    @ExcelProperty("姓名")
    private String name;
    
    @ExcelProperty("年龄")
    private Integer age;
    
    @ExcelProperty("邮箱")
    private String email;
}

// 数据监听器
public class ExcelDataListener extends AnalysisEventListener<ExcelData> {
    private final List<ExcelData> cachedData = new ArrayList<>();
    private final List<String> errors = new ArrayList<>();
    private int successCount = 0;
    
    @Override
    public void invoke(ExcelData data, AnalysisContext context) {
        // 数据校验
        if (data.getName() == null || data.getName().isEmpty()) {
            errors.add("第" + context.readRowHolder().getRowIndex() + "行: 姓名不能为空");
            return;
        }
        
        cachedData.add(data);
        if (cachedData.size() >= 100) {
            saveToDB();
            cachedData.clear();
        }
    }

    @Override
    public void doAfterAllAnalysed(AnalysisContext context) {
        if (!cachedData.isEmpty()) {
            saveToDB();
        }
    }
    
    private void saveToDB() {
        // 这里实现实际入库逻辑
        successCount += cachedData.size();
    }

    // Getter省略...
}

使用easyExcel来接收分片,即使前端是并发上传多个分片(当然也最好有个上传限制,比如限制前端控制上传5个分片),也能避免出现OOM和CPU飙升的情况。

Java解析、生成Excel比较有名的框架有apache poi、jxl。但他们都存在一个严重的问题就是非常耗内存,poi有一套SAX模式的API可以一定程度的解决一些内存溢出的问题,但poi还是有一些缺陷,比如07版本Excel解压缩以及解压后存储都是在内存中完成的,内存消耗依然很大。
而easyExcel重写了poi对07版本Excel的解析(一个3M的Excel用POI
SAX解析依然需要100M左右内存)改用easyExcel可以降低到几M,并且再打的Excel也不会出现内存溢出;03版本依赖POI的sax模式,在上层做了模型转换的封装,让使用者可以简单方便;

完结撒花,如有需要收藏的看官,顺便也用发财的小手点点赞哈,如有错漏,也欢迎各位在评论区评论!