IoC 解决"运行时怎样取得协作者";全栈资源寻址解决"这份能力属于谁、从哪里进入、怎样跨层定位,以及怎样保持同一份业务语义"。
在一个刚起步的应用里,找到功能通常很直接:搜索一个组件,跟着 import 找到请求函数,再追到后端 Controller。可当系统逐渐变成多个业务域、多个模块、后台资源页、Web 自服务页、SSR、OpenAPI 契约与前端渲染资源并存的全栈应用时,真正困难的往往不再是"能否注入一个 Service"。
例如,一个"学生"能力至少会遇到这些问题:它属于哪个业务域?哪个模块拥有它?后端怎样暴露它?菜单怎样进入它?通用资源页怎样知道当前正在展示它?前端请求、Schema、权限、表单、缓存和自定义渲染又怎样仍然指向同一份业务语义?
IoC 对这些问题很重要:它提供对象所有权、生命周期、作用域与依赖解析。但 Cabloy 更值得关注的地方在于,IoC 并非孤立存在。它与 suite、module、Bean identity、业务 Resource、OpenAPI、路由参数、selector-backed Model 和渲染元数据一起,形成了一组受约束、可映射的寻址坐标。
这不是"一个万能字符串贯穿所有层",也不是零配置魔法。Vona 和 Zova 有各自独立的运行时容器;前后端之间靠契约、元数据、生成物与构建产物协作。本文把"全栈资源寻址体系"作为一种理解 Cabloy 架构的视角,而不是一个取代既有术语的官方 API 名称。
先把 IoC 与寻址分开
IoC 的核心问题是:谁创建、持有、销毁并提供一个运行时能力?
在 Zova 中,Bean 可以由 sys、app、ctx 等不同容器范围持有;代码既可以用 @Use 注入,也可以通过容器和 Scope 做 dependency lookup。这样的模型让状态共享、生命周期、跨模块协作不必分别退回到互不相干的机制。IoC and Beans 对这套模型有完整说明。
而寻址的问题更宽一些:当某一层需要找某个东西时,它以什么身份表达目标、由谁解析、会得到哪一种资源?
以当前仓库中的 Student 样本为例,下面这些值彼此关联,却绝不能互换:
| 层级 | 示例 | 它回答的问题 |
|---|---|---|
| 业务域 / suite | a-training |
这组能力属于哪个业务域? |
| 功能模块 | training-student |
哪个能力边界拥有 Student? |
| Bean 完整地址 | training-student.service.student |
容器中要解析哪个运行时 Service? |
| 业务资源身份(onion name) | training-student:student |
当前讨论的是哪个模块限定的业务资源? |
| 路由入口 | :resource/:id/:formScene? |
当前进入的是哪个页面场景? |
| HTTP API 路径 | /api/training/student |
请求应发送到哪里? |
| Resource Model selector | training-student:student |
通用资源所有者当前为哪一个资源维护状态? |
| 渲染资源 | training-student:formFieldLevel |
哪个前端场景能力负责字段呈现? |
可以把它理解成地图上的不同坐标系:行政区、街道、楼号、导航入口、配送地址和室内房间号都与"同一地点"有关,但解决的不是同一个问题。把它们全都叫作"ID",反而会掩盖边界。
接下来,我们沿着 training-student:student 这条线走一遍。
第一个坐标:suite 和 module 先回答"谁拥有它"
Cabloy 将 suite 定义为业务域级的组合边界,将 module 定义为该业务域内部的功能实现边界。换句话说:
- suite 回答"这组能力属于哪个业务域?"
- module 回答"这个业务域中,哪一项能力拥有这份实现?"
在 Student 样本中,业务域是 a-training,具体模块是 training-student。Vona 与 Zova 都有对应的 suite/module 结构,因此后端 Controller、Service、Model、Entity/DTO,与前端页面、API、Model、metadata 可以沿着相同的业务边界演进。这里共享的首先是业务坐标,而不是浏览器和 Node.js 之间的一块共享内存。
这也是为什么模块名比目录位置更重要:目录可以重排,具体实现文件也可以迁移;但一个对外稳定的模块命名空间可以继续承载 Service、Model、API、locale、error、Resource 与前端 render resource 的逻辑身份。关于 suite 与 module 的职责边界,可参阅 Suites and Modules。
第二个坐标:Bean 完整地址与 Scope 门面
进入运行时后,同一个模块还需要定位不同能力家族。Vona 的多数 scene-based Bean 使用如下完整身份:
text
{moduleName}.{sceneName}.{beanName}
例如:
text
training-student.controller.student
training-student.service.student
training-student.model.student
其中 sceneName 区分 controller、service、model 等能力家族。因此,下面两件事应当分开理解:
training-student:student是模块限定的 onion name;training-student.service.student才是场景已经明确的 Bean 完整地址。
也就是说,onion name 并不是"省略几个字符后的全局 Bean key"。当消费方指定 service、model 或 controller 这样的 scene 后,框架才能把它解析到对应的完整 Bean 地址。
业务代码通常不需要到处手写完整地址。Scope 提供了模块资源的类型化访问门面:
ts
// 当前模块中的业务编排
const students = await this.scope.model.student.select();
// 已知的跨模块目标
const sku = await this.$scope.commerceCatalog.model.sku.getById(id);
Scope 的价值不是另造一套 ID,而是把"容器层面的完整地址"提升为"沿模块资源目录导航"的日常写法。后端 Scope 可组织 service、model、entity、config、locale、error 等资源;前端 Scope 则常见地提供 config、constant、locale、error、api、apiSchema 等资源门面。Vona Backend Foundation 与 Zova Module Scope 都强调了这种分工。
这里还有一个很重要的限制:Scope lookup 只能解析已经进入当前应用组合的模块资源。它不会因为字符串写对了就安装、加载或排序一个不存在的模块。模块依赖、suite/application composition 和运行时 lookup 是相关但不同的关系;前者解决可用性、顺序与版本,后者才解决"从已组合模块中取出资源"。
第三个坐标:@Resource() 把业务身份连接到 HTTP 契约
Student 的后端 Controller 使用了 @Controller('student') 与 @Resource()。下面代码根据当前实现裁剪,只保留本文需要的角色:
ts
@Controller('student')
@Resource()
export class ControllerStudent extends BeanBase {
@Web.get()
@Api.body(DtoStudentSelectRes)
async select(
@Arg.filter(DtoStudentSelectReq) params: IQueryParams<ModelStudent>,
): Promise<DtoStudentSelectRes> {
return await this.scope.service.student.select(params);
}
}
这段代码不只是"给一个类加两个装饰器"。它把至少三层职责接起来:
- Controller 提供 HTTP-facing action;
- DTO、Entity 字段和校验共同塑造可机器读取的契约;
@Resource()将该 Controller 的 onion identity 注册为业务 Resource。
Vona 在 Controller 装饰器处理时,会从 Bean 完整地址取得 onion name,并结合 module 与 controller path 计算 API path,写入 resource-to-route 的映射表。于是 training-student:student 可以作为稳定的业务资源身份,而 HTTP 路径则是由后端路由元数据导出的传输地址。
所以请避免说:
text
training-student:student === /api/training/student
更准确的是:
text
training-student:student
──由 Resource 注册与 bootstrap 元数据解析──▶
/api/training/student
前者用于表达"哪个业务资源",后者用于表达"请求发送到哪里"。这也是把资源身份写进前端状态、权限、Schema 或路由上下文时,比在每处散落 HTTP 字符串更稳妥的原因之一。
第四个坐标:同一份字段契约不必被复制成多份知识
资源寻址的价值并不止于"找到一个接口"。它还使同一份业务契约能在不同消费者中被识别和投影。
例如,一个后端字段可以同时参与:
- validation;
- OpenAPI generation;
- 表单与表格渲染;
- serialization 或 desensitization。
以 training-student 的 Student 实体中的 mobile 为例,下面是当前源码的裁剪版:
ts
@Api.field(
v.title($locale('Mobile')), // OpenAPI 字段标题
v.required(), // validation
v.min(11), // validation
v.serializerReplace({ // 响应序列化时脱敏
patternFrom: /^(\d{3})\d{4}(\d+)$/,
patternTo: '$1****$2',
}),
ZovaRender.order(3), // 通用表格/字段的显示顺序
)
mobile: string;
这个声明不是孤立地"描述一个字符串"。它沿着 DTO 与 Resource Schema 被投影:创建、编辑和查看 DTO 的布局把 { type: 'field', name: 'mobile' } 放进 Student Profile;列表行 Schema 继承该字段,并依据 order(3) 排列。因此在没有专用 Renderer 时,通用表单使用默认 Input,通用表格使用默认文本 Cell;而带有 @Core.serializer() 的查询响应会把 13812345678 输出为 138****5678,持久化的原始值并未被改写。
| 能力 | mobile 的声明或投影 |
被谁消费 |
|---|---|---|
| validation | v.required()、v.min(11) |
请求/DTO 校验边界 |
| OpenAPI generation | @Api.field(...)、标题与字符串 Schema |
OpenAPI 及其下游 SDK/Schema 消费者 |
| 表单与表格渲染 | DTO layout 中的 field: mobile,以及 ZovaRender.order(3) |
通用 Resource UI 的默认 Input 与文本 Cell |
| serialization / desensitization | v.serializerReplace(...) |
返回客户端前的序列化结果 |
如果该字段需要专用控件,而非默认 Renderer,则可以像同一实体的 level 一样额外声明 ZovaRender.field('training-student:formFieldLevel', ...) 和 ZovaRender.cell('training-student:level', ...)。重点不是要求每个属性都承载四类能力或都使用自定义组件,而是这些能力能够围绕同一个字段契约按需组合。
这并不意味着每个字段都会自动长出完美的 UI,也不意味着所有呈现策略都应写回后端。它表达的是:字段的业务语义、数据契约和允许暴露的结果不必在后端 DTO、前端请求类型、表单规则、表格列和响应后处理中各复制一遍,再靠人工保持一致。
在这条forward contract chain 中,后端 Controller、DTO、Entity 与校验规则是 source truth;Vona 生成 OpenAPI,Zova 再生成或消费 SDK/schema 相关契约材料。当前的推荐做法是:当后端契约改变时,先让契约真相向前传播,而不是手改多个前端副本。Backend OpenAPI to Frontend SDK 与 One Contract Surface, Four Uses 给出了这条链的边界。
第五个坐标:路由选择 UI 场景,Resource identity 选择业务上下文
通用 Resource 页面说明了"页面地址"与"业务资源身份"为什么需要拆开。
rest-resource 定义的是带动态参数的通用路由:
ts
export const routes: IModuleRoute[] = [
{ name: 'resource', path: ':resource', component: ZPageResource },
{ name: 'entryCreate', path: ':resource/create', component: ZPageEntryCreate },
{ name: 'entry', path: ':resource/:id/:formScene?', component: ZPageEntry },
];
动态 params 路由显式声明 name,由此建立页面参数的类型契约。路由回答的是:当前是资源列表、创建表单,还是某条记录的某种表单场景?但 route 本身仍不知道 Student、Course 或其他资源的业务含义。
Student 的 SSR 菜单正好补上这个上下文:
ts
@SsrMenu({
items: {
student: {
link: 'presetResource',
meta: {
params: {
resource: 'training-student:student',
},
},
},
},
})
export class SsrMenuStudent extends BeanBase {}
这里的菜单并不直接"渲染 Student 页面",也不构成权限授权。它做的是把 resource 参数交给预设的通用 Resource 路由:
text
SSR menu
→ presetResource
→ :resource route param
→ Page Controller 的 $params.resource
因此,URL/route 找到的是页面场景;$params.resource 带入的是业务资源上下文。路由名、路由参数、菜单 metadata 和权限仍是四类各自需要核验的对象。
第六个坐标:Resource identity 变成 selector-backed Model owner
通用页面接到参数之后,并不会为每个资源复制一套页面 Controller。它会向同一个通用 Model 注入当前资源身份:
ts
@Controller()
export class ControllerPageResource extends BeanControllerPageBase {
@Use({ beanFullName: 'rest-resource.model.resource' })
get $$modelResource(): ModelResource {
return usePrepareArg(this.$params.resource, true);
}
protected async __init__() {
await $QueryEnsureLoaded(() => this.$$modelResource.apiSchemasSelect.sdk);
}
}
这里有两个不同层次的地址:
text
通用 Model Bean:rest-resource.model.resource
当前 selector:training-student:student
ModelResource 以 enableSelector: true 注册。它不是一个模糊的全局 CRUD singleton,而是一个 selector-backed owner:ModelResource(training-student:student) 与 ModelResource(other-module:other-resource) 可以共享实现,却拥有不同的资源上下文。
初始化时,owner 会以资源身份获取 bootstrap 信息,得到并保存解析后的 resourceApi;随后它把 permissions、form provider、view/create/update/filter/row/page schema,以及列表和单项的 query/mutation 能力集中在同一个 owner 上。
概念链如下:
text
$params.resource
→ ModelResource(resource selector)
→ bootstrap(resource)
→ resourceApi
→ schemas / permissions / forms / queries / mutations
这有两个实际好处:
- 页面不必把同一个 API path、Schema 获取规则、权限读取方式和缓存失效规则复制到多个 Controller;
- 自定义的同资源操作可以写成薄薄的语义 facade,并复用既有 Resource owner,而不是再造一个互相竞争的 query cache owner。
例如 training-student 的前端 Model 可以以 training-student:student 初始化通用 owner,再为 summary(id) 或 deleteForce(id) 这样的业务动作补充语义入口,同时复用该 owner 的 item query/mutation 与失效边界。
还要再细分一次:调用点传入的 ['select', ...] 或 ['item', id, action] 只是 logical query key。有效的缓存身份还会由 Model Bean identity、selector 与 logical key 共同构成。因此,短 query key 不是跨整个应用天然唯一的地址。Model Resource Internals Deep Dive 和 Model State Guide 解释了这层 owner 与状态身份。
第七个坐标:Schema metadata 再选择具体的 UI 能力
通用 Resource Page 的职责保持得很薄:加载当前资源的 Schema,读取 schemaRow?.rest?.blocks,再根据 block metadata 渲染对应内容。页面 shell 负责"当前上下文是什么";Schema/metadata 决定"这个资源在这个场景应组合哪些 block"。
那么,schemaRow?.rest?.blocks 从哪里来?它不是前端页面临时拼出来的数组。对于列表页,当前 Student 样本把它定义在列表行 DTO 的 @Dto({ blocks }) 中:
tsx
import type { IDecoratorDtoOptions } from 'vona-module-a-web';
import { $Dto } from 'vona-module-a-orm';
import { Dto } from 'vona-module-a-web';
import { ZovaRender } from 'zova-rest-cabloy-basic-admin';
import { ModelStudent } from '../model/student.ts';
@Dto<IDecoratorDtoOptions>({
blocks: [
ZovaRender.block('basic-page:blockPage', {
blocks: [
ZovaRender.block('basic-page:blockFilter', {
formFieldLayout: { inline: true },
// 真实样本还在这里声明 filter 的 form layout
}),
ZovaRender.block('basic-page:blockToolbarBulk', {
actions: [ZovaRender.tableActionBulk('basic-table:actionCreate')],
}),
ZovaRender.block('basic-page:blockTable'),
ZovaRender.block('basic-page:blockPager'),
],
}),
],
})
export class DtoStudentSelectResItem extends $Dto.get(() => ModelStudent) {
// 列表行字段,以及各字段的 ZovaRender.* 元数据
}
这段代码经过裁剪:它保留了最关键的结构------根 block basic-page:blockPage 再组合 filter、批量工具栏、table、pager 四类已注册的前端 block resource。它并不表示任何 DTO 都要拥有这些 block,也不表示这些 block 的前端实现存在于 Vona;DTO 只是在后端可生成的契约 metadata 中声明"当前资源列表页应选择哪些呈现能力"。
这条链可以完整地接住 blocks 的来源:
text
DtoStudentSelectResItem 的 @Dto({ blocks })
→ Vona 将 blocks 合并为 DTO OpenAPI metadata 的 rest.blocks
→ GET select 响应的 data.list.items 引用该行 DTO Schema
→ ModelResource.apiSchemasSelect.row 解析为 schemaRow
→ schemaRow?.rest?.blocks
→ ControllerPageResource 逐个调用 ZovaJsx.render(...)
换句话说,schemaRow 是"当前 select 响应行 Schema";rest.blocks 是附着在这份 Schema 上的页面组合声明。ZovaRender.block('basic-page:blockTable') 里的 basic-page:blockTable 又是另一种模块限定的渲染资源身份,由 Zova 一侧已经注册的实现来解析。后端选择它,前端执行它,两端并没有跨进程直接调用彼此的对象。
因此,Cabloy 的资源寻址并不止到 API:表格单元格、表单字段、图片场景、Behavior 等前端能力也可以以模块限定、scene-aware 的资源身份被选择。例如字段元数据可以引用某个 training-student:formFieldLevel,由前端注册的相应场景能力完成渲染。
这不是说后端会执行前端组件。更准确地说,后端契约或 metadata 可以声明"此处应选择哪一种前端呈现资源";前端仍拥有 renderer 的实现与运行时。对于 frontend-owned routes、components、icons、table cell 或 form field 等事实,又会存在反向的 contract handoff:先刷新前端 metadata/build output,再使 Vona 消费同步后的本地依赖结果。Frontend Metadata Back to Backend 描述了这条 reverse chain。
于是可以看到两条不同方向的链:
text
Forward:Vona Controller / DTO / Entity
→ OpenAPI
→ Zova SDK / schema / API consumers
Reverse:Zova route / component / renderer / metadata
→ frontend metadata or build output
→ Vona-side metadata, tooling and type consumers
它们共同构成全栈协作,但都不是"保存任意一端源码后,另一端自动知道一切"。每条链都有自己的 source truth、生成步骤、共享 handoff 和验证点。
IoC 在这张地图中的真实位置
说 Cabloy "不仅仅是 IoC",不是贬低 IoC。恰恰相反,IoC 是许多坐标能够真正运作的运行时基础:
- Bean 的容器范围决定它的生命周期与共享边界;
@Use与beanFullName支持显式、可解析的协作关系;- Scope 让日常业务代码以模块资源门面访问能力;
- 参数化 injection 让通用
ModelResource能以当前 Resource selector 被复用; - 生命周期与 Model query/cache 机制让"找到了资源"进一步变成"由谁拥有状态"。
但若只把它看成"依赖注入比较方便",就会漏掉更大的协作结构:业务域和模块先给能力一个归属;Bean/Scope 给运行时能力一个解析边界;Resource 与 OpenAPI 给数据契约一个传输投影;route params 给页面带入上下文;selector 与 effective query key 又把状态隔离到当前资源。
IoC 让能力可以被取得;资源寻址让这些能力跨过模块、契约、路由、状态与渲染之后,仍然知道自己属于哪一份业务语义。
什么时候值得采用,什么时候不必强行套入
一套多层寻址体系当然有学习成本:开发者需要区分 suite、module、Bean、scene、Resource、route、selector、Schema 与生成物;出现问题时,也要从正确的坐标开始排查,而不是只搜索组件文件。
它通常更适合长期演进、模块较多、存在多个消费者或需要明确 contract loop 的业务系统:
| 场景 | 优先选择 | 原因 |
|---|---|---|
| 单页、临时的 UI 值 | page/controller-local state | 不需要引入可复用资源语义。 |
| 可复用的异步、持久化或跨页面状态 | Model Bean | 状态需要明确的身份、生命周期与缓存/持久化边界。 |
| Admin 风格的实体,具有 Schema、权限、表单、列表/单项生命周期 | 既有 ModelResource 或薄 facade |
复用一个 Resource owner 与统一的 invalidation policy。 |
| 同一资源上的自定义操作 | existing owner 上的语义 facade | 避免制造竞争的请求、状态和缓存所有者。 |
| 面向终端用户的独特 Web 自服务体验 | 专用 API/DTO、Model 与页面状态 | 同一持久化资源不等于必须共享同一种前端体验。 |
| 包裹既有渲染目标的横切规则 | Behavior | 这是渲染边界问题,不是业务资源所有权问题。 |
特别是最后两行:一份业务资源可以在 Admin 与 Web 中共享领域和持久化边界,却使用不同的 API/DTO、服务器 scope、状态 owner 和页面架构。好的资源寻址不是把所有东西强行收编到一个通用 CRUD 页面,而是让每个边界都能清楚地表达"它正在为谁服务"。
用七个问题跟读一条资源链
如果想从现有项目中验证这套心智模型,可以任选一个已注册 Resource,按下面顺序跟读:
- 它属于哪个 suite/module?
- 它的业务 Resource identity 是什么?
- 哪个 Controller/DTO/Entity 让它进入后端契约?
- 哪个菜单、route name 和 params 把它带入页面?
- 哪个 Bean/Model owner 用什么 selector 接住它?
- bootstrap 后的
resourceApi、Schema、权限和 render metadata 从哪里来? - 这次变更应走 forward chain、reverse chain,还是需要先排查生成/本地依赖 drift?
这套问题的价值不在于记住更多名字,而在于把"页面为什么能工作"拆成一段段可验证的解析过程。你不再只问"这个组件在哪里",而是可以问:这份资源的业务身份是什么?它在当前层被怎样映射?谁拥有它的状态和语义?
小结
Cabloy 并非用一个全局 ID 统治所有层。更准确的描述是:它以 module 为重要命名锚点,通过 suite/module 归属、Bean scene、Scope、业务 Resource、OpenAPI、route params、Model selector、query cache 与 render metadata,将多种地址空间连接成可追踪的全栈链。
IoC 是这条链的运行时基础之一,但不是全部。真正的工程收益在于:当业务能力穿过前后端、生成物与不同运行时,它不必退化为散落的 URL、重复的 import、手写的请求与彼此失联的状态;每一层都保有自己的职责和地址,同时能够映射到同一份业务语义。
延伸阅读
概念与工作流
- Cabloy:Suites and Modules
- Cabloy:Vona Backend Foundation
- Cabloy:Zova IoC and Beans
- Cabloy:Zova Module Scope
- Cabloy:Backend OpenAPI to Frontend SDK
- Cabloy:Frontend Metadata Back to Backend
- Cabloy:Model Resource Internals Deep Dive
本文核验的源码样本
本文的实现型说明核验于 Cabloy Basic revision e8994f63dfffc7be2657cc4c7cb6687a89b63dec。