本章导读

项目三是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"(客户端全量刷新)。验证:并发压测的序号审计(每客户端校验单调无洞——验收 ②③)。

决策四:连接状态机

连接建立TCP accept →握手中
握手中握手帧(昵称合法且不冲突) →在线
握手中拒绝帧(昵称冲突/非法——明确原因后关) →关闭
在线join/leave/say/dm/who 任意次 →在线
在线quit 帧 / TCP 断 / 服务端踢 →关闭
关闭连接资源释放;用户身份保留(游标与历史) →(用户离线)
(用户离线)重连握手(带游标→补齐) →在线

终态保证:连接的 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 行帧)的可运行原子:

examples/io/line/main.c源码 ↗























 */

#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;
}
$ gcc -O1 -DXRT_MODULE_ALL -I single impl.c examples/io/line/main.c -lws2_32 -liphlpapi1: INFO server started2: WARN queue is busy3: ERROR request failed

刚才发生了什么。 ① 行定界的三态循环就是 chatd 的收帧引擎:LINE 到→JSON 解析→分派;END→连接收尾;ERROR→结构化断开。② 行尾自适应(三种行尾全识别)让 telnet/nc 各种客户端都能连——宽容入口(协议的最低层宽容、上层 JSON 严格——各层各司其职)。③ 这个原子与第 140 章引用的是同一个——两个项目共用同一地基(分层复用的直接证据)。

第二个完整程序:帧载荷的结构化

第二个程序来自 examples/data/json——帧载荷层(JSON)的原子:

examples/data/json/main.c源码 ↗





































 */

#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;
}
$ gcc -O1 -DXRT_MODULE_ALL -I single impl.c examples/data/json/main.c -lws2_32 -liphlpapiname = xrt{ "name": "xrt", "features": [ "json", "http" ]}member: codemember: ok{"code":200,"message":"OK"}

刚才发生了什么。 ① 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 章"无第二实现"的测试版)。

常见错误

坑 1:身份绑在连接上

症状:断线重连后变成新用户——历史补齐无从谈起(旧游标找不到主人);私聊目标断线即失联(对方重连后路由不到)。

原因:把连接当身份。TCP 连接是会话的载体不是会话本身——重连语义要求身份独立于载体(决策一的核心)。

错误写法
struct user { xnetstream* Conn; ... };
/* Conn 断=用户消失:游标、房间关系全丢 */
正确写法
struct user {
	xnetstream* Conn;     /* 可空=离线 */
	uint64 Cursor;        /* 已见序号——连接无关 */
	...                   /* 身份字段 */
};
/* 重连:新 Conn 接上老 user——游标连续 */
坑 2:广播顺序按到达序不按定序

症状:两个客户端看到的同一批消息顺序不同(各自的接收线程调度不同)——对话逻辑错乱(回答出现在问题前面)。

原因:把"服务端处理序"当"房间序"。定序必须显式取号(锁内计数器++)并让序号成为投递依据——到达序只是物理巧合不是语义承诺。

错误写法
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));   /* 带号投递 */
}
坑 3:退房后还收到广播(幽灵帧)

症状:客户端退出后还偶尔收到房间的消息——会话收尾不干净;更糟的是重连后被旧帧污染。

原因:移除成员与广播的顺序错了(先广播后移除——这批广播仍触达已退用户)。

错误写法
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 的帧序列

写出六 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 个条目)

点击查看完整签名、参数约束、返回值与错误说明