[インデックス 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
ファイル自体の最終更新時刻ではなく、親ディレクトリの最終更新時刻が使用されていました。
この問題は、以下のようなシナリオで顕在化します。
- クライアントが
/
をリクエストし、サーバーは/index.html
を返す。この際、Last-Modified
ヘッダにはディレクトリの更新時刻が設定される。 - クライアントは、次に同じリソースをリクエストする際に、前回の
Last-Modified
ヘッダの値をIf-Modified-Since
ヘッダに含めて送信する。 - サーバーは
If-Modified-Since
ヘッダの値とディレクトリの更新時刻を比較する。 - もし
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.FileSystem
のOpen
メソッドが返すインターフェースで、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.go
に TestDirectoryIfNotModified
という新しいテストケースが追加されました。このテストは、fakeFileInfo
, fakeFile
, fakeFS
といったモックオブジェクトを使用して、仮想的なファイルシステムを構築し、以下のシナリオをシミュレートします。
index.html
を含むディレクトリをセットアップし、初期リクエストでindex.html
が正しく返され、Last-Modified
ヘッダがindex.html
の更新時刻に基づいていることを確認します。- クライアントが
If-Modified-Since
ヘッダを付けて再リクエストし、304 Not Modified
が返されることを確認します。 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.ReadSeeker
とfakeFileInfo
をラップし、http.File
インターフェースのメソッド(Close
,Stat
,Readdir
)を実装します。fakeFS
:map[string]*fakeFileInfo
を基盤とし、http.FileSystem
インターフェースのOpen
メソッドを実装します。これにより、パスに対応するfakeFileInfo
を返す仮想的なファイルシステムを構築できます。
-
TestDirectoryIfNotModified
関数:indexContents
,fileMod
,dirMod
を定義し、index.html
の内容、index.html
の更新時刻、ディレクトリの更新時刻をそれぞれ設定します。ここで、index.html
の更新時刻とディレクトリの更新時刻を意図的に異なる値に設定します。fakeFS
を作成し、ルートディレクトリ (/
) と/index.html
を登録します。ルートディレクトリはdirMod
を持ち、index.html
はfileMod
を持ちます。httptest.NewServer
を使用して、このfakeFS
を提供するテスト用のHTTPサーバーを起動します。- 最初の
GET
リクエストをサーバーに送信し、レスポンスボディがindexContents
と一致すること、そしてLast-Modified
ヘッダがindex.html
の更新時刻 (fileModStr
) と一致することを確認します。これは、index.html
が正しく提供され、その更新時刻が使用されていることを検証します。 - 次に、最初のレスポンスの
Last-Modified
ヘッダをIf-Modified-Since
ヘッダに設定して再リクエストを送信します。このとき、サーバーが304 Not Modified
ステータスコードを返すことを確認します。これは、キャッシュが正しく機能していることを検証します。 - 重要なステップ:
indexFile.modtime
を1時間進めますが、dirMod
(ディレクトリの更新時刻) は変更しません。これにより、index.html
の内容が更新されたが、親ディレクトリの更新時刻は変わっていないというシナリオをシミュレートします。 - 再度、同じ
If-Modified-Since
ヘッダ(古いindex.html
の更新時刻に基づく)を付けてリクエストを送信します。このとき、サーバーが200 OK
ステータスコードを返し、新しいコンテンツが取得できることを確認します。これは、index.html
の更新が正しく検出され、キャッシュがバイパスされたことを検証します。
このテストは、index.html
の更新時刻がディレクトリの更新時刻とは独立して If-Modified-Since
の判断に利用されるようになったことを明確に示しており、このコミットの修正が意図通りに機能していることを保証します。
関連リンク
- Go Issue #3414: https://github.com/golang/go/issues/3414
- Go CL 6300081: https://golang.org/cl/6300081
参考にした情報源リンク
- HTTP Caching - MDN Web Docs: https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching
net/http
package documentation - Go: https://pkg.go.dev/net/httpos
package documentation - Go: https://pkg.go.dev/oshttptest
package documentation - Go: https://pkg.go.dev/net/http/httptest- Go
FileServer
andindex.html
behavior (general understanding)