详解基于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 相关文章推荐
用javascript实现的仿Flash广告图片轮换效果
Apr 24 Javascript
原生js操作checkbox用document.getElementById实现
Oct 12 Javascript
JavaScript的继承的封装介绍
Oct 15 Javascript
javascript替换已有元素replaceChild()使用介绍
Apr 03 Javascript
浅谈JavaScript中setInterval和setTimeout的使用问题
Aug 01 Javascript
浅谈Sublime Text 3运行JavaScript控制台
Jun 06 Javascript
老生常谈onBlur事件与onfocus事件(js)
Jul 09 Javascript
bmob js-sdk 在vue中的使用教程
Jan 21 Javascript
js装饰设计模式学习心得
Feb 17 Javascript
解决vue+webpack打包路径的问题
Mar 06 Javascript
详解React项目的服务端渲染改造(koa2+webpack3.11)
Mar 19 Javascript
jQuery选择器之基本选择器用法实例分析
Feb 19 jQuery
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 向右侧拉菜单实现代码,测试使用中
2009/11/03 PHP
php设计模式 Singleton(单例模式)
2011/06/26 PHP
如何让搜索引擎抓取AJAX内容解决方案
2014/08/25 PHP
php连接odbc数据源并保存与查询数据的方法
2014/12/24 PHP
php使用Jpgraph绘制复杂X-Y坐标图的方法
2015/06/10 PHP
深入理解PHP中的Streams工具
2015/07/03 PHP
PHP使用GD库输出汉字的方法【测试可用】
2016/11/10 PHP
php接口实现拖拽排序功能
2018/04/23 PHP
微信公众号开发之获取位置信息php代码
2018/06/13 PHP
jquery对元素拖动排序示例
2014/01/16 Javascript
js实现跟随鼠标移动且带关闭功能的图片广告实例
2015/02/26 Javascript
JavaScript跨域调用基于JSON的RESTful API
2016/07/09 Javascript
JS HTML5实现拖拽移动列表效果
2020/08/27 Javascript
Angular 5.0 来了! 有这些大变化
2017/11/15 Javascript
vue axios 给生产环境和发布环境配置不同的接口地址(推荐)
2018/05/08 Javascript
Node+OCR实现图像文字识别功能
2018/11/26 Javascript
[01:01:14]完美世界DOTA2联赛PWL S2 SZ vs Rebirth 第一场 11.21
2020/11/23 DOTA
Python 自动安装 Rising 杀毒软件
2009/04/24 Python
python中self原理实例分析
2015/04/30 Python
详解python实现读取邮件数据并下载附件的实例
2017/08/03 Python
python+pandas分析nginx日志的实例
2018/04/28 Python
Opencv实现抠图背景图替换功能
2019/05/21 Python
Python多线程threading模块用法实例分析
2019/05/22 Python
Django框架中间件(Middleware)用法实例分析
2019/05/24 Python
Python进阶:生成器 懒人版本的迭代器详解
2019/06/29 Python
基于Tensorflow批量数据的输入实现方式
2020/02/05 Python
AmazeUI 手机版页面的顶部导航条Header与侧边导航栏offCanvas的示例代码
2020/08/19 HTML / CSS
六查六看自查材料
2014/02/17 职场文书
怀念母亲教学反思
2014/04/28 职场文书
消防安全宣传标语
2014/06/07 职场文书
农行心得体会
2014/09/02 职场文书
2014领导班子“四风问题”对照检查材料思想汇报(执法局)
2014/09/21 职场文书
2015年中学图书馆工作总结
2015/07/22 职场文书
2015双创工作总结
2015/07/24 职场文书
2019年预备党员的思想汇报:加深对党的认知
2019/09/25 职场文书
PHP 时间处理类Carbon
2022/05/20 PHP