元数据驱动架构设计

拆解低代码平台的元数据驱动架构:元数据的分类与分层、描述与运行分离、版本化与变更管理、编译与解释两条路线、表达式引擎与校验约束、缓存失效策略,给出可落地的 Schema 设计、存储选型与演进路径,回答如何用一份元数据同时驱动渲染、校验、权限与代码生成。

引言

低代码平台的一切能力都建立在同一个前提上:用数据描述应用,而不是用代码编写应用。这份「描述应用的数据」就是元数据(metadata)。表单的字段、页面的组件树、流程的节点、权限的规则,全都以结构化数据的形式存在,平台再用统一的运行时去解释并执行它们。

元数据驱动的价值在于「一次描述,多处消费」:同一份字段定义,既驱动表单渲染,又驱动后端校验,又驱动数据库建表,还驱动代码生成与文档。如果没有元数据,这四件事就要各写一遍,且必然不一致。有了元数据,它们只是同一份真相的不同视图。

但工程上的难点也随之而来:元数据一旦成为系统的「唯一真相」,它的 Schema 设计就变成了整个平台最关键的接口。Schema 太窄,表达不了业务;太宽,运行时复杂度爆炸;不稳定,则所有依赖它的下游都要跟着改。本文按「分类 → 设计 → 分离 → 版本 → 存储 → 执行 → 校验 → 缓存」的顺序展开,给出可直接落地的设计方法。

目录

  1. 什么是元数据驱动
  2. 元数据的分类与分层
  3. 元数据 Schema 设计
  4. 描述与运行分离
  5. 版本化与变更管理
  6. 元数据存储选型
  7. 编译与解释两条路线
  8. 表达式引擎
  9. 校验与约束
  10. 缓存与失效
  11. 元数据与代码的关系

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

常见坑清单

  1. 用 eval 执行表达式:安全漏洞且无法做依赖分析,必须自研或选沙箱库。
  2. id 与 name 合一:重命名字段导致数据与引用全断,必须分离。
  3. Schema 无版本号:破坏性变更后旧元数据无法识别,必须带 version。
  4. 校验只在后端:体验差;只在前后端不一致:数据脏。必须共用 Schema。
  5. 缓存只靠 TTL:发布后延迟生效,需主动失效 + 版本号。
  6. 元数据塞进算法:得到又慢又难调的「配置即编程」怪物。
  7. 发布即生效无草稿:中途状态上线,必须草稿与发布分离。
  8. 无反向引用索引:重命名字段时找不到影响面,需 GIN/反向索引。
  9. 版本历史与当前版本同库同表:历史膨胀拖慢主表,应分离存储。
  10. 表达式无依赖提取:只能全量重算,性能随字段数线性下降。

小结

元数据驱动架构的骨架是「分类分层 → Schema 契约 → 描述运行分离 → 版本化 → 存储 → 执行 → 校验 → 缓存」。每一环都围绕同一个原则:元数据是唯一真相,其他都是它的视图。

设计时最需要克制的两件事:一是 Schema 的膨胀冲动(什么都想元数据化),二是表达式的便利冲动(用 eval 图快)。前者让运行时复杂度失控,后者埋下安全与性能双重隐患。

元数据设计好之后,下一个问题是「怎么把它渲染成界面、怎么让它承载交互」,这分别对应 可视化页面搭建器实现 与渲染性能优化。而元数据的另一端——「怎么把它变成可部署的代码」——见 代码生成与领域特定语言 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「低代码」更多文章

  1. 自定义代码与逃生舱
  2. 低代码应用测试与质量
  3. 连接器与 API 编排