DSL 引擎

在 AuraBoot 中,业务页面不是手写的 React 组件,而是符合版本化 schema 的 JSON 文档,由契约驱动的引擎在运行时渲染。列表页、表单、详情布局、仪表盘——它们全部是数据,不是代码。

本页解释为什么平台这样设计,契约由哪些部分组成,以及它们如何拼合在一起:页面、block、字段、数据源、动作,以及保证这一切诚实的校验器。

为什么业务页面用 DSL,而不是 TSX

"为这一个页面单独写个 React 组件就好了"——这种诱惑是真实的,也是大多数低代码平台悄悄演变成"带一个设计器的普通代码库"的起点。

AuraBoot 持相反立场:每一个业务页面都由数据描述,渲染器是唯一被允许解释这段数据的东西。

  • 契约失败即报。 一个页面 schema 要么完全符合已发布的契约才能加载,要么直接不加载。不存在"少一个按钮但还能凑合渲染"的中间态。未知的 block 类型直接抛错。已废弃的别名会抛错并在错误信息里告诉你新名字。required 字段缺失会在表单提交之前作为字段级错误暴露出来。
  • 渲染可升级。 因为页面是版本化 schema,平台可以演进渲染器(更好的空态、无障碍修复、性能优化、新的视觉语言)而每一个已有页面都会在下一次部署后自动收益。手写 TSX 只能一屏一屏地改。
  • AI 可读 schema。 工具、自动化、AI 助手读一个页面 schema 的方式跟读一行数据库记录是一样的。一个需要给列表加列、给表单填字段、把新动作接到按钮上的模型,不必去解析 JSX、推断 state、猜测意图。schema 本身就是意图
  • 跨团队同一套心智模型。 插件作者、集成方、运维都在同一个 JSON 形状里工作。一个字段,不管它出现在 CRM 联系人表单里还是出现在维护工单详情页里,都还是同一种字段。

逃生通道是有的,但很窄:当需求确实超出 schema 词汇表时,custom block 可以加载一个手写组件。这个通道存在的目的是让契约不必吞下每一个边角情况,但使用它是一个明确的选择,不是默认行为。

具体来说,DSL 与 TSX 的边界长这样:

需求正确工具
某模型的列表 / 表单 / 详情 / 仪表盘DSL 页面
调用某个 command 的 toolbar 按钮DSL action
基于另一个字段的条件可见DSL visibleWhen
提交时的跨字段校验Command precondition 或 binding rule
平台尚未提供的新图表类型Smart 组件贡献(仍是数据驱动)
确实定制的 widget(例如 3D 站点地图)custom block + 插件组件
新的业务操作Command(ExecutionConfigBindingRule),绝不在页面层 hack

如果你发现自己频繁伸手去用 custom block,那就是一个信号 —— 要么 block 词汇表缺一个本该加进去的原语,要么需求被建模在了错的层。

Schema-first 契约

任何一个页面的契约面由四种对象类型构成:

对象描述什么存在哪里
Page顶层容器:kind / 布局 / blocks / 标题pages.json
Block页面内的一个可渲染单元(table / form section / toolbar)嵌套在 page 内
Field一个绑定的输入或列,含类型、label、校验嵌套在 block 内
Command按钮或动作所调用的命名操作commands.json

这四种都是符合版本化 schema 的 JSON 文档。它们被存储在平台里、随插件一起导出、在导入时被校验。

页面不"包含"代码。它引用模型字段、command code 和 i18n key。渲染器在运行时根据当前的元数据快照解析这些引用。

{
  "id": "lead-list",
  "kind": "list",
  "title": { "en-US": "Leads", "zh-CN": "线索" },
  "blocks": [
    { "blockType": "filters", "fields": [/* ... */] },
    { "blockType": "table", "dataSource": "lead", "columns": [/* ... */] },
    { "blockType": "toolbar", "buttons": [/* commands */] }
  ]
}

两个闸门守护这套契约:

  1. 静态审计,本地提交前就可以跑,捕捉最常见的编写错误:缺 label、未绑定字典、悬挂的 command 引用、未注册的 handler。
  2. 平台 import API 才是真闸门。一个页面 schema 只有在每个字段都能解析到模型、每个 command 都存在、每个 i18n key 都有 fallback、每个 block 都已注册的前提下才会被接受。

静态审计快、便宜,但它不是契约。平台校验器才是。一个通过了静态审计的页面照样可能在 import 时失败——正确的反应是修页面,不是放松校验器。

schema 是版本化的。每个页面声明自己的 schemaVersion,渲染器分派到对应的 parser。版本之间的迁移是显式程序而不是临时改名,这意味着对着旧版本写的插件会继续导入和渲染,直到被显式迁移到新版。向后兼容的改名会带 deprecation 信息;向后不兼容的改动需要迁移步骤。原则很简单:契约可以演进,但演进是可见的。

一份 DSL 导入后变成什么

插件目录里的 JSON 不是静态配置说明书。导入成功后,它会被拆成几类运行时产物,分别被不同系统消费:

输入文件运行时产物消费者
config/models*.json / fields*.json模型与字段元数据、字段类型、字典/引用关系、required/unique/searchable 等约束Dynamic Data、表单渲染、列表查询、import validator
config/pages/*.jsonpage schema、block tree、toolbar/form action、数据源绑定Web DSL Runtime、Page Designer、golden audit
config/commands.jsoncommand 定义、输入字段、状态迁移、preActions / postActionsagent_hintcmd_risk_levelCommand Pipeline、按钮 action、BPM、自动化、AI tool 暴露
config/permissions.json / roles.json权限码、菜单/动作可见性、角色授权Permission runtime、导航、按钮启停、API 鉴权
config/rules.json / rules/*.drl规则定义与可执行 DRLBPM Rule API、command preActions、流程 rule-task
config/processes.json / sla.json流程定义、节点配置、SLA 记录规则Smart Engine、Task Center、SLA monitor
config/i18n.jsonLocalizedText 与错误码文案页面 label、按钮文案、命令错误提示

这也是为什么 AuraBoot 文档反复强调“代码优先”。如果某个页面按钮写了一个不存在的 command code,问题不在按钮文案;它会在导入/渲染/点击任一环节暴露。相反,如果你把逻辑绕到手写 TSX 里,运行时就失去了统一鉴权、审计、规则、SLA 和 AI 风险分级的抓手。

一个典型提交动作的产物链路是:

page toolbar button
  -> command code
  -> permission check
  -> preActions / preconditions
  -> state transition or create/update
  -> postActions(start_process / publish_event / withdraw_process)
  -> audit + event
  -> downstream BPM / automation / AI observation

页面层只负责把“用户意图”指向一个 command。真正的业务不变量应当落在 command、rule、BPM/SLA 或 handler 上,这样无论入口来自 UI、批处理、AI agent 还是外部 API,都不会绕过治理。

BlockRegistry 与 kindPolicy

block 是一个命名的、可渲染的单元。BlockRegistry 是把 blockType 字符串映射到 React 组件、配置 schema、以及"允许出现在哪里"的元数据的唯一真源

dispatcher 按这个顺序解析 block:

  1. Profile override(按租户或按路由的定制化,可选)。
  2. BlockRegistry.get(blockType) —— 运行时注册表。
  3. custom block 落到动态组件 loader。

如果 block 类型未知,渲染器抛错。已重命名的别名(例如在 schema 迁移期间被淘汰的 blockType)会抛错,并在错误信息中告诉作者要重命名成什么。不会有沉默 fallback 到一个空容器。

unknown blockType "data-table"
  -> throw: was renamed to "table" since 2026-03-30

与之配套的契约是 kindPolicy:声明在哪种 page kind 下、哪种父 block 内、允许出现哪些子 block。它是"这种组合到底有没有意义"的静态对应物。

一个 kindPolicy 条目回答这样的问题:

  • form-section 能不能直接出现在 list 页面里?(不能。)
  • chart 能不能出现在 dashboard 里?(能。)
  • sub-table 能不能出现在 detail 的 tab 里?(能。)
  • toolbar 能不能出现在 form 里?(能——底部按钮区。)

kindPolicy 让设计器的调色板准确:用户把一个 chart 拖到 list 画布上时,policy 会说"这里不行",落点拒收。导入时,同一份 policy 会拒收嵌套对其 kind 没有意义的页面。

注册新 block 类型,需要在 BlockRegistry 里加一条(component + 可选的数据整形 normalizeData)并扩展相关的 kindPolicy 条目(把 blockType 加进该 kind 的 allowedBlockTypes)。两边必须一起动:已注册但没有任何 policy 接纳的 block 是不可达的;policy 接纳但未注册的 block 会让渲染器崩。

一条注册条目大致长这样:

BlockRegistry.register('kpi-card', {
  component: KpiCardBlock,
  // 可选:把原始 API payload 整形成组件期望的形状
  normalizeData: normalizeKpiData,
});

与之配套的 kindPolicy 条目(每个 page kind 声明它的 allowedBlockTypes 集合):

allowedBlockTypes: new Set<string>([
  // ...dashboard 已允许的 block 类型
  'kpi-card',
]),

这种对称是被强制的。设计器同时读两边 —— 调色板从注册表来,落点校验器从 policy 来 —— 平台校验器在 import 时也是。契约的两半不可能在没有任何一层抗议的情况下漂移。

Block 分类

五种 page kind 加一组刻意保持精简稳定的 block 类型,已经覆盖了绝大多数业务 UI。

Page kinds.

  • list —— 分页列表,含 filter、toolbar、行操作、保存视图。
  • form —— 创建或编辑单条记录,可选向导式。
  • detail —— 以阅读为主的记录视图,含 header、tabs、子表、时间线。
  • dashboard —— 图表、KPI 和绑定 widget 的网格。
  • composite —— 混合 kind 的多区域复合布局。

核心 block 类型。

  • formform-section —— 单记录输入。form-section 是几乎每个详情编辑页都在用的分组多列表单。
  • form-buttonstoolbar —— 动作组。form-buttons 锚定在表单上;toolbar 锚定在列表或详情 header 上。
  • table —— 分页数据表格,含列、行操作、列级配置。
  • filters —— 列表上方的查询表单,使用结构化的 operator/value 条目。
  • tabs —— 可承载任何其它 block 的 tab 容器,既用于列表状态 tab,也用于详情页分段。
  • sub-table —— 父记录详情页里的子关系,内嵌为表格。
  • chart —— 桥接到图表组件库,通过单一的 chartType 鉴别器支持多种图表类型。
  • description —— 静态或绑定的描述文本,用于帮助说明和只读摘要。
  • field-historyactivity-timeline —— 平台可在合适 model category 的详情页自动注入的审计与活动 block。
  • form-wizard —— 用于较长创建流程的分步多页表单。
  • monthly-grid —— 用于月度分解的日历样式网格。
  • custom —— 逃生通道。通过动态 loader 按名加载组件。

组合是递归的。一个 tabs block 可以包含若干 form-section,每个 form-section 又包含字段。一个详情页可以声明 header toolbar、body tabs、其中一个 tab 里再嵌一个 sub-table。渲染器遍历这棵树;注册表解析每一个节点。

一个典型的详情页这样组合:

detail
├── toolbar             (header 按钮:edit / delete / 自定义命令)
├── form-section        (顶部只读摘要)
└── tabs
    ├── form-section    (可编辑字段,分组)
    ├── sub-table       (行项目,关联记录)
    ├── field-history   (可审计模型上自动注入)
    └── activity-timeline

block 分类刻意保持精简。新增 block 是在确实出现新原语时才加 —— 不为视觉变化而加。视觉变化是渲染器的工作:同一个 table block 在紧凑列表视图和仪表盘 widget 里看起来不一样,是因为渲染器在解释 layout 提示,不是因为 schema 里有两种 block 类型。

ExecutionConfig 与 BindingRule —— 两种动作模式

每一个按钮、行操作、表单提交最终都会触发一个 command。这个 command 怎么跑,由以下两种配置之一描述。

ExecutionConfig 是声明式、纯 JSON 的路径。它挂在 command 上,把整次写操作当作数据来描述:哪个模型、哪种 operation type、字段默认值、状态迁移、preconditions、副作用。大约 80% 的常规 CRUD command —— create / update / delete / 简单状态迁移 —— 都不需要超出这个范围。

{
  "code": "crm:qualify_lead",
  "type": "state_transition",
  "stateField": "crm_lead_status",
  "fromStates": ["new", "contacted"],
  "toState": "qualified",
  "preconditions": [
    { "field": "crm_lead_contact_email", "operator": "is_not_null",
      "message": "Email is required before qualification." }
  ]
}

BindingRule 是命令式路径。一条 binding rule 把一个命名 handler(平台注册的 Spring bean,平台内建或插件提供的)绑到一个 command 上,带显式的 ruleType 与执行顺序(sequence)。当操作跨系统、要做多步编排、调外部服务、或实现的规则超出声明式词汇表时用它。

{
  "commandCode": "crm:qualify_lead",
  "ruleType": "handler",
  "handlerClass": "leadQualifiedNotifier",
  "sequence": 100,
  "enabled": true
}

两种模式共享同一条 command 管道(校验、授权、事务边界、审计、事件发布)。差别只在于:写操作内部到底由谁拍板 —— 是一份 JSON 声明,还是一个注册过的 handler。

两者并存是有意的设计。ExecutionConfig 让简单 command 保持诚实 —— 它们在源码里就能读懂、AI 工具可以安全建议改动、reviewer 不需要读 Java 就能审行为。BindingRule 让平台保持开放 —— 当业务逻辑确实超出了 schema 时,有一个被允许的、被注册的、有顺序的扩展点,而不是一个临时插桩。

写在 commands.json 里的 inline bindingRules 只算文档参考。真正的 binding rule 必须放在独立的 bindingRules.json 里,以一等公民的身份被导入。指向未注册 handler 的 command 会被校验器以明确的错误码拒收。

如何在两种模式之间挑,经验法则:

场景模式
普通 create / update / deleteExecutionConfig
简单守卫下的状态迁移ExecutionConfig
保存时按关联数据自动填字段ExecutionConfig defaults / computed
记录被审批后发邮件BindingRule(AFTER_COMMIT)
调外部 ERP 占库存BindingRule(BEFORE_COMMIT,失败即中止)
横跨两个模型的多步 sagaBindingRule,显式 order
任何需要读它不拥有的数据库的事BindingRule

两种模式在同一个 command 上不互斥。一个 command 可以为核心写声明 ExecutionConfig,同时挂若干 BindingRule 做 pre/post 副作用。平台按文档化的 phase 顺序运行它们,每条 binding rule 都看到同一份 command context。

DataSource 绑定

渲染数据的 block 需要一个 dataSource。契约支持四种逐步增强的动态形态:

模式何时使用形态
静态小枚举的硬编码选项{ "type": "static", "data": [...] }
模型绑定单一模型的列表或详情"dataSource": "lead"(model code)
命名查询服务端预定义、带参数、可复用的查询{ "type": "namedQuery", "code": "leadsByOwner" }
动态参数化 API 调用,常常依赖其它字段{ "type": "api", "url": "/api/.../{paramFromField}" }

任何非平凡场景,命名查询都是优先选项。它存储在服务端、与插件一起版本化、按 code 绑定而非按 URL。这让 SQL 可以在不动每个页面的情况下演进,也让审计和安全层有一个稳定的身份可以挂权限。

动态数据源可以通过有文档说明的占位符语法引用其它字段值、当前用户、租户和 URL 参数。渲染器在发起请求前解析占位符,并拒绝在 required 参数尚未解析时提交请求 —— 又一个 fail-fast 面。

一个依赖联动的 picker 上的动态数据源例如:

{
  "name": "city",
  "type": "select",
  "dataSource": {
    "type": "api",
    "url": "/api/datasource/list?datasourceId=nq:citiesByProvince&province={province}",
    "dependsOn": ["province"]
  }
}

province 变化,渲染器重新发请求。当 province 为空,请求根本不会发,picker 保持禁用并显示清晰提示,而不是发一个半成品 URL 然后默默返回空列表。dependsOn 声明就是让渲染器能在不用猜的情况下做这个决定的依据。

字段渲染契约

一个 field 条目的形状很小、很稳定:

{
  "name": "status",
  "label": "$i18n:lead.status",
  "type": "select",
  "required": true,
  "readonly": false,
  "visibleWhen": "record.stage != 'closed'",
  "dataSource": { "type": "dict", "code": "lead_status" }
}

渲染器按三步解析每个字段:

  1. 类型到组件。 field type 经一个注册表映射到一个 Smart 组件 —— 文本输入、数字、下拉、日期、picker、富文本、附件、子记录引用等。同一个 type 在任何地方都产出同一个组件,这就是字段在没有逐页样式的情况下也"看起来正确"的原因。
  2. 元数据叠加。 required / readonly / visibleWhen 在 type 之上叠加。visibleWhen 是针对当前记录上下文求值的表达式。
  3. 本地化 label 与 help。 label 从来不是裸字符串,它们是 i18n 引用,在渲染时解析。

引用了模型属性的字段会与该属性绑定:校验、默认值、字典引用从模型来,而不是从页面来。页面只在确实有理由时才覆盖。

常见字段类型与它们解析到的组件:

字段类型组件说明
text, textarea文本输入 / 文本域长度和正则来自模型
number, decimal, currency数值输入locale 感知的格式化
date, datetime日期选择通过成对字段支持区间
select, multi-select下拉由 static / dict / 动态源支撑
dict字典选择按 dict code 绑定,不按 URL
reference记录选择跨模型查询,支持联想输入
attachment上传 + 预览存储后端在平台层
rich-text富文本编辑已 sanitize;渲染安全 markup 契约
sub-record内嵌子表单用于一对多组合

字段类型注册表是开放的:插件可以贡献新类型,同样的注册纪律适用 —— 一个注册组件 + 一份配置 schema + 允许出现的上下文。

i18n 与 LocalizedText

页面 schema 中每一个面向用户的字符串,要么是一个 i18n key 引用,要么是一个 LocalizedText 对象。

"title": "$i18n:lead.list.title"
"title": { "en-US": "Leads", "zh-CN": "线索" }

解析走三层:

  1. 页面自身的 LocalizedText map,如果值是对象字面量。
  2. 插件的 i18n 包,按 $i18n: 引用查。
  3. 平台共享的 i18n 包(通用动词、错误消息、通用 label)。

任何一层都解析不到的引用会 fallback 到 key 字符串本身 —— 在 QA 里可见、丑陋、绝不会被错过。schema 内手写的裸语言字符串不是风格偏好,它是被校验器拒收的错误类。页面金标闸门存在的原因之一,就是在发布前把泄漏的语言字符串拦住。

同样的纪律适用于错误信息、按钮 label、表格列头、字典项名、确认提示。任何用户能在 UI 里读到的东西,要么是 i18n 引用,要么是 LocalizedText map。这条规则唯一不适用的地方是日志 —— 日志面向运维,活在源代码里。

失败即报语义

渲染器把契约当契约看。同样的原则贯穿每一层:

  • 未知 block 类型抛错。 不沉默跳过,不空占位。
  • 已重命名 block 类型抛错并带新名字。 迁移是响亮的。
  • 缺 required 字段以字段级错误暴露。 表单无法提交;toast 不是字段级错误的替身。
  • 非法 command code 在 toolbar 层抛错。 指向空气的按钮在设计时就被抓住。
  • 未解析字典 code 在列上抛错。 options 永远加载不出来的表格列是 bug,不是怪癖。

这是有意的。沉默 fallback 把配置错误变成"UI 有点怪"的工单 —— 三个迭代之后才有人提。失败即报把同样的错误变成立即出现的栈,由引入它的人在改动还在脑子里的时候提。

fail-fast 的边界也是明确的:它适用于配置错误,即那些本该在动记录之前就被抓住的错误。它不意味着每一个运行时异常都冒泡给用户。网络错误、数据库瞬时故障、认证挑战都由 runtime 按常规方式处理 —— 可安全重试的就重试,不能的就上报。fail-fast 守的是 schema 契约,不是运维契约。

校验闸门

有两个闸门,做不同的事。

静态审计。 一个本地脚本读插件的 pages.json / commands.json / bindingRules.json / permissions.json 以及 i18n 包,报告编写期的错误:按钮 content 上缺 label、表格列上未绑定的字典 code、bindingRules 中的悬挂引用、表单 i18n 字符串里的 required 缺失、本应有动作的页面 toolbar 为空。它快到可以挂在 pre-commit hook 里跑。

平台 import 校验器。 当插件经平台 import API 导入时,服务端会把每一个引用拿去对实时元数据解析、每一个 command 对已注册 handler 解析、每一个字典 code 对已发布字典解析、每一个字段对模型解析。返回一个结构化的 success / failure 结果。这才是决定页面是否可服务的闸门

审计与校验器有重叠但不等同。审计能抓的是静态可知的部分;校验器额外能抓的是只有服务端才能验的跨对象一致性。一个通过审计但被校验器拒收的页面,是审计的问题,但正确的修复永远在 schema 里,而不是在校验器里。

闸门常见的错误类:

  • S-PAGE-LABEL —— 按钮或字段缺一个非平凡 label。
  • S-PAGE-FORM-REQUIRED —— 模型上标 required 的字段在页面里未镜像。
  • S-PAGE-TABLE-DICT —— 列引用了不存在的字典 code。
  • S-PAGE-BUTTONS —— toolbar 或 form-buttons block 为空。
  • S-EXT-HANDLER —— binding rule 指向未注册 handler。

你不需要背它们。你只会在搞坏什么的时候在终端里看到它们,然后错误信息会告诉你去看哪里。

当校验器拒收一个页面时,会有一种想"让校验器宽松点"的冲动。抵住它。让人头疼的不是校验器,是 schema 写错了。放松校验器的代价是复利的 —— 每一个从更宽松的闸门通过的插件,都让以后再收紧的成本上升一截,因为真页面已经依赖了更宽松的行为。修一次页面,远比永久放松契约划算。

有几条做法可以让闸门帮你而不是烦你:

  • 把静态审计挂在 pre-commit hook 里。提交时抓的错误代价是分钟级;import 时抓的错误要走一整圈环境。
  • 把平台校验器的输出当真源。如果静态审计说零错而校验器说十个错,那是审计不完整 —— 给审计提 issue,但不管怎样先修 schema
  • 校验器报了一个不熟悉的错,错误码通常是最快找到文档的入口。每一个 S-* 码在运维参考里都有说明。
  • 加新 block 或字段类型时,同时加管它的静态审计规则与校验器规则。一个没有强制的原语,就是一个迟早会被滥用的原语。

两个闸门不是冗余,是分层防御。审计在编写期给开发者快速反馈;校验器保证运行时不变量 —— 每一个被服务的页面都是自洽的。

把它们拼起来

单个模型一个完整的 CRUD 切片 —— list / form / detail —— 大约是四种产物 + 几百行 JSON:

  1. 模型定义(字段、类型、字典、引用)。
  2. pages JSON 声明 list / form / detail,每个带它的 blocks。
  3. commands JSON 至少声明 create / update / delete,加上任何状态迁移。
  4. i18n 包提供所有支持 locale 的 label。

这就是全部表面积。没有 controller。没有 service。没有 view 组件。平台之所以能处理路由、分派、校验、审计、事件发布,是因为契约给了它做这些事所需的信息。

从最简单的"stub"页面到生产级页面之间变的不是产物数量 —— 变的是配置深度:visibility 表达式、命名查询替代模型绑定、computed 默认值、多步状态迁移、跨系统副作用的 binding rule。词汇表在扩展;形状保持不变。

这就是为什么这套引擎在规模化时才划算。一个有 100 个插件 / 1000 个页面的平台,不是有 100 种页面架构。它是一种架构,被实例化 1000 次,而校验器站在草率 schema 与上线之间。

企业版扩展

社区版的 DSL 契约 —— page / block / field / command / ExecutionConfig / 简单 BindingRule —— 已经覆盖大多数插件需要的全部表面。商业版沿着同一份契约在若干维度上做了扩展,服务那些超出社区版词汇表的场景:更丰富的 BindingRule 表达式与 pre/post phase、额外的数据源类型(包括以同一套 namedQuery 接口暴露的跨模型联查)、在提交时运行的跨页面与跨字段校验规则,以及页面设计器中的协作编辑能力。这些扩展是叠加式的:一个社区版页面 schema 在商业版里打开时会一字不差地继续渲染。

下一步