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


Posted in Javascript onMay 31, 2018

虽然HTTP/2目前已经逐渐的在各大网站上开始了使用,但是在目前最新的Node.js上仍然处于实验性API,还没有能有效解决生产环境各种问题的应用示例。因此在应用HTTP/2的道路上我自己也遇到了许多坑,下面介绍了项目的主要架构与开发中遇到的问题及解决方式,也许会对你有一点点启示。

配置

虽然W3C的规范中没有规定HTTP/2协议一定要使用ssl加密,但是支持非加密的HTTP/2协议的浏览器实在少的可怜,因此我们有必要申请一个自己的域名和一个ssl证书。

本项目的测试域名是 you.keyin.me ,首先我们去域名提供商那把测试服务器的地址绑定到这个域名上。然后使用Let's Encrypt生成一个免费的SSL证书:

sudo certbot certonly --standalone -d you.keyin.me

输入必要信息并通过验证之后就可以在 /etc/letsencrypt/live/you.keyin.me/ 下面找到生成的证书了。

改造Koa

Koa是一个非常简洁高效的Node.js服务器框架,我们可以简单改造一下来让它支持HTTP/2协议:

class KoaOnHttps extends Koa {
 constructor() {
  super();
 }
 get options() {
  return {
   key: fs.readFileSync(require.resolve('/etc/letsencrypt/live/you.keyin.me/privkey.pem')),
   cert: fs.readFileSync(require.resolve('/etc/letsencrypt/live/you.keyin.me/fullchain.pem'))
  };
 }
 listen(...args) {
  const server = http2.createSecureServer(this.options, this.callback());
  return server.listen(...args);
 }
 redirect(...args) {
  const server = http.createServer(this.callback());
  return server.listen(...args);
 }
}

const app = new KoaOnHttps();
app.use(sslify());
//...
app.listen(443, () => {
logger.ok('app start at:', `https://you.keyin.cn`);
});

// receive all the http request, redirect them to https
app.redirect(80, () => {
logger.ok('http redirect server start at', `http://you.keyin.me`);
});

上述代码简单基于Koa生成了一个HTTP/2服务器,并同时监听80端口,通过sslify中间件的帮助自动将http协议的连接重定向到https协议。

静态文件中间件

静态文件中间件主要用来返回url所指向的本地静态资源。在http/2服务器中我们可以在访问html资源的时候通过服务器推送(Server push)将该页面所依赖的js\css\font等资源一起推送回去。具体代码如下:

const send = require('koa-send');
const logger = require('../util/logger');
const { push, acceptsHtml } = require('../util/helper');
const depTree = require('../util/depTree');
module.exports = (root = '') => {
 return async function serve(ctx, next) {
  let done = false;
  if (ctx.method === 'HEAD' || ctx.method === 'GET') {
   try {
    // 当希望收到html时,推送额外资源。
    if (/(\.html|\/[\w-]*)$/.test(ctx.path)) {
     depTree.currentKey = ctx.path;
     const encoding = ctx.acceptsEncodings('gzip', 'deflate', 'identity');
     // server push
     for (const file of depTree.getDep()) {
      // server push must before response!
      // https://huangxuan.me/2017/07/12/upgrading-eleme-to-pwa/#fast-skeleton-painting-with-settimeout-hack
      push(ctx.res.stream, file, encoding);
     }
    }
    done = await send(ctx, ctx.path, { root });
   } catch (err) {
    if (err.status !== 404) {
     logger.error(err);
     throw err;
    }
   }
  }
  if (!done) {
   await next();
  }
 };
};

需要注意的是,推送的发生永远要先于当前页面的返回。否则服务器推送与客户端请求可能就会出现竞争的情况,降低传输效率。

依赖记录

从静态文件中间件代码中我们可以看到,服务器推送资源取自depTree这个对象,它是一个依赖记录工具,记录当前页面 depTree.currentKey 所有依赖的静态资源(js,css,img...)路径。具体的实现是:

const logger = require('./logger');

const db = new Map();
let currentKey = '/';

module.exports = {
  get currentKey() {
    return currentKey;
  },
  set currentKey(key = '') {
    currentKey = this.stripDot(key);
  },
  stripDot(str) {
    if (!str) return '';
    return str.replace(/index\.html$/, '').replace(/\./g, '-');
  },
  addDep(filePath, url, key = this.currentKey) {
    if (!key) return;
    key = this.stripDot(key);
    if(!db.has(key)){
      db.set(key,new Map());
    }
    const keyDb = db.get(key);

    if (keyDb.size >= 10) {
      logger.warning('Push resource limit exceeded');
      return;
    }
    keyDb.set(filePath, url);
  },
  getDep(key = this.currentKey) {
    key = this.stripDot(key);
    const keyDb = db.get(key);
    if(keyDb == undefined) return [];
    const ret = [];
    for(const [filePath,url] of keyDb.entries()){
      ret.push({filePath,url});
    }
    return ret;
  }
};

当设置好特定的当前页 currentKey 后,调用 addDep 将方法能够为当前页面添加依赖,调用 getDep 方法能够取出当前页面的所有依赖。 addDep 方法需要写在路由中间件中,监控所有需要推送的静态文件请求得出依赖路径并记录下来:

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 帧,再基于流的方式来高效返回文件内容:

  1. 获取当前文件的读取流 fileStream
  2. 基于 zlib 创建一个可以动态gzip压缩的变换流 compressTransformer
  3. 将这些流依次通过管道( 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。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
模仿jQuery each函数的链式调用
Jul 22 Javascript
jquery让返回的内容显示在特定div里(代码少而精悍)
Jun 23 Javascript
jquery中$(#form :input)与$(#form input)的区别
Aug 18 Javascript
Web前端开发工具——bower依赖包管理工具
Mar 29 Javascript
深究AngularJS中$sce的使用
Jun 12 Javascript
JS如何设置元素样式的方法示例
Aug 28 Javascript
微信小程序用户信息encryptedData详解
Aug 24 Javascript
js中addEventListener()与removeEventListener()用法案例分析
Mar 02 Javascript
javascript 模块依赖管理的本质深入详解
Apr 30 Javascript
详解ES6新增字符串扩张方法includes()、startsWith()、endsWith()
May 12 Javascript
Node Mongoose用法详解【Mongoose使用、Schema、对象、model文档等】
May 13 Javascript
VUE和Antv G6实现在线拓扑图编辑操作
Oct 28 Javascript
jQuery基于Ajax实现读取XML数据功能示例
May 31 #jQuery
jQuery实现动态加载select下拉列表项功能示例
May 31 #jQuery
vuejs实现标签选项卡动态更改css样式的方法
May 31 #Javascript
Vue组件之极简的地址选择器的实现
May 31 #Javascript
node跨域转发 express+http-proxy-middleware的使用
May 31 #Javascript
Vue实现侧边菜单栏手风琴效果实例代码
May 31 #Javascript
Node.js笔记之process模块解读
May 31 #Javascript
You might like
在线竞拍系统的PHP实现框架(一)
2006/10/09 PHP
PHP实现域名whois查询的代码(数据源万网、新网)
2010/02/22 PHP
php中字符查找函数strpos、strrchr与strpbrk用法
2014/11/18 PHP
Jquery 设置标题的自动翻转
2009/10/03 Javascript
javascript 模式设计之工厂模式学习心得
2010/04/27 Javascript
javascript 从if else 到 switch case 再到抽象
2010/07/17 Javascript
jQuery 源码分析笔记(6) jQuery.data
2011/06/08 Javascript
javascript 获取网页标题代码实例
2014/01/22 Javascript
页面加载完毕后滚动条自动滚动一定位置
2014/02/20 Javascript
jQuery学习笔记之 Ajax操作篇(三) - 过程处理
2014/06/23 Javascript
javascript使用call调用微信API
2014/12/15 Javascript
js中判断变量类型函数typeof的用法总结
2016/08/09 Javascript
vue2.0开发实践总结之疑难篇
2016/12/07 Javascript
js实现打地鼠小游戏
2017/02/13 Javascript
js获取当前页的URL与window.location.href简单方法
2017/02/13 Javascript
jQuery插件HighCharts实现气泡图效果示例【附demo源码】
2017/03/13 Javascript
JS实现AES加密并与PHP互通的方法分析
2017/04/19 Javascript
利用babel将es6语法转es5的简单示例
2017/12/01 Javascript
bootstrap中日历范围选择插件daterangepicker的使用详解
2018/04/17 Javascript
javascript中的数据类型检测方法详解
2019/08/07 Javascript
微信小程序实现星星评分效果
2020/11/01 Javascript
[03:07]2015国际邀请赛选手档案EHOME.rOtK 是什么让他落泪?
2015/07/31 DOTA
用python代码做configure文件
2014/07/20 Python
Python实现简易Web爬虫详解
2018/01/03 Python
python实现用户管理系统
2018/01/10 Python
python写入并获取剪切板内容的实例
2018/05/31 Python
Python虚拟环境virtualenv创建及使用过程图解
2020/12/08 Python
送给他或她的礼物:FUN.com
2018/08/17 全球购物
Rosetta Stone官方网站:语言学习
2019/01/05 全球购物
家长通知书教师评语
2014/04/17 职场文书
詹天佑教学反思
2014/04/30 职场文书
医院搬迁方案
2014/06/14 职场文书
村干部四风问题整改措施
2014/09/30 职场文书
2015年信贷员工作总结
2015/04/28 职场文书
几款流行的HTML5 UI框架比较(小结)
2021/04/08 HTML / CSS
JavaScript原始值与包装对象的详细介绍
2021/05/11 Javascript