基於Node.js的HTTP/2 Server實踐

clasky發表於2019-03-03

雖然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)將該頁面所依賴的jscssfont等資源一起推送回去。具體程式碼如下:

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());
//...
複製程式碼

即監聽推送流的關閉事件,手動撤銷檔案讀取流。

最後

本專案目前已經安穩部署在aws上,免費伺服器速度還比較快(真的良心)。大家可以大概測試一下:you.keyin.me。另外本專案程式碼開源在Github上,如果覺得對你有幫助希望能給我點個Star。

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

相關文章