Golangにおける Hyrum's Law の適用事例
(abenezer.org)- Go コードベースの
net/httpには、MaxBytesError.Error()が返す エラー文字列"http: request body too large"を Hyrum's Law のため変更できないというコメントがある - Hyrum's Law とは、API の利用者が十分に多くなると、公式な契約に含まれていない観測可能な挙動にまで誰かが依存するようになる、という原則
- エラーメッセージのように些細に見える文字列でも、外部コードがその正確な文言に合わせて動作していれば、変更した瞬間に既存コードが壊れる可能性がある
- Go 内部では
crypto/rsaやinternal/weakにも同様のコメントがあり、乱数ストリームの挙動や未確定のセマンティクスが固定化されるリスクを扱っている - Go に限った問題ではないため、公開 API やライブラリは 意図しない挙動 が事実上の標準として固まってしまわないよう設計すべき
Go コードで確認された Hyrum's Law
net/http/request.goのMaxBytesError.Error()は次の文字列を返す"http: request body too large"- 該当コメントには "Due to Hyrum's law, this text cannot be changed." と書かれている
- Hyrum's Law は Hyrum Wright の名にちなんだ原則で、hyrumslaw.com の定義は次のとおり
- API 利用者が十分に多ければ、契約で何を約束しているかに関係なく、システムのあらゆる観測可能な挙動に誰かが依存するようになる
MaxBytesErrorの事例の核心は、エラーメッセージの正確な文言が外部コードで使われうる点にある- 小さな文言変更でも既存コードを壊しうる
http: request body too largeの検索結果から、この文字列を使っている Go のオープンソースコードが確認できる
他の Go パッケージと外部コードベースの事例
-
crypto/rsaの乱数ストリーム依存性crypto/rsa/rsa.goのEncryptOAEPには Hyrum's Law に関するコメントがある- この関数は乱数ストリームに対する決定的な実行を約束していないが、
MaybeReadByteを適用していないため、誰かが現在の挙動に依存している可能性がある crypto/rsa/pss.goのSignPSSも同じ文脈のコメントを含む- どちらの場合も、よく定義された数の 乱数バイト が暗号文や署名に、よく定義された方法で含まれるため、受け入れ可能な約束として扱われている
-
internal/weakのセマンティクス固定化リスクinternal/weakでは、go:linknameによってこのパッケージや参照関数へアクセスすることをツールチェーンが明示的に禁止していると記されている- このパッケージのセマンティクスは提案プロセスを経ておらず、機能を公開すると Hyrum's Law によって既存のセマンティクスが固定化されるおそれがある
-
Go の外でも繰り返されるパターン
- Hyrum's Law への言及は Go に限られない
- grep.app の 多言語検索結果 では、複数言語の事例を見ることができる
- Python の
urllib.parseや Pixar OpenUSD のarray.hも関連するコードベースの例 - JavaScript の進化も、多くの奇妙で意図しない挙動への広範な依存が事実上の標準になった事例につながっている
変更前に確認すべきこと
- コード変更時には、文書化された API だけでなく、外部コードが依存しうる 観測可能な挙動 まで考慮する必要がある
- 最初から意図しない挙動への依存可能性を減らすシステム設計が必要
1件のコメント
Hacker News の意見
Hyrum の法則は有用な観察ではあるが、それに固執して誤った結論を出すべきではない
関数の総実行時間も観察可能な性質なので、関数をより速く最適化することさえ破壊的変更と見なせる。突然キューが速く空きすぎてデッドロックが起きるかもしれないからだ。それでもユーザーの 99.99999999% は、何の努力もなしにコードが速くなることを喜ぶはずだ
結局、何が破壊的変更なのかは、技術的な契約ではなく社会的な契約であるしかない。そうでなければ文字どおり何も変えられなくなる。ライブラリ作者は API のうち変わらない部分を文書化し、合理的に振る舞い、ユーザーに共感すべきであり、ライブラリ利用者は文書化されていないインターフェースを重要な依存関係にするのは自己責任だと理解し、作者にも共感すべきだ
ただし別の観点から見ると、Hyrum の法則は技術的な契約でも社会的な契約でもなく、十分に広く使われるシステムに現れる創発的な技術的性質だ
その性質にどう対応するかは社会的文脈に依存する。FOSS のメンテナーなら、最適化で 99.99% が速くなり、0.01% だけがコードを直すか新しい API に移ればよい場合はリリースする。大手テック企業なら、最適化もしなければならず、社内で 0% も壊してはいけないため、複数チームと協力して妥協点を探る。エンタープライズソフトウェア企業なら、0.1% だけが壊れるとしても、そのユーザーが上位 5 件の契約の一つならリリースしない
元の作者が複数の非同期関数を呼び出しておき、以前の遅いルーチンが終わるころにはそれらの関数もすべて終わっているはずだと仮定していたためだ。正確に何が起きているのかを突き止めるのに非常に時間がかかった
そのため PC には速度を落とすターボボタンが付いていたし、8ビットコンピューターはより速い CPU があったにもかかわらず、10年間ずっと速度を上げなかった。最近はほとんどすべてが複数の CPU 上で動くため、十分に速いかどうかを除けば、関数の実行時間に依存することはほとんどない。組み込みでも、単一 CPU が製造終了になる事態を経験してから、そうした依存を避けようとしている
内部 API で HTTP Status 418 を重要な依存関係にした理由と、与えられた制約の下でなぜそれが最もましな選択だったのか、という話だ
実行環境やその時点のシステム負荷、GC の実行などがすべて影響し得る
要約すると、機械から生じる創発的な振る舞いを、意図されたインターフェースや何らかの契約とは見なさない。したがって、誰かが意図しない動作に依存していたとしても、微妙なバグを修正することが破壊的変更と見なされないのと同様に、これも破壊的変更とは見なさない
この場合は何よりも、Go が後方互換性に非常に強くコミットしていることの証拠に近いように見える
はは、
crypto/rsaのコメントを書いたのは私です。Go では Hyrum の法則と後方互換性 https://go.dev/doc/go1compat を本当に真剣に扱っています。たとえば複数の
GenerateKey関数では、アルゴリズムが固定されないようにMaybeReadBytehttps://pkg.go.dev/crypto/internal/randutil#MaybeReadByte でランダムストリームからバイトを 1 つ余分に読みます。つい昨日も、nil の公開鍵が入った private ECDSA key が以前は動作していたのに今は動かないという報告が来て、おそらくまた動くようにしなければならなさそうです https://go.dev/issue/70468マップのイテレーションは、内部実装が露出しないようランダムな順序を使います。
rand.Randの出力は互換性の約束の一部と見なされるため、改善するにはかなり大きな努力が必要でした https://go.dev/blog/randv2 https://go.dev/blog/chacha8randドキュメントにどのような約束を書くか、どの挙動は「変わる可能性がある」と明記するかを常に議論しています。ドキュメント化されたものは絶対に変えられず、「変わる可能性がある」と明記していないものも、おそらく変えるのが難しいと分かっているからです https://go-review.googlesource.com/c/go/+/598336/comment/5d6...
それでも価値のあるトレードオフだと思います。Go をかなり使っていて強い後方互換性も好んでいますが、Go 開発者が性能を改善し機能を追加する自由が大きくなるなら、破壊的変更の割合が少し高くなることも喜んで受け入れられます。
他のエコシステムのユーザーが耐えている地獄、たとえば Python のような場合を見ると、こう考えるのは私だけではないと思います。
MaybeReadByteを複数のGenerateKey関数で使っていると言いましたが、ed25519 ではそうしていないようです。ed25519.NewKeyFromSeed()ができる前は、private key から public Ed25519 key を導出する唯一の方法で、そこに依存したコードを書いたことがほぼ間違いなくあります。あまり気に入ってはいませんでしたが、それしかできることがなかったので覚えやすいです。ただし、
ed25519.GenerateKeyのドキュメントが出力は決定的だと明記しているのは良いことです。Go の暗号 API で固定化された挙動を調査して維持し、新たな固定化を防ぐ仕事を本当によくやってきたように思います。悪名高い A20 line (https://en.wikipedia.org/wiki/A20_line) のように、この壊れた挙動を永遠に引きずることになります。
具体的に言及されている問題の解決策は、文字列ベースのエラーではなく センチネルエラーを使うことです https://thomas-guettler.de/go/wrapping-and-sentinel-errors
より一般的には、API の利用者が非技術的な文字列に少しでも依存したくなるようなコードを作るべきではありません。事前定義されたエラー値、型、あるいは非技術的な文字列を含む定数のような、言語の第一級の構成要素を使えば、API の利用者は文字列を直接ハードコードする代わりに、戻り値を定数と比較できます。
Hyrum の法則は確かに存在しますが、その影響を減らすことはできます。
リンク先の検索で最上位の原因に見える Grafana は、文字列比較ではなく
errors.As(&http.MaxBytesError{})を使うべきでした。Hyrum の法則の核心は、API をどれだけうまく設計しても関係ないということです。人々は契約ではなく挙動に依存するようになります。
依然として
err.String() == "no more tea available."を確認するコードを書くことはできます。そうすべきでないことには同意しますが、そうできないように防ぐものはありません。さらに
errors.Isは Go には比較的最近追加されたものなので、人々がこのような方法でエラーを確認していた時点では、リテラル文字列を確認するほうが簡単でした。Go では API 提供者が、利用者が.String()の戻り値を確認することを防げません。特に標準ライブラリでは、ほとんど言い訳の余地がありません。
プログラミング言語の歴史を意図的に無視したうえで「作りながら設計しよう」というアプローチを取ると、こうなります。
Hyrum の法則に立ち向かう方法も興味深いテーマです
1つの可能性は、人々に依存してほしくない部分にランダム性を入れることです
記憶が正しければ、QUIC プロトコルはそうしています。現行バージョンでは使われていないフィールドがありますが、ルーターがそのフィールドでパケットを識別し始めないよう、仕様では null バイトではなくランダムな値に設定することを要求しています
出典はたぶんここです: https://www.rfc-editor.org/rfc/rfc9000#section-17.2.1
「Unused フィールドの値はサーバーが任意の値に設定する。クライアントはこのフィールド値を必ず無視しなければならない。[...] QUIC の他のバージョンが同様の推奨をしない可能性があることに注意せよ」
こういうものを greasing と呼び、ossification を防ぐためのものだと理解しています
https://www.rfc-editor.org/rfc/rfc8701.html
この RFC の最も初期のドラフトは 2016 年半ばにさかのぼり、おそらくこの用語が公に初めて登場した時点である可能性が高いです: https://datatracker.ietf.org/doc/html/draft-davidben-tls-gre...
10 年後に目が覚めて、そのビットが本当に必要になったのに、10 ブランドのルーター 20 種類がそのビットは必ず特定の形でなければならないと決めつけている状況ほどひどいものはありません
反対側にチェックサムや暗号化があって、ビットがいじられると壊れるなら加点です。ミドルボックスの「賢いハック」は本当に厄介です
これは stringly typed なソフトウェアの良い例です
Go の設計者は例外を望みませんでしたが、
panic/recoverによって依然として似たものがあり、型のないエラーは有害です。逆に、パターンマッチングなしで型付きエラーをどう扱えるのでしょうか。ほとんどの言語のcatchは初歩的なパターンマッチングだからですhttps://learn.microsoft.com/en-us/dotnet/csharp/language-ref...
以前勤めていた職場で、エラーメッセージのタイプミスを見つけて直したところ、その誤字を含むテキストに依存するつながりがあまりに深く、実質的に修正できないことが分かり、結局タイプミスのあるテキストに戻さなければなりませんでした
今でも気になります
https://en.wikipedia.org/wiki/HTTP_referer
これは一種の Hyrum の法則ですが、実質的には単に Go らしい Go です
エラーが enum 型だったなら、利用者は文字列置換だけで変更できたはずです。代わりに文字列を型のように使っているため、利用者がどのように依存しているか分からなくなります。エラー文字列のうち 6 文字だけを確認していて、変更すると壊れるかもしれません
何十年も前から他の言語でより良い代替が使われていたにもかかわらず、またひとつのひどく時代錯誤な設計判断です。初期の失敗と変更不能性が組み合わさると、永遠に縛られることになります
この法則は堅牢性原則、つまり Postel の法則と正反対である点が興味深いです
「送るときは保守的に、受け取るときは寛容に」
入力を寛容に受け付けるなら、どのように寛容だったのかを理解し、少なくとも内部的には文書化すべきです。Hyrum の法則のため、大規模なコードベース変更後も、そのすべての方法を永遠にサポートし続けることになります
まさにその理由で、「受け取るものに寛容な」API は作りたくありません
API で受け取るデータの基準を緩くすると、結局そのデータをどの正規形式に整えるかを決めなければならなくなります。そしてその決定は、ほとんど常に何らかの形でユーザーにとって驚く挙動につながるように思います
パッケージ作者ごとに、この問題の受け止め方は違うようです。数日前、
jsonパッケージでこんなコメントを見ましたisValidNumberはsが有効な JSON 数値リテラルかどうかを報告するisValidNumberは内部実装の詳細であるべきだが、広く使われているパッケージがlinknameでアクセスしているhall of shame の代表的な構成員として
github.com/bytedance/sonicが含まれるAPI を公開しながら学んだことです
クライアントは、公開者が意図した方法でなくても、自分の仕事を終わらせるために必要なことは何でもします。クライアントはドキュメントを読みません。十分に多くのクライアントがある挙動に依存すると、バグも API の一部になります。API 呼び出し数が重要度と必ずしも一致するわけではありません
だから API を開発するときは、できるだけ早くベータ API を出し、どのように使われるかを見ながら驚きを減らすようにしています。ほとんどの場合、以前のバージョンをサポートしながらメジャーバージョンを上げます。そのためには API の SLA を定義する必要があります