第 5 章 / 共 51 章
卷一 · 起步与心智模型

应用目录组织:单翻译单元与按代隔离

C 项目的目录组织自由得没有边界。本章给出 demo-single 验证过的约定——它不是强制标准,但它与热重载的代隔离语义咬合得最好。

本章导读

站位:解决"代码往哪放"的问题。本章之后,你写的每个文件都有明确归属;卷三的协议章节将直接复用这里的目录约定。已有大型 C 工程经验的读者重点读 5.3 的"为什么敢用单翻译单元"。

5.1 引入

新的 XServer 应用面对的第一个问题不是"怎么写 handler",而是"文件怎么摆"。C 没有包管理器帮你划界:include 路径、符号链接、编译单元划分全靠自律。组织混乱的后果在热重载场景下会被放大——你 reload 的是整个翻译单元,任何文件的位置都影响每一代的编译。

demo-single 给出了一套轻约定:目录即职责、单翻译单元、模块自包含。它支撑了 19 条路由、SQLite、模板、受控页面的完整应用,且经受住了 routebench 万级路由的压测——说明这个约定在规模上是够用的。

5.2 目录约定

demo-single 目录树 实机验证 出处:release/demo-single/
myapp/
├── xs.exe          /* 唯一的二进制(宿主)*/
├── xs.json         /* 装配事实:服务/端口/TLS/vhost */
├── main.c          /* 应用入口:include 编排 + 生命周期 */
├── route.h         /* 路由注册清单:URI × 方法 → 处理函数 */
├── includes/       /* dev_inc:脚本额外头文件搜索路径 */
├── librarys/       /* dev_lib:第三方库(#pragma comment 引用)*/
├── modules/        /* 复用模块:http / db / template / resource / protocol */
├── route_http/     /* 请求处理器:按资源分文件 */
├── page/           /* 受控页面:wwwroot 之外,防直链 */
├── template/       /* xrtTemplate 模板 */
├── db/             /* SQLite 数据文件 */
└── wwwroot/        /* 静态资源根(唯一对外直链目录)*/
目录职责判定标准
modules/与具体业务无关的复用能力换一个应用也能直接拷走
route_http/业务处理器引用 modules,不被别人引用
page/ template/动态内容素材必须经脚本读取,不能被 URL 直达
wwwroot/静态资源可被 URL 直达的唯一目录
db/持久化数据热重载不碰它,跨代共享

注意最后两行的对照:wwwroot 是"可直达",db 是"跨代"——目录约定同时编码了访问边界与生命周期边界。

5.3 单翻译单元编排

整个应用最终合成一个翻译单元:main.c 按依赖顺序 include 一切。demo 的编排是:

main.c 的 include 编排(节选) 出处:release/demo-single/main.c
/* 1. SDK 契约 */
#include <xsbase.h>
/* 2. 复用模块(自底向上)*/
#include "modules/resource.h"
#include "modules/http.h"
#include "modules/database.h"
#include "modules/template.h"
#include "modules/protocol.h"  /* 路由框架:最后引入,依赖以上全部 */
/* 3. 业务处理器 */
#include "route_http/basic.h"
#include "route_http/item.h"
/* 4. 路由清单(引用所有处理器)*/
#include "route.h"

为什么敢用单翻译单元

  • 编译成本可控:TCC 快,demo 全量 2.6KB 入口的编译在启动日志里只是一行 script loaded (2.6 KB gen N);routebench 的万级路由应用同样即时编译。
  • 符号世界最简单:没有跨 .c 的链接边界,static 函数与全局函数的可见性一目了然,不存在"两个编译单元各有一份同名全局"的暗坑。
  • 与代隔离天然咬合:换代 = 整个翻译单元重新编译,所有静态变量成套更新,不存在"一半新一半旧"的编译单元拼装。

静态变量按代隔离

这是本目录约定里最重要的一条语义。demo 的路由模块源码里写着:

modules/protocol.h(注释原文) 出处:release/demo-single/modules/protocol.h
/* 热重载会创建一套新的静态变量,
 * 旧代在请求全部离开后才执行 ServiceUnit。*/

换句话说:route.h 里的路由表、database.h 里的 G_DB 句柄,都是代的状态。换代后新代从空白静态区开始(再跑一次 ServiceInit 重建路由表、重开数据库),旧代的那套随引用归零销毁。想跨代保留的(如连接计数、缓存),必须走 ServiceSwap 显式交接(第 28 章)。

5.4 modules 复用模式

一个好的模块要满足"换应用能直接拷走"。以 modules/http.h 的 ReplyJSON 为例看模块边界:

modules/http.h · 模块边界示例 出处:release/demo-single/modules/http.h
/* ReplyJSON 只借用 pObject,调用方仍负责 xrtValueRelease。 */
static bool ReplyJSON(XS_HttpReq* pReq, uint16 iStatus, const xvalue* pObject)
{
    /* 序列化 → ReplyRaw 写头与正文 → 归还所有权注释里说清 */
    ...
}

刚才发生了什么——一个合格模块的三要素在这 5 行里全有:

  • 只依赖下层:xsbase.h + xrt,不引用 route_http 的任何东西;
  • 所有权注释:第一行注释明确"借用不接管"——调用方不用猜要不要释放;
  • static 函数:单翻译单元下 static 即"模块私有",不污染应用命名空间。

5.5 契约

  • 依赖线:include 顺序 = 依赖顺序(SDK → modules → route_http → route.h);modules 之间也保持单向。
  • 命名线:单翻译单元下所有 static 符号共享一个命名空间——模块内符号带模块前缀(如 RouteHTTP_、DB_、Template_)。
  • 生命周期线:静态变量是代的状态;文件类资源(db/、page/、template/)跨代共享,换代不重建。

常见错误

两个模块定义了同名 static 函数
症状
脚本编译直接失败:重定义(redefinition)错误。
原因
单翻译单元里所有 .h 展开进同一作用域,同名 static 函数必然冲突——这不是链接器问题,编译期就爆。
✗ 错误
/* modules/a.h */ static void Init(void);
/* modules/b.h */ static void Init(void);
/* main.c include 两者 → 编译失败 */
✓ 正确
static void A_Init(void);
static void B_Init(void);  /* 模块前缀 */
把跨代数据放进静态变量
症状
热重载后统计计数归零、缓存丢失,"明明没重启"。
原因
静态变量按代隔离:换代即新的一套,这不是 bug 是语义——但如果你期望它延续,就踩坑了。
✗ 期望错误
static uint64 G_iTotal;  /* 总请求数
 * 热重载后 = 0,"数据丢了" */
✓ 正确
/* ServiceSwap 导出 → xsSwapTake 取回
 * (第 28 章完整实现)*/
在 page/ 放了本想公开的文件
症状
浏览器直接访问该文件路径 404,"文件明明在"。
原因
URL 可直达的只有 wwwroot;page/ 是受控目录,必须经脚本里的 LoadPage 读取(第 19 章)。
✗ 错误
/* 把下载页放进 page/download.html
 * 然后期望 /download.html 能访问 */
✓ 正确
/* 公开资源 → wwwroot/
 * 受控资源 → page/ + 路由里 LoadPage */

编程练习

基础目录体检

对照 5.2 的判定标准,检查你在第 4 章练习建的应用:每个文件是否都有明确归属?有没有混放(如模板和静态资源同目录)?

验收:列出一张文件 → 目录 → 判定依据的三列表,无"混放"项。
进阶写一个 counter 模块

新建 modules/counter.h:提供 Counter_Inc() / Counter_Get(),带模块前缀、只依赖标准库、所有权注释齐全。在两个不同 handler 里调用它。

验收:把 counter.h 拷到一个空目录(只留 xsbase.h include 之外的依赖)仍可编译;两个 handler 看到同一个计数。
挑战验证代隔离

用 counter 模块累计请求数,重启后确认归零;然后阅读 demo 的 ServiceSwap 声明(sdk/xsbase.h 中 XS_ServiceSwapProc),写出你的 counter 跨代交接方案(伪码即可)。

验收:能说清"哪个值该交接、哪个该重置、Swap 里导出的对象谁释放"。

小结

要点结论
目录即职责modules(复用)/ route_http(业务)/ page·template(受控)/ wwwroot(公开)/ db(跨代)
单翻译单元main.c 按依赖序 include 一切;TCC 编译成本可控
代隔离静态变量属于代;跨代延续必须 ServiceSwap 交接
模块三要素只依赖下层、所有权注释、模块前缀 static 符号
访问边界URL 直达仅 wwwroot;其余经脚本受控读取
xrt 延伸阅读