大文件分片上传(NodeJs+Koa)

2,756 阅读3分钟

起因: 文件上传的时候,如果文件过大,可能导致请求超时的情况,这时就需要将文件分割成小块缩短单个请求传输时间

流程图

服务端依赖

koa : HTTP中间件框架

koa-router : koa路由中间件

koa-body : koabody解析中间件,用于解析post内容

fs-extra : node文件系统扩展

koa-static : koa静态资源中间件,用于处理静态资源请求

目录结构

  • index.html 带有上传功能的html页面
  • upload 存放最后合并的大文件
  • temp 临时存放分片文件
  • server.js 服务

内容实现

步骤1 - 正常上传

上传页面

<!DOCTYPE html>
<html lang="zh-CN">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
  <!-- 引入 Koa -->
  <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>

<body>
  <input type="file" id="btnFile">
  <input type="button" value="上传" onclick="upload()">
  <script>
    let btnFile = document.querySelector('#btnFile')

    function upload() {
      // 获取上传文件
      const file = btnFile.files[0]
      const formData = new FormData()
      formData.append('file', file)
      axios.post('/upload', formData).then(res => {
        console.log(res)
      })
    }
  </script>
</body>

</html>
效果图

服务端

const path = require('path')
const Koa = require('koa')
const Router = require('koa-router')
const koaBody = require('koa-body')
const source = require('koa-static')

const app = new Koa()
const router = new Router()

// 处理静态资源
app.use(source(path.resolve(__dirname, 'public')))


// 处理页面请求
app.use(koaBody({
  multipart: true,
  formidable: {
    uploadDir: path.resolve(__dirname, 'temp'),// 文件存放地址
    keepExtensions: true,
    maxFieldsSize: 2 * 1024 * 1024
  }
}))

// 文件上传
router.post('/upload', async ctx => {
  ctx.body = '文件上传成功'
})

app.use(router.routes()).use(router.allowedMethods())

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

启动服务,上传文件

效果图

temp 中获得了上传的文件

步骤2 - 分片上传

上传脚本增加分片功能

设置每片大小 -> 根据大小进行文件分割和重命名 -> 递归分片上传

<!DOCTYPE html>
<html lang="zh-CN">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
  <link type="text/css" rel="stylesheet" href="">
  <script type="text/javascript" src=""></script>
  <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>

<body>
  <input type="file" id="btnFile">
  <input type="button" value="上传" onclick="upload(0)">
  <script>
    let btnFile = document.querySelector('#btnFile')

    // 每一片大小
    const chunkSize = 1024 * 1024 * 2

    function upload(index/* 当前片下标 */) {
      // 获取上传文件
      const file = btnFile.files[0]
      // [ 文件名, 文件后缀 ]
      const [ fname, fext ] = file.name.split('.')
      // 获取当前片的起始字节
      const start = index * chunkSize
      if (start > file.size) {// 当超出文件大小,停止递归上传
        return
      }
      const blob = file.slice(start, start + chunkSize)
      // 为每片进行命名
      const blobName = `${fname}.${index}.${fext}`
      const blobFile = new File([blob], blobName)

      const formData = new FormData()
      formData.append('file', blobFile)
      axios.post('/upload', formData).then(res => {
        console.log(res)
        // 递归分片上传
        upload(++index)
      })
    }
  </script>
</body>

</html>

服务端对分片进行整理

接收分片 -> 创建大文件临时目录 -> 将分片从 temp 目录 转移到临时目录

const path = require('path')
const Koa = require('koa')
const Router = require('koa-router')
const koaBody = require('koa-body')
const fse = require('fs-extra')
const source = require('koa-static')

const app = new Koa()
const router = new Router()

// 处理静态资源
app.use(source(path.resolve(__dirname, 'public')))

// 上传文件的目录地址
const UPLOAD_DIR = path.resolve(__dirname, 'public/upload')

// 处理页面请求
app.use(koaBody({
  multipart: true,
  formidable: {
    uploadDir: path.resolve(__dirname, 'temp'),// 文件存放地址
    keepExtensions: true,
    maxFieldsSize: 2 * 1024 * 1024
  }
}))


// 文件上传
router.post('/upload', async ctx => {// 文件转移
  // koa-body 在处理完 file 后会绑定在 ctx.request.files
  const file = ctx.request.files.file
  // [ name, index, ext ] - 分割文件名
  const fileNameArr = file.name.split('.')
  // 存放切片的目录
  const chunkDir = `${UPLOAD_DIR}/${fileNameArr[0]}`
  if (!fse.existsSync(chunkDir)) {// 没有目录就创建目录
    // 创建大文件的临时目录
    await fse.mkdirs(chunkDir)
  }
  // 原文件名.index - 每个分片的具体地址和名字
  const dPath = path.join(chunkDir, fileNameArr[1])

  // 将分片文件从 temp 中移动到本次上传大文件的临时目录
  await fse.move(file.path, dPath, { overwrite: true })
  ctx.body = '文件上传成功'
})

app.use(router.routes()).use(router.allowedMethods())

app.listen(3000, () => console.log('Server runnint on port 3000'))
效果图

步骤3 - 分片整合

客户端上传结束后向服务端发出 结束-整合 信号

<!DOCTYPE html>
<html lang="zh-CN">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
  <link type="text/css" rel="stylesheet" href="">
  <script type="text/javascript" src=""></script>
  <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>

<body>
  <input type="file" id="btnFile">
  <input type="button" value="上传" onclick="upload(0)">
  <script>
    let btnFile = document.querySelector('#btnFile')

    // 区块大小
    const chunkSize = 1024 * 1024 * 2

    function upload(index) {
      // 获取上传文件
      const file = btnFile.files[0]
      const [ fname, fext ] = file.name.split('.')
      // 获取区块内容
      const start = index * chunkSize
      if (start > file.size) {// 当超出文件大小,停止递归上传
        // 请求整合
        merge(file.name)
        return
      }
      const blob = file.slice(start, start + chunkSize)
      const blobName = `${fname}.${index}.${fext}`
      const blobFile = new File([blob], blobName)

      const formData = new FormData()
      formData.append('file', blobFile)
      axios.post('/upload', formData).then(res => {
        console.log(res)
        upload(++index)
      })
    }

    function merge(name) {
      axios.post('/merge', { name: name }).then(res => {
        console.log(res)
      })
    }
  </script>
</body>

</html>

服务端收到整合请求后开始整合分片

按序读取大文件临时目录下的文件合并成一个文件 -> 删除大文件临时目录 -> 返回整合文件的地址

const path = require('path')
const Koa = require('koa')
const Router = require('koa-router')
const koaBody = require('koa-body')
const fse = require('fs-extra')
const source = require('koa-static')

const app = new Koa()
const router = new Router()

// 处理静态资源
app.use(source(path.resolve(__dirname, 'public')))

const UPLOAD_DIR = path.resolve(__dirname, 'public/upload')

// 处理页面请求
app.use(koaBody({
  multipart: true,
  // encoding: 'gzip', // 启用压缩在 /merge 会报错
  formidable: {
    uploadDir: path.resolve(__dirname, 'temp'),// 文件存放地址
    keepExtensions: true,
    maxFieldsSize: 2 * 1024 * 1024
  }
}))


// 文件上传
router.post('/upload', async ctx => {// 文件转移
  const file = ctx.request.files.file
  // [ name, index, ext ]
  const fileNameArr = file.name.split('.')
  // 存放切片的目录
  const chunkDir = `${UPLOAD_DIR}/${fileNameArr[0]}`
  if (!fse.existsSync(chunkDir)) {// 没有目录就创建目录
    await fse.mkdirs(chunkDir)
  }
  // 原文件名.index.ext
  const dPath = path.join(chunkDir, fileNameArr[1])
  await fse.move(file.path, dPath, { overwrite: true })
  ctx.body = '文件上传成功'
})

// 合并文件
router.post('/merge', async ctx => {
  const { name }= ctx.request.body
  const fname = name.split('.')[0]

  const chunkDir = path.join(UPLOAD_DIR, fname)
  const chunks = await fse.readdir(chunkDir)

  chunks.sort((a, b) => a - b).map(chunkPath => {
    // 合并文件
    fse.appendFileSync(
      path.join(UPLOAD_DIR, name),
      fse.readFileSync(`${chunkDir}/${chunkPath}`)
    )
  })
  // 删除临时文件夹
  fse.removeSync(chunkDir)
  // 返回文件地址
  ctx.body = { msg: '合并成功', url: `http://localhost:3000/upload/${name}` }
})

app.use(router.routes()).use(router.allowedMethods())

app.listen(3000, () => console.log('Server runnint on port 3000'))
效果图

访问返回地址