CabloyJS 强大之处不仅仅是 IoC,而是全栈资源的寻址体系

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 可以由 sysappctx 等不同容器范围持有;代码既可以用 @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"。当消费方指定 servicemodelcontroller 这样的 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 FoundationZova 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);
  }
}

这段代码不只是"给一个类加两个装饰器"。它把至少三层职责接起来:

  1. Controller 提供 HTTP-facing action;
  2. DTO、Entity 字段和校验共同塑造可机器读取的契约;
  3. @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 字符串更稳妥的原因之一。

第四个坐标:同一份字段契约不必被复制成多份知识

资源寻址的价值并不止于"找到一个接口"。它还使同一份业务契约能在不同消费者中被识别和投影。

例如,一个后端字段可以同时参与:

  1. validation;
  2. OpenAPI generation;
  3. 表单与表格渲染;
  4. 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 SDKOne 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

ModelResourceenableSelector: 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 DiveModel 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 的容器范围决定它的生命周期与共享边界;
  • @UsebeanFullName 支持显式、可解析的协作关系;
  • 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,按下面顺序跟读:

  1. 它属于哪个 suite/module?
  2. 它的业务 Resource identity 是什么?
  3. 哪个 Controller/DTO/Entity 让它进入后端契约?
  4. 哪个菜单、route name 和 params 把它带入页面?
  5. 哪个 Bean/Model owner 用什么 selector 接住它?
  6. bootstrap 后的 resourceApi、Schema、权限和 render metadata 从哪里来?
  7. 这次变更应走 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 Basic revision e8994f63dfffc7be2657cc4c7cb6687a89b63dec

相关推荐
东方小月2 小时前
从零开发一个 Coding Agent(十一):实现 CLI 的 print 模式
node.js·全栈
__zRainy__3 小时前
Node系列 · Node基础:ES 模块化
node.js
深念Y4 小时前
Opencode Event 表写入优化方案
数据库·人工智能·ai·node.js·bug·优化·opencode
ikun778g6 小时前
DeepSeek Harness 本地部署保姆级教程:从 Node.js 24.0.0 安装到 WorkBuddy 一键运行
ai·node.js
To_OC14 小时前
踩了个 TS 的坑之后,我终于把 type 和 interface 掰明白了
前端·react.js·typescript
浮生望15 小时前
Next.js App Router 实战入门:从 SPA 到 SSR 的全栈思维转变
全栈
mCell18 小时前
用 Cordis 从零构建一个 Mini DeepSeek Harness
typescript·agent·deepseek
满栀58519 小时前
状态管理:Redux、Vuex、Pinia 核心区别
前端·javascript·typescript
烂蜻蜓19 小时前
Node.js入门教程(二十三):全局对象
node.js·编辑器·vim