はじめに

この記事はシリーズの第5部です。

注: モジュールの開発に関するドキュメントは、モジュールの開発と公開を参照してください。

モジュールは、新機能を追加したり、動作を変更したり、モジュールの公開インターフェースの一部を再考したりすることで、時間とともに進化します。Goモジュール: v2とその先で説明したように、v1+モジュールに対する破壊的変更は、メジャーバージョンのアップ(または新しいモジュールパスの採用)として行う必要があります。

しかし、新しいメジャーバージョンをリリースすることは、ユーザーにとっては難しいことです。彼らは新しいバージョンを見つけ、新しいAPIを学び、コードを変更しなければなりません。そして、一部のユーザーは決して更新しないかもしれないため、あなたは永遠に2つのバージョンを維持しなければならないことになります。したがって、通常は既存のパッケージを互換性のある方法で変更する方が良いです。

この記事では、非破壊的変更を導入するためのいくつかの技術を探ります。共通のテーマは「追加すること、変更または削除しないこと」です。また、最初から互換性を考慮したAPIの設計についても話します。

関数への追加

しばしば、破壊的変更は関数への新しい引数の形で現れます。この種の変更に対処するいくつかの方法を説明しますが、まずは機能しない技術を見てみましょう。

合理的なデフォルトを持つ新しい引数を追加する際、可変長引数として追加することは魅力的です。関数を拡張するために

  1. func Run(name string)

追加の size 引数をゼロにデフォルト設定して提案するかもしれません。

  1. func Run(name string, size ...int)

すべての既存の呼び出しサイトが引き続き機能するという理由からです。それは真実ですが、Run の他の使用法が壊れる可能性があります。例えば、次のように:

  1. package mypkg
  2. var runner func(string) = yourpkg.Run

元の Run 関数はその型が func(string) であるためここで機能しますが、新しい Run 関数の型は func(string, ...int) であるため、代入はコンパイル時に失敗します。

この例は、呼び出しの互換性が後方互換性には不十分であることを示しています。実際、関数のシグネチャに対して後方互換性のある変更は行えません。

関数のシグネチャを変更する代わりに、新しい関数を追加します。例えば、context パッケージが導入された後、関数の最初の引数として context.Context を渡すことが一般的な慣行となりました。しかし、安定したAPIは、context.Context を受け入れるようにエクスポートされた関数を変更することはできません。なぜなら、それはその関数のすべての使用を壊すからです。

その代わりに、新しい関数が追加されました。例えば、database/sql パッケージの Query メソッドのシグネチャは(そして今も)

  1. func (db *DB) Query(query string, args ...interface{}) (*Rows, error)

context パッケージが作成されたとき、Goチームは database/sql に新しいメソッドを追加しました:

  1. func (db *DB) QueryContext(ctx context.Context, query string, args ...interface{}) (*Rows, error)

コードのコピーを避けるために、古いメソッドは新しいメソッドを呼び出します:

  1. func (db *DB) Query(query string, args ...interface{}) (*Rows, error) {
  2. return db.QueryContext(context.Background(), query, args...)
  3. }

メソッドを追加することで、ユーザーは自分のペースで新しいAPIに移行できます。メソッドは似たように読み取れ、並べ替えられ、Context が新しいメソッドの名前に含まれているため、database/sql APIのこの拡張はパッケージの可読性や理解を損なうことはありませんでした。

将来的に関数がより多くの引数を必要とする可能性がある場合は、オプションの引数を関数のシグネチャの一部にすることで事前に計画できます。最も簡単な方法は、crypto/tls.Dial 関数のように、単一の構造体引数を追加することです:

  1. func Dial(network, addr string, config *Config) (*Conn, error)

Dial によって実施されるTLSハンドシェイクはネットワークとアドレスを必要としますが、合理的なデフォルトを持つ他の多くのパラメータがあります。config 用の nil を渡すと、それらのデフォルトが使用されます。いくつかのフィールドが設定された Config 構造体を渡すと、それらのフィールドのデフォルトが上書きされます。将来的に新しいTLS構成パラメータを追加するには、Config 構造体に新しいフィールドを追加するだけで済みます。この変更は後方互換性があります(ほぼ常に—「構造体の互換性を維持する」を参照)。

新しい関数を追加する技術とオプションを追加する技術は、オプション構造体をメソッドレシーバーにすることで組み合わせることができます。net パッケージのネットワークアドレスでのリスニング機能の進化を考えてみましょう。Go 1.11以前は、net パッケージは次のシグネチャを持つ Listen 関数のみを提供していました:

  1. func Listen(network, address string) (Listener, error)

Go 1.11では、net リスニングに2つの機能が追加されました: コンテキストを渡すことと、呼び出し元が作成後に生の接続を調整する「制御関数」を提供できるようにすることです。その結果は、コンテキスト、ネットワーク、アドレス、制御関数を受け取る新しい関数になる可能性がありました。しかし、パッケージの著者は、将来的により多くのオプションが必要になるかもしれないと予想して、ListenConfig 構造体を追加しました。そして、煩雑な名前の新しいトップレベル関数を定義するのではなく、ListenConfigListen メソッドを追加しました:

  1. type ListenConfig struct {
  2. Control func(network, address string, c syscall.RawConn) error
  3. }
  4. func (*ListenConfig) Listen(ctx context.Context, network, address string) (Listener, error)

将来的に新しいオプションを提供する別の方法は、「オプションタイプ」パターンです。ここでは、オプションが可変長引数として渡され、各オプションは構築中の値の状態を変更する関数です。これについては、Rob Pikeの投稿 自己参照関数とオプションの設計 で詳しく説明されています。広く使用されている例の1つは、google.golang.org/grpcDialOption です。

オプションタイプは、関数引数の構造体オプションと同じ役割を果たします: 振る舞いを変更する構成を渡すための拡張可能な方法です。どちらを選ぶかは主にスタイルの問題です。gRPCの DialOption オプションタイプのこのシンプルな使用法を考えてみてください:

  1. grpc.Dial("some-target",
  2. grpc.WithAuthority("some-authority"),
  3. grpc.WithMaxDelay(time.Second),
  4. grpc.WithBlock())

これは構造体オプションとしても実装できました:

  1. notgrpc.Dial("some-target", &notgrpc.Options{
  2. Authority: "some-authority",
  3. MaxDelay: time.Second,
  4. Block: true,
  5. })

関数型オプションにはいくつかの欠点があります: 各呼び出しのオプションの前にパッケージ名を書く必要があり、パッケージの名前空間が大きくなり、同じオプションが2回提供された場合の動作が不明確です。一方、オプション構造体を受け取る関数は、ほぼ常に nil である可能性のあるパラメータを必要とし、これを魅力的でないと感じる人もいます。また、型のゼロ値に有効な意味がある場合、オプションがデフォルト値を持つべきことを指定するのは不器用で、通常はポインタまたは追加のブールフィールドが必要です。

どちらも、モジュールの公開APIの将来の拡張性を確保するための合理的な選択です。

インターフェースとの作業

時には、新しい機能が公開インターフェースに変更を必要とします。例えば、インターフェースに新しいメソッドを追加する必要があります。しかし、インターフェースに直接追加することは破壊的変更です。では、公開インターフェースに新しいメソッドをサポートするにはどうすればよいのでしょうか?

基本的なアイデアは、新しいメソッドを持つ新しいインターフェースを定義し、古いインターフェースが使用されている場所では、提供された型が古い型か新しい型かを動的にチェックすることです。

これを archive/tar パッケージの例で説明しましょう。tar.NewReaderio.Reader を受け入れますが、時間が経つにつれてGoチームは、Seek を呼び出すことができれば、1つのファイルヘッダーから次のファイルヘッダーにスキップする方が効率的であることに気付きました。しかし、io.ReaderSeek メソッドを追加することはできませんでした。なぜなら、それは io.Reader のすべての実装者を壊すからです。

別の選択肢として、tar.NewReaderio.ReadSeeker を受け入れるように変更することも考えられましたが、io.Reader をサポートしているため、io.Reader メソッドと Seek をサポートしています(io.Seeker 経由で)。しかし、上記のように、関数シグネチャを変更することも破壊的変更です。

したがって、tar.NewReader シグネチャを変更せずに、tar.Reader メソッドで io.Seeker を型チェックしてサポートすることに決めました:

  1. package tar
  2. type Reader struct {
  3. r io.Reader
  4. }
  5. func NewReader(r io.Reader) *Reader {
  6. return &Reader{r: r}
  7. }
  8. func (r *Reader) Read(b []byte) (int, error) {
  9. if rs, ok := r.r.(io.Seeker); ok {
  10. // Use more efficient rs.Seek.
  11. }
  12. // Use less efficient r.r.Read.
  13. }

(実際のコードについては reader.go を参照してください。)

既存のインターフェースにメソッドを追加したい場合は、この戦略に従うことができるかもしれません。まず、新しいメソッドを持つ新しいインターフェースを作成するか、新しいメソッドを持つ既存のインターフェースを特定します。次に、それをサポートする必要がある関連する関数を特定し、2番目のインターフェースを型チェックし、それを使用するコードを追加します。

この戦略は、古いインターフェースが新しいメソッドなしでもサポートできる場合にのみ機能し、モジュールの将来の拡張性を制限します。

可能な限り、このクラスの問題を完全に回避する方が良いです。例えば、コンストラクタを設計する際は、具体的な型を返すことを好みます。具体的な型で作業することで、インターフェースとは異なり、将来的にメソッドを追加してもユーザーを壊すことはありません。その特性により、モジュールは将来的により簡単に拡張できます。

ヒント: インターフェースを使用する必要があるが、ユーザーに実装させるつもりがない場合は、エクスポートされていないメソッドを追加できます。これにより、パッケージ外で定義された型が埋め込まずにインターフェースを満たすことができなくなり、後でメソッドを追加してもユーザーの実装を壊すことがなくなります。例えば、testing.TBprivate() 関数 を参照してください。

  1. // TB is the interface common to T and B.
  2. type TB interface {
  3. Error(args ...interface{})
  4. Errorf(format string, args ...interface{})
  5. // ...
  6. // A private method to prevent users implementing the
  7. // interface and so future additions to it will not
  8. // violate Go 1 compatibility.
  9. private()
  10. }

このトピックは、Jonathan Amsterdam の「互換性のないAPI変更の検出」トーク(ビデオスライド)でも詳しく探求されています。

構成メソッドの追加

これまで、型や関数を変更することでユーザーのコードがコンパイルを停止する明白な破壊的変更について話してきました。しかし、動作の変更もユーザーを壊す可能性があります。たとえユーザーのコードが引き続きコンパイルされる場合でもです。例えば、多くのユーザーは json.Decoder が引数構造体にないJSONのフィールドを無視することを期待しています。Goチームがその場合にエラーを返したいと考えたとき、彼らは注意を払う必要がありました。オプトインメカニズムなしでそうすることは、これらのメソッドに依存している多くのユーザーが以前は受け取らなかったエラーを受け取ることを意味します。

したがって、すべてのユーザーの動作を変更するのではなく、Decoder 構造体に構成メソッドを追加しました: Decoder.DisallowUnknownFields。このメソッドを呼び出すことで、ユーザーは新しい動作をオプトインしますが、呼び出さないことで既存のユーザーの古い動作が保持されます。

構造体の互換性を維持する

関数のシグネチャに対する変更は破壊的変更であることがわかりました。構造体の場合は状況がはるかに良好です。エクスポートされた構造体型がある場合、フィールドを追加したり、エクスポートされていないフィールドを削除したりしても、互換性を壊すことはほぼありません。フィールドを追加する際は、そのゼロ値が意味を持ち、古い動作を保持することを確認してください。そうすれば、そのフィールドを設定しない既存のコードが引き続き機能します。

net パッケージの著者が、より多くのオプションが forthcoming かもしれないと考えて、Go 1.11で ListenConfig を追加したことを思い出してください。実際、彼らは正しかった。Go 1.13では、KeepAlive フィールド が追加され、keep-aliveを無効にしたり、その期間を変更したりできるようになりました。ゼロのデフォルト値は、デフォルトの期間でkeep-aliveを有効にする元の動作を保持します。

新しいフィールドがユーザーコードを予期せず壊す可能性がある微妙な方法があります。構造体内のすべてのフィールド型が比較可能である場合—つまり、それらの型の値が == および != で比較でき、マップキーとして使用できる場合—全体の構造体型も比較可能です。この場合、比較不可能な型の新しいフィールドを追加すると、全体の構造体型が比較不可能になり、その構造体型の値を比較するコードが壊れます。

構造体を比較可能に保つためには、比較不可能なフィールドを追加しないでください。それについてテストを書くことができますし、今後の gorelease ツールに頼ることもできます。

比較を最初から防ぐために、構造体に比較不可能なフィールドがあることを確認してください。すでに1つあるかもしれません—スライス、マップ、または関数型は比較可能ではありません—しかし、そうでない場合は、次のように追加できます:

  1. type Point struct {
  2. _ [0]func()
  3. X int
  4. Y int
  5. }

func() 型は比較可能ではなく、ゼロ長の配列はスペースを取らない。意図を明確にするために型を定義できます:

  1. type doNotCompare [0]func()
  2. type Point struct {
  3. doNotCompare
  4. X int
  5. Y int
  6. }

構造体で doNotCompare を使用すべきですか?ポインタとして使用されるように構造体を定義した場合—つまり、ポインタメソッドがあり、ポインタを返す NewXXX コンストラクタ関数がある場合—doNotCompare フィールドを追加することはおそらく過剰です。ポインタ型のユーザーは、その型の各値が異なることを理解しています。つまり、2つの値を比較したい場合は、ポインタを比較すべきです。

直接値として使用されることを意図した構造体を定義している場合、Point の例のように、しばしば比較可能であることを望みます。比較したくない値構造体がある珍しい場合には、doNotCompare フィールドを追加することで、後で構造体を変更する自由を得ることができます。比較を壊すことを心配する必要はありません。欠点として、その型はマップキーとして使用できなくなります。

結論

APIをゼロから計画する際は、将来的に新しい変更に対してどれだけ拡張可能であるかを慎重に考慮してください。そして、新しい機能を追加する必要がある場合は、ルールを思い出してください: 追加すること、変更または削除しないこと。ただし、例外—インターフェース、関数引数、戻り値は後方互換性のある方法で追加できません—を考慮してください。

APIを大幅に変更する必要がある場合や、APIが機能が追加されるにつれて焦点を失い始めた場合は、新しいメジャーバージョンの時期かもしれません。しかし、ほとんどの場合、後方互換性のある変更を行うことは簡単で、ユーザーに痛みを引き起こすことを避けることができます。