【大文件上传、断点续传、自动重试】核心思想及完整代码

核心思想

根据网络速度智能调整分片大小,从第一个分片开始,下一个分片从此分片结束位置开始,每个分片信息都放进chunks中,然后存进浏览器IndexedDB 数据库,键值对形式存储json.
断点续传时前端判断已上传的分片index,重复上传也没关系,因为加密后后的二进制数据会被存储进服务端中,服务端会验证分片数据的唯一性。
至于自动重试上传,是写在分片上传的逻辑中的,如果此分片没有上传,那就执行上传,在循环中上传分片,如果成功后就跳出循环,失败后就循环执行重试。


/**
  **  智能分片上传工具(支持断点续传、自动重试、加密)
  **  核心逻辑:
  **  1. 动态分片大小(基于网络速度)
  **  2. 加密前计算分片唯一标识(解决加密后识别问题)
  ** 3. 使用IndexedDB存储分片元数据
 **  4. 自动重试 + 断点续传
 */
{
	  "fileId": "abc123",
 		  "uploadedChunks": [0, 1, 3],
 		  "encryptionKey": "...",
		  "ivMap": [...]
		}

断点续传的核心

  • ‌性能优化‌
    前端用索引判断,避免重复计算哈希
    服务端用哈希校验,确保存储唯一性
  • ‌安全性保障‌
    加密密钥和IV始终在前端管理
    服务端只存储加密后的内容
  • ‌网络容错‌
    断点续传时只需校验索引,恢复速度快
    服务端哈希校验防止数据重复

这种双层级校验机制(前端索引 + 服务端哈希)完美平衡了性能与数据一致性,是断点续传设计的经典模式。

1.初始化数据

class FileUploader {
  constructor(file, options = {}) {
    this.file = file;
    this.options = {
      chunkSize: 5 * 1024 * 1024, // 默认分片大小5MB
      maxRetries: 3,              // 最大重试次数
      ...options
    };
    this.fileId = this.generateFileId(); // 唯一文件标识(不依赖加密)
    this.chunks = [];           // 分片元数据(包含加密前哈希)
    this.uploadedChunks = new Set(); // 已上传分片索引
    this.encryptionKey = null;  // AES加密密钥(全程不变)
    this.ivMap = new Map();      // 存储每个分片的IV(初始化向量)
  }

// 生成唯一文件ID(基于文件名+大小+修改时间)

generateFileId() {
  return btoa(`${this.file.name}-${this.file.size}-${this.file.lastModified}`);
}

// 主流程入口

async start() {
  await this.prepareChunks();     // 1. 分片准备
  await this.loadProgress();      // 2. 加载上传进度
  await this.uploadAllChunks();   // 3. 上传分片
  await this.sendMergeRequest();  // 4. 合并文件
}

2.基于网络速度调整分片

计算当前速度,然后根据速度调整分片大小。如果当前速度小于1MB/s,就减少分片大小,最低1MB;如果速度大于5MB/s,就增加分片大小,最高20MB。

// 基于网络速度动态调整分片大小(示例)
let chunkSize = 5 * 1024 * 1024; // 默认5MB
let lastUploadSpeed = 0;

function adjustChunkSize(uploadTimeMs) {
const currentSpeed = chunkSize / (uploadTimeMs / 1000); // MB/s
if (currentSpeed < 1) {
// 降速时减小分片,如果大于1M,那可能是5*0.8=4M,但如果是1M,可能只是0.8M,保证最小是1M,所以使用Math.max()
chunkSize = Math.max(1 * 1024 * 1024, chunkSize * 0.8);
 } else if (currentSpeed > 5) {
chunkSize = Math.min(20 * 1024 * 1024, chunkSize * 1.2); // 加速时增大分片
}
}

分片大小乘以0.8或1.2可能会让分片变化不够平滑,或者导致分片大小在边界值附近震荡。比如,当速度在1MB/s附近波动时,分片大小可能会频繁调整,还有偶发的网络波动导致分片大小频繁变化,影响上传效率,我们可以对以上代码进行优化。

可优化代码如下:

// 基于网络速度动态调整分片大小(修正版)
let chunkSize = 5 * 1024 * 1024; // 默认5MB
let lastUploadSpeed = 5 * 1024 * 1024; // 初始假设5MB/s

function adjustChunkSize(uploadTimeMs) {
  // 1. 安全处理,时间为0无法准确测出网速
  if (uploadTimeMs <= 0) uploadTimeMs = 1;

  // 2. 计算当前速度(字节/秒)
  const currentSpeed = (chunkSize / uploadTimeMs) * 1000;

  // 3. 加入历史速度平滑(加权平均),基于历史速度做动态调整
  const SMOOTH_FACTOR = 0.3;
  lastUploadSpeed = lastUploadSpeed * (1 - SMOOTH_FACTOR) + currentSpeed * SMOOTH_FACTOR;

  // 4. 转换为MB/s
  const currentSpeedMB = lastUploadSpeed / (1024 * 1024);

  // 5. 动态调整逻辑
  const MAX_CHANGE_RATE = 0.2; // 最大变化幅度20%
  if (currentSpeedMB < 1) {
    const targetSize = Math.max(1 * 1024 * 1024, chunkSize * 0.8);
    chunkSize = chunkSize * (1 - MAX_CHANGE_RATE) + targetSize * MAX_CHANGE_RATE;
  } else if (currentSpeedMB > 5) {
    const targetSize = Math.min(20 * 1024 * 1024, chunkSize * 1.2);
    chunkSize = chunkSize * (1 - MAX_CHANGE_RATE) + targetSize * MAX_CHANGE_RATE;
  }

  // 6. 取整到1MB倍数
  chunkSize = Math.round(chunkSize / (1024 * 1024)) * 1024 * 1024;
}

3.分片准备与分片处理

// 存储内容包含:
// - fileId: 文件唯一标识
// - chunks: 全部分片元数据
// - uploadedChunks: 已上传分片索引
// - encryptionKey: 加密密钥(安全导出)
// - ivMap: 所有分片的IV值
await this.saveProgressToDB();

 // 1. 分片准备(包含智能分片逻辑)
  async prepareChunks() {
    // 动态调整分片大小(基于网络速度),保留此代码为方便理解思路
   // 测试网络速度
    const networkSpeed = await this.testNetworkSpeed(); 
    // 调整网络分片
    this.options.chunkSize = Math.min(
      20 * 1024 * 1024, // 最大20MB
      Math.max(
        1 * 1024 * 1024, // 最小1MB
        Math.round(networkSpeed * 0.5) // 网络速度(MB/s) * 0.5
      )
    );

//  2.分片处理核心逻辑
 // 初始化分片切割位置
  let offset = 0;
  let chunkIndex = 0;
  
  // 循环切割文件
  while (offset < this.file.size) {
    // 动态计算分片结束位置(处理文件末尾)
    const chunkEnd = Math.min(offset + this.options.chunkSize, this.file.size);
    const chunkBlob = this.file.slice(offset, chunkEnd);

    // 生成分片唯一标识(SHA-256哈希)
    // 用途:服务端解密后校验数据完整性
    const originalHash = await this.calculateSHA256(chunkBlob);

    // 生成加密初始化向量(IV)
    // - 每个分片使用不同IV增强安全性
    // - AES-CBC要求16字节不可预测值
    const iv = crypto.getRandomValues(new Uint8Array(16));

    // 使用AES-CBC加密分片
    // 加密密钥通过generateAESKey()生成,全程保持一致
    const encryptedBlob = await this.encryptChunk(chunkBlob, iv);

    // 存储分片元数据
    this.chunks.push({
      index: chunkIndex,
      originalHash,        // 加密前哈希值
      encryptedBlob,       // 加密后的二进制数据
      iv: Array.from(iv),  // 转换IV为普通数组(IndexedDB兼容)
      startByte: offset,   // 分片起始字节
      endByte: chunkEnd     // 分片结束字节
    });

    // 记录IV用于后续解密
    this.ivMap.set(chunkIndex, iv);

    // 更新切割位置
    offset = chunkEnd;
    chunkIndex++;
  }

  // 持久化存储分片信息到IndexedDB
  // 存储内容包含:文件ID、分片列表、已上传索引、加密密钥、IV映射表
  await this.saveProgressToDB();

4.分片上传

对于每个分片,先检查是否已上传,若未上传,则进入while循环,尝试上传,失败时等待并增加重试次数,直到成功或超过重试次数。成功后跳出while循环,继续下一个分片。自动重试是在同一个分片上传失败时立即触发的,而不是在所有分片遍历完之后。

graph TD
  A[开始遍历分片] --> B{分片已上传?}
  B -- 是 --> C[跳过上传]
  B -- 否 --> D[初始化重试计数器 retryCount=0]
  D --> E{重试次数未超限?}
  E -- 是 --> F[尝试上传分片]
  F --> G{上传成功?}
  G -- 是 --> H[记录状态并处理下一个分片]
  G -- 否 --> I[等待并增加 retryCount]
  I --> E
  E -- 否 --> J[抛出错误]
async uploadAllChunks() {
  // 遍历所有分片(this.chunks是prepareChunks生成的元数据数组)
  for (const chunk of this.chunks) {
    // 关键判断:如果分片索引在已上传集合中,跳过上传
    if (this.uploadedChunks.has(chunk.index)) continue;

    let retryCount = 0;
    // 自动重试循环(最多重试maxRetries次)
    while (retryCount <= this.options.maxRetries) {
      try {
        // 尝试上传当前分片
        await this.uploadChunk(chunk);

        // 上传成功后更新状态
        this.uploadedChunks.add(chunk.index);   // 添加索引到已上传集合
        await this.saveProgressToDB();          // 立即持久化存储进度

        break; // 跳出重试循环,处理下一个分片
      } catch (error) {
        // 达到最大重试次数时抛出错误
        if (retryCount === this.options.maxRetries) throw error;

        // 指数退避策略:第1次等1秒,第2次等2秒,第3次等4秒...
        await new Promise(resolve => 
          setTimeout(resolve, 1000 * (2 ** retryCount))
        );
        
        retryCount++;
      }
    }
  }
}

// 上传单个分片

  async uploadChunk(chunk) {
    const formData = new FormData();
    formData.append('fileId', this.fileId);
    formData.append('chunkIndex', chunk.index);
    formData.append('encryptedData', chunk.encryptedBlob);
    formData.append('iv', JSON.stringify(chunk.iv)); // 加密使用的IV
    formData.append('originalHash', chunk.originalHash); // 加密前的哈希

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

    if (!response.ok) throw new Error(`上传失败: ${response.status}`);
    const result = await response.json();
    if (!result.success) throw new Error('服务端验证失败');
  }

4. 合并请求

  async sendMergeRequest() {
    const response = await fetch('/merge', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        fileId: this.fileId,
        fileName: this.file.name,
        totalChunks: this.chunks.length,
        encryptionKey: await this.exportKey(this.encryptionKey), // 导出加密密钥
        ivMap: Array.from(this.ivMap.entries()).map(([k, v]) => [k, Array.from(v)])
      })
    });
    if (!response.ok) throw new Error('合并失败');
  }

工具方法 ------------------------------------------------------

  // 测试网络速度(粗略估算)
  async testNetworkSpeed() {
    const start = Date.now();
    await fetch('https://httpbin.org/stream-bytes/1048576'); // 下载1MB测试文件
    const duration = (Date.now() - start) / 1000; // 秒
    return 1 / duration; // MB/s
  }

  // 生成AES密钥(全程不变)
  async generateAESKey() {
    return crypto.subtle.generateKey(
      { name: 'AES-CBC', length: 256 },
      true, // 是否可导出
      ['encrypt', 'decrypt']
    );
  }

  // 加密分片
  async encryptChunk(blob, iv) {
    const data = await blob.arrayBuffer();
    const encrypted = await crypto.subtle.encrypt(
      { name: 'AES-CBC', iv },
      this.encryptionKey,
      data
    );
    return new Blob([encrypted]);
  }

  // 计算分片哈希(SHA-256)
  async calculateHash(blob) {
    const data = await blob.arrayBuffer();
    const hashBuffer = await crypto.subtle.digest('SHA-256', data);
    return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
  }

  // 存储进度到IndexedDB
  async saveProgressToDB() {
    const db = await this.openDB();
    const tx = db.transaction('uploads', 'readwrite');
    tx.objectStore('uploads').put({
      fileId: this.fileId,
      uploadedChunks: Array.from(this.uploadedChunks),
      encryptionKey: await this.exportKey(this.encryptionKey),
      ivMap: Array.from(this.ivMap.entries()).map(([k, v]) => [k, Array.from(v)])
    });
  }

  // 打开IndexedDB
  openDB() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open('FileUploadDB', 1);
      request.onupgradeneeded = (e) => {
        const db = e.target.result;
        if (!db.objectStoreNames.contains('uploads')) {
          db.createObjectStore('uploads', { keyPath: 'fileId' });
        }
      };
      request.onsuccess = () => resolve(request.result);
      request.onerror = reject;
    });
  }

  // 导出密钥(用于服务端存储)
  async exportKey(key) {
    const exported = await crypto.subtle.exportKey('raw', key);
    return Array.from(new Uint8Array(exported));
  }
}

服务端实现

const express = require('express');
const multer = require('multer');
const crypto = require('crypto');
const path = require('path');
const fs = require('fs');

const app = express();
const upload = multer({ dest: 'uploads/tmp/' });

// 内存中存储加密信息(生产环境用数据库)
const fileMetadata = new Map();

// 上传分片接口
app.post('/upload-chunk', upload.single('encryptedData'), async (req, res) => {
  const { fileId, chunkIndex, originalHash, iv } = req.body;
  
  // 1. 读取加密分片
  const encryptedData = await fs.promises.readFile(req.file.path);
  
  // 2. 检查是否已上传(基于originalHash)
  if (await isChunkUploaded(fileId, originalHash)) {
    fs.unlinkSync(req.file.path); // 删除临时文件
    return res.json({ success: true, skipped: true });
  }

  // 3. 存储分片(实际存储到正式目录)
  const chunkDir = path.join('uploads', fileId);
  await fs.promises.mkdir(chunkDir, { recursive: true });
  const chunkPath = path.join(chunkDir, `${chunkIndex}.dat`);
  await fs.promises.rename(req.file.path, chunkPath);

  // 4. 记录分片哈希(用于去重)
  recordChunkHash(fileId, originalHash);

  res.json({ success: true });
});

// 合并文件接口
app.post('/merge', express.json(), async (req, res) => {
  const { fileId, fileName, totalChunks, encryptionKey, ivMap } = req.body;
  
  // 1. 准备解密参数
  const key = Buffer.from(encryptionKey);
  const ivs = new Map(ivMap.map(([k, v]) => [k, Buffer.from(v)]));

  // 2. 合并文件
  const outputPath = path.join('uploads', fileName);
  const writeStream = fs.createWriteStream(outputPath);

  for (let i = 0; i < totalChunks; i++) {
    const chunkPath = path.join('uploads', fileId, `${i}.dat`);
    const encryptedData = await fs.promises.readFile(chunkPath);

    // 3. 解密分片
    const iv = ivs.get(i);
    const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
    const decrypted = Buffer.concat([
      decipher.update(encryptedData),
      decipher.final()
    ]);

    writeStream.write(decrypted);
  }

  writeStream.end();
  await fs.promises.rmdir(path.join('uploads', fileId), { recursive: true });

  res.json({ success: true, path: outputPath });
});

// 工具方法 ------------------------------------------------------
async function isChunkUploaded(fileId, hash) {
  const metadata = fileMetadata.get(fileId);
  return metadata?.hashes?.includes(hash);
}

function recordChunkHash(fileId, hash) {
  if (!fileMetadata.has(fileId)) {
    fileMetadata.set(fileId, { hashes: [] });
  }
  fileMetadata.get(fileId).hashes.push(hash);
}

app.listen(3000, () => console.log('Server running on port 3000'));

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值