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_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 推送“已写原始字节数”,前端以此计算更准确进度。