在 .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 上运行过。

Terminal
$ 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 指向问题所在;有三种情况根本不会抛出异常:

Program.cs
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
这个示例使用的是面向已登录成员的端点。登录创建免费账户
响应结果为什么重要
被截断或无效的 JSONJsonException,带 Path、LineNumber 和 BytePositionInLine要捕获它并报告是哪个调用出了问题,否则它会变成一次原因不明的崩溃
空的 200 或 204JsonException:没有 JSON 标记如果 API 可能返回空响应体,解析前先检查
标记为 text/html 的 JSON照常解析,不报错ReadFromJsonAsync 不检查 Content-Type。真正的 HTML 错误页会在之后以无效 JSON 的形式失败
字节顺序标记正常解析自动处理
同一个键出现两次正常解析:最后一个值悄悄胜出id 变成了 2,name 变成了 "second value"。可以用 AllowDuplicateProperties = false(.NET 10)把它变成错误
形状不对(字符串、混合数组)在第一个错误的值处抛出 JsonExceptionPath 指出位置:根为 $,第二个元素为 $[1]

数字

C# 处理大整数比 JavaScript 好:long 能精确保存 9007199254740993,而 JavaScript 会把它四舍五入。但 30 位整数放不进 long,以字符串形式发送的数字则取决于你的选项。ReadFromJsonAsync 使用 Web 默认值,会接受它们;没有设置 NumberHandling.AllowReadingFromString 的选项则不会。

Program.cs
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 点查日志时你需要的东西。

JsonResponses.cs
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);
        }
    }
}
Program.cs
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 通过。

JsonResponseTests.cs
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 边界情况参考和格式参考列出了所有响应,按需返回错误则介绍了失败的状态码。