您好,登錄后才能下訂單哦!
雖然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)將該頁面所依賴的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(); } }; };
需要注意的是,推送的發生永遠要先于當前頁面的返回。否則服務器推送與客戶端請求可能就會出現競爭的情況,降低傳輸效率。
依賴記錄
從靜態文件中間件代碼中我們可以看到,服務器推送資源取自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
幀,再基于流的方式來高效返回文件內容:
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()); //...
即監聽推送流的關閉事件,手動撤銷文件讀取流。
最后
本項目代碼開源在Github上,如果覺得對你有幫助希望能給我點個Star。
以上就是本文的全部內容,希望對大家的學習有所幫助,也希望大家多多支持億速云。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。