运行时 Schema 渲染

把业务建模写成一份 JSON Schema,Known 在运行时直接把它渲染成列表、表单与增删改查——字段支持枚举下拉、外键下拉、一对多子表,页面可按 blocks 区块树所见即所发、并按 menu 驱动导航,还能按 layout 切换控制台 / 标签 / 上下 / 两栏 / 栅格五种布局;你要写的代码只剩后端的自定义业务逻辑(生命周期钩子)。

最后更新

状态:预览。 运行时渲染引擎已内置进框架(Known/Runtime/,命名空间 Known.Runtime)——菜单装配、动态建表、CRUD 读写与生命周期钩子派发都由框架自动完成,随框架发布的每个应用都能直接用。目前它覆盖"标准 CRUD + 钩子",并已支持枚举 / 外键 / 一对多子表三类进阶字段,以及页面键控(一份 Schema 可声明一棵 blocks 区块树、运行时所见即所发地直接渲染)与 menu 驱动导航;仍属预览能力,接口与细节在后续版本可能调整。想先跑起来,克隆仓库后运行 KnownWeb,登录后进侧栏「运行时模型 / 商品中心」即可看到效果。示例 Schema 放在示例项目的 KnownApp/Schemas/ 下——order.schema.json(实体级五种声明式布局)与 shop.schema.json(枚举 / 外键 / 子表 + 页面键控 / 菜单 / 区块树的进阶闭环示范),配套后端钩子 KnownApp/Runtime/OrderHooks.cs。

Known 的默认主张是元数据驱动:业务模型是数据,不是代码。对大量标准的"一张表 + 列表 + 增删改查"场景,你只需要描述实体长什么样,Known 在运行时就把这份描述直接渲染成一个能用的页面——不生成、不落盘任何页面代码。当某个实体需要偏离标准的定制逻辑时,你写的也不是页面,而是挂在保存/删除节点上的后端钩子。

一句话对比三种拿下一个 CRUD 页面的方式:

方式 产出 你要写的代码 适合
手写 一个 .razor + 服务 + 实体 全部 页面形态高度自定义
运行时渲染(本页) 无代码产物,运行时按 Schema 现挂 只有后端业务钩子(可选) 标准 CRUD、想少维护产物
代码生成 / Eject(见下) 一整套可编译的 .cs / .razor 生成后你自己接管 要把代码拿到手长期改

一个 Schema 就是一个页面

模型的唯一真相源是一份 JSON。下面这份定义了两个实体——订单与客户:

{
  "name": "OrderDemo",
  "entities": [
    {
      "name": "Order",
      "displayName": "订单",
      "table": "biz_Order",
      "fields": [
        { "name": "OrderNo",  "label": "单号", "type": "string", "required": true, "maxLength": 50 },
        { "name": "Customer", "label": "客户", "type": "string", "maxLength": 50 },
        { "name": "Quantity", "label": "数量", "type": "int",    "required": true },
        { "name": "Amount",   "label": "金额", "type": "decimal","required": true },
        { "name": "Status",   "label": "状态", "type": "string", "maxLength": 20 }
      ]
    },
    {
      "name": "Customer",
      "displayName": "客户",
      "table": "biz_Customer",
      "fields": [
        { "name": "Name",  "label": "姓名", "type": "string", "required": true, "maxLength": 50 },
        { "name": "Phone", "label": "电话", "type": "string", "maxLength": 30 },
        { "name": "Vip",   "label": "会员", "type": "bool" }
      ]
    }
  ]
}

就凭这份 JSON,运行时会自动给出:

  • 侧栏菜单:每个实体自动挂一项——框架的 AuthProvider 会把 Schema 实体追加进导航的「运行时模型」分组,无需 [Menu]、无需登记路由,也无需你在应用里写任何装配代码。
  • 列表页:按字段生成列(label 作列头),带服务端分页、按第一个文本字段的关键字搜索、行内编辑/删除。
  • 表单弹窗:按字段类型映射控件——string→Input、int/decimal→Number 步进器、bool→Switch、datetime→DateInput;required 显示必填标记,maxLength 约束输入。此外还支持 enum(下拉选项)、ref(外键下拉,自动取被引实体记录、列表把 Id 还原成可读文本)与 children(一对多子表内嵌编辑)三类进阶字段,见下节。
  • 建表:宿主启动初始化数据库时,框架按 Schema 非破坏性建表(只 CREATE TABLE / 补列,不删改已有列),沿用框架既有的数据红线;应用未发布任何 *.schema.json 时这一步安全跳过。

字段选项与代码生成用的是同一套:name(合法标识符)、label、type(别名宽松匹配)、required、maxLength、hideInList(列表隐藏该列、仍进表单)。JSON 允许 // 注释与尾逗号。

加一个实体 = 改一份 JSON。 页面、菜单、表结构、表单控件全部随之长出,你不新增任何页面文件。

页面布局:不止一种"列表 + 弹窗"

默认布局(layout 留空或 "console")就是上面那个"整页表格 + 行内编辑/删除弹窗"的标准 CRUD 控制台。但真实业务里,一个实体页常常要并列几块:主从明细、看板、多视角标签……于是运行时把页面渲染收敛到了框架的页面区块引擎(PageBlock / PageRenderer / PageComponents)——和低代码页面设计器共用同一套区块树与渲染器。一份 Schema 只需再声明 layout 与 views,运行时就把数据块拼成对应的页面形态,仍然不生成任何代码。

{
  "name": "Order", "displayName": "订单", "table": "biz_Order",
  "layout": "tabs",                       // console | tabs | top-bottom | left-right | grid
  "fields": [ /* …列定义同上… */ ],
  "views": [                              // 非 console 布局据此装配;console 忽略本清单
    { "component": "table", "title": "订单列表", "mode": "list" },
    { "component": "stats", "title": "经营总览" },
    { "component": "chart", "title": "按状态数量", "settings": { "type": "bar", "group": "Status", "metric": "count" } }
  ]
}

五种布局与它们的组合方式:

layout 形态 views 如何摆放
console(默认) 整页 CRUD 控制台 忽略 views,自动装配一个整页表格块(外观即上一节)
tabs 多标签页 每个 view 就是一个标签,标题取 view.title
top-bottom 上下看板 按 ratio(默认 0.5)把 views 切成上下两区,每区依次纵排
left-right 两栏主从 按 ratio 切成左右两区——典型是左区 table 配 mode: "master"、右区 form
grid 多块栅格 每个 view 一格并排入 CSS 网格,列数取 columns(留空自适应铺满)

数据块(view.component)有四种,均以 schema- 前缀注册进区块引擎、可与布局容器自由组合:table(自治 CRUD / 主从主区)、form(独立或主从从区的编辑表单)、stats(记录总数 + 各数值列合计的指标卡)、chart(后端聚合分组桶画出的柱/折/饼)。每个块还能带各自的 settings:表格 columns(列子集)、图表 group / value / metric(count / sum / avg / min / max)/ type、指标 fields 等;entity 缺省取本页实体,跨实体组合时显式指定。

两栏主从的联动由宿主播撒的一个 SchemaPageContext 级联贯穿:左表 mode: "master" 点某行 → 右表单载入该行编辑、点"新增" → 右表单转新建态;任一块保存/删除成功后广播一条数据变更,同页的表格重载、指标卡与图表重算聚合,各自响应、不重复拉数。示例 KnownApp/Schemas/order.schema.json 里已铺好五种布局(订单=标签、产品=两栏主从、区域=栅格、报表=上下、客户=默认控制台),跑起 KnownWeb 登录后进侧栏「运行时模型」逐一点开即可所见。

布局本身仍是声明式的一份 JSON——换布局改 layout 一个字段、换组合改 views 数组,页面文件数恒为零。当某页需要区块引擎覆盖不了的复杂交互时,再按下一节 eject 成代码接管。

字段的三种进阶:枚举 · 外键 · 一对多子表

纯文本列只能覆盖最朴素的情形。shop.schema.json 示范了三类更贴近真实业务的字段,运行时与表单编辑器(FieldEditor)据此自动切换控件、无需任何代码:

  • enum 枚举:字段加 "type": "enum" 与 "enumOptions": ["上架", "下架", "预售"],表单渲染成下拉选择、列表按选中值展示。选项也支持 "value:显示文本" 形式,value 落库、冒号后为界面文字。
  • ref 外键:字段加 "type": "ref"、"refEntity": "Category"(指向被引用实体)与可选 "refDisplay": "Name"(用被引实体的哪个字段作显示串,留空回退其首个可显示列)。表单渲染成外键下拉(运行时经 LookupAsync 拉取被引实体记录),列表里则把存下来的 Id 还原成可读文本——你看到的不再是陌生 GUID。物理上它仍是一个 varchar(50) 列,存的是被引记录主键,与框架 Id 约定一致。
  • children 一对多子表:在父实体上声明 "children": [{ "field": "Variants", "entity": "ItemVariant", "parentField": "ItemId" }]——entity 是子实体名,parentField 是子实体上回指父记录的外键列(留空按约定回退 {父实体名}Id)。它不落物理列,而是让父表单内嵌一个 schema-subtable 区块:随主从选中实时增删改子记录、保存自动回填父外键,父记录删除时级联清理子表。
{
  "name": "Item", "displayName": "商品", "table": "biz_Item",
  "children": [ { "field": "Variants", "entity": "ItemVariant", "parentField": "ItemId" } ],
  "fields": [
    { "name": "Code",       "label": "编码", "type": "string", "required": true, "maxLength": 30 },
    { "name": "CategoryId", "label": "类别", "type": "ref",   "refEntity": "Category", "refDisplay": "Name" },
    { "name": "Status",     "label": "状态", "type": "enum",  "enumOptions": ["上架", "下架", "预售"], "required": true },
    { "name": "Price",      "label": "单价", "type": "decimal","required": true }
  ]
}

页面键控与菜单驱动:一份 Schema 派生多页

实体级声明式布局之外,Schema 还能显式声明页面与导航,让同一个实体派生多张不同职责的页(台账、看板、明细……),并编排侧边栏。路由 /runtime/{Key} 的分派口径是先页后实体:Key 先按页面稳定码查 pages,未命中再按实体名查 entities——所以旧的"每实体一页"依旧成立,新的显式页面只是叠加其上。

一个页面有两种写法,运行时都吃:

  • blocks 所见即所发:直接给一棵 PageBlock 区块树(component / title / settings / children 递归),运行时原样交给 PageRenderer 渲染,容器(top-bottom / left-right / tabs / grid)与数据块(schema-table / schema-form / schema-subtable / schema-stats / schema-chart)自由嵌套。这正是设计器画布的产物——你在设计器里怎么摆,运行时就怎么显。
  • views + layout 声明式:沿用上一节的实体级写法(sourceKind: "entity"、layout、views[]),blocks 留空时运行时经翻译器(SchemaTree)现场装配成同一批区块。两种写法共用同一套数据块,行为一致。

menu 则把页面组织成侧边栏导航树:kind: "folder" 是分组、kind: "page" 是指向某页的叶子(pageId 引用页面稳定码、parentId 串起层级)。存在任意菜单节点时,侧栏「运行时」段据此装配;未被任何页面绑定为数据源的实体,仍兜底追加到「运行时模型」分组,保证每个已发布模型都可达、无一遗漏。两条路径都不写页面/菜单代码。

{
  "pages": [
    { "id": "shop-dash", "title": "商品看板", "sourceKind": "entity", "entity": "Item",
      "blocks": [
        { "id": "dash-root", "component": "top-bottom", "settings": { "ratio": "0.35" }, "children": [
          { "id": "dash-stats", "component": "schema-stats", "title": "经营指标", "settings": { "entity": "Item" } },
          { "id": "dash-chart", "component": "schema-chart", "title": "按状态数量",
            "settings": { "entity": "Item", "type": "bar", "group": "Status", "metric": "count" } }
        ] }
      ] }
  ],
  "menu": [
    { "id": "m-goods", "title": "商品中心", "kind": "folder", "icon": "grid" },
    { "id": "m-dash",  "title": "商品看板", "kind": "page", "parentId": "m-goods", "pageId": "shop-dash", "icon": "dashboard" }
  ]
}

Schema 里还能声明 virtualTables(跨实体 join 投影的只读数据源,页面以 sourceKind: "view" 引用)。当前它已解析入注册表、供页面与往返引用,运行期的实际 join 查询能力在后续增量提供。

闭环:在线设计 → 下载 → 落项目即运行

上面这些 entities / pages / blocks / menu / virtualTables 手写 JSON 当然可以,但真正的用法是在设计器里拖出来。整条闭环不经过任何代码生成:

  1. 在 Studio 可视化设计器里建模、拖页面、连主从与子表、编排导航——所见即所发地摆出区块树。
  2. 点「下载 Schema」,把当前设计投影成一份运行时契约形状的 *.schema.json(设计态字段被翻译成运行时键:枚举展平、外键补 refDisplay、children 提升为实体级关系、区块键 remap 成 schema-* 并回填 entity、把列标题翻成字段名)。
  3. 把这份 JSON 丢进你项目的 Schemas/ 目录,并在 .csproj 里设为 EmbeddedResource(与 i18n 语言包同源同法,随程序集发布)。
  4. 运行——框架的 SchemaRegistry 扫所有已注册程序集的内嵌 *.schema.json,合并为统一真相源,按页面键 + 菜单直接渲染成能用的页面。全程零页面代码,你要写的仍只有后端钩子。

这条路线的价值主张正在于此:新增或改动一张业务页 = 改一份随包发布的 JSON,而不是新增、维护一整套 .razor。当某页确实要脱离 Schema 长期手写时,再走下一节的 Eject。

你唯一要写的代码:后端业务钩子

标准 CRUD 交给运行时渲染后,真正属于你业务的差异逻辑,落在生命周期钩子上。这就是"要写的代码就是后端自定义业务逻辑"的落点。

设想一个再常见不过的诉求:订单保存成功后,要给其他系统发一条消息。你不需要碰页面,只需为 Order 写一个钩子:

[Hook("Order")]
public sealed class OrderHooks : CrudHook
{
    // 写入前:可改写字段,或置 ctx.Cancel 拒绝本次写入
    public override Task OnSaving(CrudContext ctx, Dictionary<string, object> record)
    {
        if (!record.TryGetValue("Status", out var status) || string.IsNullOrWhiteSpace(status?.ToString()))
            record["Status"] = "新建";           // 业务规则:状态缺省
        return Task.CompletedTask;
    }

    // 写入成功后:通知其它系统 / 发领域事件
    public override Task OnSaved(CrudContext ctx, Dictionary<string, object> record)
    {
        var no = record.TryGetValue("OrderNo", out var n) ? n?.ToString() : null;
        MessageBus.Publish($"已通知其他系统:订单 {no} 已保存");
        ctx.Messages.Add($"已通知其他系统:订单 {no} 已保存");   // 回传给界面提示
        return Task.CompletedTask;
    }
}

四个可选阶段:

钩子 触发时机 典型用途
OnSaving 新增/修改写入前 补默认值、跨字段校验、置 Cancel 拒绝
OnSaved 写入成功后 通知外部系统、发事件、写审计
OnDeleting 删除前 存在关联数据时置 Cancel 拦截
OnDeleted 删除成功后 清理副作用、发通知

钩子用 [Hook("实体名")] 标注服务对象,启动时由框架的钩子装配器(HookResolver)扫描所有已注册程序集(框架 + 各插件)自动发现、按实体归组——钩子写在你自己的应用程序集里即可(示例见 KnownApp/Runtime/OrderHooks.cs),同样不需要你手写注册。示例里 OnSaved 把消息投递到一个内存消息总线并写日志,直观证明"保存时确实触发了后端逻辑";真实项目里换成你的 MQ / HTTP 回调即可。

什么时候该改用代码生成(Eject)

运行时渲染的代价是"页面不是你的文件"——它由 Schema 现场装配,你能定制的是 Schema 与钩子,改不了渲染器本身。当出现下面这些情况,就该把代码生成出来、拿到自己手里长期维护:

  • 某个页面要做渲染器覆盖不了的复杂交互(多步向导、特殊布局、跨表联动编辑)。
  • 你希望从此脱离 Schema、把实体/服务/页面当普通手写代码持续演进,甚至反过来不再同步模型。
  • 需要在生成物基础上做大量一次性定制,且不想受"重跑即覆盖 Generated 区"的约束。

这正是 代码生成(Eject 逃生舱) 的用途:同一份 Schema,用 known build 出成一套与手写完全同构、可自由增删改的 .cs / .razor。默认用运行时渲染省掉维护产物的负担;需要"逃生"时再 eject 成代码。

现状与边界(预览须知)

作为框架内置的预览特性,当前实现有几处刻意留白,接入正式项目前请留意:

  • 分页与关键字搜索按 SQLite 方言手写(limit/offset + LIKE),因为框架内置的分页条件只做等值匹配;多数据库生产使用需按 provider 适配。
  • 记录以 Dictionary<string, object>(键=列名)承载,不生成 CLR 实体类型;数据读写走框架既有的 [WebApi] ⇄ [Service] 同构链路,前端 ServiceProxy、后端直连 Database。
  • 目前已覆盖"标准 CRUD + 钩子"、枚举 / 外键 / 一对多子表三类进阶字段,以及页面键控与菜单驱动渲染;尚未落地的是字段级权限、virtualTables 的运行期 join 查询(现仅解析注册、供引用),多数据库下的分页方言仍待按 provider 适配。

这些正是它标"预览"而非 GA 的原因;引擎已进框架、方向与接口是清晰的,细节会在后续版本补齐。

相关

微信公众号二维码 扫码关注公众号
QQ 交流群二维码 扫码加入 QQ 群