Spring Boot 实现文件分片上传和下载

文章目录

    • 一、原理分析
      • 1.1 文件分片
      • 1.2 断点续传和断点下载
      • 1.2 文件分片下载的 HTTP 参数
    • 二、文件上传功能实现
      • 2.1 客户端(前端)
      • 2.2 服务端
    • 三、文件下载功能实现
      • 3.1 客户端(前端)
      • 3.2 服务端
    • 四、功能测试
      • 4.1 文件上传功能测试
      • 4.2 文件下载功能实现
    • 参考资料

完整案例代码:java-demos/spring-boot-demos/spring-boot-file at main · idealzouhu/java-demos (github.com)

一、原理分析

断点上传和下载通常需要支持文件分片。

  • 断点上传:上传大文件时,支持从上一次中断的位置继续上传。
  • 断点下载:下载大文件时,支持从上一次中断的位置继续下载。

1.1 文件分片

文件分片的核心思想是将服务器上的大文件拆分成若干个小文件,等这些小份文件都下载好了之后,最后将小文件合并成一个完整的大文件。

以文件分片上传为例,客户端责任为:

  • 分片: 将文件切割成小片。

  • 记录上传进度: 记录哪些分片已上传、哪些还未上传。

  • 上传分片: 通过 HTTP 请求将每个分片上传到服务端。

服务端责任为:

  • 接收分片: 将每个分片临时存储。
  • 记录已接收分片: 记录分片的索引、大小等信息,以防止重复上传。
  • 文件合并: 在所有分片上传完成后,将它们合并成完整的文件。

1.2 断点续传和断点下载

断点续传依赖于客户端和服务端的进度记录

  • 客户端:在上传分片时记录当前上传到第几块,下一次可以从该分片继续上传。
  • 服务端:通过每个分片的编号记录已接收的分片,检查是否还需要接收未完成的分片。

1.2 文件分片下载的 HTTP 参数

文件分片下载主要依赖于 HTTP 请求头的 Range 参数来实现Range 参数用于HTTP请求中,允许客户端请求特定字节范围的内容,而不是整个资源。这通常用于下载大文件时,使得客户端可以实现分块下载、断点续传等功能。

例如,客户端只希望获取从200字节到400字节的内容。

GET /path/to/file.txt HTTP/1.1
Host: example.com
Range: bytes=200-400

服务器处理请求并返回一个206 Partial Content响应。

HTTP/1.1 206 Partial Content
Content-Range: bytes 200-400/1000
Content-Length: 201
Content-Type: text/plain

...(这里是文件的第200到400字节的内容)...

二、文件上传功能实现

在实现文件上传功能中, 客户端负责分片上传并记录上传进度服务端负责接收和合并分片。同时,本文使用分片编号来实现的,并没有使用 range 参数。

2.1 客户端(前端)

创建 fragmentUpload.html 文件,具体代码为:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>File Chunk Upload</title>
</head>
<body>
<h2>File Chunk Upload with Resume</h2>
<input type="file" id="fileInput">
<button onclick="uploadFile()">Upload</button>
<div id="progress"></div>

<script>
    const CHUNK_SIZE = 5 * 1024 * 1024;  // 1MB
    let uploadedChunks = 0;

    async function uploadFile() {
        const fileInput = document.getElementById('fileInput');
        const file = fileInput.files[0];

        if (!file) {
            alert('Please select a file!');
            return;
        }

        let totalChunks = Math.ceil(file.size / CHUNK_SIZE);
        while (uploadedChunks < totalChunks) {
            let start = uploadedChunks * CHUNK_SIZE;
            let end = Math.min(start + CHUNK_SIZE, file.size);
            let chunk = file.slice(start, end);

            let formData = new FormData();
            formData.append('chunk', chunk);
            formData.append('fileName', file.name);
            formData.append('chunkNumber', uploadedChunks + 1);
            formData.append('totalChunks', totalChunks);

            try {
                await fetch('/upload-chunk', {
                    method: 'POST',
                    body: formData
                });

                uploadedChunks++;
                document.getElementById('progress').innerText = `Uploaded chunk ${uploadedChunks} of ${totalChunks}`;
            } catch (error) {
                alert('Upload failed. Retrying...');
                break;
            }
        }
    }
</script>
</body>
</html>

前端通过 File.slice() 方法实现文件分片,并逐个上传到服务器。如果上传过程中中断,记录上传进度,并在恢复时从中断处继续上传。

2.2 服务端

服务端接收分片,保存到指定目录,并在所有分片上传完成后合并它们。其中,每个上传的分片通过 chunkNumber 参数被保存到指定的临时目录中。

@RequiredArgsConstructor
@RestController
public class FileChunkUploadController {
    // 文件临时目录,用于保存上传的分片文件
    private static final String TEMP_DIR = "D:\\Learning\\temp\\";

    // 文件上传目录,用于保存合并后的文件
    private static final String UPLOAD_DIR = "D:\\Learning\\upload\\";


    /**
     * 处理单个分片上传请求
     * <p>
     *     当文件较大或网络条件不稳定时,客户端可以将文件分割成多个分片分别上传
     *     这个方法负责接收单个分片,并将其保存到临时目录当所有分片上传完成后,将它们合并成一个完整的文件
     * </p>
     *
     * @param chunk 分片文件,包含文件的一部分
     * @param fileName 原始文件名,用于合并分片时命名
     * @param chunkNumber 当前分片的编号,从1开始
     * @param totalChunks 总分片数,用于判断是否所有分片都已上传
     * @return 分片上传的状态信息
     * @throws IOException 如果文件操作失败
     */
    @PostMapping("/upload-chunk")
    public ResponseEntity<String> uploadChunk(@RequestParam("chunk") MultipartFile chunk,
                                              @RequestParam("fileName") String fileName,
                                              @RequestParam("chunkNumber") int chunkNumber,
                                              @RequestParam("totalChunks") int totalChunks) throws IOException {

        // 保存分片到临时目录
        File tempFile = new File(TEMP_DIR + fileName + "_" + chunkNumber);
        chunk.transferTo(tempFile);

        // 检查是否所有分片都已上传
        if (isAllChunksUploaded(fileName, totalChunks)) {
            mergeChunks(fileName, totalChunks);
        }

        return ResponseEntity.ok("Chunk " + chunkNumber + " uploaded");
    }

    // 判断是否所有分片都上传完毕
    private boolean isAllChunksUploaded(String fileName, int totalChunks) {
        for (int i = 1; i <= totalChunks; i++) {
            File file = new File(TEMP_DIR + fileName + "_" + i);
            if (!file.exists()) {
                return false;
            }
        }
        return true;
    }

    // 合并所有分片
    private void mergeChunks(String fileName, int totalChunks) throws IOException {
        File mergedFile = new File(UPLOAD_DIR + fileName);
        try (FileOutputStream fos = new FileOutputStream(mergedFile, true)) {
            for (int i = 1; i <= totalChunks; i++) {
                File chunkFile = new File(TEMP_DIR + fileName + "_" + i);
                try (FileInputStream fis = new FileInputStream(chunkFile)) {
                    byte[] buffer = new byte[1024];
                    int bytesRead;
                    while ((bytesRead = fis.read(buffer)) != -1) {
                        fos.write(buffer, 0, bytesRead);
                    }
                }
                chunkFile.delete(); // 删除分片
            }
        }
    }
}

三、文件下载功能实现

在实现文件下载功能中,

  • 客户端负责接收分片,记录上传进度,以及最后的合并分片。
  • 服务端负责下载分片。

3.1 客户端(前端)

客户端实现逻辑为:

  1. 存储所有分片:使用 blobParts 数组来存储每个下载的分片的 Blob 对象。

  2. 整合分片:在所有分片下载完成后,使用 new Blob(blobParts) 创建一个完整的 Blob,然后生成一个 URL 并触发下载。

  3. 触发下载:在所有分片下载并整合完后,创建一个下载链接并点击它以触发下载。

创建 fragmentDownload.html 文件,具体代码为:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>File Download with Chunking</title>
    <style>
        body {
            font-family: Arial, sans-serif;
        }
        #downloadForm {
            margin: 20px;
        }
        #chunkSize {
            width: 100px;
        }
        #progress {
            margin-top: 20px;
        }
    </style>
</head>
<body>
<h1>File Download with Chunking</h1>
<div id="downloadForm">
    <label for="fileName">File Name:</label>
    <input type="text" id="fileName" placeholder="example.txt" required>
    <br><br>
    <label for="chunkSize">Chunk Size (bytes):</label>
    <input type="number" id="chunkSize" value="1048576" required> <!-- 1 MB -->
    <br><br>
    <button id="downloadButton">Download File</button>
</div>
<div id="progress">
    <p>Download Progress: <span id="progressText">0</span>%</p>
</div>

<script>
    async function getFileLength(fileName) {
        const response = await fetch(`download-chunk?fileName=${fileName}`, { method: 'HEAD' });
        const contentLength = response.headers.get('content-length');
        return parseInt(contentLength, 10);
    }

    async function downloadFile(fileName, start, end) {
        const range = `bytes=${start}-${end}`;
        const response = await fetch(`download-chunk?fileName=${fileName}`, {
            method: 'GET',
            headers: {
                'Range': range,
            }
        });

        if (response.status === 206) {
            return await response.blob(); // 返回 Blob 数据
        } else {
            throw new Error(`Error: ${response.status}`);
        }
    }

    async function downloadFileInChunks(fileName, chunkSize) {
        const fileLength = await getFileLength(fileName);
        let start = 0;
        let end = Math.min(chunkSize - 1, fileLength - 1);
        const totalChunks = Math.ceil(fileLength / chunkSize);
        let downloadedChunks = 0;

        const blobParts = []; // 存储所有分片的 Blob

        while (start < fileLength) {
            try {
                const blob = await downloadFile(fileName, start, end);
                blobParts.push(blob); // 将分片加入数组

                downloadedChunks++;
                const progressPercentage = Math.round((downloadedChunks / totalChunks) * 100);
                document.getElementById('progressText').innerText = progressPercentage;

                start += chunkSize;
                end = Math.min(start + chunkSize - 1, fileLength - 1);
            } catch (error) {
                console.error(`Failed to download chunk: ${error}`);
                break;
            }
        }

        // 所有分片下载完成后,整合成一个 Blob
        const finalBlob = new Blob(blobParts);
        const url = URL.createObjectURL(finalBlob);
        const a = document.createElement('a');
        a.href = url;
        a.download = fileName; // 设置下载文件名
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(url); // 释放内存

        if (start >= fileLength) {
            alert('Download completed!');
        }
    }

    document.getElementById("downloadButton").onclick = function() {
        const fileName = document.getElementById("fileName").value;
        const chunkSize = parseInt(document.getElementById("chunkSize").value);
        downloadFileInChunks(fileName, chunkSize).catch(console.error);
    };
</script>
</body>
</html>

在上述代码中,

  • downloadFileInChunks 函数:控制文件下载的分片逻辑,循环调用 downloadFile 函数进行分片下载。
  • downloadFile 函数:执行实际的文件下载请求。其中,HTTP 请求设置为 xhr.open("GET", download-chunk?fileName=${fileName}, true);

3.2 服务端

服务端主要实现根据 range 参数返回对应的文件分片即可。

@RestController
public class FileChunkDownloadController {
    private static final String FILE_DIRECTORY = "D:\\Program Files\\";

    // 处理文件下载请求的方法
    @GetMapping("/download-chunk")
    public ResponseEntity<StreamingResponseBody> downloadFile(
            @RequestParam String fileName,
            @RequestHeader(value = HttpHeaders.RANGE, required = false) String range) throws IOException {

        // 根据文件名构建文件对象
        File file = new File(FILE_DIRECTORY, fileName);
        if (!file.exists()) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
        }

        // 获取文件长度, 初始化下载的起始和结束位置
        long fileLength = file.length();
        long start = 0;
        long end = fileLength - 1;

        // 处理 Range 请求
        if (range != null) {
            // 解析 Range 请求中的起始和结束位置
            String[] ranges = range.replace("bytes=", "").split("-");
            start = Long.parseLong(ranges[0]);
            if (ranges.length > 1 && !ranges[1].isEmpty()) {
                end = Long.parseLong(ranges[1]);
            }
        }

        // 确保请求的范围合法
        if (start > end || start >= fileLength) {
            // 如果请求范围不合法,返回 416 REQUESTED RANGE NOT SATISFIABLE
            return ResponseEntity.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
                    .header(HttpHeaders.CONTENT_RANGE, "bytes */" + fileLength)
                    .build();
        }

        // 如果结束位置超出文件长度,调整结束位置
        if (end >= fileLength) {
            end = fileLength - 1;
        }

        // 设置内容长度
        long contentLength = end - start + 1;

        // 使用 final 关键字定义的变量
        final long finalStart = start;
        final long finalEnd = end;
        final long finalContentLength = contentLength;

        // 创建 StreamingResponseBody 对象
        StreamingResponseBody responseBody = outputStream -> {
            try (InputStream inputStream = new FileInputStream(file)) {
                inputStream.skip(finalStart); // 跳过起始位置

                byte[] buffer = new byte[1024];
                int bytesRead;
                long bytesToRead = finalContentLength;

                while (bytesToRead > 0 && (bytesRead = inputStream.read(buffer, 0, (int) Math.min(buffer.length, bytesToRead))) != -1) {
                    outputStream.write(buffer, 0, bytesRead);
                    bytesToRead -= bytesRead;
                }
            } catch (IOException e) {
                // 打印异常信息
                e.printStackTrace();
            }
        };

        return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
                .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_OCTET_STREAM_VALUE)
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"")
                .header(HttpHeaders.CONTENT_RANGE, "bytes " + finalStart + "-" + finalEnd + "/" + fileLength)
                .header(HttpHeaders.ACCEPT_RANGES, "bytes")
                .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(finalContentLength))
                .body(responseBody);
    }
}

四、功能测试

4.1 文件上传功能测试

打开客户端 http://localhost:8080/fragmentUpload.html, 上传文件。

在这里插入图片描述

在测试断点续传的过程中,重启服务端,然后再次点击客户端前端界面的 Upload 按钮。

4.2 文件下载功能实现

打开客户端 http://localhost:8080/fragmentDownload.html, 上传文件 。

不能直接使用 http://localhost:8080/download-chunk?fileName=demo.txt 来访问,会出现问题。

在这里插入图片描述

参考资料

实现大文件的断点下载、分片下载 (qq.com)

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/903057.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

分类预测|基于WOA鲸鱼优化K近邻KNN的数据分类预测Matlab程序 多特征输入多类别输出GWO-KNN

文章目录 一、基本原理原理流程总结 二、实验结果三、核心代码四、代码获取五、总结 一、基本原理 鲸鱼优化算法&#xff08;WOA&#xff0c;Whale Optimization Algorithm&#xff09;是一种模拟座头鲸捕猎行为的启发式优化算法&#xff0c;适用于解决各种优化问题。在K近邻&…

深度探索:超实用阿里云应用之低功耗模组AT开发示例

今天我们讲解一款低功耗4G全网通模组作为例子&#xff0c; 基于Air780EP模组AT开发的阿里云应用教程&#xff0c; 本文同样适用于以下型号&#xff1a; Air700ECQ/Air700EAQ/Air700EMQ Air780EQ/Air780EPA/Air780EPT/Air780EPS Air780E/Air780EX/Air724UG… 1、相关准备工作 …

大白话讲解分布式事务-SEATA事务四种模式(内含demo)

因为这里主要是讲解分布式事务&#xff0c;关于什么是事务&#xff0c;以及事务的特性&#xff0c;单个事务的使用方式&#xff0c;以及在Spring框架下&#xff0c;事务的传播方式&#xff0c;这里就不再赘述了。但是我这里要补充一点就是&#xff0c;一提到事务大家脑子里第一…

假如浙江与福建合并为“浙福省”

在中国&#xff0c;很多省份之间的关系颇有“渊源”&#xff0c;例如河南与河北、湖南与湖北、广东与广西等等&#xff0c;他们因一山或一湖之隔&#xff0c;地域相近、文化相通。 但有这么两个省份&#xff0c;省名没有共通之处&#xff0c;文化上也有诸多不同&#xff0c;但…

[简易版] 自动化脚本

前言 uniapp cli项目中没办法自动化打开微信开发者工具&#xff0c;需要手动打开比较繁琐&#xff0c;故此自动化脚本就诞生啦~ 实现 const spawn require("cross-spawn"); const chalk require("picocolors"); const dayjs require("dayjs&quo…

7.使用Redis进行秒杀优化

目录 1. 优化思路 总结之前实现的秒杀过程 下单流程 2. 使用Redis完成秒杀资格判断和库存 0. Redis中数据类型的选用 1.将优惠券信息保存到Redis中 2.基于Lua脚本&#xff0c;判断秒杀库存、一人一单&#xff0c;决定用户是否抢购成功 3. 开启新协程&#xff0c;处理数…

MongoDB-Plus

MongoDB-Plus是一款功能强大的数据库工具&#xff0c;它基于MongoDB&#xff0c;提供了更丰富的功能和更便捷的操作方式。以下是一篇关于MongoDB-Plus轻松上手的详细指南&#xff0c;旨在帮助初学者快速掌握其安装、配置和基础操作。 一、MongoDB-Plus概述 MongoDB是一款由C编…

鸿蒙next之导航组件跳转携带参数

官方文档推荐使用导航组件的形式进行页面管理&#xff0c;官方文档看了半天也没搞明白&#xff0c;查了各种文档才弄清楚。以下是具体实现方法&#xff1a; 在src/main/resources/base/profile下新建router_map.json文件 里边存放的是导航组件 {"routerMap" : [{&q…

鸿蒙API12 端云一体化开发——云函数篇

大家好&#xff0c;我是学徒小z&#xff0c;我们接着上次的端云一体化继续讲解&#xff0c;今天来说说云函数怎么创建和调用 文章目录 云函数1. 入口方法2. 编写云函数3. 进行云端测试4. 在本地端侧调用云函数5. 云函数传参6. 环境变量 云函数 1. 入口方法 在CloudProgram中…

软硬件开发面试问题大汇总篇——针对非常规八股问题的提问与应答(代码规范与生态管理)

软硬件开发&#xff0c;对于编码规范、生态管理等等综合问题的考察尤为重要。 阐述下环形缓冲区的用途 环形缓冲区&#xff08;Ring Buffer&#xff09;是一种固定大小的数据结构&#xff0c;常用于实现数据的流式传输或临时存储。在环形缓冲区中&#xff0c;当到达缓冲区的末尾…

Java Lock CyclicBarrier 总结

前言 相关系列 《Java & Lock & 目录》&#xff08;持续更新&#xff09;《Java & Lock & CyclicBarrier & 源码》&#xff08;学习过程/多有漏误/仅作参考/不再更新&#xff09;《Java & Lock & CyclicBarrier & 总结》&#xff08;学习总结…

什么是排列树?

一、排列树的定义 排列树就是一个能表示全排列的树形结构。全排列咱们都学过&#xff0c;就是所有可能的排列。 当问题的解是n个元素的某个排列时&#xff0c;其解空间&#xff08;全部可能解构成的集合&#xff09;就是n个元素的全排列&#xff0c;称为排列树。 以3个元素{…

1 环境配置、创建功能包、编译、Cmake文件及package文件学习笔记

1 基本结构 放张 赵虚左老师的pdf截图 2 环境配置 //每次都需配置 . install/setup.bash//或者一次配置echo "source /path/to/your/workspace_name/install/setup.bash" >> ~/.bashrcsource ~/.bashrc3 创建功能包 ros2 pkg create 包名--build-type 构建类…

ClickHouse 5节点集群安装

ClickHouse 5节点集群安装 在此架构中&#xff0c;配置了五台服务器。其中两个用于托管数据副本。其他三台服务器用于协调数据的复制。在此示例中&#xff0c;我们将创建一个数据库和表&#xff0c;将使用 ReplicatedMergeTree 表引擎在两个数据节点之间复制该数据库和表。 官…

简单易用的Android主线程耗时检测类 MainThreadMonitor

适用场景 debug 本地测试 文章目录 代码类 MainThreadMonitor.java使用方式 Application的attachBaseContextlog输出示例 代码类 MainThreadMonitor.java public class MainThreadMonitor {private static final String TAG "MainThreadMonitor";private static Sc…

uniapp的IOS证书申请(测试和正式环境)及UDID配置流程

1.说明 本教程只提供uniapp在ios端的证书文件申请&#xff08;包含正式环境和开发环境&#xff09;、UDID配置说明&#xff0c;请勿用文档中的账号和其他隐私数据进行测试&#xff0c;请勿侵权&#xff01; 2.申请前准备 证书生成网站&#xff1a;苹果应用上传、解析&#x…

iOS Block 详解(Object-C)

Block 是苹果公司较晚推出的一个语法,与很多语法的闭包差不多意思 一:Block声明 PS:很多人学不好Block,大概率是被它的声明写法给吓到了,写法确实有点奇怪 返回值类型(^block变量名)(参数列表) 例如: int(^personBlock)(NSString *,int) 返回值类型(^block变量名)(参数列表…

iOS 18.2开发者预览版 Beta 1版本发布,欧盟允许卸载应用商店

苹果今天为开发人员推送了iOS 18.2开发者预览版 Beta 1版本 更新&#xff08;内部版本号&#xff1a;22C5109p&#xff09;&#xff0c;本次更新距离上次发布 Beta / RC 间隔 2 天。该版本仅适用于支持Apple Intelligence的设备&#xff0c;包括iPhone 15 Pro系列和iPhone 16系…

uniapp 中间tabbar的实现

UI 需求 &#xff1a; 有五个tabbr栏 &#xff0c;中间的按钮更大 &#xff0c;如图 &#xff1a; 说明 &#xff1a; 在tabbar中的list 配置 其他四个tabbar &#xff1a;首页 精华 社区 我的 1. 在page.json中配置 在tabbar中 &#xff0c;与list 平级 &#xff0c;设置按钮…

sa-token 所有的异常都是未登录异常的问题

在使用satoken的时候&#xff0c;有这么一个问题&#xff0c;就是不管我是什么错误&#xff0c;都会弹出未登录异常&#xff0c;起初的时候我以为satoken的拦截器会拦截所有的异常&#xff0c;但是今后测试才发现忽略了一点&#xff0c;也是最重要最容易忽视的一点。 如果我现在…