前陣子,在Media看到一篇文章《Node.js can HTTP/2 push!》。看到push這個字眼時,我想到的是WebSocket訊息推送。難不成HTTP/2還能像WebSocket那樣可以服務端主動推送訊息?好厲害,我就一下子來了興趣。
然而閱讀完文章之後,發現理想與現實略有差距。簡單的說,HTTP/2 所謂的server push其實是當伺服器接收一個請求時,可以響應多個資源。舉個栗子:瀏覽器向伺服器請求index.html,伺服器不僅把index.html返回了,還可以把index.js,index.css等一起推送給客戶端。最直觀的好處就是,瀏覽器不用解析頁面再發起請求來獲取資料,節約了頁面載入時間。
雖然略有差距,但看起來還是挺有意思的,值得去嘗試一下。畢竟紙上得來終覺淺,絕知此事要躬行!
HTTP/2
我之前並未使用過HTTP/2,在進行實踐之前,總要先了解一下。關於HTTP/2,網上也有很多資料,我這裡就簡單說一下它最大的優點:快!。這裡的快是相比HTTP 1.x 而言的,那為什麼它會更快呢?
頭部壓縮
這裡的頭部指的是http請求頭headers。大家可能會想請求頭能有多大呢,跟資源相比算不上啥。其實不然,隨著網際網路的發展,請求頭裡攜帶的資料越來越多了,隨隨便一個“user-agent”就一長串。另外cookie也會被存放越來越多的資訊。更煩的是,一個頁面所有的請求,都會帶上這些重複的請求頭資料。
所以HTTP/2採用HPACK演算法,能極大壓縮頭部資料,減少總體資源請求大小。大致的原理就是維護兩本字典,一本靜態字典,維護比較常見的頭部名稱。一本動態字典,維護不同請求的公共的頭資料。
多路複用
我們知道,在HTTP 1.x中,我們是可以並行請求的。但是,瀏覽器對於同一個域名的並行請求是有上限的(FireFox, Chrome上限6個 )。所以很多網站的靜態資源站可能會有多個。而且每次請求都要重新建立TCP連線,想必大部分web工程師都瞭解過TCP三次握手,這個握手的代價也是比較高的。
雖然http1.x裡有keep-alive可以避免TCP三次握手,但是keep-alive又是序列的。所以要麼並行多握手,要麼序列不握手,都不是最好的結果,我們希望的是並行也不握手。
幸運的是HTTP/2解決了這個問題。當客戶端與服務端建立連線後,就會在雙方建立一個雙向流通道。這個流通道,可以同時包含多個訊息(http請求),不同訊息各自的資料幀在流裡可以亂序並行的傳送,不會互相影響與堵塞,從而實現了一個TCP連結,併發執行N個http請求。通過提高併發,減少TCP連線開銷,HTTP/2的速度得到了很大提升,尤其是在網路延遲比較高的情況下。
這裡用展現兩張網路請求時間瀑布流對比圖:
HTTP 1.1
HTTP/2
Server Push
上文中,我們描述了HTTP/2的連線會建立一個雙向流通道。Server Push就是在某次流中,可以返回客戶端並沒有主動要的資料。
上述的頭部壓縮、多路複用,並不需要開發人員做什麼操作,只要開啟HTTP/2,瀏覽器也支援就可以了。但是Server Push就需要開發人員編寫程式碼去操作了。那我們就動手,在Node上玩玩看。
Node HTTP/2 Server Push 實操
Node對HTTP/2支援情況
在Node 8.4.0版本時,就對HTTP/2實驗性的支援了。2018年4月24日晚,Node v10終於釋出了,然而對於HTTP/2,還是實驗性的支援。。。不過社群已經對HTTP/2移除實驗性進行討論了,相信在不遠的將來應該能看到Node對HTTP/2更好的支援。因此在這之前,我們可以先去掌握這個知識,做一些實踐。
依葫蘆畫瓢
我們先根據Node文件,建立一個HTTP/2服務。這裡需要提的一點就是,目前流行的瀏覽器都不支援未加密的、不安全的HTTP/2。所以我們必須生成下證照與祕鑰,然後通過http2.createSecureServer
建立安全的HTTP/2連結。
想自己實踐,生成本地證照的同學可以參考這裡:傳送門。
// server.js
const http2 = require('http2')
const fs = require('fs')
const streamHandle = require('./streamHandle/sample')
const options = {
key: fs.readFileSync('./ryans-key.pem'),
cert: fs.readFileSync('./ryans-cert.pem'),
}
const server = http2.createSecureServer(options)
server.on('stream', streamHandle)
server.listen(8125)
複製程式碼
然後我們再照著文件,編寫對流的處理,並推送一個url路徑為 '/' 的資料。
// streamHandle/sample.js
module.exports = stream => {
stream.respond({ ':status': 200 })
stream.pushStream({ ':path': '/' }, (err, pushStream, headers) => {
if (err) throw err
pushStream.respond({ ':status': 200 })
pushStream.end('some pushed data')
pushStream.on('close', () => console.log('close'))
})
stream.end('some data')
}
複製程式碼
然後我們開啟chrome,訪問https://127.0.0.1:8125 發現頁面顯示的一直是 some data。some pushed data這個主動推送的資料不知在哪裡。開啟網路請求皮膚,也沒有任何其他請求。
百思不得其解阿,但我又不想止步於此,怎麼辦呢?
從娃娃抓起
我決定先寫一個正常的HTTP/2業務請求,程式碼如下:
module.exports = (stream, headers) => {
const path = headers[':path']
if (path.indexOf('api') >= 0) {
// 請求api
stream.respond({ 'content-type': 'application/json', ':status': 200 })
stream.end(JSON.stringify({ success: true }))
} else if (path.indexOf('static') >= 0) {
// 請求靜態資源
const fileType = path.split('.').pop()
const contentType = fileType === 'js' ? 'application/javascript' : 'text/css'
stream.respondWithFile(`./src${path}`, {
'content-Type': contentType
})
} else {
// 請求html
stream.respondWithFile('./src/index.html')
}
}
複製程式碼
程式碼大意就是,判斷請求連結,當請求地址帶有api
字眼時就返回一個json,當請求地址帶有static
時,就返回對應路徑的靜態資源。其他情況就返回一個html
檔案。
html檔案內容為:
<!DOCTYPE html>
<html>
<head>
<meta charset=utf-8>
<title>HTTP/2 Server Push</title>
<link rel="shortcut icon" type=image/ico href=/static/favorite.ico>
<link href=/static/css/app.css rel=stylesheet>
</head>
<body>
<h1>HTTP/2 Server Push</h1>
<script type=text/javascript src=/static/js/test.js></script>
</body>
</html>
複製程式碼
執行後我們再開啟chrome,訪問https://127.0.0.1:8125 ,我們能看到頁面正常渲染了,檢視網路皮膚,發現協議也已經是HTTP/2。
這樣我們就開發了一個非常簡單的HTTP/2應用。下一步,我們再加上server push的功能,當訪問index.html
的請求時,我們主動將js
的資源返回,看看瀏覽器是怎麼樣的響應情況。
抓出一個葫蘆娃
module.exports = (stream, headers) => {
const path = headers[':path']
if (path.indexOf('api') >= 0) {
// 請求api部分程式碼-略
} else if (path.indexOf('static') >= 0) {
// 請求靜態資源部分程式碼-略
} else {
// 請求html時 主動推送js檔案
stream.pushStream(
{ ':path': '/static/js/test.js' },
(err, pushStream, headers) => {
if (err) throw err
pushStream.respondWithFile('./src/static/js/test2.js', {
'content-type:': 'application/javascript'
})
pushStream.on('error', console.error)
}
)
stream.respondWithFile('./src/index.html')
}
}
複製程式碼
程式碼大意就是,當客戶端請求index.html
時,服務端除了返回index.html
檔案,順便把test2.js
這個檔案推給服務端,客戶端如果再次請求 https://127.0.0.1:8125/static/js/test.js
時,就會直接獲取到test2.js
。
這裡我用test2.js
的目的是為了方便的知道,客戶端請求的到底是服務端推送的test2.js
檔案,還是直接通過伺服器再次請求獲取到的test.js
檔案。
其中test.js
會在頁面列印:This is normal js.
test2.js
會在頁面列印:This is server push js.
按照期望,應該是後者。然後我們開啟chrome,訪問 https://127.0.0.1:8125,展現如下結果:
!!!!掀桌!!!!
這個展示結果並不是意料中的列印出This is server push js
,頁面請求的js檔案還是正常網路請求的,並非是我主動推送的test2.js
。我翻山越嶺搜遍祖國內外,終於在Node的一條issue下看到類似的問題:http2 pushStream not providing files for :path entries (CHROME 65, works in FF) 。
Works in FireFox ??????? Chrome的bug ??????
你照著文件寫程式碼,結果卻不像文件所展示,各種排查沒有用,最終發現是一些非主觀的原因,程式設計師最大的痛苦莫過於此....然後我夾雜著痛苦心塞和峰迴路轉的心情,開啟了自己的Firefox,訪問頁面,展現如下結果:
這回終於對了!可以看到,頁面中列印的是test2.js
檔案的輸出結果。
最開始依葫蘆畫瓢沒用,其實也是因為Chrome的bug。不管怎麼樣,我們還是往前邁進了巨大的一步。
ps: 本人chrome版本66.0.3359.117,依舊有此bug
雞肋
雖然我們前進了一大步,可是面臨了一個很尷尬的問題:我們的靜態資源更多是託管在cdn上的。那我們實際場景就會遇到如下情況:
- 所有網站的資源,包括html/css/js/image等,都是在一臺業務伺服器上的。抱歉同學,你的業務伺服器的頻寬本來就低,怕是吃不消這麼多靜態資源的併發請求,你本來就慢的無可救藥了。
- 網路路由走後端,即html走後端,其他靜態資源託管cdn。抱歉同學,靜態資源都在cdn上的,你的業務伺服器怎麼去推?
- 完全的前後端分離,html與其他靜態資源都是在cdn上。這種情況下,還是有點用處的,但效果並不會很出色。因為HTTP/2本身就支援多路複用,已經減少了TCP三次握手帶來的網路消耗。server push僅僅只是降低了瀏覽器解析html的時間,對於現代瀏覽器來說,這太微乎其微了。(ps: 就在我寫文章之時,恰好看到某雲服務商支援了server push。)
這麼一說,這就是個雞肋啊!到頭來竹籃打水一場空?
天生我材必有用
做人還是不能輕易的放棄治療。再仔細想想,還是有一些應用場景的---初始化的API請求。
現在很多單頁應用,往往有很多的初始化請求,獲取使用者資訊、獲取頁面資料等等。而這些都是需要html載入完,然後js載入完,然後再去執行的。而且很多時候,這些資料不載入完,頁面都只能空白顯示。可是單頁應用的js資源往往又很大。一個vendor包好幾兆也很常見。等瀏覽器載入並解析完這麼大的包,可能已經很多時間消耗了。這時候再去請求一些初始化API,如果這些API又比較費時的話,頁面就要多空白很長時間。
但如果能在請求html時,我們就把初始化的api資料推送給客戶端,當js解析完再去請求時,就能馬上獲取到資料,這就能節省寶貴的白屏時間。說幹就幹,我們再次動手實踐!
module.exports = (stream, headers) => {
const path = headers[':path']
if (path.indexOf('api') >= 0) {
// 請求api
stream.respond({ 'content-type': 'application/json', ':status': 200 })
stream.end(JSON.stringify({ apiType: 'normal' }))
} else if (path.indexOf('static') >= 0) {
// 請求靜態資原始碼-略
} else {
// 請求html
stream.pushStream({ ':path': '/api/getData' }, (err, pushStream, headers) => {
if (err) throw err
pushStream.respond({ ':status': 200 , 'content-type': 'application/json'});
pushStream.end(JSON.stringify({ apiType: 'server push' }))
});
stream.respondWithFile('./src/index.html')
}
}
複製程式碼
同樣的,我讓正常請求api與服務端推送的api資料做一些差異,以便於更直觀的判斷是否獲取了服務端推送的資料。然後在前端的js檔案中寫如下請求,並列印出請求結果:
window.fetch('/api/getData').then(result => result.json()).then(rs => {
console.log('fetch:', rs)
})
複製程式碼
令人遺憾的是,我們的到的是如下的結果:
請求的結果表示這並不是server push的資料。吃一塹長一智,這會不會又是瀏覽器的什麼bug?亦或者是不是fetch
不支援獲取server push的資料?我馬上用XMLHttpRequest又寫了一版:
window.fetch('/api/getData').then(result => result.json()).then(rs => {
console.log('fetch:', rs)
})
const request = new XMLHttpRequest();
request.open('GET', '/api/getData', true)
request.onload = function(result) {
console.log('ajax:', JSON.parse(this.responseText))
};
request.send();
複製程式碼
結果如下:
!!!!掀桌!!!!
竟然還真的是fetch
不支援http2 server push!
還是雞肋
其實除了fetch
不支援外,還有一個比較致命的問題,就是這個server push,在當下的node伺服器上,不能對服務端推送資源的url進行模糊匹配。也就是說,如果一個請求有url動態引數的話,其實是匹配不到的。像我例子中的stream.pushStream({ ':path': '/api/getData' }, pushHandle)
,如果前端請求的介面是 /api/getData?param=1
,那就得不到server push的資料了。
另外,它僅支援GET請求與HEAD請求,POST、PUT這些也是不支援的。
針對fetch
這個問題,我又了搜了下祖國內外,也沒得出個所以然來。這也變相的說明,目前社群裡針對server push這個特性使用的還很少,遇到問題時,很難快速的去定位與解決問題。
所以,似乎在推送api上,它的應用場景又侷限了,僅適用於推送固定URL的初始化GET請求。
苦海無邊回頭是岸
綜上所述,我得出的結論就是:目前在Node上,使用server push,極大的情況與概率是不合適的,是付出大於收益的。主要由於如下原因:
- 截止Node v10.0.0,HTTP/2依舊是一個實驗性的模組;
- 瀏覽器支援極差;如上述的Chrome的bug,fetch對server push的不支援;
- 推送靜態資源的實際場景非常少,而且速度提升在理論上也不會很明顯;
- 推送API僅支援固定的URL,不能攜帶任何動態引數。
注:上述內容僅侷限在Node服務,其他伺服器本人未有研究,不一定有上述問題
雖然server push我目前覺得不好用,但是HTTP/2還是個好東西的,除了我文章開頭講的那些好處外,HTTP/2還有很多新奇的有用的特性,諸如流優先順序、流控制等一些特性,本文並未講到。大家可以去了解了解,對我們未來開發高效能的web應該肯定有很多幫助!
本文所涉及原始碼:https://github.com/wuomzfx/http2-test
原文連結:https://yuque.com/wuomzfx/article/eh551s