大文件分块上传的前后端实现
在实际项目中开发中,文件上传是大多数项目中的强需求,尤其是大文件的上传,比如上传一个视频到某网站或者云盘应用中,由于网络等因素经常会导致上传过程中断,不得不重新上传。虽然刘欢老师的歌曲里唱的不错,“只不过是从头再来”,但是在实际应用中我们都是通过文件分块上传以及断点续传功能来避免在上传过程中发生中断后不得不从头再来的尴尬。
本文详细介绍了大文件分块上传的全部流程,包括前端需要的预备知识以及后端的完整实现,所以本文包括两大部分:大文件分块读取以及断点续读的纯前端简易实现和大文件分块上传的前后端完整实现。本文涉及到相关技术有 HTML5 File API、HTML5 FormData、ES6/7的promise和async/await、Koa、Koa-Router以及Nodejs中的FileSystem、child_process 的相关模块。本文所有的源代码都托管在GitHub,欲看详情请猛戳
1. 大文件分块读取以及断点续读的纯前端简易实现
谈到文件的分块读取,必然是涉及到文件相关的API,对于断点续读,则是需要在本地保存下当前的读取进度,两者配合实现分块读取和断点续读,所以首先介绍HTML5中的FileAPI。
1.1 HTML5 File API基本概述
HTML5 File API主要定义了以下数据结构:
File
FileList
Blob
HTML5 File API操作文件主要分为两个大部分:选择文件和读取文件。
在选择文件之前,我们需要首先检测以下当前浏览器是否支持File API:
function isSupportFileApi() {
if(window.File && window.FileList && window.FileReader && window.Blob) {
return true;
}
return false;
}
1
2
3
4
5
6
7
functionisSupportFileApi(){
if(window.File&&window.FileList&&window.FileReader&&window.Blob){
returntrue;
}
returnfalse;
}
HTML5虽然可以让我们访问本地文件系统,但是JavaScript只能被动的读取,即:只有用户主动触发了文件读取行为,JavaScript才能访问到File API,通常的两种触发方式为:表单输入文件和拖拽文件。
在读取文件中,HTML5提供了一个FileReader的异步读取文件的接口,它主要定义了以下几个方法:
readAsBinaryString(File | Blob)
readAsText(File | Blob)
readAsDataURL(File | Blob)
readAsArrayBuffer(File | Blob)
同时FileReader还提供了以下几个事件:
onloadstart
onprogress
onload
onabort
onerror
onloadend
当调用了上述的某个方法开始读取文件后,就可以通过监听事件来获得结果和进度等。
1.2 File API的功能
File API在实际应用中主要有以下几个功能:
预览图片
预览文本文件
监控读取进度
分割文件
分段读取文件
预览图片
用FileReader的readAsDataURL方法,通过将图片数据读取成Data URI的方法,将图片展示出来,类似于Canvas中的方法
预览文本文件
用FileReader的readAsText,对于mimeType为text/plain、text/html等文件均认为是文本文件,即mimeType为text开头都可以用这个方法来预览
监控读取进度
FileReader是异步读取文件内容,可以通过onloadstart以及onprogress等事件来监听FileReader的读取进度。
分割文件和分段读取文件
一次性大文件读入内存,并不是一个很好的选择,如果文件太大,可能直接导致浏览器崩溃,更好的实现方式是通过File API的slice方法对文件进行分割后进行分段读取,这也是本文接下来的重点。
注:本部分没有贴相关的实例代码,在网上用关键字可以搜到很多实例。
1.2 大文件分块读取以及断点续读的纯前端简易实现
首先看本部分最后实现的效果如下图:
大文件分块读取以及断点续读的关键在于文件分割和读取进度的保存,本文读取进度的保存采用浏览器的localstorage来实现,本部分的关键代码如下:
分段读取
function readBlob(){
var blob;
if(file.webkitSlice){
blob = file.webkitSlice(stepSize * startPos, stepSize * (startPos + 1));
}else if(file.mozSlice){
blob = file.mozSlice(stepSize * startPos, stepSize * (startPos + 1));
}else if(file.slice){
blob = file.slice(stepSize * startPos, stepSize * (startPos + 1));
}else{
alert("浏览器不支持分段读取");
return false;
}
reader && reader.readAsArrayBuffer(blob);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
functionreadBlob(){
varblob;
if(file.webkitSlice){
blob=file.webkitSlice(stepSize *startPos,stepSize *(startPos+1));
}elseif(file.mozSlice){
blob=file.mozSlice(stepSize *startPos,stepSize *(startPos+1));
}elseif(file.slice){
blob=file.slice(stepSize *startPos,stepSize *(startPos+1));
}else{
alert("浏览器不支持分段读取");
returnfalse;
}
reader&&reader.readAsArrayBuffer(blob);
}
分块读取完成后的回调函数
function loadedCb(e){
//计算进度
var fileLoaded = startPos * stepSize + e.total,
percent = Math.floor(fileLoaded / fileSize * 100) + '%';
$(".file-progress").text(percent).css('width', percent);
//保存进度
window.localStorage.setItem(md5info, startPos);
//是否继续
if(startPos < totalStep - 1){
startPos++
setTimeout(readBlob, 2000)
//readBlob();
}else{
$(".file-progress").text('100%');
window.localStorage.removeItem(md5info);
alert("上传成功啦")
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
functionloadedCb(e){
//计算进度
varfileLoaded=startPos *stepSize+e.total,
percent=Math.floor(fileLoaded/fileSize *100)+'%';
$(".file-progress").text(percent).css('width',percent);
//保存进度
window.localStorage.setItem(md5info,startPos);
//是否继续
if(startPos<totalStep-1){
startPos++
setTimeout(readBlob,2000)
//readBlob();
}else{
$(".file-progress").text('100%');
window.localStorage.removeItem(md5info);
alert("上传成功啦")
}
}
读取过程中的停止与开始
$("body").on("click", "#stop", function(){
reader = null;
return false;
}).on("click", "#continue", function(){
reader = new FileReader();
reader.onload = function(e){
loadedCb(e);
}
startPos = parseInt(window.localStorage.getItem(md5info));
readBlob();
}).on("click", "#restart", function(){
if(!reader){
reader = new FileReader();
reader.onload = function(e){
loadedCb(e);
}
}
startPos = 0;
readBlob();
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$("body").on("click","#stop",function(){
reader=null;
returnfalse;
}).on("click","#continue",function(){
reader=newFileReader();
reader.οnlοad=function(e){
loadedCb(e);
}
startPos=parseInt(window.localStorage.getItem(md5info));
readBlob();
}).on("click","#restart",function(){
if(!reader){
reader=newFileReader();
reader.οnlοad=function(e){
loadedCb(e);
}
}
startPos=0;
readBlob();
})
本部分由于相对来说比较简单,根据上述代码就可以大体明白所有的逻辑,所以不做太多的叙述,详细的代码可以点击
2. 大文件分块上传的前后端完整实现
接下来是本文的重头戏,在这部分会看到更多的文字与代码介绍。
2.1 大文件分块上传一般思路
大文件分块上传的基本思路有以下几个步骤:
对文件内容进行MD5加密, MD5加密可以对文件进行唯一的标识, 也可以为后台进行文件完整性的校验进行比对
得到文件内容的MD5值后,首先向后台发起请求,判断当前文件是否已经上传,如果该文件已经上传过,则不再重复上传,这一点就是云盘等产品中秒传,秒传其实就是根据MD5值判断后台存储介质中已经有该文件了,只为当前用户建立一个引用或者软链即可
在上述第二步的请求中,如果当前文件没有上传过,则后台会进行判断是否上传过一部分,如果服务器上已经有部分临时分块,说明当前上传为续传,所以会给前端返回已经上传过的分块列表,本次上传只需要上传未上传过的分块即可,即:在上次断点处续传
对文件进行分块,假如一个大文件有100M,一个分块定义为1M,则分100次上传完成
根据第二三步中的返回来的已上传分块列表,对比出未上传的分块后将其上传(这里,每一个分块上传到服务器上都会形成一个临时文件)
当所有的分块上传成功后,发起合并请求,将所有临时分块合并为完整的文件
2.2 本实现基本后端服务
下面会对应上边的步骤逐一叙述每一部分的前后端实现,在具体叙述之前,首先提一下本实现所使用的后端情况。本实现的后端服务采用Koa + Koa-Router实现,由于本实现中对应异步处理采用了ES7中的async/await,所有加上了对ES新特性的babel转换,具体代码如下:
Koa + Koa-Router
//index.js
const Koa = require("koa");
const router = require("koa-router")();
const bodyParser = require("koa-body");
const app = new Koa();
app.use(bodyParser({multipart: true}));
app.use(router.routes());
app.listen(3000);
console.log("the server is listening on port 3000")
1
2
3
4
5
6
7
8
9
10
11
//index.js
constKoa=require("koa");
constrouter=require("koa-router")();
constbodyParser=require("koa-body");
constapp=newKoa();
app.use(bodyParser({multipart:true}));
app.use(router.routes());
app.listen(3000);
console.log("the server is listening on port 3000")
babel转换
//入口文件app.js
require("babel-core/register")
require("babel-polyfill")
require("./index.js");
//babel配置文件 .babelrc
{
"presets": [
"stage-3", "es2015"
]
}
1
2
3
4
5
6
7
8
9
10
11
12
//入口文件app.js
require("babel-core/register")
require("babel-polyfill")
require("./index.js");
//babel配置文件 .babelrc
{
"presets":[
"stage-3","es2015"
]
}
本部分的babel转换需要的模块为:
"babel-core": "^6.26.0",
"babel-polyfill": "^6.26.0",
"babel-preset-es2015": "^6.24.1",
"babel-preset-stage-3": "^6.24.1",
1
2
3
4
5
"babel-core":"^6.26.0",
"babel-polyfill":"^6.26.0",
"babel-preset-es2015":"^6.24.1",
"babel-preset-stage-3":"^6.24.1",
2.3 大文件分块上传前后端详细介绍
本小节开始按照第一小节的步骤详细介绍分块上传的前后端实现。
获取文件内容的MD5值
将文件按照设定分块大小进行分段读取,采用SparkMD5来获取文件内容的MD5,代码实现如下:
function md5File(file){
return new Promise((resolve, reject) => {
var blobSlice = File.prototype.slice || File.prototype.webkitSlice || File.prototype.mozSlice,
chunkSize = file.size / 100,
chunks = 100,
currentChunk = 0,
spark = new SparkMD5.ArrayBuffer(),
fileReader = new FileReader();
fileReader.onload = function(e){
spark.append(this.result);
currentChunk++;
if(currentChunk < chunks){
loadNext();
}else{
resolve(spark.end());
}
}
function loadNext(){
let start = currentChunk * chunkSize,
end = ((start + chunkSize) >= file.size) ? file.size : (start + chunkSize);
fileReader.readAsArrayBuffer(blobSlice.apply(file, [start, end]));
}
loadNext();
})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
functionmd5File(file){
returnnewPromise((resolve,reject)=>{
varblobSlice=File.prototype.slice||File.prototype.webkitSlice||File.prototype.mozSlice,
chunkSize=file.size/100,
chunks=100,
currentChunk=0,
spark=newSparkMD5.ArrayBuffer(),
fileReader=newFileReader();
fileReader.οnlοad=function(e){
spark.append(this.result);
currentChunk++;
if(currentChunk<chunks){
loadNext();
}else{
resolve(spark.end());
}
}
functionloadNext(){
letstart=currentChunk *chunkSize,
end=((start+chunkSize)>=file.size)?file.size:(start+chunkSize);
fileReader.readAsArrayBuffer(blobSlice.apply(file,[start,end]));
}
loadNext();
})
}
根据MD5值发起请求
根据MD5值发起请求,并判断当前文件是否已被上传,如果已经被上传,则返回结果;如果未被上传,则返回已上传的分块列表数组,如果一块都没有传过,则返回一个空数组。
本部分的前端只是发起请求:
function checkFile(fileName, fileMd5Value){
return new Promise((resolve, reject) => {
let url = `${baseUrl}/checkfile?fileName=${fileName}&fileMd5Value=${fileMd5Value}`;
$.get(url, data => {
resolve(data);
})
})
}
1
2
3
4
5
6
7
8
9
functioncheckFile(fileName,fileMd5Value){
returnnewPromise((resolve,reject)=>{
leturl=`${baseUrl}/checkfile?fileName=${fileName}&fileMd5Value=${fileMd5Value}`;
$.get(url,data=>{
resolve(data);
})
})
}
对应的后端接口服务根据传过来的文件名和文件MD5值,去做出相关判断
router.get('/checkfile', async function(ctx){
let fileName = ctx.query.fileName, fileMd5Value = ctx.query.fileMd5Value;
await Utils.getChunkList(path.join(uploadDir, fileName), path.join(__dirname, uploadDir, fileMd5Value),
data => {
ctx.response.body = data;
}
)
})
1
2
3
4
5
6
7
8
9
10
router.get('/checkfile',asyncfunction(ctx){
letfileName=ctx.query.fileName,fileMd5Value=ctx.query.fileMd5Value;
awaitUtils.getChunkList(path.join(uploadDir,fileName),path.join(__dirname,uploadDir,fileMd5Value),
data=>{
ctx.response.body=data;
}
)
})
上传未上传过的分块
根据上边请求的返回结果,可以得到当前文件是否传过,如果服务器中已经有了一份MD5值相同的文件,则秒传成功;如果没有,则获得了当前已传分块的列表,只需要对未传分块进行上传即可。
async function checkAndUploadChunk(fileMd5Value, chunkList){
console.log(chunkList)
chunks = Math.ceil(fileSize / chunkSize);
hasUploaded = chunkList.length;
for(let i = 0; i < chunks; i++){
let existChunk = chunkList.indexOf(i + "") > -1;
//存在则不再上传
if(!existChunk){
await uploadChunk(i, fileMd5Value, chunks);
hasUploaded++
//计算百分比
let percent = Math.floor((hasUploaded / chunks) * 100) + '%';
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
asyncfunctioncheckAndUploadChunk(fileMd5Value,chunkList){
console.log(chunkList)
chunks=Math.ceil(fileSize/chunkSize);
hasUploaded=chunkList.length;
for(leti=0;i<chunks;i++){
letexistChunk=chunkList.indexOf(i+"")>-1;
//存在则不再上传
if(!existChunk){
awaituploadChunk(i,fileMd5Value,chunks);
hasUploaded++
//计算百分比
letpercent=Math.floor((hasUploaded/chunks)*100)+'%';
}
}
}
分块上传
上一步中检查分块上传后调用了uploadChunk方法来进行分块上传,分块上传在前端主要为:根据已上传文件列表情况对文件进行分割,将分割后的分块上传。其中用到了HTML5的FormData新特性,采用FormData可以在程序中直接向表单一样的去操作数据。
前端请求:
function uploadChunk(i, fileMd5Value, chunks){
return new Promise((resolve, reject) => {
let end = (i + 1) * chunkSize >= file.size ? file.size : (i + 1) * chunkSize;
//构建一个表单
let form = new FormData()
form.append("data", file.slice(i * chunkSize, end));
form.append("totalChunks", chunks);
form.append("currChunk", i);
form.append("fileMd5Value", fileMd5Value);
let url = `${baseUrl}/upload`;
$.ajax({
url: url,
type: "post",
data: form,
async: true,
processData: false,
contentType:false,
success: function(data){
console.log(data);
resolve(data);
}
})
})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
functionuploadChunk(i,fileMd5Value,chunks){
returnnewPromise((resolve,reject)=>{
letend=(i+1)*chunkSize>=file.size?file.size:(i+1)*chunkSize;
//构建一个表单
letform=newFormData()
form.append("data",file.slice(i *chunkSize,end));
form.append("totalChunks",chunks);
form.append("currChunk",i);
form.append("fileMd5Value",fileMd5Value);
leturl=`${baseUrl}/upload`;
$.ajax({
url:url,
type:"post",
data:form,
async:true,
processData:false,
contentType:false,
success:function(data){
console.log(data);
resolve(data);
}
})
})
}
后端接口:
router.post('/upload', async function(ctx){
let data = ctx.request.body.fields,
currChunk = data.currChunk,
totalChunks = data.totalChunks,
fileMd5Value = data.fileMd5Value,
file = ctx.request.body.files,
folder = path.join('uploads', fileMd5Value);
let isExist = await Utils.folderIsExist(path.join(__dirname, folder));
if(isExist){
let destFile = path.join(__dirname, folder, currChunk),
srcFile = path.join(file.data.path);
await Utils.copyFile(srcFile, destFile).then(() => {
ctx.response.body = 'chunk ' + currChunk + ' upload success!!!'
}, (err) => {
console.error(err);
ctx.response.body = 'chunk ' + currChunk + ' upload failed!!!'
})
}
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
router.post('/upload',asyncfunction(ctx){
letdata=ctx.request.body.fields,
currChunk=data.currChunk,
totalChunks=data.totalChunks,
fileMd5Value=data.fileMd5Value,
file=ctx.request.body.files,
folder=path.join('uploads',fileMd5Value);
letisExist=awaitUtils.folderIsExist(path.join(__dirname,folder));
if(isExist){
letdestFile=path.join(__dirname,folder,currChunk),
srcFile=path.join(file.data.path);
awaitUtils.copyFile(srcFile,destFile).then(()=>{
ctx.response.body='chunk '+currChunk+' upload success!!!'
},(err)=>{
console.error(err);
ctx.response.body='chunk '+currChunk+' upload failed!!!'
})
}
})
在后端接口中,重点在于把分块文件从源路径拷贝到目的路径下,这里涉及到Node中的fs模块和child_process模块,本来应该使用fs模块可以完全实现,但是在实现过程中遇到了一些不明白的坑,不得已最后采用了child_process来执行shell命令完成了功能的实现。
在后端中,首先声明了一个文件夹变量,将所有的临时文件都存在该文件夹中的以文件md5值命名的子文件夹中,有点绕口,写出来就是临时文件存在了__dirname/uploads/77aef65d958b34b2e8043db530213daf中,在新建文件夹过程中可能会遇到权限问题,本实现在新建时将文件夹权限提前设置为777,以方便读写操作。
child_process.exec("chmod 777 " + __dirname, () => {
console.log(folder)
child_process.exec("mkdir -p " + folder, function(err){
if(err){
resolve(false);
}else{
resolve(true);
}
})
});
1
2
3
4
5
6
7
8
9
10
11
child_process.exec("chmod 777 "+__dirname,()=>{
console.log(folder)
child_process.exec("mkdir -p "+folder,function(err){
if(err){
resolve(false);
}else{
resolve(true);
}
})
});
合并文件
当所有的分块上传后,则前端可以发起合并临时文件的请求,后端收到请求后将特定目录中的临时文件合并为目标文件。合并操作可以通过fs模块的writeStream和readStream方法,本实现直接使用第三方模块concat-file。
前端请求:
function mergeChunk(fileMd5Value){
let url = `${baseUrl}/mergeChunk?md5=${fileMd5Value}&fileName=${file.name}&size=${file.size}`;
$.get(url, function(data){
alert('上传成功');
})
}
1
2
3
4
5
6
7
functionmergeChunk(fileMd5Value){
leturl=`${baseUrl}/mergeChunk?md5=${fileMd5Value}&fileName=${file.name}&size=${file.size}`;
$.get(url,function(data){
alert('上传成功');
})
}
后端接口:
router.get("/mergeChunk", async function(ctx){
let md5 = ctx.query.md5,
fileName = ctx.query.fileName,
size = ctx.query.size;
await Utils.mergeFiles(path.join(__dirname, uploadDir, md5),
path.join(__dirname, uploadDir),
fileName, size)
ctx.response.body = "success";
})
1
2
3
4
5
6
7
8
9
10
11
12
router.get("/mergeChunk",asyncfunction(ctx){
letmd5=ctx.query.md5,
fileName=ctx.query.fileName,
size=ctx.query.size;
awaitUtils.mergeFiles(path.join(__dirname,uploadDir,md5),
path.join(__dirname,uploadDir),
fileName,size)
ctx.response.body="success";
})
到此完整的大文件分块上传已全部实现,在上述后端接口中,调用的一些比如判断路径、拷贝文件、合并文件等都单独抽出来一个utils.js的文件,这里只列举一个工具函数,其余的可以在源代码中查看,猛戳
//判断文件或者文件夹是否存在
function isExist(filePath){
return new Promise((resolve, reject) => {
fs.stat(filePath, (err, stats) => {
if(err && err.code === 'ENOENT'){
resolve(false);
}else{
resolve(true);
}
})
})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
//判断文件或者文件夹是否存在
functionisExist(filePath){
returnnewPromise((resolve,reject)=>{
fs.stat(filePath,(err,stats)=>{
if(err&&err.code==='ENOENT'){
resolve(false);
}else{
resolve(true);
}
})
})
}
2.4 结果展示
本实现的最后成果如下图展示:
为了展示分块和续传,在上个结果之后,删除掉了合并后的文件和其中部分临时文件,然后重新上传,结果如下:
3. 结束语
还是一句老话,生命在于折腾,周末昏天暗地的码了一天也算是比较完整的实现了可以称为解决方案的东东。在整个实现中也踩了不少的坑,最大的坑就是对于异步处理的控制,刚开始几次对于async/await的使用总是忘记,导致后端明明已经收到了请求,但是前端还是404 。完成一篇博文越来越感觉到不容易,如果走心的完成更是不容易,虽然是对自己学习和工作的记录,但是也得付出200%的用心。在整个实现过程中,《童年的回忆》钢琴曲被我玩坏了,这也是我一直以来最喜欢的曲子,摆出来和大家一起欣赏。
全篇完
如果您觉得这篇文章对您有帮助,就给楼主点支持与鼓励吧!
打赏
长按识别或者微信扫码,打赏作者吧~