HTTP/2のヘッダーフィールドの圧縮
HTTP/2概要
RFC7540のAbstractをそのまま引用すると、以下のように書いてある。
This specification describes an optimized expression of the semantics
of the Hypertext Transfer Protocol (HTTP), referred to as HTTP
version 2 (HTTP/2). HTTP/2 enables a more efficient use of network
resources and a reduced perception of latency by introducing header
field compression and allowing multiple concurrent exchanges on the
same connection. It also introduces unsolicited push of
representations from servers to clients.
This specification is an alternative to, but does not obsolete, the
HTTP/1.1 message syntax. HTTP's existing semantics remain unchanged.
つまり、HTTP/2はネットワークリソースをより効率的に利用し、同じ接続上での複数の同時通信を可能にしていて、ヘッダーフィールドを圧縮している。またサーバーからクライアントへの未要求のプッシュ機能も導入しているとのこと。
ここで、ヘッダーフィールドの圧縮について、何をしているのかがあまり分からなかったため、今回記事にしようと思う。
同じ接続上での複数の同時通信
ヘッダーフィールドの圧縮について説明する前に、まずは同じ接続上での複数の同時通信とは何かについて説明する。
いわゆる、ストリームの多重化というもの。一般的に、Webサイトにアクセスする場合、そのWebサイトの情報を取得するために多くのHTTPリクエストが同一セッションで行われている。例えば、WebサイトはHTMLだけでなく、CSSやJavaScript、画像やフォントなど、複数のリソースで構成されている。これらの情報を取得するために、HTMLドキュメントのリクエスト、CSSファイルのリクエスト、JavaScriptファイルのリクエスト...等多くのHTTPリクエストが行われている。
HTTP/1.1では、Head-of-Line Blocking (HOL Blocking) と呼ばれる手法が取られている。これは、1つのセッション上だと、1つずつリソースの取得が完了するまで次のリソースを取得できないというもの。つまり、先ほどの例だと、CSSの取得が遅れると、その影響で後続のJavaScriptや画像の取得がブロッキングされてしまうため、多くの時間を消費してしまうというもの。
HTTP/2ではストリームの多重化を行うことで、この問題を解決した。1つのセッション上で複数のリソースの取得を並列で取得できるようにしたということである。例えば、仮にCSSの取得が遅れたとしても、JavaScriptや画像の取得は並列で行われるため、HTTP/1.1の時よりも全体でかかる時間は短縮されることになる。
HOL Blockingの詳細は、以下の記事が非常に参考になる。
Ref: Head of Line Blocking - High Performance Web 2015
ヘッダーフィールドの圧縮
ストリームを多重化することで、同一セッション上で複数のHTTPストリームを並列に処理できるようになった。
しかし、異なるHTTPリクエストであったとしても、同じデータがたくさん埋め込まれていて、非効率になっている場合がある。
例えば、HTTP/1.1ではHTMLを取得するヘッダーの構造は以下のようになっていた。
GET /index.html HTTP/1.1
Host: www.example.com
User-Agent: Mozilla/5.0
Accept: text/html
HTTP/1.1では、各リクエストが独立して送信されるため、同一セッション内であっても各リクエストに共有ヘッダーが情報が繰り返し含まれる。
なので、CSSを取得する際、以下のようなヘッダー構造になる。
GET /style.css HTTP/1.1
Host: www.example.com
User-Agent: Mozilla/5.0
Accept: text/css
この場合だと、Host
やUser-Agent
がヘッダーとして重複している。
HTTP/2ではこの重複問題を解消するために、ヘッダーフィールドの圧縮を使用して改善を行った。
具体的にはHPACKと呼ばれる圧縮方式を使用して、重複するヘッダー情報を効率的に圧縮することで、繰り返し送信される際のデータ量を削減している。
HPACKは、インデックステーブルと呼ばれる方式を用いてヘッダーフィールドの圧縮を行っている。
インデックステーブルには、静的テーブルと動的テーブルの2つが存在する。
静的テーブルは、事前定義されていて変更することができないヘッダーフィールドのテーブルであり、使用頻度順に61個のエントリが存在する。
RFC7541から引用すると、以下のようなエントリが順番に並んでいる。
+-------+-----------------------------+---------------+
| Index | Header Name | Header Value |
+-------+-----------------------------+---------------+
| 1 | :authority | |
| 2 | :method | GET |
| 3 | :method | POST |
| 4 | :path | / |
| 5 | :path | /index.html |
| 6 | :scheme | http |
| 7 | :scheme | https |
| 8 | :status | 200 |
...
| 57 | transfer-encoding | |
| 58 | user-agent | |
| 59 | vary | |
| 60 | via | |
| 61 | www-authenticate | |
+-------+-----------------------------+---------------+
動的テーブルは、62番から始まるインデックスとヘッダフィールドの対応表であり、動的テーブルにはヘッダフィールドを追加していくことができる。つまり、61番までに存在しないフィールドを使おうとした場合、必然的に動的テーブルを使用する必要が出てくる。
インデックステーブルをより深く理解するには、以下の記事を参考されたい。
Ref: https://sgyatto.hatenablog.com/entry/2019/04/11/015654
ここで、静的テーブルに:authority
や:method
などといった:
で始まるフィールドが登場している。これらは擬似ヘッダと呼ばれていて、HTTP/2においてリクエストやレスポンスの基本的な情報を表現する。
例えば、HTTP/1.1では、以下のような構造をしている。
GET /index.html HTTP/1.1
Host: www.example.com
これを最初に見た人は、それぞれが何を表しているのかが分からないかもしれない。
しかし、以下のように書いてあったらどうだろうか。
:method: GET
:path: /index.html
:scheme: https
:authority: www.example.com
メソッドがGET
で、パスが/index.html
だということがわかるだろう。
このように、HTTP/1.1では特別な形式で表現されていたフィールドを、HTTP/2では擬似ヘッダとして統一的に扱っている。
しかし、実際にはこれらの擬似ヘッダはリクエストに埋め込まれるわけではなく、インデックスと値の形式で効率的に送信される。
例えば、Go言語でHPACKでヘッダ情報を圧縮して送信しようと思った場合、以下のように書ける。
package main
import (
"bytes"
"fmt"
"golang.org/x/net/http2/hpack"
)
func main() {
// 圧縮するHTTPヘッダーのリストを作成
headers := []hpack.HeaderField{
{Name: ":method", Value: "GET"},
{Name: ":path", Value: "/index.html"},
{Name: ":scheme", Value: "https"},
{Name: ":authority", Value: "www.example.com"},
{Name: "user-agent", Value: "Mozilla/5.0"},
{Name: "accept", Value: "text/html"},
}
// バッファを作成して、エンコーダーを初期化
var buf bytes.Buffer
encoder := hpack.NewEncoder(&buf)
// ヘッダーをエンコード(圧縮)
for _, hf := range headers {
if err := encoder.WriteField(hf); err != nil {
fmt.Println("Error encoding header:", err)
return
}
}
// 圧縮されたバイナリデータを取得
compressedData := buf.Bytes()
fmt.Printf("Compressed data: %x\n", compressedData)
}
出力結果は Compressed data: 828587418cf1e3c2e5f23a6ba0ab90f4ff7a88d07f66a281b0dae05387497ca589d34d1f
になる。
この圧縮されたデータをHEADERSフレームのペイロードに格納して送信する。
以下は、フレームの送信を擬似的に表したものである。
+---------------------------------------------------+
| Length (3 bytes) | Type (1 byte) | Flags (1 byte) |
+---------------------------------------------------+
| Stream Identifier (4 bytes) |
+---------------------------------------------------+
| Compressed Header Block (variable length) |
+---------------------------------------------------+
それぞれのフィールドは
- Length: ペイロードの長さを設定(圧縮データのバイト数)
- Type: フレームタイプをHEADERS(0x1)に設定
- Flags: 必要に応じてフラグを設定(例: END_HEADERSフラグ)
- Stream Identifier: ストリーム識別子を設定(どのストリームに属するか)
- Compressed Header Block: 圧縮されたバイナリデータ
と言った情報が格納されることになる。
このように、HPACKを使用することでHTTP/2ではHTTP/1.1と比較してヘッダーを圧縮している。
Deep Dive into Go Implementation
ここで、実装の中身に入っていこうと思う。
関数WriteField()
は、以下の中には以下のようなブロックが存在する。
func (e *Encoder) WriteField(f HeaderField) error {
// (省略)
idx, nameValueMatch := e.searchTable(f)
if nameValueMatch {
e.buf = appendIndexed(e.buf, idx)
} else {
indexing := e.shouldIndex(f)
if indexing {
e.dynTab.add(f)
}
if idx == 0 {
e.buf = appendNewName(e.buf, f, indexing)
} else {
e.buf = appendIndexedName(e.buf, f, idx, indexing)
}
}
// (省略)
return err
}
関数searchTable()
は、静的テーブルと動的テーブルに、HeaderFieldのNameとValueが完全に一致するものが存在するかどうかを検索し、もし存在すればそのインデックスとtrue
を返す。
func (e *Encoder) searchTable(f HeaderField) (i uint64, nameValueMatch bool) {
i, nameValueMatch = staticTable.search(f)
if nameValueMatch {
return i, true
}
j, nameValueMatch := e.dynTab.table.search(f)
if nameValueMatch || (i == 0 && j != 0) {
return j + uint64(staticTable.len()), nameValueMatch
}
return i, false
}
func (t *headerFieldTable) search(f HeaderField) (i uint64, nameValueMatch bool) {
if !f.Sensitive {
if id := t.byNameValue[pairNameValue{f.Name, f.Value}]; id != 0 {
return t.idToIndex(id), true
}
}
if id := t.byName[f.Name]; id != 0 {
return t.idToIndex(id), false
}
return 0, false
}
関数search()
の実装として、map byNameValue
にName
とValue
のペアが存在しなかった場合、テーブルbyName
にName
が存在するかどうかを確認する。
なので、例えば:authority
がwww.example.com
だった場合、最初のHTTPリクエストではmap byNameValue
には該当する値は存在しない(Index1
はNameこそ:authority
だが、Valueが空)が、byName
には該当する値が存在するため、関数searchTable
の返り値としてはi, false
(つまり、1, false
)が返る。
その後、else
ブロックの中に入り、インデクシングするかどうかを確認したのち、e.dynTab.add(f)
が呼び出され、動的テーブルにHeaderFieldの値が格納される。
func (dt *dynamicTable) add(f HeaderField) {
dt.table.addEntry(f)
dt.size += f.Size()
dt.evict()
}
// addEntry adds a new entry.
func (t *headerFieldTable) addEntry(f HeaderField) {
id := uint64(t.len()) + t.evictCount + 1
t.byName[f.Name] = id
t.byNameValue[pairNameValue{f.Name, f.Value}] = id
t.ents = append(t.ents, f)
}
関数add()
の内部では、関数addEntry()
が呼び出され、ここで動的テーブルのbyName
というmapに対してはName
に対するインデックスが追加され、byNameValue
というmapに対してはName
とValue
に対するインデックスが追加されるが追加される。
その後、関数WriteField()
においてidx!=0
であるため、関数appendIndexedName()
が呼ばれる。
関数appendIndexedName()
において、実際にHPACKでIndexedNameがエンコーディングされる。
func appendIndexedName(dst []byte, f HeaderField, i uint64, indexing bool) []byte {
// (省略)
dst = appendVarInt(dst, n, i)
// (省略)
return appendHpackString(dst, f.Value)
}
この2行で、f.Valueと静的テーブルのインデックスを用いて実際のバイト列を返していることが分かる。
ここまでで関数WriteField()
の概要を説明したが、初めてwww.example.com
に対してHTTPリクエストを送った時の処理をまとめると、
- 既存のインデックステーブルを検索する
- 静的テーブルにはNameが
:authority
のエントリが存在することを確認し、そのインデックス(=1)を保持 - 動的テーブルに
:authority: www.example.com
を格納 - インデックスの値(=1)と、
www.example.com
をHPACKエンコーディングした値をバイト列に格納
という流れになる。
また、次のHTTPリクエストも同様にwww.example.com
に対してリクエストを送った時の処理は、 - 既存のインデックステーブルを検索する
- 静的テーブルにはNameが
:authority
のエントリが存在することを確認し、動的テーブルにはNameが:authority
かつValueがwww.example.com
のエントリが存在することを確認し、そのインデックス(=62=61+1)を保持 - 関数
appendIndexed()
が呼ばれ、そのインデックスをバイト列に格納
という流れになる。
まとめ
HTTP/2でどのようにヘッダーフィールドの圧縮が行われているのかが理解できたし、今まで説明を読んでもよく分からなかった擬似ヘッダの概念も理解できた。
これを読んで、これらの知識が整理される人が少しでも増えればと思っています。
Discussion