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

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

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

❌ 传统下载问题

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

  • 浏览器无法承载过大的文件 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 指定下载的字节范围,格式 <unit>=<start>-<end>;仅支持 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('下载完成');
            }
        }

⚠️ 五、调用样例

 

posted @ 2025-04-21 15:14  ~落辰~  阅读(565)  评论(0)    收藏  举报