2 ポイント 投稿者 GN⁺ 2024-11-23 | 1件のコメント | WhatsAppで共有
  • Go コードベースの net/http には、MaxBytesError.Error() が返す エラー文字列 "http: request body too large" を Hyrum's Law のため変更できないというコメントがある
  • Hyrum's Law とは、API の利用者が十分に多くなると、公式な契約に含まれていない観測可能な挙動にまで誰かが依存するようになる、という原則
  • エラーメッセージのように些細に見える文字列でも、外部コードがその正確な文言に合わせて動作していれば、変更した瞬間に既存コードが壊れる可能性がある
  • Go 内部では crypto/rsainternal/weak にも同様のコメントがあり、乱数ストリームの挙動や未確定のセマンティクスが固定化されるリスクを扱っている
  • Go に限った問題ではないため、公開 API やライブラリは 意図しない挙動 が事実上の標準として固まってしまわないよう設計すべき

Go コードで確認された Hyrum's Law

  • net/http/request.goMaxBytesError.Error() は次の文字列を返す
    • "http: request body too large"
    • 該当コメントには "Due to Hyrum's law, this text cannot be changed." と書かれている
  • Hyrum's LawHyrum Wright の名にちなんだ原則で、hyrumslaw.com の定義は次のとおり
    • API 利用者が十分に多ければ、契約で何を約束しているかに関係なく、システムのあらゆる観測可能な挙動に誰かが依存するようになる
  • MaxBytesError の事例の核心は、エラーメッセージの正確な文言が外部コードで使われうる点にある
    • 小さな文言変更でも既存コードを壊しうる
    • http: request body too large の検索結果から、この文字列を使っている Go のオープンソースコードが確認できる

他の Go パッケージと外部コードベースの事例

  • crypto/rsa の乱数ストリーム依存性

    • crypto/rsa/rsa.goEncryptOAEP には Hyrum's Law に関するコメントがある
    • この関数は乱数ストリームに対する決定的な実行を約束していないが、MaybeReadByte を適用していないため、誰かが現在の挙動に依存している可能性がある
    • crypto/rsa/pss.goSignPSS も同じ文脈のコメントを含む
    • どちらの場合も、よく定義された数の 乱数バイト が暗号文や署名に、よく定義された方法で含まれるため、受け入れ可能な約束として扱われている
  • 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件のコメント

 
GN⁺ 2024-11-23
Hacker News の意見
  • Hyrum の法則は有用な観察ではあるが、それに固執して誤った結論を出すべきではない
    関数の総実行時間も観察可能な性質なので、関数をより速く最適化することさえ破壊的変更と見なせる。突然キューが速く空きすぎてデッドロックが起きるかもしれないからだ。それでもユーザーの 99.99999999% は、何の努力もなしにコードが速くなることを喜ぶはずだ
    結局、何が破壊的変更なのかは、技術的な契約ではなく社会的な契約であるしかない。そうでなければ文字どおり何も変えられなくなる。ライブラリ作者は API のうち変わらない部分を文書化し、合理的に振る舞い、ユーザーに共感すべきであり、ライブラリ利用者は文書化されていないインターフェースを重要な依存関係にするのは自己責任だと理解し、作者にも共感すべきだ

    • オープンソースライブラリの作者については、上の話はすべて正しいと思う
      ただし別の観点から見ると、Hyrum の法則は技術的な契約でも社会的な契約でもなく、十分に広く使われるシステムに現れる創発的な技術的性質
      その性質にどう対応するかは社会的文脈に依存する。FOSS のメンテナーなら、最適化で 99.99% が速くなり、0.01% だけがコードを直すか新しい API に移ればよい場合はリリースする。大手テック企業なら、最適化もしなければならず、社内で 0% も壊してはいけないため、複数チームと協力して妥協点を探る。エンタープライズソフトウェア企業なら、0.1% だけが壊れるとしても、そのユーザーが上位 5 件の契約の一つならリリースしない
    • 以前、非常に非効率なルーチンを約 100秒から 0.1秒 ほどに短縮したことがあるが、そのせいでレポートシステムが壊れた
      元の作者が複数の非同期関数を呼び出しておき、以前の遅いルーチンが終わるころにはそれらの関数もすべて終わっているはずだと仮定していたためだ。正確に何が起きているのかを突き止めるのに非常に時間がかかった
    • 1980年代には実際にそうした問題があった
      そのため PC には速度を落とすターボボタンが付いていたし、8ビットコンピューターはより速い CPU があったにもかかわらず、10年間ずっと速度を上げなかった。最近はほとんどすべてが複数の CPU 上で動くため、十分に速いかどうかを除けば、関数の実行時間に依存することはほとんどない。組み込みでも、単一 CPU が製造終了になる事態を経験してから、そうした依存を避けようとしている
    • いつか load bearing teapot についてライトニングトークをしてみたい
      内部 API で HTTP Status 418 を重要な依存関係にした理由と、与えられた制約の下でなぜそれが最もましな選択だったのか、という話だ
    • 関数の総実行時間のようなものは関数作者の制御下にないため、この論理はほとんど不条理に近く見える
      実行環境やその時点のシステム負荷、GC の実行などがすべて影響し得る
      要約すると、機械から生じる創発的な振る舞いを、意図されたインターフェースや何らかの契約とは見なさない。したがって、誰かが意図しない動作に依存していたとしても、微妙なバグを修正することが破壊的変更と見なされないのと同様に、これも破壊的変更とは見なさない
      この場合は何よりも、Go が後方互換性に非常に強くコミットしていることの証拠に近いように見える
  • はは、crypto/rsa のコメントを書いたのは私です。Go では Hyrum の法則と後方互換性 https://go.dev/doc/go1compat を本当に真剣に扱っています。
    たとえば複数の GenerateKey 関数では、アルゴリズムが固定されないように MaybeReadByte https://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 で固定化された挙動を調査して維持し、新たな固定化を防ぐ仕事を本当によくやってきたように思います。
    • nil key の事例は、こういうケースまでサポートするのがどれほど正気なのか疑問に思わせます。
      悪名高い A20 line (https://en.wikipedia.org/wiki/A20_line) のように、この壊れた挙動を永遠に引きずることになります。
    • 皮肉なことに、以前 Go で ロードバランサ を書いたとき、ランダムなマップのイテレーション順序に依存していました。
    • Go で最も過小評価されている部分の 1 つです。12 年前に書いたコードが、今でもそのまま動きます。
  • 具体的に言及されている問題の解決策は、文字列ベースのエラーではなく センチネルエラーを使うことです 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() の戻り値を確認することを防げません。
    • 数年前までは、文字列エラー比較がこれを行う唯一の方法であり、Go には後方互換性の約束があります。
    • 生のエラー文字列を確認するコードは単に悪いコードであり、Go の後方互換性保証から除外すべきです。
      特に標準ライブラリでは、ほとんど言い訳の余地がありません。
    • Go の初期設計が問題です。長い間 文字列ベースのエラーが唯一の方法で、記憶が正しければ一部の標準ライブラリパッケージにもまだ残っていますし、エコシステム全体については言うまでもありません。
      プログラミング言語の歴史を意図的に無視したうえで「作りながら設計しよう」というアプローチを取ると、こうなります。
  • Hyrum の法則に立ち向かう方法も興味深いテーマです
    1つの可能性は、人々に依存してほしくない部分にランダム性を入れることです
    記憶が正しければ、QUIC プロトコルはそうしています。現行バージョンでは使われていないフィールドがありますが、ルーターがそのフィールドでパケットを識別し始めないよう、仕様では null バイトではなくランダムな値に設定することを要求しています
    出典はたぶんここです: https://www.rfc-editor.org/rfc/rfc9000#section-17.2.1
    「Unused フィールドの値はサーバーが任意の値に設定する。クライアントはこのフィールド値を必ず無視しなければならない。[...] QUIC の他のバージョンが同様の推奨をしない可能性があることに注意せよ」
    こういうものを greasing と呼び、ossification を防ぐためのものだと理解しています

    • GREASE は RFC 8701 で作られた略語で、「Generate Random Extensions And Sustain Extensibility」を意味し、当初は TLS の文脈で使われました
      https://www.rfc-editor.org/rfc/rfc8701.html
      この RFC の最も初期のドラフトは 2016 年半ばにさかのぼり、おそらくこの用語が公に初めて登場した時点である可能性が高いです: https://datatracker.ietf.org/doc/html/draft-davidben-tls-gre...
    • 素晴らしいです。QUIC にはかなり慣れていますが、この内容は知りませんでした
      10 年後に目が覚めて、そのビットが本当に必要になったのに、10 ブランドのルーター 20 種類がそのビットは必ず特定の形でなければならないと決めつけている状況ほどひどいものはありません
      反対側にチェックサムや暗号化があって、ビットがいじられると壊れるなら加点です。ミドルボックスの「賢いハック」は本当に厄介です
  • これは stringly typed なソフトウェアの良い例です
    Go の設計者は例外を望みませんでしたが、panic/recover によって依然として似たものがあり、型のないエラーは有害です。逆に、パターンマッチングなしで型付きエラーをどう扱えるのでしょうか。ほとんどの言語の catch は初歩的なパターンマッチングだからです
    https://learn.microsoft.com/en-us/dotnet/csharp/language-ref...

    • Go には型付きエラーがあります。この場合に使っていなかっただけです
  • 以前勤めていた職場で、エラーメッセージのタイプミスを見つけて直したところ、その誤字を含むテキストに依存するつながりがあまりに深く、実質的に修正できないことが分かり、結局タイプミスのあるテキストに戻さなければなりませんでした
    今でも気になります

  • これは一種の Hyrum の法則ですが、実質的には単に Go らしい Go です
    エラーが enum 型だったなら、利用者は文字列置換だけで変更できたはずです。代わりに文字列を型のように使っているため、利用者がどのように依存しているか分からなくなります。エラー文字列のうち 6 文字だけを確認していて、変更すると壊れるかもしれません
    何十年も前から他の言語でより良い代替が使われていたにもかかわらず、またひとつのひどく時代錯誤な設計判断です。初期の失敗と変更不能性が組み合わさると、永遠に縛られることになります

    • 残念ながら、そのコメントは本質的に間違っています。多くの場合、文字列そのものが公式 API でした
  • この法則は堅牢性原則、つまり Postel の法則と正反対である点が興味深いです
    「送るときは保守的に、受け取るときは寛容に」
    入力を寛容に受け付けるなら、どのように寛容だったのかを理解し、少なくとも内部的には文書化すべきです。Hyrum の法則のため、大規模なコードベース変更後も、そのすべての方法を永遠にサポートし続けることになります
    まさにその理由で、「受け取るものに寛容な」API は作りたくありません

    • 私もそちらの方が好みです
      API で受け取るデータの基準を緩くすると、結局そのデータをどの正規形式に整えるかを決めなければならなくなります。そしてその決定は、ほとんど常に何らかの形でユーザーにとって驚く挙動につながるように思います
  • パッケージ作者ごとに、この問題の受け止め方は違うようです。数日前、json パッケージでこんなコメントを見ました
    isValidNumbers が有効な JSON 数値リテラルかどうかを報告する
    isValidNumber は内部実装の詳細であるべきだが、広く使われているパッケージが linkname でアクセスしている
    hall of shame の代表的な構成員として github.com/bytedance/sonic が含まれる

  • API を公開しながら学んだことです
    クライアントは、公開者が意図した方法でなくても、自分の仕事を終わらせるために必要なことは何でもします。クライアントはドキュメントを読みません。十分に多くのクライアントがある挙動に依存すると、バグも API の一部になります。API 呼び出し数が重要度と必ずしも一致するわけではありません
    だから API を開発するときは、できるだけ早くベータ API を出し、どのように使われるかを見ながら驚きを減らすようにしています。ほとんどの場合、以前のバージョンをサポートしながらメジャーバージョンを上げます。そのためには API の SLA を定義する必要があります