CHAPTER 04 · Chatroom

做一个内存 Chatroom

这一章不替换上一章的 Fetch Runtime,而是在它前面增加 Router。HTTP 提供 SPA 和 API,WebSocket 保持长连接;房间与最近消息存在内存,所有聊天事件同时追加到日志文件。

本章新增:Router。 /api/* 继续调用第三章 Fetch handler,/ws 执行 WebSocket upgrade,静态资源按文件返回,其余无扩展名 GET 路径回退到 SPA。

本章新增:ChatHub。 它是进程级 C++ 对象,保存房间成员和最近 50 条广播。状态在重启后消失;第五章引入 RocksDB 前,我们故意保留这个限制。

本章新增:WebSocketSession。 每个升级连接有一个 session。读取循环接收文本,写入使用一条 Promise 链串行发送;排队超过 64 条时只用 1013 关闭慢连接。

本章新增:AppendLog。 join、message、leave 以 JSON Lines 立即 flush 到文件。逐条 flush 简单可靠但写入成本高,第六章会测量并优化它。

1. 从第三章增量构建

Chapter 04 的 CMake 直接复用 Chapter 03 的 Runtime 源文件,再加入聊天模块。这条依赖关系在工程里是可见的,不复制一份 Runtime 假装彼此独立。

完整文件 · ch04/CMakeLists.txt
正在加载源码...
export LAB_ROOT=/home/zq/v8v8
CMAKE="$LAB_ROOT/.deps/cmake-3.31.8/bin/cmake"
SOURCE="$LAB_ROOT/v8-mod-tutor/src/code/ch04"
BUILD="$SOURCE/build-v137"

"$CMAKE" -S "$SOURCE" -B "$BUILD" -GNinja \
  -DCMAKE_TOOLCHAIN_FILE="$LAB_ROOT/v8-mod-tutor/toolchain/v8-pinned.cmake" \
  -DCMAKE_BUILD_TYPE=Debug \
  -DCMAKE_PREFIX_PATH="$LAB_ROOT/.deps/v137"
"$CMAKE" --build "$BUILD"
步骤测试 4.1 · 增量构建
预期输出
最终链接 libchat_runtime.achatroom-testv8-chat
原因
第三章的 V8/Fetch 代码与本章 Router、ChatHub、WebSocketSession、AppendLog 已经进入同一个纯 C++ 程序。

2. 先把路由边界写死

分流是纯函数,先于网络代码完成。未知前端页面可以回退到 index.html,缺失的 .js.css 等带扩展名资源必须是 404,否则浏览器会尝试把 HTML 当脚本解析。

/api/*                 FETCH
/ws                    WEBSOCKET
/app.js、/style.css     ASSET(文件必须存在)
/rooms/lobby           SPA
/missing.js            NOT_FOUND
非 GET 页面请求         METHOD_NOT_ALLOWED
完整文件 · ch04/src/router.h
正在加载源码...
完整文件 · ch04/src/router.c++
正在加载源码...
步骤测试 4.2 · 六类路由
预期输出
[PASS] ... router separates API WebSocket assets and SPA
原因
测试逐一断言 FETCH、WEBSOCKET、ASSET、SPA、NOT_FOUND 和 METHOD_NOT_ALLOWED;路由错误不会等到浏览器才暴露。

3. 实现内存房间

ChatClient 是 ChatHub 与具体 socket 之间的窄接口。join 分配稳定 id,broadcast 给同房间每位成员 enqueue,并把服务器生成的 JSON 保存到最多 50 条的历史队列;leave 幂等删除成员。

浏览器只发送 UTF-8 文本,例如 hello。服务器统一生成结构化广播:

{"type":"message","room":"lobby","user":"alice","text":"hello"}

这样服务端不需要为本章临时手写 JSON parser;所有输出则经过唯一的 jsonEscape(),用户名、房间和消息不能破坏 JSON 结构。

完整文件 · ch04/src/chat-hub.h
正在加载源码...
完整文件 · ch04/src/chat-hub.c++
正在加载源码...
步骤测试 4.3 · 广播、历史与离开
预期输出
[PASS] ... ChatHub broadcasts and keeps in-memory history
原因
alice 和 bob 各收到一次 hello,房间历史增加一条;bob 离开后成员数从 2 变成 1。

4. 串行写 WebSocket

KJ 明确禁止同一 WebSocket 同时执行多个 send()。Session 用 outgoing Promise 链把广播逐条串起来;每条消息的拥有者通过 attach() 活到 send 完成。

完整文件 · ch04/src/websocket-session.h
正在加载源码...
完整文件 · ch04/src/websocket-session.c++
正在加载源码...

WebSocket upgrade 还有一条容易忽略的所有权规则:acceptWebSocket() 得到的对象必须由当前 HttpService::request() 返回的 Promise 持有。因此 /ws 返回 session->run().attach(session),不能把 session 扔进旁路 TaskSet 后立即返回。

步骤测试 4.4 · 慢连接隔离
预期输出
[PASS] ... a slow client is removed without blocking its room
原因
容量为 1 的 slow client 在第二条广播时得到 1013 并离开;fast client 仍收到两条消息,房间没有被一个消费者堵住。

5. 保存因果日志

日志模块创建父目录,以 append 模式打开文件,每个事件生成一行 JSON 并立即 flush。

完整文件 · ch04/src/append-log.h
正在加载源码...
完整文件 · ch04/src/append-log.c++
正在加载源码...
步骤测试 4.5 · JSONL 顺序与转义
预期输出
[PASS] ... chat log preserves join message leave order
原因
临时日志中 join 位于 message 前、message 位于 leave 前;消息里的换行保存为 \n,没有截断 JSONL 记录。

6. 组合 HTTP、SPA 与 WebSocket

RouterService 执行真正的协议分流和静态文件读取,main 组装 Runtime、日志、ChatHub、RouterService 与 KJ HttpServer。

完整文件 · ch04/src/router-service.h
正在加载源码...
完整文件 · ch04/src/router-service.c++
正在加载源码...
完整文件 · ch04/src/main.c++
正在加载源码...

7. 完整 SPA

页面、样式和浏览器逻辑都是独立文件。两个窗口只要 room 相同就会收到同一广播。

完整文件 · ch04/public/index.html
正在加载源码...
完整文件 · ch04/public/style.css
正在加载源码...
完整文件 · ch04/public/app.js
正在加载源码...

完整 C++ 测试与双客户端 smoke test:

完整文件 · ch04/test/chatroom-test.c++
正在加载源码...
完整文件 · ch04/test/websocket-smoke.js
正在加载源码...
"$BUILD/chatroom-test"
"$LAB_ROOT/.deps/cmake-3.31.8/bin/ctest" --test-dir "$BUILD" --output-on-failure
步骤测试 4.6 · 本章单元测试
预期输出
四个 case 分别显示 [PASS],最终为 4 test(s) passed;CTest 显示 1/1 ... Passed
原因
KJ 按 case 计数,CTest 按测试可执行文件计数,与第二、三章的规则相同。

8. 真实服务验收

"$BUILD/v8-chat" \
  "$LAB_ROOT/v8-mod-tutor/src/code/ch03/worker/index.js" \
  "$SOURCE/public" \
  "$LAB_ROOT/v8-mod-tutor/data/chat.log" \
  127.0.0.1:8080

另一个终端:

curl -i http://127.0.0.1:8080/rooms/lobby
curl -i http://127.0.0.1:8080/missing.js
node "$SOURCE/test/websocket-smoke.js" ws://127.0.0.1:8080/ws
tail -n 5 "$LAB_ROOT/v8-mod-tutor/data/chat.log"
章节验收 · SPA、双客户端与日志
预期输出
SPA 路由为 200,缺失脚本为 404;smoke test 输出 2 clients received alice: hello;日志依次出现两次 join、一次 message、两次 leave。
原因
两个真实 WebSocket 连接进入同一 ChatHub 房间,alice 的文本经过统一 JSON 编码和两条独立 writer chain 广播;关闭连接时 session 各自执行幂等 leave。

下一章用 RocksDB 替换“重启即丢失”的部分,但 WebSocket session 本身仍只存在内存。