把章节变成可单步观察的过程
先说结论
配置管线把策划源数据依次经过结构、引用和业务语义校验,只有全部通过才生成强类型快照和索引。目标不是“尽量把坏数据读进去”,而是让错误在构建期带着行号和字段名暴露。
场景:三行技能配置的结果
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)。如果还需要按稳定键、分组或区间查询,应显式生成二级索引;不要每次查询都扫描全表,也不要生成无人消费的索引。
“强类型快照”包含两层:
- 生成的类型定义让字段访问在编译期可检查;
- 生成的数据文件在启动时反序列化成对应记录和索引。
只生成类型但不加载数据,不算运行时接入;只加载表集合但没有功能代码查询,也只能标记为“已生成并装载,未发现消费”。
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 设为真,也不能用空表替换缺失表伪装成功。
调试时应该同时看到什么
一个可用的配置构建调试器应展示:
- 当前源记录;
- 每一层新增了哪些错误;
- 哪些阶段因前置失败而没有运行;
- 强类型快照是否获得发布资格;
- 建立了哪些索引;
- 运行时查询命中哪条记录;
- 本轮失败时,为什么没有生成假快照。
交互实验允许制造重复 ID、缺失引用和非法范围,并逐阶段观察错误累积和构建停止。
最后记住
源数据先经过结构、引用和语义校验。
所有错误集中报告,任何阻断错误都不生成新快照。
运行时只消费已验证的强类型数据和索引。