本章导读
项目三是TCP 聊天室(chatd):多房间实时聊天——用户连接、进房发言、房间广播、私聊、离线消息序号补齐。设计上它站在 configd(第 139-140 章:长期状态与并发交接)与 pushd(第 144 章:扇出与终态)之间——连接状态 × 房间状态 × 消息序序三者交织的最纯样本。本章沿用第 139 章的决策五要素模板(问题/选项/决策/理由/验证)——设计方法论的复用先于代码复用。六个设计域:房间模型(房间与成员的关系形态);线协议(TCP 字节流上的消息定界——第 72/91 章的选型重演);广播一致性(序号与补齐——pushd 序号协议的前传);连接状态机(握手→进房→聊天→退房的生命周期);存储边界(在线内存与离线历史的分界);容量与降级(慢成员与房满的策略矩阵)。
141.1引入
聊天是"看起来简单"的经典陷阱:不就是"收到消息转发给同房间的人"?真实系统的每个"简单"背后都是决策:A 在 B 进房前一秒发言——B 该看到吗(加入时机语义:历史可见性边界);A 发言到 B 收到之间 C 退房了——C 的会话怎么收尾(会话终态);同一秒两人发言——房间看到什么顺序(定序权:谁决定顺序);B 断线重连——错过的消息怎么办(补齐协议:序号游标)。每个问题答错都会在产品层变成"消息丢了""顺序乱了""幽灵会话"——而它们全部是设计期问题不是编码期问题。本章把每个问题变成显式决策——实现章(第 142 章)逐条兑现。
141.2概念
需求与验收标准
用户故事:UC1 连接与握手(TCP 连上→握手帧带昵称→服务端确认/拒绝);UC2 进房/退房(join/leave 帧——进房后收到历史尾巴);UC3 发言广播(say 帧→房间全体收到带序号的广播帧);UC4 私聊(dm 帧→仅目标用户收到);UC5 重连补齐(重连带最后序号→服务端补发缺失帧);UC6 在线列表(who 帧→房间成员列表)。验收:① 六 UC 正反用例;② 广播定序:并发发言的全房一致顺序(同房所有成员看到的序号严格单调);③ 补齐正确:断线期间的帧重连后全达且不重;④ 会话终态:退房/断开后无幽灵帧(收尾帧恰达一次);⑤ 慢成员不拖房(隔离);⑥ 房满与昵称冲突的明确拒绝路径;⑦ OOM 与逐点失败零残留;⑧ 100 并发×10 房间压测稳定。
决策一:房间模型
问题:房间与成员、成员与连接是什么关系?选项:A 房间=连接集合(直接管连接);B 房间=用户集合、用户独立于连接(重连换连接不换身份);C 无房间实体(消息按路由表分发)。决策:B。理由:重连语义(UC5 要求断线重连保留身份与序号游标——身份不能绑在连接上);私聊路由(UC4 按用户不按连接);离线历史(UC5 的补齐对象是用户不是连接)。A 被否决:连接断=身份丢——重连变新用户(补齐无从谈起)。C 被否决:房间是产品概念(历史/成员列表都是房间属性)——无实体的路由表装不下。验证:重连测试(UC5)只在 B 下可过。数据形态:Map<房间名, 房间>;房间含 Map<用户名, 用户> + 消息环(历史尾巴)+ 序号计数器;用户含当前连接指针(可空=离线)+ 游标。
决策二:线协议
问题:TCP 字节流上怎么定界消息?选项:A 裸文本行(\n 分隔);B 长度前缀二进制;C JSON 行(文本行+JSON 载荷)。决策:C。理由:人可读(教学与排障——telnet 能直接对话);结构化(帧类型/字段用 JSON——第 32 章解析一步到位);扩展(新字段零协议改动)。A 被否决:字段要自己转义(昵称带空格就炸);B 被否决:排障要 hexdump。定界的实现:第 72 章行分帧器(\n 定界)+ 第 32 章 JSON 解析——两层都是现成正题。验证:协议矩阵测试(各帧型正反例)。这个决策也是 pushd 的镜像(那边选了 JSON over WS 帧——这边 JSON over 行——载荷结构一致、定界层随传输——分层让协议设计变成单层选择)。
决策三:广播定序与一致性
问题:并发发言的顺序谁定?选项:A 发送方定序(带本地序号);B 服务端单点定序(房间计数器);C 向量时钟(分布式定序)。决策:B。理由:房间是单进程内的全序域(一个房间一个定序点)——简单且足够;C 为分布式准备(多实例才有意义——超出本设计)。机制:发言帧到达→房间锁内取号(计数器++)→广播帧带号→按号投递。一致性语义:同房所有成员看到的序号严格单调且无洞(洞=丢失——触发补齐);成员的游标记录已见最大号。补齐协议(UC5):重连帧带游标→服务端从游标+1 重发历史环内的帧→环外(太老)的回"too far behind"(客户端全量刷新)。验证:并发压测的序号审计(每客户端校验单调无洞——验收 ②③)。
决策四:连接状态机
终态保证:连接的 Close 恰好一次(第 67 章);用户身份的生命周期长于连接(离线计数/超时清理——决策一的推论)。幽灵帧的防线:退房帧在广播队列排空后发送给本人("你已退出"是最后一帧)——之后的广播不再触达(成员先移除后广播的顺序)。
决策五:存储边界
问题:消息存哪、存多久?选项:A 全内存(环缓冲);B 全落盘;C 内存热+落盘冷。决策:A(内存环)+ 边界声明。理由:教学形态的容量假设(每房 1024 帧环——约 100 KB/房);B/C 是演进点不是本设计(落盘=第 45 章追加写+启动重放——扩展路径留好)。环的语义:新帧覆盖最老;补齐只能补到环内(环外= too far behind)。验证:环回绕测试(发超环长——最老被覆盖、序号仍单调)。
决策六:容量与降级
问题:慢成员、房满、服务器过载怎么办?决策矩阵:慢成员(发送 AGAIN 持续超阈值)→ 踢出+告知(不是无界等——pushd 三档的聊天版:聊天语义不能丢帧——丢帧破坏序号无洞——所以跳过不可用、只能踢);房满(成员上限)→ 拒绝进房帧(明确原因);过载(总连接上限)→ 拒绝握手。每类拒绝都有协议帧(客户端可理解可重试)——拒绝路径是一等公民。验证:慢成员注入测试(隔离+踢出语义——验收 ⑤)。
与 pushd(第 144 章)的分工对照
chatd 与 pushd 形似(房间/广播/序号)但约束不同——对照表防混淆:传输(裸 TCP vs WebSocket——chatd 的握手/定界自己搭、pushd 白拿);身份(用户独立于连接 vs 连接即身份——chatd 的重连补齐逼出用户实体);丢帧容忍(不可丢——序号无洞 vs 可丢可 resync——pushd 的跳过档在 chatd 不可用)。结论:chatd 教"协议从字节搭起"、pushd 教"复用高层件"——同业务在两个传输层重演——设计视角的分层敏感度训练。
141.3示例
第一个完整程序:线协议的定界原子
下面的程序来自 examples/io/line——线协议定界层(\n 行帧)的可运行原子:
*/ #include <stdio.h> #include <xrt.h> int main(void) { /* 混合行尾 + 末行无换行的日志样本。 */ static const char sLog[] = "INFO server started\r\n" "WARN queue is busy\n" "ERROR request failed"; xreader* pReader; xlinereader* pLines; xlineview Line; /* 借用:到下一次 Next 前有效 */ xlinenext Next; size_t iCount = 0u; int iResult = 1; */ pReader = xrtReaderFromMemory(XRT_BYTES_LITERAL(sLog)); pLines = xrtLineReaderTake(&pReader, 1024u); if ( pLines == NULL ) { xrtReaderDestroy(pReader); return 1; } /* 逐行消费:每行是借用视图,处理完再取下一行。 */ while ( (Next = xrtLineReaderNext(pLines, &Line)) == XLINE_NEXT_LINE ) { printf("%zu: %.*s\n", ++iCount, (int)Line.Text.Size, Line.Text.Data); } if ( Next == XLINE_NEXT_END ) { iResult = 0; } /* 接管模式:一个 Destroy 连底层 Reader 一起关闭。 */ if ( !xrtLineReaderDestroy(pLines) ) { iResult = 1; } return iResult; }
刚才发生了什么。 ① 行定界的三态循环就是 chatd 的收帧引擎:LINE 到→JSON 解析→分派;END→连接收尾;ERROR→结构化断开。② 行尾自适应(三种行尾全识别)让 telnet/nc 各种客户端都能连——宽容入口(协议的最低层宽容、上层 JSON 严格——各层各司其职)。③ 这个原子与第 140 章引用的是同一个——两个项目共用同一地基(分层复用的直接证据)。
第二个完整程序:帧载荷的结构化
第二个程序来自 examples/data/json——帧载荷层(JSON)的原子:
*/ #include <stdio.h> #include <xrt.h> */ static xjsonvisitaction printJsonEvent( const xjsonevent* pEvent, ptr pUserData ) { (void)pUserData; if ( pEvent->HasName ) { printf("member: %.*s\n", (int)pEvent->Name.Size, pEvent->Name.Data); } return XJSON_VISIT_NEXT; } int main(void) { xjsonreadconfig ReadConfig; xjsonwriteconfig WriteConfig; xjsonwriter* pWriter; xvalue* pRoot; xvalue* pName; xstrview Name; str sText; size_t iSize; /* ---- 1) DOM:解析 → 随机访问 → 美化序列化 ---- */ pRoot = xrtJsonParse(XRT_STR_LITERAL( "{\"name\":\"xrt\",\"features\":[\"json\",\"http\"]}" )); if ( pRoot == NULL ) { return 1; } /* ObjectGet 返回借用指针(树拥有它);GetString 取出视图。 */ pName = xrtValueObjectGet(pRoot, XRT_STR_LITERAL("name")); if ( !xrtValueGetString(pName, &Name) ) { xrtValueRelease(pRoot); return 2; } printf("name = %.*s\n", (int)Name.Size, Name.Data); /* Stringify 产物是拥有式字符串(xrtFree);之后树即可释放。 */ sText = xrtJsonStringify(pRoot, true, &iSize); xrtValueRelease(pRoot); if ( sText == NULL ) { return 3; } printf("%.*s\n", (int)iSize, sText); xrtFree(sText); /* ---- 2) SAX:同一份输入按事件流走一遍,不建树 ---- */ xrtJsonReadConfigInit(&ReadConfig); if ( xrtJsonVisit( XRT_STR_LITERAL("{\"code\":200,\"ok\":true}"), &ReadConfig, printJsonEvent, NULL ) != XJSON_VISIT_DONE ) { return 4; } /* ---- 3) Writer:增量构建 {"code":200,"message":"OK"} ---- */ xrtJsonWriteConfigInit(&WriteConfig); pWriter = xrtJsonWriterCreate(&WriteConfig); if ( (pWriter == NULL) || !xrtJsonWriterObject(pWriter) || /* 开对象 */ !xrtJsonWriterName(pWriter, XRT_STR_LITERAL("code")) || !xrtJsonWriterInt(pWriter, 200) || !xrtJsonWriterName(pWriter, XRT_STR_LITERAL("message")) || !xrtJsonWriterString(pWriter, XRT_STR_LITERAL("OK")) || !xrtJsonWriterEnd(pWriter) || /* 闭对象 */ !xrtJsonWriterFinish(pWriter) /* 完整性校验 */ ) { xrtJsonWriterFree(pWriter); return 5; } /* Take 移交文本(xrtFree 释放);Take 后 writer 仍需 Free。 */ sText = xrtJsonWriterTake(pWriter, &iSize); xrtJsonWriterFree(pWriter); if ( sText == NULL ) { return 6; } printf("%.*s\n", (int)iSize, sText); xrtFree(sText); return 0; }
刚才发生了什么。 ① DOM 解析产字段访问(member: code/ok 的查询形态)——chatd 帧分派的直接词汇(op 字段路由——与 pushd 的 on_message 同构)。② 序列化往返(Writer 产出合法 JSON)是广播帧的生成形态。③ 两层原子(行定界+JSON)合起来就是线协议的全部——设计决策二的实现预演:两个现成正题拼一个协议——零协议自研代码。
设计决策汇总表
六个决策的一页速览(评审用)——每行一个域、五要素压缩为三列:
| 域 | 决策 | 关键理由与验证 |
|---|---|---|
| 房间模型 | 用户独立于连接 | 重连保身份(UC5 可过);Map 双层 |
| 线协议 | 行定界+JSON | 人可读+结构化+零自研定界 |
| 定序 | 房间单点取号 | 单进程全序域;序号单调无洞 |
| 状态机 | 六态含离线态 | 身份周期长于连接 |
| 存储 | 内存环 1024 帧 | 教学容量假设;落盘留演进 |
| 降级 | 踢/拒两级+协议帧 | 序号无洞禁跳过;拒绝可理解 |
这张表是实现章(第 142 章)的对账索引——每行的"验证"列就是实现完成时的测试清单条目。
与第 139 章设计章的方法论对位
两次设计章的模板执行对照——方法论自身的检验:决策五要素在第 139 章展开较重(每个决策一节)、本章压缩为表格+节——详略随决策的争议度(有真实多选项的展开、方向明显的压缩——篇幅服务于歧义消除);验收先行的清单两章一致(六条 vs 八条——本章多了容量与压测——并发密度更高的项目验收自然更多);备选保留(第 139 章模板引擎留扩展点、本章落盘留演进点——否决不销毁:理由消失时回来的路留着)。三次可迁移的模板经验:模板是骨架不是表格——五要素的顺序(问题先行)比格式重要;每个决策配验证——没有验证方案的决策是愿望不是决策;决策间要互检(本章的"身份独立"决定了"补齐协议"的形态——孤立的决策各自最优可能整体矛盾——设计 review 的主要工作就是找这种交叉)。
141.4契约
- 决策五要素:每个设计域问题/选项/决策/理由/验证齐备——实现章逐条兑现。
- 房间模型:用户独立于连接(重连保身份与游标);房间=用户集+消息环+定序计数器。
- 线协议:行定界(宽容)+ JSON 载荷(严格)——两层现成正题。
- 定序:房间单点定序;序号严格单调无洞;游标记录已见。
- 补齐协议:重连带游标→环内补发、环外 too far behind。
- 会话终态:退房帧后无幽灵帧(先移除后广播);Close 恰一次。
- 身份生命周期:长于连接;离线超时清理(可配)。
- 存储边界:内存环(1024 帧/房)+ 落盘为演进预留。
- 降级矩阵:慢成员踢出(不可跳——序号无洞)/房满拒绝/过载拒握手——拒绝都有协议帧。
- 与 pushd 分工:裸 TCP 自搭协议 vs WS 复用高层件——同业务的两个传输层对照训练。
协议演进的兼容性设计
线协议的版本化预留(设计期的远见练习):版本字段(握手帧带 "v":1——服务端按版本分派解析——v2 加字段时 v1 客户端不炸:JSON 解析对未知字段天然宽容——选 JSON 的又一个红利兑现);能力的协商(握手响应带回服务端能力列表——客户端按能力裁剪功能——UC6 的 who 可以演进为分页而不破旧客户端);降级路径(未知 op 回"unknown op"帧不断连——与第 140 章消息宽容同款)。三条加起来约二十行设计——换来协议可演进而非每次改动全量同步升级。真实 IM 系统的协议寿命以十年计——兼容性是协议的第一需求,功能才是第二。
测试替身与客户端模拟
chatd 的测试策略(设计期定、实现期执行):客户端替身——脚本化虚拟用户(连→握手→进房→发言/收帧/游标审计)——第 59 章 executor 驱动 N 个替身;服务端形态配置——正常/慢注入/房满/环回绕——测试矩阵的四个服务端行为;序号审计器——替身收帧时校验单调无洞(每个客户端自带验收 ② 的检查器);断线注入——替身在指定帧号后主动断开(测补齐)与被动断开(测终态)。替身的正确性靠与真实客户端共用同一协议库(替身不是第二实现——第 130 章"无第二实现"的测试版)。
常见错误
症状:断线重连后变成新用户——历史补齐无从谈起(旧游标找不到主人);私聊目标断线即失联(对方重连后路由不到)。
原因:把连接当身份。TCP 连接是会话的载体不是会话本身——重连语义要求身份独立于载体(决策一的核心)。
struct user { xnetstream* Conn; ... }; /* Conn 断=用户消失:游标、房间关系全丢 */
struct user { xnetstream* Conn; /* 可空=离线 */ uint64 Cursor; /* 已见序号——连接无关 */ ... /* 身份字段 */ }; /* 重连:新 Conn 接上老 user——游标连续 */
症状:两个客户端看到的同一批消息顺序不同(各自的接收线程调度不同)——对话逻辑错乱(回答出现在问题前面)。
原因:把"服务端处理序"当"房间序"。定序必须显式取号(锁内计数器++)并让序号成为投递依据——到达序只是物理巧合不是语义承诺。
on_say(user, msg) { broadcast(room, msg); /* 按到达序直发:锁外顺序无保证 */ }
on_say(user, msg) { uint64 seq; lock(room); seq = room->Next++; unlock(room); broadcast(room, frame(seq, msg)); /* 带号投递 */ }
症状:客户端退出后还偶尔收到房间的消息——会话收尾不干净;更糟的是重连后被旧帧污染。
原因:移除成员与广播的顺序错了(先广播后移除——这批广播仍触达已退用户)。
broadcast(room, "user left"); /* 先广播 */ remove_member(room, user); /* 后移除:本批广播含已退者 */
remove_member(room, user); /* 先移除 */ send_to(user, "you left"); /* 给本人的最后一帧(恰一次) */ broadcast(room, "user left"); /* 房间广播不含已退者 */
为设计定稿的帧型字典(实现章的协议权威):上行(握手/join/leave/say/dm/who/quit——各带必选字段表);下行(ok/err/broadcast/dm/history_tail/too_far/you_left/member_list——各带字段与序号出现规则);双向能力(未知 op 的 unknown 回执)。字典用三段式表格(帧型/方向/字段+约束)——第 140 章期望文件的数据化思路用于协议。验收标准:六 UC 的每个帧序列在字典中查得到全部帧型;错误分支的 err 帧带可定位原因码。
编程练习
写出六 UC 各自的完整帧序列(客户端发什么、服务端回什么——含错误分支)——JSON 帧型定义成表。验收标准:每 UC 正反例各一;帧字段最少完备(op/必要参数/序号字段的出现规则)。
画三个时序:并发两人发言(取号与投递交错——全房一致);断线 5 帧后重连补齐(游标+环);环回绕后的补齐拒绝(too far behind)。验收标准:每个时序的序号链可手推——无洞无重。
估算:100 房×50 用户×1024 帧环×平均 200 字节/帧——内存总量;1000 并发连接的每连接缓冲(第 65 章)——总预算表。给出容量上限建议与过载拒绝阈值的关系。验收标准:预算表各项有出处(章引用);阈值与预算自洽。
小结
| 知识点 | 速查 |
|---|---|
| 设计模板 | 决策五要素(问题/选项/决策/理由/验证)——138 章模板复用 |
| 房间模型 | 用户独立于连接;房间=用户集+消息环+定序器 |
| 线协议 | 行定界(宽容入口)+ JSON 载荷(严格结构)——两层现成 |
| 定序 | 房间单点取号;序号单调无洞;游标记录已见 |
| 补齐 | 重连带游标→环内补发;环外 too far behind 全量刷新 |
| 会话终态 | 先移除后广播;本人收尾帧恰一次 |
| 身份周期 | 长于连接;离线超时清理 |
| 存储 | 内存环+落盘演进预留;环回绕覆盖最老 |
| 降级 | 慢成员踢(不可跳)/房满拒/过载拒——拒绝有帧 |
| 与 pushd 分工 | 裸 TCP 自搭协议 vs WebSocket 复用高层件——同业务的两个传输层训练对 |
📖 本章涉及的 API 参考手册:core(25 个条目)value(159 个条目)net(247 个条目)
点击查看完整签名、参数约束、返回值与错误说明