Semantic Import Versioning
(Go & Versioning、パート 3)
※訳註: この記事は、Russ Coxによって 2018 年 2 月 21 日に投稿された記事 “Semantic Import Versioning” の和訳版です。筆者に許可をとり、@nekketsuuu が和訳・公開しました。
既存のパッケージにどうやって非互換な変更をするだろうか? これはどんなパッケージ管理システムにおいても基礎的な課題であり、基礎的な決定である。その答えによって、最終的にできるシステムの複雑度が決まる。パッケージ管理がどのくらい使いやすいか・使いにくいかが決まるのだ。(これは同時にパッケージ管理がどのくらい実装しやすいか・実装しにくいかも決めるが、ユーザー体験の方がより重要である。)
この質問に答えるためにこの投稿では、まず Go のための import compatibility rule を提示しておきたい。
もし古いパッケージと新しいパッケージが同一の import path を持っているなら、新しいパッケージは古いパッケージと後方互換でないといけない。
我々は Go が生まれたときからこの原則について議論してきたが、このように名付けたことや、直接述べたことは無かった。
Import compatibility rule は非互換性を持つバージョンのパッケージの使い方を劇的に単純化する。異なるバージョンのものがそれぞれ異なる import path を持つとき、与えられた import が何を意味するかという部分に曖昧性は無い。開発者にとってもツールにとっても、このことが Go のプログラムを理解しやすくするのだ。
昨今の開発者はパッケージのバージョンを記述するのにセマンティック・バージョンを使うだろうから、我々もモデルの中にそれを採用した。具体的には、ある module my/thing
が v0 のとき、それは開発中の期間であり様々な破壊的変更が行われるかもしれない。続いて v1 は、最初の安定したメジャー・バージョンである。そして v2 を追加することになったとき、現在の安定版 my/thing
の意味を再定義する代わりに、新しい名前 my/thing/v2
としてリリースするのだ。
セマンティック・バージョニングを使いながら import compatibility rule を守るときのこの慣習を、私は semantic import versioning と呼んでいる。
1 年前、このような形でバージョンを import path に入れるのは見苦しいし、望ましくないし、おそらく避けるべきだろうと私は信じていた。しかし時間が過ぎ、私はこのやり方によってシステムがどれだけ明確になり、どれだけ単純になるかを理解した。今回の投稿では、なぜ私が意見を変えるに至ったのかの直感を分かってもらえればと思う。
『依存性ものがたり』
議論を具体化するために、次のような物語を考えて欲しい。この物語は仮想のものだが、もちろん実際の問題に着想を得ている。Dep
がリリースされたとき、OAuth2 のパッケージを書いていた Google のチームが、彼らが長らく望んでいた非互換なアップデートをどうやって導入すればよいか私に相談しに来た。私はそれについて考えれば考えるほど、それは見た目ほど簡単ではなく、少なくとも semantic import versioning 無しには考えられないと思うようになった。
前書き
パッケージ管理ツールの視点から見ると、コードの作者 (author) とコードのユーザー (user) が大事だ。Alice、Anna、そして Amy が異なるパッケージの作者だとしよう。Alice は Google で働いていて、OAuth2 パッケージを書いた。Amy は Microsoft で働いていて Azure のクライアント・ライブラリを書いた。Anna は Amazon で働いていて、AWS のクライアント・ライブラリを書いた。Ugo はこれら全てのパッケージのユーザーであり、究極のクラウド・アプリ Unity を作ろうとしている。Unity はこれらのパッケージとその他のパッケージを使用している。
作者として、Alice、Anna、そして Amy は自分たちのパッケージの新しいバージョンを書いてリリースできる必要がある。パッケージのそれぞれのバージョンは、それが必要とする依存パッケージのバージョンを指定する。
ユーザーとして、Ugo はこれらのパッケージと共に Unity をビルドしないといけない。それぞれのビルドにおいてどのバージョンが使われるのかを正確に管理する必要があるし、彼が選んだバージョンにアップデートできる必要もある。
パッケージ管理ツールに期待する機能はきっと他にもあるだろう。特に発見、テスト、ポータビリティ、役立つ診断などがあるだろうが、今回の物語には関係してこない。
さてさて物語は、Ugo が Unity を下のような依存関係でビルドしようとした所からはじまる。
第 1 章
誰もが一人でソフトウェアを書いている。
Google では、Alice は OAuth2 パッケージのための新しい、単純な、使いやすい API を設計するのに忙しかった。それは全て古いパッケージでもできることではあったが、API の機能を生かしきれるような形ではなかった。彼女はその新しいパッケージを OAuth2 r2 としてリリースすることにした。(ここの ‘r’ は revision (改定) を表す。今のところこのリビジョン・ナンバーには、順番を示す以外の意味はない。特に、セマンティック・バージョンではない。)
Microsoft では、Amy はバカンスに行っており、彼女のチームは彼女が帰ってくるまで OAuth2 r2 に関する変更を行わないことにした。つまり Azure パッケージは今のところ OAuth2 r1 を使い続けることになる。
Amazon では、Anna が OAuth2 r2 を使うと AWS r1 の数々の悪い実装を削除できることに気がついた。だから彼女は AWS で OAuth2 r2 を使うことにし、更にその上でいくつかのバグと issue を直したバージョンとして AWS r2 をリリースした。
Ugo は Azure での挙動に関するバグ報告を受け調査していたところ、その原因が Azure クライアント・ライブラリのバグにあることを突き止めた。そのバグは Amy がバカンスに出かける前に直され、Azure r2 としてリリースされていた。Ugo は Unity に先程のバグのテストを書いて失敗することを確かめられたので、パッケージ管理ツールで Azure r2 へアップデートしようとしている。
そのアップデートが終わると、Ugo の行うビルドは次のようになる。
Ugo は先程のテストが通り、今までのテストも全て通ることを確認した。そして Azure のバージョンをロックし、それを Unity のアップデートとしてリリースした。
第 2 章
喜ばしいことに、Amazon が新しいクラウドを始めることになった。Amazon Zeta Functions だ。その準備に伴って、Anna は Zeta のサポートを AWS パッケージに追加し、AWS r3 としてリリースした。
Ugo は Amazon Zeta のことを聞きつけ、いくらかサンプルのプログラムを書き、その素晴らしさを知った。急いで Unity をアップデートし、これに対応しないと! しかし、今回のアップデートは前回のようには上手くいかない。Ugo は Unity に Zeta サポートを追加するため、それぞれの最新版である Azure r2 と AWS r3 を使ってビルドしないといけない。しかし Azure r2 には OAuth2 r1 (r2 ではない) が必要だし、AWS r3 には OAuth2 r2 (r1 ではない) が必要だ。これは古典的なダイアモンド継承になっている。ただ、Ugo はそのことに気付いておらず、単に Unity をビルドしたいと考えていた。
悪いことに、この問題は誰の責任でもない。Alice はより良い OAuth2 パッケージを書いた。Amy は Azure のいくつかのバグを直してバカンスに行った。Anna は AWS 内部実装において OAuth2 を使うと決め、その後 Zeta をサポートした。Ugo は Unity のために最新版の Azure と AWS を使いたい。誰も何か悪いことをしたわけではないだろう。誰も悪くないのであれば、これはパッケージ・マネジャーの過失である。我々は今まで、Ugo の Unity をビルドする際には OAuth2 のバージョンは単一であると仮定していた。おそらくこれが問題だ。パッケージ・マネジャーは一回のビルドの中に異なるバージョンが現れることを許すべきなのだろう。この例からは、そうしなけばいけないように感じる。
Ugo はビルドができないので、StackOverflow を検索して、今回のパッケージ・マネジャーには -fmultiverse
という、複数のバージョンがあることを許すオプションがあることを知った。これを使えば、Ugo のプログラムは下のようにビルドされる。
Ugo はこれを試してみたが、動かない。より問題を深堀りしていったところ、Azure も AWS も、有名な OAuth2 ミドルウェア・ライブラリである Moauth を使って OAuth2 の処理を簡略化していることが明らかになった。Moauth は完全に API を互換するわけではなく、従ってそれを使うにしろ OAuth2 を import しなくてはいけないのだが、それでも Moauth は API の呼び出しを簡略にするためには有用だった。OAuth2 が r1 から r2 に変わろうと Moauth の細かいところはそのままでよかったので、存在する唯一のバージョンであるところの Moauth r1 が Oauth2 のどちらのバージョンとも互換性を持っていた。Azure r2 と AWS r3 のどちらもが Moauth r1 を使っているということだ。これは Azure だけを使っているプログラムや AWS だけを使っているプログラムでは正しく動作するのだが、Ugo の Unity のような場合は下のようになってしまう。
Unity は OAuth2 の両方のバージョンを必要としているわけだが、Moauth が import すべきはどちらだろう?
ビルドを通すためには、おそらく Moauth をコピーして 2 つ用意する必要があるだろう。片方は Azure のために OAuth2 r1 を import し、他方は AWS のために OAuth2 r2 を import する。さて、簡単に StackOverflow を見てみたところ、今回のパッケージ・マネジャーにはこのためのフラグ -fclone
を持っているようだ。このフラグを使えば、Ugo のプログラムは次のようにビルドされる。
こうするとたしかにビルドは通り、テストも通る。Ugo は他に見落としている問題がないか逡巡しつつも、帰宅して遅めの夕食を取ることにした。
第 3 章
Microsoft に話を戻そう。Amy はバカンスから帰ってきて、Azure はしばらく OAuth2 r1 を使い続けることを決めた。また彼女は、直接 Azure API を通して Moauth にトークンを渡せた方が使いやすいだろうと考え、これを後方互換な方法で Azure パッケージに実装し、Azure r3 としてリリースした。Amazon では、Anna がこの Azure パッケージの新しい API を気に入り、似たような API を AWS パッケージに追加し、AWS r4 としてリリースした。
Ugo はこの変更を確認し、この Moauth を使った API を利用するための Azure と AWS 両方を最新版にアップデートすると決めた。この時彼は午後いっぱいの時間を使おうとした。ひとまず彼は、Unity 本体を弄ることなく Azure と AWS だけをアップデートしてみた。ビルドが通った!
嬉しくなった Ugo は、そのまま Unity を Moauth を使った Azure API を利用するコードに書き換えた。それのビルドも通った。しかし、Moauth を使った AWS API を利用するコードに同様に書き換えたところ、ビルドが失敗した。がっかりして、Ugo は Azure の変更点をリバートし、AWS のものだけ残した。ビルド成功。もう一度 Azure の変更をしてみると、やはりビルドが失敗する。Ugo はまた StackOverflow の力を借りることにした。
Moauth を使った API をただひとつ使うだけなら (この場合、Azure だけを使うなら)、-fmultiverse -fclone
のもとで、Unity は暗に下のようにビルドされる。
しかし両方の API を利用したとき、Unity 中のたったひとつの import "moauth"
の意味が曖昧になる。Unity はメイン・パッケージなので、Moauth とは異なり複製できない。
StackOverflow では、Moauth の import を別々の 2 つのパッケージに切り分け、Unity 本体はそれらを import するだけにするのはどうか、というコメントをもらった。Ugo がそうしてみたところ、確かにそれは上手くいった。
Ugo はこれで帰宅できた。彼はこのパッケージ・マネジャーがあまり好きになれなかったが、StackOverflow は大好きになれたようだ。
セマンティック・バージョニングを使ってもう一度
さて、それでは魔法の杖を使い、彼のパッケージ・マネジャーが、元々の「‘r’ + 数字」の代わりにセマンティック・バージョンを使っていたとして、今の物語をもう一度なぞってみよう。
物語はこう変わる。
- OAuth2 r1 は OAuth2 1.0.0 に。
- Moauth r1 は Moauth 1.0.0 に。
- Azure r1 は Azure 1.0.0 に。
- AWS r1 は AWS 1.0.0 に。
- OAuth2 r2 は OAuth2 2.0.0 に。(部分的に非互換な API だ。)
- Azure r2 は Azure 1.0.1 に。(バグを修正。)
- AWS r2 は AWS 1.0.1 に。(バグを修正。内部的にOAuth2 2.0.0 を使う。)
- AWS r3 は AWS 1.1.0 に。(Zeta サポートの機能を追加。)
- Azure r3 は Azure 1.1.0 に。(Moauth API の機能を追加。)
- AWS r4 は AWS 1.2.0 に。(Moauth API の機能を追加。)
これ以外の物語は何も変わらない。Ugo はやはりビルドに関して同じ問題にぶち当たるし、同じように StackOverflow へアクセスし、Unity をビルドするためだけのビルド・フラグやリファクタリング手法を学ばなければいけない。ただし、semver によると、Unity が import しているものにはどこにもメジャー・バージョンの引き上げが無いので、アップデート自体には困らなかったはずだ。唯一 OAuth2 のみが、Unity の依存関係の奥深くで引き上げをした。Unity 自体は OAuth2 を import していない。どこで間違えたのだろう?
ここでの問題は、semver の仕様はバージョン文字列を選び、比較するため以上のものではないということだ。あの仕様はそれ以上のことを何も述べていない。特に、メジャー・バージョンが上がったときの非互換な変更をどのようにすべきかについて、何も述べていないのだ。
Semver の最も有用な部分は、可能なら後方互換であろうとするところだ。FAQ は確かにこう書いている。
「非互換な変更は、それにどっぷり依存しているソフトウェアに対して軽々と行われるべきではない。その変更によって引き起こされるコストは大きい。メジャー・バージョンを上げて非互換な変更をリリースすることは、その変更によって生じる影響についてよく考え、利益損失のバランスを評価したということを意味する。」
私はこの「非互換な変更が軽々と行われるべきではない」というところに非常に同意する。私が semver の至らない所だと思うのは、「メジャー・バージョンを上げ」るときに「その変更によって生じる影響についてよく考え、利益損失のバランスを評価」しなくてはいけないところだ。私は全く反対の立場を取っている。つまり、非互換な変更を行うたびメジャー・バージョンを上げれば、それ以外の全ては上手くいく、ということを意味するように semver を捉える方が、よほど簡単だということだ。今回の例も例外ではない。
Alice にとっては、OAuth2 API には後方非互換な変更が必要であった。そしてそれをしたときバージョン 2.0.0 をつけたことによって、そういった非互換な OAuth2 パッケージをリリースしてもよいだろうということを semver が保証した。しかし semver によって許された変更が、Ugo と Unity が陥ることになる問題の引き金となったのであった。
セマンティック・バージョンは作者がユーザーに挙動を予測させるには重要であるが、それだけである。それだけでは、巨大なビルド問題を解決できないだろう。その代わりとして、そのようなビルド問題を解決できるアプローチについて見てみよう。その後、このアプローチをどうやって semver と共存させるかについて考えることとする。
Import Versioning を使ってもう一度
もう一回物語を語り直してみよう。今回は import compatibility rule を用いる。
もし古いパッケージと新しいパッケージが同一の import path を持っているなら、新しいパッケージは古いパッケージと後方互換でないといけない。
すると筋書きは大きく変わる。物語の始まりは同じだが、第 1 章において Alice が非互換性を含む新しい OAuth2 API を作ろうと決めたとき、彼女は import path として "oauth2"
を使えない。代わりに、それを新しいバージョン Pocoauth と名付け、import path として "pocoauth"
を与えることになる。この 2 つの異なる OAuth2 パッケージによって、Moauth の作者である Moe は、Pocoauth に対応した Moauth という新しいパッケージを書かなければいけない。Anna が AWS パッケージに新しい OAuth2 API を導入したとき、彼女もコード中の import パスを "oauth2"
から "pocoauth"
に変え、"moauth"
を "pocomoauth"
に変えることになる。すると物語は AWS r2 と AWS r3 のリリースまでは以前と同じく進むことになる。
第 2 章において、Ugo が早くも Amazon Zeta を使おうとした際、何もしなくても全てそのまま上手くいく。どのパッケージに書いてある import も、ビルドされるべきものにぴったり合っているからだ。Ugo は StackOverflow で特別なフラグを探す必要もないし、昼食に 5 分遅れることもない。
第 3 章では、Amy が Moauth を使った API を Azure に追加し、一方で Anna は Pocomouth を使った API を AWS に追加する。
Ugo が Azure と AWS 両方をアップデートすると決めたとき、やはりここにも問題が無い。特別なリファクタリング無しで、彼の新しいプログラムはビルドできる。
今回の物語の最後の場面では、Ugo はパッケージ・マネジャーについて思いを馳せることもない。ちゃんと動いた、それだけだ。それが存在していたことにも気づかないだろう。
セマンティック・バージョニングを使って物語を再構成したときと比べて、import versioning を使ったときは重要な差異が 2 つ生まれた。第一に、Alice が後方非互換な OAuth2 API を導入したとき、それを新しいパッケージ (Pocoauth) としてリリースしないといけなかったこと。第二に、Moe がラッパー・パッケージ Moauth を作るには OAuth2 パッケージの API に含まれる型定義に従わなければいけなかったため、Alice が新しいパッケージをリリースしたとなると、Moe も新しいリリース (Pocomoauth) をしないといけなかったことだ。Alice と Moe のパッケージが分裂し、Unity のようなクライアントが必要とする構造をしっかり作ったため、最終的に Ugo が行った Unity のビルドは上手くいったのである。-fmultiverse -fclone
を使った外部のリファクタリングのような複雑性を要求する不完全なパッケージ・マネジャーを Ugo などのユーザーに使わせる代わりに、import compatibility rule はパッケージ作者の仕事の量を減らし、全てのユーザーに利益をもたらす。
後方非互換な API の変更をする度に新しく名前をつけるのには確かにコストがかかるが、semver の FAQ が言っていたように、このコストが作者にそのような変更をすることの影響についてきちんと考えさせ、それが本当に必要なのか考えることになるだろう。特に Import Versioning の場合、このコストがユーザーに大きな利益をもたらすのだ。
ここでの Import Versioning の利点は、パッケージ名と import path が Go の開発者によく理解されていることだ。もしパッケージに非互換な変更が入り、作者に新しい import path で新しいパッケージを作ってくださいとお願いしたとしても、その作者はそうすると何が起こるか想像することができる。つまり、パッケージ自身の import を直す必要があるとか、Moauth は新しいパッケージでは動かないだろうとか、そういうことだ。
ユーザーへの影響をより明確に予測できるようになると、パッケージに変更を加えることに関してより良く決定できるようになるだろう。Alice はパッケージが分裂するのを防ぐため、既存の OAuth2 API を壊すことなく、新しくて綺麗な APIをパッケージに入れられる方法が無いか探すだろう。Moe は新しい Pocomoauth パッケージを避けるために、OAuth2 と Pocoauth 両方をサポートするよう Moauth のインターフェースを使えないかどうか熟慮するだろう。Amy は、Azure API が時代遅れの OAuth2 と Moauth のパッケージを使う代わりに、Pocoauth と Pocomoauth をアップデートする価値があると考えるかもしれない。Anna はAzure のユーザーが簡単に変更へ追従できるよう、AWS API が Moauth と Pocomoauth のどちらにも対応するようできないか考えたかもしれない。
これに対して、semver の「メジャー・バージョン・アップ」で予測できることはそこまで明確でなく、パッケージ作者へ設計に関する同種のプレッシャーを与えることはない。より明確に言うと、このアプローチは作者にいくらか苦労を強いるが、その苦労はユーザーに確かな利益を与えることによって正当化される。一般にこのバランスには意味がある。なぜならパッケージは作者よりももっとたくさんのユーザーに向けて作られているものであるし、幸いなことに全てのパッケージにはユーザーが少なくとも作者と同じ数だけいるからである。
Semantic Import Versioning
前節では、アップデートの際 import versioning がいかにビルドを単純かつ予測可能にするかについて見た。しかし、後方非互換な変更が入る度に新しい名前を選ぶのは難しいし、ユーザーに優しくない。Oauth2 にするか Pocoauth にするか選べるなら、Amy が使うべきなのはどちらだろう? いくらか調べない限り、それを知る手立ては無い。対照的に、セマンティック・バージョニングはこれを簡単にする。OAuth2 2.0.0 は明らかに OAuth2 1.0.0 を置き換えるものとして作られたものだ。
我々はセマンティック・バージョニングを使い、更に、メジャー・バージョンを import path へ含めることにより import compatibility rule も守る。Pocoauth のようなキュートだが元と関係無い新しい名前を発明する代わりに、Alice は彼女の新しい API を OAuth2 2.0.0 とし、新しい import path "oauth2/v2"
を作れば良い。Moe についても同様で、Moauth 1.0.0 が OAuth2 1.0.0 のヘルパーだったのと同じく、Moauth 2.0.0 ("moauth/v2"
として import する) を OAuth2 2.0.0 のためのヘルパーとすれば良い。
第 2 章で Ugo が Zeta のサポートを追加するときのビルドは下のようになる。
"moauth"
と "moauth/v2"
は単に違うパッケージなのだから、Azure で "moauth"
を、AWS で "moauth/v2"
を使うために Ugo がすべきことはとても明らかだ。どちらも import すれば良い。
現行の Go の使われ方への互換性と、API の後方非互換な変更はしないほうが良いだろうという思いから、メジャー・バージョン 1 は import path から省略されることを私は仮定している。"moauth/v1"
ではなく "moauth"
だ。互換性を気にしないことを示すメジャー・バージョン 0 も、同様に import path から省略されている。これは、v0 のパッケージに依存する場合、ユーザーは破壊的変更がありうることについてきちんと知っておくべきだし、アップデートする度にそれに対応する義務があるという意見に基づいている。(もちろんこの場合、アップデートが自動的に起こらないことも重要だ。次回の投稿では minimal version selection がどのようにこれを助けるのかについて見る。)
ファンクショナルな名前と、イミュータブルな意味
20 年前、Rob Pike と私で Plan 9 C ライブラリの内部を弄ったとき、関数の挙動を変えたときに関数の名前も変えるという経験則について Rob が教えてくれた。古い名前はひとつの意味を持つ。新しい名前に異なる意味を持たせ、古いものを削除することで、我々が確認・更新すべき全ての箇所をコンパイラが出力することを保証できる。間違ったコードが何もエラーを出さずコンパイルされることは無い。また、もし誰かがその関数を使ったプログラムを書いていたとしたら、彼らもコンパイル時エラーを見ることになり、長いデバグをする必要が無くなる。現在のように分散バージョン・コントロールが流行っている時代において最後の問題は大きく、名前を変更することがより重要になっている。古い意味の関数を使っているかもしれないコードを同時に書いていてそれらをマージする際、新しい意味が何の出力もなく導入されてはいけない、ということだ。
もちろん、古い関数を消すのは、Plan 9 のような研究用システムのように、それが利用されている箇所が全て分かるかユーザーが変更についてくる保証があるときだけである。公開した API においては、古い名前と古い挙動はそのまま残しておき、新しい名前と新しい挙動を追加するだけにするのが普通である。Rich Hickey は 2016 年の講演 “Spec-ulation” で、新しい名前と挙動を追加するだけにして、古い名前を削除したり新しく意味を再定義したりはしない方針は、まさに関数型プログラミングにおける個々の変数やデータ構造のようだ、と話した。この関数型アプローチは小さいスケールのプログラミングに明確性と予測可能性をもたらすし、より大きな規模の場合においても、import compatibility rule のごとく、API 全体に良い影響をもたらす。依存性の闇はミュータビリティーの闇を拡大するのだ。これは先の講演のほんの一部であるから、是非講演全体を聞いて欲しい。
“go get
” の初期では、後方非互換な変更をするときどうすれば良いという質問に対して、その種の変更に関する数年かの経験則に基づき、import versioning rule を作ればよいと答えていたが、なぜこの方法が import path にメジャー・バージョンを書く方法より良いのかについて上手い説明はできなかった。Go 1.2 では FAQ にパッケージのバージョン付けについて下のように書かれた (これは Go 1.10 でも変わっていない)。
誰かに使ってもらうためのパッケージは、後方互換性を守るべきだ。Go 1 の互換性ガイドラインが参考になるだろう: export された名前を削除しない、タグ付きの composite literal を使う、など。もし新しい機能が必要なら、古いものを変えるかわりに新しい名前を付けよう。もし完全に非互換とする必要があるなら、新しい import path で新しいパッケージを作ろう。
この記事を投稿したひとつの動機は、明解かつ信頼に値するような例を通して今回のルールがとても重要であることを示すことにある。
シングルトン問題の回避
Semantic import versioning という方針へのよくある批判のひとつは、昨今のパッケージ作者は、あるビルドにおいて彼らのパッケージはたったひとつしか使われていないと思いがちであろう、ということだ。異なるメジャー・バージョンで複数のパッケージがあることを許してしまうと、シングルトンだと思っていたものが複数あることによる問題が生じるかもしれない。たとえば HTTP ハンドラの登録を考えてみよう。もし my/thing
が /debug/my/thing
のための HTTP ハンドラを登録するとして、パッケージが 2 つあると複数登録されてしまい、登録時にエラーを引き起こすだろう。別の例として、プログラムの中で 2 つの HTTP スタックがある場合を考えてみよう。明らかにひとつの HTTP スタックしか 80 番ポートを listen できないので、どちらか片方のプログラムの使われないハンドラを登録したくない。Go 開発者の間では既に、ベンダリングされたパッケージの中でベンダリングするとき同じ問題が起きている。
ただし、vgo
へ移行し semantic import versioning を使うことで、今の状況は明確化され、単純化される。ベンダリングの中でベンダリングして闇雲にコピーを作る代わりに、パッケージのメジャー・バージョンごとのコピーは唯一であるという保証が得られる。Import path にメジャー・バージョンを含むことにより、作者にとって my/thing
と my/thing/v2
が違うものであり、共存できるようにする必要があることが分かりやすくなるだろう。もしかしたらこれは v2 のためのデバッグ情報を /debug/my/thing/v2
に出すべきだ、ということかもしれないし、適当に調整すべき、ということかもしれない。あるいは v2 がハンドラ登録の主導権を握りつつ、v1 向けにページを表示するための情報を与えるフックも提供されるということかもしれない。こうなると my/thing
が my/thing/v2
を異なる import path で import したり、その逆をしたりするだろう。これは簡単にできるし、簡単に理解できる。対照的に、もし v1 と v2 両方が同じ my/thing
だとすると、片方が他方を import するようなことは理解しづらい。
API の自動アップデート
巨大なプログラムにおいてパッケージの v1 と v2 が共存することを許したい大きな理由のひとつは、そのパッケージのクライアントが一気にアップグレードしてもきちんとビルドできるようにするためだ。これは gradual code repair というより一般的な問題の具体化である。(この問題についてより詳しくは、2016 年の私の記事 “Codebase Refactoring (with help from Go)” 参照。)
ビルドし続けるようにするだけでなく、semantic import versioning には gradual code repair に対して前節で触れたような重要なメリットがある。パッケージのひとつのメジャー・バージョンが他のを import した上で書けるということだ。あの v2 API が v1 の実装のラッパーとして書けることは自明だし、その逆もそうだ。こうすることでコードを共有できるし、適切に設計を決めたり、時には type alias を使ったりして、v1 と v2 を相互に使うようなクライアントも許すことができるだろう。これは同時に、API を自動アップデートを作る際の技術的難点も解決するだろう。
Go 1 以前では、新しい Go にアップデートした後に実行し、コンパイルできなくなったプログラムを見つけるコマンドである go fix
を多用していた。コンパイルできなくなったコードでアップデートを行うと、プログラム解析ツールの殆どが使えなくなるのだ。これはそれらが正当なプログラムしか受け付けないように作られていたためだ。また、どうやって Go の標準ライブラリ以外のパッケージの作者に API アップデートについての「修正」を提供してもらえるか、という問題もあった。これに対し、ひとつのプログラムの中で複数の非互換なバージョンに名前を付け扱うことができるとなると、ある解決策が浮かび上がった。もし v1 API が v2 API のラッパーとして実装できるのであれば、そのラッパー実装は仕様の修正を倍にする。たとえば、API の v1 が EnableFoo
と DisableFoo
という関数を持っており、v2 がそれらをひとつの SetFoo(enabled bool)
にしたとしよう。API v2 のリリース後、v1 は v2 のラッパーとして実装できる。
package p // v1
import v2 "p/v2"
func EnableFoo() {
//go:fix
v2.SetFoo(true)
}
func DisableFoo() {
//go:fix
v2.SetFoo(false)
}
この特別な //go:fix
というコメントは、対応するラッパー本体が呼び出し箇所にインラインで書き換えられるべきだということを go fix
に示すためのものだ。こうした上で go fix
を走らせると、v1 の EnableFoo
を呼び出している箇所が v2 の SetFoo(true)
へ書き換えられる。この書き換えは単なる Go のコードなので、書き換え箇所の特定も型検査も簡単に行える。より良いことに、この書き換えは safe だ。EnableFoo
は 既に v2 の SetFoo(true)
を呼んでいるので、呼び出されるものを書き換えるだけならばプログラムの意味を変えない。
SetFoo
を使った v1 を EnableFoo
と DisableFoo
を使った v2 へと逆向きに API を変更するため、go fix
に
symbolic execution をさせても良いかもしれない。ここで v1 の SetFoo
実装は大体こんなものだろう。
package q // v1
import v2 "q/v2"
func SetFoo(enabled bool) {
if enabled {
//go:fix
v2.EnableFoo()
} else {
//go:fix
v2.DisableFoo()
}
}
すると go fix
は SetFoo(true)
を EnableFoo()
にアップデートし、SetFoo(false)
を DisableFoo()
にアップデートする。ひとつのメジャー・バージョン内のアップデートにもこの種の修正をすることができるだろう。たとえば、v1 で SetFoo
を deprecated にして (残しつつ) EnableFoo
と DisableFoo
を導入することができる。Deprecated な API を使わないようにするのにも同様のことができるだろう。
きちんと言っておくとこれは現状実装されていないが、有望ではあり、違うものには違う名前を付ければこの種のツールを作ることができるようになる。今回の例は特定のコードの動作へ永続的かつイミュータブルな名前をつけるメリットを示している。何か変更をしたら、その名前も変えようというルールに従うだけで良い。
互換性へのコミット
Semantic import versioning はパッケージの作者にこそオススメだ。作者たちは、ただ v2 を出し、v1 から離れ、Ugo のようなユーザーを放っておくようなことはできない。そのようなことをするとユーザーに手間をかけてしまう。そこで、システム側からユーザーに手間がかかりにくくし、ユーザーに手間がかからないような挙動へ自然に作者が誘導されるようにするのは良いことだと私は思う。
より一般的には、GopherCon 2017 で Sam Boyer が、ソフトウェアを作る人々が協力するという意味での社会的交流をパッケージ・マネジャーがどのようにモデレートするか、ということについて話した。我々は決断している。互換性、自然な乗り換え、そして共に協調していくようなシステムのコミュニティでやっていきたいだろうか? それとも、非互換性を作って説明し、作者がユーザーのプログラムを壊すことを許容するようなシステムのコミュニティでやっていきたいだろうか? Import versioning、特にセマンティックなメジャー・バージョンを import path に持ち込みセマンティック・バージョニングを扱うことで、前者のようなコミュニティにおいてやっていくことを表明している。
互換性にコミットしよう。
訳註
元記事には更に追加で、同じリポジトリに別のメジャー・バージョンを扱う別のブランチを持つことに関しての Russ のコメントが載っています。
翻訳ミス、誤字等の指摘は、編集リクエストやこのページへのコメントで行って頂けると幸いです。
記事の内容そのものに対する指摘は、Russ による元の記事へのコメントや、Go subreddit への投稿、golang-nuts への投稿をお願いします。意見が Russ に届くことが重要なので、日本語では意味が薄そうです。
ライセンス表示: この記事は、Russ Cox によって書かれたものを @nekketsuuu が翻訳・公開したものです。CC BY 4.0 ライセンスの元で公開します。