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

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

このコミットは、Go言語の標準ライブラリであるnet/httpパッケージにおけるResponseWriterReadFromメソッドの挙動を修正するものです。具体的には、ReadFromがデータが読み込まれる前に副作用(HTTPヘッダの書き込みなど)を引き起こす問題を解決し、sendfileのような最適化が適用できない場合に通常のio.Copyにフォールバックするロジックを改善しています。

コミット

  • コミットハッシュ: de04bf24e5a9d4a52f0024dd684de21ec4a36340
  • Author: Brad Fitzpatrick bradfitz@golang.org
  • Date: Thu Aug 8 14:02:54 2013 -0700

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

https://github.com/golang/go/commit/de04bf24e5a9d4a52f0024dd684de21ec4a36340

元コミット内容

net/http: fix early side effects in the ResponseWriter's ReadFrom

The ResponseWriter's ReadFrom method was causing side effects on
the output before any data was read.

Now, bail out early and do a normal copy (which does a read
before writing) when our input and output are known to not to
be the pair of types we need for sendfile.

Fixes #5660

R=golang-dev, rsc, nightlyone
CC=golang-dev
https://golang.org/cl/12632043

変更の背景

このコミットは、Goのnet/httpパッケージにおけるResponseWriter.ReadFromメソッドが抱えていた問題、具体的にはgolang.org/issue/5660で報告されたバグを修正するために導入されました。

問題の核心は、ResponseWriter.ReadFromが、実際にデータがソースから読み込まれる前に、HTTPレスポンスヘッダを早期に書き込んでしまう可能性があった点にあります。これは、特にio.PipeのようなブロッキングI/Oを使用している場合や、レスポンスが別のゴルーチンで生成されている場合に問題となりました。例えば、リクエストボディを読み込んでいる最中に、レスポンスヘッダが先に書き込まれてしまうと、予期せぬ動作や競合状態が発生する可能性がありました。

HTTPプロトコルでは、レスポンスヘッダはボディの前に送信される必要がありますが、ReadFromが最適化のために内部的にsendfileのようなゼロコピー操作を利用しようとする際、ソースからのデータがまだ利用可能でないにもかかわらず、ヘッダをフラッシュしてしまうことがありました。これにより、コンテンツタイプ推測(Content-Type sniffing)などの処理が正しく行われなくなる可能性もありました。

このコミットは、このような早期の副作用を防ぎ、sendfileのような特定の最適化が適用できない状況では、より安全で予測可能な通常のio.Copyの動作にフォールバックするようにReadFromのロジックを変更することを目的としています。

前提知識の解説

このコミットの理解には、以下のGo言語のI/Oおよびネットワークに関する概念の理解が不可欠です。

  • io.ReaderFromインターフェース: io.ReaderFromインターフェースは、ReadFrom(r Reader) (n int64, err error)メソッドを定義します。このインターフェースを実装する型は、別のio.Readerからデータを効率的に読み込む能力があることを示します。特に、io.Copy関数は、コピー元がio.ReaderFromを実装している場合、そのReadFromメソッドを呼び出して最適化されたコピー処理を試みます。これにより、中間バッファを介さずに直接データを転送できる場合があります(例: sendfileシステムコール)。

  • io.Copy関数: io.Copy(dst Writer, src Reader) (written int64, err error)は、srcからdstへデータをコピーするGoの標準関数です。この関数は、コピー元がio.ReaderFromを実装している場合、またはコピー先がio.WriterToを実装している場合に、それらの最適化されたメソッドを利用しようとします。それ以外の場合は、内部的にバッファを使用してデータを読み書きします。

  • net/httpパッケージ: Go言語でHTTPクライアントおよびサーバーを実装するための標準パッケージです。

    • http.ResponseWriter: HTTPレスポンスを構築するためにサーバーハンドラが使用するインターフェースです。ヘッダの設定、ステータスコードの書き込み、レスポンスボディの書き込みなどを行います。io.Writerインターフェースも実装しています。
    • http.Request: 受信したHTTPリクエストを表す構造体です。リクエストメソッド、URL、ヘッダ、ボディなどの情報を含みます。
  • sendfileシステムコール(またはsplice: sendfileは、Unix系OSで利用可能なシステムコールで、カーネル空間内でファイルディスクリプタからソケットディスクリプタへデータを直接転送する機能を提供します。これにより、ユーザー空間へのデータコピーが不要になり、I/Oのオーバーヘッドが大幅に削減されます。これは「ゼロコピー」I/Oの一種です。net/httpパッケージのResponseWriter.ReadFromは、特定の条件下でこのsendfileのような最適化を利用しようとします。

  • コンテンツタイプ推測(Content-Type Sniffing): HTTPレスポンスにおいて、Content-Typeヘッダが明示的に設定されていない場合、ブラウザやクライアントはレスポンスボディの最初の数バイトを検査(スニッフィング)して、そのコンテンツタイプ(例: text/html, image/pngなど)を推測しようとします。この推測は、セキュリティ上の理由(例: XSS攻撃の防止)や、正しいレンダリングのために重要です。ResponseWriter.ReadFromが早期にヘッダを書き込んでしまうと、このスニッフィングが正しく行われなくなる可能性がありました。

技術的詳細

このコミットは、net/httpパッケージのresponse構造体(http.ResponseWriterの実装)のReadFromメソッドの動作を根本的に変更しています。

以前のReadFromの実装では、responseio.ReaderFromを実装している場合(これは通常、基盤となるネットワーク接続がio.ReaderFromを実装している場合に該当します)、まずw.WriteHeader(StatusOK)を呼び出してHTTPステータスコードとヘッダを書き込み、その後、基盤となる接続のReadFromメソッドを直接呼び出してsendfileのような最適化を試みていました。

この挙動の問題点は、w.WriteHeader(StatusOK)が呼び出されると、たとえソースsrcからまだデータが読み込まれていなくても、レスポンスヘッダがクライアントに送信されてしまうことでした。これにより、以下のような問題が発生する可能性がありました。

  1. 早期のヘッダ送信: srcがブロッキングI/O(例: io.Pipe)である場合、データが利用可能になる前にヘッダが送信されてしまい、クライアントが不完全なレスポンスを受け取るか、コンテンツタイプ推測が正しく行われない。
  2. 競合状態: サーバーハンドラがリクエストボディを読み込んでいる最中に、別のゴルーチンがReadFromを呼び出してレスポンスヘッダを送信してしまうと、予期せぬ動作を引き起こす。

このコミットによる修正は、この早期の副作用を防ぐために、sendfileのような最適化が利用できる条件をより厳密にチェックするように変更しました。

新しいロジックでは、response.ReadFromは以下の条件をチェックします。

  1. 出力先がio.ReaderFromを実装しているか?: w.conn.rwc(通常は*net.TCPConn)がio.ReaderFromインターフェースを実装しているかを確認します。これはsendfileのような最適化が可能であるための前提条件です。
  2. 入力元が通常のファイルであるか?: 新しく導入されたヘルパー関数srcIsRegularFile(src io.Reader)を使用して、入力元srcが通常のファイル(*os.File)であるかどうかをチェックします。これは、sendfileが主にファイルディスクリプタからソケットディスクリプタへの転送に最適化されているためです。

これらの両方の条件が満たされない場合(つまり、出力先がio.ReaderFromを実装していない、または入力元が通常のファイルではない場合)、ReadFromは早期に処理を中断し、io.Copy(writerOnly{w}, src)を呼び出して通常のコピー処理にフォールバックします。

writerOnly構造体は、io.Writerインターフェースのみを公開するラッパーです。これは、io.Copyがコピー元またはコピー先がio.ReaderFromまたはio.WriterToを実装している場合に最適化されたパスを試みるのを防ぐために使用されます。writerOnly{w}io.Copyに渡すことで、io.CopywReadFromメソッドを持っていることを認識せず、常にバッファリングされた通常のコピー処理を実行します。これにより、ReadFromの内部でio.Copyが再帰的にReadFromを呼び出すことを防ぎ、予測可能な動作を保証します。

sendfileパス: 上記の条件が満たされ、sendfileのような最適化が可能な場合のみ、w.WriteHeader(StatusOK)を呼び出してヘッダを書き込み、その後、基盤となる接続のrf.ReadFrom(src)を呼び出して最適化されたコピー処理を実行します。これにより、ヘッダの書き込みは、sendfileが実際に実行される直前に行われるようになります。

この変更により、ReadFromは、sendfileが利用できない状況では安全に通常のコピーにフォールバックし、早期の副作用を防ぐことができます。

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

このコミットによる主要な変更は、以下の2つのファイルに集中しています。

  1. src/pkg/net/http/serve_test.go: 新しいテストケースTestServerReaderFromOrderが追加されました。
  2. src/pkg/net/http/server.go: response構造体のReadFromメソッドのロジックが変更され、writerOnly構造体とsrcIsRegularFileヘルパー関数が追加されました。

src/pkg/net/http/serve_test.go の変更

--- a/src/pkg/net/http/serve_test.go
+++ b/src/pkg/net/http/serve_test.go
@@ -1815,6 +1815,51 @@ func TestHTTP10ConnectionHeader(t *testing.T) {
 	}
 }
 
+// See golang.org/issue/5660
+func TestServerReaderFromOrder(t *testing.T) {
+	defer afterTest(t)
+	pr, pw := io.Pipe()
+	const size = 3 << 20
+	ts := httptest.NewServer(HandlerFunc(func(rw ResponseWriter, req *Request) {
+		rw.Header().Set("Content-Type", "text/plain") // prevent sniffing path
+		done := make(chan bool)
+		go func() {
+			io.Copy(rw, pr)
+			close(done)
+		}()
+		time.Sleep(25 * time.Millisecond) // give Copy a chance to break things
+		n, err := io.Copy(ioutil.Discard, req.Body)
+		if err != nil {
+			t.Errorf("handler Copy: %v", err)
+			return
+		}
+		if n != size {
+			t.Errorf("handler Copy = %d; want %d", n, size)
+		}
+		pw.Write([]byte("hi"))
+		pw.Close()
+		<-done
+	}))
+	defer ts.Close()
+
+	req, err := NewRequest("POST", ts.URL, io.LimitReader(neverEnding('a'), size))
+	if err != nil {
+		t.Fatal(err)
+	}
+	res, err := DefaultClient.Do(req)
+	if err != nil {
+		t.Fatal(err)
+	}
+	all, err := ioutil.ReadAll(res.Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+	res.Body.Close()
+	if string(all) != "hi" {
+		t.Errorf("Body = %q; want hi", all)
+	}
+}
+
 func BenchmarkClientServer(b *testing.B) {
 	b.ReportAllocs()
 	b.StopTimer()

src/pkg/net/http/server.go の変更

--- a/src/pkg/net/http/server.go
+++ b/src/pkg/net/http/server.go
@@ -16,6 +16,7 @@ import (
 	"log"
 	"net"
 	"net/url"
+	"os"
 	"path"
 	"runtime"
 	"strconv"
@@ -345,11 +346,44 @@ func (w *response) needsSniff() bool {
 	return !w.cw.wroteHeader && w.handlerHeader.Get("Content-Type") == "" && w.written < sniffLen
 }
 
+// writerOnly hides an io.Writer value's optional ReadFrom method
+// from io.Copy.
 type writerOnly struct {
 	io.Writer
 }
 
+func srcIsRegularFile(src io.Reader) (isRegular bool, err error) {
+	switch v := src.(type) {
+	case *os.File:
+		fi, err := v.Stat()
+		if err != nil {
+			return false, err
+		}
+		return fi.Mode().IsRegular(), nil
+	case *io.LimitedReader:
+		return srcIsRegularFile(v.R)
+	default:
+		return
+	}
+}
+
+// ReadFrom is here to optimize copying from an *os.File regular file
+// to a *net.TCPConn with sendfile.
 func (w *response) ReadFrom(src io.Reader) (n int64, err error) {
+	// Our underlying w.conn.rwc is usually a *TCPConn (with its
+	// own ReadFrom method). If not, or if our src isn't a regular
+	// file, just fall back to the normal copy method.
+	rf, ok := w.conn.rwc.(io.ReaderFrom)
+	regFile, err := srcIsRegularFile(src)
+	if err != nil {
+		return 0, err
+	}
+	if !ok || !regFile {
+		return io.Copy(writerOnly{w}, src)
+	}
+
+	// sendfile path:
+
 	if !w.wroteHeader {
 		w.WriteHeader(StatusOK)
 	}
@@ -367,16 +401,12 @@ func (w *response) ReadFrom(src io.Reader) (n int64, err error) {
 
 	// Now that cw has been flushed, its chunking field is guaranteed initialized.
 	if !w.cw.chunking && w.bodyAllowed() {
-		if rf, ok := w.conn.rwc.(io.ReaderFrom); ok {
-			n0, err := rf.ReadFrom(src)
-			n += n0
-			w.written += n0
-			return n, err
-		}
+		n0, err := rf.ReadFrom(src)
+		n += n0
+		w.written += n0
+		return n, err
 	}
 
-	// Fall back to default io.Copy implementation.
-	// Use wrapper to hide w.ReadFrom from io.Copy.
 	n0, err := io.Copy(writerOnly{w}, src)
 	n += n0
 	return n, err

コアとなるコードの解説

src/pkg/net/http/server.go

  1. writerOnly構造体:

    type writerOnly struct {
        io.Writer
    }
    

    この構造体は、io.Writerインターフェースを埋め込むことで、io.Writerのメソッド(Write)のみを公開します。response構造体自体はio.Writerio.ReaderFromの両方を実装していますが、io.Copy(writerOnly{w}, src)のようにwriterOnlyでラップして渡すことで、io.CopywReadFromメソッドを認識せず、通常のバッファリングされたコピー処理を実行するように強制します。これにより、ReadFromの内部でio.Copyが再帰的にReadFromを呼び出すことを防ぎ、無限ループや予期せぬ動作を回避します。

  2. srcIsRegularFile関数:

    func srcIsRegularFile(src io.Reader) (isRegular bool, err error) {
        switch v := src.(type) {
        case *os.File:
            fi, err := v.Stat()
            if err != nil {
                return false, err
            }
            return fi.Mode().IsRegular(), nil
        case *io.LimitedReader:
            return srcIsRegularFile(v.R)
        default:
            return
        }
    }
    

    このヘルパー関数は、与えられたio.Readerが通常のファイル(*os.File)であるかどうかを再帰的にチェックします。

    • *os.Fileの場合、Stat()を呼び出してファイル情報を取得し、fi.Mode().IsRegular()で通常のファイルであるかを確認します。
    • *io.LimitedReaderの場合、その内部のio.Readerに対して再帰的にsrcIsRegularFileを呼び出します。これは、io.LimitReaderでラップされたファイルも正しく扱えるようにするためです。
    • その他の型の場合、通常のファイルではないと判断し、falseを返します。 この関数は、sendfileのような最適化がファイルディスクリプタに特化しているため、入力元が通常のファイルであるかどうかの条件を厳密にチェックするために使用されます。
  3. response.ReadFromメソッドの変更:

    func (w *response) ReadFrom(src io.Reader) (n int64, err error) {
        // Our underlying w.conn.rwc is usually a *TCPConn (with its
        // own ReadFrom method). If not, or if our src isn't a regular
        // file, just fall back to the normal copy method.
        rf, ok := w.conn.rwc.(io.ReaderFrom)
        regFile, err := srcIsRegularFile(src)
        if err != nil {
            return 0, err
        }
        if !ok || !regFile {
            return io.Copy(writerOnly{w}, src)
        }
    
        // sendfile path:
    
        if !w.wroteHeader {
            w.WriteHeader(StatusOK)
        }
        // ... (既存のsendfileパスのロジック) ...
    }
    

    これが最も重要な変更点です。

    • まず、w.conn.rwc(基盤となるネットワーク接続、通常は*net.TCPConn)がio.ReaderFromを実装しているか(rf, ok := w.conn.rwc.(io.ReaderFrom))と、入力元srcsrcIsRegularFileによって通常のファイルであるか(regFile, err := srcIsRegularFile(src))をチェックします。
    • !ok || !regFile: これらの条件のいずれかが満たされない場合(つまり、出力先がio.ReaderFromを実装していないか、入力元が通常のファイルではない場合)、io.Copy(writerOnly{w}, src)を呼び出して、sendfileのような最適化を試みずに通常のコピー処理にフォールバックします。これにより、早期のヘッダ書き込みを防ぎ、安全な動作を保証します。
    • sendfile path: 上記の条件が満たされた場合のみ、sendfileによる最適化が可能なパスに進みます。このパスでは、!w.wroteHeaderのチェック後にw.WriteHeader(StatusOK)を呼び出し、その後、基盤となる接続のrf.ReadFrom(src)を呼び出します。これにより、ヘッダの書き込みは、実際にデータ転送が開始される直前に行われるようになります。

src/pkg/net/http/serve_test.go

  1. TestServerReaderFromOrderテストケース: この新しいテストケースは、golang.org/issue/5660で報告された問題を再現し、修正が正しく機能することを確認するために追加されました。
    • io.Pipeを使用して、書き込み側(pw)と読み込み側(pr)を作成します。
    • HTTPハンドラ内で、別のゴルーチンでio.Copy(rw, pr)ResponseWriterにパイプからコピー)を実行します。
    • メインのハンドラゴルーチンでは、time.Sleep(25 * time.Millisecond)で意図的に遅延を入れ、io.Copy(ioutil.Discard, req.Body)でリクエストボディを読み込みます。
    • その後、pw.Write([]byte("hi"))でパイプにデータを書き込み、pw.Close()で閉じます。
    • このテストの目的は、リクエストボディの読み込みが完了する前に、ResponseWriter.ReadFromがレスポンスヘッダを書き込んでしまうという以前のバグを検出することです。修正後、ReadFromはパイプからのデータが利用可能になるまでヘッダの書き込みを遅延させるため、テストは成功するはずです。
    • クライアント側では、大きなリクエストボディを送信し、レスポンスボディが期待通りに「hi」であることを確認します。これにより、ReadFromの動作順序が正しく、データが完全に転送されることが検証されます。

関連リンク

参考にした情報源リンク