低代码 BI 做到第三个版本时,历史报表开始出现一些难以解释的判断:if (!config.tooltip) 可能是在兼容旧版默认值,也可能是用户真的关闭了 tooltip;dataset 和 source.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 版本化的设计一脉相承;当债务积累到需要偿还时,我在架构债务不是旧代码里记录了双算与逐步切换的完整做法。