Node HTTP/2 Server Push 從瞭解到放棄

相學長發表於2019-02-16

前陣子,在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

undefined

HTTP/2

undefined

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 datasome 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。

undefined

這樣我們就開發了一個非常簡單的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,展現如下結果:

undefined

!!!!掀桌!!!!

這個展示結果並不是意料中的列印出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,訪問頁面,展現如下結果:

undefined

這回終於對了!可以看到,頁面中列印的是test2.js檔案的輸出結果。

最開始依葫蘆畫瓢沒用,其實也是因為Chrome的bug。不管怎麼樣,我們還是往前邁進了巨大的一步。

ps: 本人chrome版本66.0.3359.117,依舊有此bug

雞肋

雖然我們前進了一大步,可是面臨了一個很尷尬的問題:我們的靜態資源更多是託管在cdn上的。那我們實際場景就會遇到如下情況:

  1. 所有網站的資源,包括html/css/js/image等,都是在一臺業務伺服器上的。抱歉同學,你的業務伺服器的頻寬本來就低,怕是吃不消這麼多靜態資源的併發請求,你本來就慢的無可救藥了。
  2. 網路路由走後端,即html走後端,其他靜態資源託管cdn。抱歉同學,靜態資源都在cdn上的,你的業務伺服器怎麼去推?
  3. 完全的前後端分離,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)
})
複製程式碼

令人遺憾的是,我們的到的是如下的結果:

undefined

請求的結果表示這並不是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();
複製程式碼

結果如下:

undefined

!!!!掀桌!!!!

竟然還真的是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,極大的情況與概率是不合適的,是付出大於收益的。主要由於如下原因:

  1. 截止Node v10.0.0,HTTP/2依舊是一個實驗性的模組;
  2. 瀏覽器支援極差;如上述的Chrome的bug,fetch對server push的不支援;
  3. 推送靜態資源的實際場景非常少,而且速度提升在理論上也不會很明顯;
  4. 推送API僅支援固定的URL,不能攜帶任何動態引數。

注:上述內容僅侷限在Node服務,其他伺服器本人未有研究,不一定有上述問題

雖然server push我目前覺得不好用,但是HTTP/2還是個好東西的,除了我文章開頭講的那些好處外,HTTP/2還有很多新奇的有用的特性,諸如流優先順序、流控制等一些特性,本文並未講到。大家可以去了解了解,對我們未來開發高效能的web應該肯定有很多幫助!

本文所涉及原始碼:https://github.com/wuomzfx/http2-test

原文連結:https://yuque.com/wuomzfx/article/eh551s

相關文章