後方互換性、Go 1.21、そしてGo 2
Backward Compatibility, Go 1.21, and Go 2 by Russ Cox
Go 1.21には互換性を向上させる新機能が含まれています。ここで読むのをやめないでください、退屈に聞こえるのはわかっています。しかし退屈というのは良いことでもあるのです。Go 1の初期の頃、Goは刺激的でサプライズに満ちていました。毎週新しいスナップショットリリースを切り出し、誰もがサイコロを振るようにして、何が変更されたのか、そして自分のプログラムがどう壊れるのかを確かめていたのです。私たちはGo 1とその互換性の約束をリリースすることで、こうした刺激をなくし、Goの新しいリリースが退屈なものになるようにしました。
退屈というのは良いことです。退屈というのは安定していることです。退屈であるということは、Goの何が変わったかではなく、自分の作業に集中できるということを意味します。この記事では、Goを退屈であり続けさせるためにGo 1.21で私たちが取り組んだ重要な作業について説明します。
Go 1の互換性
私たちは10年以上にわたって互換性に注力してきました。Go 1のために、2012年にさかのぼりますが、私たちは「Go 1 and the Future of Go Programs(Go 1とGoプログラムの将来)」というタイトルのドキュメントを公開し、そこで非常に明確な意図を示しました。
Go 1の仕様に従って書かれたプログラムは、その仕様が有効である限り、変更を加えなくてもコンパイルと正しい実行を続けられることを意図しています。……今日動作しているGoプログラムは、将来Go 1のリリースが登場しても動作し続けるべきです。
これにはいくつかの留保があります。第一に、互換性とはソースレベルの互換性を意味します。新しいバージョンのGoに更新する際には、コードを再コンパイルする必要はあります。第二に、新しいAPIを追加することはできますが、既存のコードを壊すような形で追加することはできません。
このドキュメントの最後には、「将来のどんな変更もどんなプログラムも壊さないと保証することは不可能である」という警告があります。そして、それでもプログラムが壊れうる理由がいくつか挙げられています。
たとえば、あなたのプログラムがバグのある挙動に依存していて、私たちがそのバグを修正すれば、あなたのプログラムが壊れるのは道理にかなっています。しかし私たちは、できる限り壊れる範囲を小さくし、Goを退屈なものに保つよう懸命に努力しています。これまで使ってきた主なアプローチは、APIチェックとテストの2つです。
APIチェック
互換性について最も明白な事実はおそらく、APIを取り除くことはできない、ということでしょう。そうしなければ、その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を開発する際の助けとして、私たちは各パッケージのエクスポートされた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
私たちの標準的なテストの一つは、実際のパッケージのAPIがこれらのファイルと一致しているかを確認します。パッケージに新しいAPIを追加した場合、それをAPIファイルに追加しない限りテストは失敗します。また、APIを変更したり削除したりした場合にも、テストは失敗します。これにより私たちはミスを避けられます。しかし、この種のツールが見つけられる問題は、APIの変更や削除という特定のクラスのものに限られます。Goに非互換な変更を加える方法は他にもあります。
そこで、Goを退屈に保つために使っている2つめのアプローチであるテストの話に移ります。
テスト
予期しない非互換性を見つける最も効果的な方法は、既存のテストを次期Goリリースの開発版に対して実行することです。私たちはGoogle社内のすべてのGoコードに対して、Goの開発版をローリング方式でテストしています。テストが通ったら、そのコミットをGoogleの本番用Goツールチェインとしてインストールします。
ある変更がGoogle社内のテストを壊す場合、それがGoogle社外のテストも壊すだろうと想定し、影響を減らす方法を探ります。ほとんどの場合、変更を完全にロールバックするか、どのプログラムも壊さないように書き直す方法を見つけます。しかし時には、いくつかのプログラムを壊すとしても、その変更を行うことが重要であり「互換性がある」と判断することもあります。その場合でも、影響をできる限り減らす努力を続け、その上でリリースノートに潜在的な問題を記載します。
ここでは、Googleの社内でGoをテストすることで見つかったものの、それでもGo 1.1に取り込むことになった、そうした微妙な互換性問題の例を2つ紹介します。
構造体リテラルと新しいフィールド
次のコードはGo 1では問題なく動作します。
package main
import "net"
var myAddr = &net.TCPAddr{
net.IPv4(18, 26, 4, 9),
80,
}
パッケージ main はグローバル変数 myAddr を宣言していて、これは net.TCPAddr 型の複合リテラルです。Go 1では、パッケージ net は型 TCPAddr を IP と Port という2つのフィールドを持つ構造体として定義しています。これらは複合リテラル内のフィールドと一致するので、このプログラムはコンパイルできます。
Go 1.1では、このプログラムは「too few initializers in struct literal(構造体リテラルの初期化子が足りません)」というコンパイルエラーとともにコンパイルできなくなりました。問題は、私たちが 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 を呼び出し、その結果を save と load を通じて往復させ、同じ時刻が戻ってくることを期待しています。もし save と load がマイクロ秒精度しか保持しない表現を使っていれば、これはGo 1では問題なく動作しますが、Go 1.1では失敗します。
このようなテストの修正を助けるために、私たちは不要な精度を切り捨てるための Round メソッドと Truncate メソッドを追加し、リリースノートには起こりうる問題とそれを修正するための新しいメソッドについて記載しました。
これらの例は、テストがAPIチェックとは異なる種類の非互換性を見つけることを示しています。もちろん、テストも互換性を完全に保証するものではありませんが、APIチェックだけよりは網羅的です。テスト中に見つかり、互換性のルールを破っていると判断してリリース前にロールバックした問題の例はたくさんあります。時刻の精度の変更は、プログラムを壊すものでありながら、それでもリリースした興味深い例です。私たちがこの変更を行ったのは、精度の向上がより良いものであり、かつその関数のドキュメント化された挙動の範囲内で許容されるものだったからです。
この例は、多大な労力と注意を払っていても、Goを変更することがGoプログラムを壊すことを意味する場合があるということを示しています。これらの変更は、厳密に言えばGo 1のドキュメントの意味において「互換性がある」ものですが、それでもなおプログラムを壊します。こうした互換性の問題のほとんどは、出力の変更、入力の変更、プロトコルの変更という3つのカテゴリのいずれかに分類できます。
出力の変更
出力の変更とは、ある関数が以前とは異なる出力を返すようになるが、新しい出力は以前の出力と同じくらい正しい、あるいはそれ以上に正しい、という場合に起こります。既存のコードが古い出力だけを期待するように書かれていれば、それは壊れます。私たちはすでにその例を見ました。time.Now がナノ秒精度を追加した件です。
Sort。 別の例はGo 1.6で起こりました。このとき私たちは sort の実装を変更し、約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 の順になりました。
sort が等しい要素をどんな順序で返してもよいことは明らかであり、この変更によって10%高速化されたのは喜ばしいことです。しかし、特定の出力を期待するプログラムは壊れてしまいます。これは、なぜ互換性がこれほど難しいのかを示す良い例です。私たちはプログラムを壊したくありませんが、同時にドキュメント化されていない実装の詳細に縛られたくもないのです。
Compress/flate。 別の例として、Go 1.8では compress/flate を改良し、CPUとメモリのオーバーヘッドをほぼ変えずに、より小さい出力を生成するようにしました。これは一見どちらにとっても得な話に聞こえますが、再現可能なアーカイブビルドを必要としていたGoogle社内のあるプロジェクトを壊してしまいました。彼らはもはや古いアーカイブを再現できなくなったのです。彼らは compress/flate と compress/gzip をフォークして、古いアルゴリズムのコピーを保持しました。
私たちもGoコンパイラで同様のことをしていて、sort パッケージ(および他のいくつか)のフォークを使うことで、コンパイラが以前のバージョンのGoでビルドされた場合でも同じ結果を生成するようにしています。
このような出力変更による非互換性に対する最良の答えは、有効などのような出力も受け入れるプログラムとテストを書き、こうした破壊的変更を、期待する答えを更新するだけでなく、テスト戦略そのものを見直す機会として活用することです。本当に再現可能な出力が必要な場合、次善の策はコードをフォークして変更から自分を切り離すことですが、それによってバグ修正からも自分を切り離してしまうことを忘れないでください。
入力の変更
入力の変更とは、ある関数が受け付ける入力の種類、あるいはその処理方法を変更する場合に起こります。
ParseInt。 たとえば、Go 1.13では読みやすさのために大きな数値にアンダースコアを入れられるようになりました。この言語仕様の変更とあわせて、strconv.ParseInt にもこの新しい構文を受け付けるようにしました。この変更はGoogle社内では何も壊しませんでしたが、ずっと後になって、コードが壊れたという外部ユーザーの声を耳にしました。そのプログラムは、アンダースコア区切りの数値をデータ形式として使っていました。そして、まず ParseInt を試し、それが失敗した場合にのみアンダースコアの確認処理にフォールバックしていました。ParseInt が失敗しなくなったことで、アンダースコアを処理するコードが実行されなくなってしまったのです。
ParseIP。 別の例として、Goの net.ParseIP は、先頭にゼロを付けた10進数のIPアドレスをよく示していた初期のIP RFCの例に従っていました。そのため、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のバージョンの両方がパースに成功した場合に、その意味について両者が一致することが保証されます。
この変更はGoogle社内では何も壊しませんでしたが、Kubernetesチームは、以前はパースできていたのにGo 1.17ではパースできなくなってしまう保存済みの設定について懸念を示しました。先頭にゼロが付いたアドレスは、Goが事実上他のほぼすべての言語とは異なる解釈をする以上、そうした設定からはおそらく取り除かれるべきですが、それはGoのタイムラインではなく、Kubernetesのタイムラインで行われるべきことです。この意味の変更を避けるために、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。 ここでは、プロトコルの変更のもう少し微妙な例を紹介します。もはや誰もHTTPSにSHA1ベースの証明書を使うべきではありません。認証局は2015年にはSHA1証明書の発行を停止し、2017年にはすべての主要なブラウザがそれらを受け付けなくなりました。2020年の初め、Go 1.18ではデフォルトでSHA1証明書のサポートを無効にし、その変更を上書きするための GODEBUG 設定を用意しました。また、Go 1.19でその GODEBUG 設定を削除する意向も表明しました。
Kubernetesチームから、一部の導入環境ではいまだにプライベートなSHA1証明書が使われている、という知らせがありました。セキュリティ上の是非はさておき、そうした企業に証明書インフラのアップグレードを強制するのはKubernetesの役割ではありませんし、SHA1のサポートを維持するために crypto/tls と net/http をフォークするのは非常に骨の折れることです。そこで私たちは、秩序だった移行のためにより多くの時間を確保できるよう、当初の予定よりも長くこの上書き設定を維持することに合意しました。結局のところ、私たちはできる限り少ないプログラムしか壊したくないのです。
Go 1.21における拡張されたGODEBUGサポート
ここまで検討してきたような微妙なケースにおいても後方互換性を向上させるために、Go 1.21では GODEBUG の利用を拡張し、正式なものにしました。
まず、Go 1の互換性によって許容されているものの既存のプログラムを壊してしまうかもしれない変更については、これまで見てきたようなあらゆる作業を行って潜在的な互換性の問題を理解し、できる限り多くの既存のプログラムが動作し続けるように変更を設計します。それでも残ってしまうプログラムに対しては、次のような新しいアプローチを取ります。
個々のプログラムが新しい挙動をオプトアウトできるように、新しい
GODEBUG設定を定義します。GODEBUG設定の追加が実現不可能な場合には追加されないこともありますが、それは極めて稀なケースであるはずです。互換性のために追加された
GODEBUG設定は、最低でも2年間(Goの4リリース分)は維持されます。http2clientやhttp2serverのような一部の設定は、それよりもずっと長く、無期限に維持されることさえあります。可能な場合には、各
GODEBUG設定に対して/godebug/non-default-behavior/<name>:eventsという名前のruntime/metricsカウンターが関連付けられます。これは、その設定に非デフォルトの値が設定されたことによって、特定のプログラムの挙動が変化した回数をカウントするものです。たとえばGODEBUG=http2client=0が設定されている場合、/godebug/non-default-behavior/http2client:eventsは、そのプログラムがHTTP/2サポートなしで設定したHTTPトランスポートの数をカウントします。プログラムの
GODEBUG設定は、mainパッケージのgo.modファイルに記載されているGoのバージョンに合わせて設定されます。あなたのプログラムのgo.modファイルがgo 1.20と書かれた状態でGo 1.21のツールチェインに更新した場合、Go 1.21で変更されたGODEBUG制御下の挙動は、go.modをgo 1.21に変更するまで、古いGo 1.20の挙動を維持します。プログラムは、パッケージ
mainの中で//go:debug行を使うことで、個々のGODEBUG設定を変更できます。すべての
GODEBUG設定は、参照しやすいように単一の集中管理された一覧としてドキュメント化されます。
このアプローチが意味するのは、Goの新しいバージョンはそれぞれ、古いバージョンのGoの最良の実装であるべきだ、ということです。それは、後のリリースで「互換性はあるが破壊的な」形で変更された挙動であっても、古いコードをコンパイルする際にはそれを保持するということです。
たとえば、Go 1.21では panic(nil) が(nilではない)ランタイムパニックを引き起こすようになり、これによって recover の結果が現在のゴルーチンがパニック中かどうかを確実に報告するようになりました。この新しい挙動は GODEBUG 設定によって制御されており、そのためmainパッケージの go.mod の go 行に依存します。go 1.20 以前と書かれていれば、panic(nil) は今でも許容されます。go 1.21 以降と書かれていれば、panic(nil) は runtime.PanicNilError を伴うパニックになります。そして、このバージョンに基づくデフォルトの挙動は、パッケージ main に次のような行を追加することで明示的に上書きできます。
//go:debug panicnil=1
これらの機能を組み合わせると、プログラムはそれまで使っていた古いツールチェインの挙動を保ったまま新しいツールチェインに更新でき、必要に応じて特定の設定をよりきめ細かく制御でき、そしてどのジョブが実際にこうした非デフォルトの挙動を利用しているかを本番環境のモニタリングによって把握できます。これらを組み合わせることで、新しいツールチェインの展開はこれまでよりもさらにスムーズになるはずです。
詳細については「Go, Backwards Compatibility, and GODEBUG」を参照してください。
Go 2に関する近況
この記事の冒頭で引用した「Go 1 and the Future of Go Programs」の文章では、省略記号の部分に次のような限定が隠されていました。
いつか不確定な時点で、Go 2の仕様が現れるかもしれませんが、その時が来るまでは、[……すべての互換性の詳細……]。
これは当然の疑問を生みます。古いGo 1のプログラムを壊すようなGo 2の仕様は、いつ登場すると考えればよいのでしょうか?
答えは「決してない」です。過去と決別し、古いプログラムをもうコンパイルできなくなるという意味でのGo 2は、決して起こりません。2017年に私たちが目指し始めた、Go 1のメジャーな改訂という意味でのGo 2は、すでに実現しています。
Go 1のプログラムを壊すようなGo 2は存在しません。その代わりに、私たちは互換性にさらに力を注いでいきます。それは過去との決別がもたらしうるどんな価値よりもはるかに価値のあるものです。実際、互換性を優先することこそが、私たちがGo 1のために下した最も重要な設計上の決断だったと私たちは考えています。
ですから、これから数年の間に皆さんが目にすることになるのは、数多くの新しく刺激的な仕事です。ただし、それは慎重で互換性を保った形で行われるものであり、あるツールチェインから次のツールチェインへのアップグレードをできる限り退屈なものにし続けるためのものです。
By Russ Cox