Menu

C# JSON:用 System.Text.Json 序列化与反序列化

如何在 C# 中用 System.Text.Json 处理 JSON:JsonSerializer.Serialize 与 Deserialize,camelCase 与缩进输出,JsonPropertyName 和 JsonIgnore 等特性,把枚举写成字符串,用 JsonDocument 和 JsonNode 在没有类的情况下读取 JSON,错误处理,以及它与 Newtonsoft.Json 的对比。

大多数 C# 程序都用 JSON 与 Web API 通信、保存设置和交换数据。现代 .NET 用 System.Text.Json 读写 JSON,它从 .NET Core 3.0 起就是运行时的一部分,不需要 NuGet 包。它的主要入口是静态类 JsonSerializer,负责把对象转换成 JSON 字符串,也能再转换回来。

System.Text.Json 内置于 .NET Core 3.0 及之后的每个版本(.NET 5 到 .NET 10)。使用 .NET Framework 4.6.2 及以上版本或 .NET Standard 2.0 的项目,也可以通过安装 System.Text.Json NuGet 包来使用它。本页的示例以普通代码展示,输出写在注释里。要在 .NET 项目中运行它们,请添加这些 using 指令:

using System.Text.Json;
using System.Text.Json.Serialization;

序列化:对象转 JSON

JsonSerializer.Serialize 会写出对象的每一个 public 属性,属性名保持原样:

public class Product
{
    public string Name { get; set; }
    public decimal Price { get; set; }
    public List<string> Tags { get; set; } = new List<string>();
    public bool InStock { get; set; }
}

var lamp = new Product { Name = "Desk lamp", Price = 34.90m, Tags = { "home", "light" }, InStock = true };

string json = JsonSerializer.Serialize(lamp);
Console.WriteLine(json);
// {"Name":"Desk lamp","Price":34.90,"Tags":["home","light"],"InStock":true}

集合会变成数组,键为字符串的字典会变成对象({"apples":3,"pears":5}),null 仍然是 null,DateTime 会变成 ISO 8601 字符串("2026-03-01T14:30:00")。有两个默认行为常常让人踩坑:

  • 字段会被跳过。 只有属性会被序列化。一个带有 public int X; 的类会被序列化成 {},除非你在选项中设置 IncludeFields = true,或者把字段改成属性。
  • 枚举是数字。 OrderStatus.Shipped 会被写成 1(枚举的底层值)。加上 JsonStringEnumConverter(见下文)才会写成 "Shipped"。

反序列化:JSON 转对象

JsonSerializer.Deserialize<T> 会创建一个 T,并根据 JSON 设置它的属性:

string json = "{\"Name\":\"Desk lamp\",\"Price\":34.90,\"Tags\":[\"home\",\"light\"]}";
Product p = JsonSerializer.Deserialize<Product>(json);
Console.WriteLine($"{p.Name} {p.Price} {p.Tags.Count}");   // Desk lamp 34.90 2

var many = JsonSerializer.Deserialize<List<Product>>("[{\"Name\":\"A\",\"Price\":1},{\"Name\":\"B\",\"Price\":2.5}]");
Console.WriteLine(many.Count);                               // 2

没有对应 C# 属性的 JSON 字段会被忽略,没有对应 JSON 字段的 C# 属性则保持默认值。默认情况下这两种情况都不算错误。

几乎每个人都会被这条规则绊倒:名称匹配区分大小写。大多数 Web API 发送的是 camelCase,而 camelCase 与 PascalCase 的属性匹配不上:

string fromApi = "{\"name\":\"Mug\",\"price\":8.5}";

var a = JsonSerializer.Deserialize<Product>(fromApi);
Console.WriteLine($"[{a.Name}] {a.Price}");    // [] 0: nothing matched, no error

var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var b = JsonSerializer.Deserialize<Product>(fromApi, options);
Console.WriteLine($"[{b.Name}] {b.Price}");    // [Mug] 8.5

new JsonSerializerOptions(JsonSerializerDefaults.Web) 提供的是 ASP.NET Core 使用的设置:读取时不区分大小写,写入时用 camelCase,并接受写成带引号字符串的数字。

反序列化需要一种设置每个值的途径:public setter、init 访问器,或者参数名与属性匹配的构造函数。正是最后这条规则让 record 开箱即用:

public record Point(int X, int Y);

Point pt = JsonSerializer.Deserialize<Point>("{\"X\":1,\"Y\":2}");
Console.WriteLine(pt);   // Point { X = 1, Y = 2 }

选项:camelCase 与缩进输出

JsonSerializerOptions 控制命名、格式等方面。创建一个实例并重复使用:序列化器会按选项实例缓存元数据,所以每次调用都新建选项会明显变慢。

private static readonly JsonSerializerOptions Options = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    WriteIndented = true,
};

Console.WriteLine(JsonSerializer.Serialize(lamp, Options));
// {
//   "name": "Desk lamp",
//   "price": 34.90,
//   "tags": [
//     "home",
//     "light"
//   ],
//   "inStock": true
// }

其他值得了解的选项:DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull 用于省略值为 null 的属性,IncludeFields = true,NumberHandling 用于接受写成字符串的数字,以及用于手工编辑的配置文件的 ReadCommentHandling = JsonCommentHandling.Skip 加上 AllowTrailingCommas = true。.NET 8 新增了 JsonNamingPolicy.SnakeCaseLower,适用于使用 snake_case 的 API。

特性:重命名、忽略、把枚举写成字符串

写在类上的特性一次控制一个属性,并且优先于选项:

public enum OrderStatus { Pending, Shipped }

public class Order
{
    [JsonPropertyName("order_id")]
    public int Id { get; set; }

    public string Customer { get; set; }

    [JsonIgnore]
    public string InternalNote { get; set; }                  // never written or read

    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
    public string Coupon { get; set; }                        // left out when null

    [JsonConverter(typeof(JsonStringEnumConverter))]
    public OrderStatus Status { get; set; }

    public DateTime PlacedAt { get; set; }
}

var order = new Order
{
    Id = 1042, Customer = "Ana", InternalNote = "vip",
    Status = OrderStatus.Shipped, PlacedAt = new DateTime(2026, 3, 1, 14, 30, 0),
};
Console.WriteLine(JsonSerializer.Serialize(order));
// {"order_id":1042,"Customer":"Ana","Status":"Shipped","PlacedAt":"2026-03-01T14:30:00"}

如果想把所有枚举都写成字符串,而不是逐个标记属性,就把转换器加到选项中:options.Converters.Add(new JsonStringEnumConverter());。

不用类读取 JSON:JsonDocument 与 JsonNode

当你只需要从一个很大的响应中取几个值,或者它的结构不固定时,就不必定义类。JsonDocument 会把 JSON 解析成一棵由 JsonElement 值组成的只读树:

string weather = "{\"city\":\"Lisbon\",\"current\":{\"temp\":21.5,\"conditions\":[\"sunny\",\"windy\"]},\"alerts\":null}";

using (JsonDocument doc = JsonDocument.Parse(weather))
{
    JsonElement root = doc.RootElement;
    Console.WriteLine(root.GetProperty("city").GetString());                        // Lisbon
    Console.WriteLine(root.GetProperty("current").GetProperty("temp").GetDecimal()); // 21.5

    foreach (JsonElement c in root.GetProperty("current").GetProperty("conditions").EnumerateArray())
        Console.WriteLine(c.GetString());                                            // sunny, windy

    Console.WriteLine(root.TryGetProperty("humidity", out _));                       // False
    Console.WriteLine(root.GetProperty("alerts").ValueKind);                         // Null
}

名称不存在时 GetProperty 会抛出 KeyNotFoundException,所以可选字段要用 TryGetProperty。JsonDocument 会租用池化的内存,这就是为什么要用 using 释放它。

要修改 JSON,就使用 System.Text.Json.Nodes 中的 JsonNode(.NET 6 及以后),它提供一棵带索引器、可修改的树:

JsonNode node = JsonNode.Parse(weather);
Console.WriteLine((string)node["city"]);   // Lisbon

node["current"]["temp"] = 23;
node["updated"] = true;
Console.WriteLine(node.ToJsonString());
// {"city":"Lisbon","current":{"temp":23,"conditions":["sunny","windy"]},"alerts":null,"updated":true}

文件与流

磁盘上的 JSON 就是文件里的一个字符串,所以文件方法可以直接与序列化器配合使用:

File.WriteAllText("settings.json", JsonSerializer.Serialize(settings, Options));
var loaded = JsonSerializer.Deserialize<Settings>(File.ReadAllText("settings.json"), Options);
// Same options both ways: camelCase names written with Options would not match on a default read.

对于大文件和 HTTP 请求体,异步的流重载可以避免在内存中构建整个字符串:

await using FileStream stream = File.OpenRead("orders.json");
List<Order> orders = await JsonSerializer.DeserializeAsync<List<Order>>(stream);

在 ASP.NET Core 中以及使用 HttpClient 时,你很少需要自己调用序列化器:控制器会自动绑定 JSON 请求体,而 httpClient.GetFromJsonAsync<Order>(url)(位于 System.Net.Http.Json)一次调用就完成请求和反序列化。

错误

无效的 JSON,或者无法转换成属性类型的值,会抛出 JsonException。它的消息会指出 JSON 路径和位置,通常足以定位问题:

try
{
    JsonSerializer.Deserialize<Product>("{\"Name\": \"Lamp\", \"Price\": \"cheap\"}");
}
catch (JsonException e)
{
    Console.WriteLine(e.Message);
    // The JSON value could not be converted to System.Decimal. Path: $.Price | LineNumber: 0 | BytePositionInLine: 33.
}

当 JSON 文本就是字面量 null 时,Deserialize 返回 null(而不是抛出异常),所以当输入来自外部时要检查结果。

输出中的转义字符

默认情况下,序列化器会转义非 ASCII 字符和在 HTML 中不安全的字符:

Console.WriteLine(JsonSerializer.Serialize(new { city = "São Paulo", note = "5 > 3" }));
// {"city":"S\u00E3o Paulo","note":"5 \u003E 3"}

这是合法的 JSON,读回来就是原始文本;之所以转义,是为了让输出可以安全地放进 HTML 页面。如果要生成给人阅读的文件,就在选项中设置 Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping(来自 System.Text.Encodings.Web)。其中的“unsafe”仅指把结果嵌入 HTML 这件事。

System.Text.Json 与 Newtonsoft.Json 对比

Newtonsoft.Json(Json.NET,即 JsonConvert 类)做了十年的事实标准,至今仍随处可见。主要区别如下:

System.Text.JsonNewtonsoft.Json
可用性内置于 .NET Core 3.0+;.NET Framework 4.6.2+ 可用 NuGet 包.NET Framework 和 .NET 均通过 NuGet 包使用
序列化 / 反序列化JsonSerializer.Serialize(obj) / Deserialize<T>(json)JsonConvert.SerializeObject(obj) / DeserializeObject<T>(json)
名称匹配默认区分大小写不区分大小写
无类型读取JsonDocument、JsonNodeJObject、JToken,支持 JSONPath 查询
重命名属性[JsonPropertyName("x")][JsonProperty("x")]
宽松程度严格:除非启用,否则不允许注释、末尾逗号或带引号的数字默认宽松
性能更快,内存分配更少,支持用于裁剪和 AOT 的源代码生成较慢,基于反射

两者的特性名称和命名空间不同,所以迁移一个代码库需要做一轮查找替换,再为更严格的解析补上测试。写新的 .NET 代码,就从 System.Text.Json 开始。

源代码生成(.NET 6+)

JsonSerializer 通常在运行时用反射检查你的类型。对于经过裁剪或提前编译的应用(Native AOT、Blazor WebAssembly),可以改用源代码生成器在编译时生成这部分代码:

[JsonSerializable(typeof(Product))]
internal partial class AppJsonContext : JsonSerializerContext { }

string json = JsonSerializer.Serialize(lamp, AppJsonContext.Default.Product);

常见错误

  • 用默认选项把 camelCase 的 JSON 读进 PascalCase 的属性。 每个属性都悄无声息地保持为空。请使用 PropertyNameCaseInsensitive 或 JsonSerializerDefaults.Web。
  • 用 public 字段代替属性。 除非设置了 IncludeFields,否则字段不会被序列化。
  • 没有 setter 的属性。 只读属性如果没有对应的构造函数参数,反序列化时不会被填充。
  • 每次调用都新建 JsonSerializerOptions。 重复使用一个静态实例。
  • 用浮点数表示金额。 0.1 + 0.2 的 double 会被序列化成 0.30000000000000004。金额请使用 decimal。

常见问题

C# 中如何把对象转换成 JSON?

调用 System.Text.Json 中的 JsonSerializer.Serialize(obj),它内置于 .NET Core 3.0 及以后的版本,无需安装任何包。它会写出所有 public 属性:{"Name":"Desk lamp","Price":34.90}。传入 new JsonSerializerOptions { WriteIndented = true } 可以得到易读的输出,传入 PropertyNamingPolicy = JsonNamingPolicy.CamelCase 可以得到 camelCase 名称。

C# 中如何把 JSON 转换成对象?

var product = JsonSerializer.Deserialize<Product>(json); 会创建一个 Product,并根据 JSON 中名称匹配的字段填充它的 public 可写属性。默认情况下名称匹配区分大小写,所以 camelCase 的 JSON 会让 PascalCase 的属性保持为空,除非你传入 PropertyNameCaseInsensitive = true 或 new JsonSerializerOptions(JsonSerializerDefaults.Web)。格式错误的 JSON 或类型不对的值会抛出 JsonException。

C# 中如何不创建类就读取 JSON?

使用 JsonDocument.Parse(json),再用 GetProperty("name")、GetString()、GetInt32() 和 EnumerateArray() 遍历 RootElement;它是只读的,速度快,用完必须释放。如果你想修改 JSON,JsonNode.Parse(json)(.NET 6+)会给你一棵可修改的树:node["city"]、赋值,以及 ToJsonString()。

应该用 System.Text.Json 还是 Newtonsoft.Json?

在 .NET Core 3.0 或更高版本上写新代码,就用 System.Text.Json:它是内置的,速度更快,内存分配更少,而且 ASP.NET Core 默认使用它。Newtonsoft.Json(Json.NET)仍然常见于 .NET Framework 项目(在那里 System.Text.Json 只能作为 NuGet 包使用)、依赖其额外功能的代码(JSONPath 查询、非常宽松的解析、TypeNameHandling),以及早已建立在它之上的大型代码库。

为什么 System.Text.Json 会转义 é 和 < 这样的字符?

默认的编码器会把非 ASCII 字符和 HTML 敏感字符(<、>、&、')转义成 \uXXXX,这样输出可以安全地嵌入 HTML。它仍然是合法的 JSON,反序列化后会还原成原来的文本。如果想要易读的输出,可以在选项中设置 Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,但前提是这段 JSON 不会被写进 HTML。

Coddy 编程语言插图

和 Coddy 一起学编程

开始