大文件上传解决方案:分片上传

大文件上传技术:切片上传

随着互联网技术的发展,文件上传已成为我们日常开发中不可或缺的功能之一。特别是当上传的文件较大时,上传过程容易受到网络不稳定、超时等问题的影响。为了提升上传效率,保证上传过程的稳定性,我们通常采用大文件上传技术,特别是切片上传和断点续传技术。本文将结合前端和后端的实现方式,详细介绍大文件上传的实现过程。

一、切片上传的原理

切片上传是将大文件拆分成若干个小文件块,分批次上传,每一块文件称为一个切片。这样做有以下几个优点:

  • 减少上传失败的风险:如果某一个切片上传失败,只需要重新上传该切片,而不需要从头开始上传整个文件。
  • 提高上传效率:切片文件相对较小,可以更快地上传并避免超时。
  • 支持断点续传:切片上传能根据需要恢复上传,尤其是在网络中断的情况下。

在切片上传过程中,前端将文件分成若干个小块,并逐一上传到后端。后端收到切片后,进行保存,直到所有切片都上传完毕,再将这些切片合并成一个完整的文件。

二、前端实现大文件切片上传

前端实现大文件上传的过程可以分为以下几个步骤:

  1. 读取本地文件并做切片处理

    我们可以使用 Blob.slice() 方法将文件分成多个小块。以下是前端实现切片功能的代码:

    javascript 复制代码
    function createChunk(file, size = 5 * 1024 * 1024) {  // 默认每个切片5MB
      const chunkList = [];
      let cur = 0;
      while (cur < file.size) {
        // 使用 slice 方法对文件进行切片
        const chunk = file.slice(cur, cur + size);
        chunkList.push(chunk);
        cur += size;
      }
      return chunkList;
    }
  2. 将切片转换为 FormData 对象,并附带标识信息

    在上传每个切片时,需要附带文件名、切片名等信息,以便后端能准确接收并存储这些切片。

    javascript 复制代码
    function handleChunk(chunkList) {
      const handleChunkList = chunkList.map((chunk, index) => {
        return {
          file: chunk,
          size: chunk.size,
          chunkName: `${fileObj.file.name}-${index}`,
          fileName: fileObj.file.name,
          index
        }
      });
      // 发起请求上传切片
      uploadChunks(handleChunkList);
    }
    
    function uploadChunks(handleChunkList) {
      const formDataChunkList = handleChunkList.map(({ file, chunkName, fileName, index }) => {
        const formData = new FormData();
        formData.append('file', file);
        formData.append('fileName', fileName);
        formData.append('chunkName', chunkName);
        return { formData, index };
      });
    
      // 循环发送每个切片
      const requestList = formDataChunkList.map(({ formData, index }) => {
        return axios.post('http://localhost:3000/upload', formData);
      });
    
      Promise.all(requestList).then(res => {
        // 当所有切片上传完成后,通知后端进行合并
        axios.post('http://localhost:3000/merge', {
          size: 5 * 1024 * 1024,  // 切片大小
          fileName: fileObj.file.name
        });
      });
    }

三、后端实现切片存储与合并

后端负责接收前端发送的切片文件,并将它们保存在本地。待所有切片上传完毕后,后端会将这些切片合并成一个完整的文件。

  1. 接收并保存切片

    后端通过 multiparty 插件解析 FormData 数据,提取切片并保存。

    javascript 复制代码
    if (req.url === '/upload') {
      const form = new multiparty.Form();
      form.parse(req, async (err, fields, files) => {
        if (err) {
          console.log(err);
          return;
        }
    
        const [file] = files.file;
        const [fileName] = fields.fileName;
        const [chunkName] = fields.chunkName;
        const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`);
    
        // 判断文件夹是否存在,不存在则创建
        if (!fs.existsSync(chunkDir)) {
          await fs.mkdirs(chunkDir);
        }
    
        // 将切片文件移动到指定目录
        fs.move(file.path, `${chunkDir}/${chunkName}`);
        res.end('切片上传成功');
      });
    }
  2. 合并切片

    一旦所有切片上传完成,后端将会合并这些切片生成完整的文件。合并的过程通过创建可写流来逐个写入文件。

    javascript 复制代码
    const mergeFileChunks = async (filePath, fileName, size) => {
      const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`);
      let chunkPaths = fs.readdirSync(chunkDir);
      chunkPaths.sort((a, b) => a.split('-').pop() - b.split('-').pop());
    
      const arr = chunkPaths.map((chunkPath, index) => {
        return pipeStream(
          path.resolve(chunkDir, chunkPath),
          fs.createWriteStream(filePath, {
            start: index * size,
            end: (index + 1) * size
          })
        );
      });
      await Promise.all(arr);
    };

    通过 pipeStream 函数将切片读入并写入到目标文件中。

    javascript 复制代码
    const pipeStream = (path, writeStream) => {
      return new Promise((resolve) => {
        const readStream = fs.createReadStream(path);
        readStream.on('end', () => {
          fs.unlinkSync(path);  // 删除已合并的切片
          resolve();
        });
        readStream.pipe(writeStream);
      });
    };

四、完整代码

1.前端代码

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
<body>
  <input type="file" id="input">
  <button id="btn">上传</button>

  <script>
    const input = document.getElementById('input');
    const btn = document.getElementById('btn');
    const fileObj= {
      file: null
    }
    input.addEventListener('change', function(e) {
      const [ file ] = e.target.files;
      if (!file) return;
      fileObj.file = file;
    })
  
    btn.addEventListener('click', function() {
      if (!fileObj.file) return;
      // 先将文件切片
      const chunkList = createChunk(fileObj.file);
      // console.log(fileObj.file);
      // console.log(chunkList);
      // 处理切片
      handleChunk(chunkList);

    })

    function createChunk(file, size = 5 * 1024 * 1024) {  // 切片
      const chunkList = [];
      let cur = 0;
      while (cur < file.size) {
        // slice 方法的参数是 开始位置和结束位置,左闭右开   //文件对象也拥有slice方法
        const chunk = file.slice(cur, cur + size);
        chunkList.push(chunk);
        cur += size;
      }
      return chunkList;
    }
  
    function handleChunk(chunkList) {  // 处理切片
      const handleChunkList = chunkList.map((chunk, index) => {
        return {
          file: chunk,
          size: chunk.size,
          chunkName: `${fileObj.file.name}-${index}`,
          fileName: fileObj.file.name,
          index
        }
      })
      console.log(handleChunkList);
      // 发请求
      uploadChunks(handleChunkList)
    }
  
    function uploadChunks(handleChunkList) { // 上传切片
      const formDataChunkList =  handleChunkList.map(({ file, chunkName, fileName, index }) => {
        const formData = new FormData();  //十六进制对象
        formData.append('file', file);
        formData.append('fileName', fileName);
        formData.append('chunkName', chunkName);
        return {formData, index}
      })
      // 将formDataChunkList 中的formData 一份一份发送给服务器
      const requestList = formDataChunkList.map(({ formData, index }) => {
        return axios.post('http://localhost:3000/upload', formData)
      })
      

      // console.log(requestList);
      Promise.all(requestList).then(res => {
        axios.post('http://localhost:3000/merge', {
          size: 5 * 1024 * 1024, 
          fileName: fileObj.file.name
        })
      })
    }
  </script>
</body>
</html>

2. 后端代码

js 复制代码
const http = require('http');
const multiparty = require('multiparty');
const path = require('path');
const fs = require('fs-extra');

const UPLOAD_DIR = path.resolve(__dirname, 'qiepian')

// 解析post请求
const resolvePost = (req) => {
  return new Promise((resolve) => {
    let chunk = ''
    req.on('data', (data) => {
      chunk += data
    })
    req.on('end', () => {
      resolve(JSON.parse(chunk))
    })
  })
}
// 合并切片
const mergeFileChunks = async(filePath, fileName, size) => {
  const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`)
  let chunkPaths = fs.readdirSync(chunkDir)
  chunkPaths.sort((a, b) => a.split('-').pop() - b.split('-').pop())
  
  const arr = chunkPaths.map((chunkPath, index) => {
    return pipeStream(
      path.resolve(chunkDir, chunkPath),
      fs.createWriteStream(filePath, {
        start: index * size,
        end: (index + 1) * size
      })
    )
  })
  await Promise.all(arr)
  
}
// pipeStream
const pipeStream = (path, writeStream) => {
  return new Promise((resolve) => {
    const readStream = fs.createReadStream(path) // 读取流
    readStream.on('end', () => {
      fs.unlinkSync(path) // 删除文件
      resolve()
    })
    readStream.pipe(writeStream)
  })
}

const server = http.createServer(async(req, res) => {
  res.setHeader('Access-Control-Allow-Origin', '*');  // 处理跨域问题
  res.setHeader('Access-Control-Allow-Headers', '*');
  if (req.method === 'OPTIONS') {
    res.writeHead(200);
    res.end();
    return;
  }

  if (req.url === '/upload') {
    const form = new multiparty.Form();
    form.parse(req, async(err, fields, files) => {   //parse读取到响应体
      // console.log(fields, files);
      if (err) {
        console.log(err)
        return 
      }

      const [ file ] = files.file
      const [ fileName ] = fields.fileName
      const [ chunkName ] = fields.chunkName
      // 保存片段
      const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`)
      // console.log(chunkDir);
      
      // 判断文件夹是否存在
      if (!fs.existsSync(chunkDir)) {
        await fs.mkdirs(chunkDir)
      }
      fs.move(file.path, `${chunkDir}/${chunkName}`)

      res.end('切片上传成功')
    })
  }

  if (req.url === '/merge') {  // 该合并某一个文件切片了
    const data = await resolvePost(req)
    const { fileName, size } = data
    const filePath = path.resolve(UPLOAD_DIR, fileName)
    // 将 path 路径对应的文件夹下的所有文件合并
    await mergeFileChunks(filePath, fileName, size)
    res.end('文件合并成功')
  }
})

server.listen(3000, () => {
    console.log('Server is running on port 3000');
});

3. 运行结果

此时我们将前后端都运行起来。

然后在前端上传文件。

此时在后端的qiepian文件夹里就能成功收到完整文件啦。

五、断点续传

这里我们再提一种优化手段:断点续传。

这是大文件上传过程中非常重要的技术,它允许在网络中断后,或者用户选择暂停后,文件上传能够从中断的地方继续上传,而不是重新开始上传。

  1. 获取已经上传的切片信息

    在前端点击"继续上传"按钮时,前端会先发起一个请求,查询哪些切片已经成功上传,哪些尚未上传。

  2. 上传未完成的切片

    前端会过滤掉已上传的切片,只上传那些未上传的部分。通过这种方式,用户可以实现从断点处恢复上传。

五、总结

切片上传通过将大文件分成多个小块,降低了上传失败的风险,提高了上传效率,同时也为断点续传提供了支持。前端和后端需要紧密配合,前端负责将文件切片并上传,后端则负责接收切片、保存文件并在所有切片上传完成后合并成完整文件。

通过这一技术,用户能够在上传大文件时获得更好的体验,避免因网络中断等问题导致的上传失败。

相关推荐
烂蜻蜓16 分钟前
深入理解 Vue 3 项目结构与运行机制
前端·javascript·vue.js
han_hanker1 小时前
一个普通的vue权限管理方案-菜单权限控制
前端·javascript·vue.js
z26373056111 小时前
springboot继承使用mybatis-plus举例相关配置,包括分页插件以及封装分页类
spring boot·后端·mybatis
老大白菜2 小时前
lunar是一款无第三方依赖的公历 python调用
前端·python
混血哲谈4 小时前
如何使用webpack预加载 CSS 中定义的资源和预加载 CSS 文件
前端·css·webpack
追逐时光者4 小时前
分享一个纯净无广、原版操作系统、开发人员工具、服务器等资源免费下载的网站
后端·github
JavaPub-rodert5 小时前
golang 的 goroutine 和 channel
开发语言·后端·golang
浪遏6 小时前
我的远程实习(二) | git 持续更新版
前端
智商不在服务器6 小时前
XSS 绕过分析:一次循环与两次循环的区别
前端·xss
MonkeyKing_sunyuhua6 小时前
npm WARN EBADENGINE required: { node: ‘>=14‘ }
前端·npm·node.js