HTML5 File API 配合 Web Worker 计算大文件 SHA3 Hash 值
Web
技术
这学期的安全学课程有个作业,内容是写一个软件实现 SHA3 Hash 值的快速计算。想一想老师这么安排,大致上也有一种推广新的密码学算法的意图。既然希望应用起来,天然跨平台的 Web 显然是一项非常具备优势的技术,想到 HTML5 有定义网页与文件系统交互的 File API 标准,而且很多浏览器已经实现,基于浏览器端,实现一个 Sha3 的在线哈希岂不是更好?
根据文档,浏览器端的 FileReader 对象提供了 readAsArrayBuffer 的方法,可以将文件的二进制内容读取到 ArrayBuffer 字节数组对象中,然后就能通过JS去操作包含文件内容的字节数组,这也让浏览器端实现文件哈希提供了可能。当然,实际上也是可以实现的,这里分为两个部分来介绍这个过程。
文件内容的读取
首先当然是想办法得到这个文件在 JavaScript 环境中的表达,浏览器 JS 环境中,文件抽象为 File 对象, 它可以通过 DOM 提供的 FileList 接口拿到通过表单文件域得到,也可以从拖放事件中拿到,下面的是通过表单的 FileList 来获得的代码。
const selectedFile = document.getElementById('input').files[0];
File 对象本身只代表文件本身和其中部分元数据,对于文件的内容,浏览器 JS 是通过 FileReader 等对象来操作(读取)的。FileReader 的用法也十分简单,需要注意的是,它是异步的API,所以需要绑定一下回调函数,然后调用 readAsArrayBuffer 让浏览器发起文件读取请求:
let reader = new FileReader();
reader.onloadend = function () {
console.log(reader.result);
}
reader.readAsArrayBuffer(selectedFile);
接下来浏览器将会通过系统调用把 selectedFile 变量所指代的 File 对象的内容读取到内存中。这里存在一个问题,载入文件的时候,JS引擎需要向内存申请一块与文件内容等大的内存空间来存放这个文件的内容,显然,在内存有限的前提下,直接读取的做法是处理不了太大的文件的。这里我们需要换一下思路。从哈希算法角度来说,哈希的过程,实际上也是把原文加上 padding 之后以一个个分组为单位来进行的,也就意味着,我们可以在输出最终结果之前,分批读取原文,输入哈希函数,最后从哈希函数的最终状态中读取结果。
立足于这个想法,只要我们在浏览器端实现文件的部分读取的话,这一套流程就能打通了,需求也得以实现。正好,在浏览器的 JS 环境中,File 对象的原型是名为 Blob 的对象,Blob 的定义是一段不可变的原始二进制数据,在浏览器JS的环境中,文件被抽象成了 Blob 所描述的一块只读的二进制数据。在 Blob 对象中,实现了一个 Blob.slice([Range]) 方法,执行这个方法返回的是一个新的 Blob 对象,这个新的 Blob 所代表的是方法执行时通过 Range 参数指定的部分,类似“分割”的感觉。File 对象继承了 Blob 的所有方法,所以同样地,我们也可以通过 File.slice() 方法实现返回一个只代表其中一部分内容的新的 File 对象。接下来,我们再使用 FileReader 来读取这个新的 File 对象,就能让浏览器底层通过系统调用读取相应 Range 的字节载入到内存中了。
回到我们分批读取文件的需求。再往上层去看的话,分批去读取文件内容参与哈希,是一个从前到后的连续读取的 pattern。这样的工作模式,抽象起来类似于流(Stream)的机制。类似我们平时在 C++ 用到的 iostream, fstream 等等,所谓的 Stream,本质上也是一种按顺序读取的机制的具体实现。这种机制在浏览器JS引擎中所对应的,是在新的 Web 标准所定义的 Stream API 标准。Node.js 的 fs 模块已经实现了这样的机制,但浏览器的 FileReader 暂时并没有提供一个用 ReadableStream 接口实现文件流的方法,鉴于此,我们可以模仿 ReadableStream 接口的工作模式,自己封装一个 类似的功能。
与 C++ 的 fstream 等不一样的是,JS的流中,传输数据的单位是 "chunk",一个 chunk 一般以若干个原子单位(也就是byte)组成,很少有一次只传输单个 byte 的情况,所以这里并不需要额外定义 buffer,但需要留意一下每个 chunk 默认的大小。关于 chunk 的具体介绍,可以参考chunk 在 WHATWG 的定义。
文件读取函数的实现,我这里是通过一个状态变量记录文件当前读取到的位置(下一次读取这个文件的偏移量),返回一个对应的闭包交给调用者处理。由于文件读取是异步操作,在闭包中,通过返回 Promise 来传递异步结果,调用时直接 await 这个闭包函数就好了。每一次调用,就返回一个 chunk。这里约定 chunk 占用 1M 大小的空间,当文件读取到最后,根据定义,最后一个 chunk 的大小为文件大小 mod chunkSize,符合我们的预期,具体的函数的代码如下。
function getStreamReader(file, chunkSize = 1024 * 1024) {
let reader = new FileReader();
let offset = 0;
return function () {
return new Promise(function (resolve, reject) {
if (offset >= file.size) return resolve(null);
reader.onloadend = function () {
resolve(reader.result);
offset = offset + chunkSize;
}
reader.onerror = reject;
reader.readAsArrayBuffer(file.slice(offset, offset + chunkSize));
});
}
}
有了 Promise,我们可以利用 async/await 特性,优雅地迭代这个“流”。
const selectedFile = document.getElementById('input').files[0];
console.log(selectedFile);
let read = getStreamReader(selectedFile);
(async () => {
let chunk;
while (chunk = await read()) {
console.log(chunk);
}
})();
