应用架构
Wippy 应用不是一棵源码文件树 — 它是一张注册表条目图。代码位于 function.lua 和 process.lua 条目中;将它们连接起来的一切 — 哪个函数响应哪个 HTTP 路由、某个服务监督哪个进程、哪个库导入哪个库 — 都在 _index.yaml 中声明。组织一个应用,意味着决定如何将这张图切分为命名空间,使它在增长过程中保持可组合、可测试、可启动。
本页讲的是布局背后的道理。文件格式、命名、_index.yaml 放在哪里等机械性规则参见 YAML 与项目结构。条目种类本身参见条目种类指南。
单元是切片
按功能组织,而不是按文件类型。一个切片端到端地拥有一项能力 — 它的数据库访问、它的长时运行进程、它的 HTTP 界面,以及它们共享的词汇 — 并位于同一个命名空间前缀之下:
src/app/jobs/ namespace: app.jobs
src/app/auth/ namespace: app.auth
src/app/billing/ namespace: app.billing
另一种做法 — 顶层按 handlers/、models/、services/ 划分 — 会把每个功能散落在整棵树上,并通过位置上的邻近使它们相互耦合。切片将一个功能的影响范围限制在一个文件夹内:你可以阅读、测试或删除它,而无需在整个项目中追踪引用。
切片内的分层
在切片内部,沿着什么会接触外部世界这条轴进行划分。这就是端口与适配器(六边形)架构,以子命名空间的形式表达:
src/app/jobs/ namespace: app.jobs ← shared vocabulary
consts.lua config.lua types.lua
persist/ namespace: app.jobs.persist ← database adapters (sql)
service/ namespace: app.jobs.service ← processes, workers
api/ namespace: app.jobs.api ← http.endpoints
导入只沿一个方向流动,从最外层到最内层:
api → service → persist → { consts, config, types }
切片根(共享词汇)不从自己的子命名空间导入任何东西。子层导入根。没有哪一层会向上回引,并且没有切片会直接导入另一个切片 — 跨切片共享通过公共父命名空间(例如 app.core:types)进行,绝不横向进行。
较小的切片可以省去这些仪式 — 一个包含库和单个端点的 _index.yaml 就足够了。在任何规模下都成立的规则是导入方向,而不是文件夹数量。
共享词汇
三个文件反复出现在结构良好的切片根部。它们承载着每一层都要读取、但没有任何一层本身是的内容:
| 文件 | 承载内容 | 能力 |
|---|---|---|
consts.lua |
状态机、枚举、队列层级、进程的注册表 ID。与数据库 CHECK 约束互为镜像的值。 |
无 |
config.lua |
可用环境变量调节、带代码默认回退的旋钮(env.get(KEY) or DEFAULT),因此值要成为可选项无需任何 env.variable 条目。 |
env |
types.lua |
实体形状(type Job = { ... })— 持久层返回的行。 |
无 |
consts 和 types 不声明任何宿主能力 — 它们是返回一个 table 的纯 library.lua。这是有意为之:你的领域词汇无法执行 I/O,因此不会漂移成业务逻辑,并且无需数据库、无需进程宿主即可进行单元测试。
让这套词汇保持切片私有。跨切片共享的常量和类型放在公共父命名空间中,并通过那里的导入引用 — 绝不复制到每个切片里。
能力按层归类
每个条目在 modules: 中声明它需要的宿主能力。在分层的切片中,它们干净地各归其位:
persist/*声明sql— 其余任何部分都拿不到数据库访问权。service/*声明channel和进程宿主能力 — 其余任何部分都不会派生或监督进程。api/*声明端点整理请求所需的能力。- 根词汇什么都不声明。
这样做的回报是:任何能力的影响范围都恰好是一层。想知道所有能写数据库的代码,读 persist/ 就够了。依赖倒置不再是抽象原则,而成了一个可以用 grep 验证的属性。
应用与组件
同一种形态可以从单个应用扩展到已发布的库,唯一改变的是由谁来填洞。
应用是顶层的、可部署的图。它在根命名空间(惯例为 app)下拥有具体的基础设施 — http.service、process.host、数据库连接 — 并亲自把一切接线组装起来。
组件是挂载到宿主中的可发布模块。它无法直接指名宿主的数据库或路由器,因为它并不知道它们。它转而声明一个洞的接口 — ns.requirement 条目 — 由依赖该组件的宿主来填充。组件内部的结构与应用切片完全相同:同样的分层、同样的词汇、同样的导入方向。唯一的新增是位于其边缘的需求接口。
这是一条光谱,而不是两个类别:
- 单个应用、内部切片 — 切片位于
src/app/之下,通过引用app:db、app:processes直接共享应用的基础设施。不需要需求接口;没有外部事物会挂载它们。(专注的单一服务就是这样构建的。) - 多组件组合 — 每个组件都是独立的可发布模块,带有
ns.definition和ns.requirement接口,由宿主通过ns.dependency组合。宿主为每个需求(数据库、进程宿主、路由器)填充一次。(由可复用部件组成的平台就是这样构建的。)
选择的依据是:这个切片是否要被你无法控制的东西消费。如果是,就给它一个需求接口并发布。如果不是,就让它直接引用应用的基础设施,省去这些仪式。分层在两端都是不变量;随复用程度伸缩的是打包方式。
为什么是这种形态 {#why-this-shape}
上面的纪律不是风格问题。每条规则对于运行时如何组合并启动一张图都是承重的:
命名空间边界就是注入接缝。由于各层只通过显式的 imports: 链接、且位于不同的命名空间中,ns.requirement 机制才有具体的注入目标 — 宿主把它的数据库指向 persist 层的条目,把它的进程宿主指向 service 层的条目。如果 persist 直接抓取 app:db,组件就永远无法挂载到别的宿主中:没有洞可填。正是分层让组件可迁移。
**单向导入保证启动顺序存在。**运行时在启动时解析条目图,必须找到一个拓扑顺序。api → service → persist → root,绝不横向、绝不向上,意味着图在构造上就是无环的。跨切片耦合经由共享父命名空间路由,使各切片保持可独立挂载,而不是纠缠成加载器无法排序的环。
**按层限定能力约束了影响范围。**宿主能力按条目授予。当只有 persist 声明 sql 时,能触达数据库的代码集合就是一个目录,一眼即可审计 — 而不是整个应用的涌现属性。
分层产生可测试性梯度。纯词汇无需任何外部世界即可测试。persist 的测试会触达数据库但不会触达 worker。随后,覆盖整个模块的挂载测试审计单元测试刻意看不到的接缝 — 每个受监督的服务都指向真实进程、每个被派生的 ID 都能解析、每个需求都被填充。只有当各层真正可分离时,才会有这样的梯度。
一句话总结:六边形分层是让需求注入、按层能力限定和无环启动解析同时成立的唯一形态。运行时的组合模型需要端口与适配器的划分才能运作 — 这份纪律换来的是一张能启动的图,和一个别人可以挂载的组件。