雖然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是一個很是簡潔高效的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更快。
答案就在於資源壓縮(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
中通過上述改造,一樣的請求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。
本人萌新一枚,若有疏漏請各位大佬不吝賜教~