基於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。

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

相關文章
相關標籤/搜索