# 应用服务器
应用服务器(App Server)是 Deft 面向 Web 应用的生产部署层。开发时,单个应用文件以单进程模式提供服务;生产环境中,同一个文件运行在 N 个工作进程运行时中,每个都以 `SO_REUSEPORT` 绑定同一端口。内核在多个工作进程套接字之间对传入连接做负载均衡 —— 不存在应用层代理跳转。
```
┌──────────────────────────────────┐
│ Parent runtime │
│ (no listener, no traffic) │
│ • file watcher (reload) │
│ • cluster state │
│ • http/cluster-* stdlib functions │
└──────────────┬───────────────────┘
│ createChild + spawnFile
┌──────────────┴───────────┐
│ │
┌─────┴─────┐ ┌─────┴─────┐
│ Worker 0 │ │ Worker N │
│ SO_REUSEPORT │ SO_REUSEPORT
│ own VM │ │ own VM │
│ own heap │ │ own heap │
│ own thread│ │ own thread│
└───────────┘ └───────────┘
````
每个工作进程都是完全隔离的运行时(拥有自己的 VM、堆、事件循环和 OS 线程),独立加载应用文件。同一个应用文件在单进程模式和集群模式下都能工作 —— 在集群工作进程模式下,`[vwait]` 是空操作(循环线程通过其 tick 循环处理请求),`[http/serve]` 会自动设置 `SO_REUSEPORT`。
底层的 HTTP 机制(路由器、中间件、模板)在 HTTP 服务器 中单独说明。
## 用法
应用文件与单进程模式相比没有变化:
# app.dft def site %{ routes @{ %{ path "" method :GET handler { "hello" } } } } |> [http/router] [http/serve $site :3000] [vwait]
集群由单独的驱动器启动:
# driver.dft [http/serve-cluster 3000 "app.dft"] # n-workers defaults to [os/cpu-count] [vwait] # parent stays alive for reloads + watchers
`http/serve-cluster` 接受:
| 参数 | 类型 | 说明 |
|---|---|---|
| `port` | number | 要绑定的 TCP 端口 |
| `app-path` | string | 每个工作进程加载的应用文件 |
| `n-workers` | number | 可选,默认为 `os/cpu-count` |
| `opts` | map | 可选,例如 `%{ :app-name "api" :watch-dirs @{...} }` |
显式指定工作进程数量:
[http/serve-cluster 3000 "app.dft" 8]
监听内容目录(父进程监听这些目录以进行重新加载;集群模式下各工作进程会跳过自己的 `--watch`):
[http/serve-cluster 3000 "app.dft" %{ :watch-dirs @{"packages/help/manual" "www/pages"} }] [vwait]
父进程(通过 `vwait`)保持存活,为重新加载提供文件监听服务。监听目录默认为应用文件所在目录;传入 `:watch-dirs` 可添加内容树(例如应用渲染的 `docs/*.md` 目录)。
## 控制标准库函数
| 函数 | 用途 |
|---|---|
| `[http/cluster-metrics]` | 完整信息映射:port、app-name、app-path、workers、generation |
| `[http/cluster-status]` | 轻量级健康信息:`:status`、`:healthy`、port、workers、generation |
| `[http/cluster-reload]` | 手动触发新一代重新加载;返回信息映射 |
| `[http/cluster-stop]` | 销毁所有工作进程并清除集群状态 |
## 重新加载(新一代换入)
父进程监听应用文件。文件变化时(或调用 `[http/cluster-reload]` 时):
1. 派生 N 个全新的工作进程运行时(各自绑定 `SO_REUSEPORT` —— 内核立即开始向它们发送 accept)。
2. 关闭旧工作进程的监听 fd(内核停止向它们路由)。
3. 汇合(join)并销毁旧工作进程运行时。
4. `generation` 递增。
切换期间存在一个短暂窗口,新旧工作进程同时处于绑定状态。旧工作进程上的进行中请求会在其运行时被 join 时中断;客户端重试后会命中新的工作进程。
## 工作进程隔离
工作进程之间不共享可变状态。每个工作进程有自己的堆、已加载模块和缓存。必须跨工作进程保持一致的状态必须经由:
- SQLite 文件(WAL 模式) —— 渲染缓存、结构化状态。每个工作进程打开自己的连接;WAL 允许并发读取者而不阻塞写入者。
- `config/*` 注册表 —— 持久化的用户偏好。
- `pub/sub` —— 运行时之间的临时消息传递。
工作进程本地 `def` 中的会话状态只对一部分请求可见,并会在重新加载时丢失。
## 跨工作进程缓存约定
对于渲染缓存或任何跨工作进程的结构化状态,请以 WAL 模式打开 SQLite 文件。缓存文件约定:工作区根目录下的 `.deft/http_<app>.cache`,其中 `<app>` 派生自 app-path 的基名(或 `:app-name` 选项)。
def db [sqlite/open ".deft/http_app.cache"] [sqlite/exec $db "PRAGMA journal_mode=WAL"] [sqlite/exec $db "PRAGMA synchronous=NORMAL"] [sqlite/exec $db "CREATE TABLE IF NOT EXISTS render_cache ( path TEXT PRIMARY KEY, hash TEXT, body TEXT, ts INTEGER )"]
如果缓存键是内容寻址的(包含源码哈希),就永远不会命中过期条目,重新加载时也无需显式失效。如果键是基于路径的,父进程会在文件变化时发布一条 `"cache-invalidate"` 发布/订阅消息;工作进程订阅并删除匹配的行。
## 性能说明
- 每个工作进程独立加载应用、解析模板等。内存开销是单进程部署的 N 倍。
- accept 路径随工作进程数量线性扩展(内核级分发,没有用户空间瓶颈)。
- `n-workers` 推荐使用默认值 `os/cpu-count`。
## 示例:deft-lang.org
deft-lang.org 站点位于 `www/`,由应用服务器提供服务。
应用文件构建路由器,并在开发时以单进程方式提供服务:
# www/app.dft def site %{ prefix "" statics @{ %{ path "/assets" dir "./public" } } routes @{ %{ path "" method :GET handler home/home-page } %{ path "/docs/:slug" method :GET handler docs/docs-page } # ... } } |> [http/router] [http/serve 3002 $site $partials --watch --watch-dirs "packages/help/manual"] [vwait]
集群驱动器是单独的两行文件:
# www/cluster.dft [http/serve-cluster 3000 "www/app.dft" %{ :watch-dirs @{"packages/help/manual" "www/pages"} }] [vwait]
静态 `dir` 值可以是导入/包路径:`%{ path "/manual" dir "deft/help/manual" }` 在启动时解析一次该包的 `manual/` 目录(开发树或已安装的包),然后通过常规 sendfile 路径提供服务。VFS 挂载(`%Local{}` / `%Ssh{}`)也可作为静态目录,并按请求解析(非本地挂载会被跳过)。
帮助语料就是这样把图片与 markdown 放在一起发布的:`packages/help/manual/**` 文件相对文档引用图片(``),或通过语料挂载引用(``),而 `www/components/markdown.dft` 中的文档渲染器会把两者重写为服务 URL 前缀(`/manual`、`/reference`)。站点绝对引用(`/assets/...`)和外部 URL 原样通过。
两条正文路径都会在服务出的 HTML 中生成真正的 `<img>` 标签 —— 服务端渲染器(`markdown-to-html`,由 `/docs-full` 使用)以及来自 `highlight-deft-blocks` 的 `.markdown-raw` div(由 `/docs`、`/status`、`/platform`、`/api/doc/*` 使用)—— 因此图片在 marked.js 运行之前就已存在;`www/public/js/app.js` 会在其客户端 markdown 处理过程中保留这些元素。
## 快速参考
| 形式 | 用途 |
|---|---|
| `[http/serve-cluster $port $app-path ?n-workers ?opts]` | 启动集群(多工作进程,SO_REUSEPORT) |
| `[http/cluster-metrics]` | 集群信息映射 |
| `[http/cluster-status]` | 集群健康映射 |
| `[http/cluster-reload]` | 触发集群重新加载 |
| `[http/cluster-stop]` | 停止集群 |