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

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

このコミットは、Go言語の標準ライブラリ net/http パッケージにおいて、ServeContent 関数がHTTPの複数バイト範囲リクエスト(Multiple Byte Ranges)をサポートするように拡張するものです。これにより、クライアントは単一のリクエストでファイル内の複数の非連続な部分を要求できるようになり、サーバーは multipart/byteranges コンテンツタイプを使用してそれらの部分を効率的に提供します。

コミット

commit fa6f9b4a3e2c1cd5b3da7786250f3c49c1f40325
Author: Brad Fitzpatrick <bradfitz@golang.org>
Date:   Fri Jun 29 07:44:04 2012 -0700

    net/http: support multiple byte ranges in ServeContent
    
    Fixes #3784
    
    R=golang-dev, adg
    CC=golang-dev
    https://golang.org/cl/6351052

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

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

元コミット内容

このコミットは、net/http パッケージの ServeContent 関数に、HTTPの複数バイト範囲リクエストを処理する機能を追加します。以前は単一のバイト範囲リクエストのみをサポートしていましたが、この変更により、RFC 2616で定義されている multipart/byteranges コンテンツタイプを使用して、複数のバイト範囲を効率的に提供できるようになります。これにより、クライアントは一度のリクエストでファイルの複数の部分を取得でき、特に大きなファイルのダウンロードやストリーミングにおいて、ネットワーク効率が向上します。

変更の背景

この変更の背景には、Go言語の net/http パッケージがHTTPのバイト範囲リクエストの完全な仕様をサポートしていなかったという問題があります。具体的には、Issue #3784で報告されたように、ServeContent 関数が単一のバイト範囲リクエストしか処理できず、複数のバイト範囲が指定された場合にはエラーを返していました。

HTTPのバイト範囲リクエストは、クライアントがファイルの特定の部分のみを要求することを可能にする重要な機能です。これは、部分的なダウンロード、中断されたダウンロードの再開、ビデオやオーディオのストリーミング、または大きなファイルの特定の部分へのアクセスなど、多くのシナリオで利用されます。

RFC 2616(HTTP/1.1の仕様)では、クライアントが複数のバイト範囲を要求した場合、サーバーは multipart/byteranges コンテンツタイプを使用して、各範囲のデータを個別のパートとして含むレスポンスを返すことが規定されています。このコミットは、この仕様に準拠し、net/http パッケージの堅牢性と互換性を向上させることを目的としています。

前提知識の解説

HTTPバイト範囲リクエスト (HTTP Byte Range Requests)

HTTPバイト範囲リクエストは、HTTP/1.1プロトコルの一部であり、クライアントがサーバーに対して、リソース(ファイルなど)の全体ではなく、その特定の部分(バイト範囲)のみを要求できるようにするメカニズムです。これは、Range ヘッダーフィールドを使用して行われます。

  • Range ヘッダー: クライアントがリクエストに含めるヘッダーで、要求するバイト範囲を指定します。
    • 例: Range: bytes=0-499 (最初の500バイト)
    • 例: Range: bytes=500-999 (500バイト目から999バイト目まで)
    • 例: Range: bytes=-500 (最後の500バイト)
    • 例: Range: bytes=500- (500バイト目から最後まで)
    • 複数範囲の例: Range: bytes=0-499, 1000-1499 (最初の500バイトと1000バイト目から1499バイト目まで)

HTTPステータスコード

  • 200 OK: リクエストが成功し、リソース全体が返された場合。
  • 206 Partial Content: クライアントのバイト範囲リクエストが成功し、リソースの部分が返された場合。
  • 416 Requested Range Not Satisfiable: クライアントが要求したバイト範囲がリソースの範囲外である場合。

Content-Range ヘッダー

サーバーが 206 Partial Content レスポンスを返す際に含めるヘッダーで、返されるデータのバイト範囲とリソース全体のサイズを示します。

  • 例: Content-Range: bytes 0-499/10000 (10000バイトのリソースのうち、0-499バイト目を返している)

multipart/byteranges コンテンツタイプ

クライアントが複数のバイト範囲を要求し、サーバーがそれらをすべて返す場合に使用されるMIMEタイプです。この場合、レスポンスボディは複数のパートに分割され、各パートが要求されたバイト範囲のデータを含みます。各パートは独自の Content-Type ヘッダーと Content-Range ヘッダーを持ちます。

  • MIME Multipart: 複数の異なるデータタイプを単一のメッセージボディに結合するための標準的な方法です。各パートは境界文字列(boundary string)で区切られます。
  • multipart/byteranges は、特にバイト範囲リクエストのために設計されたMIME Multipartのサブタイプです。

io.Pipeio.CopyN

  • io.Pipe(): Go言語の io パッケージで提供される関数で、メモリ内でパイプを作成します。これは、io.Readerio.Writer のペアを返します。一方の端に書き込まれたデータは、もう一方の端から読み取ることができます。これは、ストリーミング処理や、データを生成するゴルーチンとデータを消費するゴルーチンを接続するのに便利です。
  • io.CopyN(dst Writer, src Reader, n int64): src から dst へ最大 n バイトをコピーします。これは、特定のバイト数だけをコピーしたい場合に効率的です。

mime/multipart パッケージ

Go言語の標準ライブラリで、MIME multipartメッセージの作成と解析をサポートします。このコミットでは、multipart.NewWriter を使用して multipart/byteranges レスポンスボディを構築しています。

技術的詳細

このコミットの主要な変更は、net/http/fs.go 内の serveContent 関数にあります。

  1. 複数範囲の検出と処理:

    • parseRange 関数が、Range ヘッダーから複数のバイト範囲を正しく解析できるようになりました。
    • len(ranges) > 1 の場合、サーバーは StatusPartialContent (206) ステータスコードを返し、multipart/byteranges コンテンツタイプを使用するように設定されます。
  2. multipart/byteranges レスポンスの構築:

    • io.Pipe() を使用して、sendContent という io.Reader が作成されます。これにより、レスポンスボディの書き込みと読み取りが並行して行われます。
    • multipart.NewWriter(pw) を使用して、multipart/byteranges メッセージを構築するためのライターが作成されます。
    • 新しいゴルーチンが起動され、このゴルーチン内で各バイト範囲が処理されます。
      • 各範囲に対して mw.CreatePart() が呼び出され、個別のMIMEパートが作成されます。
      • content.Seek() を使用して、元のコンテンツの正しい開始位置にシークします。
      • io.CopyN(part, content, ra.length) を使用して、指定されたバイト範囲のデータがMIMEパートにコピーされます。
      • すべての範囲が処理された後、mw.Close()pw.Close() が呼び出され、パイプが閉じられます。
    • w.Header().Set("Content-Type", "multipart/byteranges; boundary="+mw.Boundary()) により、レスポンスヘッダーに正しいコンテンツタイプと境界文字列が設定されます。
  3. 単一範囲の処理の改善:

    • 単一のバイト範囲の場合の Content-Range ヘッダーの生成ロジックが、httpRange 構造体の新しいメソッド contentRange() にカプセル化されました。
    • RFC 2616の規定に従い、単一範囲のリクエストに対しては multipart/byteranges を使用しないことが保証されます。
  4. parseRange 関数の堅牢性向上:

    • Range ヘッダーの解析において、空白文字のトリミング (strings.TrimSpace) が追加され、より柔軟な入力に対応できるようになりました。
    • 無効な範囲指定に対するエラーハンドリングが改善されました。
  5. テストの追加と修正:

    • fs_test.go に、複数バイト範囲リクエストのテストケースが追加されました。これらのテストは、multipart/byteranges レスポンスが正しく構築され、各パートのデータとヘッダーが期待通りであることを検証します。
    • range_test.go に、parseRange 関数の新しいテストケースが追加され、様々な有効および無効な範囲文字列の解析が正しく行われることを確認します。

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

src/pkg/net/http/fs.go

--- a/src/pkg/net/http/fs.go
+++ b/src/pkg/net/http/fs.go
@@ -11,6 +11,8 @@ import (
 	"fmt"
 	"io"
 	"mime"
+	"mime/multipart"
+	"net/textproto"
 	"os"
 	"path"
 	"path/filepath"
@@ -123,8 +125,9 @@ func serveContent(w ResponseWriter, r *Request, name string, modtime time.Time,
 	code := StatusOK
 
 	// If Content-Type isn't set, use the file's extension to find it.
-	if w.Header().Get("Content-Type") == "" {
-		ctype := mime.TypeByExtension(filepath.Ext(name))
+	ctype := w.Header().Get("Content-Type")
+	if ctype == "" {
+		ctype = mime.TypeByExtension(filepath.Ext(name))
 		if ctype == "" {
 			// read a chunk to decide between utf-8 text and binary
 			var buf [1024]byte
@@ -141,18 +144,27 @@ func serveContent(w ResponseWriter, r *Request, name string, modtime time.Time,
 	}
 
 	// handle Content-Range header.
-	// TODO(adg): handle multiple ranges
 	sendSize := size
+	var sendContent io.Reader = content
 	if size >= 0 {
 		ranges, err := parseRange(r.Header.Get("Range"), size)
-		if err == nil && len(ranges) > 1 {
-			err = errors.New("multiple ranges not supported")
-		}
 		if err != nil {
 			Error(w, err.Error(), StatusRequestedRangeNotSatisfiable)
 			return
 		}
-		if len(ranges) == 1 {
+		switch {
+		case len(ranges) == 1:
+			// RFC 2616, Section 14.16:
+			// "When an HTTP message includes the content of a single
+			// range (for example, a response to a request for a
+			// single range, or to a request for a set of ranges
+			// that overlap without any holes), this content is
+			// transmitted with a Content-Range header, and a
+			// Content-Length header showing the number of bytes
+			// actually transferred.
+			// ...
+			// A response to a request for a single range MUST NOT
+			// be sent using the multipart/byteranges media type."
 			ra := ranges[0]
 			if _, err := content.Seek(ra.start, os.SEEK_SET); err != nil {
 				Error(w, err.Error(), StatusRequestedRangeNotSatisfiable)
@@ -160,7 +172,41 @@ func serveContent(w ResponseWriter, r *Request, name string, modtime time.Time,
 			}
 			sendSize = ra.length
 			code = StatusPartialContent
-			w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", ra.start, ra.start+ra.length-1, size))
+			w.Header().Set("Content-Range", ra.contentRange(size))
+		case len(ranges) > 1:
+			for _, ra := range ranges {
+				if ra.start > size {
+					Error(w, err.Error(), StatusRequestedRangeNotSatisfiable)
+					return
+				}
+			}
+			sendSize = rangesMIMESize(ranges, ctype, size)
+			code = StatusPartialContent
+
+			pr, pw := io.Pipe()
+			mw := multipart.NewWriter(pw)
+			w.Header().Set("Content-Type", "multipart/byteranges; boundary="+mw.Boundary())
+			sendContent = pr
+			defer pr.Close() // cause writing goroutine to fail and exit if CopyN doesn't finish.
+			go func() {
+				for _, ra := range ranges {
+					part, err := mw.CreatePart(ra.mimeHeader(ctype, size))
+					if err != nil {
+						pw.CloseWithError(err)
+						return
+					}
+					if _, err := content.Seek(ra.start, os.SEEK_SET); err != nil {
+						pw.CloseWithError(err)
+						return
+					}
+					if _, err := io.CopyN(part, content, ra.length); err != nil {
+						pw.CloseWithError(err)
+						return
+					}
+				}
+				mw.Close()
+				pw.Close()
+			}()
 		}
 
 		w.Header().Set("Accept-Ranges", "bytes")
@@ -172,11 +218,7 @@ func serveContent(w ResponseWriter, r *Request, name string, modtime time.Time,
 	w.WriteHeader(code)
 
 	if r.Method != "HEAD" {
-		if sendSize == -1 {
-			io.Copy(w, content)
-		} else {
-			io.CopyN(w, content, sendSize)
-		}
+		io.CopyN(w, sendContent, sendSize)
 	}
 }
 
@@ -314,6 +356,17 @@ type httpRange struct {
 	start, length int64
 }
 
+func (r httpRange) contentRange(size int64) string {
+	return fmt.Sprintf("bytes %d-%d/%d", r.start, r.start+r.length-1, size)
+}
+
+func (r httpRange) mimeHeader(contentType string, size int64) textproto.MIMEHeader {
+	return textproto.MIMEHeader{
+		"Content-Range": {r.contentRange(size)},
+		"Content-Type":  {contentType},
+	}
+}
+
 // parseRange parses a Range header string as per RFC 2616.
 func parseRange(s string, size int64) ([]httpRange, error) {
 	if s == "" {
@@ -325,11 +378,15 @@ func parseRange(s string, size int64) ([]httpRange, error) {
 	}
 	var ranges []httpRange
 	for _, ra := range strings.Split(s[len(b):], ",") {
+		ra = strings.TrimSpace(ra)
+		if ra == "" {
+			continue
+		}
 		i := strings.Index(ra, "-")
 		if i < 0 {
 			return nil, errors.New("invalid range")
 		}
-		start, end := ra[:i], ra[i+1:]
+		start, end := strings.TrimSpace(ra[:i]), strings.TrimSpace(ra[i+1:])
 		var r httpRange
 		if start == "" {
 			// If no start is specified, end specifies the
@@ -367,3 +424,25 @@ func parseRange(s string, size int64) ([]httpRange, error) {
 	}
 	return ranges, nil
 }
+
+// countingWriter counts how many bytes have been written to it.
+type countingWriter int64
+
+func (w *countingWriter) Write(p []byte) (n int, err error) {
+	*w += countingWriter(len(p))
+	return len(p), nil
+}
+
+// rangesMIMESize returns the nunber of bytes it takes to encode the
+// provided ranges as a multipart response.
+func rangesMIMESize(ranges []httpRange, contentType string, contentSize int64) (encSize int64) {
+	var w countingWriter
+	mw := multipart.NewWriter(&w)
+	for _, ra := range ranges {
+		mw.CreatePart(ra.mimeHeader(contentType, contentSize))
+		encSize += ra.length
+	}
+	mw.Close()
+	encSize += int64(w)
+	return
+}

src/pkg/net/http/fs_test.go

このファイルでは、ServeFileRangeTests に複数範囲のテストケースが追加され、multipart/byteranges レスポンスの検証ロジックが実装されています。

src/pkg/net/http/range_test.go

このファイルでは、parseRange 関数のテストケースが拡張され、空白文字を含む範囲指定や、より複雑な複数範囲のシナリオがカバーされています。

コアとなるコードの解説

serveContent 関数内の変更

  • import の追加: mime/multipartnet/textproto がインポートされ、MIME multipartメッセージの構築とヘッダー操作が可能になりました。
  • ctype の初期化: w.Header().Get("Content-Type") を先に取得し、もし空であればファイル拡張子から推測するように変更されました。これにより、既に Content-Type が設定されている場合はそれを尊重します。
  • sendContent の導入: io.Reader 型の sendContent 変数が導入され、単一範囲または複数範囲のどちらの場合でも、最終的にこのリーダーからデータがコピーされるように抽象化されました。
  • switch 文による範囲処理の分岐:
    • len(ranges) == 1 の場合: 以前と同様に単一範囲として処理されますが、Content-Range ヘッダーの生成に ra.contentRange(size) メソッドが使用されます。RFC 2616の規定に従い、単一範囲では multipart/byteranges を使用しないことがコメントで明記されています。
    • len(ranges) > 1 の場合:
      • io.Pipe() を使用してパイプを作成し、pr (Reader) を sendContent に割り当てます。
      • multipart.NewWriter(pw)mw を作成し、レスポンスの Content-Typemultipart/byteranges に設定します。
      • 新しいゴルーチンを起動し、その中で各範囲をループ処理します。
        • mw.CreatePart(ra.mimeHeader(ctype, size)) で各範囲のMIMEパートを作成します。mimeHeader は新しいヘルパーメソッドで、パートの Content-RangeContent-Type ヘッダーを生成します。
        • content.Seek(ra.start, os.SEEK_SET) で元のコンテンツの読み取り位置を調整します。
        • io.CopyN(part, content, ra.length) で指定されたバイト数のデータをパートにコピーします。
        • エラーが発生した場合は pw.CloseWithError(err) でパイプを閉じ、ゴルーチンを終了させます。
      • すべてのパートが書き込まれた後、mw.Close()pw.Close() でライターとパイプを閉じます。
  • io.CopyN(w, sendContent, sendSize): 最終的に、sendContent から w (ResponseWriter) へデータがコピーされます。これにより、単一範囲でも複数範囲でも統一された方法でレスポンスボディが送信されます。

httpRange 構造体と新しいメソッド

  • func (r httpRange) contentRange(size int64) string:
    • Content-Range ヘッダーの文字列を生成するためのヘルパーメソッドです。これにより、コードの重複が排除され、可読性が向上します。
  • func (r httpRange) mimeHeader(contentType string, size int64) textproto.MIMEHeader:
    • multipart/byteranges の各パートのヘッダー(Content-RangeContent-Type)を生成するためのヘルパーメソッドです。

parseRange 関数の変更

  • strings.TrimSpace(ra): 各範囲文字列の先頭と末尾の空白を削除することで、より柔軟な入力に対応できるようになりました。
  • strings.TrimSpace(ra[:i]), strings.TrimSpace(ra[i+1:]): 開始と終了のバイトオフセットを解析する際にも空白をトリミングします。

countingWriterrangesMIMESize

  • type countingWriter int64:
    • io.Writer インターフェースを実装し、書き込まれたバイト数をカウントするシンプルな型です。
  • func rangesMIMESize(ranges []httpRange, contentType string, contentSize int64) (encSize int64):
    • multipart/byteranges レスポンス全体のエンコードされたサイズ(各パートのデータサイズとMIME境界、ヘッダーのオーバーヘッドを含む)を計算するためのヘルパー関数です。これは、Content-Length ヘッダーの値を正確に設定するために使用されます。countingWriter を利用して、multipart.NewWriter が書き込む境界やヘッダーのサイズを測定しています。

これらの変更により、net/http パッケージはHTTPの複数バイト範囲リクエストを完全にサポートし、より堅牢で標準に準拠したファイルサービング機能を提供できるようになりました。

関連リンク

参考にした情報源リンク

  • 上記のRFC 2616のドキュメント
  • Go言語の net/http パッケージのドキュメント
  • Go言語の mime/multipart パッケージのドキュメント
  • Go言語の io パッケージのドキュメント
  • GitHub上のGo言語リポジトリの該当コミットと関連するIssue
  • HTTPバイト範囲リクエストに関する一般的なウェブ上の解説記事