The Go Blog
モジュールの互換性を維持する
はじめに
この記事はシリーズのパート5です。
- パート1 — Go Modules の使用
- パート2 — Go Modules への移行
- パート3 — Go Modules の公開
- パート4 — Go Modules: v2 以降
- パート5 — モジュールの互換性を維持する (この記事)
注: モジュールの開発に関するドキュメントについては、モジュールの開発と公開をご覧ください。
モジュールは、新しい機能の追加、動作の変更、モジュールのパブリックインターフェースの一部を再検討するにつれて、時間の経過とともに進化します。Go Modules: v2 and Beyondで説明したように、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の投稿Self-referential functions and the design of optionsで詳しく説明されています。広く使用されている例の1つは、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のすべての実装を壊すことになるからです。
除外されたもう1つのオプションは、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の「互換性のないAPI変更の検出」の講演(ビデオ、スライド)でも詳しく説明されています。
設定メソッドの追加
これまでは、型や関数を変更するとユーザーのコードがコンパイルされなくなるという、明らかな破壊的変更について話してきました。しかし、ユーザーのコードがコンパイルされ続けても、動作の変更がユーザーを壊すこともあります。たとえば、多くのユーザーは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()型は比較不可能であり、長さゼロの配列はスペースを消費しません。意図を明確にするために型を定義できます。
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がリリースされました
前の記事: ジェネリクスへの次のステップ
ブログインデックス