应用目录组织:单翻译单元与按代隔离
C 项目的目录组织自由得没有边界。本章给出 demo-single 验证过的约定——它不是强制标准,但它与热重载的代隔离语义咬合得最好。
本章导读
5.1 引入
新的 XServer 应用面对的第一个问题不是"怎么写 handler",而是"文件怎么摆"。C 没有包管理器帮你划界:include 路径、符号链接、编译单元划分全靠自律。组织混乱的后果在热重载场景下会被放大——你 reload 的是整个翻译单元,任何文件的位置都影响每一代的编译。
demo-single 给出了一套轻约定:目录即职责、单翻译单元、模块自包含。它支撑了 19 条路由、SQLite、模板、受控页面的完整应用,且经受住了 routebench 万级路由的压测——说明这个约定在规模上是够用的。
5.2 目录约定
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 的编排是:
/* 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 的路由模块源码里写着:
/* 热重载会创建一套新的静态变量,
* 旧代在请求全部离开后才执行 ServiceUnit。*/
换句话说:route.h 里的路由表、database.h 里的 G_DB 句柄,都是代的状态。换代后新代从空白静态区开始(再跑一次 ServiceInit 重建路由表、重开数据库),旧代的那套随引用归零销毁。想跨代保留的(如连接计数、缓存),必须走 ServiceSwap 显式交接(第 28 章)。
5.4 modules 复用模式
一个好的模块要满足"换应用能直接拷走"。以 modules/http.h 的 ReplyJSON 为例看模块边界:
/* 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/)跨代共享,换代不重建。
常见错误
- 症状
- 脚本编译直接失败:重定义(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 章完整实现)*/- 症状
- 浏览器直接访问该文件路径 404,"文件明明在"。
- 原因
- URL 可直达的只有 wwwroot;page/ 是受控目录,必须经脚本里的 LoadPage 读取(第 19 章)。
/* 把下载页放进 page/download.html
* 然后期望 /download.html 能访问 *//* 公开资源 → wwwroot/
* 受控资源 → page/ + 路由里 LoadPage */编程练习
对照 5.2 的判定标准,检查你在第 4 章练习建的应用:每个文件是否都有明确归属?有没有混放(如模板和静态资源同目录)?
新建 modules/counter.h:提供 Counter_Inc() / Counter_Get(),带模块前缀、只依赖标准库、所有权注释齐全。在两个不同 handler 里调用它。
用 counter 模块累计请求数,重启后确认归零;然后阅读 demo 的 ServiceSwap 声明(sdk/xsbase.h 中 XS_ServiceSwapProc),写出你的 counter 跨代交接方案(伪码即可)。
小结
| 要点 | 结论 |
|---|---|
| 目录即职责 | modules(复用)/ route_http(业务)/ page·template(受控)/ wwwroot(公开)/ db(跨代) |
| 单翻译单元 | main.c 按依赖序 include 一切;TCC 编译成本可控 |
| 代隔离 | 静态变量属于代;跨代延续必须 ServiceSwap 交接 |
| 模块三要素 | 只依赖下层、所有权注释、模块前缀 static 符号 |
| 访问边界 | URL 直达仅 wwwroot;其余经脚本受控读取 |
- xrt 参考 · template — template/ 目录里模板引擎的完整 API
- xrt 参考 · map — modules/protocol.h 路由表的底层数据结构