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

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

このコミットは、Go言語の標準ライブラリ net/http パッケージにおける FileServer の挙動を修正するものです。具体的には、ディレクトリがリクエストされ、かつそのディレクトリ内に index.html ファイルが存在する場合に、HTTPの If-Modified-Since ヘッダの処理において、ディレクトリ自体の更新時刻ではなく index.html ファイルの更新時刻を使用するように変更しています。これにより、index.html の内容が更新された際に、クライアントが適切に新しいコンテンツを取得できるようになります。

コミット

commit 45969825b5e502a99615fc296bc1acca1881170a
Author: Brad Fitzpatrick <bradfitz@golang.org>
Date:   Wed Jun 13 14:53:05 2012 -0700

    net/http: use index.html modtime (not directory) for If-Modified-Since
    
    Thanks to Håvid Falch for finding the problem.
    
    Fixes #3414
    
    R=r, rsc
    CC=golang-dev
    https://golang.org/cl/6300081

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

https://github.com/golang/go/commit/45969825b5e502a99615fc296bc1acca1881170a

元コミット内容

net/http: use index.html modtime (not directory) for If-Modified-Since

Thanks to Håvid Falch for finding the problem.

Fixes #3414

R=r, rsc
CC=golang-dev
https://golang.org/cl/6300081

変更の背景

この変更は、net/http パッケージの FileServer が、ディレクトリへのリクエストに対して index.html を提供する際に、HTTP キャッシュの挙動に問題があったことに起因します。

従来の FileServer の実装では、クライアントがディレクトリ(例: //some/path/)をリクエストし、そのディレクトリ内に index.html ファイルが存在する場合、FileServer はその index.html の内容を返していました。しかし、If-Modified-Since ヘッダのチェックには、index.html ファイル自体の最終更新時刻ではなく、親ディレクトリの最終更新時刻が使用されていました。

この問題は、以下のようなシナリオで顕在化します。

  1. クライアントが / をリクエストし、サーバーは /index.html を返す。この際、Last-Modified ヘッダにはディレクトリの更新時刻が設定される。
  2. クライアントは、次に同じリソースをリクエストする際に、前回の Last-Modified ヘッダの値を If-Modified-Since ヘッダに含めて送信する。
  3. サーバーは If-Modified-Since ヘッダの値とディレクトリの更新時刻を比較する。
  4. もし index.html の内容が更新されたとしても、ディレクトリの更新時刻が変わっていなければ、サーバーは 304 Not Modified ステータスコードを返してしまう。

結果として、クライアントは index.html の新しい内容を取得できず、古いキャッシュされたコンテンツを表示し続けることになります。これは、ウェブサーバーとして非常に重要なキャッシュの整合性に関するバグであり、Håvid Falch氏によって発見され、GoのIssue #3414として報告されました。このコミットは、この問題を解決するために導入されました。

前提知識の解説

このコミットを理解するためには、以下の前提知識が必要です。

1. HTTP キャッシングと If-Modified-Since / Last-Modified ヘッダ

HTTPキャッシングは、ウェブのパフォーマンスを向上させるための重要なメカニズムです。クライアント(ブラウザなど)は、以前に取得したリソースをローカルに保存し、次回同じリソースが必要になったときにサーバーに再検証を求めます。

  • Last-Modified ヘッダ: サーバーがレスポンスを返す際に、リソースの最終更新日時を示すために使用します。例: Last-Modified: Wed, 21 Oct 2015 07:28:00 GMT
  • If-Modified-Since ヘッダ: クライアントがサーバーにリソースの再検証を求める際に、以前に取得したリソースの Last-Modified の値をこのヘッダに含めて送信します。サーバーは、この値とリソースの現在の最終更新日時を比較します。
    • もしリソースが If-Modified-Since で指定された日時以降に更新されていなければ、サーバーはボディなしの 304 Not Modified ステータスコードを返します。クライアントはローカルキャッシュを使用します。
    • もしリソースが更新されていれば、サーバーは 200 OK ステータスコードとともに新しいリソースのボディを返します。

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

net/http はGo言語の標準ライブラリで、HTTPクライアントとサーバーの実装を提供します。

  • http.FileServer: 指定された http.FileSystem インターフェースを実装するオブジェクトからファイルを提供するHTTPハンドラを作成します。静的ファイルの配信によく使われます。
  • http.FileSystem インターフェース: ファイルシステムを抽象化するためのインターフェースで、Open(name string) (File, error) メソッドを持ちます。
  • http.File インターフェース: http.FileSystemOpen メソッドが返すインターフェースで、io.ReadSeeker, io.Closer, Readdir(count int) ([]os.FileInfo, error), Stat() (os.FileInfo, error) メソッドを持ちます。
  • os.FileInfo インターフェース: ファイルのメタデータ(名前、サイズ、更新時刻、ディレクトリかどうかなど)を提供するインターフェースです。特に ModTime() time.Time メソッドはファイルの最終更新時刻を返します。

3. index.html の特別な扱い

多くのウェブサーバーでは、ディレクトリへのリクエスト(例: GET /)があった場合、そのディレクトリ内に index.html という名前のファイルが存在すれば、その index.html の内容を自動的に返すという慣習があります。これは「ディレクトリインデックス」または「デフォルトドキュメント」と呼ばれます。net/http.FileServer もこの慣習に従います。

技術的詳細

このコミットの技術的な核心は、net/http/fs.go 内の serveFile 関数における checkLastModified の呼び出し位置の変更にあります。

serveFile 関数は、http.FileServer の内部で実際にファイルやディレクトリのコンテンツをクライアントに提供する役割を担っています。この関数は、リクエストされたパスがファイルであるかディレクトリであるかを判断し、それに応じて適切な処理を行います。

従来のコードでは、serveFile 関数がリクエストされたパスがディレクトリであると判断した場合、まず最初にそのディレクトリ自体の ModTime() を使って checkLastModified を呼び出していました。

// 変更前 (簡略化)
func serveFile(...) {
    // ...
    if d.IsDir() {
        // ここでディレクトリのModTimeを使ってIf-Modified-Sinceをチェック
        if checkLastModified(w, r, d.ModTime()) {
            return // 304 Not Modified を返す
        }
        // index.html の存在チェックと提供
        index := name + indexPage
        ff, err := fs.Open(index)
        if err == nil {
            // index.html が見つかった場合、その内容を返す
            // しかし、Last-Modified ヘッダはディレクトリのModTimeに基づいている
            // そして、If-Modified-Since のチェックは既にディレクトリのModTimeで行われている
            // ...
        }
    }
    // ...
}

このロジックの問題点は、index.html が存在し、その内容がクライアントに提供される場合でも、If-Modified-Since のチェックがディレクトリの更新時刻に基づいて行われてしまう点です。ディレクトリの更新時刻は、そのディレクトリ内のファイルが追加・削除されたり、ディレクトリ自体のメタデータが変更されたりした場合に更新されますが、index.html の内容が変更されただけでは更新されないことがあります(特に、ファイルの内容だけが変更され、ファイル名やパーミッションなどが変わらない場合)。

このコミットでは、この checkLastModified の呼び出しを、index.html が見つからなかった場合、つまり実際にディレクトリリストを返すか、またはディレクトリとして扱われる場合にのみ行うように変更しました。

// 変更後 (簡略化)
func serveFile(...) {
    // ...
    if d.IsDir() {
        // index.html の存在チェックと提供
        index := name + indexPage
        ff, err := fs.Open(index)
        if err == nil {
            // index.html が見つかった場合、その内容を返す
            // この場合、serveContent が index.html のModTimeを使ってLast-Modifiedを設定し、
            // If-Modified-Since のチェックも適切に行う
            // ...
            return
        }
    }

    // Still a directory? (we didn't find an index.html file)
    if d.IsDir() { // index.html が見つからなかった、またはそもそもディレクトリとして扱われる場合
        // ここで初めてディレクトリのModTimeを使ってIf-Modified-Sinceをチェック
        if checkLastModified(w, r, d.ModTime()) {
            return // 304 Not Modified を返す
        }
        dirList(w, f) // ディレクトリリストを返す
        return
    }

    // serverContent will check modification time
    // ファイルの場合、serveContent がファイルのModTimeを使って適切に処理する
    serveContent(w, r, d.Name(), d.ModTime(), d.Size(), f)
}

この変更により、index.html が提供される場合は、最終的に serveContent 関数が呼び出され、その際に index.html ファイル自体の ModTime()Last-Modified ヘッダの設定と If-Modified-Since のチェックに使用されるようになります。これにより、index.html の内容が更新された際には、クライアントは正しく新しいコンテンツを取得できるようになります。

また、この修正を検証するために、fs_test.goTestDirectoryIfNotModified という新しいテストケースが追加されました。このテストは、fakeFileInfo, fakeFile, fakeFS といったモックオブジェクトを使用して、仮想的なファイルシステムを構築し、以下のシナリオをシミュレートします。

  1. index.html を含むディレクトリをセットアップし、初期リクエストで index.html が正しく返され、Last-Modified ヘッダが index.html の更新時刻に基づいていることを確認します。
  2. クライアントが If-Modified-Since ヘッダを付けて再リクエストし、304 Not Modified が返されることを確認します。
  3. index.html の更新時刻のみを進め(ディレクトリの更新時刻は変更しない)、再度 If-Modified-Since ヘッダを付けてリクエストします。このとき、サーバーが 200 OK を返し、新しいコンテンツが取得できることを確認します。

このテストは、まさにこのコミットが解決しようとしている問題を正確に捉え、修正が正しく機能していることを保証します。

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

diff --git a/src/pkg/net/http/fs.go b/src/pkg/net/http/fs.go
index f35dd32c30..2ef27a18b4 100644
--- a/src/pkg/net/http/fs.go
+++ b/src/pkg/net/http/fs.go
@@ -243,9 +243,6 @@ func serveFile(w ResponseWriter, r *Request, fs FileSystem, name string, redirec
 
 	// use contents of index.html for directory, if present
 	if d.IsDir() {
-		if checkLastModified(w, r, d.ModTime()) {
-			return
-		}
 		index := name + indexPage
 		ff, err := fs.Open(index)
 		if err == nil {
@@ -259,11 +256,16 @@ func serveFile(w ResponseWriter, r *Request, fs FileSystem, name string, redirec
 		}
 	}
 
+	// Still a directory? (we didn't find an index.html file)
 	if d.IsDir() {
+		if checkLastModified(w, r, d.ModTime()) {
+			return
+		}
 		dirList(w, f)
 		return
 	}
 
+	// serverContent will check modification time
 	serveContent(w, r, d.Name(), d.ModTime(), d.Size(), f)
 }
 
diff --git a/src/pkg/net/http/fs_test.go b/src/pkg/net/http/fs_test.go
index 5aa93ce583..45580cbd2a 100644
--- a/src/pkg/net/http/fs_test.go
+++ b/src/pkg/net/http/fs_test.go
@@ -16,6 +16,7 @@ import (
 	"net/url"
 	"os"
 	"os/exec"
+	"path"
 	"path/filepath"
 	"regexp"
 	"runtime"
@@ -325,6 +326,122 @@ func TestServeIndexHtml(t *testing.T) {
 	}
 }
 
+type fakeFileInfo struct {
+	dir      bool
+	basename string
+	modtime  time.Time
+	ents     []*fakeFileInfo
+	contents string
+}
+
+func (f *fakeFileInfo) Name() string       { return f.basename }
+func (f *fakeFileInfo) Sys() interface{}   { return nil }
+func (f *fakeFileInfo) ModTime() time.Time { return f.modtime }
+func (f *fakeFileInfo) IsDir() bool        { return f.dir }
+func (f *fakeFileInfo) Size() int64        { return int64(len(f.contents)) }
+func (f *fakeFileInfo) Mode() os.FileMode {
+	if f.dir {
+		return 0755 | os.ModeDir
+	}
+	return 0644
+}
+
+type fakeFile struct {
+	io.ReadSeeker
+	fi   *fakeFileInfo
+	path string // as opened
+}
+
+func (f *fakeFile) Close() error               { return nil }
+func (f *fakeFile) Stat() (os.FileInfo, error) { return f.fi, nil }
+func (f *fakeFile) Readdir(count int) ([]os.FileInfo, error) {
+	if !f.fi.dir {
+		return nil, os.ErrInvalid
+	}
+	var fis []os.FileInfo
+	for _, fi := range f.fi.ents {
+		fis = append(fis, fi)
+	}
+	return fis, nil
+}
+
+type fakeFS map[string]*fakeFileInfo
+
+func (fs fakeFS) Open(name string) (File, error) {
+	name = path.Clean(name)
+	f, ok := fs[name]
+	if !ok {
+		println("fake filesystem didn't find file", name)
+		return nil, os.ErrNotExist
+	}
+	return &fakeFile{ReadSeeker: strings.NewReader(f.contents), fi: f, path: name}, nil
+}
+
+func TestDirectoryIfNotModified(t *testing.T) {
+	const indexContents = "I am a fake index.html file"
+	fileMod := time.Unix(1000000000, 0).UTC()
+	fileModStr := fileMod.Format(TimeFormat)
+	dirMod := time.Unix(123, 0).UTC()
+	indexFile := &fakeFileInfo{
+		basename: "index.html",
+		modtime:  fileMod,
+		contents: indexContents,
+	}
+	fs := fakeFS{
+		"/": &fakeFileInfo{
+			dir:     true,
+			modtime: dirMod,
+			ents:    []*fakeFileInfo{indexFile},
+		},
+		"/index.html": indexFile,
+	}
+
+	ts := httptest.NewServer(FileServer(fs))
+	defer ts.Close()
+
+	res, err := Get(ts.URL)
+	if err != nil {
+		t.Fatal(err)
+	}
+	b, err := ioutil.ReadAll(res.Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if string(b) != indexContents {
+		t.Fatalf("Got body %q; want %q", b, indexContents)
+	}
+	res.Body.Close()
+
+	lastMod := res.Header.Get("Last-Modified")
+	if lastMod != fileModStr {
+		t.Fatalf("initial Last-Modified = %q; want %q", lastMod, fileModStr)
+	}
+
+	req, _ := NewRequest("GET", ts.URL, nil)
+	req.Header.Set("If-Modified-Since", lastMod)
+
+	res, err = DefaultClient.Do(req)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if res.StatusCode != 304 {
+		t.Fatalf("Code after If-Modified-Since request = %v; want 304", res.StatusCode)
+	}
+	res.Body.Close()
+
+	// Advance the index.html file's modtime, but not the directory's.
+	indexFile.modtime = indexFile.modtime.Add(1 * time.Hour)
+
+	res, err = DefaultClient.Do(req)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if res.StatusCode != 200 {
+		t.Fatalf("Code after second If-Modified-Since request = %v; want 200; res is %#v", res.StatusCode, res)
+	}
+	res.Body.Close()
+}
+
 func TestServeContent(t *testing.T) {
 	type req struct {
 		name    string

コアとなるコードの解説

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

  • 変更前:

    	if d.IsDir() {
    		if checkLastModified(w, r, d.ModTime()) {
    			return
    		}
    		index := name + indexPage
    		ff, err := fs.Open(index)
    		// ...
    	}
    

    ディレクトリであると判断された直後に、ディレクトリの ModTime() を使って checkLastModified が呼び出されていました。これにより、index.html が存在する場合でも、ディレクトリの更新時刻がキャッシュの判断基準となっていました。

  • 変更後:

    	if d.IsDir() {
    		index := name + indexPage
    		ff, err := fs.Open(index)
    		if err == nil {
    			// ... index.html を提供するロジック ...
    		}
    	}
    
    	// Still a directory? (we didn't find an index.html file)
    	if d.IsDir() {
    		if checkLastModified(w, r, d.ModTime()) {
    			return
    		}
    		dirList(w, f)
    		return
    	}
    

    checkLastModified の呼び出しが、index.html の存在チェックと提供ロジックのに移動されました。 新しいロジックでは、まず index.html が存在するかどうかを確認します。

    • もし index.html が存在し、それが開かれた場合、そのファイルの内容が serveContent 関数によって提供されます。serveContent は、提供されるファイル(この場合は index.html)の ModTime() を使用して Last-Modified ヘッダを設定し、If-Modified-Since のチェックも適切に行います。これにより、index.html の更新時刻がキャッシュの判断基準となります。
    • もし index.html が存在しない、または開けなかった場合、そしてリクエストされたパスが依然としてディレクトリである場合(d.IsDir() が真の場合)、その時点で初めてディレクトリの ModTime() を使って checkLastModified が呼び出されます。これは、実際にディレクトリリストを返す場合(dirList)にのみ適用されるため、正しい挙動となります。

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

このファイルには、TestDirectoryIfNotModified という新しいテスト関数が追加されました。このテストは、問題の再現と修正の検証を行うためのものです。

  • fakeFileInfo, fakeFile, fakeFS 構造体: これらの構造体は、os.FileInfo, http.File, http.FileSystem インターフェースをそれぞれモック(模擬)するために定義されています。これにより、実際のファイルシステムに依存せずに、テスト内で特定のファイルやディレクトリの構造、内容、更新時刻を自由に設定できます。

    • fakeFileInfo: ファイルやディレクトリのメタデータ(名前、更新時刻、ディレクトリかどうか、内容など)を保持します。
    • fakeFile: io.ReadSeekerfakeFileInfo をラップし、http.File インターフェースのメソッド(Close, Stat, Readdir)を実装します。
    • fakeFS: map[string]*fakeFileInfo を基盤とし、http.FileSystem インターフェースの Open メソッドを実装します。これにより、パスに対応する fakeFileInfo を返す仮想的なファイルシステムを構築できます。
  • TestDirectoryIfNotModified 関数:

    1. indexContents, fileMod, dirMod を定義し、index.html の内容、index.html の更新時刻、ディレクトリの更新時刻をそれぞれ設定します。ここで、index.html の更新時刻とディレクトリの更新時刻を意図的に異なる値に設定します。
    2. fakeFS を作成し、ルートディレクトリ (/) と /index.html を登録します。ルートディレクトリは dirMod を持ち、index.htmlfileMod を持ちます。
    3. httptest.NewServer を使用して、この fakeFS を提供するテスト用のHTTPサーバーを起動します。
    4. 最初の GET リクエストをサーバーに送信し、レスポンスボディが indexContents と一致すること、そして Last-Modified ヘッダが index.html の更新時刻 (fileModStr) と一致することを確認します。これは、index.html が正しく提供され、その更新時刻が使用されていることを検証します。
    5. 次に、最初のレスポンスの Last-Modified ヘッダを If-Modified-Since ヘッダに設定して再リクエストを送信します。このとき、サーバーが 304 Not Modified ステータスコードを返すことを確認します。これは、キャッシュが正しく機能していることを検証します。
    6. 重要なステップ: indexFile.modtime を1時間進めますが、dirMod (ディレクトリの更新時刻) は変更しません。これにより、index.html の内容が更新されたが、親ディレクトリの更新時刻は変わっていないというシナリオをシミュレートします。
    7. 再度、同じ If-Modified-Since ヘッダ(古い index.html の更新時刻に基づく)を付けてリクエストを送信します。このとき、サーバーが 200 OK ステータスコードを返し、新しいコンテンツが取得できることを確認します。これは、index.html の更新が正しく検出され、キャッシュがバイパスされたことを検証します。

このテストは、index.html の更新時刻がディレクトリの更新時刻とは独立して If-Modified-Since の判断に利用されるようになったことを明確に示しており、このコミットの修正が意図通りに機能していることを保証します。

関連リンク

参考にした情報源リンク