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

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

配置

虽然W3C的规范中没有规定HTTP/2协议必定要使用ssl加密,可是支持非加密的HTTP/2协议的浏览器实在少的可怜,所以咱们有必要申请一个本身的域名和一个ssl证书。
本项目的测试域名是you.keyin.me,首先咱们去域名提供商那把测试服务器的地址绑定到这个域名上。而后使用Let's Encrypt生成一个免费的SSL证书:html

sudo certbot certonly --standalone -d you.keyin.me
复制代码

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

改造Koa

Koa是一个很是简洁高效的Node.js服务器框架,咱们能够简单改造一下来让它支持HTTP/2协议:github

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();
    }
  };
};
复制代码

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

依赖记录

从静态文件中间件代码中咱们能够看到,服务器推送资源取自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文档中已经简单描述了服务器推送的写法,实现很简单:app

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());
//...
复制代码

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

最后

本项目目前已经安稳部署在aws上,免费服务器速度还比较快(真的良心)。你们能够大概测试一下:you.keyin.me。另外本项目代码开源在Github上,若是以为对你有帮助但愿能给我点个Star。

本人萌新一枚,若有疏漏请各位大佬不吝赐教~

相关文章
相关标签/搜索