详解基于Node.js的HTTP/2 Server实践(2)

router.get(/\.(js|css)$/, async (ctx, next) => { let filePath = ctx.path; if (/\/sw-register\.js/.test(filePath)) return await next(); filePath = path.resolve('../dist', filePath.substr(1)); await next(); if (ctx.status === 200 || ctx.status === 304) { depTree.addDep(filePath, ctx.url); } });

服务器推送

Node.js最新的API文档中已经简单描述了服务器推送的写法,实现很简单:

exports.push = function(stream, file) { if (!file || !file.filePath || !file.url) return; file.fd = file.fd || fs.openSync(file.filePath, 'r'); file.headers = file.headers || getFileHeaders(file.filePath, file.fd); const pushHeaders = {[HTTP2_HEADER_PATH]: file.url}; stream.pushStream(pushHeaders, (err, pushStream) => { if (err) { logger.error('server push error'); throw err; } pushStream.respondWithFD(file.fd, file.headers); }); };

stream 代表的是当前HTTP请求的响应流, file 是一个对象,包含文件路径 filePath 与文件资源链接 url 。先使用 stream.pushStream 方法推送一个 PUSH_PROMISE 帧,然后在回调函数中调用 responseWidthFD 方法推送具体的文件内容。

以上写法简单易懂,也能立即见效。网上很多文章介绍到这里就没有了。但是如果你真的拿这样的HTTP/2服务器与普通的HTTP/1.x服务器做比较的话,你会发现现实并没有你想象的那么美好,尽管HTTP/2理论上能够加快传输效率,但是HTTP/1.x总共传输的数据明显比HTTP/2要小得多。最终两者相比较起来其实还是HTTP/1.x更快。

Why?

答案就在于资源压缩(gzip/deflate)上,基于Koa的服务器能够很轻松的用上 koa-compress 这个中间件来对文本等静态资源进行压缩,然而尽管Koa的洋葱模型能够保证所有的HTTP返回的文件数据流经这个中间件,却对于服务器推送的资源来说鞭长莫及。这样造成的后果是,客户端主动请求的资源都经过了必要的压缩处理,然而服务器主动推送的资源却都是一些未压缩过的数据。也就是说,你的服务器推送资源越大,不必要的流量浪费也就越大。新的服务器推送的特性反而变成了负优化。

因此,为了尽可能的加快服务器数据传输的速度,我们只有在上方 push 函数中手动对文件进行压缩。改造后的代码如下,以gzip为例。

exports.push = function(stream, file) { if (!file || !file.filePath || !file.url) return; file.fd = file.fd || fs.openSync(file.filePath, 'r'); file.headers = file.headers || getFileHeaders(file.filePath, file.fd); const pushHeaders = {[HTTP2_HEADER_PATH]: file.url}; stream.pushStream(pushHeaders, (err, pushStream) => { if (err) { logger.error('server push error'); throw err; } if (shouldCompress()) { const header = Object.assign({}, file.headers); header['content-encoding'] = "gzip"; delete header['content-length']; pushStream.respond(header); const fileStream = fs.createReadStream(null, {fd: file.fd}); const compressTransformer = zlib.createGzip(compressOptions); fileStream.pipe(compressTransformer).pipe(pushStream); } else { pushStream.respondWithFD(file.fd, file.headers); } }); };

我们通过 shouldCompress 函数判断当前资源是否需要进行压缩,然后调用 pushStream.response(header) 先返回当前资源的 header 帧,再基于流的方式来高效返回文件内容:

获取当前文件的读取流 fileStream

基于 zlib 创建一个可以动态gzip压缩的变换流 compressTransformer

将这些流依次通过管道( pipe )传到最终的服务器推送流 pushStream 中

Bug

经过上述改造,同样的请求HTTP/2服务器与HTTP/1.x服务器的返回总体资源大小基本保持了一致。在Chrome中能够顺畅打开。然而进一步使用Safari测试时却返回HTTP 401错误,另外打开服务端日志也能发现存在一些红色的异常报错。

经过一段时间的琢磨,我最终发现了问题所在:因为服务器推送的推送流是一个特殊的可中断流,当客户端发现当前推送的资源目前不需要或者本地已有缓存的版本,就会给服务器发送 RST 帧,用来要求服务器中断掉当前资源的推送。服务器收到该帧之后就会立即把当前的推送流( pushStream )设置为关闭状态,然而普通的可读流都是不可中断的,包括上述代码中通过管道连接到它的文件读取流( fileStream ),因此服务器日志里的报错就来源于此。另一方面对于浏览器具体实现而言,W3C标准里并没有严格规定客户端这种情况应该如何处理,因此才出现了继续默默接收后续资源的Chrome派与直接激进报错的Safari派。

解决办法很简单,在上述代码中插入一段手动中断可读流的逻辑即可。

//... fileStream.pipe(compressTransformer).pipe(pushStream); pushStream.on('close', () => fileStream.destroy()); //...

即监听推送流的关闭事件,手动撤销文件读取流。

最后

本项目代码开源在Github上,如果觉得对你有帮助希望能给我点个Star。

内容版权声明:除非注明,否则皆为本站原创文章。

转载注明出处:http://www.heiqu.com/3c483a4a34aea9a453d3ace4e27b9089.html