未来を形作るテクノロジーの深掘り記事。

IRCv3:後方互換性を保つプロトコル設計の教訓

1988年のIRCを壊さずに進化させたIRCv3。ケーパビリティ交渉、メッセージタグ、後方互換なプロトコル進化から学ぶ設計の教訓を解説。

机の上で、光るケーブルで最新のスマートフォンにつながれたビンテージの電信機
IRCv3のトリック:1988年のワイヤーフォーマットはそのまま動き、新しいクライアントには現代的な体験が届きます。

1988年、フィンランドの大学生が数ページに収まるチャットプロトコルを書きました。それから約40年近く経った今も、そのプロトコル(IRC)は毎日数百万件のメッセージを運び続けています。そしてIRCv3ワーキンググループは、既存の利用者を切り捨てることなくプロトコルを進化させる方法を、静かに私たちに教えてくれています。他人が依存しているAPIのバージョニングで悩んだことがあるなら、IRCv3はぜひ真似すべきケーススタディです。

数年にわたって、断続的にIRC向けのボットや簡易クライアントを作ってきました。そして何度も驚かされるのは、両端が新しい拡張に対応しているとき、体験がいかに現代的に感じられるかということです。サーバーによる履歴の再送、本物の認証、ブイナー(bouncer)越しでも生き残るメッセージのタイムスタンプ。それなのに、1995年に書かれたクライアントも同じサーバーに接続して普通にチャットできるのです。この組み合わせは偶然生まれたものではありません。意図的な設計判断と、コミュニティが迂回してきたいくつかの痛い失敗の積み重ねの結果です。

最初の罪:問い合わせられないケーパビリティ

RFC 1459、その後のRFC 2812に定められたIRCのコアプロトコルには、クライアントがサーバーに対応機能を尋ねる仕組みがありませんでした。まったくないのです。接続してNICKとUSERを送ったら、あとは推測するしかありません。特定のモードや拡張をサーバーがサポートしているか知りたければ、送ってみてエラーが返るかどうかを確かめるしかないのです。仕様の曖昧な部分をサーバー実装ごとに少しずつ違う解釈で扱う、5種類のサーバーに対して個別に分岐を書かなければならないクライアントライブラリを保守したことがあれば、これがどれほど厄介か分かるはずです。

この失敗モードは理論上の話ではありません。開発者たちは、コマンドを投げてエラー番号(numeric)を解析することで機能を探るクライアントを書いていました。サーバーがエラー文字列を一つ変えただけで壊れる、脆い代物です。ボットは誤った前提でチャンネルに参加し、失敗を大量に垂れ流しました。これはまさに仕様と実装のギャップの典型例です。仕様が発見(discovery)の仕組みを提供しなければ、実装者はそれぞれ勝手に、しかも下手な方法を発明してしまうのです。

IRCv3の解決策はケーパビリティ交渉(capability negotiation)で、その単純さは少し気恥ずかしくなるほどです。接続登録の際、クライアントはCAP LSを送り、サーバーは対応しているケーパビリティ(名前付きの機能)の一覧を返します。sasl、server-time、message-tagsといったものです。クライアントは欲しい部分集合だけをCAP REQで要求し、サーバーが承認すると、両者はそのセッションが何をサポートするかについて、明示的に共有された理解を持った状態で先に進みます。CAPを一度も送らないレガシーなクライアントは、従来の振る舞いをそのまま受け取ります。CAPを理解しないレガシーなサーバーは、クライアントが安全に無視してフォールバックできるエラーを返します。

-> CAP LS 302
<- CAP * LS :sasl=PLAIN,EXTERNAL server-time message-tags echo-message
-> CAP REQ :sasl server-time
<- CAP * ACK :sasl server-time
-> AUTHENTICATE PLAIN
<- AUTHENTICATE +
-> AUTHENTICATE dXNlcgB1c2VyAHBhc3N3b3Jk
<- 900 jane jane!jane@host jane :You are now logged in

この教訓はIRCの枠を超えて通用します。今日どんなプロトコルやAPIを設計するにしても、交渉の仕組みはバージョン1に組み込んでおきましょう。たとえまだ交渉するものが何もなくても。コストはメッセージ一往復分だけです。見返りとして、将来のあらゆる機能がオプトインになり、破壊的変更にならずに済みます。

教訓:拡張性は新コマンドに勝る

IRCv3の第二の大きなアイデアはメッセージタグで、これは実際の失敗から生まれました。人々は新しいコマンドを定義したり、既存のメッセージパラメータに無理やり意味を詰め込んだりして機能を追加し続けていたのです。IRCのワイヤーフォーマットは悪名高いほど簡潔で、プレフィックス、コマンド、末尾パラメータのリストだけからなります。メタデータを載せる場所がなかったのです。そのため、再送された履歴にタイムスタンプを付けたい、あるいはどのアカウントがメッセージを送ったかを示したい場合、昔ながらの答えは新しい数値を発明するか、末尾のテキストを崩すことでした。どちらのやり方も古いクライアントを壊すか、すべてのパーサーに特殊ケースを増やさせるかのどちらかです。

解決策は、メッセージの先頭に新しい任意のセクションを追加することでした。プレフィックスの前に@key=value;key2=value2の形で置きます。重要なのは、サーバーがmessage-tagsケーパビリティを交渉したクライアントにだけタグを送る点です。したがって古いクライアントは、解析できないバイトを一つも目にしません。タイムスタンプ付きメッセージが実際にワイヤー上でどう見えるかは次のとおりです:

@time=2024-03-14T09:26:00.000Z;account=jane :jane!jane@host PRIVMSG #rust :borrow checker finally clicked for me

これはHTTPヘッダー、gRPCのメタデータ、protobufの未知フィールドへの寛容さと同じアーキテクチャ上の発想です。汎用的な拡張ポイントを確保しておけば、機能追加のためにプロトコルの根幹を手術する必要がなくなります。server-time拡張は、再送されたメッセージがすべて今届いたかのように見えるという長年のブイナー問題を解決しますが、実体はたった一つのタグです。クライアントが特定メッセージへの返信をスレッド化できるようにする+replyドラフトも、また別の一つのタグです。新しいコマンドも、新しい数値もなし。プロトコル移行の「フラッグデー」も不要です。

私自身、正反対の失敗をやらかしたことがあります。何年も前、社内のpub/subシステムで、「エンベロープ形式を変えるのは大変すぎる」という理由で、JSONフィールドにマジックプレフィックスを被せて機能を追加したのです。8か月後には、そのマジックを展開する正規表現を3つの言語で保守する羽目になり、あるチームのパーサーがマジック文字列をそのままリテラルデータとして扱ってしまい、忘れがたいインシデントが起きました。単純で明示的なメタデータフィールドなら1日の作業で済んだはずです。マジック文字列は私たちに四半期分のコストをかけさせました。IRCv3のメッセージタグこそ、あのシステムが最初から持つべきだったものです。

ベルトコンベア上の封筒。いくつかには小さくカラフルなメタデータのシールが貼られている
メッセージタグ:クライアントが要求したときだけ見える、メッセージごとに付与される任意のメタデータ。

教訓:平文のパスワードという回避策をやめる

2000年代にIRCクライアントを使っていた人に、ログインはどうしていたかと聞けば、同じ答えが返ってくるでしょう。/msg NickServ IDENTIFY passwordと打つのです。認証情報は普通のチャットメッセージとして送られ、ログに残り、クライアントがコマンドを間違えると公開チャンネルに貼り付けられることもありました。あまりに一般的だったので、ちょっとした通過儀礼としてネタにまでなったほどです。プロトコルに認証という概念がなかったため、認証はアプリケーション層に後付けされていたのです。

IRCv3はこれを正面から直しました。登録時のSASL認証を標準化したのです。接続後にボットとチャットして認証するのではなく、クライアントはsaslケーパビリティを交渉し、誰にも見えるニックネームを持つ前に、ハンドシェイクの一部として認証します。サーバーは最初のメッセージからあなたが誰かを把握しており、それが他のあらゆることにつながります。account-tag拡張は、すべてのメッセージにアカウント名を付与できます。ボットはNickServをパースするための独自のヒューリスティクスなしに権限判断ができます。クライアントは、NOTICEの壁のようなテキストを読ませることなく登録フローを構築できます。

より広い教訓はこうです。回避策が普遍的になると、それはもはや回避策ではなく、事実上のプロトコルになります。しかも設計の配慮は何もないまま。NickServはどのRFCにも載っていませんでしたが、二十年にわたるクライアントの振る舞い、その失敗モードまで含めて形作りました。人々がすでにやっていたこと(接続時の認証)を、本物の仕組みで標準化したことで(SASLにはPLAIN、クライアント証明書用のEXTERNALなどがあり、さらなる拡張の余地もあります)、言い伝えは基盤になりました。ユーザー全員が何かを成し遂げるために同じぎこちない手順を踏んでいるなら、それはサポートのFAQではなく、次に書くべき仕様項目です。

教訓:履歴はクライアントの問題ではなくプロトコルの問題

IRCの歴史の大半において、プロトコルは厳密にライブでした。送信時にクライアントが接続していなければ、そのメッセージは自分にとって存在しなかったことになります。コミュニティの答えはブイナーでした。VPS上に常時接続したプロキシ(代表格はZNC)を置き、セッションを保持して、取り逃したものを再生するのです。ブイナーは機能しましたが、それ自体がまた一つ運用すべきインフラであり、管理すべき認証情報でもありました。そして再生体験は最悪でした。すべてが再生時刻で刻印され、ライブで見たものと順序がずれていたのです。

IRCv3はこれを多層的に攻めました。まずserver-timeが、実際の受信タイムスタンプを運ぶことで再送履歴を使えるものにしました。次にchathistoryドラフトが、履歴の範囲を要求する標準的な方法をクライアントに与え、Ergoのようなサーバーがチャンネル履歴を直接保存するようになりました。結果は、友人のために小さなErgoサーバーを立ち上げた多くの人が発見したように、驚くべきものです。一週間オフラインだった後にスマートフォンから接続すると、サーバーは正しいタイムスタンプとともにすべてを再生します。ブイナーも、VPSの面倒も不要です。IRCがこれまで得た中で最大の使い勝手の向上であり、それはクライアントの賢いハックからではなく、プロトコルそのものから来たものです。

真剣に受け止めるべき反論があり、日常的にIRCを使う人々はそれを口にします。IRCの見かけ上の不格好さの大半はクライアント側の問題で、良いクライアントとブイナーを使えば今でも快適に使える、というものです。確かにそうです。しかしその議論は、逆方向から同じ結論を証明しています。クライアント開発者たちはそれぞれ独立に、スクロールバックの結合、ハイライト追跡、再生の重複排除を再実装し、微妙に異なるバグを抱え込みました。難しい部分をプロトコルとサーバーに移せば、正しく一度だけ実装され、すべてのクライアントが恩恵を受けます。Web開発でも同じ判断を絶えず下しています。ブラウザがネイティブのfetchを持つようになったとき、あるいはTLSが当たり前になったとき、どれだけのアプリコードが消えたかを考えてみてください。プラットフォームに不変条件を押し込む方が、何千ものコードベースが中途半端に解決し続けるよりずっと良いのです。

教訓:任意機能は食い違う現実を生む

さて、IRCv3がまだ完全には解決できていない失敗、そして拡張可能なシステムを設計する人に最も関係の深い失敗について話します。すべての拡張が任意で、個別に交渉されるため、同じチャンネルにいる二人のユーザーが、文字通り別々の会話を見ている可能性があるのです。片方のクライアントの発言は+replyタグを持ち、スレッド化された返信として表示されます。もう片方は同じメッセージを、前後の文脈のないフラットな行として表示します。あるユーザーには絵文字リアクションがきれいなアイコンとしてメッセージに付いて見え、別のユーザーには何も見えないか、意味の分からないフォールバック表示が出ます。IRCv3のクライアント対応表は、○と×が並んだグリッドであり、どの列にいるかで体験が決まります。

これはグレースフルデグラデーションの暗い側面です。後方互換性によって何も壊れないことは保証されましたが、一貫性が保証されるわけではありません。SlackやDiscordには、他に何と言われようとこの問題がありません。プラットフォームが両端を十分に強く統制しているため、リアクションは全員にとってリアクションなのです。IRCv3は制御よりも進化を選びました。そのコストは、恒常的にむらのある機能の風景です。ドラフト仕様が何年もドラフトのままになることさえあります。

拡張可能なものを作っているなら、初日からこのコストを見込んでおきましょう。IRCv3や類似のエコシステムが採用した緩和策には、次のようなものがあります:

  • 縮退の仕方を意図的に設計する。返信タグは消えるのではなく、読める引用行にフォールバックすべきです。フォールバックが使い物にならないなら、その機能はオプションとして出荷できる段階にありません。
  • 対応表を公開し、正直に保つ。IRCv3のクライアントとサーバーの表は、ユーザーが納得して選択できるようにします。情報がないと、批判は見当違いのソフトウェアに向かいます。
  • ベースラインを定義して引き上げる。番号付きリリース(3.1、3.2)はケーパビリティをまとめるので、クライアントは個別の11個のフラグではなく「3.2対応」を目標にできます。エコシステムが収束すべき床を与えましょう。
  • ドラフトには期限を設ける。サーバーに出荷されたのに確定しないドラフトは(チャンネル履歴はこの運命に何度か近づきました)、何もコミットしないまま挙動を分岐させるので、ドラフトがないより悪いのです。

共同編集がなぜ思っているより難しいのかという記事でも、似た話がありました。参加者が共有状態の形について意見を違えうる瞬間に、善意だけでなく明示的な収束ルールが必要になるのです。機能をデフォルトでオプションにするのは、ケーパビリティに対する良い戦略です。しかし、ユーザーから見える意味論には、対応表の反対側に取り残された人々のための計画が必要です。

同じストリームを映した二つの様式化されたウィンドウ。一方はスレッドや記号で賑やかで、もう一方は素朴
任意の拡張のせいで、同じチャンネルを見ている二人のユーザーが別々の会話を見ることがあります。

次のAPIやプロトコルで考えるべきこと

チャットへの懐かしさを取り除けば、IRCv3はワイヤーフォーマットを持つあらゆるものを進化させるためのコンパクトな指南書です。プロトコル、イベントスキーマ、さらには公開REST APIを始めるチームに、私が渡すチェックリストは次のとおりです:

  1. まず交渉を出荷する。ケーパビリティの一覧が空でも、バージョン1でケーパビリティ交換を入れておきましょう。後から発見の仕組みを追加するのは、十倍困難です。
  2. 拡張ポイントを確保する。タグ、ヘッダー、メタデータマップなど、将来の機能がコア文法に触れずに掛けられる汎用スロットを一つ用意しましょう。
  3. 新しい振る舞いは接続ごとにオプトインにする。古いクライアントには、古い振る舞いがバイト単位で同一に届かなければなりません。IRCv3のサーバーは、要求していないクライアントにタグを送ることは決してありません。
  4. 言い伝えを標準化する。ユーザーが収束させてきた回避策(NickServ式の認証、ブイナー式の再生など)は、きちんと書かれるのを待っている仕様です。
  5. 対応表に備える。任意機能は食い違います。縮退時の体験がどう見えるかを決め、誰が何に対応しているかを公開しましょう。
  6. 進捗は名前付きのマイルストーンにまとめる。個別のフラグは実装者のためのもので、番号付きリリースは人間のためのものです。

私が本当に印象深いと感じるのは、その自制心です。IRCv3はエンドツーエンド暗号化も、フェデレーションの再設計も、大がかりな書き直しも追いかけませんでした。発見、メタデータ、認証、時刻という小さく鋭い問題を選び、1995年のクライアントが無視できる形で解決したのです。だからこそIRCは、同世代のインターネットプロトコルの中で、ほぼ設計どおりに動き続けている唯一の生き残りであり、今日の週末プロジェクトが、友人のグループに5ドルのVPSで履歴同期、TLS、本物のアカウントを与えられるのです。

後方互換性とは、変化がないことではありません。すべての変化を、過去が安全に無視できるものにするという規律のことです。

次にAPIのバージョニングを、パスを/v1から/v2に上げて、すべての利用者に移行を強いる形で行いたくなったら、ダイヤルアップ時代のクライアントと最新のモバイルアプリが同じチャンネルを共有できたプロトコルを思い出してください。ケーパビリティを交渉しましょう。新しいコマンドを発明する代わりにメタデータを付けましょう。床を堅固に保ち、天井は独自のペースで上がらせましょう。IRCv3は、先人を尊重しつつ新しい技を学び続けるプロトコルを作れることを証明しています。1988年のチャットシステムにそれができたのなら、三年前に作ったマイクロサービスに言い訳の余地はありません。