从 TCP 字节流到终端群聊
最近尝试了一下 Vibe Coding,一行代码没写,最后跑出来的是一个终端实时群聊系统。这个体验挺有意思:人不直接写代码,但要负责判断需求边界、检查工程设计、看测试是不是兜住了关键路径。换句话说,代码可以交给工具生成,工程判断还是得自己扛。
所以这篇不想写成工具体验报告,而是顺着这个仓库,把一个实时聊天系统从 TCP 字节流一路拆到终端界面。很多人第一次写网络程序,会以为发送一条消息,对方就会收到一条消息。实际不是这样。TCP 给应用程序的是一条可靠、有序的字节流,网络层只保证字节按顺序到达,不保证应用层的一次发送会对应对方的一次读取。一次 write 写出去的数据,可能被对方分几次读到;几次 write 的数据,也可能被对方一次读完。
chat-tui 这个仓库就是从这个问题开始设计的。它做的是一个终端实时群聊系统:一个 asyncio TCP 服务端,多个 Textual 终端客户端,服务端和客户端都用 SQLite 做持久化,线上传输用自定义的长度前缀 UTF-8 JSON 协议。功能上有注册登录、群聊、私聊、在线状态、未读数、本地历史和 Base64 图片消息。看起来像一个小项目,但它覆盖了网络应用里很典型的一条链路。
Table of contents
Open Table of contents
协议不是 JSON,协议是怎么切出一条消息
仓库里的协议实现放在 src/chat_tui/protocol/framing.py 和 src/chat_tui/protocol/messages.py。这里有一个很关键的选择:每个消息帧由 4 字节大端长度前缀加 JSON 负载组成。
4-byte big-endian payload length
UTF-8 JSON payload
这 4 个字节解决的是 TCP 字节流没有消息边界的问题。接收端先读 4 个字节,算出后面 JSON 负载有多少字节,再用 readexactly(n) 读完整负载。读少了就是截断帧,读多了就是长度不一致。负载不是 UTF-8、不是 JSON、JSON 顶层不是对象,也都能在协议层直接挡掉。
这比直接按换行切消息稳一些。聊天内容里以后可能出现换行,图片内容虽然当前是 Base64 字符串,但继续扩展时也不应该把业务内容和协议边界绑死。长度前缀还有一个实际好处:服务端可以设置最大帧大小。当前实现把单帧负载限制在 1,048,576 字节,太大的帧直接拒绝,避免一个客户端把内存拖垮。
JSON 只解决可读性和跨语言表达,不能替代协议。真正的协议还包括:如何分帧、字段是否必填、未知类型怎么处理、错误如何返回、什么时候断开连接。这个仓库把这些规则拆成两层:framing.py 管字节怎么变成 JSON 对象,messages.py 管 JSON 对象怎么变成有类型的协议消息。
消息外壳让业务和网络解耦
所有业务消息都有统一外壳:
{
"type": "group.send",
"payload": {
"group": "general",
"content_type": "text",
"content": "hello"
}
}
type 决定这是什么请求或事件,payload 承载业务字段。messages.py 里列出了当前支持的类型:register、login、group.create、group.join、group.leave、group.list、group.send、direct.open、direct.send、group.message、direct.message、presence.update、success、error。
这里有两个设计点值得看,都是小项目里很容易被省掉的部分。
一方面,客户端请求和服务端事件走同一套外壳。客户端发 group.send,服务端返回 success,同时给在线成员推 group.message。私聊也是一样,客户端发 direct.send,服务端返回 success,再推 direct.message。
另一方面,当前协议没有请求 ID。客户端通过一个请求锁保证同一时间只挂起一个请求,然后等待下一个匹配请求类型的 success 或 error。这个选择牺牲了并发请求能力,但让协议和客户端状态都简单很多。对一个终端聊天 MVP 来说,这个取舍没问题;以后真要支持并发发送、批量操作或更复杂的同步流程,就该在协议里加请求 ID,而不是在客户端里猜响应归属。
服务端连接其实是一台小状态机
src/chat_tui/server/connection.py 里的 ClientConnection 负责单个 TCP 连接。它的主循环很短:读一帧,解析消息,按认证状态处理,必要时发送错误,然后继续读下一帧。
认证前只允许 register 和 login。认证后再发认证请求会收到 already_authenticated,没认证就发群聊或私聊请求会收到 unauthenticated。连接状态机最基础的边界就在这里:同一条消息,在不同连接状态下含义不一样。
在线用户由 OnlineUserRegistry 维护,内部用 asyncio.Lock 保护用户名到连接对象的映射。登录成功时先 claim 用户名,失败说明同一用户已经在线;断开时在 finally 里释放。这个 finally 很重要。网络断开不一定会走正常退出路径,客户端可能直接断电、杀进程或 transport.abort()。清理逻辑不能指望客户端临走前还礼貌地发一条退出消息。
群消息的服务端路径也很直。server/messages.py 先检查群是否存在、用户是否是成员,再把消息写入 SQLite,最后生成 group.message 推给在线群成员。私聊在 server/direct.py 里处理,服务端会把消息发给发送者和接收者中当前在线的连接。在线状态只推给已经打开过直接聊天关系的用户,不做全站广播。
这里有一个工程上很重要的顺序:先持久化,再广播。这样服务端返回给客户端的消息时间戳和推送事件来自同一条数据库记录,发送方本地回显和接收方收到的事件能对齐。要是先广播再落库,数据库写失败时就会出现对方看到了消息、服务端却没有记录的情况。
异步的价值在于不互相挡住
这个系统用 asyncio.start_server 接收连接,每个客户端连接由一个协程处理。发送数据时用 writer.write(...) 加 await writer.drain(),读取数据时用 reader.readexactly(...)。Python 文档里把 asyncio streams 定位成适合 async/await 的网络连接高级接口,正好契合这个场景。
异步的价值不在于让单个数据库写入变快,而在于一个空闲连接不会占住线程。聊天系统里大量连接都在等用户输入或等网络数据。如果每个连接一个线程,50 个空闲客户端就已经有明显资源成本;用 asyncio 时,空闲连接主要只是事件循环里的一组状态,成本轻很多。
仓库的验收测试 tests/test_concurrency_acceptance.py 专门测了这件事:50 个已认证空闲客户端在线时,群消息和私聊消息的代表性投递都要低于 500ms。这个测试没有证明系统可以承受互联网级流量,但能说明当前架构没有在很早的地方把自己锁死。
还要注意,异步代码里不能随便塞 CPU 密集操作。服务端创建和验证密码时用的是 PBKDF2-HMAC-SHA256,默认 600,000 次迭代。server/persistence.py 把密码哈希放到 asyncio.to_thread 里跑,避免一次密码计算卡住整个事件循环。很多时候,真正决定体验的不是用了 asyncio 这个标签,而是这些容易被忽略的小地方。
SQLite 不是临时存储,两端都承担恢复责任
服务端 SQLite 保存用户、密码校验数据、群、成员关系、群消息和私聊消息。客户端 SQLite 保存本地已知会话、聊天历史、未读数和已知在线状态。两边都落库,看起来像重复存储,其实对应两种不同的恢复目标。
服务端恢复的是服务事实:哪些用户存在,谁属于哪个群,服务端收到过哪些消息。客户端恢复的是用户工作区:左侧有哪些会话,本地看过哪些消息,哪些会话还有未读。服务端重启后,用户还能登录,群成员关系还在;客户端重启后,终端界面还能从 --history-db 找回本地历史和未读状态,不至于像第一次打开一样空荡荡。
aiosqlite 在这里的作用也比较清楚。它提供的是 SQLite 的异步接口,官方文档说明每个连接背后有一个共享线程和请求队列,避免多个操作在同一连接上重叠执行。对这个项目来说,这比把同步 sqlite3 直接放进事件循环里更合适,也比一开始就上 PostgreSQL 更贴近 MVP 的复杂度。
SQLite schema 里还用了不少约束:用户名和内容不能为空,消息类型只能是 group 或 direct,内容类型只能是 text 或 image,群消息必须有 group_name 且不能有 recipient_username,私聊消息则相反。应用层会校验,数据库层也兜底。对持久化代码来说,双层校验不是洁癖,而是防止未来哪次改动绕过某一层时把脏数据写进去。
客户端比看起来更复杂
很多聊天系统的难点不在服务端,而在客户端状态。这个仓库把客户端拆成几层:client/network.py 管 TCP 连接和请求响应,client/state.py 管群会话,client/messages.py 管群消息,client/direct.py 管私聊和在线状态,client/persistence.py 管本地 SQLite。
ChatClient 连接成功后会启动一个后台接收任务。这个任务持续读服务端帧,把 success 或 error 分配给当前挂起的请求,把 group.message、direct.message、presence.update 放进事件队列。TUI 工作区再从事件队列取事件,刷新时间线、未读数和在线状态。
发送方本地回显是一个很容易翻车的点。用户发出群消息后,客户端会从 group.send 的成功响应里拿到服务端确认的消息并保存为 outgoing;同一条消息随后也可能作为 group.message 从服务端广播回来,因为发送者也是群成员。如果不处理,发送者本地就会看到两条一样的消息。
client/messages.py 和 client/direct.py 用待发送签名和已存记录做去重。签名主要由目标、内容类型和内容组成,收到自己发送的广播事件时,客户端会优先匹配已保存的 outgoing 记录;如果请求还在飞行中,就先不保存,等成功响应落本地。这个逻辑不花哨,但很实用,能把发送成功响应和实时事件广播这两条异步路径收束到同一份本地历史里。
未读数也是类似的客户端问题。消息来了,如果当前正在看这个会话,就直接写入时间线;如果不是当前会话,就保存消息并增加对应会话的未读数。切到这个会话时再清零。服务端不需要知道用户当前看的是哪个终端面板,这个状态属于客户端。
TUI 是网络状态的投影
Textual 这层代码主要在 src/chat_tui/tui/app.py 和 src/chat_tui/tui/workspace.py。登录页负责连接、注册和登录;进入工作区后,左侧是群和私聊会话,右侧是当前会话时间线和输入框。群管理、打开私聊、文本/图片模式、发送消息、键盘切换会话,都在同一个工作区完成。
TUI 没有直接拼网络帧,也不直接写 SQLite。它调用客户端状态对象,再把状态对象的结果渲染出来。这个边界很舒服:UI 测试可以不真的跑完整网络,网络测试也不需要启动终端界面。
图片消息当前用 Base64 字符串表达,不走文件传输。工作区只校验输入是不是合法 Base64,时间线里显示 [image payload N chars]。这个范围很窄:协议支持一种图片内容类型,但不处理文件名、MIME、分片、重传和离线下载。边界写清楚了,后面扩展时才知道该动协议、服务端还是客户端。
错误要能恢复,不能让坏客户端拖垮系统
这个仓库对协议错误的态度很务实:能继续读下一帧的错误就返回 error 并保持连接,比如零长度帧、非法 JSON、未知消息类型、缺少必填字段;已经没法安全继续的情况才关闭当前连接,比如截断帧或超大帧。
tests/test_recovery_acceptance.py 里有一条专门的故障路径:一个原始 TCP 客户端先发零长度帧,再发非法 JSON,再发未知消息类型,再发缺字段登录请求,最后还能正常注册成功。与此同时,另一个健康客户端继续登录和创建群。这个测试比单纯断言异常类型更有价值,因为它验证的是系统边界:坏输入只能影响当前连接,不能把整个服务端带偏。
客户端也有类似边界。服务端如果发来坏帧,ChatClient 会把它转成 ClientProtocolError 放进事件队列;服务端断开连接,会失败当前挂起请求并通知上层。TUI 收到这些错误后更新状态栏,不让异常直接炸出界面。
测试覆盖的是行为,不只是函数
这个项目的测试目录基本对应架构目录。协议层有帧编解码、消息 schema 和契约样例;服务端有持久化、连接、群和私聊;客户端有网络、本地持久化、群状态、私聊状态;再往上有端到端测试、TUI headless 测试、恢复测试、并发验收和文档一致性测试。
Textual 官方文档提供了 App.run_test(),可以让应用在 headless 模式下运行,并用 Pilot 模拟键盘和鼠标。仓库的 TUI 测试就利用了这一点,还用 snapshot 覆盖工作区渲染,避免终端 UI 在改动中悄悄走样。
这里更值得借鉴的是测试层次。协议错误用单元测试压细节,真实 TCP 行为用异步集成测试,恢复和并发目标用验收测试,文档则用测试保证命令、路径和协议类型没有过期。一个聊天系统如果只测纯函数,很容易漏掉连接关闭、事件竞态、服务端重启、客户端重启这些线上很常见的麻烦。
这个 MVP 没做什么,同样重要
docs/protocol.md 和 memory bank 都明确写了当前不做的事:不做加密、不做任意文件传输、不做多服务端集群、不做 ACK/retry、不做离线消息重放、不做协议版本协商、不做发送批处理、不做管理后台。
这些限制会影响系统能力。比如服务端只把实时事件发给在线连接,离线用户重连后不会从服务端补拉遗漏事件;协议没有请求 ID,所以客户端请求是串行的;没有 TLS,所以只能绑定在可信本机或可信 LAN 上。讲工程实现时必须把这些边界放在台面上,不然读者很容易误以为这是一个生产级互联网聊天系统。
这个仓库适合学习,也正因为它没有把所有功能都堆进去。它把一条实时聊天链路做完整:从 TCP 字节流切帧,到消息 schema 校验,到连接状态机,到持久化,到实时事件,到客户端本地状态,到 TUI 渲染,再到恢复和并发验收。每一层都不大,但边界清楚。
读这个仓库可以带走的几个判断
网络编程入门最容易忽略协议边界。只要用 TCP,就要自己定义应用层消息边界;只要允许 JSON,就要定义 JSON 的顶层形状、字段类型、错误语义和大小限制。
异步连接处理的重点是别堵住事件循环。I/O 等待交给 await,写出数据后配合 drain(),CPU 密集的密码哈希放到线程里,数据库访问用异步封装。这样 50 个空闲连接不会把系统变成 50 个线程的堆叠。
实时聊天的客户端状态不比服务端简单。发送回显、自广播去重、未读数、会话切换、本地恢复、在线状态,这些都不会因为协议里有字段就自动解决,需要单独建模和测试。
持久化要服务于恢复目标。服务端保存服务事实,客户端保存工作区事实。两边都持久化,不是偷懒,而是因为它们恢复的对象不同。
范围控制是工程质量的一部分。这个项目能把 MVP 做稳,很大原因是它没有同时追求离线投递、端到端加密、多端同步和集群。等这些需求真的进入计划时,应该从协议和持久化模型开始改,而不是在现有消息处理函数里继续加分支。
参考资料
- RFC 9293: Transmission Control Protocol,TCP 的可靠、有序、字节流语义。
- Python 3.12 asyncio Streams,
start_server、open_connection、readexactly、write、drain和流关闭语义。 - Python 3.12 json,JSON 编解码和互操作细节。
- Python 3.12 sqlite3,SQLite 事务、连接和上下文管理行为。
- aiosqlite documentation,SQLite 的 asyncio 接口,以及每个连接背后的共享线程和请求队列模型。
- Textual testing guide,
App.run_test()、Pilot交互和 snapshot 测试。
以上