モジュールの互換性を維持する
Keeping Your Modules Compatible by Jean Barkhuysen and Jonathan Amsterdam
はじめに
この記事は全5回のシリーズの第5回です。
- 第1回:Go Modulesを使う
- 第2回:Go Modulesへの移行
- 第3回:Go Modulesを公開する
- 第4回:Go Modules: v2とその先へ
- 第5回:モジュールの互換性を維持する(この記事)
注: モジュールの開発に関するドキュメントについては、Developing and publishing modules を参照してください。
モジュールは、新機能を追加したり、挙動を変更したり、公開部分を見直したりすることで、時間とともに進化していきます。Go Modules: v2とその先へ で議論したように、v1以降のモジュールに対する破壊的変更は、メジャーバージョンの増加(あるいは新しいモジュールパスの採用)を伴わなければいけません。
しかし、新しいメジャーバージョンをリリースすることは、ユーザーにとって負担になります。ユーザーは新しいバージョンを見つけ、新しいAPIを学び、自分のコードを変更しなければなりません。また、一部のユーザーは決して更新しないかもしれず、その場合モジュールの作者は同じコードの2つのバージョンを永遠にメンテナンスし続けることになります。そのため、通常は既存のパッケージを互換性を保ったまま変更する方が良いのです。
この記事では、破壊的でない変更を導入するためのテクニックをいくつか見ていきます。共通するテーマは「追加せよ、変更や削除はするな」です。また、最初から互換性を考慮してAPIを設計する方法についても触れます。
関数への追加
破壊的変更は、しばしば関数への新しい引数の追加という形で現れます。この種の変更に対処するいくつかの方法を説明しますが、まずはうまくいかない手法を見てみましょう。
妥当なデフォルト値を持つ新しい引数を追加する際、可変長引数として追加したくなるものです。次の関数を拡張するとして、
func Run(name string)
デフォルト値がゼロである追加の size 引数を加えるとして、既存の呼び出し箇所がすべて動作し続けることを理由に、次のように提案するかもしれません。
func Run(name string, size ...int)
それは事実ですが、Run の他の使われ方が壊れる可能性があります。例えば次のようなケースです。
package mypkg
var runner func(string) = yourpkg.Run
元の Run 関数はここでは動作します。その型が func(string) だからです。しかし新しい Run 関数の型は func(string, ...int) になるため、この代入はコンパイル時に失敗します。
この例は、呼び出し時の互換性だけでは後方互換性としては不十分であることを示しています。実のところ、関数のシグネチャに対して行える後方互換な変更は存在しません。
関数のシグネチャを変更する代わりに、新しい関数を追加しましょう。例えば、context パッケージが導入された後、関数の第一引数として context.Context を渡すことが一般的な慣習になりました。しかし、安定版のAPIでは、既存のエクスポートされた関数を context.Context を受け取るように変更することはできませんでした。それはその関数のすべての使用箇所を壊してしまうからです。
代わりに、新しい関数が追加されました。例えば、database/sql パッケージの Query メソッドのシグネチャは(そして今でも)次の通りでした。
func (db *DB) Query(query string, args ...interface{}) (*Rows, error)
context パッケージが作られたとき、Goチームは database/sql に新しいメソッドを追加しました。
func (db *DB) QueryContext(ctx context.Context, query string, args ...interface{}) (*Rows, error)
コードの重複を避けるため、古いメソッドは新しいメソッドを呼び出します。
func (db *DB) Query(query string, args ...interface{}) (*Rows, error) {
return db.QueryContext(context.Background(), query, args...)
}
メソッドを追加することで、ユーザーは自分のペースで新しいAPIに移行できます。両方のメソッドは似た読み方ができ、並べて配置され、新しいメソッドの名前に Context が含まれているため、この database/sql APIの拡張はパッケージの読みやすさや理解のしやすさを損なうことはありませんでした。
将来的に関数にさらに引数が必要になると予想される場合は、あらかじめオプションの引数を関数のシグネチャの一部としておくことで備えられます。最も単純な方法は、crypto/tls.Dial 関数がそうしているように、単一の構造体を引数として追加することです。
func Dial(network, addr string, config *Config) (*Conn, error)
Dial が行うTLSハンドシェイクにはネットワークとアドレスが必要ですが、それ以外にも妥当なデフォルト値を持つ多数のパラメータがあります。config に nil を渡すとそれらのデフォルト値が使われます。一部のフィールドを設定した Config 構造体を渡すと、そのフィールドのデフォルト値が上書きされます。将来、新しいTLS設定パラメータを追加する場合は、Config 構造体に新しいフィールドを追加するだけで済みます。これはほとんどの場合後方互換な変更です(例外については後述の「構造体の互換性を保つ」を参照してください)。
新しい関数の追加とオプションの追加という2つのテクニックは、オプション用の構造体をメソッドのレシーバにすることで組み合わせられる場合があります。net パッケージのネットワークアドレスでの待ち受け機能がどのように進化したかを見てみましょう。Go 1.11より前は、net パッケージは次のシグネチャを持つ Listen 関数のみを提供していました。
func Listen(network, address string) (Listener, error)
Go 1.11では、net の待ち受け機能に2つの機能が追加されました。1つはコンテキストを渡せるようにすること、もう1つは呼び出し元が「コントロール関数」を提供し、生成後かつバインド前の生のコネクションを調整できるようにすることです。結果として、コンテキスト、ネットワーク、アドレス、コントロール関数を受け取る新しい関数を作ることもできたでしょう。しかしパッケージの作者たちは、将来さらにオプションが必要になることを見越して、代わりに ListenConfig 構造体を追加しました。そして扱いにくい名前の新しいトップレベル関数を定義する代わりに、ListenConfig に Listen メソッドを追加しました。
type ListenConfig struct {
Control func(network, address string, c syscall.RawConn) error
}
func (*ListenConfig) Listen(ctx context.Context, network, address string) (Listener, error)
将来的に新しいオプションを提供するもう一つの方法は「オプション型」パターンです。これはオプションを可変長引数として渡し、各オプションが構築中の値の状態を変更する関数になっているというものです。この手法については、Rob Pikeの記事 Self-referential functions and the design of options でより詳しく説明されています。広く使われている例の一つが google.golang.org/grpc の DialOption です。
オプション型は、関数の引数における構造体オプションと同じ役割を果たします。つまり、挙動を変更する設定を渡すための拡張可能な方法です。どちらを選ぶかは主にスタイルの問題です。gRPCの DialOption オプション型を使ったこの単純な例を考えてみましょう。
grpc.Dial("some-target",
grpc.WithAuthority("some-authority"),
grpc.WithMaxDelay(time.Second),
grpc.WithBlock())
これは構造体オプションとしても実装できたでしょう。
notgrpc.Dial("some-target", ¬grpc.Options{
Authority: "some-authority",
MaxDelay: time.Second,
Block: true,
})
関数型オプションにはいくつかの欠点があります。呼び出しのたびにオプションの前にパッケージ名を書く必要があること、パッケージの名前空間が大きくなること、そして同じオプションが2回指定された場合の挙動が不明確であることです。一方で、オプション構造体を引数に取る関数は、ほとんど常に nil になりうるパラメータを必要とし、これを好まない人もいます。また、ある型のゼロ値が有効な意味を持つ場合、そのオプションにデフォルト値を指定させるのは不格好になりがちで、通常はポインタや追加のbool型フィールドが必要になります。
どちらを選んでも、モジュールの公開APIの将来的な拡張性を確保するための妥当な選択肢です。
インターフェースを扱う
新機能が公開インターフェースへの変更を必要とすることがあります。例えば、インターフェースに新しいメソッドを追加して拡張する必要がある場合です。しかし、インターフェースに直接メソッドを追加することは破壊的変更になります。では、どうすれば公開インターフェースに新しいメソッドをサポートさせられるのでしょうか。
基本的な考え方は、新しいメソッドを持つ新しいインターフェースを定義し、古いインターフェースが使われている箇所で、渡された型が古い型なのか新しい型なのかを動的にチェックすることです。
archive/tar パッケージの例でこれを説明しましょう。tar.NewReader は io.Reader を受け取りますが、時間が経つにつれてGoチームは、Seek を呼び出せれば、あるファイルヘッダから次のファイルヘッダへスキップするのがより効率的になることに気づきました。しかし、io.Reader に Seek メソッドを追加することはできませんでした。それは io.Reader のすべての実装を壊してしまうからです。
もう一つ却下された選択肢は、tar.NewReader を io.Reader の代わりに io.ReadSeeker を受け取るように変更することでした。io.ReadSeeker は io.Reader のメソッドと(io.Seeker 経由の)Seek の両方をサポートしているからです。しかし、先に見た通り、関数のシグネチャを変更することもまた破壊的変更です。
そこで彼らは tar.NewReader のシグネチャは変更せずそのままにし、tar.Reader のメソッド内で io.Seeker かどうかを型チェックして(サポートして)対応することにしました。
package tar
type Reader struct {
r io.Reader
}
func NewReader(r io.Reader) *Reader {
return &Reader{r: r}
}
func (r *Reader) Read(b []byte) (int, error) {
if rs, ok := r.r.(io.Seeker); ok {
// より効率的なrs.Seekを使う。
}
// 効率は落ちるがr.r.Readを使う。
}
(実際のコードについては reader.go を参照してください。)
既存のインターフェースにメソッドを追加したい場合に、この戦略に従える場合があります。まず、新しいメソッドを持つ新しいインターフェースを作成するか、その新しいメソッドを持つ既存のインターフェースを見つけます。次に、それに対応する必要がある関連する関数を特定し、その2つ目のインターフェースであるかを型チェックし、それを使うコードを追加します。
この戦略は、新しいメソッドを持たない古いインターフェースを引き続きサポートできる場合にのみ機能し、モジュールの将来的な拡張性を制限してしまいます。
可能であれば、この種の問題自体を完全に避ける方が良いでしょう。例えば、コンストラクタを設計する際には、具象型を返すことを優先してください。具象型を扱えば、インターフェースとは異なり、将来ユーザーを壊すことなくメソッドを追加できます。この性質のおかげで、モジュールを将来より簡単に拡張できるようになります。
ヒントとして、インターフェースを使う必要があるが、ユーザーにそれを実装させるつもりがない場合は、非公開のメソッドを追加できます。これにより、パッケージ外で定義された型が、埋め込みなしにそのインターフェースを満たすことを防げるため、後からユーザーの実装を壊さずにメソッドを追加できるようになります。例えば testing.TB の private() 関数 を見てください。
// TBはTとBに共通のインターフェースです。
type TB interface {
Error(args ...interface{})
Errorf(format string, args ...interface{})
// ...
// ユーザーがこのインターフェースを実装できないようにするための
// 非公開メソッド。これにより、将来の追加がGo 1の互換性に
// 違反しないようになります。
private()
}
このトピックについては、Jonathan Amsterdamによる “Detecting Incompatible API Changes” というトークでもより詳しく解説されています(動画、スライド)。
設定用メソッドを追加する
ここまでは、型や関数の変更によってユーザーのコードがコンパイルできなくなるような、明らかな破壊的変更について話してきました。しかし、ユーザーのコードがコンパイルできる状態のままであっても、挙動の変更によってユーザーを壊してしまうことがあります。例えば、多くのユーザーは json.Decoder が、引数の構造体に存在しないJSON内のフィールドを無視することを期待しています。Goチームがそのようなケースでエラーを返したいと考えたとき、慎重にならざるを得ませんでした。オプトインの仕組みなしにそれを行うと、それらのメソッドに依存している多くのユーザーが、これまで発生しなかったエラーを受け取り始めてしまうかもしれないからです。
そこで彼らは、すべてのユーザーに対して挙動を変更する代わりに、Decoder 構造体に設定用メソッド Decoder.DisallowUnknownFields を追加しました。このメソッドを呼び出すことで、ユーザーは新しい挙動をオプトインできます。呼び出さなければ、既存のユーザーに対しては古い挙動が保たれます。
構造体の互換性を保つ
先に見た通り、関数のシグネチャへのいかなる変更も破壊的変更になります。構造体の場合は状況がずっと良好です。エクスポートされた構造体型があれば、フィールドを追加したり、非公開のフィールドを削除したりしても、ほとんどの場合互換性を壊さずに済みます。フィールドを追加する際は、そのゼロ値が意味を持ち、古い挙動を保つようにしてください。そうすれば、そのフィールドを設定しない既存のコードも動作し続けます。
net パッケージの作者たちが、将来さらにオプションが必要になるかもしれないと考えてGo 1.11で ListenConfig を追加したことを思い出してください。実際、彼らの予想は正しかったのです。Go 1.13では、keep-aliveを無効にしたり、その周期を変更したりできるように KeepAlive フィールド が追加されました。ゼロ値というデフォルト値は、デフォルトの周期でkeep-aliveを有効にするという元々の挙動を保っています。
新しいフィールドが思いがけずユーザーのコードを壊してしまう、微妙なケースが一つあります。構造体内のすべてのフィールドの型が比較可能である場合(つまりそれらの型の値が == や != で比較でき、マップのキーとして使える場合)、構造体全体の型も比較可能になります。この場合、比較不可能な型の新しいフィールドを追加すると、構造体全体の型が比較不可能になってしまい、その構造体型の値を比較しているコードを壊してしまいます。
構造体を比較可能なままにするには、比較不可能なフィールドを追加しないようにしてください。そのためのテストを書くこともできますし、今後登場予定の gorelease ツールに検出を任せることもできます。
そもそも比較自体を防ぎたい場合は、構造体に比較不可能なフィールドを持たせるようにしてください。スライス、マップ、関数型はいずれも比較不可能なので、すでにそのようなフィールドがあるかもしれません。もしなければ、次のように追加できます。
type Point struct {
_ [0]func()
X int
Y int
}
func() 型は比較不可能で、長さゼロの配列は場所を取りません。意図を明確にするために型を定義することもできます。
type doNotCompare [0]func()
type Point struct {
doNotCompare
X int
Y int
}
自分の構造体で doNotCompare を使うべきでしょうか。もしその構造体をポインタとして使うように定義している場合(つまりポインタレシーバのメソッドを持ち、ポインタを返す NewXXX コンストラクタ関数もおそらく用意している場合)、doNotCompare フィールドを追加するのはおそらくやり過ぎです。ポインタ型のユーザーは、その型の各値が別個のものであることを理解しています。つまり2つの値を比較したいなら、ポインタ同士を比較すべきだと理解しているのです。
先ほどの Point の例のように、値として直接使われることを意図した構造体を定義する場合は、多くの場合その型を比較可能にしたいはずです。値として使う構造体を比較させたくないという珍しいケースでは、doNotCompare フィールドを追加することで、比較が壊れる心配をせずに後から構造体を変更できる自由が得られます。ただしその代わり、その型はマップのキーとしては使えなくなります。
まとめ
APIをゼロから設計する際は、将来の新しい変更に対してそのAPIがどれだけ拡張可能かを慎重に検討してください。そして新機能を追加する必要が生じたときは、「追加せよ、変更や削除はするな」というルールを思い出してください。ただし例外があることも忘れないでください。インターフェース、関数の引数、戻り値は、後方互換な形で追加できません。
APIを劇的に変更する必要がある場合、あるいは機能が増えるにつれてAPIの焦点がぼやけてきた場合は、新しいメジャーバージョンを出す時期かもしれません。しかし、ほとんどの場合、後方互換な変更を行うのは簡単であり、ユーザーに苦痛を与えずに済みます。
By Jean Barkhuysen and Jonathan Amsterdam