一个博客客户端把超时字段从 timeoutSeconds 误写成 timeotSeconds。JSON 语法正确,反序列化也可能成功,结果程序拿到默认值。若默认值又被解释成“无限等待”,一次拼写错误就会变成难以复现的请求卡死。
配置加载应当是一条明确的边界:文件输入、语法解析、结构约束、业务验证,最后才生成可供运行中代码使用的状态。本文以 .NET 8、自带 System.Text.Json 和反射序列化为基线;代码是设计示例,未声称已经编译或在实际项目运行。
缺失、null、零和默认值是四种决定
假设配置是:
1 | { |
如果 DTO 使用普通 int,缺失超时字段通常会留下默认整数零;如果使用 int?,缺失和显式 null 通常都表现为空值。是否要区分它们,取决于业务协议,而不是“哪个类型更方便”。完整配置文件可以要求必须提供,局部更新接口则可能用缺失表示保持原值、null 表示清空,不能套同一套模型。
required 或 [JsonRequired] 可以要求 JSON 中出现对应属性;“字段必须出现”与“值不能为 null”并不是同一约束。不要依赖 .NET 8 的可空引用注解自动拒绝输入 null,也不要把较新运行时的构造参数选项当作 .NET 8 默认行为。Microsoft 必需属性说明
本例选择:两个字段都必须出现;显式 null 不允许;超时范围为 1 到 120 秒;字段大小写严格匹配。它不是 JSON 的通用规范,而是一份小型人工维护配置的契约。
严格契约还需要升级策略。以后若把秒改为毫秒,直接沿用旧字段名会让旧文件产生数量级错误。可以增加明确配置版本,先把旧版本转换成当前原始模型,再统一验证;不能遇到未知版本就猜测。本文代码没有实现版本迁移,扩展时应把迁移规则、拒绝策略和样例文件一起交付,避免不同机器悄悄采用不同默认值。
未知字段和重复键分开处理
.NET 8 提供 JsonUnmappedMemberHandling.Disallow,可以在反序列化遇到未映射属性时抛出异常。这能捕获拼写错误,但不能代替必填验证,也不能自动说明所有值都符合业务规则。Microsoft 未映射成员处理
另一种问题是同一对象里写两次 timeoutSeconds。RFC 8259 建议对象成员名唯一,并说明接收端对重复名字的行为存在差异。让配置依赖“最后一个值获胜”,会导致不同工具看到的结果不一致。JSON 对象成员与互操作性 RFC 8259
本例在 DTO 转换前遍历 DOM,主动拒绝每个对象中的重复名称,采用与大小写严格匹配一致的 Ordinal 比较。这样 siteUrl 与 SiteUrl 不算完全同名,但后者会因为未知字段而失败。若以后启用大小写不敏感映射,重复策略也必须同步调整,不能允许两个名称最终映射到同一属性。
让已验证类型无法被随手构造
下面的类型把构造函数设为私有,公开只读属性,只有完成解析与验证后才生成实例。这样比公开位置参数的 record 更能表达“外部代码不能绕过工厂创建无效配置”。
1 | using System; |
这段代码主动限制深度,但没有实现文件大小限制。对于外部输入,还应在读取层设置字节上限,不能等把无限文件读入内存之后再检查字符串长度。小型本地配置采用 DOM 增加一次遍历,换来清晰错误;对大型流式输入,可以用 Utf8JsonReader 实现同样策略,但状态管理和路径诊断更复杂。
递归检查覆盖嵌套对象,而不是只检查根节点。不过本例契约本来只有两个字段,嵌套内容大多会在结构转换时失败。保留递归是为了策略完整,不能把它误当成已经实现任意复杂配置的业务验证。
错误应该保留失败层次
文件不存在、访问被拒绝、取消读取,属于输入层;非法 JSON、字段类型错误、缺少 required 字段或未知属性,属于解析与结构层;合法整数超出业务范围,则属于业务验证层。日志至少保留阶段、字段或 JSON 路径以及当前应用版本,避免只写一句“配置错误”。
同时不要原样记录整份 JSON。今天配置可能没有秘密,明天可能增加令牌;长期保留的日志就会成为意外副本。重复字段名称来自输入,也应经过日志系统的长度和字符处理,避免巨长名称淹没诊断信息。
不要捕获所有异常后改写成“JSON 无效”。例如磁盘故障需要运维介入,用户取消不需要报警,程序自身的空引用更不该伪装成配置拼写错误。错误分类是为了让后续动作正确,而不是为了让所有调用都返回 false。
热加载先生成候选,再发布一个引用
运行中的服务不应该逐字段改共享对象:先改 URL,稍后才改超时,其他线程可能观察到从未通过验证的组合。更稳妥的做法是先得到完整候选快照,验证全部成功后一次替换引用。读请求在开始时取得一次快照,并在本次操作中持续使用它。
下面仅演示已经拿到 JSON 内容之后的内存发布,文件监视、日志与失败策略由调用方负责:
1 | using System.Threading; |
引用交换不等于解决所有并发问题。两个加载任务可能先后读取新旧文件,却按相反顺序完成解析,最后把旧配置重新发布。调用方应串行处理重载,或用单调递增的版本号拒绝过时结果。文件监视可能重复通知,也可能在写入尚未结束时通知,生产流程还需要合并事件、稳定读取和候选校验。
若重载失败,可继续使用上一份有效配置并记录失败;但首次启动没有旧值,应明确停止启动。不能静默落到一个随意默认地址,否则故障会从启动阶段转移到用户请求中。正在执行的请求一般继续使用旧快照,新请求使用新快照,避免半途改变目标。
URI 验证只说明符合用途
示例拒绝用户凭据、查询串和片段,是因为本例需要一个基础地址,并不是宣称这些 URI 结构普遍不合法。是否允许子路径、非默认端口或指定主机白名单,也应来自实际产品需求。
一个形式正确的 HTTPS 地址仍然可能指向回环、内网或敏感管理接口。若地址来自不可信用户,SSRF 防护还要覆盖解析后的目标、DNS 变化、重定向与网络出口边界。仅检查 Scheme == https 或 IsLoopback,不能证明最终网络请求安全。这里的配置工厂只解决业务契约,不承担完整网络访问控制。
用小型矩阵检验协议
| 输入变化 | 本例预期结果 |
|---|---|
| 正确地址与整数 15 | 返回不可变快照 |
| 缺少 timeoutSeconds | required 结构检查失败 |
| timeoutSeconds 为 null、0、121 | 业务验证失败 |
| timeoutSeconds 为字符串 “15” | 类型转换失败 |
| timeotSeconds 拼错 | 未知字段失败 |
| 同一键出现两次 | 重复字段检查失败 |
| 根节点为 null 或数组 | 根节点契约失败 |
| 重载一份非法配置 | 旧快照仍可使用 |
还应补充并发重载顺序、取消读取、超大文件和嵌套深度边界。查看 DOM 时,某些按名称取属性的 API 会在重复成员中返回最后一个匹配,不能拿一次查询成功证明输入没有重复。Microsoft JsonElement 属性查询说明
一个可靠的配置边界,应让调用方拿到“已验证且版本一致的状态”,而不是让每次网络请求重新猜测缺失字段、默认值和错误格式分别意味着什么。