上传大文件——断点续传

背景

在现代Web应用中,文件上传是一个常见的功能需求,特别是在处理大型文件时,如视频、大型文档等。传统的文件上传方式在遇到网络不稳定或服务器问题时,容易导致上传失败,且用户需要重新上传整个文件,这不仅浪费了时间和带宽,也极大地影响了用户体验。为了解决这一问题,断点续传技术应运而生。

断点续传的优点

  • 提高上传成功率:即使在网络不稳定的情况下,用户也不需要重新上传整个文件,只需从断点处继续上传。

  • 节省时间和带宽:避免了重复上传已成功传输的部分,减少了用户的等待时间和网络资源的浪费。

  • 提升用户体验:用户可以随时暂停和恢复上传,更加灵活方便。

  • 适用于大文件:对于大文件的上传,断点续传尤为重要,因为它可以显著减少因网络问题导致的上传失败。

断点续传的基本原理

断点续传的核心思想是将大文件分割成多个小块(分片),每次只上传一个分片。如果上传过程中断,下次上传时可以从上次中断的分片开始,而不是重新上传整个文件。具体实现步骤如下:

前端

1. 文件选择与初始化

  • 文件选择:用户通过文件输入框选择要上传的文件。

  • 初始化:当用户选择文件后,触发 uploadFile 方法。该方法获取文件对象,并计算文件的总分片数 totalChunks。每个分片的大小默认为1MB。

2. 文件分片

  • 分片计算:根据文件大小和分片大小(1MB),计算出总分片数 totalChunks

  • 分片遍历:使用一个 for 循环遍历每个分片,从 currentChunk 开始,直到 totalChunks

3. 检查已上传分片

  • 检查已上传分片:在上传每个分片之前,检查 uploadedChunks 数组,如果当前分片已经上传过,则跳过该分片。

4. 上传分片

  • 创建分片:计算当前分片的起始位置 start 和结束位置 end,使用 file.slice 方法创建当前分片的 Blob 对象。

  • 创建表单数据:使用 FormData 对象封装分片数据,包括分片文件、文件名、分片索引和总分片数。

  • 发送请求:使用 axios 发送 POST 请求,将分片数据上传到服务器。

  • 更新进度:每次成功上传一个分片后,更新 uploadedChunks 数组和上传进度 percentage

5. 暂停与继续上传

  • 暂停上传:用户点击“暂停”按钮时,调用 pauseUpload 方法,将 isPaused 设置为 false,停止上传循环。

  • 记录当前分片:在暂停上传时,调用 record 方法,记录当前分片序号 currentChunk,并等待用户恢复上传。

  • 继续上传:用户点击“继续”按钮时,调用 continueUpload 方法,从服务器获取已上传的分片列表,更新 uploadedChunks 数组,重新启动上传循环。

服务端

1. 文件切片上传:

  • 客户端将大文件分割成多个小切片(chunks),每个切片通过单独的 HTTP 请求上传。

  • 每个切片上传时,客户端会发送文件名 (filename)、当前切片索引 (chunkIndex) 和总切片数 (totalChunks)。

2. 处理文件上传:

  • 服务器接收到切片上传请求后,首先检查该切片是否已经存在。如果存在,直接返回成功响应,避免重复上传。

  • 如果切片不存在,服务器会创建一个临时目录来存储该文件的所有切片。

  • 将上传的切片文件移动到临时目录中,并记录切片索引。

  • 如果当前切片是最后一个切片(即 chunkIndex === totalChunks - 1),则调用 mergeChunks 函数进行文件合并。

3. 获取已上传的切片信息:

  • 客户端可以通过 /checkUploadedchunks 接口查询某个文件已上传的切片信息。

  • 服务器会检查临时目录是否存在,并读取其中的文件列表,返回已上传的切片索引。

4. 文件合并:

  • mergeChunks 函数负责将所有切片按顺序合并成一个完整的文件。

  • 读取每个切片文件的内容,并将其追加到一个 Buffer 中。

  • 将合并后的 Buffer 写入最终的输出文件。

  • 合并完成后,删除所有临时切片文件和临时目录。

代码示例:

前端代码示例:

<template>
  <div>
    <div class="upload_con">
      <div class="upload">
        <label for="fileInput">上传文件</label>
        <input id="fileInput" type="file" @change="uploadFile" name="上传文件"></input>
      </div>
      <div class="progress" v-show="isUploading">
        <div class="btn_group">
          <el-button type="primary" plain v-show="isPaused" @click="pauseUpload">暂停</el-button>
          <el-button type="primary" plain v-show="!isPaused" @click="continueUpload">继续</el-button>
        </div>
        <div>
          <el-progress :text-inside="true" :stroke-width="20" :percentage="percentage"></el-progress>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import axios from "axios";
import {
    
     uploadFile, checkUploadedChunks } from "@/api/file";

export default {
    
    
  data() {
    
    
    return {
    
    
      percentage: 0,
      isUploading: false,
      isPaused: true,
      uploadedChunks: [],
    }
  },
  watch: {
    
    
    percentage(val, oldVal) {
    
    
      if (val > 0) {
    
    
        this.isUploading = true;
      }
      if (val == 100) {
    
    
        this.isUploading = false;
        this.$message.success('上传成功!');
      }
    }
  },
  methods: {
    
    
    // 上传文件按钮
    async uploadFile(event) {
    
    
      this.isPaused = true;
      // 获取文件对象
      const files = event.target.files || event.dataTransfer.files;
      this.file = files[0];
      this.fileName = this.file.name;
      this.totalChunks = Math.ceil(this.file.size / (1 * 1024 * 1024));

      this.currentChunk = 0;
      this.uploadChunks();
    },
    // 上传文件接口
    async uploadChunks() {
    
    
      // 定义每个分片的大小为 1MB
      const chunkSize = 1 * 1024 * 1024;
      let uploadedChunks = this.uploadedChunks.length;

      // 遍历所有分片
      for (let i = this.currentChunk; i < this.totalChunks; i++) {
    
    
        console.log('当前片段序号---::: ', i);
        if (this.uploadedChunks.includes(i)) {
    
    
          // console.log('this.uploadedChunks.includes(i)::: ', this.uploadedChunks.includes(i));
          continue; // 跳过已上传的分片
        }

        if (!this.isPaused) {
    
    
          await this.record(i);
          return; // 暂停时退出循环
        }

        // 计算当前分片的起始位置
        const start = i * chunkSize;
        // 计算当前分片的结束位置
        const end = Math.min(start + chunkSize, this.file.size);
        // 创建当前分片的 Blob 对象
        const blob = this.file.slice(start, end);

        // 创建表单数据对象
        const formData = new FormData();
        // 添加当前分片的文件
        formData.append('file', blob, `${
      
      this.fileName}_${
      
      i}`);
        // 添加文件名
        formData.append('filename', this.fileName);
        // 添加分片索引
        formData.append('chunkIndex', i.toString());
        // 添加总分片数
        formData.append('totalChunks', this.totalChunks.toString());

        try {
    
    
          // 上传分片
          const response = await uploadFile(formData);
          console.log(`Chunk ${
      
      i} uploaded successfully.`);
          uploadedChunks++;
          this.currentChunk = i + 1;

          this.percentage = (uploadedChunks / this.totalChunks) * 100;
        } catch (error) {
    
    
          console.error(`Failed to upload chunk ${
      
      i}:`, error);
        }
      }
    },

    // 暂停上传
    pauseUpload() {
    
    
      this.isPaused = false;
    },
    // 暂停
    async record(currentChunk) {
    
    
      console.log('currentChunk::: ', currentChunk);
      this.currentChunk = currentChunk;
      while (!this.isPaused) {
    
    
        await new Promise(resolve => setTimeout(resolve, 1000));
      }
    },

    // 继续上传
    async continueUpload() {
    
    
      console.log('this.fileName::: ', this.fileName);
      // 获取已上传的分片
      const response = await checkUploadedChunks(this.fileName);
      console.log('response.data::: ', response.data);
      this.uploadedChunks = response.chunks || [];
      this.isPaused = true;
      this.uploadChunks();
    },
  }
}
</script>

接口

import request from "@/utils/request";


// 上传文件
export function uploadFile(data) {
    
    
  return request({
    
    
    url: "/upload",
    method: "post",
    data,
    headers: {
    
    
      'Content-Type': 'multipart/form-data',
    },
  });
}


/ 获取已上传的分片
export function checkUploadedChunks(fileName) {
    
    
  return request({
    
    
    url: `/checkUploadedchunks?fileName=${
      
      fileName}`,
    method: "get",
  });
}

实现效果
在这里插入图片描述

后端代码示例:

const express = require('express');
const multer = require('multer');
const fs = require('fs');
const path = require('path');
const util = require('util');
const {
    
     code, message } = require('statuses');

const app = express();
const upload = multer({
    
     dest: 'uploads/' });
// 定义文件夹路径
const mergedDir = path.join(__dirname, 'merged');

// 设置静态文件夹
app.use(express.static('uploads'));

// 将 fs 方法转换为 Promise 版本
const mkdir = util.promisify(fs.mkdir);
const rename = util.promisify(fs.rename);
const unlink = util.promisify(fs.unlink);
const readFile = util.promisify(fs.readFile);
const writeFile = util.promisify(fs.writeFile);
const rmdir = util.promisify(fs.rmdir);

// 文件合并函数
async function mergeChunks(filename, totalChunks) {
    
    
    // 定义存储切片临时文件夹路径
    const tempDir = `uploads/${
      
      filename}/`;
    // 定义最终合并文件的路径
    const outputFilePath = `merged/${
      
      filename}`;

    // 创建输出目录
    await mkdir(path.dirname(outputFilePath), {
    
     recursive: true });

    // 初始化一个空的 Buffer 用于存储合并后的数据
    let combinedData = Buffer.alloc(0);

    // 遍历所有切片文件并读取内容
    for (let i = 0; i < totalChunks; i++) {
    
    
        // 获取每个切片文件的路径
        const chunkPath = `${
      
      tempDir}${
      
      i}`;
        // 读取当前切片文件的内容
        const chunkData = await readFile(chunkPath);

        // 合并切片文件的内容追加到 combinedData 中
        combinedData = Buffer.concat([combinedData, chunkData]);
    }

     // 将合并后的数据写入最终的输出文件
    await writeFile(outputFilePath, combinedData);

    console.log('File merged successfully.');

    // 删除临时切片文件
    for (let i = 0; i < totalChunks; i++) {
    
    
        const chunkPath = `${
      
      tempDir}${
      
      i}`;
        try {
    
    
            await unlink(chunkPath);
        } catch (err) {
    
    
            console.error(`Error deleting chunk ${
      
      i}:`, err);
        }
    }

    // 删除临时文件夹
    try {
    
    
        await rmdir(tempDir, {
    
     recursive: true });
        console.log('Temporary directory deleted successfully.');
    } catch (err) {
    
    
        console.error('Error deleting temporary directory:', err);
    }
}

// 处理文件上传
app.post('/upload', upload.single('file'), async (req, res) => {
    
    
    const {
    
     filename, chunkIndex, totalChunks } = req.body;
    const chunkPath = `uploads/${
      
      filename}/${
      
      chunkIndex}`;

    try {
    
    
         // 检查切片是否已经存在
         const fileExists = await new Promise((resolve, reject) => {
    
    
            fs.access(chunkPath, fs.constants.F_OK, (err) => {
    
    
                resolve(!err);
            });
        });

        if (fileExists) {
    
    
            console.log(`Chunk ${
      
      chunkIndex} already exists`);
            res.status(200).send('Chunk already exists');
            return;
        }

        // 创建文件切片目录
        await mkdir(path.dirname(chunkPath), {
    
     recursive: true });

        // 移动上传的文件到切片目录
        await rename(req.file.path, chunkPath);

        console.log(`Chunk ${
      
      chunkIndex} saved successfully`);

        // 如果这是最后一个切片,则合并所有切片
        if (parseInt(chunkIndex) === parseInt(totalChunks) - 1) {
    
    
            await mergeChunks(filename, totalChunks);
        }

        res.status(200).send('Chunk received');
    } catch (err) {
    
    
        console.error(`Error handling chunk ${
      
      chunkIndex}:`, err);
        res.status(500).send('Internal Server Error');
    }
});

// 获取已上传的切片信息
app.get('/checkUploadedchunks', (req, res) => {
    
    
    const {
    
     fileName } = req.query;
    // 获取上传的临时文件夹路径
    const tempDir = `uploads/${
      
      fileName}/`;
    console.log('tempDir::: ', tempDir);
	 // 检查临时文件夹是否存在
    fs.access(tempDir, fs.constants.F_OK, (err) => {
    
    
        if (err) {
    
    
            return res.status(404).json({
    
     chunks: [], message: '临时文件夹不存在' });
        }
		// 读取临时文件夹中的文件列表
        fs.readdir(tempDir, (err, files) => {
    
    
            if (err) {
    
    
                return res.status(500).json({
    
     error: '无法读取临时文件夹' });
            }
			 // 将文件名转换为整数,并按升序排序
            const uploadedChunks = files.map(file => parseInt(file)).sort((a, b) => a - b);
            res.status(200).json({
    
     chunks: uploadedChunks, message: '获取已上传切片成功' });
        });
    });
});


// 启动服务器
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
    
    
    console.log(`Server is running on port ${
      
      PORT}`);
});

文件结构如下
在这里插入图片描述

后端

总结

断点续传技术通过将大文件分割成多个小分片,逐个上传,并在上传过程中记录已上传的分片信息。当上传中断时,可以从最后一个已上传的分片继续上传,避免了重新上传整个文件的问题。前端通过文件选择、分片处理、暂停与继续上传等逻辑实现断点续传功能,而后端则负责接收分片、保存分片和提供已上传分片的查询接口。这种技术在实际应用中可以显著提高文件上传的可靠性和用户体验。

猜你喜欢

转载自blog.csdn.net/a123456234/article/details/143169576