分卷压缩下载是一种将大文件分割成多个较小部分进行下载的技术,特别适用于超过10GB的超大文件下载场景。
一、分卷压缩的基本概念
分卷压缩(Multi-part archive)是将大型压缩文件拆分成多个较小部分的技术,通常用于将大文件保存到多个磁盘或可移动介质时使用。主要目的包括:
- 绕过文件大小限制(如邮件附件、网盘上传限制等)
- 提高下载可靠性(支持断点续传)
- 方便管理(多个小文件比一个大文件更易于管理)
- 加快下载速度(可并发下载多个分卷)
当文件或文件夹过大时,压缩软件可能会将其分割成多个较小的部分,以便更容易地存储、传输或共享。在这种情况下,.zip 文件通常是压缩包的主文件,而 .z01(以及可能的 .z02、.z03 等后续文件)则是压缩包的附加部分。
以下是详细的说明:
-
主文件(.zip) :
- 这是压缩包的主要部分,包含了文件的元数据、压缩算法信息以及可能的部分文件数据。
- 当你尝试解压缩一个由多个部分组成的压缩包时,通常需要先找到并使用这个主文件。
-
附加部分(.z01, .z02, .z03 等) :
- 这些文件包含了压缩后的文件数据的其余部分。
- 文件的命名通常遵循一定的模式,如 .z01, .z02, .z03 等,以便用户能够轻松地按顺序排列它们。
- 在解压缩时,压缩软件会依次读取这些附加部分,并将它们与主文件中的数据合并,以恢复原始文件。
-
如何处理这些文件:
- 当你下载或接收到一个由多个部分组成的压缩包时,确保你拥有所有部分(包括主文件和所有附加部分)。
- 使用支持多部分压缩包的解压缩软件(如 WinRAR、7-Zip 等)。
- 通常,你只需要双击主文件(.zip)来开始解压缩过程。解压缩软件会自动找到并使用所有附加部分。
-
注意事项:
- 确保所有部分都完好无损。如果任何一个部分损坏或丢失,解压缩过程可能会失败。
- 在传输或存储这些文件时,尽量保持它们的顺序,以避免混淆。
总之,.zip 文件是压缩包的主文件,而 .z01 等文件是压缩包的附加部分。当你需要解压缩一个由多个部分组成的压缩包时,确保你拥有所有部分,并使用支持多部分压缩包的解压缩软件。
二、后端生成分卷压缩文件
Java实现示例(使用Apache Commons Compress):
import org.apache.commons.compress.archivers.zip.*;
import org.apache.commons.compress.utils.IOUtils;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public class SplitZipCreator {
public static void createSplitZip(String sourcePath, String outputPath, long splitSize) throws IOException {
Path source = Paths.get(sourcePath);
long totalSize = Files.size(source);
long currentSize = 0;
try (OutputStream outputStream = new FileOutputStream(outputPath + ".zip");
ZipArchiveOutputStream zipArchiveOutputStream = new ZipArchiveOutputStream(outputStream)) {
ZipArchiveEntry entry = new ZipArchiveEntry(source.getFileName().toString());
zipArchiveOutputStream.putArchiveEntry(entry);
try (InputStream inputStream = Files.newInputStream(source)) {
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
zipArchiveOutputStream.write(buffer, 0, bytesRead);
currentSize += bytesRead;
if (currentSize >= splitSize) {
zipArchiveOutputStream.closeArchiveEntry();
outputStream.close();
currentSize = 0;
int partNumber = (int) (totalSize / splitSize) + 1;
outputStream = new FileOutputStream(outputPath + ".z" + String.format("%02d", partNumber));
zipArchiveOutputStream = new ZipArchiveOutputStream(outputStream);
entry = new ZipArchiveEntry(source.getFileName().toString());
zipArchiveOutputStream.putArchiveEntry(entry);
}
}
}
zipArchiveOutputStream.closeArchiveEntry();
}
}
public static void main(String[] args) {
try {
createSplitZip("path/to/large/file", "path/to/output/file", 1024 * 1024 * 100); // Split into 100MB parts
} catch (IOException e) {
e.printStackTrace();
}
}
}
前端代码实现
1. downloadFile封装
# request.js
import axios from 'axios';
axios.defaults.timeout = 10000;
axios.defaults.withCredentials = true;
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
// 下载文件
export const downloadFile = async (
url,
data,
onDownloadProgress,
setProgress,
setProgressStatus,
filename
) => {
try {
setProgressStatus('default');
setProgress(0);
const response = await axios({
method: 'POST',
url: process.env.VUE_APP_BASE_URL + url,
responseType: 'blob',
data: data,
onDownloadProgress: (progressEvent) => {
if (progressEvent.lengthComputable) {
const progress = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
setProgress(progress);
} else {
console.log('Download progress: unknown');
}
if (onDownloadProgress) {
onDownloadProgress(progressEvent);
}
},
});
setProgressStatus('success');
const disposition = response.headers['content-disposition'] || '';
const resFileName = disposition
? decodeURI(
disposition
.split('=')[1]
.replace(/'/g, '')
.replace(/UTF-8/g, '')
.replace(/utf-8/g, '')
)
: '';
saveFile(response.data, resFileName);
} catch (error) {
setProgressStatus('exception');
} finally {
setProgress(100);
}
};
// 保存文件到本地
const saveFile = (blob, filename) => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
2. API
import { downloadFile } from '@/api/request.js';
// 会议下载
export const getMeetZip = (
data,
onDownloadProgress,
setProgress,
setProgressStatus,
filename
) =>
downloadFile(
'xxx/xxx/xxx/downloadZip',
data,
onDownloadProgress,
setProgress,
setProgressStatus,
filename
);
3. 分批处理任务batchProcessor.js
/**
* 分批处理任务
* @param {Array} items - 要处理的任务列表
* @param {Function} processorFn - 处理单个任务的函数
* @param {number} maxConcurrent - 最大并发数(默认6)浏览器限制6-8
* @returns {Promise<Array>} - 返回所有任务的结果
*/
export const processInBatches = async (
items,
processorFn,
maxConcurrent = 6
) => {
const results = [];
let currentIndex = 0;
while (currentIndex < items.length) {
// 取出当前批次(最多maxConcurrent个)
const batch = items.slice(currentIndex, currentIndex + maxConcurrent);
// 并行处理当前批次的所有任务
const batchPromises = batch.map(
(item, index) => processorFn(item, currentIndex + index) // 传递索引以便跟踪
);
// 等待当前批次的所有任务完成
const batchResults = await Promise.allSettled(batchPromises);
results.push(...batchResults);
// 移动到下一批次
currentIndex += maxConcurrent;
}
return results;
};
# sleep.js
export const sleep = (time) => new Promise((resolve) => setTimeout(() => resolve(), time));
4. vue 代码
<template>
<el-button type="text" @click="downloadMeetZip(scope.row)">下载</el-button>
<el-dialog
title="提示"
:visible.sync="visZip"
width="500px"
:destroy-on-close="true"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
top="calc(50vh - 200px)"
@close="handleDialogClose"
>
<section class="meet-tips" v-for="(item, index) in zipList" :key="item">
<section class="tips-box">
<p v-if="progressStatus[index] === 'default'">
<span v-if="progress[index] > 0">
<i class="el-icon-loading"></i>
正在下载
</span>
<span v-else>
<i class="el-icon-loading"></i>
等待中
</span>
</p>
<p v-if="progressStatus[index] === 'success'" class="icon">
<i class="el-icon-success"></i>
下载完成
</p>
<p v-if="progressStatus[index] === 'exception'" class="icon">
<i class="el-icon-error"></i>
下载出错
</p>
<p v-if="progressStatus[index] === 'warning'" class="icon">
<i class="el-icon-info" v-if="!!progressText[index]"></i>
{{ progressText[index] }}
</p>
<section v-if="progressStatus[index] !== 'warning'">
{{
item.match(/videoDownload\/(.+)/)
? item.match(/videoDownload\/(.+)/)[1]
: ''
}}
</section>
</section>
<section>
<el-progress
style="margin: 10px 0"
:percentage="progress[index] || 0"
:status="progressStatus[index] || 'default'"
:stroke-width="18"
:text-inside="true"
></el-progress>
</section>
</section>
<div slot="footer" class="dialog-footer">
<el-button type="text" v-if="this.zipList.length > 1">
{{ successCount }} / {{ progressStatus.length }}
</el-button>
<el-button
type="primary"
:disabled="progressStatus.some((item) => item === 'default')"
@click="confirmDownload"
>
确定
</el-button>
</div>
</el-dialog>
</template>
<script>
import { getMeetZip } from '@/api/index.js';
import { processInBatches } from '@/utils/batchProcessor';
import { sleep } from '@/utils/sleep';
# 核心代码
downloadMeetZip(meet) {
this.$confirm('确定下载超大文件么?', '超大文件下载', {
confirmButtonText: '确定',
cancelButtonText: '取消',
showClose: false,
closeOnClickModal: false,
closeOnPressEscape: false,
type: 'warning',
})
.then(async () => {
const data = {
meetId: meet.meetId,
meetCode: meet.meetCode,
};
const filename = `${meet.meetCode}_${this.formatTimestamp(
Date.now()
)}`;
await sleep(200);
this.visZip = true;
this.zipList = ['0'];
this.setProgressStatus(0, 'default');
this.setProgress(0, 0);
this.setProgressText(0, '');
// 查询状态接口 3 下载
try {
const meetStatus = await getMeetStatus(data);
if (meetStatus.success && meetStatus.data.statusCode === '3') {
const resFileList = await getSplitFilePaths(data);
this.zipList = resFileList.data || [];
this.progress = Array(this.zipList.length).fill(0);
this.progressStatus = Array(this.zipList.length).fill('default');
this.progressText = Array(this.zipList.length).fill(
'正在下载...'
);
try {
// 分批处理下载
const results = await processInBatches(
this.zipList,
(item, index) => {
return getMeetZip(
{ partFilePath: item },
(value) => this.handleProgress(index, value),
(value) => this.setProgress(index, value),
(status) => this.setProgressStatus(index, status),
`${filename}${index}`
);
},
3
);
if (this.successCount === this.zipList.length) {
saveDownloadLog(data)
.then((res) => {
if (res.success) {
console.log('res', res.message);
}
})
.catch((err) => {
console.log('err', err);
});
}
console.log('所有下载完成:', results);
} catch (error) {
console.error('批量下载失败:', error);
}
} else {
this.zipList = ['0'];
this.setProgressStatus(0, 'warning');
this.setProgressText(
0,
meetStatus?.data?.message || meetStatus?.message
);
}
} catch (error) {
this.zipList = ['0'];
this.setProgressStatus(0, 'warning');
this.setProgressText(0, error.message || '下载失败,请稍后重试');
}
})
.catch(() => {
this.$message({
type: 'info',
message: '操作已取消',
});
});
},
handleProgress(index, progressEvent) {
// 可以在这里处理额外的进度逻辑
// console.log('Custom progress handler:', progressEvent);
// console.log('progressEvent.loaded', progressEvent.loaded);
},
setProgress(index, value) {
this.$set(this.progress, index, value);
},
setProgressStatus(index, status) {
this.$set(this.progressStatus, index, status);
},
setProgressText(index, text) {
this.$set(this.progressText, index, text);
},
</script>
5. UI展示
- 后端分卷大小为800M左右,总大小11个G左右
- 前端分批最大并发数设置为3. 可以设置为其他值,但浏览器有限制6-8,启用http/2 可以突破限制,需要服务端设置。
6. 浏览器并发限制
- 同一域名下的限制:浏览器对同一域名的并发连接数量有限制,通常为6-8个(不同浏览器和HTTP版本可能有所不同)。当并发请求超过这个限制时,后续的请求会被置入队列等待,直到前面的请求完成并释放连接。
- 队头阻塞:当浏览器达到并发连接数限制时,后续的请求会排队等待前面的请求完成。这个过程被称为“队头阻塞”,它会导致页面加载速度变慢,影响用户体验。