欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

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

程序员文章站 2022-04-10 17:57:22
虽然http/2目前已经逐渐的在各大网站上开始了使用,但是在目前最新的node.js上仍然处于实验性api,还没有能有效解决生产环境各种问题的应用示例。因此在应用http/...

虽然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。

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