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

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

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

一、切片上传的原理

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

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

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

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

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

  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. 上传未完成的切片

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

五、总结

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

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

相关推荐
我爱娃哈哈39 分钟前
SpringBoot + Spring Security + RBAC:企业级权限模型设计与动态菜单渲染实战
spring boot·后端·spring
欣然~1 小时前
法律案例 PDF 批量转 TXT 工具代码
linux·前端·python
一个小废渣1 小时前
Flutter Web端网络请求跨域错误解决方法
前端·flutter
符文师2 小时前
css3 新特性
前端·css3
小王不爱笑1322 小时前
SpringBoot 配置文件
java·spring boot·后端
ct9782 小时前
WebGL开发
前端·gis·webgl
想用offer打牌2 小时前
Spring AI vs Spring AI Alibaba
java·人工智能·后端·spring·系统架构
C_心欲无痕3 小时前
前端页面渲染方式:CSR、SSR、SSG
前端
果粒蹬i3 小时前
生成式 AI 质量控制:幻觉抑制与 RLHF 对齐技术详解
前端·人工智能·easyui
码农幻想梦4 小时前
实验五 spring入门及IOC实验
java·后端·spring