雖然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
幀,再基於流的方式來高效返回檔案內容:
- 獲取當前檔案的讀取流
fileStream
- 基於
zlib
建立一個可以動態gzip壓縮的變換流compressTransformer
- 將這些流依次通過管道(
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。
本人萌新一枚,如有疏漏請各位大佬不吝賜教~