search
LoginSignup
6

posted at

updated at

Goで見るHTTP/2

HTTP/3も出てきて、QUICトランスポートを使っていこうという時期ですが、
そもそもHTTP/2も知らないでHTTP/3に進めるのか?
ということでHTTP/2を理解するためにあれこれやってみた事を記事にしました。

また、HTTP/3はそもそもまだ仕様も策定中(まもなく確定?)でライブラリ等で気楽に使える状況にも無いので、
この先プロジェクトで使っていくことがあるとしたらまずはHTTP/2かなというのも調べてみた理由です。
HTTP/3自体HTTP over QUICとしてHTTP/2の機能からQUICと重複する部分を切り落として効率化したプロトコルなので、
今後HTTP/3の利用が本格化した際にもキャッチアップが楽になるだろうと。

HTTP/2の概要は HTTP/2 の概要 (オライリーのHigh Performance Browser Networkingからの抜粋記事) 等がわかりやすいですが、
幾つか基本的な事を書いておきます。

HTTP/2の概要の概要

基本的な構成要素はHTTP/1.1と同じ

HTTP/2であっても、HTTPを構成する メソッド,ヘッダ,ステータス,ボディ といった要素は変わりません。
というかこのあたりのシンタックスを維持したままHTTP/1.1で生じていた様々な課題を解決しようと生まれたのがHTTP/2です。

なので、普通に使う分には今までと殆ど変わりはありません。(というか意識してないだけで多分使ってます。)
では何が違うのか。

大きな変更点

HTTP/2での主な変更点は下記の通り。

  • メッセージ構造がテキストベースからバイナリベースになった
  • 単一のTCP接続で複数のストリームを持ち、多重に送受信が出来るようになった
  • ストリーム内での優先順位設定や、サーバーサイドプッシュなどの新たな機能の実装
  • ヘッダーが圧縮されるようになった

その他、仕様上は強制では無いものの多くのサーバーがTLS必須になっています。
HTTP/2はそれ以前のHTTP/1系と互換性が無い全く別のプロトコルになっているため、
通信経路上にあるプロキシが自分が解釈出来ないプロトコルとして遮断してしまう事があるようです。
TLSを使えば通信内部は完全に隠蔽されプロキシが通信内容を解釈する余地が挟まれないため、
安定した通信が出来るようになるというわけです。

GoのHTTP/2対応

Goはバージョン1.6から標準でHTTP/2に対応しています。
HTTP/2の実装はgolang.org/x/net/http2に置かれていますが、直接呼び出して使う必要はありません。
殆どの場合は、HTTP/2だからと言って特別な事は何もなく利用できます。

package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, "Hello world!!")
	})

	err := http.ListenAndServeTLS(":3000", "server.crt", "server.key", nil)
	if err != nil {
		log.Fatal(err)
	}
}

*手元で確認する場合は適当にサーバー側の証明書とキーを生成して使ってください。

これだけで、HTTP/2サーバとして機能します。
ブラウザから呼び出してみましょう。

ブラウザのデベロッパーツールで確認するとプロトコルにHTTP/2が使われているのがわかります。
dev-tool.png

ネゴシエーション時にお互いにHTTP/2に対応しているかを確認(NPN/ALPN)しており、
可能であれば何もせずともHTTP/2が使用されています。

バイナリフレーミングレイヤーを見る

普通に使う分にはそれほど意識する事はありませんが、実際ライブラリの中身ではHTTP/2対応で様々な事が行われています。

大きな変更点として挙げていた バイナリベース の部分をもう少し掘り下げて見て行きます。

JSON文字列をPOSTするクライアントと、それを受け画像データをチャンクで返すサーバを作り、
少し細かい動きを見て行きましょう。

使ったコードの置き場はこちら

クライアントの実装

クライアント側は単にメッセージをPOSTして、受信した画像データを保存するだけです。

func main() {

	client := http.Client{
		Transport: &http2.Transport{},
	}

	resp, err := client.Post("https://localhost:3000", "application/json", bytes.NewReader([]byte("{\"message\":\"hello\"}")))
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()

	file, err := os.Create("image.png")
	if err != nil {
		panic(err)
	}
	defer file.Close()

	io.Copy(file, resp.Body)
	log.Printf("Protocol Version: %s\n", resp.Proto)
}

実行時にGODEBUG=http2debug=2を指定する事で詳細なデバッグログが得られ、
送受信しているフレームの詳細を見ることが出来ます。
以下ではこちらのログと合わせて動きを追って行きます。

サーバーの実装

先ほどのようにListenAndServeTLSを使ってしまうと中の動きが追い辛いため、
Lisnerを取得して

	cert, err := tls.LoadX509KeyPair("server.crt", "server.key")
	if err != nil {
		log.Fatal("load key pair error:", err)
	}

	tlsCfg := &tls.Config{
		Certificates: []tls.Certificate{cert},
		NextProtos:   []string{"h2"},
	}

	l, err := tls.Listen("tcp", ":3000", tlsCfg)

	if err != nil {
		log.Fatal("listen for tls over tcp error:", err)
	}
	defer l.Close()

クライアントからのアクセスを待ち、生TCPコネクションを取得します。

	conn, err := l.Accept()
	if err != nil {
		log.Fatal("accept connection error:", err)
	}

HTTP/2ではプロトコル使用の確認のため、最初にコネクションプリフェイスを送信します。
プリフェイスは"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"と定義されており、
これが期待と異なる場合にはサーバーはコネクションエラーとして扱わなければなりません。
RFC7540 3.5

	const preface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
	b := make([]byte, len(preface))
	if _, err := io.ReadFull(conn, b); err != nil {
		log.Fatal("read from conn error:", err)
	}
	if string(b) != preface {
		log.Fatal("invalid preface:", string(b))
	}

ここから先はHTTP/2の世界なので、バイナリフレームでのやりとりが行われます。
HTTPの基本要素となる メソッド,ヘッダ,ステータス,ボディ も全てフレームに内包されます。

バイナリ化された通信

クライアント側のデバッグログを確認すると、ヘッダーを圧縮(HPACK)しHADERフレームに、
ボディのデータをDATAフレームに書き込んでいます。

http2: Transport creating client conn 0xc00040c000 to [::1]:3000
http2: Framer 0xc0003fa0e0: wrote SETTINGS len=18, settings: ENABLE_PUSH=0, INITIAL_WINDOW_SIZE=4194304, MAX_HEADER_LIST_SIZE=10485760
http2: Framer 0xc0003fa0e0: wrote WINDOW_UPDATE len=4 (conn) incr=1073741824
http2: Transport encoding header ":authority" = "localhost:3000"
http2: Transport encoding header ":method" = "POST"
http2: Transport encoding header ":path" = "/"
http2: Transport encoding header ":scheme" = "https"
http2: Transport encoding header "content-type" = "application/json"
http2: Transport encoding header "content-length" = "19"
http2: Transport encoding header "accept-encoding" = "gzip"
http2: Transport encoding header "user-agent" = "Go-http-client/2.0"
http2: Framer 0xc0003fa0e0: wrote HEADERS flags=END_HEADERS stream=1 len=52
http2: Framer 0xc0003fa0e0: wrote DATA flags=END_STREAM stream=1 len=19 data="{\"message\":\"hello\"}"

GoではFramer構造体が用意されており、フレームデータの読み書きを担ってくれます。

	framer := http2.NewFramer(conn, conn)

	frames, err := readFrames(framer)
	if err != nil {
		log.Fatal("read frames error:", err)
	}

フレームの種類は下記の通り。詳しくはRFCを参照してください。

種類 データ
HEADERS ヘッダー
DATA ボディ
PRIORITY 優先度
RST_STREAM エラーコード
SETTINGS 各種設定値
PUSH_PROMISE プッシュ開始予約
PING PING
GOAWAY コネクション終了通知
WINDOW_UPDATE ウインドウ値の更新
CONTINUATION HEADERS/PUSH_PROMISEの続きのデータ

Framerからフレームデータを読み出して行きます。
ストリームの最終フレームはEND_STREAMフラグを持っているので、そこまで読んでいきます。

func readFrames(framer *http2.Framer) ([]http2.Frame, error) {
	frames := make([]http2.Frame, 0)
	for {
		frame, err := framer.ReadFrame()
		if err != nil {
			return frames, err
		}
		frames = append(frames, frame)
		if frame.Header().Flags.Has(http2.FlagDataEndStream) {
			return frames, nil
		}
	}
}

では早速フレームのデータを見てみましょう。
現在のストリームのStreamIDと受信したJSON文字列を確認します。

	var streamID uint32
	var data []byte
	for _, frame := range frames {
		if headersframe, ok := frame.(*http2.HeadersFrame); ok {
			streamID = headersframe.StreamID
		}
		if headersframe, ok := frame.(*http2.DataFrame); ok {
			data = headersframe.Data()
		}
    }
	fmt.Printf("StreamID: %v, Message: %s\n", streamID, string(data))
StreamID: 1, Message: {"message":"hello"}

ストリームID=1 でPOSTしたJSONメッセージがデータフレームから取得出来ているのがわかります。

サーバーはプリフェイスが問題ない事を確認したら、まずはSETTINGフレームを返します。
これは空でも良いとされているので、今回はとりあえず空で返します。

	framer.WriteRawFrame(http2.FrameSettings, 0, 0, []byte{})
http2: Framer 0xc0003fa0e0: read SETTINGS len=0
http2: Transport received SETTINGS len=0
http2: Framer 0xc0003fa0e0: wrote SETTINGS flags=ACK len=0

クライアントに送信する画像データを開いて、レスポンスヘッダ情報をHPACK圧縮します。

	pic, err := ioutil.ReadFile("image.png")
	if err != nil {
		log.Fatal(err)
	}
	hbuf := bytes.NewBuffer([]byte{})
	henc := hpack.NewEncoder(hbuf)
	henc.WriteField(hpack.HeaderField{Name: ":status", Value: "200"})
	henc.WriteField(hpack.HeaderField{Name: "content-length", Value: strconv.Itoa(len(pic))})
	henc.WriteField(hpack.HeaderField{Name: "content-type", Value: "image/png"})

	fmt.Printf("Encoded Header: %d Byte\n", len(hbuf.Bytes()))

ヘッダデータは16バイトに圧縮されています。

Encoded Header: 16 Byte

圧縮したヘッダーデータをHEADERSフレームに書き込みます。

	err = framer.WriteHeaders(http2.HeadersFrameParam{StreamID: streamID, BlockFragment: hbuf.Bytes(), EndHeaders: true})
	if err != nil {
		log.Fatal("write headers error: ", err)
    }

HTTP/1.1ではヘッダーに"Transfer-Encoding: chunked"を指定して転送エンコードを行っていましたが、
HTTP/2では単純にDATAフレームに分割して送信するだけで効率の良いデータストリーミングが行えます。
最後のDATAフレームにEND_STREAMフラグを立てて送れば、クライアント側はそれを検知して通信を終了します。

	for _, chunk := range chunkBy(pic, 1024) {
		framer.WriteData(streamID, false, chunk)
	}
	framer.WriteData(streamID, true, []byte{})
func chunkBy(items []byte, chunkSize int) [][]byte {
	var chunks [][]byte
	for chunkSize < len(items) {
		items, chunks = items[chunkSize:], append(chunks, items[0:chunkSize:chunkSize])
	}

	return append(chunks, items)
}

クライアント側のデバッグログではヘッダーデータ受信後、続けてチャンク化された画像データを受信しているのが確認できます。
(データ内容はあんまり長ったらしいので省略しています。)
クライアントは逐次受信したデータを処理し、途中何度かWINDOW_UPDATEフレームで空いたバッファサイズを
サーバーに通知してフロー制御を行っています。

http2: Framer 0xc0003fa0e0: read HEADERS flags=END_HEADERS stream=1 len=16
http2: decoded hpack field header field ":status" = "200"
http2: decoded hpack field header field "content-length" = "15229"
http2: decoded hpack field header field "content-type" = "image/png"
http2: Transport received HEADERS flags=END_HEADERS stream=1 len=16
http2: Framer 0xc0003fa0e0: read DATA stream=1 len=1024 data="\x89PNG\..."
http2: Transport received DATA stream=1 len=1024 data="" (768 bytes omitted)
http2: Framer 0xc0003fa0e0: read DATA stream=1 len=1024 data="<TS\xa3k\..."
...
http2: Framer 0xc0003fa0e0: wrote WINDOW_UPDATE stream=1 len=4 incr=5120
...
http2: Framer 0xc0003fa0e0: wrote WINDOW_UPDATE stream=1 len=4 incr=5120
...
http2: Framer 0xc0003fa0e0: read DATA flags=END_STREAM stream=1 len=0 data=""
http2: Transport received DATA flags=END_STREAM stream=1 len=0 data=""
http2: Framer 0xc0003fa0e0: wrote WINDOW_UPDATE stream=1 len=4 incr=4989
Protocol Version: HTTP/2.0

最後に

こうやって一連の流れを追ってみると、HTTP/2もやっている事自体はシンプルかつ、より効率的なプロトコルになっている事がわかります。
今回は触れていませんが、一本のTCPコネクションでストリームを多重化していたり、双方向通信も行えるので、
今後サーバの内側でもHTTP/2を使ってサービス同士の通信を行い効率化されていくんだろうなと思います。

とはいえ今回の様な実装をプロジェクトの中でする機会はおそらくないと思いますが、
HTTP/2がどの様に動作しているのかをある程度把握しておけば、今後gRPCなどを扱う場面で
勘が利くようにもなるでしょうし、実装して動かして見る事で理解の助けにもなるものと思います。

参考

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
6