在完成升级前的准备工作之后,真正进入 .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 转成 int 的 conv.* 语义,以及前面的负号,也就是 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;
};
});
这段代码并不是推荐你默认加回去,而是用来说明"如果你确实想恢复旧行为,该怎么做"。OnRedirectToLogin 和 OnRedirectToAccessDenied 这两个事件一旦被显式覆盖,框架就会继续执行重定向逻辑。也正因为如此,升级时一定要判断一下项目里哪些端点是真正的 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 又给日常开发带来了哪些真正值得用起来的新能力。