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

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

このコミットは、Go言語の標準ライブラリであるnet/urlパッケージ内のValues.Encode()メソッドの挙動を改善するものです。net/urlパッケージは、URLの解析、構築、エンコーディング、デコーディングといった機能を提供します。Values型は、URLクエリパラメータやフォームデータを表現するために使用されるmap[string][]stringのエイリアスであり、Encode()メソッドはこのValues型のデータをURLエンコードされたクエリ文字列形式(例: key1=value1&key2=value2)に変換します。

コミット

このコミットの主な目的は、net/urlパッケージのValues.Encode()メソッドが生成するクエリ文字列の順序を決定論的にすることです。具体的には、クエリパラメータのキーをアルファベット順にソートしてからエンコードすることで、同じ入力に対して常に同じ出力文字列が生成されるように変更されました。これにより、Go言語のmapのイテレーション順序が非決定論的であることによる問題(例: キャッシュの非効率性、テストの不安定性)が解消されます。

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

https://github.com/golang/go/commit/61809cd182372930fe821b74e182bc3dd7b9f439

元コミット内容

net/url: sort keys in Encode; don't enumerate map randomly

R=golang-dev, adg
CC=golang-dev
https://golang.org/cl/6303098

変更の背景

Go言語のmap(ハッシュマップ)は、その設計上、要素をイテレートする際の順序が保証されません。これは、マップの内部実装がハッシュテーブルに基づいており、要素の追加や削除、リサイズなどによってメモリ上の配置が変化するためです。この非決定論的な順序は、多くの場合は問題になりませんが、特定の状況下では予期せぬ挙動やバグを引き起こす可能性があります。

net/url.Values.Encode()メソッドは、内部でmap[string][]stringであるValues型をイテレートしてクエリ文字列を構築していました。このため、同じValuesインスタンスであっても、Encode()を複数回呼び出すたびに、key1=value1&key2=value2key2=value2&key1=value1のように、キーの順序が異なるクエリ文字列が生成される可能性がありました。

このような非決定論的な出力は、以下のような問題を引き起こします。

  1. キャッシュの非効率性: WebプロキシやCDN、アプリケーションレベルのキャッシュなどがURLをキーとして使用する場合、同じ意味のURLでもクエリパラメータの順序が異なると、異なるURLとして扱われ、キャッシュミスが発生しやすくなります。これにより、キャッシュのヒット率が低下し、パフォーマンスに悪影響を及ぼす可能性があります。
  2. テストの不安定性 (Flaky Tests): クエリ文字列の順序に依存するテストケースがある場合、テストが実行されるたびに成功したり失敗したりする「Flaky Test」の原因となります。これは、テストの信頼性を損ない、開発者の生産性を低下させます。
  3. 署名やハッシュの不整合: APIリクエストの署名やデータのハッシュ値を計算する際に、クエリ文字列全体を使用する場合、順序が異なると異なる署名やハッシュ値が生成され、認証やデータ整合性の検証が失敗する原因となります。
  4. デバッグの困難さ: 開発者がデバッグを行う際に、同じ入力に対して異なる出力が得られると、問題の特定が困難になります。

これらの問題を解決し、Values.Encode()メソッドの出力をより予測可能で安定したものにするために、キーをソートする変更が導入されました。

前提知識の解説

Go言語におけるmapのイテレーション順序

Go言語のmapは、キーと値のペアを格納するハッシュテーブルの実装です。Goの仕様では、mapをイテレートする際の順序は保証されていません。これは意図的な設計であり、開発者が順序に依存しないコードを書くことを促し、マップの実装を最適化する自由度をコンパイラに与えるためです。したがって、for key, value := range myMapのようなループを使用する場合、キーが返される順序は実行ごとに異なる可能性があります。

URLエンコーディング

URLエンコーディング(パーセントエンコーディングとも呼ばれる)は、URL内で特別な意味を持つ文字(例: &, =, ?, /, (スペース))や、非ASCII文字を、安全に転送できる形式に変換するプロセスです。スペースは%20または+に、&%26のように変換されます。net/urlパッケージのQueryEscape関数がこの役割を担います。

net/urlパッケージ

net/urlパッケージは、Go言語でURLを扱うための基本的な機能を提供します。主な機能には以下のようなものがあります。

  • URLの解析: 文字列からurl.URL構造体を生成し、スキーム、ホスト、パス、クエリ、フラグメントなどの各コンポーネントにアクセスできるようにします。
  • URLの構築: url.URL構造体からURL文字列を構築します。
  • クエリパラメータの操作: url.Values型を使用して、クエリパラメータの追加、取得、エンコード、デコードを行います。

Values型とEncode()メソッド

url.Values型は、map[string][]stringのエイリアスです。これは、URLクエリパラメータやHTTPフォームデータのように、同じキーに対して複数の値を持つことができる構造を表現するのに適しています。例えば、?key=value1&key=value2のようなクエリは、Values{"key": {"value1", "value2"}}として表現されます。

Values.Encode()メソッドは、このValues型のデータを、WebブラウザやHTTPクライアントがサーバーに送信する際に使用する標準的なURLエンコードされたクエリ文字列形式(例: key1=valueA&key1=valueB&key2=valueC)に変換します。

bytes.Bufferstrings.Join

Go言語で文字列を効率的に構築する方法として、主にstrings.Joinbytes.Bufferが挙げられます。

  • strings.Join(parts []string, sep string) string: 文字列スライスpartsの要素を、指定された区切り文字sepで結合して一つの文字列を生成します。これは、事前にすべての部分文字列が確定している場合に便利です。しかし、多数の小さな文字列をappendでスライスに追加し、最後にJoinを呼び出す場合、appendのたびに新しいスライスが割り当てられる可能性があり、効率が低下することがあります。
  • bytes.Buffer: 可変長のバイトシーケンスを効率的に構築するための型です。内部的にバイトスライスを保持し、必要に応じて自動的に拡張されます。WriteStringWriteByteなどのメソッドを使用して、文字列やバイトをバッファに書き込むことができます。最終的にString()メソッドを呼び出すことで、構築された文字列を取得できます。多数の文字列を繰り返し結合する場合や、文字列の構築中に条件分岐がある場合など、段階的に文字列を構築するシナリオでは、bytes.Bufferstrings.Joinよりも効率的であることが多いです。これは、メモリの再割り当てがより少なく済むためです。

このコミットでは、以前のstrings.Joinベースの実装からbytes.Bufferベースの実装に切り替えることで、文字列構築の効率も改善されています。

技術的詳細

このコミットにおけるValues.Encode()メソッドの変更は、主に以下のステップで構成されます。

  1. キーの抽出: Valuesマップからすべてのキーを抽出し、新しい文字列スライスkeysに格納します。
  2. キーのソート: 抽出したkeysスライスをsort.Strings()関数を使用してアルファベット順にソートします。これにより、クエリ文字列のキーの順序が決定論的になります。
  3. 効率的な文字列構築: bytes.Bufferを使用して、エンコードされたクエリ文字列を効率的に構築します。
    • ソートされたキーの順序でループを回します。
    • 各キーとそれに対応する値のペアをURLエンコードします。
    • 最初のキーと値のペア以外は、&(アンパサンド)を区切り文字として追加します。
    • エンコードされたキーと値をbytes.Bufferに書き込みます。

以前の実装では、mapの非決定論的なイテレーション順序に依存してpartsスライスに文字列を追加し、最後にstrings.Joinで結合していました。新しい実装では、まずキーをソートすることで順序を固定し、その後bytes.Bufferを使って効率的に文字列を構築することで、パフォーマンスと決定論的な出力を両立させています。

テストコードsrc/pkg/net/url/url_test.goも、この変更に合わせて修正されています。以前は、マップのイテレーション順序が非決定論的であったため、expectedexpected1という2つの期待される出力パターンを許容していました。しかし、キーがソートされるようになったことで、出力は常に一意になるため、expected1フィールドは削除され、テストは単一の期待される出力expectedのみをチェックするように簡素化されました。また、新しいテストケースが追加され、複数のキーを持つValuesが正しくソートされてエンコードされることを確認しています。

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

src/pkg/net/url/url.goValues.Encode()メソッドの変更点:

--- a/src/pkg/net/url/url.go
+++ b/src/pkg/net/url/url.go
@@ -7,7 +7,9 @@
 package url
 
 import (
+	"bytes"
 	"errors"
+	"sort"
 	"strconv"
 	"strings"
 )
@@ -538,14 +540,24 @@ func (v Values) Encode() string {
 	if v == nil {
 		return ""
 	}
-	parts := make([]string, 0, len(v)) // will be large enough for most uses
-	for k, vs := range v {
+	var buf bytes.Buffer
+	keys := make([]string, 0, len(v))
+	for k := range v {
+		keys = append(keys, k)
+	}
+	sort.Strings(keys)
+	for _, k := range keys {
+		vs := v[k]
 		prefix := QueryEscape(k) + "="
 		for _, v := range vs {
-			parts = append(parts, prefix+QueryEscape(v))
+			if buf.Len() > 0 {
+				buf.WriteByte('&')
+			}
+			buf.WriteString(prefix)
+			buf.WriteString(QueryEscape(v))
 		}
 	}
-	return strings.Join(parts, "&")
+	return buf.String()
 }
 
 // resolvePath applies special path segments from refs and applies

src/pkg/net/url/url_test.goの変更点:

--- a/src/pkg/net/url/url_test.go
+++ b/src/pkg/net/url/url_test.go
@@ -453,20 +453,24 @@ func TestEscape(t *testing.T) {
 //}\n \n type EncodeQueryTest struct {\n-\tm         Values\n-\texpected  string\n-\texpected1 string\n+\tm        Values\n+\texpected string\n }\n \n var encodeQueryTests = []EncodeQueryTest{\n-\t{nil, \"\", \"\"},\n-\t{Values{\"q\": {\"puppies\"}, \"oe\": {\"utf8\"}}, \"q=puppies&oe=utf8\", \"oe=utf8&q=puppies\"},\n-\t{Values{\"q\": {\"dogs\", \"&\", \"7\"}}, \"q=dogs&q=%26&q=7\", \"q=dogs&q=%26&q=7\"},\n+\t{nil, \"\"},\n+\t{Values{\"q\": {\"puppies\"}, \"oe\": {\"utf8\"}}, \"oe=utf8&q=puppies\"},\n+\t{Values{\"q\": {\"dogs\", \"&\", \"7\"}}, \"q=dogs&q=%26&q=7\"},\n+\t{Values{\n+\t\t\"a\": {\"a1\", \"a2\", \"a3\"},\n+\t\t\"b\": {\"b1\", \"b2\", \"b3\"},\n+\t\t\"c\": {\"c1\", \"c2\", \"c3\"},\n+\t}, \"a=a1&a=a2&a=a3&b=b1&b=b2&b=b3&c=c1&c=c2&c=c3\"},\n }\n \n func TestEncodeQuery(t *testing.T) {\n-\tfor _, tt := range encodeQueryTests {\n-\t\tif q := tt.m.Encode(); q != tt.expected && q != tt.expected1 {\n+\tfor _, tt := range encodeQueryTests {\n+\t\tif q := tt.m.Encode(); q != tt.expected {\n \t\t\tt.Errorf(`EncodeQuery(%+v) = %q, want %q`, tt.m, q, tt.expected)\n \t\t}\n \t}\n```

## コアとなるコードの解説

### `src/pkg/net/url/url.go`

1.  **`import`文の追加**:
    ```go
    import (
    	"bytes" // bytes.Bufferを使用するために追加
    	"errors"
    	"sort"  // キーをソートするために追加
    	"strconv"
    	"strings"
    )
    ```
    `bytes`パッケージと`sort`パッケージが新しくインポートされています。これは、それぞれ`bytes.Buffer`と`sort.Strings`関数を使用するためです。

2.  **`Values.Encode()`メソッドの変更**:
    ```go
    func (v Values) Encode() string {
    	if v == nil {
    		return ""
    	}
    	var buf bytes.Buffer // 新しくbytes.Bufferを宣言
    	keys := make([]string, 0, len(v)) // マップのキーを格納するスライス
    	for k := range v { // マップからすべてのキーを抽出
    		keys = append(keys, k)
    	}
    	sort.Strings(keys) // 抽出したキーをアルファベット順にソート
    	for _, k := range keys { // ソートされたキーの順序でループ
    		vs := v[k] // 現在のキーに対応する値のスライスを取得
    		prefix := QueryEscape(k) + "=" // キーをエンコードし、"="を付加
    		for _, v := range vs { // 各値についてループ
    			if buf.Len() > 0 { // 最初の要素以外の場合、"&"を区切り文字として追加
    				buf.WriteByte('&')
    			}
    			buf.WriteString(prefix) // エンコードされたキーと"="をバッファに書き込み
    			buf.WriteString(QueryEscape(v)) // エンコードされた値をバッファに書き込み
    		}
    	}
    	return buf.String() // 構築された文字列を返す
    }
    ```
    *   以前の`parts := make([]string, ...)`と`strings.Join`による実装が削除されました。
    *   `bytes.Buffer`型の変数`buf`が宣言され、これを使って最終的なクエリ文字列が構築されます。
    *   `keys := make([]string, 0, len(v))`で、マップのキーを格納するためのスライスが初期化されます。
    *   `for k := range v`ループで、`Values`マップからすべてのキーが抽出され、`keys`スライスに追加されます。この時点では、`keys`スライスの順序は非決定論的です。
    *   `sort.Strings(keys)`が呼び出され、`keys`スライス内の文字列(マップのキー)がアルファベット順にソートされます。これにより、以降の処理でキーが常に同じ順序で処理されることが保証されます。
    *   `for _, k := range keys`ループで、ソートされたキーの順序でイテレーションが行われます。
    *   内部の`for _, v := range vs`ループでは、各キーに対応する複数の値が処理されます。
    *   `if buf.Len() > 0 { buf.WriteByte('&') }`は、最初のキーと値のペアが追加されるとき以外は、`&`を区切り文字としてバッファに書き込むための条件分岐です。
    *   `buf.WriteString(prefix)`と`buf.WriteString(QueryEscape(v))`によって、エンコードされたキーと値が`bytes.Buffer`に効率的に書き込まれます。
    *   最終的に`return buf.String()`で、`bytes.Buffer`に蓄積された文字列が返されます。

### `src/pkg/net/url/url_test.go`

1.  **`EncodeQueryTest`構造体の変更**:
    ```diff
    --- a/src/pkg/net/url/url_test.go
    +++ b/src/pkg/net/url/url_test.go
    @@ -453,20 +453,24 @@ func TestEscape(t *testing.T) {
     //}\n \n type EncodeQueryTest struct {\n-	m         Values
    -	expected  string
    -	expected1 string
    +	m        Values
    +	expected string
     }\n    ```
    `expected1`フィールドが削除されました。これは、`Encode()`メソッドの出力が決定論的になったため、複数の期待される出力パターンを考慮する必要がなくなったためです。

2.  **`encodeQueryTests`テストケースの変更**:
    ```diff
    --- a/src/pkg/net/url/url_test.go
    +++ b/src/pkg/net/url/url_test.go
    @@ -453,20 +453,24 @@ func TestEscape(t *testing.T) {
     //}\n \n type EncodeQueryTest struct {\n-	m         Values
    -	expected  string
    -	expected1 string
    +	m        Values
    +	expected string
     }\n \n     var encodeQueryTests = []EncodeQueryTest{\n-	{nil, \"\", \"\"},\n-	{Values{\"q\": {\"puppies\"}, \"oe\": {\"utf8\"}}, \"q=puppies&oe=utf8\", \"oe=utf8&q=puppies\"},\n-	{Values{\"q\": {\"dogs\", \"&\", \"7\"}}, \"q=dogs&q=%26&q=7\", \"q=dogs&q=%26&q=7\"},\n+	{nil, \"\"},\n+	{Values{\"q\": {\"puppies\"}, \"oe\": {\"utf8\"}}, \"oe=utf8&q=puppies\"},\n+	{Values{\"q\": {\"dogs\", \"&\", \"7\"}}, \"q=dogs&q=%26&q=7\"},\n+	{Values{\n+\t\t\"a\": {\"a1\", \"a2\", \"a3\"},\n+\t\t\"b\": {\"b1\", \"b2\", \"b3\"},\n+\t\t\"c\": {\"c1\", \"c2\", \"c3\"},\n+\t}, \"a=a1&a=a2&a=a3&b=b1&b=b2&b=b3&c=c1&c=c2&c=c3\"},\n     }\n    ```
    *   既存のテストケースから`expected1`に対応する値が削除されました。
    *   新しいテストケースが追加されました。このテストケースは、複数のキー(`a`, `b`, `c`)を持つ`Values`が、アルファベット順にソートされてエンコードされることを明示的に検証しています。期待される出力は`a=a1&a=a2&a=a3&b=b1&b=b2&b=b3&c=c1&c=c2&c=c3`であり、キーが`a`, `b`, `c`の順になっていることが確認できます。

3.  **`TestEncodeQuery`関数のアサーションの変更**:
    ```diff
    --- a/src/pkg/net/url/url_test.go
    +++ b/src/pkg/net/url/url_test.go
    @@ -453,20 +453,24 @@ func TestEscape(t *testing.T) {
     //}\n \n type EncodeQueryTest struct {\n-	m         Values
    -	expected  string
    -	expected1 string
    +	m        Values
    +	expected string
     }\n \n     var encodeQueryTests = []EncodeQueryTest{\n-	{nil, \"\", \"\"},\n-	{Values{\"q\": {\"puppies\"}, \"oe\": {\"utf8\"}}, \"q=puppies&oe=utf8\", \"oe=utf8&q=puppies\"},\n-	{Values{\"q\": {\"dogs\", \"&\", \"7\"}}, \"q=dogs&q=%26&q=7\", \"q=dogs&q=%26&q=7\"},\n+	{nil, \"\"},\n+	{Values{\"q\": {\"puppies\"}, \"oe\": {\"utf8\"}}, \"oe=utf8&q=puppies\"},\n+	{Values{\"q\": {\"dogs\", \"&\", \"7\"}}, \"q=dogs&q=%26&q=7\"},\n+	{Values{\n+\t\t\"a\": {\"a1\", \"a2\", \"a3\"},\n+\t\t\"b\": {\"b1\", \"b2\", \"b3\"},\n+\t\t\"c\": {\"c1\", \"c2\", \"c3\"},\n+\t}, \"a=a1&a=a2&a=a3&b=b1&b=b2&b=b3&c=c1&c=c2&c=c3\"},\n     }\n \n     func TestEncodeQuery(t *testing.T) {\n-	for _, tt := range encodeQueryTests {\n-		if q := tt.m.Encode(); q != tt.expected && q != tt.expected1 {\n+	for _, tt := range encodeQueryTests {\n+		if q := tt.m.Encode(); q != tt.expected {\n     			t.Errorf(`EncodeQuery(%+v) = %q, want %q`, tt.m, q, tt.expected)\n     		}\n     	}\n    ```
    アサーションが`q != tt.expected && q != tt.expected1`から`q != tt.expected`に変更されました。これにより、テストは単一の期待される出力のみを検証するようになり、`Encode()`メソッドの決定論的な挙動を反映しています。

## 関連リンク

*   Go言語 `net/url` パッケージドキュメント: [https://pkg.go.dev/net/url](https://pkg.go.dev/net/url)
*   Go言語 `sort` パッケージドキュメント: [https://pkg.go.dev/sort](https://pkg.go.dev/sort)
*   Go言語 `bytes` パッケージドキュメント: [https://pkg.go.dev/bytes](https://pkg.go.dev/bytes)

## 参考にした情報源リンク

*   Go言語の`map`の順序に関する公式ドキュメントやブログ記事(一般的な知識として参照)
*   Go言語の`bytes.Buffer`と`strings.Builder`(または`strings.Join`)のパフォーマンスに関する一般的な情報(一般的な知識として参照)