IRCv3:向后兼容的协议设计经验
IRCv3 在不破坏旧版本的前提下让 1988 年的协议焕发新生,聊聊能力协商、消息标签与向后兼容的协议演进之道。

1988 年,一位芬兰大学生写了一个能装进几页纸的聊天协议。近四十年后,这个协议——IRC——依然每天承载着数百万条消息,而 IRCv3 工作组则在悄悄地告诉我们其他人:如何演进一个协议,而不把已有的生态整个搞崩。如果你曾经需要为一堆陌生人依赖的 API 做版本管理,IRCv3 就是值得你照搬的案例。
这些年我时不时地基于 IRC 写些机器人和小玩具客户端,一直让我惊讶的是:当两端都支持较新的扩展时,体验可以相当现代。服务端回放历史消息、真正的身份认证、经过 bouncer 之后依然保留的消息时间戳。而与此同时,1995 年写的客户端依然能连上同一个服务器正常聊天。这种兼容性并非偶然,而是一系列刻意的设计决策,外加社区为绕开一些痛苦错误而付出的努力。
原罪:无法询问的能力
RFC 1459 以及后来的 RFC 2812 所规定的 IRC 核心协议,并没有提供客户端询问服务器支持什么的机制。一点都没有。你连上之后发送 NICK 和 USER,剩下的全靠猜。想知道服务器是否支持某个模式或扩展?那就发过去,看看会不会报错。这听起来有点过时,但如果你维护过一个客户端库,要为五种不同的服务器实现做特殊分支处理,而每种实现对规范中含糊的部分理解又略有不同,你就会明白这有多痛苦。
这种失败模式并非纸上谈兵。开发者通过发送命令并解析错误数字来探测功能,这种做法十分脆弱,服务器只要改一下错误字符串就会崩掉。机器人带着错误的假设加入频道,然后刷屏式地报错。这正是规范与实现之间的鸿沟的典型场景:规范没有给出发现机制,每个实现者就只能各自发明一套,而且做得很糟糕。
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,哪怕还没有什么需要协商的功能,也应该在第一版就把协商机制建起来。成本只是一次消息交换,回报是未来每一项新功能都变成可选启用,而不是破坏性变更。
经验:可扩展性胜过新增命令
IRCv3 的第二个重要理念是 消息标签(message tags),它源于一个真实的错误:人们总是通过定义新命令,或者把语义硬塞进已有消息参数里来添加功能。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 扩展解决了 bouncer 的老大难问题——回放的消息都看起来像是刚刚到达的——它本质上就是一个标签。+reply 草案允许客户端把回复串联到具体消息上,这是另一个标签。没有新命令,没有新数字码,也没有“版本切换日”。
我自己也犯过相反的错误。几年前在一个内部发布/订阅系统里,因为“修改信封格式工作量太大”,我们就用一个带魔法前缀的 JSON 字段来加功能。八个月后,我们不得不在三种语言里维护一段用来拆解魔法前缀的正则,而且有一个团队的解析器把魔法字符串当成了字面数据,结果引发了一次让人记忆犹新的事故。一个简单、明确的元数据字段只需要花一天,而那个魔法字符串让我们付出了好几个季度的代价。IRCv3 的消息标签,正是那个系统一开始就该有的东西。

经验:消灭明文密码的变通方案
问问任何在 2000 年代用过 IRC 客户端的人,登录是怎么做的,你会得到同一个答案:输入 /msg NickServ IDENTIFY 密码。你的凭据会作为普通聊天消息发送出去,会被记录进日志,有时候客户端一出错,还会被误贴到公共频道里——这种经历太常见了,甚至成了一个梗。认证之所以是在应用层临时拼凑的,是因为协议本身根本没有这个概念。
IRCv3 通过标准化 注册阶段的 SASL 认证 把这个问题妥善解决了。客户端不再在连接后与机器人聊天,而是协商 sasl 能力,并在握手过程中完成认证,此时它还没有任何人能看到的昵称。服务器从第一条消息起就知道你是谁,这一点会连锁影响很多方面:account-tag 扩展可以把你的账号名附加到每条消息上,机器人不必再用自己的 NickServ 解析启发式规则来做权限判断,客户端也能设计不需要阅读一大段 NOTICE 文字的注册流程。
更广泛的经验是:当一种变通方案变得普遍,它就不再是变通方案,而会成为你事实上的协议,却没有任何设计上的审慎。NickServ 从来不在任何 RFC 中,但它塑造了二十年的客户端行为,包括它的各种故障模式。把人们已经在做的事情(连接时认证)用一个真正的机制(SASL,它包括 PLAIN、用于客户端证书的 EXTERNAL,并为更多方式留出了空间)标准化之后,民间经验就变成了基础设施。如果你的用户都在做同样别扭的动作才能完成某件事,那就应该把它列为下一个规范条目,而不是写进支持 FAQ。
经验:历史是协议问题,而不是客户端问题
在 IRC 的大部分历史里,协议是严格实时的:如果你的客户端在消息发出时没有连接,那条消息对你来说就根本不存在。社区的答案是 bouncer——一个永久在线的代理(ZNC 是经典代表),运行在一台 VPS 上,保持你的会话,并回放你错过的内容。Bouncer 确实能用,但它又是一套需要运维的基础设施,又多了一份需要管理的凭据,而且回放体验非常糟糕:所有消息都被打上回放时间的戳,和你实时看到的内容顺序对不上。
IRCv3 分层解决了这个问题。首先,server-time 通过携带真实的接收时间戳,让回放的历史变得可用。接着,chathistory 草案为客户端提供了一种标准方式来请求某个时间段的历史,而 Ergo 等服务器也开始直接存储频道历史。结果正如不少人在为朋友搭建小型 Ergo 服务器时发现的那样,令人惊讶:一周没上线后用手机连上,服务器会带着正确的时间戳回放所有内容,既不需要 bouncer,也不需要照看 VPS。这是 IRC 有史以来最大的体验提升,而且它来自协议本身,而不是某个聪明的客户端黑科技。
有一种值得认真对待的反驳观点,日常 IRC 用户经常提出:IRC 给人的那种粗糙感大多来自客户端,只要用一个不错的客户端加一个 bouncer,今天也能获得很好的体验。这没错,但这恰恰从另一个方向证明了我们的观点。每个客户端作者都独立重新实现了滚动历史拼接、高亮追踪和回放去重,而且各自的 bug 略有不同。把困难的部分下沉到协议和服务器中,就意味着它只需实现一次,而且是正确的,所有客户端都能受益。我们在 Web 开发中也经常做同样的选择:把不变量推入平台(想想浏览器原生支持 fetch 之后,多少应用代码消失了,或者 TLS 成为基本要求之后发生了什么),而不是让上千个代码库各自半吊子地解决。
经验:可选功能会造成分裂的现实
现在来谈谈 IRCv3 尚未完全解决的那个问题——这也是任何设计可扩展系统的人最需要关注的。由于每个扩展都是可选的,并且各自独立协商,同一频道里的两个用户看到的对话可能完全不同。一个客户端发出的消息带着 +reply 标签,渲染成串联的回复;另一个客户端则把这些消息显示成扁平、没有上下文的单行文字。一个用户看到表情回应被整齐地挂在消息上,另一个用户则什么也看不到,或者只看到一个神秘的回退文本。IRCv3 的客户端支持矩阵就是一张满是“支持”与“不支持”的表格,你的体验取决于你处在哪一列。
这就是优雅降级的阴暗面。向后兼容保证了不会有任何东西坏掉,但模型本身并不保证一致性。Slack 和 Discord 不管你怎么评价它们,从来没有这个问题:平台对两端的控制足够紧密,以至于一个回应对所有人来说都是同一个回应。IRCv3 选择了演进而非控制,代价就是功能版图永久不均衡,而且草案规范有时一放就是好几年,始终停留在草案阶段。
如果你在构建可扩展的东西,从第一天起就应该把这一点纳入预算。IRCv3 以及类似生态所采取的一些缓解措施包括:
- 刻意设计降级行为。回复标签应该回退为可读的引用行,而不是直接消失。如果回退方案不可用,那么这个功能就还不适合作为可选功能发布。
- 公开支持矩阵,并保持诚实。IRCv3 的客户端与服务器支持表让用户能做出明智的选择;信息不透明,只会让责备落到错误的软件头上。
- 定义基线并推动采用。编号版本(3.1、3.2)把能力打包在一起,客户端就可以瞄准“3.2 支持”,而不必逐一检查十一个独立标志。给你的生态一个可以共同收敛的底线。
- 让草案有期限。一个已经在服务器中发布却迟迟无法定稿的草案(频道历史就曾差点落到这个下场)比没有草案更糟,因为它在分叉行为的同时却没有做出承诺。
我们在为什么协同编辑比你想象的更难一文中也见过类似的问题:一旦参与者可以对共享状态的形态产生分歧,你就需要明确的收敛规则,而不只是良好的意图。把可选作为能力的默认策略是不错的,但用户可见的语义则需要为那些站在矩阵另一侧的人制定方案。

这对你的下一个 API 或协议意味着什么
抛开对聊天的怀旧情绪,IRCv3 其实是一本精炼的手册,教你如何演进任何带有线路格式的东西。下面是我会交给任何准备开发协议、事件 schema,甚至公开 REST API 的团队的清单:
- 先交付协商机制。第一版就要有能力交换,即使能力列表是空的。事后补上发现机制要难十倍。
- 预留扩展点。标签、头部、元数据映射——留一个通用插槽,让未来的功能能挂在上面,而不必改动核心语法。
- 让新行为按连接选择启用。老客户端必须收到逐字节相同的旧行为。IRCv3 服务器从不会把标签发给没有请求它的客户端。
- 把民间经验标准化。无论你的用户收敛到了什么变通方案(类似 NickServ 的认证、类似 bouncer 的回放),都是一份等待被正式撰写的规范。
- 为支持矩阵做规划。可选功能会走向分化;决定降级体验应该是什么样,并公开谁支持什么。
- 把进展打包成命名的里程碑。单个标志是给实现者看的,编号版本才是给人看的。
我真正觉得了不起的是它的克制。IRCv3 没有去追求端到端加密、联邦架构重新设计,或者任何宏大的重写。它挑选了那些小而尖锐的问题——发现、元数据、认证、时间——并以让 1995 年的客户端可以直接忽略的方式解决它们。这就是为什么 IRC 基本上是同一代互联网协议中唯一大体上仍按原设计运行的幸存者,也是为什么今天一个周末的小项目,就能在一台 5 美元的 VPS 上,为一群朋友提供历史同步、TLS 和真正的账号。
向后兼容并不是没有变化,而是一种纪律:让每一次变化都成为过去可以安全忽略的东西。
下次当你想通过把路径从 /v1 改成 /v2、迫使每个消费者迁移来给 API 做版本管理时,请记住那个让拨号时代的客户端和现代移动应用能共享同一个频道的协议。协商能力,附加元数据,而不是发明新命令。守住底线,让上限按自己的节奏提升。IRCv3 证明了一个协议完全可以尊重前辈,同时继续学习新把戏——如果一个 1988 年的聊天系统都能做到,你那个三年前的微服务就没有任何借口了。


