大文件上传和下载

3,972 阅读4分钟

整体流程

1、选择文件,切片,计算文件hash,查询文件是否已经上传部分切片

2、如果是,则返回已上传的切片列表,对比所有切片,得到未上传的切片列表,并上传。否则上传所有切片。

4、服务器端接收到切片后,建立文件夹,以文件hash命名。切片文件存入文件夹中,以切片序号命名。

3、当所有切片成功上传,发送合并切片的请求,在服务器端合并切片,并删除hash文件夹。

文件目录:

  • routes:路由
  • static:页面和静态资源文件,上传和下载的文件放在files中
  • app.js:项目入口文件

文件切片上传

<input type="file">选择文件后会返回一个File对象数组,File类继承自Blob。所以它也有slice方法,file.slice(start,end)返回新的Blob对象,它包含有源 Blob 对象中指定范围内的数据。我们用slice方法将大文件切片,然后上传。同一个站点可以并发发送6个http请求,也就是可以同时发送6个切片,理论上应该会比直接传一个大文件快(后面发现只快一点点,但是在中途断掉后再续传就会优秀很多)。

  • 1、生成切片
function createFileChunk(file, size=CHUNK_SIZE) {
   var fileChunkList = []
   var cur = 0,i=0 
   while (cur < file.size) {
      fileChunkList.push({
         file: file.slice(cur, cur + size),
         idx: i
      })
      cur += size
      i++
   }
   return fileChunkList
}
  • 2、计算文件hash hash是文件的唯一标识,可以利用文件的hash查询文件的上传状态。生成hash用的是spark-md5库,大文件的hash计算需要分片读取文件,因为如果一次性读取文件太大,会导致页面很卡,甚至崩掉。

将读取的文件内容添加到spark-md5的hash计算中,直到文件读取完毕,最后返回最终的hash码。

function genHash(){
  return new Promise(function(resolve,reject){
     var chunkList = createFileChunk(file),
	 chunks = chunkList.length,
    	 currentChunk = 0,
    	 spark = new SparkMD5.ArrayBuffer(),
         fileReader = new FileReader()
       
    function loadNext() {
        fileReader.readAsArrayBuffer(chunkList[currentChunk].file);
    }  
    fileReader.onload = function(e){
      	spark.append(e.target.result)
      	currentChunk++
      	if (currentChunk < chunks) {
          	loadNext()
      	}else {
          	var md5 = spark.end()
          	resolve(md5)
      	}
  }
  fileReader.onerror = function (err) {
    console.warn('oops, something went wrong.')
    reject(err)
  }
    loadNext()
  })
}
  • 3、查询文件状态 得到文件hash后,从后台查询是否存在名称为hash的文件夹,如果存在,列出文件夹下所有文件,得到已上传的切片列表,如果不存在,则已上传的切片列表为空。
Ajax('POST','http://localhost:9000/checkFile',{
    name: FileMd5
}).then(function(res){
    var chunks = res.data.chunks
    ...
})

  • 4、发送切片 将已经上传的切片和所有切片作对比,得到需要上传的切片。
var chunks = res.data.chunks //服务器端已经存在的切片,以chunkList[i].idx命名
var list = chunkList //由createFileChunk(file)得到的所有切片
if(chunks.length > 0){
    var newList = []
    chunks.sort(function(a,b){return a.idx-b.idx})
    for(var i=0,j=0; i<chunkList.length; i++){
       if(chunks[j] == chunkList[i].idx){
           j++
       }else{
           newList.push(chunkList[i])
       }
    } 
    list = newList
}

将切片上传,会用到Promise.all方法,如果所有切片都上传成功,执行成功回调函数,只要有切片上传失败,则执行失败回调函数。

function addRequest(chunkList,hash){
    return chunkList.map(function(chunk){
        var formData = new FormData()
        formData.append('data', chunk.file)
        formData.append('index', chunk.idx)
        formData.append('hash', hash)
        return uploadAjax('POST','http://localhost:9000/uploadBigFile',formData,{
            progressCallback: function(loaded,total){
                 chunk.loaded = loaded //用于计算文件上传进度
            }
       })
    })
}

Promise.all(addRequest(list,FileMd5)).then(function(res){
    mergeRequest(FileMd5,fileName)
},function(err){})
  • 5、所有切片上传成功后,发送合并文件请求。在服务器端,将切片合并成文件后,删除切片文件。
function mergeRequest(hash,name){
    Ajax('POST','http://localhost:9000/mergeFile',{
         hash: hash,
         name: name
    }).then(function(res){//...})
}

服务器端

服务器端用的是koa2、koa-router。

app.js

const Koa = require('koa');
const path = require('path');
const koaBody = require('koa-body');
const static = require('koa-static');
const home = require('./routes/home.js')

const app = new Koa();
app.use(async (ctx, next) => {
  fileFilter(ctx)
  await next()
})
app.use(koaBody({ //ctx.request.body 用于获取post的参数;//ctx.query 是用于获取get请求的参数;ctx.request.files获取文件
  multipart: true, // 支持文件上传
  formidable: {
    maxFileSize: 2 * 1024 * 1024 * 1024, // 单次上传的文件最大为2G
    multipart: true 
  }
}));

app.use(static(path.join(__dirname,'./static')));

app.use(home.routes(), home.allowedMethods())

app.on("error",(err,ctx)=>{//捕获异常记录错误日志
  console.log(new Date(),":",err);
});

app.listen(9000, () => {
  console.log('server is listen in 9000');
});

function fileFilter(ctx){//文件下载设置
  const url = ctx.request.url
  const p = /^\/files\//
  if(p.test(url)){
    ctx.set('Accept-Ranges', 'bytes')
    ctx.set('Content-Disposition', 'attachment')
  }
}

上传的文件切片放在files/temp文件夹,合并完后生成的文件放在files文件夹。

  • 查询文件上传状态。files/temp文件夹中是否有文件hash命名的文件夹,如果有,则返回其中已上传的切片。
router.post('/checkFile', async (ctx) => { 
    var { name } = ctx.request.body //以hash命名
    var chunks = []
    var files = fs.readdirSync(resolveFilePathTemp(''))
    files.forEach( file => {
        if(file == name){
            var filePath = resolveFilePathTemp(file);
            var stat = fs.statSync(filePath);
            if(stat.isDirectory()){
                chunks = fs.readdirSync(resolveFilePathTemp(file))
            }
        }
    })
    ctx.body = {
        code: 400001,
        data: {
            chunks: chunks
        }
    }
});
  • 接收切片。以文件hash查询文件夹,如果不存在则创建hash文件夹,然后往该文件夹中存入切片。
router.post('/uploadBigFile', async (ctx) => { 
    var { index, hash } = ctx.request.body
    var file = ctx.request.files.data
    await saveFragmentFile(file,hash,index)
    ctx.body = {
        code: 400001,
        message: '上传成功'
    }
});
//处理分片上传文件
async function saveFragmentFile(file,hash,index){
    return new Promise((resolve,reject) =>{
        var exist = fs.existsSync(resolveFilePathTemp(hash))
        if(!exist){
            fs.mkdirSync(resolveFilePathTemp(hash))
        }
        var writeFilePath = resolveFilePathTemp(`${hash}/${index}`)
        readStream = fs.createReadStream(file.path);
        writeStream = fs.createWriteStream(writeFilePath);
        readStream.pipe(writeStream);
        readStream.on("end",() => {
            resolve()
        })
        readStream.on("error",() => {
            reject()
        })
    })
};

  • 合并切片。由hash找到需要合并的文件夹,其中文件可能不是按照切片序号先后到达的,所以先按文件名排序,然后依次写入目标文件。合并完后将hash文件夹删除。
router.post('/mergeFile', async (ctx) => { 
    var { hash,name } = ctx.request.body
    var files = fs.readdirSync(resolveFilePathTemp(hash)).sort(function(a,b){return a-b})
    var dirs = files.map((item) => {
        return resolveFilePathTemp(`${hash}/${item}`)
    })
    await mergeFile(dirs, resolveFilePath(name))
    deleteDir(resolveFilePathTemp(hash))
    ctx.body = {
        code: 400001,
        message: '上传成功'
    }
});
//合并文件
async function mergeFile(dirs, writePath){
    const fileWriteStream = fs.createWriteStream(writePath);
    return new Promise((resolve) => {
        mergeFileRecursive(dirs, fileWriteStream,resolve)
    })
}
function mergeFileRecursive(dirs, fileWriteStream,resolve){
    if (!dirs.length) {
        fileWriteStream.end()
        resolve()
        return 
    }
    const currentFile = dirs.shift()
    const currentReadStream = fs.createReadStream(currentFile)

    currentReadStream.pipe(fileWriteStream, { end: false });
    currentReadStream.on('end', function() {
        mergeFileRecursive(dirs, fileWriteStream,resolve);
    });
}

上传进度

因为多个切片并发上传,所以监听每一个切片的xhr.upload.onprogress不能直接得到整个文件的上传进度。这里,我们监听每一个切片的xhr.upload.onprogress,将每个切片已上传的部分,为每个切片所属的对象添加loaded属性。然后每隔一秒读取每个切片对象这个属性,求和,再除以整个文件的大小,得到上传进度。

var interval
function setProgress(){
    interval = setInterval(function(){
        var loadedSum = 0
        chunkList.forEach(item => {
           if(item.loaded){
               loadedSum += item.loaded
           }
        });
        var percent = loadedSum/fileSize >= 1 ? 1: loadedSum/fileSize
        progressBar.innerText = (percent * 100).toFixed(2) + '%'
    },1000)
}
function clearProgress(){
    clearInterval(interval)
}

webworker生成hash

生成文件hash是比较耗时的一个操作,会引起js线程阻塞。如果是分片生成hash,那么分片文件size越大,阻塞越明显。所以我们尝试用webworker另启线程来处理。 大文件计算hash耗时比较久,所以我们在file输入框onchange事件即开始计算hash,而不是点击上传按钮再开始计算。所以如果file输入框的文件变了,就需要中止当前hash计算,重新开始。

importScripts("./spark-md5.min.js")
var fileReader = new FileReader(),
    chunkList,chunks,currentChunk,spark

function loadNext() {
    fileReader.readAsArrayBuffer(chunkList[currentChunk].file);
}
fileReader.onload = function(e){
    console.log('load')
    spark.append(e.target.result)
    currentChunk++
    if (currentChunk < chunks) {
        loadNext()
    }else {
        var md5 = spark.end()
        self.postMessage(md5)
    }
}
fileReader.onerror = function (err) {
    console.warn('oops, something went wrong.')
    self.postMessage(null);
}
fileReader.onabort = function (err) {
    console.log('abort')
}
self.addEventListener('message', function (e) {
    console.log(e.data)
    if(e.data.changeChunk){
        fileReader.abort()
    }
    chunkList = e.data.chunkList
    chunks = chunkList.length,
    currentChunk = 0,
    spark = new SparkMD5.ArrayBuffer()
    loadNext()

}, false);

下载

  • 方式一 一个url指向的资源,如果浏览器可以解析就会渲染,解析不了就直接下载,所以如果是.html/.jpg/.mp4等就会在开一个tab渲染出来,如果是.rar/.zip等文件就会直接下载。

如果需要所有资源都直接下载,不管什么媒体类型,就需要在响应头加上Content-Disposition:attachment。

app.use(async (ctx, next) => {
  fileFilter(ctx)
  await next()
})
function fileFilter(ctx){
  const url = ctx.request.url
  const p = /^\/files\//
  if(p.test(url)){
    ctx.set('Accept-Ranges', 'bytes')
    ctx.set('Content-Disposition', 'attachment')
  }
}

点击a标签就可以直接下载文件。

  • 方式二 如果用户要监听下载进度,并且在进度中做一些定制,用上面这种方法就不行了,要用ajax,然后监听xhr.onprogress事件。
var xhr = new XMLHttpRequest()
xhr.onreadystatechange = function () {
    if(xhr.readyState == 4 && xhr.status == 200){
       //xhr.response为二进制流
       var blob = new Blob([xhr.response],{type:downloadOption.fileType});
       var href = window.URL.createObjectURL(blob);
       var link = document.createElement('a');
       link.href = href;
       link.download = downloadOption.fileName;
       link.click();
       if(downloadOption.successCallback){
            downloadOption.successCallback()
       }
    }
}
xhr.onprogress = function (ev) {
    if (ev.lengthComputable && downloadOption.progressCallback) {
        downloadOption.progressCallback(ev.loaded,ev.total)
    }
}

如上所示,在后一种方式中,在下载过程中,如果不小心关了页面,下载就会停止。所以在大文件系统中,我们选择第一种方式。

项目地址: github.com/alasolala/f…