Goブログ
モジュールの互換性を維持する
はじめに
この投稿はシリーズの第5部です。
- 第1部 — Goモジュールの使用
- 第2部 — Goモジュールへの移行
- 第3部 — Goモジュールの公開
- 第4部 — Goモジュール:v2以降
- 第5部 — モジュールの互換性を維持する (この投稿)
注記:モジュールの開発に関するドキュメントについては、モジュールの開発と公開を参照してください。
モジュールは、新しい機能を追加したり、動作を変更したり、モジュールの公開サーフェスの部分を再考したりするにつれて、時間の経過とともに進化します。Goモジュール: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`構造体に新しいフィールドを追加するだけで済みます。これは(ほとんどの場合—以下の「構造体の互換性を維持する」を参照)下位互換性のある変更です。
新しい関数の追加とオプションの追加というテクニックは、オプション構造体をメソッドレシーバにすることで組み合わせることができます。ネットワークアドレスでリスンする`net`パッケージの機能の進化を考えてみましょう。Go 1.11より前、`net`パッケージは、次のシグネチャを持つ`Listen`関数のみを提供していました。
func Listen(network, address string) (Listener, error)
Go 1.11では、`net`リスンに2つの機能が追加されました。コンテキストを渡すこと、および呼び出し元が、作成後、バインド前に生の接続を調整するための「制御関数」を提供できるようにすることです。その結果は、コンテキスト、ネットワーク、アドレス、制御関数を受け取る新しい関数だった可能性があります。代わりに、パッケージの作者は、将来さらにオプションが必要になることを予測して、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)
将来、新しいオプションを提供するもう1つの方法は、「オプション型」パターンです。オプションは可変長引数として渡され、各オプションは構築中の値の状態を変更する関数です。これらについては、Rob Pikeの投稿自己参照関数とオプションの設計で詳しく説明されています。広く使用されている例としては、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`である可能性のあるパラメータを必要とし、一部の人はそれを魅力的ではないと考えています。そして、型のゼロ値に有効な意味がある場合、オプションがデフォルト値を持つように指定することは面倒で、通常はポインタまたは追加のブールフィールドが必要です。
どちらの方法も、モジュールの公開APIの将来の拡張性を確保するための妥当な選択肢です。
インターフェースの操作
場合によっては、新しい機能には、公開されているインターフェースの変更が必要になります。たとえば、インターフェースに新しいメソッドを追加する必要があります。ただし、インターフェースに直接追加することは破壊的な変更です。では、どのようにして公開されているインターフェースで新しいメソッドをサポートできるでしょうか?
基本的な考え方は、新しいメソッドを持つ新しいインターフェースを定義し、古いインターフェースが使用されている場所で、提供された型が古い型か新しい型かを動的にチェックすることです。
archive/tar
パッケージの例で説明します。tar.NewReader
はio.Reader
を受け入れますが、時間の経過とともに、Goチームは、Seek
を呼び出すことができれば、ファイルヘッダー間をより効率的にスキップできることに気づきました。しかし、io.Reader
にSeek
メソッドを追加することはできませんでした。そうすると、io.Reader
の実装者がすべて壊れてしまうからです。
別の却下されたオプションは、tar.NewReader
をio.Reader
ではなくio.ReadSeeker
を受け入れるように変更することでした。これはio.Reader
メソッドとSeek
の両方(io.Seeker
を介して)をサポートしています。しかし、上記で見たように、関数のシグネチャを変更することも破壊的な変更です。
そこで、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 {
// Use more efficient rs.Seek.
}
// Use less efficient r.r.Read.
}
(実際のコードについては、reader.goを参照してください。)
既存のインターフェースにメソッドを追加したい場合、この戦略に従える場合があります。まず、新しいメソッドを持つ新しいインターフェースを作成するか、新しいメソッドを持つ既存のインターフェースを特定します。次に、それをサポートする必要がある関連する関数を特定し、2番目のインターフェースを型チェックして、それを使用するコードを追加します。
この戦略は、新しいメソッドのない古いインターフェースをまだサポートできる場合にのみ機能し、モジュールの将来の拡張性を制限します。
可能であれば、この種の課題を完全に回避する方が良いでしょう。たとえば、コンストラクタを設計する際には、具体的な型を返すことを優先します。具体的な型を使用すると、インターフェースとは異なり、将来メソッドを追加してもユーザーを壊すことがありません。その特性により、モジュールを将来より簡単に拡張できます。
ヒント:インターフェースを使用する必要があるものの、ユーザーがそれを実装することを意図していない場合は、エクスポートされていないメソッドを追加できます。これにより、パッケージの外部で定義された型が埋め込みなしでインターフェースを満たすことが防止され、後でメソッドを追加してもユーザー実装が壊れることがなくなります。例として、testing.TB
のprivate()
関数を参照してください。
// TB is the interface common to T and B.
type TB interface {
Error(args ...interface{})
Errorf(format string, args ...interface{})
// ...
// A private method to prevent users implementing the
// interface and so future additions to it will not
// violate Go 1 compatibility.
private()
}
このトピックについては、Jonathan Amsterdam氏の「Detecting Incompatible API Changes」講演(ビデオ、スライド)でより詳細に説明されています。
設定メソッドの追加
これまで、型または関数の変更によってユーザーのコードがコンパイルされなくなるような、明らかな破壊的変更について説明してきました。しかし、ユーザーコードがコンパイルされ続けても、動作の変更によってユーザーが問題に直面することがあります。たとえば、多くのユーザーはjson.Decoder
が、引数の構造体にはないJSON内のフィールドを無視することを期待しています。Goチームがこの場合にエラーを返すことを望んだとき、彼らは注意深く対処する必要がありました。オプトインメカニズムなしで行うと、それらのメソッドに依存している多くのユーザーが、以前は発生していなかったエラーを受け取り始める可能性があります。
そこで、すべてのユーザーに対して動作を変更するのではなく、Decoder
構造体に設定メソッドDecoder.DisallowUnknownFields
を追加しました。このメソッドを呼び出すと、ユーザーは新しい動作を選択できますが、呼び出さない場合は、既存のユーザーに対して以前の動作が維持されます。
構造体の互換性の維持
上記で見たように、関数のシグネチャへの変更はすべて破壊的変更です。構造体の場合、状況はずっと良好です。エクスポートされた構造体型がある場合、ほとんどの場合、フィールドを追加したり、エクスポートされていないフィールドを削除したりしても、互換性を損なうことはありません。フィールドを追加する場合は、そのゼロ値が意味を持ち、古い動作を維持するようにしてください。そうすれば、フィールドを設定しない既存のコードは引き続き機能します。
net
パッケージの作者がGo 1.11でListenConfig
を追加したのは、さらに多くのオプションが追加される可能性があると考えたためです。彼らは正しかったことが判明しました。Go 1.13では、KeepAlive
フィールドが追加され、キープアライブの無効化または期間の変更が可能になりました。ゼロのデフォルト値により、デフォルト期間でのキープアライブの有効化という元の動作が維持されます。
新しいフィールドが予期せずユーザーコードを壊す可能性がある微妙な方法が1つあります。構造体のすべてのフィールド型が比較可能である場合(つまり、それらの型の値を==
と!=
で比較し、マップキーとして使用できる場合)、全体的な構造体型も比較可能になります。この場合、比較不可能な型の新しいフィールドを追加すると、全体的な構造体型が比較不可能になり、その構造体型の値を比較するコードがすべて壊れます。
構造体を比較可能な状態に保つには、比較不可能なフィールドを追加しないでください。それについてテストを作成するか、間もなく提供されるgoreleaseツールを利用して検出できます。
そもそも比較を防止するには、構造体に比較不可能なフィールドを含めます。既に存在している可能性があります(スライス、マップ、関数型はどれも比較可能ではありません)。存在しない場合は、次のように追加できます。
type Point struct {
_ [0]func()
X int
Y int
}
func()
型は比較可能ではなく、長さ0の配列はスペースを占有しません。意図を明確にするために型を定義できます。
type doNotCompare [0]func()
type Point struct {
doNotCompare
X int
Y int
}
構造体でdoNotCompare
を使用する必要がありますか?ポインターとして使用されるように構造体を定義した場合(つまり、ポインターメソッドと、おそらくポインターを返すNewXXX
コンストラクター関数がある場合)、doNotCompare
フィールドを追加するのはおそらく過剰です。ポインター型のユーザーは、その型の各値が別個であることを理解しています。つまり、2つの値を比較する必要がある場合は、ポインターを比較する必要があるということです。
値として直接使用されるように構造体を定義する場合(Point
の例のように)、多くの場合、比較可能であることが望ましいです。比較したくない値構造体がまれにある場合、doNotCompare
フィールドを追加すると、比較を壊すことを心配することなく、後で構造体に変更を加えることができます。欠点としては、その型をマップキーとして使用できなくなることです。
結論
APIを最初から計画する際には、将来の新しい変更に対してAPIがどの程度拡張可能であるかを慎重に検討してください。そして、新しい機能を追加する必要がある場合は、「追加する、変更したり削除したりしない」というルールを覚えておいてください。例外として、インターフェース、関数引数、戻り値は、後方互換性のある方法では追加できません。
APIを大幅に変更する必要がある場合、または機能の追加に伴ってAPIが焦点を見失い始めた場合は、新しいメジャーバージョンに移行する時期かもしれません。しかし、ほとんどの場合、後方互換性のある変更を行うのは簡単であり、ユーザーに負担をかけるのを回避できます。
次の記事:Go 1.15 リリース
前の記事:ジェネリックスの次のステップ
ブログインデックス