# 应用服务器 应用服务器(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/**` 文件相对文档引用图片(`![diagram](/manual-zh/05-App-Server/images/flow.png)`),或通过语料挂载引用(`![diagram](/manual/images/flow.png)`),而 `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]` | 停止集群 |