C# 读取 JSON 配置:反序列化之后,还需要验证什么


一个博客客户端把超时字段从 timeoutSeconds 误写成 timeotSeconds。JSON 语法正确,反序列化也可能成功,结果程序拿到默认值。若默认值又被解释成“无限等待”,一次拼写错误就会变成难以复现的请求卡死。

配置加载应当是一条明确的边界:文件输入、语法解析、结构约束、业务验证,最后才生成可供运行中代码使用的状态。本文以 .NET 8、自带 System.Text.Json 和反射序列化为基线;代码是设计示例,未声称已经编译或在实际项目运行。

缺失、null、零和默认值是四种决定

假设配置是:

1
2
3
4
{
"siteUrl": "https://blog.example.com/",
"timeoutSeconds": 15
}

如果 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;

[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed record RawSettings
{
[JsonPropertyName("siteUrl")]
public required string? SiteUrl { get; init; }

[JsonPropertyName("timeoutSeconds")]
public required int? TimeoutSeconds { get; init; }
}

public sealed class ValidatedSettings
{
public Uri SiteUrl { get; }
public TimeSpan Timeout { get; }

private ValidatedSettings(Uri siteUrl, TimeSpan timeout)
=> (SiteUrl, Timeout) = (siteUrl, timeout);

private static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = false,
MaxDepth = 16
};

public static ValidatedSettings Parse(string json)
{
using var document = JsonDocument.Parse(
json, new JsonDocumentOptions { MaxDepth = 16 });

if (document.RootElement.ValueKind != JsonValueKind.Object)
throw new InvalidDataException("配置根节点必须是对象。");

RejectDuplicates(document.RootElement);
var raw = document.RootElement.Deserialize<RawSettings>(Options)
?? throw new InvalidDataException("配置不能为空。");

if (!Uri.TryCreate(raw.SiteUrl, UriKind.Absolute, out var uri)
|| uri.Scheme != Uri.UriSchemeHttps
|| string.IsNullOrWhiteSpace(uri.Host)
|| uri.UserInfo.Length != 0
|| uri.Query.Length != 0
|| uri.Fragment.Length != 0)
{
throw new InvalidDataException(
"siteUrl 必须是无凭据、查询串和片段的 HTTPS 基础地址。");
}

if (raw.TimeoutSeconds is not int seconds
|| seconds < 1 || seconds > 120)
throw new InvalidDataException("timeoutSeconds 必须为 1~120。");

return new ValidatedSettings(uri, TimeSpan.FromSeconds(seconds));
}

private static void RejectDuplicates(JsonElement element)
{
if (element.ValueKind == JsonValueKind.Object)
{
var names = new HashSet<string>(StringComparer.Ordinal);
foreach (var property in element.EnumerateObject())
{
if (!names.Add(property.Name))
throw new InvalidDataException(
$"发现重复配置字段:{property.Name}");
RejectDuplicates(property.Value);
}
}
else if (element.ValueKind == JsonValueKind.Array)
{
foreach (var item in element.EnumerateArray())
RejectDuplicates(item);
}
}
}

这段代码主动限制深度,但没有实现文件大小限制。对于外部输入,还应在读取层设置字节上限,不能等把无限文件读入内存之后再检查字符串长度。小型本地配置采用 DOM 增加一次遍历,换来清晰错误;对大型流式输入,可以用 Utf8JsonReader 实现同样策略,但状态管理和路径诊断更复杂。

递归检查覆盖嵌套对象,而不是只检查根节点。不过本例契约本来只有两个字段,嵌套内容大多会在结构转换时失败。保留递归是为了策略完整,不能把它误当成已经实现任意复杂配置的业务验证。

错误应该保留失败层次

文件不存在、访问被拒绝、取消读取,属于输入层;非法 JSON、字段类型错误、缺少 required 字段或未知属性,属于解析与结构层;合法整数超出业务范围,则属于业务验证层。日志至少保留阶段、字段或 JSON 路径以及当前应用版本,避免只写一句“配置错误”。

同时不要原样记录整份 JSON。今天配置可能没有秘密,明天可能增加令牌;长期保留的日志就会成为意外副本。重复字段名称来自输入,也应经过日志系统的长度和字符处理,避免巨长名称淹没诊断信息。

不要捕获所有异常后改写成“JSON 无效”。例如磁盘故障需要运维介入,用户取消不需要报警,程序自身的空引用更不该伪装成配置拼写错误。错误分类是为了让后续动作正确,而不是为了让所有调用都返回 false。

热加载先生成候选,再发布一个引用

运行中的服务不应该逐字段改共享对象:先改 URL,稍后才改超时,其他线程可能观察到从未通过验证的组合。更稳妥的做法是先得到完整候选快照,验证全部成功后一次替换引用。读请求在开始时取得一次快照,并在本次操作中持续使用它。

下面仅演示已经拿到 JSON 内容之后的内存发布,文件监视、日志与失败策略由调用方负责:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
using System.Threading;

public sealed class SettingsStore
{
private ValidatedSettings _current;

public SettingsStore(ValidatedSettings initial)
{
ArgumentNullException.ThrowIfNull(initial);
_current = initial;
}

public ValidatedSettings Current => Volatile.Read(ref _current);

// Parse 失败会抛出异常,Exchange 尚未发生,因此旧快照保留。
public void Reload(string json)
{
var candidate = ValidatedSettings.Parse(json);
Interlocked.Exchange(ref _current, candidate);
}
}

引用交换不等于解决所有并发问题。两个加载任务可能先后读取新旧文件,却按相反顺序完成解析,最后把旧配置重新发布。调用方应串行处理重载,或用单调递增的版本号拒绝过时结果。文件监视可能重复通知,也可能在写入尚未结束时通知,生产流程还需要合并事件、稳定读取和候选校验。

若重载失败,可继续使用上一份有效配置并记录失败;但首次启动没有旧值,应明确停止启动。不能静默落到一个随意默认地址,否则故障会从启动阶段转移到用户请求中。正在执行的请求一般继续使用旧快照,新请求使用新快照,避免半途改变目标。

URI 验证只说明符合用途

示例拒绝用户凭据、查询串和片段,是因为本例需要一个基础地址,并不是宣称这些 URI 结构普遍不合法。是否允许子路径、非默认端口或指定主机白名单,也应来自实际产品需求。

一个形式正确的 HTTPS 地址仍然可能指向回环、内网或敏感管理接口。若地址来自不可信用户,SSRF 防护还要覆盖解析后的目标、DNS 变化、重定向与网络出口边界。仅检查 Scheme == https 或 IsLoopback,不能证明最终网络请求安全。这里的配置工厂只解决业务契约,不承担完整网络访问控制。

用小型矩阵检验协议

输入变化 本例预期结果
正确地址与整数 15 返回不可变快照
缺少 timeoutSeconds required 结构检查失败
timeoutSeconds 为 null、0、121 业务验证失败
timeoutSeconds 为字符串 “15” 类型转换失败
timeotSeconds 拼错 未知字段失败
同一键出现两次 重复字段检查失败
根节点为 null 或数组 根节点契约失败
重载一份非法配置 旧快照仍可使用

还应补充并发重载顺序、取消读取、超大文件和嵌套深度边界。查看 DOM 时,某些按名称取属性的 API 会在重复成员中返回最后一个匹配,不能拿一次查询成功证明输入没有重复。Microsoft JsonElement 属性查询说明

一个可靠的配置边界,应让调用方拿到“已验证且版本一致的状态”,而不是让每次网络请求重新猜测缺失字段、默认值和错误格式分别意味着什么。


 上一篇
安卓开发:用 ViewModel 和 StateFlow 管理 Compose 页面状态 安卓开发:用 ViewModel 和 StateFlow 管理 Compose 页面状态
以笔记列表为例,明确首次加载、保留旧内容刷新和失败恢复的状态转移。进一步处理取消不及时导致的旧响应覆盖、生命周期收集、消息消费与进程恢复,让 StateFlow 承载可验证的页面状态。
2026-10-16
下一篇 
C# 异步编程:把等待、取消和超时处理清楚 C# 异步编程:把等待、取消和超时处理清楚
从 HTTP 下载的完整生命周期出发,分析协作式取消、总期限、流式响应、资源所有权与竞态分类,并用可控故障场景验证超时是否真正覆盖底层工作。
2026-10-16
  目录