如何实现一个带文件断点续传的案例?

wen java案例 54

本文目录导读:

如何实现一个带文件断点续传的案例?

  1. 核心原理
  2. 环境准备
  3. 第一步:后端服务 (Node.js + Express)
  4. 第二步:前端页面 (HTML + JavaScript)
  5. 第三步:运行与测试
  6. 关键点与扩展

这是一个复杂但非常实用的需求,实现断点续传的核心在于客户端记录上传进度,并服务端支持偏移量写入

下面我将分步骤讲解原理,并提供一个基于 HTTP + Node.js(后端) + 浏览器(前端) 的完整可运行案例。

核心原理

  1. 切分文件:前端将大文件(如视频)切成固定大小的块(Slice,1MB)。
  2. 标记块:为每个块分配一个唯一的序号(chunkIndex)和文件唯一标识(fileHash)。
  3. 记录进度:客户端(浏览器)记录已成功上传的块序号(例如使用 localStorage 或内存 Map)。
  4. 断点恢复:上传中断后,重新开始前先发送一个“查询进度”请求给服务端。
  5. 断点写入:服务端收到“查询进度”的响应后,告知客户端哪些块已上传,客户端只上传缺失的块。
  6. 合并文件:所有块上传完毕后,客户端通知服务端合并这些块。

环境准备

  1. Node.js 环境
  2. 需要安装 express(Web框架)、multer(文件上传处理)、spark-md5(浏览器端计算文件 Hash)。

后端依赖安装命令:

npm init -y
npm install express multer

前端依赖(直接在 HTML 中使用 CDN):

<script src="https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>

第一步:后端服务 (Node.js + Express)

创建一个 server.js 文件。

// server.js
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const app = express();
const PORT = 3000;
// 临时存储块的目录
const CHUNK_DIR = path.join(__dirname, 'uploads', 'chunks');
// 最终合并文件的目录
const MERGED_DIR = path.join(__dirname, 'uploads', 'files');
// 确保目录存在
if (!fs.existsSync(CHUNK_DIR)) fs.mkdirSync(CHUNK_DIR, { recursive: true });
if (!fs.existsSync(MERGED_DIR)) fs.mkdirSync(MERGED_DIR, { recursive: true });
// 配置 multer 存储(将块存为临时文件)
const storage = multer.diskStorage({
    destination: (req, file, cb) => {
        // 根据文件id创建子文件夹
        const fileId = req.body.fileId; // 前端发送的文件唯一标识
        const chunkDir = path.join(CHUNK_DIR, fileId);
        if (!fs.existsSync(chunkDir)) {
            fs.mkdirSync(chunkDir, { recursive: true });
        }
        cb(null, chunkDir);
    },
    filename: (req, file, cb) => {
        // 使用块索引命名文件
        const chunkIndex = req.body.chunkIndex;
        cb(null, `chunk_${chunkIndex}`);
    }
});
const upload = multer({ storage: storage });
// 解析 JSON 请求体
app.use(express.json());
// 1. 上传单个块
app.post('/upload', upload.single('file'), (req, res) => {
    console.log(`块 ${req.body.chunkIndex} 上传成功`);
    res.json({ code: 0, message: '块接收成功' });
});
// 2. 查询已上传的块列表(实现断点续传的核心)
app.get('/check', (req, res) => {
    const { fileId } = req.query;
    const chunkDir = path.join(CHUNK_DIR, fileId);
    const uploadedChunks = [];
    if (fs.existsSync(chunkDir)) {
        const files = fs.readdirSync(chunkDir);
        files.forEach(file => {
            // 文件名是 chunk_0, chunk_1...
            const index = parseInt(file.split('_')[1]);
            if (!isNaN(index)) {
                uploadedChunks.push(index);
            }
        });
    }
    // 升序排列
    uploadedChunks.sort((a, b) => a - b);
    console.log(`文件 ${fileId} 已上传块: ${uploadedChunks.join(', ')}`);
    res.json({ code: 0, data: uploadedChunks });
});
// 3. 合并所有块
app.post('/merge', (req, res) => {
    const { fileId, fileName } = req.body;
    const chunkDir = path.join(CHUNK_DIR, fileId);
    const mergedFilePath = path.join(MERGED_DIR, fileName);
    // 检查是否所有块都存在(这里简单处理,直接合并所有存在的块)
    const chunkFiles = fs.readdirSync(chunkDir).sort((a, b) => {
        const indexA = parseInt(a.split('_')[1]);
        const indexB = parseInt(b.split('_')[1]);
        return indexA - indexB;
    });
    // 创建可写流写入最终文件
    const writeStream = fs.createWriteStream(mergedFilePath);
    let chunksProcessed = 0;
    function appendChunk() {
        if (chunksProcessed >= chunkFiles.length) {
            writeStream.end();
            // 合并完成后删除块目录
            fs.rmSync(chunkDir, { recursive: true, force: true });
            console.log(`文件 ${fileName} 合并完成`);
            res.json({ code: 0, message: '合并成功' });
            return;
        }
        const chunkPath = path.join(chunkDir, chunkFiles[chunksProcessed]);
        const readStream = fs.createReadStream(chunkPath);
        readStream.pipe(writeStream, { end: false });
        readStream.on('end', () => {
            chunksProcessed++;
            appendChunk(); // 处理下一个块
        });
        readStream.on('error', (err) => {
            console.error(err);
            res.status(500).json({ code: -1, message: '合并失败' });
        });
    }
    appendChunk();
});
// 启动服务
app.listen(PORT, () => {
    console.log(`服务已启动,请访问 http://localhost:${PORT}/public/index.html`);
});
// 提供静态文件服务(让前端页面可以访问)
app.use('/public', express.static(path.join(__dirname, 'public')));

第二步:前端页面 (HTML + JavaScript)

在项目根目录下创建 public/index.html 文件。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">断点续传 Demo</title>
    <script src="https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>
    <style>
        .progress-bar { width: 100%; background: #ddd; margin: 10px 0; }
        .progress-bar .progress { width: 0%; height: 30px; background: green; text-align: center; line-height: 30px; color: white; }
    </style>
</head>
<body>
    <h1>文件断点续传</h1>
    <input type="file" id="fileInput" />
    <button id="uploadBtn">上传</button>
    <button id="pauseBtn" disabled>暂停</button>
    <div id="status"></div>
    <div class="progress-bar">
        <div class="progress" id="progressBar">0%</div>
    </div>
    <script>
        const CHUNK_SIZE = 1 * 1024 * 1024; // 1MB 一个块
        let file = null;
        let fileId = null;
        let uploadedChunks = []; // 已上传的块索引
        let chunkTotal = 0;
        let currentChunk = 0;
        let isPaused = false;
        const fileInput = document.getElementById('fileInput');
        const uploadBtn = document.getElementById('uploadBtn');
        const pauseBtn = document.getElementById('pauseBtn');
        const statusEl = document.getElementById('status');
        const progressBar = document.getElementById('progressBar');
        // 选择文件
        fileInput.addEventListener('change', async (e) => {
            file = e.target.files[0];
            if (!file) return;
            statusEl.textContent = `已选择文件: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)} MB)`;
            // 计算文件唯一标识(用于服务端区分文件)
            fileId = await calculateFileHash(file);
            console.log('文件 hash:', fileId);
            // 检查服务端是否已经有部分上传
            await checkUploadedChunks(fileId);
            uploadBtn.disabled = false;
        });
        // 计算文件 Hash (使用 SparkMD5,对文件分块计算)
        function calculateFileHash(file) {
            return new Promise((resolve, reject) => {
                const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
                const chunkSize = 2 * 1024 * 1024; // 2MB 用于计算hash
                const chunks = Math.ceil(file.size / chunkSize);
                let currentChunk = 0;
                const spark = new SparkMD5.ArrayBuffer();
                const fileReader = new FileReader();
                fileReader.onload = (e) => {
                    spark.append(e.target.result);
                    currentChunk++;
                    if (currentChunk < chunks) {
                        loadNext();
                    } else {
                        resolve(spark.end());
                    }
                };
                fileReader.onerror = (e) => reject(e.target.error);
                function loadNext() {
                    const start = currentChunk * chunkSize;
                    const end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
                    fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
                }
                loadNext();
            });
        }
        // 检查已上传块
        async function checkUploadedChunks(fileId) {
            try {
                const res = await fetch(`http://localhost:3000/check?fileId=${fileId}`);
                const result = await res.json();
                uploadedChunks = result.data || [];
                console.log('已上传块索引:', uploadedChunks);
                // 更新进度显示
                chunkTotal = Math.ceil(file.size / CHUNK_SIZE);
                updateProgressBar();
            } catch (error) {
                console.error('检查进度失败:', error);
            }
        }
        // 上传文件
        uploadBtn.addEventListener('click', async () => {
            if (!file) return;
            uploadBtn.disabled = true;
            pauseBtn.disabled = false;
            isPaused = false;
            chunkTotal = Math.ceil(file.size / CHUNK_SIZE);
            // 从已上传的块之后开始(或从0开始)
            if (uploadedChunks.length > 0) {
                currentChunk = uploadedChunks[uploadedChunks.length - 1] + 1;
            } else {
                currentChunk = 0;
            }
            for (let i = currentChunk; i < chunkTotal; i++) {
                if (isPaused) {
                    statusEl.textContent = '上传已暂停';
                    break;
                }
                // 如果已经上传过,跳过
                if (uploadedChunks.includes(i)) {
                    continue;
                }
                const start = i * CHUNK_SIZE;
                const end = Math.min(start + CHUNK_SIZE, file.size);
                const blob = file.slice(start, end);
                const formData = new FormData();
                formData.append('file', blob, `chunk_${i}`);
                formData.append('fileId', fileId);
                formData.append('chunkIndex', i);
                try {
                    const res = await fetch('http://localhost:3000/upload', {
                        method: 'POST',
                        body: formData
                    });
                    const result = await res.json();
                    if (result.code === 0) {
                        uploadedChunks.push(i);
                        updateProgressBar();
                        statusEl.textContent = `正在上传块 ${i + 1} / ${chunkTotal}`;
                    } else {
                        statusEl.textContent = `上传块 ${i} 失败`;
                        break;
                    }
                } catch (error) {
                    console.error('上传错误:', error);
                    statusEl.textContent = `上传错误: ${error.message}`;
                    break;
                }
                // 模拟网络波动(非必须)
                // await new Promise(r => setTimeout(r, 50));
            }
            // 所有块上传完成,请求合并
            if (!isPaused && uploadedChunks.length === chunkTotal) {
                statusEl.textContent = '所有块上传完成,正在合并...';
                await mergeFile();
            }
            uploadBtn.disabled = false;
            pauseBtn.disabled = true;
        });
        // 暂停上传
        pauseBtn.addEventListener('click', () => {
            isPaused = true;
            pauseBtn.disabled = true;
        });
        // 请求合并文件
        async function mergeFile() {
            try {
                const res = await fetch('http://localhost:3000/merge', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({
                        fileId: fileId,
                        fileName: file.name
                    })
                });
                const result = await res.json();
                if (result.code === 0) {
                    statusEl.textContent = '上传成功!';
                    progressBar.style.background = 'blue';
                    progressBar.textContent = '100%';
                } else {
                    statusEl.textContent = '合并失败: ' + result.message;
                }
            } catch (error) {
                statusEl.textContent = '合并请求错误: ' + error.message;
            }
        }
        // 更新进度条
        function updateProgressBar() {
            const total = chunkTotal || 1;
            const progress = Math.round((uploadedChunks.length / total) * 100);
            progressBar.style.width = progress + '%';
            progressBar.textContent = progress + '%';
        }
    </script>
</body>
</html>

第三步:运行与测试

  1. 确保你的目录结构如下:

    your-project/
    ├── server.js
    ├── public/
    │   └── index.html
    └── package.json
  2. 启动服务端:

    node server.js
  3. 打开浏览器,访问 http://localhost:3000/public/index.html

  4. 测试断点续传

    • 选择一个稍大的文件(10MB 的文本或视频)。
    • 点击上传,观察控制台和进度条,上传几秒后,关闭浏览器标签页(模拟异常中断)。
    • 再次刷新页面,重新选择同一个文件,点击上传,你会看到进度条不会从 0 开始,而是从之前中断的位置继续上传。

关键点与扩展

  1. 文件唯一标识 fileId:使用了文件的 MD5 哈希,生产环境可以用 文件名 + 文件大小 + 修改时间 的组合,避免大文件计算 Hash 耗时太久(因为 Hash 计算本身也要读文件)。

  2. 安全性:这个示例没有身份验证和权限控制,生产环境中,fileId 需要和用户绑定,防止恶意覆盖他人文件。

  3. 并发控制:案例中使用了串行上传(逐个上传),简单稳定但速度中等,要加速,可以用 Promise.all 同时上传 3~5 个块,但要注意:

    • 服务端需要处理并发写入同一个块的问题(通常不会,因为块索引是唯一的)。
    • 重试机制:如果并发中某个块失败,需要单独重试。
  4. 服务器端的合并:示例使用 pipe 串行合并,对于超大文件,可以使用流式逐块追加,或者用 ffmpeg 对视频进行无损合并。

  5. 前端存储:目前记录 uploadedChunks 是依赖服务端(通过 /check 接口),如果不想每次查询服务器,也可以使用 localStorage 缓存 fileId -> chunks 的映射,但服务器端校验仍然是最后的保障。

这个案例实现了断点续传的核心流程,可以作为面试或项目原理入门的参考。

抱歉,评论功能暂时关闭!