状态:预览。 运行时渲染引擎已内置进框架(
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 当然可以,但真正的用法是在设计器里拖出来。整条闭环不经过任何代码生成:
- 在 Studio 可视化设计器里建模、拖页面、连主从与子表、编排导航——所见即所发地摆出区块树。
- 点「下载 Schema」,把当前设计投影成一份运行时契约形状的
*.schema.json(设计态字段被翻译成运行时键:枚举展平、外键补refDisplay、children提升为实体级关系、区块键 remap 成schema-*并回填entity、把列标题翻成字段名)。 - 把这份 JSON 丢进你项目的
Schemas/目录,并在.csproj里设为EmbeddedResource(与 i18n 语言包同源同法,随程序集发布)。 - 运行——框架的
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 的原因;引擎已进框架、方向与接口是清晰的,细节会在后续版本补齐。
相关
- 需要把代码拿到手长期改:代码生成(Eject 逃生舱)
- 想先手写一个 CRUD 页面、理解每个文件为何这么写:写第一个 CRUD 页面
- 启动时按实体非破坏性建表的机制:数据层与 Database 契约
- 一次反射把整模块接进路由与元模型:模块与插件式装配
扫码关注公众号
扫码加入 QQ 群