![flow]({{ site.url }}/assets/images/h2c-flow.png)
前言
主流使用 HTTP/2 時都是基於 TLS protocol,不過在 HTTP/2 RFC7540 規範中, HTTP/2 其實也可以直接基於 cleartext TCP 來溝通。這次主要介紹 based on cleartext TCP 的 HTTP/2 server 與 client 實作,後續會再加入 HTTP/2 結合 TLS protocol 的相關內容。
HTTP/2 Version Identification
在 RFC7540 3.1 中有明確定義出 HTTP/2 on TLS protocol or cleartext TCP 的識別號,這個識別主要用於 client 端詢問 server 對於 HTTP/2 的 protocol 支援,以及切換到 HTTP/2 的過程。
1. h2
The string “h2” identifies the protocol where HTTP/2 uses Transport Layer Security (TLS).
2. h2c
The string “h2c” identifies the protocol where HTTP/2 is run over cleartext TCP.
為了方便辨識,以下會直接以 h2
h2c
代號來表示基於不同 protocol 的 HTTP/2。
而在h2c 部分,有更細節的說明:
This identifier is used in the HTTP/1.1 Upgrade header field and in any place where HTTP/2 over TCP is identified.
The “h2c” string is reserved from the ALPN identifier space but describes a protocol that does not use TLS.
Client 與 server 在決定要用什麼 protocol 溝通時,h2 基於 TLS ,因此是採用 TLS-ALPN(Application-Layer Protocol Negotiation)
方式;至於 h2c 沒有 TLS ,所以就走 HTTP/1.1 Upgrade 機制,在 request header 中會加入 Upgrade:h2c
,讓 server 可以根據此來辨識是否支援並且 upgrade。
Upgrading from HTTP/2
(RFC7540 8.1.1)雖然 HTTP/1.1 可以透過 Upgrade
機制來切換到 HTTP/2 protocol,但是 HTTP/2 卻是明確禁止這樣的行為,也因此無法透過這樣的 upgrade 機制從 HTTP/2 轉換到其他 protocol。
h2c HTTP/2 Server
在上面內容有提到,h2c HTTP/2 server 要能識別 HTTP/1.1 Upgrade 機制,並且給予適當回應,因此這也是在實作 server 時的首要功能。
http.Server
with h2c handler
用 Go 建立 h2c server 的方式很簡單,一樣是使用大家熟悉的 net/http
package Server.ListenAndServe
來啟動 server 並接收後續的 request,不過因為必須處理 HTTP/2 型態的 request,所以可以猜到我們需要調整 Handler
設定,來讓 server 能了解 HTTP/2 格式的 request。
調整 Handler 的方式,可以透過 "golang.org/x/net/http2/h2c"
package 來達成目的。使用 h2c.NewHandler
method 就能產生 h2cHandler
, h2cHandler 會根據 request 的內容來判斷後續執行方式:
-
HTTP/2: 如果讀到 client HTTP/2 Connection Preface(RFC7540-3.5,後面會說明),就會建立額外的 http2.serverConn,後續 request 將由這新的 connect 來接收處理。
-
HTTP/1.1 upgrade to HTTP/2 h2c: 第二種情況則是 request 包含 protocol upgrade 資訊(RFC7540-3.2),如果可以 upgrade,就會將 HTTP 內容轉換成 HTTP/2 格式,接著同樣地新建 http2.serverConn 來處理 request。
-
HTTP: 如果沒有包含任何 HTTP/2 和 Upgrade 資訊,就當成一般 HTTP request 處理。
有了這些概念後,我們就可以知道 h2c server 該如何建立。
H2c Server Example
1package main
2
3import (
4 "fmt"
5 "log"
6 "net/http"
7
8 "golang.org/x/net/http2"
9 "golang.org/x/net/http2/h2c"
10)
11
12func main() {
13 var h2serv http2.Server
14
15 handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16 fmt.Fprint(w, "HTTP/2 Server Example")
17 })
18
19 serv := http.Server{
20 Addr: ":9090",
21 // Create a h2c handler to dispatch incoming requests.
22 Handler: h2c.NewHandler(handler, &h2serv),
23 }
24
25 log.Printf("Start server %s", serv.Addr)
26 if err := serv.ListenAndServe(); err != http.ErrServerClosed {
27 log.Panicln(err)
28 }
29}
在網路上可能會看到以下寫法:
1func main() {
2 var server http2.Server
3
4 l, err := net.Listen("tcp", "localhost:9090")
5 if err != nil {
6 log.Fatalln(err)
7 }
8
9 for {
10 conn, err := l.Accept()
11 if err != nil {
12 log.Fatalln(err)
13 }
14
15 server.ServeConn(conn, &http2.ServeConnOpts{
16 Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
17 fmt.Fprintf(w, "HTTP/2 Server Example")
18 }),
19 })
20 }
21}
這種 low-level API l.Accept()
Loop,並且直接使用 server.ServeConn
的做法等於省略了 h2cHandler
這層判斷,讓 request 直接進入 HTTP/2 格式處理。建議還是使用 h2c.NewHandler
作法,讓 server handler 比較有彈性。
H2c Client
有了 HTTP/2 server 後,就寫個簡單的 client 來進行測試吧!
首先,Client 端發出含有 Upgrade:h2c
的 HTTP request 到 server 端,然後確認 response 是否如預期。
1. 建立 upgrade request
1req, err := http.NewRequest(http.MethodGet, "http://localhost:9090", nil)
2req.Header.Add("Upgrade", "h2c")
2. 加入更多 HTTP/2 upgrade requirement
光有 Upgrade:h2c
還不夠,還要有 HTTP2-Settings
Header Field 以及其內容。
The content of the HTTP2-Settings header field is the payload of a SETTINGS frame, encoded as a base64url string.
HTTP2-Settings 其實就是 HTTP/2 的 SETTINGS frame(RFC7540 6.5),其值用來進行溝通期間的參數設定,例如更新 window size 或是 設定 max frame size 等。只不過因為我們起初是發送 HTTP request,所以就將值 encoding 後放入到 HTTP2-Settings
header field 中。
1settings := []byte{0, 5, 0, 0xa, 0, 0, 0, 3, 0, 0, 0, 0xfa, 0, 6, 0, 10, 01, 0x40, 0, 4, 0, 10, 0, 0}
2req, _ := http.NewRequest(http.MethodGet, "http://localhost:9000", nil)
3req.Header.Add("Connection", "HTTP2-Settings")
4req.Header.Add("HTTP2-Settings", base64.RawURLEncoding.EncodeToString(settings))
簡單說明一下,settings 的內容是由數個 Identifier (16 bit) + Value (32 bit) 所組成,其格式定義在 RFC7540 6.5.1 SETTINGS FORMAT。
舉例來說:
1[]byte{0, 5, 0, 0xa, 0, 0}
2// big-endian
3// 0x5 - SETTINGS_MAX_FRAME_SIZE, value - 0xa0000 = 655360
最後,為什麼 upgrade request 需要 HTTP2-Settings
,在 RFC7540 3.2.1 有解答:
Providing these values (HTTP2-Settings) in the upgrade request gives a client an opportunity to provide parameters prior to receiving any frames from the server.
3. Send upgrade request and get response
Client sample code
1conn, err := net.Dial("tcp", "localhost:9000")
2if err != nil {
3 log.Fatalln(err)
4}
5
6req := newUpgradeRequest()
7
8// send a request
9req.Write(conn)
10
11// get data from server
12buf := bufio.NewReader(conn)
13
14// convert data to HTTP response format.
15resp, err := http.ReadResponse(buf, req)
16if err != nil {
17 log.Fatalln(err)
18}
19
20if resp.StatusCode != http.StatusSwitchingProtocols {
21 log.Fatalln("server does not support h2c")
22}
為了 re-use net.Conn
以及使用 http2.Client,因此就採用 low level API 的方式來發送 HTTP request。
我們從 Wireshark 可以看到結果,在正確設置了 upgrade header 後,就會回應 switching protocols response,而這也正是我們所預期的。
![flow]({{ site.url }}/assets/images/h2c-upgrade-1.png)
而從 Go server Debug tool 來看 (其實就是在 go run server.go 前面加上GODEBUG=http2debug=2
)
12019/08/27 15:23:33 http2: Framer 0xc0001420e0: wrote SETTINGS len=24, settings: MAX_FRAME_SIZE=655360, MAX_CONCURRENT_STREAMS=250, MAX_HEADER_LIST_SIZE=655680, INITIAL_WINDOW_SIZE=655360
MAX_FRAME_SIZE value 也有正確被 server 讀取,皆大歡喜!
反之,如果只設置 Upgrade:"h2c"
而沒有設置 HTTP2-Settings
的情況下:
![flow]({{ site.url }}/assets/images/h2c-upgrade-2.png)
Server 只會把這個 request 當作 HTTP 來處理,就直接回 200 以及對應的 response data 啦~
4. Starting HTTP/2 with Prior Knowledge
根據規範指示,當我們收到 101 response,就需要接著發送 connection preface (RFC7540 3.4) 以利 server 繼續進行 HTTP/2 connection 建立。
Upon receiving the 101 response, the client MUST send a connection preface (Section 3.5), which includes a SETTINGS frame.
從 h2c.go source code 也可以看到有這樣的確認機制:
1// h2cUpgrade establishes a h2c connection using the HTTP/1 upgrade (Section 3.2).
2func h2cUpgrade(w http.ResponseWriter, r *http.Request) (net.Conn, error) {
3
4 // more...
5
6 rw.Write([]byte("HTTP/1.1 101 Switching Protocols\r\n" +
7 "Connection: Upgrade\r\n" +
8 "Upgrade: h2c\r\n\r\n"))
9 rw.Flush()
10
11 // A conforming client will now send an H2 client preface which need to drain
12 // since we already sent this.
13 if err := drainClientPreface(rw); err != nil {
14 return nil, err
15 }
16
17 c := &rwConn{
18 Conn: conn,
19 Reader: io.MultiReader(initBytes, rw),
20 BufWriter: newSettingsAckSwallowWriter(rw.Writer),
21 }
22 return c, nil
23}
如果 client 沒有接著發送 connection preface
那該連線就無法成立。
因此,我們就繼續實作 client,在收到 101 status code 後,就接著使用 http2.Transport.NewClientConn
來建立 client conn 並發送 client connection preface。
1var tr http2.Transport
2http2Conn, err := tr.NewClientConn(conn)
3if err != nil {
4 log.Fatalln(err)
5}
http2.Transport.NewClientConn
client connection preface 在 NewClientConn 階段就會發送給 server 端,從 source code 可以看到相關實作。
1func (t *Transport) newClientConn(c net.Conn, singleUse bool) (*ClientConn, error) {
2 cc := &ClientConn{
3 t: t,
4 tconn: c,
5 readerDone: make(chan struct{}),
6 nextStreamID: 1,
7 maxFrameSize: 16 << 10, // spec default
8 initialWindowSize: 65535, // spec default
9 maxConcurrentStreams: 1000, // "infinite", per spec. 1000 seems good enough.
10 peerMaxHeaderListSize: 0xffffffffffffffff, // "infinite", per spec. Use 2^64-1 instead.
11 streams: make(map[uint32]*clientStream),
12 singleUse: singleUse,
13 wantSettingsAck: true,
14 pings: make(map[[8]byte]chan struct{}),
15 }
16
17 // skip..
18
19 // send the client connection preface
20 cc.bw.Write(clientPreface)
21
22 cc.fr.WriteSettings(initialSettings...)
23 cc.fr.WriteWindowUpdate(0, transportDefaultConnFlow)
24 cc.inflow.add(transportDefaultConnFlow + initialWindowSize)
25 cc.bw.Flush()
26 if cc.werr != nil {
27 return nil, cc.werr
28 }
29
30 go cc.readLoop()
31 return cc, nil
32}
5. Communicating communications
最後,連線建立完成,可以開始開啟新 stream 並且交換 frames!
1req, _ := http.NewRequest(http.MethodGet, "http://localhost:9000", nil)
2http2Conn.RoundTrip(req)
![flow]({{ site.url }}/assets/images/h2c-upgrade-3.png)
8/31 補充:關於 http2.Transport
在整合 demo code 成為一個完整的 h2c-client 時,發現一個連線 bug。
當 client 發送一個包含 HTTP/2 upgrade 的 HTTP request 時, server 在接收到後,會將其格式轉成 HTTP/2 ,並在後續建立起 HTTP/2 連線時,會把這個資訊放入 buffer 中。HTTP/2 連線建立成功, server 發送完初始 SETTINGS frames 後,就會緊接著將這個 request 處理完並且將 response 回應給 client 端。
此時,這個 response 的 stream ID 為 0x1
(RFC 7540 5.1.1),但是 client 端在接收到 response 後卻會顯示 error, error 內容為收到未預期的 stream ID ,並強制關閉 connection。
查看了一下 client transport source code,發現 transport 在接收 DATA frame 時會檢查 stream ID 是否超出 request 的 stream ID。
1func (rl *clientConnReadLoop) processData(f *DataFrame) error {
2 neverSent := cc.nextStreamID
3 if f.StreamID >= neverSent {
4 // We never asked for this.
5 cc.logf("http2: Transport received unsolicited DATA frame; closing connection")
6 return ConnectionError(ErrCodeProtocol)
7 }
8}
看的出來是 nextStreamID
的問題。這時候回去看 newClientConn
source code,會看到 nextStreamID
會被初始化成 1
,而此時因為 client 端在 HTTP/2 連線建立成功後,還沒有發送任何 request,因此 nextStreamID
維持 1,進而造成這樣的錯誤回報。
那麼要如何避免這樣的錯誤發生呢?
一個蠻 tricky 的做法,就是:
1tr := &http2.Transport{AllowHTTP: true}
把 Transport 的 AllowHTTP 設定為 true。雖然根據官方 source code 的說法:
AllowHTTP, if true, permits HTTP/2 requests using the insecure, plain-text “http” scheme. Note that this does not enable h2c support.
看起來好像跟問題沒有什麼太大關係,不過這樣的設定會更動到 nextStreamID
的值。一樣是在 newClientConn
method:
1if t.AllowHTTP {
2 cc.nextStreamID = 3
3}
因為 nextStreamID 初始值變成 3 了,所以當接收到 response 的 stream ID 為 1 時,判斷就會是正常結果,避免掉上述的問題。
老實說,把 nextStreamID 設置為 3,對我來說是一個蠻 magic number 的行為,code 裡面也沒有解釋為什麼是 3 而不是 4,5,6,7 … ,不過既然可以解決了,就這樣吧 XD 畢竟 Go 本來的 HTTP2 package 本來就是預設 TLS 行為,這個錯誤只會發生在使用 HTTP/1.1 upgrade 後而已。
Sample Codes
Server
{%gist YuShuanHsieh/c902a1bd245b9ad52e30f832d2cc9ab3 %}
Client
{%gist YuShuanHsieh/e249dfe245df7612d7fdd9cb7333ba16 %}