离上次更新已经有一个月了,惭愧。
最近在做一个网盘的项目,不出意外的涉及到大文件的上传,那么问题来了:如何实时的显示文件上传的进度?
问题分解
似乎是老生常谈,几年前我做过类似的功能模块(基于.NET平台),方案思路:
- 基于表单提交
- Server端根据上传文件分配标识符(GUID)并进行流式读取
- Browser端发起Ajax拉取文件上传状态
这种方案的问题是受制于文件大小(最大2G)。所谓文件上传进度的实时显示,个人觉得比较理想的方案是:
- Browser 端需要告诉Server文件的大小
- Browser 端需要能对文件分块读取
- Server 端需要根据接收到的块及文件大小计算出进度,并告知Browser端
- Browser 端在进度未完成时,继续读取分块上传
HTML5 File API
上述方案中,最大的难点在于Browser端分块读取文件。好在HTML5 File API提供了这样的接口:FileReader
使用FileReader对象,web应用程序可以异步的读取存储在用户计算机上的文件(或者原始数据缓冲)内容,可以使用File对象或者Blob对象来指定所要读取的文件或数据。其中File对象可以是来自用户在一个<input>元素上选择文件后返回的FileList对象……
有意思的是Blob接口,它只有一个方法:slice()——不难想象,它是用进行数据分块的,方法签名形如:
1 2 3 4 5 |
Blob slice( optional long long start, optional long long end, optional DOMString contentType }; |
从W3C Draft 可以看出,File 接口实际上是继承自Blob接口的,意味着File.slice(start, end) 可以返回文件的块数据,结合FileReader.readAsBinaryString方法,我们在Browser端能读取到本地文件的任意部分数据。
关于FileReader
首先,FileReader并不是每个浏览器都支持的,兼容性测试情况(很不幸,巨硬的IE又拖后腿了……:
操作系统 | Firefox | Chrome | Internet Explorer | Opera | Safari |
Windows | 支持 | 支持 | 不支持 | 支持 | 不支持 |
MAC OS X | 支持 | 支持 | N/A | 支持 | 支持 |
其次,使用readAsBinaryString的方法,需要对FileReader的 onloadend事件进行订阅处理,即读取块数据操作完成时,这个事件订阅方法将得到已读取的二进制块数据:
1 2 3 |
currentFileReader.onload = function (evnt) { console.log('Data content length: ', evnt.target.result.length); }; |
B/S通信
拿到了块数据,剩下的问题是怎么发出去,有这么些选项:AJAX,富客户端编程,WebSocket。由于网盘项目基于Node开发,我选用了Socket.IO 做为B/S两端通信的框架。
从B端开始
页面准备和引用:
1 2 3 4 5 6 7 8 |
<div> <progress id="progressBar" value="0" max="100"></progress> </div> <input type="button" id="choose-button" value="选择文件"> <input type="file" id="choose-file" class="hidden"/> </div> <script src="https://code.jquery.com/jquery-1.10.2.min.js"></script> <script src="/socket.io/socket.io.js"></script> |
浏览器兼容性测试先行:
1 2 3 4 5 6 7 8 |
if (!window.File && !window.FileReader) { alert('Your browser does not support the File API. Please use modern browser'); return; } else { var socket = io.connect(); var currentFile = null; var currentFileReader = null; } |
在用户选择了文件后,对相应事件进行处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
$('#choose-file').on('change', function () { currentFile = document.getElementById('choose-file').files[0]; if (currentFile) { currentFileReader = new FileReader(); currentFileReader.onload = function (evnt) { socket.emit('upload', { 'Name': currentFile.name, 'Segment': evnt.target.result }); }; socket.emit('start', { 'Name': currentFile.name, 'Size': currentFile.size }); } }); |
从上边的代码可以看出,socket.emit(‘start’) 是整个交互流程的开始,它告诉Server端文件信息;FileReader.onload 则按块向Server端 emit 数据。还缺一段触发 FileReader的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
socket.on('moreData', function (data) { updateProgressBar(data.percent); var position = data.position * 524288; var newFile = null; if (currentFile.slice) newFile = currentFile.slice(position, position + Math.min(524288, currentFile.size - position)); else if (currentFile.webkitSlice) newFile = currentFile.webkitSlice(position, position + Math.min(524288, currentFile.size - position)); else if (currentFile.mozSlice) newFile = currentFile.mozSlice(position, position + Math.min(524288, currentFile.size - position)); if (newFile) currentFileReader.readAsBinaryString(newFile); // trigger upload event }); |
Browser端这个moreData消息,是由Server端触发的,在收到start消息后,Server端将向Browser端发送这个moreData消息。这里需要注意的是,各家浏览器对于Blob.slice接口实现不一 (Firefox 12之前的版本上为blob.mozSlice(), Safari上为blob.webkitSlice()
上传完成的收尾工作:
1 2 3 4 5 |
socket.on('done', function (data) { delete currentFileReader; delete currentFile; updateProgressBar(100); }); |
Server端实现
首先,需要一个全局数据结构,来保存每一个上传文件的描述符(传完后从作用域删除):
1 |
var Files = {}; |
然后是Socket.IO的初始化,准备文件描述符:
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 |
var io = require('socket.io').listen(server); io.sockets.on('connection', function (socket) { //prepare for uploading socket.on('start', function (data) { var name = data.Name; var size = data.Size; var filePath = '/tmp'; var position = 0; Files[name] = { // define storage structure fileSize: size, data: '', downloaded: 0, handler: null, filePath: filePath, }; Files[name].getPercent = function () { return parseInt((this.downloaded / this.fileSize) * 100); }; Files[name].getPosition = function () { return this.downloaded / 524288; }; fs.open(Files[name].filePath, 'a', 0755, function (err, fd) { if (err) console.log('[start] file open error: ' + err.toString()); else { Files[name].handler = fd; // the file descriptor socket.emit('moreData', { 'position': position, 'percent': 0 }); } }); }); }); |
Server端收到upload消息时,并不立即写入,而是进行缓冲,以10M分批进行写入:
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 |
socket.on('upload', function (data) { var name = data.Name; var segment = data.Segment; Files[name].downloaded += segment.length; Files[name].data += segment; if (Files[name].downloaded === Files[name].fileSize) { fs.write(Files[name].handler, Files[name].data, null, 'Binary', function (err, written) { //uploading completed delete Files[name]; socket.emit('done', { file: file }); }); } else if (Files[name].data.length > 10485760) { //buffer >= 10MB fs.write(Files[name].handler, Files[name].data, null, 'Binary', function (err, Writen) { Files[name].data = ''; //reset the buffer socket.emit('moreData', { 'position': Files[name].getPosition(), 'percent': Files[name].getPercent() }); }); } else { socket.emit('moreData', { 'position': Files[name].getPosition(), 'percent': Files[name].getPercent() }); } }); |
小结
基于File API 上传方案最大的问题是兼容性,IE,你懂的… 不过,时代总是在进步,我们不能被腐朽落后绑架而裹足不前,也没准开发者和用户的力量真能让这些腐朽落后的玩意儿淡出我们的视线
我最近也在写大文件上传,也是Node作为后端,不过没有使用websocket。
也遇到一些问题,请教一下:
1. 分片传到后端,在merge之前有没有办法验证mimetype
2. Node 作为API,用它来做merge会不会有点耗IO,影响服务器性能?虽说Node是单线程,IO也是多线程实现的。
交给worker来做会不会比较好一点?
经验不够,前辈能否指点一下
1. 分片没法验证
2. 流式接口性能还好,不过大文件这种功能,还是使用第三方的对象存储性能会更好一些