引言
低代码平台的一切能力都建立在同一个前提上:用数据描述应用,而不是用代码编写应用。这份「描述应用的数据」就是元数据(metadata)。表单的字段、页面的组件树、流程的节点、权限的规则,全都以结构化数据的形式存在,平台再用统一的运行时去解释并执行它们。
元数据驱动的价值在于「一次描述,多处消费」:同一份字段定义,既驱动表单渲染,又驱动后端校验,又驱动数据库建表,还驱动代码生成与文档。如果没有元数据,这四件事就要各写一遍,且必然不一致。有了元数据,它们只是同一份真相的不同视图。
但工程上的难点也随之而来:元数据一旦成为系统的「唯一真相」,它的 Schema 设计就变成了整个平台最关键的接口。Schema 太窄,表达不了业务;太宽,运行时复杂度爆炸;不稳定,则所有依赖它的下游都要跟着改。本文按「分类 → 设计 → 分离 → 版本 → 存储 → 执行 → 校验 → 缓存」的顺序展开,给出可直接落地的设计方法。
目录
- 什么是元数据驱动
- 元数据的分类与分层
- 元数据 Schema 设计
- 描述与运行分离
- 版本化与变更管理
- 元数据存储选型
- 编译与解释两条路线
- 表达式引擎
- 校验与约束
- 缓存与失效
- 元数据与代码的关系
1. 什么是元数据驱动
先区分三个概念,它们经常被混用。
配置(Configuration)
→ 影响行为但不定义结构,如「每页 20 条」
→ 特点:扁平、少量、可硬编码默认值
元数据(Metadata)
→ 描述应用的结构,如「这个表单有哪些字段」
→ 特点:结构化、层级、是运行时的输入
代码(Code)
→ 元数据无法表达的兜底,如复杂算法
→ 特点:灵活、不可配置、需发布
元数据驱动的本质是「把结构从代码里搬到数据里」。但要注意边界:不是所有东西都该元数据化。把算法也塞进元数据会得到一个又慢又难调试的「配置即编程」怪物。判断标准是「它是否随业务变化而变化」——字段、布局、流程、权限会变,排序算法不会。
2. 元数据的分类与分层
一个成熟的平台通常有 5 到 7 类元数据,它们不是平级的,而是有依赖关系。
第一层:数据模型 实体、字段、类型、关系、约束
第二层:界面 页面、组件树、布局、样式
第三层:行为 事件、表达式、动作、联动
第四层:权限 角色、资源、操作、行级规则
第五层:流程 节点、连线、条件、触发
第六层:应用与导航 菜单、路由、入口、主题
依赖是有向的:数据模型被界面引用,界面绑定行为,行为受权限约束。这个顺序决定了变更的影响面——改数据模型影响最大,改主题影响最小。理解引用关系后,就能实现「重命名字段时自动更新所有引用」这类能力,这几乎是平台可用性的分水岭。
3. 元数据 Schema 设计
Schema 设计要同时满足「人能读」「机器能校验」「运行时能解释」。推荐用 TypeScript 定义类型,用 JSON Schema 做校验,用 JSON 存储。
// 字段定义:平台最核心的元数据类型
interface FieldSchema {
id: string; // 稳定标识,不可变
name: string; // 机器名,用于绑定
label: string; // 显示名,可 i18n
type: FieldType; // string/number/date/ref/enum...
required?: boolean;
defaultValue?: unknown;
validators?: Validator[]; // 校验规则
visibleWhen?: string; // 显隐表达式
editableWhen?: string; // 可编辑表达式
options?: OptionSource; // 枚举/引用来源
meta?: Record<string, unknown>; // 组件私有配置
}
type FieldType =
| 'string' | 'text' | 'number' | 'boolean'
| 'date' | 'datetime' | 'enum' | 'ref'
| 'file' | 'json' | 'richText';
关键设计点:id 与 name 分离。id 是内部稳定标识,name 是业务可读名。这样重命名 name 不影响数据存储与引用关系。
3.1 表单 Schema 示例
{
"id": "form_leave_001",
"version": 3,
"fields": [
{
"id": "f_1",
"name": "applicant",
"label": "申请人",
"type": "ref",
"options": { "source": "user", "displayField": "fullName" },
"required": true,
"editableWhen": "false"
},
{
"id": "f_2",
"name": "days",
"label": "请假天数",
"type": "number",
"required": true,
"validators": [{ "type": "min", "value": 0.5 }]
},
{
"id": "f_3",
"name": "reason",
"label": "事由",
"type": "text",
"visibleWhen": "days > 3",
"required": true
}
]
}
这份 Schema 已经能驱动表单渲染、前端校验、后端校验三件事。字段的显隐与必填由表达式驱动,而不是硬编码——这正是元数据驱动的核心收益。表单渲染的完整实现见 Schema 驱动的表单引擎 。
4. 描述与运行分离
元数据驱动架构最重要的原则是:描述层与运行层解耦。元数据只负责「是什么」,运行时负责「怎么做」。
描述层(Design Time)
设计器产出元数据
元数据 = 纯数据,无副作用
── 编译/加载 ──
运行层(Run Time)
渲染器读取元数据 → 生成视图
求值器读取表达式 → 计算结果
执行器读取动作 → 调用接口
分离带来的好处是:设计器可以任意重构(换技术栈、换交互)而不影响运行时;运行时可以针对性能优化(缓存、预编译)而不影响设计器。两者通过元数据契约解耦。
4.1 契约的稳定性
元数据 Schema 就是这份契约。它的演进必须向后兼容:新增字段用可选,废弃字段先标记 deprecated 再删。破坏性变更要提供迁移器(migrator),把旧版元数据自动升级到新版。
5. 版本化与变更管理
生产环境的元数据必须版本化,否则一次误改就是线上事故。
版本化三要素:
1. 快照:每次发布生成不可变快照
2. 差异:能对比任意两个版本的差异
3. 回滚:能一键回到任意历史版本
版本粒度:
应用级(整体发布)vs 元素级(单个表单)
推荐:应用级发布 + 元素级 diff
# 发布记录
release:
app: leave-approval
version: 12
baseVersion: 11
author: leeting
changes:
- op: add
path: /fields/3
value: { name: "attachment", type: "file" }
- op: update
path: /fields/1/required
from: false
to: true
用 JSON Patch 风格记录变更,既能生成 diff,又能反向应用实现回滚。
5.1 草稿与发布分离
设计器编辑的是草稿,只有点「发布」才生成新版本并生效。这避免了「边改边生效」导致的中途状态。草稿本身也要持久化,否则用户关掉浏览器就丢失。
6. 元数据存储选型
| 存储 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 关系库 JSON 列 | 事务、查询方便 | 深层查询弱 | 大多数场景 |
| 文档库(Mongo) | 天然嵌套 | 事务弱 | 元数据树为主 |
| 对象存储 | 便宜、快照天然 | 无查询 | 版本快照归档 |
| Git 仓库 | 天然版本、diff | 写并发差 | 少量、需审计 |
最常见的方案是「关系库存当前版本 + 对象存储存历史快照」:当前版本需要频繁读取与查询,放关系库;历史版本只读且体积大,放对象存储。
-- 元数据主表:当前生效版本
CREATE TABLE app_metadata (
app_id VARCHAR(64) PRIMARY KEY,
tenant_id VARCHAR(64) NOT NULL,
version INT NOT NULL,
schema JSONB NOT NULL, -- 完整元数据
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by VARCHAR(64) NOT NULL
);
-- 版本历史:只读归档
CREATE TABLE app_metadata_history (
app_id VARCHAR(64) NOT NULL,
version INT NOT NULL,
schema JSONB NOT NULL,
release_note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (app_id, version)
);
若需按字段名检索(如「哪些表单用了 customer_id」),可加 GIN 索引 USING GIN (schema jsonb_path_ops) 配合 @? 路径查询,这是「影响面分析」的基础。
7. 编译与解释两条路线
元数据怎么变成运行时的行为?有两条路线。
路线 A:解释(Interpret)
运行时直接读取元数据 → 逐步解释执行
优点:改动即时生效、无需构建
缺点:运行时开销、逻辑分散
路线 B:编译(Compile)
构建期把元数据编译为代码/IR → 运行代码
优点:运行时快、可静态分析
缺点:需要构建步骤、改动有延迟
路线 C:混合
结构解释(渲染)、热点编译(复杂表达式)
大多数平台走混合路线:结构用解释(改字段立即生效),表达式与复杂逻辑预编译成闭包缓存起来(避免每次重新解析)。
7.1 预编译表达式
const cache = new Map<string, (ctx: Context) => unknown>();
function compile(expr: string) {
if (cache.has(expr)) return cache.get(expr)!;
const ast = parse(expr);
const fn = (ctx: Context) => evaluate(ast, ctx);
cache.set(expr, fn);
return fn;
}
缓存键是表达式字符串本身,命中率通常极高,因为同一表单的表达式会被反复求值。
8. 表达式引擎
表达式是元数据从「静态描述」变成「动态行为」的关键。它是平台里最需要谨慎设计的部分。
设计目标:
1. 安全:不能执行任意代码
2. 可分析:能提取依赖字段
3. 可解释:错误信息可读
4. 有限:不图灵完备(避免死循环)
常见语法:
days > 3 && status == "pending"
$user.dept == $record.dept
CONCAT(firstName, " ", lastName)
不要用 eval 或 new Function 直接执行用户输入——这既是安全漏洞,也无法做依赖分析。正确做法是自己写词法/语法分析器,或选用成熟的沙箱表达式库。
8.1 依赖提取
表达式求值前需要知道它依赖哪些字段,才能建立「字段变化 → 重新求值」的响应关系。做法是遍历 AST,把 Identifier 节点收集成依赖列表。
function extractDeps(ast: Node): string[] {
const deps = new Set<string>();
walk(ast, (n) => { if (n.type === 'Identifier') deps.add(n.name); });
return [...deps];
}
有了依赖列表,运行时就能精确订阅:只有 days 变化时才重算依赖它的显隐规则,而不是全量重算。
9. 校验与约束
校验必须前后端共用同一份元数据,否则会「前端过了后端拒」。
// 校验器:同一份 Schema 在前端与后端都能跑
type Validator =
| { type: 'min'; value: number }
| { type: 'max'; value: number }
| { type: 'pattern'; value: string }
| { type: 'minLength'; value: number }
| { type: 'custom'; expr: string };
function validateField(field: FieldSchema, value: unknown): string | null {
if (field.required && isEmpty(value)) return `${field.label} 不能为空`;
for (const v of field.validators ?? []) {
const err = runValidator(v, value);
if (err) return err;
}
return null;
}
9.1 服务端不可信原则
前端校验只为体验,后端校验才是防线。后端加载同一份元数据,用同一套 validateField 再跑一遍,不信任前端传来的任何「已校验」标记。
10. 缓存与失效
元数据读多写少,天然适合缓存,但失效策略要精确。
缓存层级:
L1 进程内(Map):解析后的元数据对象
L2 分布式(Redis):跨实例共享
L3 CDN/边缘:静态渲染场景
失效方式:
- 主动失效:发布时发布事件,各实例清缓存
- 版本号:元数据带 version,读取时校验
- TTL 兜底:即使事件丢失,也能最终一致
async function getMetadata(appId: string) {
const cached = l1.get(appId);
if (cached && cached.version === await latestVersion(appId)) return cached;
const fresh = await loadFromDb(appId);
l1.set(appId, fresh);
return fresh;
}
推荐「版本号 + 事件通知 + TTL 兜底」三件套:事件保证实时,版本号保证正确,TTL 保证最终一致。
11. 元数据与代码的关系
元数据不是要取代代码,而是要减少「结构性代码」。字段、布局、显隐、必填、枚举、简单校验与简单流程适合元数据;复杂算法、外部系统深度集成、高性能路径、不可枚举的业务规则适合代码。
折中方式是「引用 + 注册」:元数据里只放引用,代码里放实现,例如 validator: { type: "custom", ref: "leaveBalanceCheck" }。这既保持了元数据的声明性,又保留了代码的表达力,与 插件机制与扩展体系
的思路一致。
权衡取舍
| 决策点 | 选项 A | 选项 B | 建议 |
|---|---|---|---|
| 执行方式 | 纯解释 | 纯编译 | 混合:结构解释、表达式编译 |
| 存储 | 关系库 | 文档库 | 元数据树复杂选文档库 |
| 版本粒度 | 应用级 | 元素级 | 应用级发布 + 元素级 diff |
| 表达式 | 自研解析 | 现成库 | 需依赖分析则自研 |
| 校验位置 | 仅前端 | 前后端 | 必须前后端共用 Schema |
常见坑清单
- 用
eval执行表达式:安全漏洞且无法做依赖分析,必须自研或选沙箱库。 id与name合一:重命名字段导致数据与引用全断,必须分离。- Schema 无版本号:破坏性变更后旧元数据无法识别,必须带 version。
- 校验只在后端:体验差;只在前后端不一致:数据脏。必须共用 Schema。
- 缓存只靠 TTL:发布后延迟生效,需主动失效 + 版本号。
- 元数据塞进算法:得到又慢又难调的「配置即编程」怪物。
- 发布即生效无草稿:中途状态上线,必须草稿与发布分离。
- 无反向引用索引:重命名字段时找不到影响面,需 GIN/反向索引。
- 版本历史与当前版本同库同表:历史膨胀拖慢主表,应分离存储。
- 表达式无依赖提取:只能全量重算,性能随字段数线性下降。
小结
元数据驱动架构的骨架是「分类分层 → Schema 契约 → 描述运行分离 → 版本化 → 存储 → 执行 → 校验 → 缓存」。每一环都围绕同一个原则:元数据是唯一真相,其他都是它的视图。
设计时最需要克制的两件事:一是 Schema 的膨胀冲动(什么都想元数据化),二是表达式的便利冲动(用 eval 图快)。前者让运行时复杂度失控,后者埋下安全与性能双重隐患。
元数据设计好之后,下一个问题是「怎么把它渲染成界面、怎么让它承载交互」,这分别对应 可视化页面搭建器实现 与渲染性能优化。而元数据的另一端——「怎么把它变成可部署的代码」——见 代码生成与领域特定语言 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。