3.【.NET10 实战--孢子记账--产品智能化】--.NET 10 核心新特性概览:运行时与 ASP.NET Core 10

在完成升级前的准备工作之后,真正进入 .NET 10 时,最应该先看清楚的并不是某一个零散的 API,而是整个平台的默认行为正在发生什么变化。.NET 10 作为新的 LTS 版本,一方面继续沿着 .NET 8 和 .NET 9 已经铺开的高性能路线向前推进,另一方面也把 ASP.NET Core 在验证、OpenAPI、可观测性和安全行为上的默认体验推向了更现代的方向。对于孢子记账这样的微服务系统来说,这些变化并不是"知道有这个特性"就结束了,它们会直接影响接口定义、启动速度、内存占用、调试方式以及后续章节里的代码组织方式。因此这一章不急着改业务代码,而是先结合微软最新官方文档,把 .NET 10 在运行时、ASP.NET Core 和基础类库层面的关键变化看透,这样后面真正动手升级时,很多调整就不再是被动排错,而是有明确收益预期的升级改造。

一、运行时(Runtime)新特性

1.1 JIT 编译器改进

.NET 10 在运行时层面最值得关注的变化,首先来自 JIT 编译器。微软在官方文档里把这一轮改进概括为几个关键词:结构体参数代码生成优化、循环反转增强、数组接口方法去虚化、内联策略提升以及代码布局改进。把这些名字翻译成更接近项目实践的语言,其实就是一句话:JIT 现在更擅长把看起来抽象、间接、层层封装的代码,重新压缩成更接近底层机器执行习惯的形态。

先看一个最容易说明问题的例子。下面这段代码出自微软官方文档,它演示的是结构体参数在调用过程中的代码生成优化。

csharp 复制代码
using System;
using System.Runtime.CompilerServices;

struct Point
{
	public int X;
	public int Y;

	public Point(int x, int y)
	{
		X = x;
		Y = y;
	}
}

[MethodImpl(MethodImplOptions.NoInlining)]
static void Consume(Point p)
{
	Console.WriteLine(p.X + p.Y);
}

static void Main()
{
	Point p = new Point(10, 20);
	Consume(p);
}

这段代码表面上很普通,只是构造了一个 Point 并把它传给 Consume。但在 JIT 看来,真正关键的点不在 Console.WriteLine,而在 Point 这个结构体参数究竟怎样从调用者传递到被调用者。在旧一些的实现里,如果结构体成员需要被打包到同一个寄存器中,JIT 常常会先把值落到内存,再从内存重新装载到寄存器里。这个过程在功能上当然没问题,但它引入了不必要的内存访问。到了 .NET 10,JIT 对这种场景的内部表示做了改进,能够直接把经过物理提升的结构体成员放进共享寄存器,而不再经过那次中转。对于业务代码来说,你一行都不用改;但对于高频调用路径来说,这意味着更少的指令、更少的访存以及更紧凑的机器码。

官方同时强调,.NET 10 还改进了循环反转的识别方式。过去它更多依赖词法层面的分析,现在切换成了基于控制流图的自然循环识别。这个变化听起来很底层,但意义很实际,因为循环克隆、循环展开、归纳变量优化这些进一步的优化,往往都建立在循环已经被整理成适合分析的形状之上。也就是说,.NET 10 并不是只优化某一个技巧,而是在为更多后续优化创造前提条件。

另一个和日常代码距离很近的变化,是数组接口方法去虚化。先看官方文档给出的这种写法。

csharp 复制代码
using System.Collections.Generic;

static int Sum(int[] array)
{
	int sum = 0;
	IEnumerable<int> temp = array;

	foreach (var num in temp)
	{
		sum += num;
	}

	return sum;
}

这段代码和最朴素的 for 循环相比,多了一层 IEnumerable<int> 抽象。过去一旦写成这种形式,JIT 对数组枚举器的很多调用就不容易去虚化,后续的内联、边界检查消除、栈上分配等优化也会被连带影响。.NET 10 的改进点就在这里:当底层对象其实就是数组时,JIT 现在能识别这种接口调用背后的真实类型,并把这些调用去虚化、内联化。换句话说,代码依然保持了较高层次的表达方式,但运行时离"直接遍历数组"的执行形态又更近了一步。

除了上面两个最容易看见的点,.NET 10 的内联器也有两个很关键的提升。第一,它现在可以处理一部分带有 try-finally 语义的方法内联;第二,它会更积极地利用 profile data,也就是热点调用数据,来放宽对内联候选方法大小的限制。这两个改动叠加起来的结果,就是热路径更容易被压平,调用栈更浅,间接跳转更少。与此同时,JIT 还把基本块重排问题建模成近似的非对称旅行商问题,用新的启发式算法改进热路径布局。你在 C# 代码里看不到这些细节,但最终会感受到更好的吞吐和更稳的延迟。

1.2 栈上分配扩展

如果说 JIT 的改进更多是在"如何生成更好的代码",那么 .NET 10 在栈上分配上的推进,解决的就是"能不能连堆分配都省掉"。这件事的价值非常直接,因为一旦某个对象被证明不会逃逸出当前方法,运行时就可以把它放在栈上而不是托管堆上。对象一旦不进堆,GC 就不需要跟踪它,很多额外成本也会一起消失。

先看官方文档中的第一个例子,它展示了小型值类型数组的栈上分配。

csharp 复制代码
using System;

static void Sum()
{
	int[] numbers = { 1, 2, 3 };
	int sum = 0;

	for (int i = 0; i < numbers.Length; i++)
	{
		sum += numbers[i];
	}

	Console.WriteLine(sum);
}

这段代码的关键并不是 for 循环本身,而是 numbers 这个数组同时满足了几个条件:它的大小在编译期就是固定的,它的元素类型是值类型,而且它不会逃逸出当前方法。官方明确说明,在这种情况下,.NET 10 会把这个小数组直接放到栈上。也就是说,int[] numbers = { 1, 2, 3 } 这行代码在语义上仍然是数组,但在运行时未必真的会触发一次堆分配。对业务开发者来说,这个优化的意义在于,一些原本为了避免分配而不得不手写 Span<T> 或复杂缓冲区复用逻辑的场景,现在可以先用更自然的代码表达,再交给 JIT 去识别和优化。

.NET 10 更进一步的一点在于,它把这个能力从值类型数组扩展到了小型引用类型数组。官方使用的例子如下:

csharp 复制代码
using System;

static void Print()
{
	string[] words = { "Hello", "World!" };

	foreach (var str in words)
	{
		Console.WriteLine(str);
	}
}

这个例子之所以重要,是因为过去开发者很容易默认认为"引用类型数组一定在堆上"。到了 .NET 10,这个结论已经不再绝对成立。只要运行时能够证明数组本身不会逃逸,它同样可以被放到栈上。注意,这里说的是数组对象的分配位置发生变化,不是说字符串本身会凭空变成栈对象。words 仍然是一个引用类型数组,只不过承载这些引用的数组壳体不一定进入 GC 堆。这种改进对于很多只在方法内部临时拼装参数、标签、列名或者消息片段的代码尤其有价值。

除了数组,.NET 10 的逃逸分析还扩展到了局部结构体字段引用和委托对象。委托这一点特别值得单独看,因为很多现代 C# 代码都会使用 lambda,而 lambda 在 IL 层面通常意味着闭包对象和 Func 对象。官方给出的示例如下:

csharp 复制代码
public static int Main()
{
	int local = 1;
	int[] arr = new int[100];
	var func = (int x) => x + local;
	int sum = 0;

	foreach (int num in arr)
	{
		sum += func(num);
	}

	return sum;
}

这段代码在以前往往意味着至少两类分配:一个闭包对象,用来保存被捕获的 local,另一个 Func<int, int> 对象,用来承载委托实例。.NET 10 的进步在于,如果 JIT 发现 func 不会逃逸出当前方法,那么 Func 对象本身就有机会放到栈上。官方也坦率说明,目前闭包对象本身仍可能保留在堆上,后续版本还会继续推进。但即便只先消掉 Func 的堆分配,在高频局部 lambda 场景里,也已经是很有感知的改进了。

1.3 NativeAOT 改进

除了即时编译路径,.NET 10 也继续加强了 NativeAOT。这个版本里一个看起来不算显眼、但很有含金量的更新,是 type preinitializer 现在支持所有 conv.*neg 操作码。它意味着包含类型转换和取负运算的方法,有更多机会在 AOT 阶段完成预初始化,而不是把这部分工作留到程序启动之后。

下面用一个极简示例理解这个变化的价值。

csharp 复制代码
static int Normalize(short value)
{
	return -((int)value);
}

这段代码里最核心的两个动作,恰好就是把 short 转成 intconv.* 语义,以及前面的负号,也就是 neg 语义。过去当 AOT 预初始化器对这些操作码支持不完整时,某些初始化逻辑就只能退回运行时阶段处理。到了 .NET 10,类似这种带显式转换和取负运算的场景,理论上就能覆盖得更完整。对微服务应用来说,这类改进最现实的收益通常不在单次调用,而在冷启动路径上,尤其是容器化部署、短生命周期实例或者需要快速扩缩容的服务。

1.4 Arm64 写屏障改进

GC 方面,.NET 10 还把 x64 上已经证明有效的一项能力带到了 Arm64,也就是写屏障实现的动态切换。世代 GC 的核心假设是老对象通常活得更久,年轻对象更可能被回收。但只要老对象开始引用年轻对象,GC 就必须准确知道这件事,否则就有可能错误回收仍然存活的数据。写屏障就是为了解决这个问题而插入的那层运行时机制。

微软在官方文档里给出的结论非常直接,Arm64 上新的默认写屏障实现能更精确地处理 GC regions,尽管写屏障本身的吞吐可能会有一点点代价,但换来的是更好的 GC 暂停表现,基准测试里的暂停时间改善大致在 8% 到 20% 之间。对于部署在 Arm 服务器或者云原生环境中的服务,这个变化会比表面看上去更重要,因为它影响的不是单个请求,而是整条服务曲线的稳定性。

Tip:这一节没有对应的业务代码改动,因为它完全属于运行时内部优化。但在升级基准测试时,最好单独比较一下 x64 与 Arm64 的 P95、P99 延迟,避免只看平均吞吐。

二、ASP.NET Core 10 新特性

2.1 Minimal API 的内置验证能力

如果说运行时优化更多是在"底下悄悄变快",那么 ASP.NET Core 10 的很多更新则属于"你一眼就能用上"。其中最先值得落地的,就是 Minimal API 内置验证。过去在 Minimal API 里做参数验证,开发者往往要自己写 endpoint filter、显式调用验证器,或者退回到 MVC 风格的控制器模型。到了 .NET 10,这件事终于有了官方内置方案。

csharp 复制代码
using System.ComponentModel.DataAnnotations;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddValidation();

var app = builder.Build();

app.MapPost("/products", (Product product) =>
{
	return TypedResults.Ok(product);
});

app.MapPost("/internal/products",
	([EvenNumber(ErrorMessage = "商品编号必须为偶数")] int productId, [Required] string name)
		=> TypedResults.Ok(new { productId, name }))
	.DisableValidation();

app.Run();

public record Product(
	[Required] string Name,
	[Range(1, 1000)] int Quantity);

public sealed class EvenNumberAttribute : ValidationAttribute
{
	public override bool IsValid(object? value)
		=> value is int number && number % 2 == 0;
}

这段代码的第一处关键点,是 builder.Services.AddValidation()。在 .NET 10 里,这一行会把 Minimal API 所需的验证服务注册进容器,并为符合条件的端点自动挂上验证过滤器。第二个关键点,是 Product 这个 record。官方文档特别说明,.NET 10 的 Minimal API 已经支持直接对 record 类型应用 DataAnnotations,这意味着你可以继续使用更紧凑的模型表达方式,而不用为了验证回到 class。第三个关键点,是第二个端点上的 .DisableValidation()。它说明这套机制不是全局强制的,你仍然可以对某些内部端点显式关掉验证。

把它放到孢子记账项目的上下文里看,这个能力的价值非常高。因为我们的接口里既有外部 API,也有服务间调用接口。外部入口可以统一收敛到 DataAnnotations 驱动的验证方式,减少手写判空和范围判断;而那些已经在上游完成校验、只想保留极简路径的内部端点,则可以像上面的示例一样局部关闭验证。这样既获得了统一约束,又不至于把所有接口都推成一刀切的实现。

微软同时还修复了一个很容易踩坑的表单绑定细节:当 Minimal API 使用 [FromForm] 绑定复杂对象时,空字符串现在会自动映射成可空值类型的 null,而不是直接解析失败。这个变化很小,但对日期、金额、提醒时间等可选字段很多的业务表单来说,体验会自然很多。

2.2 Server-Sent Events(SSE)支持

另一项很适合产品智能化场景的更新,是 ASP.NET Core 10 对 SSE 的原生支持。SSE 不是 WebSocket 的替代品,但它在"服务端持续向浏览器单向推送事件"这个场景里更简单,协议也更轻量。对于智能提醒、预算变动提示、异步分析任务进度播报这类能力,SSE 往往比上来就引入 WebSocket 更合适。

csharp 复制代码
using System.Collections.Generic;
using System.Runtime.CompilerServices;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/heartrate", (CancellationToken cancellationToken) =>
{
	async IAsyncEnumerable<HeartRateRecord> GetHeartRate(
		[EnumeratorCancellation] CancellationToken cancellationToken)
	{
		while (!cancellationToken.IsCancellationRequested)
		{
			var heartRate = Random.Shared.Next(60, 100);
			yield return HeartRateRecord.Create(heartRate);
			await Task.Delay(2000, cancellationToken);
		}
	}

	return TypedResults.ServerSentEvents(
		GetHeartRate(cancellationToken),
		eventType: "heartRate");
});

app.Run();

public sealed record HeartRateRecord(int Value, DateTimeOffset At)
{
	public static HeartRateRecord Create(int value) => new(value, DateTimeOffset.Now);
}

这段代码的核心在 TypedResults.ServerSentEvents(...)。它接收的不是一次性计算出的集合,而是一个 IAsyncEnumerable<T>,也就是"按时间逐步产出数据"的异步流。示例里的 GetHeartRate 会在 while 循环中不断 yield return 新数据,并在两次推送之间 Task.Delay 两秒。[EnumeratorCancellation] 则保证了当客户端断开连接时,取消令牌能正确传递到异步枚举器内部,避免服务端继续白白生成数据。

如果把这个思路迁移到孢子记账项目里,最直接的落点就是智能化提示流。比如 AI 分类建议、账单导入进度、预算预警、异常支出识别结果,都可以先用 SSE 建一个稳定的"服务端推送通道"。它不要求客户端维护更复杂的双向连接状态,服务端实现成本也低,尤其适合先做出第一版可用功能。

2.3 OpenAPI 3.1 支持

对于微服务项目来说,ASP.NET Core 10 里影响最大的变化之一,几乎肯定是 OpenAPI。这个版本默认生成 OpenAPI 3.1 文档,而 3.1 虽然版本号只比 3.0 高了一个小数点,但它背后实际上引入了更完整的 JSON Schema 2020-12 语义,所以你会在生成结果里看到一系列"看起来不一样,其实是规范升级"的变化。

csharp 复制代码
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi(options =>
{
	options.OpenApiVersion = Microsoft.OpenApi.OpenApiSpecVersion.OpenApi3_1;
});

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
	app.MapOpenApi();
	app.MapOpenApi("/openapi/{documentName}.yaml");
}

app.Run();

这段代码的第一层含义很明确:AddOpenApi 现在直接就是 ASP.NET Core 官方推荐方案,不再需要先围着第三方工具做一层适配。第二层含义更值得注意,options.OpenApiVersion 指定为 OpenApi3_1 之后,文档中的可空类型会采用新的 3.1 表达方式。过去常见的是 nullable: true,现在更多会看到 type 变成数组,比如 type: ["null", "string"]。第三层含义是 YAML 输出已经原生支持,只要把 MapOpenApi 的路由写成 .yaml.yml 后缀,服务端就能直接给出 YAML 格式文档。对于需要把接口文档投喂给 AI 工具链、网关配置脚本或者多语言 SDK 生成流程的团队来说,YAML 往往比 JSON 更易读。

OpenAPI 3.1 升级的另一个重点,是 XML 注释现在可以自动进入文档。这个能力对团队协作很重要,因为它意味着接口说明可以回到源码本身,而不是散落在 Wiki、Swagger 注解和独立文档里。

xml 复制代码
<PropertyGroup>
  <GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
csharp 复制代码
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();

var app = builder.Build();

app.MapGet("/hello", Hello);

app.Run();

static partial class Program
{
	/// <summary>
	/// Sends a greeting.
	/// </summary>
	/// <param name="name">The name of the person to greet.</param>
	/// <returns>A greeting.</returns>
	public static string Hello(string name)
	{
		return $"Hello, {name}!";
	}
}

这里要特别注意两件事。第一件事是 .csproj 里的 <GenerateDocumentationFile>true</GenerateDocumentationFile>,没有这一行,后面的 XML 注释能力就不会生效。第二件事是端点从 lambda 改成了具名方法 Hello。微软官方明确说明,C# 编译过程不会把写在 lambda 上的 XML 注释收集进最终产物,所以如果你希望 Minimal API 的说明出现在 OpenAPI 文档中,最稳妥的做法就是像这里一样,把处理函数提取为普通方法,再在方法上写 <summary><param><returns> 等注释。

除了功能增强,这一轮升级还有一个必须正视的 Breaking Change :底层的 Microsoft.OpenApi 已经升级到 2.0.0,很多 transformer 的写法需要跟着调整,最典型的就是 OpenApiAny 被移除,改为直接使用 JsonNode

csharp 复制代码
using System.Text.Json.Nodes;

builder.Services.AddOpenApi(options =>
{
	options.AddSchemaTransformer((schema, context, cancellationToken) =>
	{
		if (context.JsonTypeInfo.Type == typeof(WeatherForecast))
		{
			schema.Example = new JsonObject
			{
				["date"] = DateTime.Now.AddDays(1).ToString("yyyy-MM-dd"),
				["temperatureC"] = 0,
				["temperatureF"] = 32,
				["summary"] = "Bracing"
			};
		}

		return Task.CompletedTask;
	});
});

如果你以前在 .NET 9 里写过 new OpenApiObject { ["date"] = new OpenApiString(...) } 这样的代码,那么这里就是最先要改的地方。现在 schema.Example 接受的是基于 JsonNode 的结构,所以字符串、数字和对象节点都直接用 JsonObject、字面量和普通赋值来表达。这个变化对孢子记账项目尤其关键,因为我们后续很可能会给 OpenAPI 文档做自定义增强,比如补充统一错误结构、鉴权说明、AI 接口标签等。只要项目中存在 document、operation 或 schema transformer,这部分迁移就绕不过去。

csharp 复制代码
[HttpGet]
[ProducesResponseType<IEnumerable<WeatherForecast>>(StatusCodes.Status200OK,
	Description = "未来 5 天天气预报")]
public IEnumerable<WeatherForecast> Get()
{
	return [];
}

这一小段示例对应的是另一个很实用的增强,ProducesResponseType 现在可以直接写 Description。它的意义并不在于"终于多了一个字符串参数",而在于响应语义描述终于可以随着代码一起进入 OpenAPI 文档,而且不必再额外依赖第三方扩展方式。对于后续我们整理统一响应模型、标准化错误码和接口契约,这个细节会很有帮助。

注意:如果项目里已经写过自定义 OpenAPI transformer,那么哪怕你临时把 OpenAPI 版本固定回 3.0,也仍然要处理 Microsoft.OpenApi 2.0.0 带来的 API 变化,不能把它当成只在 3.1 模式下才发生的问题。

2.4 身份验证与授权改进

ASP.NET Core 10 在认证和授权上的变化,分成两部分看会更清楚,第一部分是可观测性增强,第二部分是默认行为的改变。可观测性方面,微软新增了一组 Microsoft.AspNetCore.Identity meter,覆盖了用户创建、登录、退出、校验密码、生成令牌、双因素相关动作等事件。这意味着如果项目已经接入 OpenTelemetry、Aspire 仪表盘或者统一监控平台,升级到 .NET 10 后就可以更自然地追踪身份系统的运行状态,而不必全部靠自定义日志堆出来。

更值得在升级时明确写进检查清单的,是 Cookie 认证对 API 端点的默认行为变化。过去未认证请求打到受保护接口时,框架经常会给你一个 302 重定向到登录页,这对 MVC 页面是合理的,但对 API 而言通常是错误信号,因为客户端真正需要的是 401 或 403。到了 .NET 10,带有 [ApiController] 的端点、返回 JSON 的 Minimal API 端点、SignalR 端点等"已知 API 端点",默认就不再重定向,而是直接返回正确状态码。

csharp 复制代码
builder.Services.AddAuthentication()
	.AddCookie(options =>
	{
		options.Events.OnRedirectToLogin = context =>
		{
			context.Response.Redirect(context.RedirectUri);
			return Task.CompletedTask;
		};

		options.Events.OnRedirectToAccessDenied = context =>
		{
			context.Response.Redirect(context.RedirectUri);
			return Task.CompletedTask;
		};
	});

这段代码并不是推荐你默认加回去,而是用来说明"如果你确实想恢复旧行为,该怎么做"。OnRedirectToLoginOnRedirectToAccessDenied 这两个事件一旦被显式覆盖,框架就会继续执行重定向逻辑。也正因为如此,升级时一定要判断一下项目里哪些端点是真正的 API,哪些端点还是页面交互入口。对孢子记账这种前后端分离系统来说,API 端点直接返回 401/403 其实是更合理的默认值,前端拿到状态码后再做统一跳转或弹窗处理,整体链路会更清晰。

2.5 其他值得关注的更新

除了上面几个最核心的点,ASP.NET Core 10 还在一些看起来"小",但实际很容易影响工程质量的地方做了增强。比如服务器内存池现在支持自动回收闲置内存块,这对请求波动明显的服务很有意义。官方文档还给出了通过 IMemoryPoolFactory 使用这项能力的示例。

csharp 复制代码
using System.Buffers;

public class MyBackgroundService : BackgroundService
{
	private readonly MemoryPool<byte> _memoryPool;

	public MyBackgroundService(IMemoryPoolFactory<byte> factory)
	{
		_memoryPool = factory.Create();
	}

	protected override async Task ExecuteAsync(CancellationToken stoppingToken)
	{
		while (!stoppingToken.IsCancellationRequested)
		{
			try
			{
				await Task.Delay(20, stoppingToken);
				using var rented = _memoryPool.Rent(100);
			}
			catch (OperationCanceledException)
			{
				return;
			}
		}
	}
}

这里最关键的是构造函数里注入的 IMemoryPoolFactory<byte>。在 .NET 10 之前,很多开发者如果要自己管理内存池,往往要么依赖共享池,要么自建实现。现在框架把这层工厂抽象放进了 DI,应用可以显式创建受框架管理的内存池,并自动享受空闲回收能力。示例里的 Rent(100) 只是一个非常小的演示,但它表达的思想很明确:即便是后台服务、非 HTTP 请求路径,也可以和 ASP.NET Core 统一使用这一套改进后的内存池机制。

另一个很适合在安全方面顺手采用的工具,是 RedirectHttpResult.IsLocalUrl。它的作用并不复杂,但很实用:在重定向之前先判断目标地址是不是本地地址,从而避免开放重定向漏洞。

csharp 复制代码
app.MapGet("/login/callback", (string? returnUrl) =>
{
	if (!string.IsNullOrWhiteSpace(returnUrl) && RedirectHttpResult.IsLocalUrl(returnUrl))
	{
		return Results.LocalRedirect(returnUrl);
	}

	return Results.LocalRedirect("/");
});

这段代码的逻辑很直接。如果 returnUrl 不为空,而且 IsLocalUrl 判断它确实是站内地址,就允许重定向回去,否则统一回首页。以前很多项目会自己写一套字符串判断逻辑,比如检查是否以 / 开头、是否包含域名等,但这些判断既繁琐又容易漏边角。框架现在把它收敛成内置方法,直接用掉通常比自定义实现更稳。

这一组"其他更新"里还包括几个升级时应该顺手记住的点。第一,顶级语句应用在测试场景下不再需要手写 public partial class Program,因为 .NET 10 的 Source Generator 会自动生成,测试项目引用会自然很多。第二,Kestrel 已经更好地支持 .localhost 顶级域,开发证书也更新到了 *.dev.localhost,本地多服务调试时会比过去更规整。第三,MVC、Minimal API 和 ReadFromJsonAsync 现在默认走 Json + PipeReader 路径,应用本身不用改代码,但如果项目里有自定义 JsonConverter,就要检查它是否正确处理 Utf8JsonReader.HasValueSequence

注意:如果你在项目里实现过自定义 JsonConverter,升级到 .NET 10 后要重点检查 reader.HasValueSequence 分支。否则在 PipeReader 路径下,可能出现读取不全、越界或者反序列化异常。

三、基础类库补充更新

3.1 System.Text.Json 更严格也更高效了

虽然这一章的标题重点是运行时和 ASP.NET Core,但在实际项目升级里,System.Text.Json 的变化同样值得提前掌握,因为它直接决定了 API 输入输出的安全边界和性能表现。.NET 10 在这个方向上给出了两个很鲜明的信号。第一,默认宽松策略开始有了官方的"严格版"替代;第二,序列化管道继续向更底层、更少中转的方向靠拢。

先看重复属性处理和严格模式。

csharp 复制代码
using System.Text.Json;

string json = """{ "Value": 1, "Value": -1 }""";

Console.WriteLine(JsonSerializer.Deserialize<MyRecord>(json)!.Value);

JsonSerializerOptions options = new()
{
	AllowDuplicateProperties = false
};

JsonSerializer.Deserialize<MyRecord>(json, options);

var strictResult = JsonSerializer.Deserialize<MyRecord>(json, JsonSerializerOptions.Strict);

record MyRecord(int Value);

这段代码里,第一行 json 故意构造了一个重复键的 JSON。默认行为下,System.Text.Json 会接受它,并且以后出现的值覆盖前面的值,所以第一次反序列化输出的是 -1。当我们显式把 AllowDuplicateProperties 设为 false 之后,第二次反序列化就会抛出 JsonException。再往前一步,JsonSerializerOptions.Strict 则不是只做这一件事,它还会同时开启不允许未知成员、大小写敏感、尊重 nullable 注解、尊重必填构造参数等更严格的策略。也就是说,严格模式的真正价值不只是"更容易报错",而是把很多原本会被静默吞掉的数据质量问题提前暴露出来。

对孢子记账项目来说,这个能力尤其适合用在外部输入边界,比如开放 API、消息订阅入口或者 AI 回调入口。以前很多接口在面对字段拼错、重复字段、意外多传字段时,会以一种"看上去勉强成功,其实埋下不一致数据"的方式继续往下走。升级到 .NET 10 之后,这类入口完全可以考虑切到更严格的 JSON 反序列化策略,让错误更早暴露。

另一个非常实用的变化,是 JsonSerializer.DeserializeAsync 现在可以直接从 PipeReader 读取。

csharp 复制代码
using System.IO.Pipelines;
using System.Text.Json;

var pipe = new Pipe();

await JsonSerializer.SerializeAsync(pipe.Writer, new Person("Alice"));
await pipe.Writer.CompleteAsync();

var result = await JsonSerializer.DeserializeAsync<Person>(pipe.Reader);
await pipe.Reader.CompleteAsync();

Console.WriteLine($"Your name is {result!.Name}.");

record Person(string Name);

这段代码值得注意的地方,在于序列化写入端用的是 pipe.Writer,反序列化读取端直接用的是 pipe.Reader,中间不再需要把 PipeReader 先转成 Stream。这一步减少的不是"语法糖",而是真正的中间层和额外适配成本。也正因为有了这个底层能力,ASP.NET Core 10 才能顺势把 MVC、Minimal API 以及 ReadFromJsonAsync 的默认 JSON 读取路径切到 PipeReader 上。对开发者来说,最理想的结果就是你完全不用改控制器代码,但框架内部的读取效率会自然提升。

3.2 后量子密码学(PQC)支持

.NET 10 在密码学库里新增了一个很有时代感的方向,也就是后量子密码学。微软官方文档列出了三个新算法家族:ML-KEM,对应 FIPS 203;ML-DSA,对应 FIPS 204;SLH-DSA,对应 FIPS 205。它们的意义并不是让我们立刻把现有所有加密逻辑替换掉,而是意味着 .NET 平台已经开始把下一代密码能力纳入正式 API 体系。

csharp 复制代码
using System;
using System.Security.Cryptography;

if (!MLKem.IsSupported)
{
	throw new PlatformNotSupportedException("当前系统不支持 ML-KEM。");
}

using (MLKem key = MLKem.GenerateKey(MLKemAlgorithm.MLKem768))
{
	string publicKeyPem = key.ExportSubjectPublicKeyInfoPem();
	Console.WriteLine(publicKeyPem);
}

这段代码很好地体现了 .NET 10 在 PQC API 设计上的新风格。第一,它不再沿用传统 AsymmetricAlgorithm 那种"先 new 对象、再导入密钥或者生成密钥"的习惯,而是直接通过静态工厂方法 GenerateKey 来创建具体算法的密钥实例。第二,它提供了 IsSupported 这样的静态能力检测入口,让你在运行时先判断当前系统的底层密码库是否支持该算法。官方文档说明,PQC 的可用性依赖于 OpenSSL 3.5 及以上版本,或者支持 PQC 的 Windows CNG 环境。第三,ExportSubjectPublicKeyInfoPem() 直接给出 PEM 格式公钥,这让和外部系统交换密钥材料时更顺手。

从项目视角看,孢子记账短期内未必会马上用到 PQC 作为核心业务加密方案,但这个能力非常值得在架构层面提前知道。因为只要系统里存在密钥托管、长期数据保护、合规要求升级或者未来需要和更高安全等级的平台对接的可能,.NET 10 已经把相关 API 基础打好了。需要额外注意的是,官方文档同时说明 ML-DSA、SLH-DSA 以及 Composite ML-DSA 里仍有部分实验性标注,生产采用前要结合具体平台和合规要求再做判断。

3.3 其他几个和业务代码很近的类库更新

除了 JSON 和密码学,.NET 10 还有几项虽然不算"重磅头条",但写业务代码时很容易马上受益的更新。第一个是字符串的数字顺序比较。

csharp 复制代码
using System.Globalization;

StringComparer numericStringComparer =
	StringComparer.Create(CultureInfo.CurrentCulture, CompareOptions.NumericOrdering);

Console.WriteLine(numericStringComparer.Equals("02", "2"));

foreach (string os in new[] { "Windows 10", "Windows 8", "Windows 11" }.Order(numericStringComparer))
{
	Console.WriteLine(os);
}

这段代码里,CompareOptions.NumericOrdering 是核心。没有它时,字符串比较通常是按字典序进行的,所以 "10" 可能会排在 "2" 前面。有了它之后,比较器会把连续数字当成数值理解,于是 "Windows 8""Windows 10""Windows 11" 的顺序就会符合人的直觉。第一行输出 True 也说明 "02""2" 在这种比较方式下被视为数值相等。这个能力对账期、批次号、分期编号、报表版本号这类字符串字段很多的系统很实用。

ZIP API 的异步化也是一个很容易落地的改进。

csharp 复制代码
using System.IO.Compression;

await ZipFile.CreateFromDirectoryAsync(sourceDir, zipPath);
await ZipFile.ExtractToDirectoryAsync(zipPath, targetDir);

这两行代码虽然非常短,但它背后是 .NET 10 正式把 ZIP 的创建、打开、解压、条目读取等一整套 I/O 行为推到了异步模型上。对于导出账单、批量导入附件、生成离线分析包这类明显 I/O 密集的场景来说,异步 API 不只是"写法更现代",而是能减少线程阻塞,提高服务在高并发下的稳定性。

最后还有一个非常适合实时通信和流式消息处理的 API:WebSocketStream

csharp 复制代码
using System.IO;
using System.Net.WebSockets;
using System.Text.Json;

using Stream messageStream = WebSocketStream.CreateReadableMessageStream(
	connectedWebSocket,
	WebSocketMessageType.Text);

var appMessage = await JsonSerializer.DeserializeAsync<AppMessage>(messageStream);

这段代码的价值在于,它把原本偏底层的 WebSocket 包装成了 Stream。一旦变成 Stream,很多现成的 .NET API 就都能直接接上来,最典型的就是这里的 JsonSerializer.DeserializeAsync。以前如果你想从 WebSocket 中接收一个完整 JSON 消息,通常要自己处理缓冲区、分帧、拼包、消息结束判断等细节。现在 WebSocketStream 把这些麻烦吸收掉了,应用层就可以回到自己熟悉的流式 API。

四、与孢子记账项目的关联

把这章的内容落回孢子记账项目本身,会发现它们并不是彼此孤立的几个"新特性知识点",而是后续改造路线的前置条件。运行时上的 JIT 改进、栈上分配扩展和 Arm64 GC 优化,决定了我们在升级到 .NET 10 之后,很多原本已经写得比较现代的代码能够自动吃到更多性能红利,这也是为什么升级框架版本本身就值得做基线测试。Minimal API 的内置验证、SSE 支持和 OpenAPI 3.1,则会直接影响后续接口层设计,尤其是统一请求验证、流式通知能力以及接口文档标准化方案。

更重要的是,这些变化和后续章节天然是连在一起的。下一步当我们真正开始做接口改造时,OpenAPI 3.1 的默认行为、XML 注释注入方式、JsonNode 版 transformer 写法,都会进入项目代码。再往后,当我们把产品智能化能力逐步接入接口层时,SSE、严格 JSON 反序列化、RedirectHttpResult.IsLocalUrl 这类能力也都会落到真实场景里。所以这一章的意义并不只是"了解新版本有什么",而是先把升级后的地基摸清楚,后面每一处改动才能知道自己是在利用什么能力,而不是只在适配语法差异。

五、总结

总体来看.NET 10 的核心价值并不只是某一个惊艳的单点特性,而是它把运行时优化、Web 框架默认能力和基础类库约束一起向前推了一步。运行时层面,JIT 更敢优化、栈上分配覆盖范围更大、Arm64 的 GC 表现更成熟;ASP.NET Core 层面,Minimal API 更像一个真正完整的 API 框架,OpenAPI 终于进入官方主路线,安全与认证行为也更符合现代前后端分离架构;基础类库层面,System.Text.Json、PQC、异步 ZIP、WebSocketStream 等能力则补齐了很多过去需要绕路才能解决的问题。对孢子记账这样的微服务系统来说,这意味着升级到 .NET 10 并不是为了追一个版本号,而是在为后续的接口标准化、产品智能化和性能优化争取一个更稳固的基础。下一章我们会继续把视角从运行时和框架层,下沉到语言层,看看 C# 14 又给日常开发带来了哪些真正值得用起来的新能力。

相关推荐
Khsc434ka2 小时前
.NET 10 与智能体时代的架构演进:以 File-Based Apps 为核心的 C# 生态重塑
架构·c#·.net
SQVIoMPLe2 小时前
一个使用 .NET 实现的零 GC 压力的无锁 MPSC 原生队列
.net
wenzhangli72 小时前
OoderAgent 能力架构:基于 Workflow 控制理论的能力安装管理
后端·架构·asp.net
峥嵘life2 小时前
Android 13 Miracast 投屏代码适配总结
android·后端·asp.net
硅基喵13 小时前
Microsoft Agent Framework + Kimi API 实战:控制台应用跑通单次与多轮 Agent 对话
.net
IeE1QQ3GT16 小时前
使用ASP.NET Abstractions增强ASP.NET应用程序的可测试性
后端·asp.net
qZ6bgMe4316 小时前
记录一次bug:不可见字符/零宽字符
服务器·.net
de之梦-御风19 小时前
【Winform】OwnerDraw(自绘) 的用法
.net
Jp7gnUWcI19 小时前
基于.NET操作Excel COM组件生成数据透视报表
.net·excel