大文件分片下载功能设计-简单好用

大文件分片下载功能设计-简单好用

在处理大文件下载时,传统方式容易出现下载失败、内存溢出或无法续传等问题。本文通过“按字节范围”动态分片的方式,详细讲解如何用最简单的前后端方案实现一个稳定、高效、可断点续传的大文件下载功能,前端无依赖、后端零改动成本,真正做到“简单好用、拿来即用”。

🎯 一、为什么要使用分片下载?

❌ 传统下载问题

传统的单次下载存在如下问题:

浏览器无法承载过大的文件 Blob(通常几百 MB 就容易内存崩溃)

网络波动或中断会导致重新下载整个文件

用户体验差,文件未下载完之前无法操作

✅ 分片下载优势

分片下载(Byte Range Requests)能够:

⬇️ 边下载边使用(适合媒体类文件)

🧩 失败重试某一片(无需整体重新下载)

🛠️ 支持断点续传

🧠 更易并发优化

🧠 二、设计思路:如何划分文件分片?

关键点是:

📦 “按照字节范围分片”,而不是把文件拆成多个独立小文件。

比如:一个 100MB 的文件,我们可以每次请求 1MB 字节:

Range: bytes=0-1048575 // 第1片

Range: bytes=1048576-2097151 // 第2片

……

每片通过 HTTP 的 Range 头请求后端返回指定范围的字节流

前端将每片 Blob 拼接成最终的 Blob 并触发下载

这就像“按需索取”,无需服务器提前分片存储,非常灵活高效。

请求头

Header示例描述

Range

bytes=0-1048575

指定下载的字节范围,格式 =-;仅支持 bytes 单位

Accept

*/*

可选,客户端可接受的媒体类型列表

响应

200 OK

描述:请求未包含 Range,返回完整文件内容。

响应头:

NameValue

Content-Type

application/octet-stream

Content-Disposition

`attachment; filename="{encoded filename}"

Content-Length

{fileSize}(完整文件大小,字节)

响应体:完整文件的二进制流

206 Partial Content

描述:请求包含合法 Range,返回指定字节区间。

响应头:

NameValue

Content-Type

application/octet-stream

Content-Disposition

`attachment; filename="{encoded filename}"

Accept-Ranges

bytes

Content-Length

{end - start + 1}(本次返回字节数)

Content-Range

bytes {start}-{end}/{fileSize}

响应体:指定区间的字节流

404 Not Found

描述:文件不存在

状态码:404

416 Range Not Satisfiable

描述:请求的 Range 超出文件总长度

状态码:416

响应头:

Content-Range: bytes */{fileSize} (指示有效总长度)

响应体:可选空或错误描述

🧱 三、后端实现(Java 示例)

核心目标:支持 Range 请求 + 正确响应头设置 + 支持跨域

控制层:FileController

/**

* 文件控制器

*

* @author demo

*/

@RequiredArgsConstructor

@RestController

@RequestMapping ("file")

@CrossOrigin

public class FileController {

@RequestMapping(value = "packageFile", method = RequestMethod.GET)

@ApiOperation(value = "打包文件", notes = "打包文件")

@ApiImplicitParams({

@ApiImplicitParam(name = "filePath", value = "文件路径", required = true, dataType = "String")

})

public void packageMaterialFile(@Valid @NotEmpty(message = "文件路径filePath不允许为空") String filePath,

HttpServletResponse response, HttpServletRequest request) throws IOException {

FileSliceUtil.fileSlice(response, request,filePath);

}

}

分片下载工具类:FileSliceUtil

/**

* 文件分片工具类

*

* @author demo

* @date 2025/4/21 14:24

*/

@Slf4j

public class FileSliceUtil {

/**

* 文件分片

*

* @param filePath 文件路径

*/

public static void fileSlice(HttpServletResponse response, HttpServletRequest request, String filePath) throws IOException {

File file = new File(filePath);

if (!file.exists()) {

response.setStatus(HttpServletResponse.SC_NOT_FOUND);

return;

}

// 生成MD5摘要

String md5Digest = DigestUtils.md5DigestAsHex(Files.readAllBytes(file.toPath()));

String filename = file.getName();

long fileLength = file.length();

String rangeHeader = request.getHeader("Range");

long start = 0, end = fileLength - 1;

// 处理Range请求头

if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {

String[] ranges = rangeHeader.substring("bytes=".length()).split("-");

try {

start = Long.parseLong(ranges[0]);

if (ranges.length > 1) {

end = Long.parseLong(ranges[1]);

}

} catch (NumberFormatException e) {

response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);

return;

}

response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);

} else {

response.setStatus(HttpServletResponse.SC_OK);

}

long contentLength = end - start + 1;

// 跨域暴露响应头

response.setHeader("Access-Control-Allow-Origin", "*");

response.setHeader("Access-Control-Expose-Headers", "Content-Length, Content-Range, ETag");

response.setHeader("Content-Type", "application/octet-stream");

response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(filename, "UTF-8"));

response.setHeader("Accept-Ranges", "bytes");

response.setHeader("Content-Length", String.valueOf(contentLength));

response.setHeader("ETag", md5Digest);

response.setHeader("Content-Range", String.format("bytes %d-%d/%d", start, end, fileLength));

// RandomAccessFile 对文件进行随机读取

try (RandomAccessFile raf = new RandomAccessFile(file, "r");

OutputStream os = response.getOutputStream()) {

raf.seek(start);

byte[] buffer = new byte[8192];

long bytesToRead = contentLength;

while (bytesToRead > 0) {

int len = raf.read(buffer, 0, (int) Math.min(buffer.length, bytesToRead));

if (len == -1) break;

os.write(buffer, 0, len);

bytesToRead -= len;

}

os.flush();

}

}

}

💻 四、前端实现(jQuery/AJAX 分片请求)

function downloadFileInChunks(url, fileName, chunkSize = 1024 * 1024 * 100) {

let downloadedSize = 0;

let totalSize = 0;

const chunks = [];

// 第一步:获取总大小

$.ajax({

url: url,

type: 'GET',

headers: {

'Range': 'bytes=0-' + chunkSize

},

xhrFields: {

responseType: 'blob'

},

success: function (data, status, xhr) {

const contentRange = xhr.getResponseHeader('Content-Range');

if (!contentRange) {

alert("服务端未返回 Content-Range,无法支持分片下载");

return;

}

totalSize = parseInt(contentRange.split('/')[1], 10);

console.log('总文件大小:' + totalSize);

downloadNextChunk();

}

});

// 第二步:循环下载每个分片

function downloadNextChunk() {

if (downloadedSize >= totalSize) {

mergeChunks();

return;

}

const start = downloadedSize;

const end = Math.min(start + chunkSize - 1, totalSize - 1);

$.ajax({

url: url,

type: 'GET',

headers: {

'Range': `bytes=${start}-${end}`

},

xhrFields: {

responseType: 'blob'

},

success: function (data, status, xhr) {

chunks.push(data);

downloadedSize += data.size;

console.log(`已下载 ${downloadedSize}/${totalSize}`);

downloadNextChunk(); // 下载下一个分片

},

error: function () {

alert('分片下载失败,请检查网络或服务端 Range 支持');

}

});

}

// 第三步:合并 Blob 并下载

function mergeChunks() {

const blob = new Blob(chunks);

const downloadUrl = URL.createObjectURL(blob);

const a = document.createElement('a');

a.href = downloadUrl;

a.download = fileName;

document.body.appendChild(a);

a.click();

document.body.removeChild(a);

URL.revokeObjectURL(downloadUrl);

console.log('下载完成');

}

}

⚠️ 五、调用样例


相关推荐

软重启(reboot)
星露谷物语怎么生孩子 星露谷物语怀孕多久生孩子
黄鹤楼(1916细支)香烟价格表
巴萨球员俄罗斯之旅:西班牙挺进16强,苏神任意球贴地斩,今夜阿根廷生死战
背后的故事曝光 揭开《英雄使命》神秘面纱
佛家篇章:普贤菩萨的故事