MinIO 批量下载功能说明
1. 功能描述
前端勾选多个对象文件后,一次性将这些对象从 MinIO 拉取并打包成 ZIP,通过浏览器直接下载。整体特性:
- 支持跨桶批量下载(不同
bucket的对象可同时下载)。 - 服务端采用流式压缩边读边写,内存占用低,适合大文件与多文件场景。
- 前端使用 XMLHttpRequest 的
onprogress实时展示下载进度:- 若服务器返回响应体长度或提供总大小提示,显示确定进度百分比。
- 否则显示不确定进度(动效)。
2. 时序图(时间序列图)
用户浏览器 前端页面 FileController MinioUtil MinIO服务器 ZIP流 1. 文件列表加载阶段 打开批量下载页面 1 GET /file/listAllFile 2 listAllFile() 3 获取所有存储桶 4 返回桶列表 5 获取桶内文件 6 返回文件列表 7 为文件设置bucket信息 8 loop 遍历每个桶 返回完整文件列表 9 返回JSON响应 10 渲染文件列表 11 2. 批量下载阶段 选择文件并点击下载 12 验证选择,创建FormData 13 POST /file/batchDownload 14 batchDownload(objectNames, buckets, response) 15 3. 服务端处理阶段 统计所有文件总大小 16 设置响应头 X-Total-Bytes 17 创建ZIP输出流 18 下载文件流 19 返回文件数据 20 写入ZIP条目 21 flush()刷新数据 22 触发progress事件 23 更新进度条 24 loop 处理每个文件 关闭ZIP流 25 下载完成 26 响应完成 27 4. 前端下载阶段 创建Blob对象 28 生成下载链接 29 自动触发下载 30 清理资源 31 用户浏览器 前端页面 FileController MinioUtil MinIO服务器 ZIP流
3. 关键代码与说明
3.1 后端控制器接口
java
// 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 服务层:流式打包与进度提示
java
// 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 前端下载与进度条
html
<!-- 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_RECEIVED
const 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 推送"已写原始字节数",前端以此计算更准确进度。