技术速递|.NET 9 中 System.Text.Json 的新增功能
作者:Eirik Tsarpalis - 首席软件工程师
排版:Alan Wang
System.Text.Json 的9.0 版本包含许多功能,主要侧重于 JSON 架构和智能应用程序支持。它还包括一些备受期待的增强功能,例如可空引用类型支持、自定义枚举成员名称、无序元数据反序列化和自定义序列化缩进。
获取最新信息
您可以通过引用 System.Text.Json NuGet 包的最新版本或 .NET 9 的最新 SDK来尝试新功能。
JSON 架构导出器
新的 JsonSchemaExporter 类可以使用 JsonSerializerOptions 或 JsonTypeInfo 实例从 .NET 类型中提取 JSON 架构文档:
using System.Text.Json.Schema;
JsonSerializerOptions options = JsonSerializerOptions.Default;
JsonNode schema = options.GetJsonSchemaAsNode(typeof(Person));
Console.WriteLine(schema.ToString());
//{
// "type": ["object", "null"],
// "properties": {
// "Name": { "type": "string" },
// "Age": { "type": "integer" },
// "Address": { "type": ["string", "null"], "default": null }
// },
// "required": ["Name", "Age"]
//}
record Person(string Name, int Age, string? Address = null);
生成的模式为该类型提供了 JSON 序列化契约的规范。从这个例子中可以看出,它区分了可空属性和不可空属性,并根据构造函数参数是否可选来填充“required”关键字。模式的输出可以通过在 JsonSerializerOptions 或 JsonTypeInfo 实例中指定的配置进行影响:
JsonSerializerOptions options = new(JsonSerializerOptions.Default)
{
PropertyNamingPolicy = JsonNamingPolicy.KebabCaseUpper,
NumberHandling = JsonNumberHandling.WriteAsString,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
};
JsonNode schema = options.GetJsonSchemaAsNode(typeof(MyPoco));
Console.WriteLine(schema.ToString());
//{
// "type": ["object", "null"],
// "properties": {
// "NUMERIC-VALUE": {
// "type": ["string", "integer"],
// "pattern": "^-?(?:0|[1-9]\\d*)$"
// }
// },
// "additionalProperties": false
//}
class MyPoco
{
public int NumericValue { get; init; }
}
用户可以使用 JsonSchemaExporterOptions 配置类型进一步控制生成的模式:
JsonSerializerOptions options = JsonSerializerOptions.Default;
JsonSchemaExporterOptions exporterOptions = new()
{
// 将根级类型标记为不可空
TreatNullObliviousAsNonNullable = true,
};
JsonNode schema = options.GetJsonSchemaAsNode(typeof(Person), exporterOptions);
Console.WriteLine(schema.ToString());
//{
// "type": "object",
// "properties": {
// "Name": { "type": "string" }
// },
// "required": ["Name"]
//}
record Person(string Name);
最后,用户可以通过指定 TransformSchemaNode 委托, 对生成的架构节点应用自己的转换。以下是包含来自 DescriptionAttribute 注释的文本的示例:
JsonSchemaExporterOptions exporterOptions = new()
{
TransformSchemaNode = (context, schema) =>
{
// 确定类型或属性并提取相关属性提供程序
ICustomAttributeProvider? attributeProvider = context.PropertyInfo is not null
? context.PropertyInfo.AttributeProvider
: context.TypeInfo.Type;
//查找任何描述属性
DescriptionAttribute? descriptionAttr = attributeProvider?
.GetCustomAttributes(inherit: true)
.Select(attr => attr as DescriptionAttribute)
.FirstOrDefault(attr => attr is not null);
//将描述属性应用于生成的架构
if (descriptionAttr != null)
{
if (schema is not JsonObject jObj)
{
// 处理架构为布尔值的情况
JsonValueKind valueKind = schema.GetValueKind();
Debug.Assert(valueKind is JsonValueKind.True or JsonValueKind.False);
schema = jObj = new JsonObject();
if (valueKind is JsonValueKind.False)
{
jObj.Add("not", true);
}
}
jObj.Insert(0, "description", descriptionAttr.Description);
}
return schema;
}
};
综合以上内容,我们现在可以生成一个包含来自属性注释的 description 关键字源的模式:
JsonNode schema = options.GetJsonSchemaAsNode(typeof(Person), exporterOptions);
Console.WriteLine(schema.ToString());
//{
// "description": "A person",
// "type": ["object", "null"],
// "properties": {
// "Name": { "description": "The name of the person", "type": "string" }
// },
// "required": ["Name"]
//}
[Description("A person")]
record Person([property: Description("The name of the person")] string Name);
在为 .NET 方法或 API 生成架构时,这是一个特别有用的组件;它被用于支持 ASP.NET Core 最新发布的 OpenAPI 组件,并且我们已将其部署到许多具有工具调用要求的 AI 相关库和应用程序中,例如 Semantic Kernel、Visual Studio Copilot 和最新发布的 Microsoft.Extensions.AI 库。
流式处理多个 JSON 文档
Utf8JsonReader 现在支持从单个缓冲区或流中读取多个以空格分隔的 JSON 文档。默认情况下,如果 Utf8JsonReader 检测到第一个顶级文档后面有任何非空格字符,它将抛出 异常。这可以使用 JsonReaderOptions.AllowMultipleValues 标志来改变:
JsonReaderOptions options = new() { AllowMultipleValues = true };
Utf8JsonReader reader = new("null {} 1 \r\n [1,2,3]"u8, options);
reader.Read();
Console.WriteLine(reader.TokenType); // Null
reader.Read();
Console.WriteLine(reader.TokenType); // StartObject
reader.Skip();
reader.Read();
Console.WriteLine(reader.TokenType); // Number
reader.Read();
Console.WriteLine(reader.TokenType); // StartArray
reader.Skip();
Console.WriteLine(reader.Read()); // False
此外,这还使得从可能包含无效 JSON 尾部数据的有效负载中读取 JSON 成为可能:
Utf8JsonReader reader = new("[1,2,3] <NotJson/>"u8, new() { AllowMultipleValues = true });
reader.Read();
reader.Skip(); // Success
reader.Read(); // throws JsonReaderException
Utf8JsonReader reader = new("[1,2,3] "u8, new() { AllowMultipleValues = true });
在流式反序列化方面,我们添加了新的 JsonSerializer.DeserializeAsyncEnumerable 重载,使流式处理多个顶级值成为可能。默认情况下,DeserializeAsyncEnumerable 将尝试流式处理顶级 JSON 数组中包含的元素。这可以使用新的 topLevelValues 标志切换:
ReadOnlySpan<byte> utf8Json = """[0] [0,1] [0,1,1] [0,1,1,2] [0,1,1,2,3]"""u8;
using var stream = new MemoryStream(utf8Json.ToArray());
await foreach (int[] item in JsonSerializer.DeserializeAsyncEnumerable<int[]>(stream, topLevelValues: true))
{
Console.WriteLine(item.Length);
}
遵循可 null 注释
JsonSerializer 现在为序列化和反序列化中的非空引用类型强制增加了有限的支持。这可以使用 RespectNullableAnnotations 标志进行切换:
#nullable enable
JsonSerializerOptions options = new() { RespectNullableAnnotations = true };
MyPoco invalidValue = new(Name: null!);
JsonSerializer.Serialize(invalidValue, options);
// System.Text.Json.JsonException:类型“MyPoco”上的属性或字段“Name”不允许获取空值。//请考虑更新其可空性注释。
record MyPoco(string Name);
同样,此设置在反序列化时添加了强制执行:
JsonSerializerOptions options = new() { RespectNullableAnnotations = true };
string json = """{"Name":null}""";
JsonSerializer.Deserialize<MyPoco>(json, options);
// System.Text.Json.JsonException:类型“MyPoco”上的构造函数参数“Name”不允许为空值。//请考虑更新其可空性注释。
限制
由于非空引用类型的实现方式,此功能带有一些重要的限制,用户在启用之前需要熟悉这些限制。问题的根源在于引用类型可空性在 IL 中没有一流的表示形式,例如从运行时反射的角度来看,表达式 MyPoco 和 MyPoco? 是无法区分的。虽然编译器会尽可能通过发出属性元数据来弥补这一点,但这仅限于特定类型定义范围内的非泛型成员注解。正是出于这个原因,该标志仅验证非泛型属性、字段和构造函数参数上存在的可空性注释。System.Text.Json 不支持对
顶级类型,也就是进行第一次 JsonSerializer.(De)serialize 调用时传递的类型。
集合元素类型,也就是我们无法区分 List和 List类型。
任何通用的属性、字段或构造函数参数。
如果您希望在这些情况下强制执行可空性验证,建议您将类型建模为 struct(因为结构体不允许空值),或者编写一个自定义转换器,将其 HandleNull 属性重写为 true。
功能开关
用户可以使用 System.Text.Json.Serialization.RespectNullableAnnotationsDefault 功能开关全局打开 RespectNullableAnnotations 设置,该开关可通过项目配置进行设置:
<ItemGroup>
<RuntimeHostConfigurationOption Include="System.Text.Json.Serialization.RespectNullableAnnotationsDefault" Value="true" />
</ItemGroup>
可空参数和可选参数之间的关系
需要注意的是,RespectNullableAnnotations 不会将强制执行范围扩展到未指定的 JSON 值:
JsonSerializerOptions options = new() { RespectNullableAnnotations = true };
var result = JsonSerializer.Deserialize<MyPoco>("{}", options); // No exception!
Console.WriteLine(result.Name is null); // True
class MyPoco
{
public string Name { get; set; }
}
这是因为 STJ 将必需属性和非可空属性视为正交概念。这源于 C# 语言本身,在 C# 语言中,您可以拥有可空的 required 属性:
MyPoco poco = new() { Value = null }; // 没有编译器警告
class MyPoco
{
public required string? Value { get; set; }
}
以及不可为空的可选属性:
class MyPoco
{
public string Value { get; set; } = "default";
}
同样的正交性也适用于构造函数参数:
record MyPoco(
string RequiredNonNullable,
string? RequiredNullable,
string OptionalNonNullable = "default",
string? OptionalNullable = "default");
遵循非可选的构造函数参数
基于 STJ 构造函数的反序列化历来将所有构造函数参数视为可选,如以下示例中所示:
var result = JsonSerializer.Deserialize<Person>("{}");
Console.WriteLine(result); // Person { Name = , Age = 0 }
record Person(string Name, int Age);
在 .NET 9 中,我们包含了 RespectRequiredConstructorParameters 标志,该标志会改变行为,使得非可选的构造函数参数现在被视为必需的:
JsonSerializerOptions options = new() { RespectRequiredConstructorParameters = true };
string json = """{"Optional": "value"}""";
JsonSerializer.Deserialize<MyPoco>(json, options);
record MyPoco(string Required, string? Optional = null);
// JsonException:类型“MyPoco”的 JSON 反序列化缺少必需的属性,包括:“Required”。
功能开关
用户可以使用 System.Text.Json.Serialization.RespectRequiredConstructorParametersDefault 功能开关全局打开 RespectRequiredConstructorParameters 设置,该开关可通过项目配置进行设置:
<ItemGroup>
<RuntimeHostConfigurationOption Include="System.Text.Json.Serialization.RespectRequiredConstructorParametersDefault" Value="true" />
</ItemGroup>
RespectNullableAnnotations 和 RespectRequiredConstructorParameter 属性均作为可选标记实现,以避免破坏现有应用程序。如果您正在编写新应用程序,强烈建议您在代码中启用这两个标记。
自定义枚举成员名称
新的 JsonStringEnumMemberName 特性可以用来为作为字符串序列化的类型中的单个枚举成员自定义名称:
JsonSerializer.Serialize(MyEnum.Value1 | MyEnum.Value2); // "Value1, Custom enum value"
[Flags, JsonConverter(typeof(JsonStringEnumConverter))]
enum MyEnum
{
Value1 = 1,
[JsonStringEnumMemberName("Custom enum value")]
Value2 = 2,
}
无序元数据读取
System.Text.Json 的某些功能(例如多态性或 ReferenceHandler.Preserve)需要在数据传输中发出元数据属性:
JsonSerializerOptions options = new() { ReferenceHandler = ReferenceHandler.Preserve };
Base value = new Derived("Name");
JsonSerializer.Serialize(value, options); // {"$id":"1","$type":"derived","Name":"Name"}
[JsonDerivedType(typeof(Derived), "derived")]
record Base;
record Derived(string Name) : Base;
默认情况下,STJ 元数据读取器要求元数据属性 $id 和 $type 必须在 JSON 对象的开始处定义:
JsonSerializer.Deserialize<Base>("""{"Name":"Name","$type":"derived"}""");
// JsonException: The metadata property is either not supported by the
// type or is not the first property in the deserialized JSON object.
众所周知,当需要反序列化不是来自 System.Text.Json 的 JSON 有效负载时,这会产生问题。可以配置新的 AllowOutOfOrderMetadataProperties 来禁用此限制:
JsonSerializerOptions options = new() { AllowOutOfOrderMetadataProperties = true };
JsonSerializer.Deserialize<Base>("""{"Name":"Name","$type":"derived"}""", options); // Success
启用此标志时应小心谨慎,因为在对非常大的 JSON 对象执行流式反序列化时,它可能会导致缓冲过度(和 OOM 故障)。这是因为元数据属性必须在实例化反序列化对象之前读取,这意味着所有位于 $type 属性之前的属性必须保留在缓冲区中,以便后续的属性绑定。
自定义缩进
JsonWriterOptions 和 JsonSerializerOptions 类型现在公开了用于配置缩进的 API。以下示例启用了单制表符缩进:
JsonSerializerOptions options = new()
{
WriteIndented = true,
IndentCharacter = '\t',
IndentSize = 1,
};
JsonSerializer.Serialize(new { Value = 42 }, options);
JsonObject 属性顺序操作
JsonObject 类型是可变 DOM 的一部分,用于表示 JSON 对象。尽管该类型被建模为 IDictionary,但它确实封装了用户不可修改的隐式属性顺序。新版本公开了其他 API,可有效地将该类型建模为有序字典:
public partial class JsonObject : IList<KeyValuePair<string, JsonNode?>>
{
public int IndexOf(string key);
public void Insert(int index, string key, JsonNode? value);
public void RemoveAt(int index);
}
这允许修改可以直接影响属性顺序的对象实例:
// 将 $id 属性添加或移动到对象的开头
var schema = (JsonObject)JsonSerializerOptions.Default.GetJsonSchemaAsNode(typeof(MyPoco));
switch (schema.IndexOf("$id", out JsonNode? idValue))
{
case < 0: // 缺少 $id 属性
idValue = (JsonNode)"https://example.com/schema";
schema.Insert(0, "$id", idValue);
break;
case 0: // $id 属性已位于对象的开头
break;
case int index: //$id 存在但不在对象的开头
schema.RemoveAt(index);
schema.Insert(0, "$id", idValue);
}
JsonElement 和 JsonNode 中的 DeepEquals 方法
新的 JsonElement.DeepEquals 方法扩展了对 JsonElement 实例的深度相等比较,补充了已有的 JsonNode.DeepEquals 方法。此外,这两个方法在其实现中进行了改进,例如处理等效 JSON 数字表示的方式:
JsonElement left = JsonDocument.Parse("10e-3").RootElement;
JsonElement right = JsonDocument.Parse("0.001").RootElement;
JsonElement.DeepEquals(left, right); // True
JsonSerializerOptions.Web
新的 JsonSerializerOptions.Web 单例可以使用 JsonSerializerDefaults.Web 设置快速序列化值:
JsonSerializerOptions options = JsonSerializerOptions.Web; // 用来代替 new(JsonSerializerDefaults.Web);
JsonSerializer.Serialize(new { Value = 42 }, options); // {"value":42}
性能改进
有关 .NET 9 中 System.Text.Json 性能改进的详细说明,请参阅 Stephen Toub 的“.NET 9 中的性能改进”文章中的相关部分。
结束语
.NET 9 拥有大量新功能和使用质量改进,重点是 JSON 架构和智能应用程序支持。在 .NET 9 开发期间,共有 46 个拉取请求为 System.Text.Json 做出贡献。我们希望您尝试新功能并向我们提供反馈,告诉我们它如何改进您的应用程序,以及您可能遇到的任何可用性问题或错误。
我们随时欢迎社区贡献。如果您想为 System.Text.Json 做出贡献,请查看我们在 GitHub 上的求助问题列表。