DYNAMIC EXPLAINER / ARTICLE FLOW

把章节变成可单步观察的过程

01 / 11
STEP 01 / CODE / LOGIC

先说结论

配置管线把策划源数据依次经过结构、引用和业务语义校验,只有全部通过才生成强类型快照和索引。目标不是“尽量把坏数据读进去”,而是让错误在构建期带着行号和字段名暴露。

Skill 101:Damage=30,EffectId=5       -> 通过
Skill 102:Damage="abc",EffectId=5  -> 结构错误,Damage 不是数字
Skill 103:Damage=20,EffectId=999     -> 引用错误,Effect 999 不存在

先说结论

配置管线把策划源数据依次经过结构、引用和业务语义校验,只有全部通过才生成强类型快照和索引。目标不是“尽量把坏数据读进去”,而是让错误在构建期带着行号和字段名暴露。

场景:三行技能配置的结果

Skill 101:Damage=30,EffectId=5       -> 通过
Skill 102:Damage="abc",EffectId=5  -> 结构错误,Damage 不是数字
Skill 103:Damage=20,EffectId=999     -> 引用错误,Effect 999 不存在

构建应一次收集并报告第 2、3 行问题,然后拒绝生成新快照。若只跳过坏行继续生成,运行时可能出现技能列表与引用索引不一致,错误会变得更晚、更难定位。

配置表不是把电子表格“另存为 JSON”。一条可靠的生成管线要把人类易编辑的源记录,经过多层验证,变成运行时可安全查询的只读快照:

源记录
  → 结构校验
  → 引用校验
  → 语义校验
  → 强类型记录
  → 主键索引
  → 发布快照
  → 运行时加载与查询

任何校验失败都应阻止新快照发布。失败不是生成一份“尽量可用”的残缺数据,而是返回完整错误集合,让作者一次修正更多问题。

1. 源记录与 Schema 各管一件事

源记录描述内容,Schema 描述契约:

源记录:id=201, name="轻击", cost=12, effect_id=901
Schema:id:int 主键
        name:string 非空
        cost:int 范围 [0, 100]
        effect_id:int 引用 Effect.id

这里的数值和字段都是教学示例。生产配置应从自己的权威 Schema 读取类型、范围和引用,不能把演示值写成工程默认值。

把 Schema 与数据分开有三个好处:

  • 编辑器可以提前提示字段类型;
  • 生成器可以统一执行校验;
  • 运行时代码可以生成强类型访问,而不是反复解析字典。

2. 结构校验:先确认“能不能读”

结构层只回答记录是否符合形状:

void ValidateShape(SourceRow row, ErrorBag errors)
{
    RequireInteger(row, "id", errors);
    RequireText(row, "name", errors);
    RequireInteger(row, "cost", errors);
    RequireInteger(row, "effect_id", errors);
}

缺列、类型错误、无法解析的复合值都属于结构错误。后续引用和范围校验依赖正确类型,因此结构失败的字段不应继续参与需要该值的规则,但其他记录仍可继续检查并收集错误。

主键重复也应在构建期报告。运行时用字典建立索引时,重复键已经太晚:要么抛异常阻断启动,要么被覆盖而悄悄丢数据。

3. 引用校验:目标存在才算连接成功

引用不是“看起来像另一个 ID”的整数,而是一条跨表约束:

void ValidateReferences(
    SourceRow row,
    IReadOnlySet<int> effectIds,
    ErrorBag errors)
{
    if (!effectIds.Contains(row.EffectId))
        errors.Add(row, "effect_id", "引用目标不存在");
}

校验顺序通常是先收集各表合法主键,再检查引用。这样前向引用不要求目标记录必须写在源文件前面。

可空引用、删除策略和跨版本兼容属于明确的 Schema 规则。不能在引用缺失时擅自改成 0、第一条记录或空对象,这会把配置错误变成运行期的错误行为。

4. 语义校验:类型正确不代表业务成立

cost = -8 是合法整数,但可能不是合法消耗。语义层负责范围、组合关系和集合约束:

void ValidateMeaning(TypedRow row, ErrorBag errors)
{
    if (row.Cost < 0 || row.Cost > 100)
        errors.Add(row, "cost", "超出教学范围 [0, 100]");

    if (string.IsNullOrWhiteSpace(row.Name))
        errors.Add(row, "name", "名称不能为空");
}

更复杂的规则包括:

  • 最小值不能大于最大值;
  • 权重总和必须满足约定;
  • 同一分组内的稳定键必须唯一;
  • 只有某种模式下,另一个字段才允许出现。

这些规则应集中在生成期或独立校验器中,并带上表、行、字段和错误码。只在运行时日志里写“配置错误”很难定位源记录。

5. 先收集错误,再决定是否生成

验证阶段可以继续检查互不依赖的记录,最终一次返回全部错误:

BuildResult Build(SourceData source)
{
    ErrorBag errors = ValidateAll(source);
    if (errors.Count > 0)
        return BuildResult.Failed(errors);

    TypedSnapshot snapshot = GenerateTypedSnapshot(source);
    return BuildResult.Success(snapshot);
}

这里没有“错误时生成空快照”的降级路径。调用方必须看到失败,并保留上一份已发布快照。新快照只有在完整验证与生成成功后才获得可发布资格。

若生成工具直接写最终目录,它可能在中途失败后留下旧文件或部分文件。更稳妥的发布流程是写入临时目录、验证文件集合,再用原子交换或版本目录切换。是否具备这个保证必须从工具实现验证,不能只凭“命令失败了”推断磁盘状态安全。

6. 强类型快照与索引

通过验证后,生成器把源记录转换成只读记录,并按声明的主键建立索引:

sealed record ActionConfig(
    int Id,
    string Name,
    int Cost,
    int EffectId);

sealed class ActionTable
{
    private readonly Dictionary<int, ActionConfig> byId;

    public ActionConfig Get(int id) => byId[id];
    public bool TryGet(int id, out ActionConfig value)
        => byId.TryGetValue(id, out value);
}

建立索引是 O(n) 时间与 O(n) 额外空间,主键查询平均为 O(1)。如果还需要按稳定键、分组或区间查询,应显式生成二级索引;不要每次查询都扫描全表,也不要生成无人消费的索引。

“强类型快照”包含两层:

  1. 生成的类型定义让字段访问在编译期可检查;
  2. 生成的数据文件在启动时反序列化成对应记录和索引。

只生成类型但不加载数据,不算运行时接入;只加载表集合但没有功能代码查询,也只能标记为“已生成并装载,未发现消费”。

7. 运行时 Provider 必须有真实消费证据

运行时通常先加载清单中的全部数据文件,再构造表集合:

async Task<ConfigSnapshot> LoadSnapshot()
{
    Dictionary<string, JsonNode> nodes = await LoadAllManifestEntries();
    ConfigSnapshot snapshot = new(nodes);
    return snapshot;
}

表构造器会反序列化记录、建立字典索引并解析声明引用。加载句柄随后释放,运行时保留的是解析后的快照。

判断某张表是否真正接入,需要沿调用链找到:

功能入口
  → Repository / Service
  → snapshot.Actions.GetOrDefault(id)
  → 读取具体字段
  → 影响功能输出

以下证据强度不同:

状态 能说明什么
Schema 中存在 可以生成
生成目录存在类型与数据 已产生快照材料
启动时构造表 已加载
Provider 暴露表 可以被查询
功能代码读取字段并产生结果 已消费

不要把前四项写成第五项。

8. 启动失败与清理

运行时加载缺表、类型不匹配或构造索引失败时,应让初始化保持未就绪并暴露原始错误:

async Task Warmup()
{
    try
    {
        current = await LoadSnapshot();
        ready = true;
    }
    catch (Exception error)
    {
        ready = false;
        lastError = error;
        throw;
    }
}

模块卸载时清空当前快照和 Provider 引用。不能在失败后把 ready 设为真,也不能用空表替换缺失表伪装成功。

调试时应该同时看到什么

一个可用的配置构建调试器应展示:

  1. 当前源记录;
  2. 每一层新增了哪些错误;
  3. 哪些阶段因前置失败而没有运行;
  4. 强类型快照是否获得发布资格;
  5. 建立了哪些索引;
  6. 运行时查询命中哪条记录;
  7. 本轮失败时,为什么没有生成假快照。

交互实验允许制造重复 ID、缺失引用和非法范围,并逐阶段观察错误累积和构建停止。

打开配置数据生成交互实验

最后记住

源数据先经过结构、引用和语义校验。
所有错误集中报告,任何阻断错误都不生成新快照。
运行时只消费已验证的强类型数据和索引。