低代码 BI 做到第三个版本时,历史报表开始出现一些难以解释的判断:if (!config.tooltip) 可能是在兼容旧版默认值,也可能是用户真的关闭了 tooltip;datasetsource.datasetId 同时存在,运行时按组件不同选择字段。每个组件都能“勉强打开”,却没人说得清一份 2021 年配置经过了哪些兼容。

我们最终把兼容从组件渲染里移出,所有配置先经过单向迁移到当前 Schema,再校验和编译。运行时只理解一个版本。

历史低代码配置按版本逐步迁移、校验并编译成运行时计划

图 1:迁移是数据管线,不是散落在 UI 中的一组 ?? defaultValue

每一步只知道相邻版本

type Migrator<From, To> = (input: From) => To;
 
const migrations: Record<number, Migrator<any, any>> = {
  1: (v1: ChartV1): ChartV2 => ({
    ...v1,
    version: 2,
    interaction: { tooltip: v1.tooltip !== false },
  }),
  2: (v2: ChartV2): ChartV3 => ({
    ...omit(v2, "dataset"),
    version: 3,
    source: { datasetId: v2.dataset },
  }),
};
 
function migrateToCurrent(input: UnknownSpec): ChartV3 {
  let current = structuredClone(input);
  while (current.version < 3) {
    const migrate = migrations[current.version];
    if (!migrate) throw new MigrationError("MIGRATION_PATH_MISSING", current.version);
    current = migrate(current);
  }
  return validateV3(current);
}

V1 直接迁到 V3 看似少一步,版本多后会产生大量组合。相邻迁移让每次发布只维护一个新边,历史配置按同一路径回放。

结构正确不等于语义正确

JSON Schema 能验证字段类型,不能判断“饼图只能有一个 measure”或“时间粒度只适用于时间字段”。迁移后还有领域校验:

function validateSemantics(spec: ChartV3, dataset: DatasetSchema): Issue[] {
  const issues: Issue[] = [];
  if (spec.kind === "pie" && spec.query.measures.length !== 1) {
    issues.push({ path: "query.measures", code: "PIE_REQUIRES_ONE_MEASURE" });
  }
  if (!dataset.fields.has(spec.encoding.x.field)) {
    issues.push({ path: "encoding.x.field", code: "FIELD_NOT_FOUND" });
  }
  return issues;
}

错误带路径、代码和上下文,编辑器可以定位字段;不能只抛一句 invalid config

迁移必须保持用户意图

改变默认值最容易悄悄改图。例如 V1 缺少 connectNulls 时,旧运行时默认 true,新运行时默认 false。迁移不能简单留空,而要显式写入旧语义。

变更 迁移策略
字段重命名 复制值到新字段并删除旧字段
默认值改变 为旧配置显式写入旧默认
一个字段拆成多个 根据旧枚举做确定性映射
能力被移除 标记 unsupported,不能静默丢弃
外部资源不存在 进入待修复队列,不猜替代资源

用黄金样例验证视觉结果

单元测试验证 JSON 输出还不够。我们保存一组代表性历史配置和固定数据,迁移前用旧运行时截图,迁移后用新运行时截图,对关键视觉和查询计划做差异检查。

黄金样例包括空数据、长标签、双轴、联动筛选和被删除字段。每次新增迁移都跑完整链路:V1 → 当前、V2 → 当前、当前重复迁移。重复执行当前配置应该不再改变内容。

读时迁移与写回分开

页面打开时先在内存迁移,确保可读;是否持久化新版本由后台任务控制。直接在用户打开页面时覆盖旧配置,一旦新运行时有 bug 就失去原始证据。

批量写回按租户和报表分批,记录原版本、目标版本、输入 hash、输出 hash、迁移器版本与结果。失败配置隔离,不阻塞全部批次,也不反复自动重试未知错误。

迁移管线上线后,我盯三个数字:迁移失败率、写回前后 hash 不一致率、以及用户打开历史报表时的报错数。前两个归数据团队管,第三个直接反映用户感知。一次默认值改动曾经让黄金样例里的两张旧图悄悄改变样式——如果只统计“迁移成功”而不断言视觉结果,这类静默变化会被平均分掩盖。所以验收只看两个终态:迁移结果与迁移前语义等价,且每一步都有可回放的证据。

这次治理之后,组件代码里大量“历史原因”判断被删除。更重要的是,我们终于能回答一份配置从哪个版本来、怎样变成今天的结构、哪一步可能改变语义。低代码配置一旦被用户保存,就和数据库数据一样需要严肃的演进纪律。这与低代码 BI 引擎里 Schema 版本化的设计一脉相承;当债务积累到需要偿还时,我在架构债务不是旧代码里记录了双算与逐步切换的完整做法。