HTTP/2 サーバープッシュ

HTTP/2 Server Push by Jaana Burcu Dogan and Tom Bergan

はじめに

HTTP/2はHTTP/1.xが抱えるさまざまな欠点に対処するために設計されました。 現代のWebページはHTML、スタイルシート、スクリプト、画像など、多くのリソースを使用します。 HTTP/1.xでは、これらのリソースはそれぞれ明示的にリクエストされなければなりません。 これは遅い処理になりがちです。ブラウザはまずHTMLの取得から始め、ページを解析・評価する過程で 段階的に他のリソースの存在を知ることになります。サーバーはブラウザが各リクエストを送るのを 待たなければならないため、ネットワークはしばしばアイドル状態になり、十分に活用されません。

レイテンシを改善するために、HTTP/2では サーバープッシュ が導入されました。これはブラウザから 明示的にリクエストされる前に、サーバーがリソースをブラウザへプッシュできるようにする仕組みです。 サーバーはページが必要とする追加のリソースの多くをあらかじめ把握していることが多く、最初のリクエストに 応答する際にそれらのリソースのプッシュを開始できます。これにより、サーバーは本来アイドル状態になる ネットワークを十分に活用でき、ページの読み込み時間を改善できます。

h2push-serverpush

プロトコルのレベルでは、HTTP/2サーバープッシュは PUSH_PROMISE フレームによって駆動されます。 PUSH_PROMISE は、ブラウザが近い将来送ることになるとサーバーが予測したリクエストを記述したものです。 ブラウザは PUSH_PROMISE を受信するとすぐに、サーバーがそのリソースを配信することを知ります。 ブラウザが後になってこのリソースが必要だと気づいた場合、新しいリクエストを送信するのではなく、 プッシュが完了するのを待ちます。これにより、ブラウザがネットワークを待つ時間が短縮されます。

net/httpでのサーバープッシュ

Go 1.8では、http.Server からのレスポンスのプッシュに対応する機能が導入されました。 この機能は、実行中のサーバーがHTTP/2サーバーであり、かつ受け付けた接続がHTTP/2を使用している場合に利用できます。 任意のHTTPハンドラの中で、http.ResponseWriter が新しい http.Pusher インターフェースを 実装しているかどうかを調べることで、サーバープッシュに対応しているかをアサートできます。

たとえば、ページの描画に app.js が必要になるとサーバーが知っている場合、ハンドラは http.Pusher が利用可能であればプッシュを開始できます。

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    if pusher, ok := w.(http.Pusher); ok {
        // プッシュに対応している。
        if err := pusher.Push("/app.js", nil); err != nil {
            log.Printf("Failed to push: %v", err)
        }
    }
    // ...
})

Push の呼び出しは /app.js に対する合成リクエストを作成し、そのリクエストを PUSH_PROMISE フレームへと 合成した上で、その合成リクエストをサーバーのリクエストハンドラへと転送します。転送されたリクエストは プッシュされるレスポンスを生成します。Push の第2引数には PUSH_PROMISE に含める追加のヘッダーを指定します。 たとえば、/app.js へのレスポンスがAccept-Encodingによって変化する場合、PUSH_PROMISE にも Accept-Encodingの値を含めるべきです。

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    if pusher, ok := w.(http.Pusher); ok {
        // プッシュに対応している。
        options := &http.PushOptions{
            Header: http.Header{
                "Accept-Encoding": r.Header["Accept-Encoding"],
            },
        }
        if err := pusher.Push("/app.js", options); err != nil {
            log.Printf("Failed to push: %v", err)
        }
    }
    // ...
})

完全に動作するサンプルはこちらから入手できます。

このサーバーを実行して https://localhost:8080 を読み込むと、ブラウザの開発者ツールに app.jsstyle.css がサーバーからプッシュされたことが表示されるはずです。

h2push-networktimeline

レスポンスを送る前にプッシュを開始する

Push メソッドは、レスポンスのバイト列を送信し始める前に呼び出しておくのがよい習慣です。 そうしないと、意図せず重複したレスポンスを生成してしまうことがあります。たとえば、 HTMLレスポンスの一部を次のように書き込んだとします。

<html>
<head>
    <link rel="stylesheet" href="a.css">...

その後で Push("a.css", nil) を呼び出したとします。ブラウザは PUSH_PROMISE を受け取るより前に このHTMLの断片を解析してしまうことがあり、その場合ブラウザは PUSH_PROMISE を受け取ることに加えて a.css へのリクエストを送信してしまいます。こうなると、サーバーは a.css に対して2つのレスポンスを 生成することになります。レスポンスを書き込む前に Push を呼び出しておけば、この可能性を完全に回避できます。

サーバープッシュを使うべきとき

ネットワークのリンクがアイドル状態になる場面では、いつでもサーバープッシュの利用を検討してください。 Webアプリのために送るHTMLをちょうど送り終えたところでしょうか?時間を無駄にせず、クライアントが 必要とするリソースのプッシュを始めましょう。レイテンシを削減するためにリソースをHTMLファイルに インライン化していますか?インライン化する代わりにプッシュを試してみてください。リダイレクトも プッシュを使うのに良いタイミングです。クライアントがリダイレクトに従う間、ほとんど常に1往復分の 無駄が発生するからです。プッシュを利用できる場面は他にもたくさんあります。私たちはまだ始めたばかりです。

いくつか注意点に触れないのは片手落ちというものでしょう。第一に、プッシュできるのは自分のサーバーが 権限を持つリソースだけです。つまり、サードパーティのサーバーやCDN上でホストされているリソースを プッシュすることはできません。第二に、クライアントに実際に必要とされていると確信できないリソースを プッシュしてはいけません。そうでなければプッシュは帯域を無駄にします。同じ理由から、クライアントが 既にそのリソースをキャッシュしている可能性が高い場合もプッシュを避けるべきです。第三に、ページ上の すべてのリソースを機械的にプッシュするような素朴なアプローチは、しばしばパフォーマンスを悪化させます。 迷ったら、計測しましょう。

次のリンクは参考文献として役立ちます。

結論

Go 1.8では、標準ライブラリがHTTP/2サーバープッシュへの対応を標準機能として提供するようになり、 Webアプリケーションを最適化するための柔軟性が広がりました。

実際に動く様子を見るには、HTTP/2 Server Pushのデモページを訪れてみてください。

By Jaana Burcu Dogan and Tom Bergan