在 .NET 中用 System.Text.Json 处理格式错误的 JSON
ReadFromJsonAsync 如何处理损坏和刁钻的 JSON、哪些情况不会抛出异常,以及一个把每种情况都变成清楚错误的读取器。
示例最后一次针对 API 运行于
API 可能用一个不是合法 JSON 的响应体返回 200 OK:被代理截断的响应、空响应体、HTML 错误页、类型不对的值。你的 .NET 代码如何应对,决定了用户看到的是清楚的错误,还是一次崩溃加上日志深处的一个 JsonException。要测试这一点,你需要故意损坏的响应。https://api.testingapis.com 提供了一整套,供已登录成员免费使用。本指南准确展示 System.Text.Json 和 ReadFromJsonAsync 如何处理每一种、哪些本该抛出异常却没有抛出、一个安全的读取器,以及 xUnit 测试。所有内容都在 .NET 10 上运行过。
$ curl -i https://api.testingapis.com/api/formats/malformed-json
HTTP/1.1 200 OK
content-length: 61
content-type: application/json; charset=utf-8
{"id": 1, "name": "Alice", "tags": ["a", "b",], "active": tru这 61 个字节里有两处错误:"b" 后面多了一个逗号,响应体在 true 中间被截断。状态码仍然是 200,内容类型仍然声明是 JSON。
按需获取损坏和刁钻的 JSON
| 请求 | 返回什么 |
|---|---|
GET /api/formats/malformed-json登录后查看 | 200,application/json,无效且被截断的 JSON |
GET /api/formats/empty登录后查看 | 响应体为空的 200 |
GET /api/formats/no-content登录后查看 | 204 No Content |
GET /api/formats/wrong-content-type登录后查看 | 标记为 text/html 的合法 JSON |
GET /api/formats/bom登录后查看 | 前面带 UTF-8 字节顺序标记的合法 JSON |
GET /api/edge-cases/duplicate-keys登录后查看 | 同一个键出现两次的合法 JSON |
GET /api/edge-cases/numbers登录后查看 | int64 边界值、超出 JavaScript 安全范围的数字、30 位整数、以字符串形式出现的数字 |
GET /api/edge-cases/top-level/string登录后查看 | 期望对象的地方出现了一个 JSON 字符串 |
GET /api/edge-cases/mixed-array登录后查看 | 一个混有数字、字符串、布尔值、null、对象和数组的数组 |
ReadFromJsonAsync 如何处理每一种
每个响应都读入一个简单的 record。无效的 JSON 会抛出 JsonException,其 Path 指向问题所在;有三种情况根本不会抛出异常:
using System.Net.Http.Json;
using System.Text.Json;
using var http = new HttpClient { BaseAddress = new Uri("https://api.testingapis.com") };
async Task Read<T>(string path, JsonSerializerOptions? options = null)
{
try
{
using var response = await http.GetAsync(path);
var value = await response.Content.ReadFromJsonAsync<T>(options);
Console.WriteLine($"{path,-38} OK {JsonSerializer.Serialize(value)}");
}
catch (JsonException ex)
{
Console.WriteLine($"{path,-38} JsonException at {ex.Path}: {ex.Message[..ex.Message.IndexOf(". ")]}");
}
}
await Read<Person>("/api/formats/malformed-json");
await Read<Person>("/api/formats/empty");
await Read<Person>("/api/formats/no-content");
await Read<Person>("/api/formats/wrong-content-type");
await Read<Person>("/api/formats/bom");
await Read<Person>("/api/edge-cases/duplicate-keys");
await Read<Person>("/api/edge-cases/top-level/string");
await Read<List<int>>("/api/edge-cases/mixed-array");
record Person(int Id, string Name);/api/formats/malformed-json JsonException at $.tags: The JSON array contains a trailing comma at the end which is not supported in this mode
/api/formats/empty JsonException at $: The input does not contain any JSON tokens
/api/formats/no-content JsonException at $: The input does not contain any JSON tokens
/api/formats/wrong-content-type OK {"Id":1,"Name":"Alice Johnson"}
/api/formats/bom OK {"Id":1,"Name":"Alice Johnson"}
/api/edge-cases/duplicate-keys OK {"Id":2,"Name":"second value"}
/api/edge-cases/top-level/string JsonException at $: The JSON value could not be converted to Person
/api/edge-cases/mixed-array JsonException at $[1]: The JSON value could not be converted to System.Int32| 响应 | 结果 | 为什么重要 |
|---|---|---|
| 被截断或无效的 JSON | JsonException,带 Path、LineNumber 和 BytePositionInLine | 要捕获它并报告是哪个调用出了问题,否则它会变成一次原因不明的崩溃 |
| 空的 200 或 204 | JsonException:没有 JSON 标记 | 如果 API 可能返回空响应体,解析前先检查 |
| 标记为 text/html 的 JSON | 照常解析,不报错 | ReadFromJsonAsync 不检查 Content-Type。真正的 HTML 错误页会在之后以无效 JSON 的形式失败 |
| 字节顺序标记 | 正常解析 | 自动处理 |
| 同一个键出现两次 | 正常解析:最后一个值悄悄胜出 | id 变成了 2,name 变成了 "second value"。可以用 AllowDuplicateProperties = false(.NET 10)把它变成错误 |
| 形状不对(字符串、混合数组) | 在第一个错误的值处抛出 JsonException | Path 指出位置:根为 $,第二个元素为 $[1] |
数字
C# 处理大整数比 JavaScript 好:long 能精确保存 9007199254740993,而 JavaScript 会把它四舍五入。但 30 位整数放不进 long,以字符串形式发送的数字则取决于你的选项。ReadFromJsonAsync 使用 Web 默认值,会接受它们;没有设置 NumberHandling.AllowReadingFromString 的选项则不会。
using System.Net.Http.Json;
using System.Text.Json;
using var http = new HttpClient { BaseAddress = new Uri("https://api.testingapis.com") };
using var response = await http.GetAsync("/api/edge-cases/numbers");
var json = await response.Content.ReadAsStringAsync();
Console.WriteLine(JsonSerializer.Serialize(
JsonSerializer.Deserialize<Numbers>(json, JsonSerializerOptions.Web)));
// The same field, read with the web defaults and without them.
var strict = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
foreach (var (label, options) in new[] { ("Web defaults", JsonSerializerOptions.Web), ("Without web defaults", strict) })
{
try
{
var value = JsonSerializer.Deserialize<AsString>(json, options);
Console.WriteLine($"{label}: \"9223372036854775807\" read as {value!.Int64MaxAsString}");
}
catch (JsonException ex) { Console.WriteLine($"{label}: JsonException at {ex.Path}"); }
}
try { JsonSerializer.Deserialize<TooBig>(json, JsonSerializerOptions.Web); }
catch (JsonException ex) { Console.WriteLine($"bigInteger into long: JsonException at {ex.Path}"); }
record Numbers(long Int64Max, long BeyondSafeInteger, double PointOnePlusPointTwo);
record AsString(long Int64MaxAsString);
record TooBig(long BigInteger);{"Int64Max":9223372036854775807,"BeyondSafeInteger":9007199254740993,"PointOnePlusPointTwo":0.30000000000000004}
Web defaults: "9223372036854775807" read as 9223372036854775807
Without web defaults: JsonException at $.int64MaxAsString
bigInteger into long: JsonException at $.bigInteger安全的读取器:每个坏响应体都对应一个清楚的错误
先把响应体读成字符串,这样错误信息可以引用它,然后再解析。每次失败都会变成一个 InvalidDataException,写明 URL、内容类型、解析停在哪里以及响应体的开头。这正是凌晨 3 点查日志时你需要的东西。
using System.Text.Json;
public static class JsonResponses
{
// Web defaults (camelCase, case-insensitive, numbers in strings allowed), but a repeated
// property is an error instead of the last value silently winning (.NET 10).
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
AllowDuplicateProperties = false,
};
/// Reads a JSON body, or throws an InvalidDataException that says what came back and where it broke.
public static async Task<T> ReadJsonAsync<T>(this HttpResponseMessage response, CancellationToken cancellationToken = default)
{
response.EnsureSuccessStatusCode();
var url = response.RequestMessage?.RequestUri;
var contentType = response.Content.Headers.ContentType?.ToString() ?? "no content type";
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(body))
{
throw new InvalidDataException($"{(int)response.StatusCode} from {url} had an empty body ({contentType}).");
}
try
{
return JsonSerializer.Deserialize<T>(body, Options)
?? throw new InvalidDataException($"{url} returned JSON null.");
}
catch (JsonException ex)
{
var preview = body.Length > 120 ? body[..120] + "..." : body;
throw new InvalidDataException(
$"Invalid JSON from {url} ({contentType}) at {ex.Path}, line {ex.LineNumber}, byte {ex.BytePositionInLine}: {preview}", ex);
}
}
}using var http = new HttpClient { BaseAddress = new Uri("https://api.testingapis.com") };
foreach (var path in new[] { "/api/Products/1", "/api/formats/malformed-json", "/api/formats/empty", "/api/edge-cases/duplicate-keys" })
{
try
{
using var response = await http.GetAsync(path);
var item = await response.ReadJsonAsync<Item>();
Console.WriteLine($"OK: {item}");
}
catch (InvalidDataException ex)
{
Console.WriteLine(ex.Message);
}
}
record Item(int Id);OK: Item { Id = 1 }
Invalid JSON from https://api.testingapis.com/api/formats/malformed-json (application/json; charset=utf-8) at $.tags, line 0, byte 45: {"id": 1, "name": "Alice", "tags": ["a", "b",], "active": tru
200 from https://api.testingapis.com/api/formats/empty had an empty body (no content type).
Invalid JSON from https://api.testingapis.com/api/edge-cases/duplicate-keys (application/json; charset=utf-8) at $.id, line 5, byte 7: {
"id": 1,
"name": "first value",
"name": "second value",
"nested": { "flag": true, "flag": false },
"id": 2
}重复项在 $.id 第 5 行被报告:Item 只有一个 Id,所以那就是它关心的重复属性。类型没有映射的键无论如何都会被跳过。
xUnit 测试
一个 theory 覆盖所有损坏的响应;一个 fact 检查正常的 JSON 仍能读取。它们使用上面的 JsonResponses 类,在 .NET 10 上用 xUnit 2.9 通过。
using Xunit;
public class JsonResponseTests
{
private static readonly HttpClient Http = new() { BaseAddress = new Uri("https://api.testingapis.com") };
private record Item(int Id);
[Theory]
[InlineData("/api/formats/malformed-json")]
[InlineData("/api/formats/empty")]
[InlineData("/api/edge-cases/duplicate-keys")]
[InlineData("/api/edge-cases/top-level/string")]
public async Task Bad_json_becomes_one_clear_error(string path)
{
using var response = await Http.GetAsync(path);
var ex = await Assert.ThrowsAsync<InvalidDataException>(() => response.ReadJsonAsync<Item>());
Assert.Contains(path, ex.Message); // the message says which call broke
}
[Fact]
public async Task Good_json_still_reads()
{
using var response = await Http.GetAsync("/api/Products/1");
var item = await response.ReadJsonAsync<Item>();
Assert.Equal(1, item.Id);
}
}在客户端中要检查什么
- 状态码为 200 的无效 JSON 是一个错误,而不是崩溃:被捕获、连同 URL 一起记录、向用户展示。
- 如果 API 可能返回空响应体或 204,在解析前就处理掉。
- 在悄悄“最后一个胜出”会掩盖 bug 的地方,拒绝重复的键。
- 大 ID 要放得进它的类型:
long,或者在可能超出时用string。 - 错误信息要引用足够多的响应体以便诊断,但不要记录敏感信息。
同样的响应在其他语言中也有用:JSON 边界情况参考和格式参考列出了所有响应,按需返回错误则介绍了失败的状态码。