Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 10791] ファイルの概要

このコミットは、Go言語の標準ライブラリであるnet/httpパッケージ内のtransport.goファイルに対する変更です。transport.goは、HTTPクライアントがネットワーク接続を管理し、リクエストを送信し、レスポンスを受信する際の低レベルな詳細を扱う重要な部分です。具体的には、HTTPトランスポート層の実装が含まれており、コネクションの再利用(Keep-Alive)、プロキシの処理、TLSハンドシェイク、そしてレスポンスボディの読み取りとデコード(gzip圧縮など)といった機能を提供します。

コミット

このコミットは、HTTP HEADリクエストに対するレスポンスがgzip圧縮されているように見える場合に、net/httpパッケージのTransportが誤ってgzip解凍を試み、その結果としてエラーが発生するバグを修正します。HEADリクエストはレスポンスボディを持たないため、ボディの解凍を試みるべきではありません。

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/22dafc9bc5c8b339628a64c9f786491a60031005

元コミット内容

commit 22dafc9bc5c8b339628a64c9f786491a60031005
Author: Brad Fitzpatrick <bradfitz@golang.org>
Date:   Wed Dec 14 11:20:21 2011 -0800

    http: fix failing Transport HEAD request with gzip-looking response
    
    We only want to attempt to un-gzip if there's a body (not in
    response to a HEAD)
    
    This was accidentally passing before, but revealed to be broken
    when c3c6e72d7cc went in.
    
    R=golang-dev, rsc
    CC=golang-dev
    https://golang.org/cl/5477093

変更の背景

この変更の背景には、Goのnet/httpパッケージにおけるHTTP HEADリクエストの処理に関するバグが存在しました。

  1. HEADリクエストの特性: HTTP HEADメソッドは、GETメソッドと同様にリソースのヘッダー情報のみを取得するために使用されます。重要なのは、HEADリクエストに対するレスポンスにはメッセージボディが含まれないというHTTPの仕様です。サーバーはGETリクエストに対するレスポンスと同じヘッダーを返しますが、ボディは送信しません。

  2. バグの原因: net/httpTransport実装では、レスポンスヘッダーにContent-Encoding: gzipが含まれている場合、レスポンスボディをgzip解凍しようとするロジックがありました。しかし、HEADリクエストの場合、たとえヘッダーにContent-Encoding: gzipが含まれていても、実際にはボディが存在しないため、gzip.NewReaderが非ボディデータ(例えば、ソケットのEOF)をgzipデータとして解釈しようとし、結果としてエラー("unexpected EOF"など)を発生させていました。

  3. 回帰(Regression): コミットメッセージによると、この問題は以前は「偶然にも通過していた」とされています。これは、おそらく以前のGoのバージョンや特定の条件下では、この誤った解凍試行が致命的なエラーにならなかったことを示唆しています。しかし、c3c6e72d7ccというコミットが導入されたことで、この潜在的なバグが顕在化し、HEADリクエストが失敗するようになりました。c3c6e72d7ccは、GoのHTTPクライアントにおけるレスポンス処理の内部的な変更(例えば、コネクションの読み取りロジックやエラーハンドリングの厳密化)に関連している可能性が高いです。これにより、HEADリクエストに対する不適切なgzip解凍の試みが、以前は無視されていたか、異なる方法で処理されていたエラーとして認識されるようになったと考えられます。

このコミットは、HEADリクエストの特性を正しく考慮し、ボディが存在しない場合にはgzip解凍を試みないようにすることで、このバグを修正することを目的としています。

前提知識の解説

1. HTTP HEAD メソッド

HTTP HEADメソッドは、Webサーバーからリソースのヘッダー情報のみを取得するために使用されます。これは、リソースの存在確認、最終更新日時、コンテンツタイプ、コンテンツサイズなどを、実際のコンテンツ(ボディ)をダウンロードせずに知りたい場合に非常に有用です。

  • 目的: GETリクエストと同じヘッダーを取得するが、メッセージボディは含まない。
  • 用途:
    • リンクの有効性チェック。
    • リソースのメタデータ(例: Content-Type, Content-Length, Last-Modified)の取得。
    • キャッシュの検証(If-Modified-Sinceヘッダーと組み合わせて)。
  • 重要な特性: レスポンスにボディは含まれません。サーバーはContent-Lengthヘッダーを送信する場合がありますが、これはGETリクエストで返されるであろうボディのサイズを示すものであり、HEADレスポンス自体のボディサイズではありません。

2. HTTP Content-Encoding: gzip

Content-Encodingヘッダーは、メッセージボディに適用されたエンコーディング(通常は圧縮アルゴリズム)を示します。gzipは最も一般的な圧縮方式の一つです。

  • 目的: ネットワーク転送量を削減し、Webページのロード時間を短縮する。
  • 仕組み: サーバーはレスポンスボディをgzipで圧縮して送信し、クライアント(ブラウザやHTTPクライアントライブラリ)はそれを受信して解凍します。
  • 関連ヘッダー: クライアントはAccept-Encoding: gzipヘッダーを送信して、gzip圧縮を受け入れ可能であることをサーバーに伝えます。

3. Go言語 net/http パッケージの Transport

Goのnet/httpパッケージは、HTTPクライアントとサーバーを構築するための強力な機能を提供します。

  • http.Client: HTTPリクエストを送信するための高レベルなインターフェースを提供します。
  • http.Transport: http.Clientの背後で動作し、実際のネットワーク通信(TCP接続の確立、TLSハンドシェイク、リクエストの書き込み、レスポンスの読み取りなど)を処理する低レベルな実装です。
    • コネクションプーリング: Transportは、HTTP/1.xのKeep-Aliveコネクションを再利用することで、新しいTCP接続を確立するオーバーヘッドを削減し、パフォーマンスを向上させます。
    • persistConn: Transport内部で、個々の永続的なTCPコネクションを管理する構造体です。このpersistConnが、リクエストの送信とレスポンスの受信のループ(readLoop)を担当します。
    • readLoop: persistConn内で動作するゴルーチンで、ネットワークからレスポンスデータを継続的に読み取り、それを処理します。このループ内で、レスポンスヘッダーの解析やボディのデコード(gzipなど)が行われます。

4. 回帰(Regression)

ソフトウェア開発における回帰とは、以前は正しく動作していた機能が、新しい変更(コミット)の導入によって動作しなくなる、またはバグが発生する現象を指します。このコミットの背景にある問題は、まさにこのような回帰によって顕在化しました。

技術的詳細

このコミットの技術的詳細は、Goのnet/httpパッケージにおけるHTTPレスポンスの処理フロー、特にTransportpersistConnがどのようにレスポンスを読み取り、gzip解凍を試みるかに関連しています。

  1. persistConn.readLoop(): transport.go内のpersistConn構造体には、readLoop()というメソッドがあります。これは、HTTPコネクションからレスポンスを非同期に読み取るためのゴルーチンとして実行されます。このループの主な役割は以下の通りです。

    • ネットワークから生データを読み取る。
    • http.ReadResponse関数を使用して、生データからHTTPレスポンス(ヘッダーとボディ)をパースする。
    • パースされたレスポンスを、対応するリクエストを待っているクライアントに渡す。
  2. gzip解凍のロジック: readLoop内でレスポンスが読み取られた後、GoクライアントがAccept-Encoding: gzipヘッダーをリクエストに追加していた場合(rc.addedGzipがtrue)、かつレスポンスヘッダーにContent-Encoding: gzipが含まれている場合、Transportはレスポンスボディを自動的に解凍しようとします。これは、gzip.NewReader(resp.Body)を呼び出すことで行われます。この関数は、提供されたresp.Bodyio.Readerインターフェース)をラップし、読み取り時に自動的に解凍を行う新しいio.Readerを返します。

  3. HEADリクエストにおける問題: 前述の通り、HEADリクエストに対するレスポンスにはボディがありません。しかし、サーバーはGETリクエストの場合に返されるであろうContent-Encoding: gzipヘッダーをHEADレスポンスにも含めることがあります。 この状況で、GoのTransportは以下のように動作していました。

    • rc.addedGzipがtrue(クライアントがgzipを受け入れる設定)
    • resp.Header.Get("Content-Encoding") == "gzip"がtrue これらの条件が満たされると、gzip.NewReader(resp.Body)が呼び出されます。しかし、resp.BodyHEADリクエストのため空(またはEOF状態)です。gzip.NewReaderは、有効なgzipデータストリームを期待するため、空の入力や不完全な入力に対してはエラー(例: io.EOFgzip: invalid header)を返します。このエラーが、HEADリクエストの失敗として報告されていました。
  4. c3c6e72d7ccによる顕在化: コミットメッセージにあるc3c6e72d7ccは、GoのHTTPクライアントの内部的な堅牢性やエラーハンドリングを改善したコミットであると推測されます。この変更により、以前は無視されていたか、異なる方法で処理されていたgzip.NewReaderからのエラーが、より厳密に扱われるようになり、結果としてHEADリクエストの失敗が顕在化したと考えられます。

このコミットは、gzip.NewReaderを呼び出す前に、レスポンスに実際にボディが存在するかどうかを明示的にチェックすることで、この問題を解決します。これにより、HEADリクエストのようにボディがない場合には、不必要なgzip解凍の試みを回避し、エラーを防ぎます。

コアとなるコードの変更箇所

--- a/src/pkg/net/http/transport.go
+++ b/src/pkg/net/http/transport.go
@@ -539,12 +539,13 @@ func (pc *persistConn) readLoop() {
 		resp, err := ReadResponse(pc.br, rc.req)
 
 		if err == nil {
-			if rc.addedGzip && resp.Header.Get("Content-Encoding") == "gzip" {
+			hasBody := rc.req.Method != "HEAD" && resp.ContentLength != 0
+			if rc.addedGzip && hasBody && resp.Header.Get("Content-Encoding") == "gzip" {
 				resp.Header.Del("Content-Encoding")
 				resp.Header.Del("Content-Length")
 				resp.ContentLength = -1
 				tgzReader, zerr := gzip.NewReader(resp.Body)
-				if err != nil {
+				if zerr != nil {
 					pc.close()
 					err = zerr
 				} else {

コアとなるコードの解説

このコミットでは、src/pkg/net/http/transport.goファイルのpersistConn.readLoop()関数内で、HTTPレスポンスのgzip解凍処理に関する2つの重要な変更が行われています。

  1. hasBody変数の導入と条件の追加: 変更前は、gzip解凍の条件はrc.addedGzip(クライアントがgzipを受け入れる設定)とresp.Header.Get("Content-Encoding") == "gzip"(レスポンスがgzipエンコードされていると宣言)の2つでした。 変更後、新たにhasBodyというブール変数が導入されました。

    hasBody := rc.req.Method != "HEAD" && resp.ContentLength != 0
    

    このhasBodyは、以下の2つの条件が両方とも真である場合にtrueとなります。

    • rc.req.Method != "HEAD": 現在のリクエストメソッドがHEADではないこと。HEADリクエストはボディを持たないため、この条件で除外されます。
    • resp.ContentLength != 0: レスポンスのContent-Lengthヘッダーが0ではないこと。Content-Lengthが0の場合もボディがないことを示唆します。 そして、gzip解凍の条件式にこのhasBodyが追加されました。
    if rc.addedGzip && hasBody && resp.Header.Get("Content-Encoding") == "gzip" {
    

    これにより、HEADリクエストやContent-Lengthが0のレスポンスに対しては、たとえContent-Encoding: gzipヘッダーが存在しても、不必要なgzip解凍の試みが回避されるようになりました。これは、HTTPの仕様に則った正しい振る舞いです。

  2. エラー変数の修正 (err -> zerr): 変更前のコードでは、gzip.NewReader(resp.Body)の呼び出し後に、その戻り値であるzerr(gzipリーダー作成時のエラー)ではなく、外側のスコープのerr変数をチェックしていました。

    // 変更前
    tgzReader, zerr := gzip.NewReader(resp.Body)
    if err != nil { // ここでzerrではなくerrをチェックしていた
        pc.close()
        err = zerr
    } else {
        // ...
    }
    

    これは論理的なバグであり、gzip.NewReaderがエラーを返しても、そのエラーが適切に処理されない可能性がありました。 変更後、この部分がzerrをチェックするように修正されました。

    // 変更後
    tgzReader, zerr := gzip.NewReader(resp.Body)
    if zerr != nil { // zerrを正しくチェック
        pc.close()
        err = zerr
    } else {
        // ...
    }
    

    この修正により、gzip.NewReaderの呼び出しで発生したエラーが正しく捕捉され、コネクションのクローズやエラーの伝播が行われるようになりました。これは、コードの堅牢性を高めるための重要な修正です。

これらの変更により、GoのHTTPクライアントはHEADリクエストをより正確に処理し、不必要なエラーを回避できるようになりました。

関連リンク

参考にした情報源リンク