MinIO 批量下载功能说明
1. 功能描述
前端勾选多个对象文件后,一次性将这些对象从 MinIO 拉取并打包成 ZIP,通过浏览器直接下载。整体特性:
- 支持跨桶批量下载(不同
bucket
的对象可同时下载)。 - 服务端采用流式压缩边读边写,内存占用低,适合大文件与多文件场景。
- 前端使用 XMLHttpRequest 的
onprogress
实时展示下载进度:- 若服务器返回响应体长度或提供总大小提示,显示确定进度百分比。
- 否则显示不确定进度(动效)。
2. 时序图(时间序列图)
3. 关键代码与说明
3.1 后端控制器接口
// File: com/wangfugui/apprentice/controller/FileController.java
@ApiOperation("批量下载文件")
@PostMapping("/batchDownload")
public void batchDownload(@RequestParam String[] objectNames,@RequestParam String[] buckets,HttpServletResponse response) throws Exception {if (objectNames.length != buckets.length) {throw new IllegalArgumentException("文件对象名和存储桶数量不匹配");}minioUtil.batchDownload(objectNames, buckets, response);
}
- 参数
objectNames[]
与buckets[]
按序对应,每个元素指向一个要下载的对象及其桶。 - 直接将响应流交由
MinioUtil.batchDownload
写入 ZIP 内容。
3.2 服务层:流式打包与进度提示
// File: com/wangfugui/apprentice/common/util/MinioUtil.java
public void batchDownload(String[] objectNames, String[] buckets, HttpServletResponse response) throws Exception {// 统计总字节:以各对象原始大小之和作为估算(ZIP压缩后大小可能不同)long totalBytes = 0L;for (int i = 0; i < objectNames.length; i++) {try {totalBytes += minioClient.statObject(StatObjectArgs.builder().bucket(buckets[i]).object(objectNames[i]).build()).size();} catch (Exception e) {log.warn("统计对象大小失败,跳过: {}/{} - {}", buckets[i], objectNames[i], e.getMessage());}}response.setContentType("application/zip");response.setCharacterEncoding("UTF-8");response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode("批量下载文件.zip", "UTF-8"));response.setHeader("X-Total-Bytes", String.valueOf(totalBytes)); // 前端估算进度的总大小提示response.setHeader("Cache-Control", "no-store");ServletOutputStream outputStream = response.getOutputStream();ZipOutputStream zipOut = new ZipOutputStream(outputStream);try {for (int i = 0; i < objectNames.length; i++) {String objectName = objectNames[i];String bucket = buckets[i];try (InputStream fileStream = download(bucket, objectName)) {String fileName = objectName.substring(objectName.lastIndexOf("/") + 1);zipOut.putNextEntry(new ZipEntry(fileName));byte[] buffer = new byte[8192];int bytesRead;while ((bytesRead = fileStream.read(buffer)) != -1) {zipOut.write(buffer, 0, bytesRead);zipOut.flush();response.flushBuffer(); // 关键:推动数据到浏览器,触发前端 onprogress}zipOut.closeEntry();log.info("成功添加文件到ZIP: {}/{}", bucket, objectName);} catch (Exception e) {log.error("添加文件到ZIP失败: {}/{}, 错误: {}", bucket, objectName, e.getMessage());// 忽略单文件错误,继续其他对象}}} finally {zipOut.close();outputStream.close();}
}
关键点说明:
- 使用
ZipOutputStream
边读边写,避免大文件占用大量内存。 - 通过
response.setHeader("X-Total-Bytes", ...)
提示前端总大小,提升无法Content-Length
时的进度可用性。 - 每次写入后
zipOut.flush()
与response.flushBuffer()
,推动数据尽快到达浏览器,保证onprogress
事件高频触发。
3.3 前端下载与进度条
<!-- File: src/main/resources/static/batch-download.html (片段) -->
<div class="section download-section" id="downloadSection" style="display: none;"><h3>下载进度</h3><div class="progress-bar" id="downloadProgressBar"><div class="progress-fill" id="downloadProgressFill"></div></div><div class="progress-text" id="downloadProgressText">0%</div><!-- 确定/不确定进度两种模式:- 有总长度或总大小提示时显示百分比- 否则显示不确定动画 --><style>.progress-bar.indeterminate .progress-fill { width: 30%; position: absolute; left: -30%; animation: indeterminate-move 1.2s linear infinite; }@keyframes indeterminate-move { 0% { left: -30%; } 100% { left: 100%; } }</style>
</div><script>
// 发送下载请求并实时刷新进度
const xhr = new XMLHttpRequest();
xhr.open('POST', '/file/batchDownload', true);
xhr.responseType = 'blob';let totalHint = 0; // 后端给的总大小提示
xhr.onreadystatechange = function () {if (xhr.readyState === 2) { // HEADERS_RECEIVEDconst headerVal = xhr.getResponseHeader('X-Total-Bytes');totalHint = headerVal ? parseInt(headerVal, 10) : 0;}
};xhr.onprogress = function (e) {if (e.lengthComputable) {setIndeterminate(false);setDownloadProgress(Math.floor((e.loaded / e.total) * 100));return;}if (totalHint > 0) {setIndeterminate(false);setDownloadProgress(Math.min(Math.floor((e.loaded / totalHint) * 100), 99));} else {setIndeterminate(true);}
};xhr.onload = function () {setIndeterminate(false);setDownloadProgress(100);if (xhr.status >= 200 && xhr.status < 300) {const url = window.URL.createObjectURL(xhr.response);const a = document.createElement('a');a.href = url;a.download = '批量下载文件.zip';document.body.appendChild(a);a.click();window.URL.revokeObjectURL(url);document.body.removeChild(a);}
};function setDownloadProgress(p) {const fill = document.getElementById('downloadProgressFill');const text = document.getElementById('downloadProgressText');fill.style.width = Math.max(0, Math.min(100, p)) + '%';text.textContent = Math.max(0, Math.min(100, p)) + '%';
}
function setIndeterminate(on) {const bar = document.getElementById('downloadProgressBar');const text = document.getElementById('downloadProgressText');if (on) { bar.classList.add('indeterminate'); text.textContent = '正在下载...'; }else { bar.classList.remove('indeterminate'); }
}
</script>
要点说明:
- 采用
XMLHttpRequest
(而非 fetch)以便使用onprogress
事件。 - 优先使用浏览器提供的
lengthComputable
,否则退化为基于X-Total-Bytes
的估算。 - 使用两种进度模式:确定百分比与不确定动效,提升不同后端/网络环境下的体验一致性。
4. 小结与建议
- 生产环境中建议:
- 若对象较多,可限制每次最大文件数或总字节阈值,避免超大 ZIP 影响响应时延或者内存不足报错。
- 若需更精确进度:服务端可在每写入 N 字节时通过 SSE/WebSocket 推送“已写原始字节数”,前端以此计算更准确进度。