Goブログ

後方互換性、Go 1.21、および Go 2

Russ Cox
2023年8月14日

Go 1.21には、互換性を向上させるための新機能が含まれています。読むのをやめる前に、退屈だと分かっています。しかし、退屈は良いことです。Go 1の初期には、Goはエキサイティングでサプライズに満ちていました。毎週新しいスナップショットリリースを行い、誰もが変更内容とそのプログラムがどのように壊れるかを確認するためにサイコロを振っていました。私たちはGo 1とその互換性の約束をリリースして、興奮を取り除き、Goの新しいリリースを退屈なものにしました。

退屈は良いことです。退屈は安定しています。退屈とは、Goの違いにではなく、自分の仕事に集中できることを意味します。この記事では、Goを退屈に保つためにGo 1.21で出荷された重要な作業について説明します。

Go 1互換性

私たちは10年以上、互換性に重点を置いてきました。2012年のGo 1では、「Go 1とGoプログラムの将来」というタイトルの文書を発表し、非常に明確な意図を示しました。

Go 1仕様に従って記述されたプログラムは、その仕様の存続期間中、変更せずに正しくコンパイルおよび実行され続けることを意図しています。…今日の動作するGoプログラムは、将来のGo 1リリースが登場しても動作し続けるはずです。

これにはいくつかの条件があります。第一に、互換性とはソース互換性を意味します。新しいバージョンのGoに更新する場合は、コードを再コンパイルする必要があります。第二に、新しいAPIを追加できますが、既存のコードを壊すような方法ではありません。

文書の最後に、「将来の変更でどのプログラムも壊れないと保証することは不可能です」という警告があります。次に、プログラムが依然として壊れる可能性のあるいくつかの理由を示しています。

たとえば、プログラムがバグのある動作に依存しており、そのバグを修正した場合、プログラムが壊れるのは理にかなっています。しかし、私たちはできる限りプログラムを壊さないように、Goを退屈に保つために非常に努力しています。これまでに使用してきた主なアプローチは2つあります。APIチェックとテストです。

APIチェック

互換性に関する最も明確な事実の1つは、APIを取り除くことはできないということです。そうしないと、それを使用するプログラムが壊れてしまいます。

たとえば、誰かが書いた、私たちが壊すことができないプログラムを以下に示します。

package main

import "os"

func main() {
    os.Stdout.WriteString("hello, world\n")
}

パッケージosを削除することはできません。*os.Fileであるグローバル変数os.Stdoutを削除することはできません。また、os.FileメソッドWriteStringを削除することもできません。これらのいずれかを削除すると、このプログラムが壊れることは明らかです。

os.Stdoutの型をまったく変更できないことは、それほど明確ではありません。同じメソッドを持つインターフェースにすることを望んでいるとします。先ほど見たプログラムは壊れませんが、次のプログラムは壊れます。

package main

import "os"

func main() {
    greet(os.Stdout)
}

func greet(f *os.File) {
    f.WriteString(“hello, world\n”)
}

このプログラムは、*os.File型の引数を必要とするgreetという名前の関数にos.Stdoutを渡します。そのため、os.Stdoutをインターフェースに変更すると、このプログラムが壊れます。

Goの開発を支援するために、実際のpackageとは別にファイルに各パッケージのエクスポートされたAPIのリストを維持するツールを使用しています。

% cat go/api/go1.21.txt
pkg bytes, func ContainsFunc([]uint8, func(int32) bool) bool #54386
pkg bytes, method (*Buffer) AvailableBuffer() []uint8 #53685
pkg bytes, method (*Buffer) Available() int #53685
pkg cmp, func Compare[$0 Ordered]($0, $0) int #59488
pkg cmp, func Less[$0 Ordered]($0, $0) bool #59488
pkg cmp, type Ordered interface {} #59488
pkg context, func AfterFunc(Context, func()) func() bool #57928
pkg context, func WithDeadlineCause(Context, time.Time, error) (Context, CancelFunc) #56661
pkg context, func WithoutCancel(Context) Context #40221
pkg context, func WithTimeoutCause(Context, time.Duration, error) (Context, CancelFunc) #56661

標準テストの1つは、実際のpackage APIがこれらのファイルと一致することを確認します。パッケージに新しいAPIを追加する場合、APIファイルに追加しない限り、テストは失敗します。また、APIを変更または削除した場合も、テストは失敗します。これにより、ミスを防ぐことができます。ただし、このようなツールでは、APIの変更と削除という特定の種類の問題しか検出できません。Goに互換性のない変更を加えるには、他にも方法があります。

そこで、Goを退屈に保つために使用する2番目のアプローチであるテストについて説明します。

テスト

予期しない非互換性を検出する最も効果的な方法は、既存のテストを次のGoリリースの開発バージョンに対して実行することです。Googleの内部Goコードすべてに対して、開発バージョンのGoを継続的にテストしています。テストが合格すると、そのコミットをGoogleのプロダクションGoツールチェーンとしてインストールします。

変更によってGoogle内部のテストが壊れる場合、Google以外でもテストが壊れると想定し、影響を軽減する方法を探します。ほとんどの場合、変更を完全にロールバックするか、プログラムを壊さないように書き直します。ただし、変更が重要であり、プログラムを壊す場合でも「互換性がある」と結論付ける場合があります。その場合でも、影響をできる限り軽減し、リリースノートで潜在的な問題を文書化します。

Google内部でGoをテストすることで見つかった、そのような微妙な互換性の問題の2つの例をGo 1.1に含めました。

構造体リテラルと新しいフィールド

Go 1で正常に実行されるコードを以下に示します。

package main

import "net"

var myAddr = &net.TCPAddr{
    net.IPv4(18, 26, 4, 9),
    80,
}

パッケージmainは、net.TCPAddr型の複合リテラルであるグローバル変数myAddrを宣言します。Go 1では、パッケージnetTCPAddr型を2つのフィールド(IPPort)を持つ構造体として定義しています。これらは複合リテラルのフィールドと一致するため、プログラムはコンパイルされます。

Go 1.1では、「構造体リテラルの初期化子が少なすぎます」というコンパイラエラーで、プログラムのコンパイルが停止しました。問題は、net.TCPAddrに3番目のフィールドZoneを追加したことであり、このプログラムにはその3番目のフィールドの値がありません。修正方法は、タグ付きリテラルを使用してプログラムを書き直すことで、Goの両方のバージョンでビルドできます。

var myAddr = &net.TCPAddr{
    IP:   net.IPv4(18, 26, 4, 9),
    Port: 80,
}

このリテラルはZoneの値を指定していないため、ゼロ値(この場合は空文字列)が使用されます。

標準ライブラリ構造体に対して複合リテラルを使用するこの要件は、互換性ドキュメントで明示的に説明されており、go vetは、将来のバージョンのGoとの互換性を確保するためにタグが必要なリテラルを報告します。この問題はGo 1.1では新しいものであり、リリースノートに短いコメントを付ける価値がありました。最近は、新しいフィールドについて言及するだけです。

時間精度

Go 1.1をテスト中に見つかった2番目の問題は、APIとはまったく関係ありませんでした。時間に関する問題でした。

Go 1がリリースされた直後、誰かがtime.Nowがマイクロ秒単位の精度で時間を返すことを指摘しましたが、追加のコードを使用すると、代わりにナノ秒単位の精度で時間を返すことができると言いました。それは良いことのように聞こえますね?より高い精度はより優れています。そこで、その変更を行いました。

これにより、Google内部で次のようなスキームのテストがいくつか壊れました。

func TestSaveTime(t *testing.T) {
    t1 := time.Now()
    save(t1)
    if t2 := load(); t2 != t1 {
        t.Fatalf("load() = %v, want %v", t1, t2)
    }
}

このコードはtime.Nowを呼び出し、結果をsaveloadを通してラウンドトリップし、同じ時間が返されることを期待します。saveloadがマイクロ秒精度のみを保存する表現を使用する場合、Go 1では正常に動作しますが、Go 1.1では失敗します。

このようなテストの修正に役立つように、不要な精度を破棄するためのRoundメソッドとTruncateメソッドを追加し、リリースノートでは、考えられる問題とそれを修正するための新しいメソッドについて説明しました。

これらの例は、テストによってAPIチェックとは異なる種類の非互換性がどのように検出されるかを示しています。もちろん、テストも互換性の完全な保証ではありませんが、APIチェックだけよりも完全です。テスト中に発見されたが、互換性ルールを破ると判断され、リリース前にロールバックされた問題の例はたくさんあります。時間精度変更は、プログラムを壊しましたが、それでもリリースした興味深い例です。この変更は、精度の向上が優れており、関数の文書化された動作内で許可されていたために行われました。

この例は、多大な努力と注意にもかかわらず、Goを変更することがGoプログラムを壊すことがあることを示しています。変更は、厳密にはGo 1ドキュメントの意味で「互換性がある」ものですが、それでもプログラムを壊します。これらの互換性の問題のほとんどは、出力の変更、入力の変更、プロトコルの変更の3つのカテゴリのいずれかに分類できます。

出力の変更

出力の変更とは、関数が以前とは異なる出力を生成する場合ですが、新しい出力は古い出力と同じくらい、あるいは古い出力よりも正確な場合です。既存のコードが古い出力のみを期待するように記述されている場合、それは壊れます。time.Nowがナノ秒単位の精度を追加したことで、この例を見ました。

ソート。別の例はGo 1.6で発生しました。ソートの実装を変更して約10%高速化したためです。名前の長さで色のリストをソートするプログラムを以下に示します。

colors := strings.Fields(
    `black white red orange yellow green blue indigo violet`)
sort.Sort(ByLen(colors))
fmt.Println(colors)

Go 1.5:  [red blue green white black yellow orange indigo violet]
Go 1.6:  [red blue white green black orange yellow indigo violet]

ソートアルゴリズムを変更すると、等しい要素の順序が変わる場合があり、ここで発生しました。Go 1.5はgreen、white、blackをこの順序で返しました。Go 1.6はwhite、green、blackを返しました。

ソートは明らかに、任意の順序で等しい結果を返すことが許可されており、この変更により10%高速化されました。これは素晴らしいことです。しかし、特定の出力を期待するプログラムは壊れます。これは、互換性が非常に難しい理由の良い例です。プログラムを壊したくありませんが、文書化されていない実装の詳細に縛られたくもありません。

Compress/flate。別の例として、Go 1.8ではcompress/flateを改善して、CPUとメモリのオーバーヘッドはほぼ同じで、より小さい出力を生成しました。それはウィンウィンのように聞こえますが、再現可能なアーカイブビルドを必要とするGoogle内部のプロジェクトを壊しました。今では、古いアーカイブを再現できなくなりました。古いアルゴリズムのコピーを保持するために、compress/flatecompress/gzipをフォークしました。

Goコンパイラでも同様の処理を行い、`sort`パッケージ(他多数)をフォークすることで、古いバージョンのGoを使ってビルドした場合でも、コンパイラが同じ結果を生成するようにしています。

このような出力の変更による非互換性については、あらゆる有効な出力を許容するプログラムとテストを作成し、この種の破壊をテスト戦略を変更する機会として捉えることが最善策です。単に期待される回答を更新するだけではありません。真に再現可能な出力を必要とする場合は、コードをフォークして変更から自分自身を隔離することが次善策ですが、バグ修正からも自分自身を隔離していることを忘れないでください。

入力の変更

入力の変更とは、関数が受け入れる入力や入力の処理方法が変更されることです。

ParseInt. 例えば、Go 1.13では、可読性向上のため、大きな数値にアンダースコアを使用できるようになりました。言語の変更に合わせて、`strconv.ParseInt`に新しい構文を受け入れるように変更しました。この変更はGoogle内部では何も壊しませんでしたが、ずっと後になって、コードが壊れた外部ユーザーから連絡がありました。彼らのプログラムは、アンダースコアで区切られた数値をデータ形式として使用していました。最初に`ParseInt`を試行し、`ParseInt`が失敗した場合にのみアンダースコアのチェックにフォールバックしていました。`ParseInt`が失敗しなくなったため、アンダースコア処理コードが実行されなくなりました。

ParseIP. もう1つの例として、Goの`net.ParseIP`は、初期のIP RFCの例に従っており、先頭にゼロが付いた10進数のIPアドレスを頻繁に表示していました。IPアドレス18.032.4.011を、いくつかの余分なゼロが付いただけの18.32.4.11として読み取っていました。ずっと後になって、BSD派生のCライブラリは、IPアドレスの先頭のゼロを8進数の開始として解釈するということがわかりました。これらのライブラリでは、18.032.4.011は18.26.4.9を意味します!

これはGoと世界の他の部分との間の深刻な不一致でしたが、あるGoリリースから次のリリースまで先頭のゼロの意味を変更することも深刻な不一致になります。これは大きな非互換性になります。最終的に、Go 1.17で`net.ParseIP`を変更して、先頭のゼロを完全に拒否することにしました。この厳格な構文解析により、GoとCの両方でIPアドレスが正常に解析された場合、または古いGoバージョンと新しいGoバージョンで正常に解析された場合、その意味についてすべて合意できるようになります。

この変更はGoogle内部では何も壊しませんでしたが、Kubernetesチームは、以前に解析されていた可能性のあるが、Go 1.17では解析されなくなる保存済みの設定を懸念していました。先頭のゼロが付いたアドレスは、Goが本質的に他のすべての言語とは異なる方法で解釈するため、それらの設定から削除する必要がありますが、これはKubernetesのタイムラインで行うべきであり、Goのタイムラインで行うべきではありません。セマンティックな変更を回避するために、Kubernetesは元の`net.ParseIP`の独自のフォークされたコピーを使用し始めました。

入力の変更に対する最善の対応策は、値を解析する前に、受け入れる構文を最初に検証することによってユーザー入力を処理することですが、場合によってはコードをフォークする必要があります。

プロトコルの変更

最後の一般的な非互換性の種類は、プロトコルの変更です。プロトコルの変更とは、プログラムが外部世界と通信するために使用するプロトコルにおいて、外部から見える変更がパッケージに加えられることです。`ParseInt`と`ParseIP`で見たように、ほとんどすべての変更が特定のプログラムで外部から見えるようになる可能性がありますが、プロトコルの変更は本質的にすべてのプログラムで外部から見えるようになります。

HTTP/2. プロトコルの変更の明確な例として、Go 1.6でHTTP/2の自動サポートが追加されたことが挙げられます。Go 1.5クライアントが、HTTP/2を壊すミドルボックスを持つネットワークを介してHTTP/2対応サーバーに接続しているとします。Go 1.5はHTTP/1.1のみを使用するため、プログラムは正常に動作します。しかし、Go 1.6に更新すると、プログラムが壊れます。Go 1.6はHTTP/2の使用を開始し、このコンテキストではHTTP/2が機能しないためです。

Goはデフォルトで最新のプロトコルをサポートすることを目指していますが、この例は、HTTP/2を有効にすることで、プログラム自体の過失(またはGoの過失)によらずにプログラムが壊れる可能性があることを示しています。この状況にある開発者はGo 1.5に戻ることができますが、これはあまり満足のいくものではありません。代わりに、Go 1.6ではリリースノートでこの変更について説明し、HTTP/2を無効にする方法を簡素化しました。

実際、Go 1.6では、HTTP/2を無効にする2つの方法について説明されています。パッケージAPIを使用して`TLSNextProto`フィールドを明示的に設定するか、GODEBUG環境変数を設定します。

GODEBUG=http2client=0 ./myprog
GODEBUG=http2server=0 ./myprog
GODEBUG=http2client=0,http2server=0 ./myprog

後で見るように、Go 1.21ではこのGODEBUGメカニズムを一般化し、潜在的に破壊的な変更の標準としています。

SHA1. これはプロトコルの変更のより微妙な例です。もはやSHA1ベースの証明書をHTTPSで使用すべきではありません。認証局は2015年に発行を停止し、すべての主要なブラウザは2017年に受け入れを停止しました。2020年初頭、Go 1.18ではデフォルトでそれらのサポートが無効になり、GODEBUG設定でその変更をオーバーライドできるようになりました。また、Go 1.19でGODEBUG設定を削除する意向を発表しました。

Kubernetesチームは、一部のインストールでまだプライベートSHA1証明書が使用されていることを知らせてくれました。セキュリティ上の問題を脇に置いておけば、Kubernetesがこれらの企業に証明書インフラストラクチャのアップグレードを強制する立場ではなく、`crypto/tls`と`net/http`をフォークしてSHA1のサポートを維持するのは非常に困難です。代わりに、計画していたよりも長くオーバーライドを維持することに同意し、秩序だった移行のための時間を確保しました。結局のところ、できるだけ多くのプログラムを壊したくありません。

Go 1.21におけるGODEBUGサポートの拡張

これらの微妙なケースでも後方互換性を向上させるために、Go 1.21ではGODEBUGの使用を拡張して正式化しています。

まず、Go 1互換性で許可されているが、既存のプログラムを壊す可能性のある変更については、潜在的な互換性の問題を理解するために、これまで見てきたすべての作業を行い、できるだけ多くの既存のプログラムが動作し続けるように変更を設計します。残りのプログラムについては、新しいアプローチは次のとおりです。

  1. 新しい動作をオプトアウトできる新しいGODEBUG設定を定義します。そうすることが不可能な場合は、GODEBUG設定を追加しない場合があります。ただし、これは非常にまれです。

  2. 互換性のために追加されたGODEBUG設定は、最低2年間(Goリリース4回)維持されます。`http2client`や`http2server`などの一部の設定は、さらに長く、無期限に維持される可能性があります。

  3. 可能な限り、各GODEBUG設定には、関連付けられた`runtime/metrics`カウンタ(`/godebug/non-default-behavior/<name>:events`)があり、特定のプログラムの動作がその設定の非デフォルト値に基づいて変更された回数をカウントします。例えば、`GODEBUG=http2client=0`が設定されている場合、`/godebug/non-default-behavior/http2client:events`は、プログラムがHTTP/2サポートなしで設定したHTTPトランスポートの数をカウントします。

  4. プログラムのGODEBUG設定は、メインパッケージの`go.mod`ファイルにリストされているGoバージョンと一致するように構成されます。プログラムの`go.mod`ファイルに`go 1.20`と記載されており、Go 1.21ツールチェーンに更新した場合、Go 1.21で変更されたGODEBUGで制御される動作は、`go.mod`を`go 1.21`に変更するまで、古いGo 1.20の動作を維持します。

  5. プログラムは、パッケージ`main`に`//go:debug`行を使用することで、個々のGODEBUG設定を変更できます。

  6. すべてのGODEBUG設定は、単一の、中央のリストに文書化されているため、簡単に参照できます。

このアプローチにより、新しいバージョンのGoは、古いバージョンのGoの可能な限り最良の実装になり、後続のリリースで互換性があるものの破壊的な方法で変更された動作も、古いコードをコンパイルする際に維持されるようになります。

たとえば、Go 1.21では、`panic(nil)`は(非nilの)ランタイムパニックを引き起こすようになったため、`recover`の結果は、現在のゴルーチンがパニックしているかどうかを確実に報告するようになりました。この新しい動作はGODEBUG設定によって制御されるため、メインパッケージの`go.mod`の`go`行に依存します。`go 1.20`以前の場合は、`panic(nil)`はまだ許可されます。`go 1.21`以降の場合は、`panic(nil)`は`runtime.PanicNilError`によるパニックになります。そして、バージョンに基づくデフォルトは、パッケージmainに次のような行を追加することで明示的にオーバーライドできます。

//go:debug panicnil=1

これらの機能を組み合わせることで、プログラムは新しいツールチェーンに更新しながら、以前使用していたツールチェーンの動作を維持し、必要に応じて特定の設定をより細かく制御し、運用監視を使用して、実際にこれらの非デフォルトの動作を使用するジョブを理解することができます。これらを組み合わせることで、以前よりもスムーズに新しいツールチェーンをロールアウトできるようになります。

詳しくは、「Go、後方互換性、およびGODEBUG」をご覧ください。

Go 2に関する最新情報

この記事の先頭にある「Go 1とGoプログラムの将来」からの引用テキストでは、省略記号によって次の限定子が隠されていました。

ある不特定の時点で、Go 2仕様が生じる可能性がありますが、その時点までは、[…すべての互換性に関する詳細…]。

これは明らかな疑問を提起します。古いGo 1プログラムを破壊するGo 2仕様はいつ期待すべきでしょうか?

答えは、決してありません。過去との断絶と古いプログラムのコンパイルが不可能になるという意味でのGo 2は、決して起こることはありません。2017年に開始されたGo 1のメジャーな改訂版という意味でのGo 2は、すでに実現しています。

Go 1プログラムを破壊するGo 2はありません。代わりに、互換性を重視します。これは、過去とのあらゆる可能な断絶よりもはるかに価値があります。実際、互換性を優先することは、Go 1にとって最も重要な設計上の決定であったと考えています。

そのため、今後数年間で見られるのは、多くの新しくエキサイティングな作業ですが、注意深く互換性のある方法で行われます。これにより、あるツールチェーンから次のツールチェーンへのアップグレードをできるだけ退屈なものに保つことができます。

次の記事:Go 1.21における前方互換性とツールチェーン管理
前の記事:Go 1.21がリリースされました!
ブログインデックス