大多数 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.Json | Newtonsoft.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、JsonNode | JObject、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。