应用架构

Wippy 应用不是一棵源码文件树 — 它是一张注册表条目图。代码位于 function.luaprocess.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 = { ... })— 持久层返回的行。

conststypes 不声明任何宿主能力 — 它们是返回一个 table 的纯 library.lua。这是有意为之:你的领域词汇无法执行 I/O,因此不会漂移成业务逻辑,并且无需数据库、无需进程宿主即可进行单元测试。

让这套词汇保持切片私有。跨切片共享的常量和类型放在公共父命名空间中,并通过那里的导入引用 — 绝不复制到每个切片里。

能力按层归类

每个条目在 modules: 中声明它需要的宿主能力。在分层的切片中,它们干净地各归其位:

  • persist/* 声明 sql — 其余任何部分都拿不到数据库访问权。
  • service/* 声明 channel 和进程宿主能力 — 其余任何部分都不会派生或监督进程。
  • api/* 声明端点整理请求所需的能力。
  • 根词汇什么都不声明。

这样做的回报是:任何能力的影响范围都恰好是一层。想知道所有能写数据库的代码,读 persist/ 就够了。依赖倒置不再是抽象原则,而成了一个可以用 grep 验证的属性。

应用与组件

同一种形态可以从单个应用扩展到已发布的库,唯一改变的是由谁来填洞

应用是顶层的、可部署的图。它在根命名空间(惯例为 app)下拥有具体的基础设施 — http.serviceprocess.host、数据库连接 — 并亲自把一切接线组装起来。

组件是挂载宿主中的可发布模块。它无法直接指名宿主的数据库或路由器,因为它并不知道它们。它转而声明一个洞的接口ns.requirement 条目 — 由依赖该组件的宿主来填充。组件内部的结构与应用切片完全相同:同样的分层、同样的词汇、同样的导入方向。唯一的新增是位于其边缘的需求接口。

这是一条光谱,而不是两个类别:

  • 单个应用、内部切片 — 切片位于 src/app/ 之下,通过引用 app:dbapp:processes 直接共享应用的基础设施。不需要需求接口;没有外部事物会挂载它们。(专注的单一服务就是这样构建的。)
  • 多组件组合 — 每个组件都是独立的可发布模块,带有 ns.definitionns.requirement 接口,由宿主通过 ns.dependency 组合。宿主为每个需求(数据库、进程宿主、路由器)填充一次。(由可复用部件组成的平台就是这样构建的。)

选择的依据是:这个切片是否要被你无法控制的东西消费。如果是,就给它一个需求接口并发布。如果不是,就让它直接引用应用的基础设施,省去这些仪式。分层在两端都是不变量;随复用程度伸缩的是打包方式。

需求/依赖机制参见构建组件,锁文件一侧参见依赖管理

为什么是这种形态 {#why-this-shape}

上面的纪律不是风格问题。每条规则对于运行时如何组合并启动一张图都是承重的:

命名空间边界就是注入接缝。由于各层只通过显式的 imports: 链接、且位于不同的命名空间中,ns.requirement 机制才有具体的注入目标 — 宿主把它的数据库指向 persist 层的条目,把它的进程宿主指向 service 层的条目。如果 persist 直接抓取 app:db,组件就永远无法挂载到别的宿主中:没有洞可填。正是分层让组件可迁移

**单向导入保证启动顺序存在。**运行时在启动时解析条目图,必须找到一个拓扑顺序。api → service → persist → root,绝不横向、绝不向上,意味着图在构造上就是无环的。跨切片耦合经由共享父命名空间路由,使各切片保持可独立挂载,而不是纠缠成加载器无法排序的环。

**按层限定能力约束了影响范围。**宿主能力按条目授予。当只有 persist 声明 sql 时,能触达数据库的代码集合就是一个目录,一眼即可审计 — 而不是整个应用的涌现属性。

分层产生可测试性梯度。纯词汇无需任何外部世界即可测试。persist 的测试会触达数据库但不会触达 worker。随后,覆盖整个模块的挂载测试审计单元测试刻意看不到的接缝 — 每个受监督的服务都指向真实进程、每个被派生的 ID 都能解析、每个需求都被填充。只有当各层真正可分离时,才会有这样的梯度。

一句话总结:六边形分层是让需求注入、按层能力限定和无环启动解析同时成立的唯一形态。运行时的组合模型需要端口与适配器的划分才能运作 — 这份纪律换来的是一张能启动的图,和一个别人可以挂载的组件。

另请参阅