前端处理大文件上传

在常规的应用场景中,很少需要在浏览器上传几百兆、几千兆的文件,但假如在特殊场景中需要浏览器上传超大文件,那么我们如何上传?如何优化?

下面的示例全部由 vueJs 和 nodeJs 来完成

做这个之前,首先要考虑的几个问题

总体来说,主要还是因为上传时间太长容易引发不可控的意外:

  • 网络波动无法控制
  • 想暂停,如何续传

先来一个简单上传

前端代码:

<!-- App.vue 页面模板 -->
<template>
 <div>
   <input type="file" @change="uploadFile">
 </div>
</template>
复制代码
// axios/index.js axios 请求
import Axios from 'axios'

const Server = Axios.create({
 baseURL: '/api'
})

export default Server

// main.js
import Vue from 'vue'
import App from './App.vue'

import Axios from './axios'

Vue.config.productionTip = false
Vue.prototype.$http = Axios

new Vue({
 render: h => h(App),
}).$mount('#app')
复制代码
// App.vue js
<script>
export default {
  methods: {
    // input改变事件监听
    uploadFile(e) {
      const file = e.target.files[0]
      this.sendFile(file)
    },
    // 文件上传方法
    sendFile(file) {
      let formdata = new FormData()
      formdata.append("file", file)

      this.$http({
        url: "/upload/file",
        method: "post",
        data: formdata,
        headers: { "Content-Type": "multipart/form-data" }
      }).then(({ data }) => {
        console.log(data, 'upload/file')
      })
    },
  }
}
</script>
复制代码

nodeJs:

const Koa = require('koa')
const router = require('koa-router')() // koa路由模块
const koaBody = require('koa-body') //解析文件上传的插件
const fs = require('fs') // nodeJs内置文件模块
const path = require('path') // nodeJs内置路径模块

const uploadPath = path.join(__dirname, 'public/uploads') // 定义文件上传目录

// 如果初始没有改文件目录,则自动创建
if (!fs.existsSync(uploadPath)) {
 fs.mkdirSync(uploadPath)
}

const app = new Koa() // 实例化

// 一些自定义的全局请求处理
app.use(async (ctx, next) => {
 console.log(`Process ${ctx.request.method} ${ctx.request.url}...`);

 if (ctx.request.method === 'OPTIONS') {
   ctx.status = 200
 }

 try {
   await next();
 } catch (err) {
   ctx.status = err.statusCode || err.status || 500
   ctx.body = {
     code: 500,
     msg: err.message
   }
 }
})

// 加载文件上传中间件
app.use(koaBody({
 multipart: true,
 formidable: {
   // keepExtensions: true, // 保持文件后缀
   uploadDir: uploadPath, // 初始指定文件存放地址,否则将会放入系统临时文件目录
   maxFileSize: 10000 * 1024 * 1024    // 设置上传文件大小最大限制,默认20M
 }
}))

// 文件上传处理
function uploadFn(ctx) {
 return new Promise((resolve, reject) => {
   const { name, path: _path } = ctx.request.files.file // 拿到上传的文件信息
   const filePath = path.join(uploadPath, name) // 重新组合文件名

   // 将临时文件重新设置文件名及地址
   fs.rename(_path, filePath, (err) => {
     if (err) {
       return reject(err)
     }
     resolve(name)
   })
 })
}

// 文件上传接口
router.post('/api/upload/file', async function uploadFile(ctx) {
 await uploadFn(ctx).then((name) => {
   ctx.body = {
     code: 0,
     url: path.join('http://localhost:3000/uploads', name),
     msg: '文件上传成功'
   }
 }).catch(err => {
   ctx.body = {
     code: -1,
     msg: '文件上传失败'
   }
 })
})

app.use(router.routes())

// 用端口3000启动服务
app.listen(3000)
复制代码

以上为一个简单的文件上传,前端用一个input元素上传文件,然后使用ajax将整个文件传输给后端,后端接收文件并保存在服务器。

image.png

image.png

对于小文件来说,这样的操作没有任何问题,但是文件一大,就会引发上述的问题。

优化

整体优化思路:

  • 既然小文件的上传处理没有问题,而大文件有不可预料的问题和体验,我们不能控制用户的网路状态和用户的想法,那么我们换个思路,可以把整个大的文件在前端分割成一个个小文件,然后再由前端将这些小文件一个个上传,等所有的小文件上传完成之后,再通知后端将这些小文件合并成大文件。如果用户在上传过程中突然断网或者想暂停,当用户再次上传之后,之前已上传的直接跳过,只上传之前没有上传的那些内容。

但是这里又会出现新的问题:

  • 如何确定不同的文件上传不会错乱
  • 大文件分割成很多个小文件,再由小文件合并成大文件时,顺序需要与分割的一样
  • 在上传之前,如何知道哪些有上传完,哪些没有上传

在尝试解决这些问题之前,我们还有一个加速上传的优化:异步并发控制

听到并发这个词,大家在脑海里的第一反应是后端服务器在同一时间对大量请求的同时响应。但是在 http 进入 1.1 版本后,浏览器处理请求的时候实现了TCP并发请求,2.0 实现了HTTP并发处理,而且现在的浏览器 http 版本基本都是 1.1 版本后的,所以我们可以利用这个机制,对于前端大量的请求,可以对请求进行并发处理。

正好大文件上传需要分割成几十上百个小文件上传请求,与前端并发控制需要的场景完美嵌合,加上并发处理,达到加速整个文件的上传。

异步并发控制

先来熟悉前端对于请求的异步并发控制的代码实现:

/**
 * 异步并发控制
 * arr {Array} 异步任务队列
 * max {Number} 允许同时执行的最大任务数
 * callback {Function} 所有任务完成之后的回调函数
 */
function sendRequest(arr, max = 5, callback) {
  let i = 0 // 数组下标
  let fetchArr = [] // 正在执行的请求

  let toFetch = () => {
    // 如果异步任务都已开始执行,剩最后一组,则结束并发控制
    if (i === arr.length) {
      return Promise.resolve()
    }

    // 执行异步任务
    let it = fetch(arr[i++])
    // 添加异步事件的完成处理
    it.then(() => {
      fetchArr.splice(fetchArr.indexOf(it), 1)
    })
    // 添加新任务
    fetchArr.push(it)

    let p = Promise.resolve()
    // 如果并发数达到最大数,则等其中一个异步任务完成再添加
    if (fetchArr.length >= max) {
      p = Promise.race(fetchArr)
    }

    // 执行递归
    return p.then(() => toFetch())
  }

  toFetch().then(() => 
    // 最后一组全部执行完再执行回调函数
    Promise.all(fetchArr).then(() => {
      callback()
    })
  )
}
复制代码

异步并发控制的原理:先创建一个任务队列,任务队列的容量可以自己设立,然后结合Promise等来监听每一个异步任务。如果队列没满就直接将新的任务放进去,然后设置一下该任务的完成的回调操作:当该任务完成时,将当前任务从任务队列中移除,并继续往任务队列中添加一个待处理的任务,这样我们可以时刻保持满队列执行。直到所有待处理任务都已处理完成,最后再执行所有任务处理完成的回调函数。

image.png

上面这个截图,我设置的是最大任务队列数量是5个,上图中显示的是同一个时间点都有5个请求在执行。

唯一文件ID

我们需要确定每个文件都有一个唯一的id,这样上传到后端才能确定文件不会错乱,如何为每个文件生成一个唯一的id,我们可以使用一个现有的插件:SparkMD5。

用 SparkMD5 生成文件 id 后,在服务器上以 id 为名称生成一个文件夹来存放所有切分的小文件。

import SparkMD5 from 'spark-md5'

export default {
  methods: {
    uploadFile(e) {
      const file = e.target.files[0]
      this.createFileMd5(file).then(fileMd5 => {
        // fileMd5 为文件唯一的id,只要文件内容没变,那这id就不会变
        console.log(fileMd5, 'md5')
      })
    },
    createFileMd5(file) {
      return new Promise((resolve, reject) => {
        const spark = new SparkMD5.ArrayBuffer()
        const reader = new FileReader()
        reader.readAsArrayBuffer(file)
        
        reader.addEventListener('loadend', () => {
          const content = reader.result
          spark.append(content)
          const hash = spark.end()
          resolve(hash, content)
        })
        
        reader.addEventListener('error', function _error(err) {
          reject(err)
        })
      })
    }
  }
}
复制代码

文件切分

第一步就是需要将大文件切分成一个个小文件,然后将这些小文件依次全部上传到后端。

// 文件分割
cutBlob(fileHash, file) {
  const chunkArr = [] // 所有切片缓存数组
  const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
  const chunkNums = Math.ceil(file.size / this.chunkSize) // 切片总数

  return new Promise((resolve, reject) => {
    let startIndex = ''
    let endIndex = ''
    let contentItem = ''

    for(let i = 0; i < chunkNums; i++) {
      startIndex = i * this.chunkSize // 片段起点
      endIndex = (i + 1) * this.chunkSize // 片段尾点
      endIndex > file.size && (endIndex = file.size)

      // 切割文件
      contentItem = blobSlice.call(file, startIndex, endIndex)

      chunkArr.push({
        index: i, // 当前文件片段顺序索引,一并传给后端确定顺序
        chunk: contentItem // 当前文件片段内容
      })
    }

    this.fileInfo = {
      hash: fileHash,
      total: chunkNums,
      name: file.name,
      size: file.size
    }
    resolve(chunkArr)
  })
},
复制代码

小文件合并的顺序

前端将大文件切割成小文件后,在上传的时候为小文件的请求加上一个递增的 index 参数,后端接收完数据生成文件的时候将index作为文件名后缀,在合并的时候根据文件名后缀数字的顺序来合并。

// 分片文件上传接口
router.post('/api/upload/snippet', async function snippet(ctx) {
  const { index, hash } = ctx.request.body

  // 切片上传目录
  const chunksPath = path.join(uploadPath, hash, '/')

  if(!fs.existsSync(chunksPath)) {
    fs.mkdirSync(chunksPath)
  }

  // 切片文件, index作为文件名后缀用来将来合并的时候确定顺序
  const chunksFileName = chunksPath + hash + '-' + index
  
  await uploadFn(ctx, chunksFileName).then(name => {
    ctx.body = {
      code: 0,
      msg: '切片上传完成'
    }
  }).catch(err => {
    console.log(err)
    ctx.body = {
      code: -1,
      msg: '切片上传失败'
    }
  })
})
复制代码

续传

当用户在上传过程中主动暂停或者网络断了,再次上传的时候,之前已上传完成的不应该再次上传,所以我们可以在每一次开始上传之前发送一个请求来获取该文件是否有上传过小文件,如果有则返回所有已上传的小文件的 index 前缀或者文件名。这样在前端上传时就可以过滤掉已上传的小文件。

// 前端js
// 请求已上传文件
getUploadedChunks(hash) {
  return this.$http({
    url: "/upload/checkSnippet",
    method: "post",
    data: { hash }
  })
}

// nodeJs
// 查询分片文件是否上传
router.post('/api/upload/checkSnippet', function snippet(ctx) {
  const { hash } = ctx.request.body

  // 切片上传目录
  const chunksPath = path.join(uploadPath, hash, '/')

  let chunksFiles = []

  if(fs.existsSync(chunksPath)) {
    // 切片文件
    chunksFiles = fs.readdirSync(chunksPath)
  }

  ctx.body = {
    code: 0,
    data: chunksFiles,
    msg: '查询成功'
  }
})
复制代码

image.png

image.png

image.png

合并文件

当所有文件片段上传完成之后,则需要通知后端在服务器进行合并。

// 前端js
// 请求合并
chunkMerge(data) {
  this.$http({
    url: "/upload/merge",
    method: "post",
    data,
  }).then(res => {
    console.log(res.data)
  })
}

// nodeJs
// 删除文件夹及内部所有文件
function deleteFiles(dirpath) {
  if (fs.existsSync(dirpath)) {
    fs.readdir(dirpath, (err, files) => {
      if (err) throw err
      while(files.length) {
        fs.unlinkSync(dirpath + files.shift())
      }
      fs.rmdir(dirpath, () => {})
    })
  }
}

/**
 * 文件异步合并
 * @param {String} dirPath 分片文件夹
 * @param {String} filePath 目标文件
 * @param {String} hash 文件hash
 * @param {Number} total 分片文件总数
 * @returns {Promise}
 */
function mergeFile(dirPath, filePath, hash, total) {
  return new Promise((resolve, reject) => {
    fs.readdir(dirPath, (err, files) => {
      if (err) {
        return reject(err)
      }
      if(files.length !== total || !files.length) {
        return reject('上传失败,切片数量不符')
      }

      const fileWriteStream = fs.createWriteStream(filePath)
      function merge(i) {
        return new Promise((res, rej) => {
          // 合并完成
          if (i === files.length) {
            fs.rmdir(dirPath, (err) => {
              console.log(err, 'rmdir')
            })
            return res()
          }
          const chunkpath = dirPath + hash + '-' + i
          fs.readFile(chunkpath, (err, data) => {
            if (err) return rej(err)

            // 将切片追加到存储文件
            fs.appendFile(filePath, data, () => {
              // 删除切片文件
              fs.unlink(chunkpath, () => {
                // 递归合并
                res(merge(i + 1))
              })
            })
          })

        })
      }
      merge(0).then(() => {
        // 默认情况下不需要手动关闭,但是在某些文件的合并并不会自动关闭可写流,比如压缩文件,所以这里在合并完成之后,统一关闭下
        resolve(fileWriteStream.close())
      })
    })
  })
}

/**
 * 文件合并接口
 * 1、判断是否有切片hash文件夹
 * 2、判断文件夹内的文件数量是否等于total
 * 4、然后合并切片
 * 5、删除切片文件信息
 */
router.post('/api/upload/merge', async function uploadFile(ctx) {
  const { total, hash, name } = ctx.request.body
  const dirPath = path.join(uploadPath, hash, '/')
  const filePath = path.join(uploadPath, name) // 合并文件

  // 已存在文件,则表示已上传成功
  if (fs.existsSync(filePath)) {
    deleteFiles(dirPath) // 删除临时文件片段包
    ctx.body = {
      code: 0,
      url: path.join('http://localhost:3000/uploads', name),
      msg: '文件上传成功'
    }
  // 如果没有切片hash文件夹则表明上传失败
  } else if (!fs.existsSync(dirPath)) {
    ctx.body = {
      code: -1,
      msg: '文件上传失败'
    }
  } else {
    // 合并文件
    await mergeFile(dirPath, filePath, hash, total).then(() => {
      ctx.body = {
        code: 0,
        url: path.join('http://localhost:3000/uploads', name),
        msg: '文件上传成功'
      }
    }).catch(err => {
      ctx.body = {
        code: -1,
        msg: err
      }
    })
  }
})
复制代码

进阶优化

对于越大的文件计算 hash 值则越久,这是有点瑕疵的。对于这一块可以继续优化:

  • 利用 webWork 机制,在计算的时候新开一个线程单独做这件事,计算完成之后将结果返回给主线程。
    // vue中需要安装 worker-loader
    npm install worker-loader -D
    
    // App.vue js
    import Worker from './hash.worker.js'
    
    createFileMd5(file) {
      return new Promise(() => {
        const worker = new Worker()
    
        worker.postMessage({file, chunkSize: this.chunkSize})
    
        worker.onmessage = event => {
          resolve(event.data)
        }
      })
    }
    
    // hash.worker.js
    import SparkMD5 from 'spark-md5'
    
    onmessage = function(event) {
      getFileHash(event.data)
    }
    
    function getFileHash({file, chunkSize}) {
      console.log(file, chunkSize)
      const spark = new SparkMD5.ArrayBuffer()
      const reader = new FileReader()
      reader.readAsArrayBuffer(file)
    
      reader.addEventListener('loadend', () => {
        const content = reader.result
        spark.append(content)
    
        const hash = spark.end()
        postMessage(hash)
      })
    
      reader.addEventListener('error', function _error(err) {
        postMessage(err)
      })
    }
    
    复制代码
  • 采用抽样 hash 来计算,但是抽样方式会损失一定的精度,有可能将本不是一个文件生成了一个同样的 hash。
    • 抽样方式的做法是,在大文件切分一个个小片段后,我们可以分别取每个片段的开头10个字符,中间10个字符,末尾10个字符(规则自定)等方式组合生成的 id。这种方式的效率是非常快的。
    createFileMd5(file) {
      return new Promise((resolve, reject) => {
        const spark = new SparkMD5.ArrayBuffer()
        const reader = new FileReader()
        reader.readAsArrayBuffer(file)
    
        reader.addEventListener('loadend', () => {
          console.time('抽样hash计算:')
          const content = reader.result
          // 抽样hash计算
          // 规则:每半个切片大小取前10个
          let i = 0
    
          while(this.chunkSize / 2 * (i + 1) + 10 < file.size) {
            spark.append(content.slice(this.chunkSize / 2 * i, this.chunkSize / 2 * i + 10))
            i++
          }
    
          const hash = spark.end()
          console.timeEnd('抽样hash计算:')
          resolve(hash)
        })
    
        reader.addEventListener('error', function _error(err) {
          reject(err)
        })
      })
    }
    
    // 这是用一个9M大小的文件,切片大小为100K分别计算得到的时间,
    // 全部内容hash计算:: 101.6240234375 ms
    // 抽样hash计算:: 2.216796875 ms
    复制代码

完整代码

<!-- 页面代码 -->
<template>
  <div>
    <input type="file" @change="uploadFile">
  </div>
</template>
复制代码
// 前端js处理
// App.vue js
<script>
import Worker from './hash.worker.js'

export default {
  data() {
    return {
      fileInfo: null,
      chunkSize: 100 * 1024 // 切片大小
    }
  },
  methods: {
    // input改变事件监听
    uploadFile(e) {
      const file = e.target.files[0]
      // 如果文件大小大于文件分片大小的5倍才使用分片上传
      if (file.size / this.chunkSize < 5) {
        this.sendFile(file)
        return
      }
      this.createFileMd5(file).then(async fileMd5 => {
        // 先查询服务器是否已有上传完的文件切片
        let {data} = await this.getUploadedChunks(fileMd5)
        let uploaded = data.data.length ? data.data.map(v => v.split('-')[1] - 0) : []
        // 切割文件
        const chunkArr = await this.cutBlob(fileMd5, file, uploaded)
        // 开始上传
        this.sendRequest(chunkArr, 5, this.chunkMerge)
      })
    },
    createFileMd5(file) {
      return new Promise((resolve) => {
        const worker = new Worker()

        worker.postMessage({file, chunkSize: this.chunkSize})

        worker.onmessage = event => {
          resolve(event.data)
        }
      })
    },
    // 文件分割
    cutBlob(fileHash, file, uploaded) {
      const chunkArr = [] // 所有切片缓存数组
      const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
      const chunkNums = Math.ceil(file.size / this.chunkSize) // 切片总数

      return new Promise(resolve => {
        let startIndex = ''
        let endIndex = ''
        let contentItem = ''

        for(let i = 0; i < chunkNums; i++) {
          // 如果已上传则跳过
          if (uploaded.includes(i)) continue

          startIndex = i * this.chunkSize // 片段起点
          endIndex = (i + 1) * this.chunkSize // 片段尾点
          endIndex > file.size && (endIndex = file.size)

          // 切割文件
          contentItem = blobSlice.call(file, startIndex, endIndex)

          chunkArr.push({
            index: i,
            chunk: contentItem
          })
        }
        this.fileInfo = {
          hash: fileHash,
          total: chunkNums,
          name: file.name,
          size: file.size
        }
        resolve(chunkArr)
      })
    },
    // 请求并发处理
    sendRequest(arr, max = 6, callback) {
      let fetchArr = []

      let toFetch = () => {
        if (!arr.length) {
          return Promise.resolve()
        }

        const chunkItem = arr.shift()

        const it = this.sendChunk(chunkItem)
        it.then(() => {
          // 成功从任务队列中移除
          fetchArr.splice(fetchArr.indexOf(it), 1)
        }, err => {
          // 如果失败则重新放入总队列中
          arr.unshift(chunkItem)
          console.log(err)
        })
        fetchArr.push(it)

        let p = Promise.resolve()
        if (fetchArr.length >= max) {
          p = Promise.race(fetchArr)
        }

        return p.then(() => toFetch())
      }

      toFetch().then(() => {
        Promise.all(fetchArr).then(() => {
          callback()
        })
      }, err => {
        console.log(err)
      })
    },
    // 请求已上传文件
    getUploadedChunks(hash) {
      return this.$http({
        url: "/upload/checkSnippet",
        method: "post",
        data: { hash }
      })
    },
    // 小文件上传
    sendChunk(item) {
      if (!item) return
      let formdata = new FormData()
      formdata.append("file", item.chunk)
      formdata.append("index", item.index)
      formdata.append("hash", this.fileInfo.hash)
      // formdata.append("name", this.fileInfo.name)

      return this.$http({
        url: "/upload/snippet",
        method: "post",
        data: formdata,
        headers: { "Content-Type": "multipart/form-data" }
      })
    },
    // 文件上传方法
    sendFile(file) {
      let formdata = new FormData()
      formdata.append("file", file)

      this.$http({
        url: "/upload/file",
        method: "post",
        data: formdata,
        headers: { "Content-Type": "multipart/form-data" }
      }).then(({ data }) => {
        console.log(data, 'upload/file')
      })
    },
    // 请求合并
    chunkMerge() {
      this.$http({
        url: "/upload/merge",
        method: "post",
        data: this.fileInfo,
      }).then(res => {
        console.log(res.data)
      })
    }
  }
}
</script>

// hash.worker.js
import SparkMD5 from 'spark-md5'

onmessage = function(event) {
  getFileHash(event.data)
}

function getFileHash({file, chunkSize}) {
  const spark = new SparkMD5.ArrayBuffer()
  const reader = new FileReader()
  reader.readAsArrayBuffer(file)

  reader.addEventListener('loadend', () => {
    const content = reader.result
    // 抽样hash计算
    // 规则:每半个切片大小取前10个
    let i = 0

    while(chunkSize / 2 * (i + 1) + 10 < file.size) {
      spark.append(content.slice(chunkSize / 2 * i, chunkSize / 2 * i + 10))
      i++
    }

    const hash = spark.end()
    postMessage(hash)
  })

  reader.addEventListener('error', function _error(err) {
    postMessage(err)
  })
}
复制代码

NodeJs处理

const Koa = require('koa')
const router = require('koa-router')() // koa路由模块
const koaBody = require('koa-body') //解析文件上传的插件
const fs = require('fs') // nodeJs内置文件模块
const path = require('path') // nodeJs内置路径模块

const uploadPath = path.join(__dirname, 'public/uploads') // 定义文件上传目录

// 如果初始没有改文件目录,则自动创建
if (!fs.existsSync(uploadPath)) {
  fs.mkdirSync(uploadPath)
}

const app = new Koa() // 实例化

// 一些自定义的全局请求处理
app.use(async (ctx, next) => {
  console.log(`Process ${ctx.request.method} ${ctx.request.url}...`);

  if (ctx.request.method === 'OPTIONS') {
    ctx.status = 200
  }

  try {
    await next();
  } catch (err) {
    ctx.status = err.statusCode || err.status || 500
    ctx.body = {
      code: 500,
      msg: err.message
    }
  }
})

// 加载文件上传中间件
app.use(koaBody({
  multipart: true,
  formidable: {
    // keepExtensions: true, // 保持文件后缀
    uploadDir: uploadPath, // 初始指定文件存放地址,否则将会放入系统临时文件目录
    maxFileSize: 10000 * 1024 * 1024    // 设置上传文件大小最大限制,默认20M
  }
}))

// 文件上传处理
function uploadFn(ctx, destPath) {
  return new Promise((resolve, reject) => {
    const { name, path: _path } = ctx.request.files.file // 拿到上传的文件信息
    const filePath = destPath || path.join(uploadPath, name) // 重新组合文件名

    // 将临时文件重新设置文件名及地址
    fs.rename(_path, filePath, (err) => {
      if (err) {
        return reject(err)
      }
      resolve(filePath)
    })
  })
}

// 查询分片文件是否上传
router.post('/api/upload/checkSnippet', function snippet(ctx) {
  const { hash } = ctx.request.body

  // 切片上传目录
  const chunksPath = path.join(uploadPath, hash, '/')

  let chunksFiles = []

  if(fs.existsSync(chunksPath)) {
    // 切片文件
    chunksFiles = fs.readdirSync(chunksPath)
  }

  ctx.body = {
    code: 0,
    data: chunksFiles,
    msg: '查询成功'
  }
})

// 分片文件上传接口
router.post('/api/upload/snippet', async function snippet(ctx) {
  const { index, hash } = ctx.request.body

  // 切片上传目录
  const chunksPath = path.join(uploadPath, hash, '/')

  if(!fs.existsSync(chunksPath)) {
    fs.mkdirSync(chunksPath)
  }

  // 切片文件
  const chunksFileName = chunksPath + hash + '-' + index
  
  await uploadFn(ctx, chunksFileName).then(name => {
    ctx.body = {
      code: 0,
      msg: '切片上传完成',
      data: name
    }
  }).catch(err => {
    ctx.body = {
      code: -1,
      msg: '切片上传失败',
      data: err
    }
  })
})

// 文件上传接口
router.post('/api/upload/file', async function uploadFile(ctx) {
  await uploadFn(ctx).then((name) => {
    ctx.body = {
      code: 0,
      url: path.join('http://localhost:3000/uploads', name),
      msg: '文件上传成功'
    }
  }).catch(err => {
    ctx.body = {
      code: -1,
      msg: '文件上传失败'
    }
  })
})

// 删除文件夹及内部所有文件
function deleteFiles(dirpath) {
  if (fs.existsSync(dirpath)) {
    fs.readdir(dirpath, (err, files) => {
      if (err) throw err
      // 删除文件
      while(files.length) {
        fs.unlinkSync(dirpath + files.shift())
      }
      // 删除目录
      fs.rmdir(dirpath, () => {})
    })
  }
}
/**
 * 文件异步合并
 * @param {String} dirPath 分片文件夹
 * @param {String} filePath 目标文件
 * @param {String} hash 文件hash
 * @param {Number} total 分片文件总数
 * @returns {Promise}
 */
function mergeFile(dirPath, filePath, hash, total) {
  return new Promise((resolve, reject) => {
    fs.readdir(dirPath, (err, files) => {
      if (err) {
        return reject(err)
      }
      if(files.length !== total || !files.length) {
        return reject('上传失败,切片数量不符')
      }

      // 创建文件写入流
      const fileWriteStream = fs.createWriteStream(filePath)
      function merge(i) {
        return new Promise((res, rej) => {
          // 合并完成
          if (i === files.length) {
            fs.rmdir(dirPath, (err) => {
              console.log(err, 'rmdir')
            })
            return res()
          }
          const chunkpath = dirPath + hash + '-' + i
          fs.readFile(chunkpath, (err, data) => {
            if (err) return rej(err)

            // 将切片追加到存储文件
            fs.appendFile(filePath, data, () => {
              // 删除切片文件
              fs.unlink(chunkpath, () => {
                // 递归合并
                res(merge(i + 1))
              })
            })
          })

        })
      }
      merge(0).then(() => {
        // 默认情况下不需要手动关闭,但是在某些文件的合并并不会自动关闭可写流,比如压缩文件,所以这里在合并完成之后,统一关闭下
        resolve(fileWriteStream.close())
      })
    })
  })
}

/**
 * 文件合并接口
 * 1、判断是否有切片hash文件夹
 * 2、判断文件夹内的文件数量是否等于total
 * 4、然后合并切片
 * 5、删除切片文件信息
 */
router.post('/api/upload/merge', async function uploadFile(ctx) {
  const { total, hash, name } = ctx.request.body
  const dirPath = path.join(uploadPath, hash, '/')
  const filePath = path.join(uploadPath, name) // 合并文件

  // 已存在文件,则表示已上传成功
  if (fs.existsSync(filePath)) {
    // 删除所有的临时文件
    deleteFiles(dirPath)
    ctx.body = {
      code: 0,
      url: path.join('http://localhost:3000/uploads', name),
      msg: '文件上传成功'
    }
  // 如果没有切片hash文件夹则表明上传失败
  } else if (!fs.existsSync(dirPath)) {
    ctx.body = {
      code: -1,
      msg: '文件上传失败'
    }
  } else {
    // 开始合并
    await mergeFile(dirPath, filePath, hash, total).then(() => {
      ctx.body = {
        code: 0,
        url: path.join('http://localhost:3000/uploads', name),
        msg: '文件上传成功'
      }
    }).catch(err => {
      ctx.body = {
        code: -1,
        msg: err
      }
    })
  }
})

app.use(router.routes())

// 用端口3000启动服务
app.listen(3000)
复制代码

GitHub:github.com/554246839/f…

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享