最近 .NET 11 Preview 7 的更新内容已经相当多了

.NET 11 已经不是在单纯“补 API”了,而是在同时解决语言表达能力、异步执行效率、CLI 启动速度、Web 长连接资源占用以及桌面 UI 现代化这些更底层的问题。

尤其是几个变化,我觉得值得单独拿出来聊。

C# 15 开始支持带目标的 break / continue,union 和 closed 类型体系继续完善;Runtime 对 async/await 的优化已经深入到分层编译和 JIT;SDK 开始默认启用 NativeAOT CLI 和 MSBuild Server;基础库第一次加入 IEEE 754 十进制浮点数,还给 ZIP 补上了密码和 AES 加密;ASP.NET Core 则开始让 Blazor Server 的 Circuit 在后台自动“休眠”。Windows Forms 这边也没闲着,新的视觉样式、系统主题变化监听和批量更新暂停绘制都来了。

所以这篇文章不打算照着 Release Notes 一条条翻译,我们直接聊几个我认为真正会影响开发体验的变化。

C# 15:终于可以告诉break,我到底想跳出哪一层循环了

先从最容易看懂的 C# 15 开始。

写过稍微复杂一点循环的人,大概率都碰到过这种代码:

for (int row = 0; row < rows; row++){    bool found = false;    for (int col = 0; col < cols; col++)    {        if (matrix[row, col] == target)        {            found = true;            break;        }    }    if (found)    {        break;    }}

问题不是它不能工作,而是我们的真实意图其实很简单:找到以后,直接结束外层循环。

但过去 C# 的 break 只能退出当前这一层,所以要么加状态变量,要么重构代码,有的人甚至会直接写 goto。

C# 15 在 Preview 7 中给 break 和 continue 加上了“目标”。现在可以直接给循环加标签,然后告诉 break 要退出哪一层。

代码会变成:

search:for (int row = 0; row < rows; row++){    for (int col = 0; col < cols; col++)    {        if (matrix[row, col] == target)        {            Console.WriteLine($"找到了:[{row}, {col}]");            break search;        }    }}

continue 也是一样。

比如处理二维数据时,如果当前行出现某种条件,希望直接处理下一行:

nextRow:for (int row = 0; row < rows; row++){    for (int col = 0; col < cols; col++)    {        if (ShouldIgnoreRow(row, col))        {            continue nextRow;        }        Process(matrix[row, col]);    }}

我觉得这个功能属于那种“看起来特别小,但实际代码里非常舒服”的改进。

它并不是传统意义上的 goto。标签必须直接绑定到 for、foreach、while、do 或 switch 这样的结构上,而且 break / continue 也只能在对应结构内部使用。换句话说,它还是结构化控制流,只是表达能力更强了。

C# 的另一个方向更重要:union和closed正在把类型系统补起来

如果只是 labeled break,那顶多算语法糖。C# 15 更值得关注的其实是 union 和 closed。

Preview 7 继续完善了 union 的模式匹配。现在对一个 union 做 pattern matching 时,编译器既会尝试匹配 union 本身,也会尝试匹配里面真正保存的值。

例如:

public record Dog(string Name);public record Cat(int Lives);public union Pet(Dog, Cat);Pet pet = new Cat(9);if (pet is Cat { Lives: > 0 } cat){    Console.WriteLine($"这只猫还有 {cat.Lives} 条命");}

这意味着以后很多类似:

Result<TSuccess, TError>OneOf<A, B>Message<TextMessage, ImageMessage, FileMessage>

这样的模型,不一定再需要依赖第三方库或者自己设计一套继承体系。

与此同时,closed 类型也越来越完整。

例如:

public closed record Shape;public record Circle(double Radius) : Shape;public record Rectangle(double Width, double Height) : Shape;

一旦基类被定义成 closed,编译器就知道这个继承体系是封闭的。

于是下面这种 switch:

static double GetArea<T>(T shape)    where T : Shape{    return shape switch    {        Circle(var radius)            => Math.PI * radius * radius,        Rectangle(var width, var height)            => width * height    };}

编译器能够知道所有直接子类型都已经覆盖,不再因为泛型参数 T 而错误地认为这个 switch 可能遗漏情况。Preview 7 特别补齐的,就是 泛型参数约束到 closed 类型时的 exhaustiveness analysis,也就是穷尽性分析。 

这两个特性放到一起看就有意思了。

C# 正在逐渐获得一种过去在 Rust、F#、Swift 这类语言里面比较常见的能力:让编译器更完整地理解“这个值到底可能是什么”。

这对于 Result、状态机、领域模型、编译器、协议解析这一类代码会很有价值。

目前这些 C# 15 特性属于预览能力,测试时项目里可以显式开启 Preview 语言版本:

<Project Sdk="Microsoft.NET.Sdk">  <PropertyGroup>    <OutputType>Exe</OutputType>    <TargetFramework>net11.0</TargetFramework>    <LangVersion>preview</LangVersion>  </PropertyGroup></Project>

官方在 closed 与 System.Text.Json 的示例中同样明确要求使用 C# Preview 语言版本。

Runtime:async/await 这次真的开始动到底层了

接下来是我觉得 .NET 11 很值得持续关注的一条线:Runtime Async。

过去我们写:

async Task<User> GetUserAsync(){    return await repository.GetUserAsync();}

编译器通常会把这个方法转换成一个异步状态机。

这个设计非常成熟,但状态机意味着更多生成代码,同时涉及状态保存、恢复以及相应的运行时开销。

.NET 11 正在推进的 Runtime Async,则希望把一部分 async/await 的实现能力进一步下沉到 Runtime。

到了 Preview 7,一个很重要的变化是:Runtime Async 版本的异步方法正式进入 Tiered Compilation,也就是分层编译流程。 之前这些方法基本停留在更偏快速启动的 Tier 0,现在热点 async 代码也能够进入更积极优化的 Tier 1。

这里甚至出现了一些非常夸张的优化案例。

官方测试中,一个循环等待已经完成的 Task 的场景,在 JIT 能够把相关 await helper 内联之后,1 亿次调用从大约 191ms 降到了约 32ms。

另一个 TechEmpower platform-json 测试里,因为 tail-await 已经可以在 Tier 0 工作,预热期间记录到的最大分配速率从大约 110MB/s 降到了约 8MB/s。

当然,这不代表你的 Web API 换成 Preview 7 就直接快六倍。

JIT 已经开始理解 async 的语义,而不是只把它当作普通方法调用。

比如这些代码:

return Task.CompletedTask;return Task.FromResult(value);return ValueTask.CompletedTask;return ValueTask.FromResult(value);

以及:

new ValueTask(SomeTask())

JIT 现在能够识别更多常见的 Task / ValueTask factory 和适配器,在合适情况下把不必要的包装直接消掉。

这条路线如果最终成熟,对 ASP.NET Core、数据库客户端、消息系统、RPC 服务这些高度依赖异步调用的项目影响会非常直接。

顺带一提,WebAssembly 上的 CoreCLR 也越来越不像实验品了

.NET 11 还有一条容易被忽略的路线:CoreCLR on WebAssembly。

Preview 6 的时候,CoreCLR 的 WebAssembly 配置已经能够启动;到了 Preview 7,它已经可以端到端运行 .NET Libraries 的测试套件。

背后包括标准 WebAssembly Exception Handling、RyuJIT 的 SIMD 支持、ReadyToRun、WASI host,以及 WebAssembly 下的诊断栈遍历能力。

换句话说,微软并不是简单地说“以后浏览器里也跑 CoreCLR”。

它正在一点点补齐:

运行

→ AOT / R2R→ SIMD→ Exception Handling→ WASI→ Diagnostic→ Libraries compatibility

整条工程链路。

这个方向短期不一定影响普通业务开发,但从 Runtime 架构来看非常值得继续观察。

基础库这次很猛:.NET 终于有 IEEE 754 十进制浮点数了

如果让我从 Libraries 里面挑一个最有“新版本味道”的功能,我会选这个:

Decimal32、Decimal64 和 Decimal128。

它们位于 System.Numerics,分别提供 7 位、16 位和 34 位十进制精度,并实现 IEEE 754-2019 的 decimal floating-point 体系。

这和我们一直使用的 System.Decimal 不是一回事。

它们支持 IEEE 浮点语义,包括 Infinity 和 NaN,同时还能参与 .NET Generic Math。

于是以后可以写:

using System.Numerics;Decimal64 price = Decimal64.Parse("199.90");Decimal64 discount = Decimal64.Parse("0.85");Decimal64 finalPrice = price * discount;Console.WriteLine(finalPrice);

更有意思的是泛型数学。

例如:

using System.Numerics;static T RoundMoney<T>(T value)    where T : IFloatingPoint<T>{    return T.Round(        value,        digits: 2,        MidpointRounding.ToEven);}Decimal64 price = Decimal64.Parse("19.995");Console.WriteLine(RoundMoney(price));

同一套泛型算法以后可以覆盖更多数字类型,而不是到处针对 decimal、double 再写一遍。

.NET 11 同时还加入了泛型版本的复数:

Complex<float> value = new Complex<float>(3, 4);Console.WriteLine(value.GetMagnitude());

以前 System.Numerics.Complex 基本绑定 double,现在新的 Complex<T> 可以使用 float、Half、新的 Decimal 浮点类型等,只要类型满足对应的 Generic Math 约束。

这类变化普通 CRUD 项目可能完全感觉不到,但对于科学计算、金融计算、信号处理、数值库作者来说就完全是另一回事了。

一个我特别喜欢的小 API:TryParsePartial

写解析器的人应该很容易理解这个功能为什么实用。

假设现在收到:123;456;789

以前你想基于 Span<T> 高性能解析第一个数字,经常要自己先找分隔符:

var index = input.IndexOf(';');var numberSpan = input[..index];int value = int.Parse(numberSpan);

.NET 11 Preview 7 给 Generic Math 增加了 TryParsePartial。

它不要求整个 Span 都必须是数字,而是告诉你:我成功解析到了哪里。

类似这样:

using System.Globalization;ReadOnlySpan<char> input =    "123;456;789";if (int.TryParsePartial(    input,    NumberStyles.Integer,    CultureInfo.InvariantCulture,    out int value,    out int consumed)){    Console.WriteLine(value);    // 123    input = input[consumed..];    Console.WriteLine(input.ToString());    // ;456;789}

对于 CSV、协议解析、日志格式解析以及各种零拷贝 Parser,这种 API 非常舒服,因为不用为了找到字段边界反复切字符串或者复制数据。

HttpClient终于可以比较自然地压缩 Request Body 了

HTTP Response 压缩我们已经很熟悉。

但客户端上传一个很大的 JSON、日志或者批量数据时,过去想压缩 Request Body 往往得自己套 Stream。

.NET 11 Preview 7 在 System.Net.Http 里直接加入了:GZipCompressedContent和BrotliCompressedContent

以及:ZstandardCompressedContent。

比如:

using System.IO.Compression;using System.Net.Http;using var client = new HttpClient();var json = new StringContent(    """    {        "name": "张三",        "message": "Hello .NET 11"    }    """);using var request =    new HttpRequestMessage(        HttpMethod.Post,        "https://example.com/api/messages");request.Content =    new ZstandardCompressedContent(        json,        CompressionLevel.Optimal);using HttpResponseMessage response =    await client.SendAsync(request);response.EnsureSuccessStatusCode();

包装以后,Content-Encoding 等信息会由对应 Content 类型处理,而且内容是流式压缩的。

不过这里有一个很重要的前提:Request Compression 目前是显式选择的,HttpClient 不会自动和服务器协商“你支不支持 zstd”。

所以一定要确认服务器支持对应的编码格式。

ZIP 终于原生支持密码和 AES 了

这个变化估计会解决不少历史代码。

System.IO.Compression 现在可以直接读写有密码的 ZIP Entry,而且支持:

ZipCryptoAES-128AES-192AES-256。

比如:

using System.IO.Compression;const string password = "my-strong-password";using var archive =    ZipFile.Open(        "backup.zip",        ZipArchiveMode.Create);var entry =    archive.CreateEntry(        "data.txt",        password: password,        encryptionMethod: ZipEncryptionMethod.Aes256);using var stream =    entry.Open(password);using var writer =    new StreamWriter(stream);writer.WriteLine("这部分内容已经加密");

读取也一样:

using var archive = ZipFile.OpenRead("backup.zip");var entry = archive.GetEntry("data.txt")!;using var stream = entry.Open("my-strong-password");using var reader = new StreamReader(stream);Console.WriteLine(reader.ReadToEnd());

对于报表导出、数据交换、备份工具这种场景,终于不用为了“ZIP 加个密码”再引入一套额外实现了。

而且 Preview 7 不只是密码,还同时增加了 ZIP 创建、解压的 Options 类型以及更多异步 API。

DNS API 也终于不只会问“这个域名 IP 是什么”

以前很多人认识的 .NET DNS API 基本就是:

Dns.GetHostAddressesAsync(...)

但真实 DNS 世界显然不只有 A / AAAA。

Preview 7 开始提供类型化 DNS 查询,包括 SRV、MX、TXT、CNAME、PTR、NS 等记录,并且返回 DnsResult<T>,里面还能看到 Response Code 和 Negative Cache TTL。

例如:

using System.Net;DnsResult<SrvRecord> result =    await Dns.ResolveSrvAsync(        "_ldap._tcp.example.com");foreach (SrvRecord record         in result.Records){    Console.WriteLine(        $"{record.Target}:{record.Port}");}

这对于 Service Discovery、邮件系统、基础设施工具和 DNS 管理程序都很有用。

不过 Preview 7 这里有一个限制:这一版的类型化 DNS Resolver 实现目前首先支持 Windows,其他平台在当前 Preview 中还会抛 PlatformNotSupportedException。 

SDK 的变化可能每天都能感觉到:dotnet CLI 开始默认走 NativeAOT

下面聊开发体验。Preview 7 把之前需要手动开启的一项功能直接变成默认行为:NativeAOT dotnet CLI fast path 默认启用。

并不是整个 dotnet CLI 突然全部 AOT 了。

现在的策略更像是:能够由 NativeAOT 路径直接处理的命令,就不要再为了它启动第二套 CoreCLR;需要 MSBuild、NuGet 等复杂能力的时候,再回退到传统 managed CLI。

效果很直观。

微软给出的测试里:

dotnet tool list378 ms → 68 ms

大约快了 5.5 倍。

dotnet dev-certs https、dotnet ef 这类工具分发命令,则从大约 700ms 降到了 200~220ms 左右。

所以它优化的其实是开发过程里那种非常烦人的:

执行命令等一下执行命令又等一下

单次看起来只是几百毫秒,但一天执行几十上百次以后,体感差异就出来了。

如果某些环境出现兼容问题,也可以临时关闭:

DOTNET_CLI_ENABLEAOT=false dotnet --info

MSBuild Server 也默认开启了

另一个类似的优化是 MSBuild Server。

过去每次:

dotnet builddotnet testdotnet run

都可能重新承担一部分 MSBuild 启动成本。

现在 SDK 默认让 MSBuild Server 保持一个 warm worker,也就是保留已经热起来的 MSBuild 工作进程,让后续 CLI 调用避免重复初始化。

这类优化本身不改变你的业务代码,却很可能是日常开发体验提升最明显的东西。

如果想恢复传统单次模式,可以关闭:

export DOTNET_CLI_USE_MSBUILD_SERVER=false

或者:

export MSBUILDUSESERVER=0

所以 .NET 11 SDK 的思路非常清晰:

能不重复启动的东西,就别重复启动。

NativeAOT CLI 解决 dotnet 自己的部分启动成本,MSBuild Server 则解决反复执行工程命令时的 MSBuild 初始化成本。

dotnet test终于能直接规定“最多跑多久”和“最多失败几个”

CI 里面还有一个很实用的功能。

在 Microsoft.Testing.Platform 下,现在可以写:

dotnet test --timeout 90s

意思是整个测试运行最多允许 90 秒。

也可以:

dotnet test --maximum-failed-tests 5

意思是累计失败 5 个以后就停止整次测试。

这个变化看起来不起眼,但放在大型 CI 里非常实际。

假设 5000 个测试里面,前 50 个已经连续炸了 20 个。

以前剩下 4950 个仍然慢慢跑,可能没有任何意义。

现在直接:

dotnet test \    --timeout 10m \    --maximum-failed-tests 20

就可以给整个测试 Session 加一个明确的 fail-fast 策略。

而且这两个参数放在 — 前面时控制的是整个 Run,不只是某一个测试应用。

ASP.NET Core:Blazor Server 的后台标签页终于可以“睡觉”了

我觉得 ASP.NET Core Preview 7 最值得聊的是 Blazor。

Blazor Interactive Server 有一个天然问题:

一个浏览器页面后面对应一个 Circuit。

用户打开页面以后切到别的 Tab,甚至去开会一个小时,这个页面虽然根本没人看,但服务器端 Circuit 仍然可能继续占资源。

Preview 7 引入了 Auto Pause Circuit。

基本配置类似:

<PackageReference    Include="Microsoft.AspNetCore.Components.Server.AutoPause" />

然后:

app.MapRazorComponents<App>()    .AddInteractiveServerRenderMode()    .WithBrowserOptions(options =>    {        options.AddAutoPause(pause =>        {            pause.Enabled = true;            pause.HiddenDelay =                TimeSpan.FromSeconds(30);        });    });

当页面处于隐藏状态并达到指定时间以后,Circuit 可以进入暂停状态,释放服务器资源;用户重新回来后再恢复。

不过微软也没有粗暴地说“Tab 隐藏 30 秒直接冻死”。

如果页面中存在未保存文本、正在播放的未静音媒体、Picture-in-Picture、Web Lock,或者还有 JS Interop、Stream Transfer、Render 等活动,自动暂停可能会被延后。

也就是说,它想解决的是:真正闲置的 Circuit。

而不是把用户正在进行的工作强行暂停。

对于拥有大量同时在线用户的 Blazor Server 应用,我觉得这个方向相当重要。

Blazor SSR 现在甚至可以缓存一块组件树的 HTML

另一个有意思的功能叫 CacheView。

以前做 SSR 时,一个组件区域每次 Request 都重新实例化、执行、渲染。

现在可以:

<CacheView    ExpiresAfter="TimeSpan.FromMinutes(10)"    VaryByRoute="productId"    VaryByQuery="page,pageSize"    VaryByCulture="true">    <ExpensiveProductSummary        ProductId="productId" /></CacheView>

当缓存命中以后,CacheView 内部的子组件甚至不会重新实例化和渲染,而是直接重放之前捕获的 HTML。

这个思路其实和 Web 开发中非常成熟的:

Response CacheFragment CachePartial Cache

很接近。

只不过现在进入了 Blazor 组件模型。

对于商品详情页、CMS 内容、导航区域以及高成本 SSR Component,这个功能可能比单纯优化组件生命周期有价值得多。

Validation 也在继续收口

.NET 11 前几个 Preview 一直在改新的 Validation 体系。

Preview 7 做了一件很重要的事情:本地化能力直接进入 Microsoft.Extensions.Validation,不再需要单独的 Preview Localization 包。

只要 DI 中存在 IStringLocalizerFactory,Validation 就可以走本地化流程。

例如:

builder.Services.AddLocalization();builder.Services.AddValidation();Model:[ValidatableType]public class Customer{    [Display(Name = "CustomerName")]    [Required(        ErrorMessage = "NameRequired")]    public string? Name { get; set; }}

这里 CustomerName 和 NameRequired 就可以作为资源 Key 参与本地化。

这种变化其实透露出一个趋势:

Validation 正在从 MVC 的附属能力逐渐变成整个 .NET Extensions 体系里的基础设施。

SSE 和 OpenAPI 也终于开始真正互相认识了

现在 AI Streaming、实时日志以及 Agent 输出越来越多地使用 Server-Sent Events。

ASP.NET Core 本身已经可以返回 SSE:

app.MapGet("/messages/stream",(CancellationToken cancellationToken) =>        TypedResults.ServerSentEvents(            GetMessagesAsync(                cancellationToken)));

Preview 7 的变化是,如果 Endpoint 返回 SseItem<T>,生成 OpenAPI 3.2 文档时可以使用 itemSchema 来描述每一条事件的数据类型,而不是简单地把整个 text/event-stream 当成字符串。

这对 SDK Generator、API Explorer 和 AI API 工具来说挺重要。

因为:这是一个 text/event-stream和:这是一个 event stream,其中每一条 event 都是 TodoDto完全不是一个信息量级。

Windows Forms 也没躺平:.NET 11 开始换新的视觉体系

最后聊聊 WinForms。

每次新 .NET 发布,很多人第一反应都是:WinForms 还有更新?答案不只是有,而且 Preview 7 的改动还挺实际。

现在 WinForms 加入新的 VisualStylesMode,可以明确选择 Classic、Disabled、Net11 或 Latest。

例如:

using System.Windows.Forms;Application.SetDefaultVisualStylesMode(    VisualStylesMode.Net11);var form = new Form{    Text = ".NET 11 WinForms",    VisualStylesMode = VisualStylesMode.Latest};Application.Run(form);

新的渲染模式会逐步覆盖 Button、CheckBox、RadioButton、GroupBox、TextBox、RichTextBox 等常用控件,TextBox / RichTextBox 还开始支持与新视觉样式配合的 Padding 和圆角化表现。

与此同时,应用还可以监听系统视觉设置的变化。

比如 Windows Accent Color 变化:

Application.SystemVisualSettingsChanged +=    (_, e) =>    {        if ((e.Changed &             SystemVisualSettingsCategories.AccentColor)            != 0)        {            Console.WriteLine(                e.NewSettings.AccentColor);        }    };

文本缩放比例改变也可以收到通知。

这意味着 WinForms 应用以后对 Windows 系统主题和辅助功能的响应可以做得更自然。

WinForms 还有一个非常实用的性能 API

如果你曾经往 ListBox、TreeView、ListView 里面一次塞几千条数据,应该见过那种:界面疯狂重绘、布局不停计算,最后窗口一卡一卡的。

过去往往要组合:

BeginUpdateEndUpdateSuspendLayoutResumeLayout

Preview 7 把这类需求统一成了一个非常符合现代 C# 写法的 API:

using var _ =listBox.SuspendPainting(        LayoutSuspendTraversal.Target);for (int i = 0; i < 10_000; i++){    listBox.Items.Add(        $"Item {i}");}

离开 using Scope 时恢复 Layout 和 Painting。

完整一点就是:

using System.Windows.Forms;var listBox = new ListBox();using (    listBox.SuspendPainting(        LayoutSuspendTraversal.Target)){    for (int i = 0;         i < 10_000;         i++)    {        listBox.Items.Add(            $"Item {i}");    }}

这属于我很喜欢的 API 设计。

功能本身不新鲜,但:

using (...){}

天然保证恢复动作能够发生,比手工 Begin / End 更不容易写错。

所以,.NET 11 Preview 7 到底在做什么?

把这六份 Release Notes 放在一起以后,我觉得 .NET 11 的方向已经比较明显了。

它不是那种:我们今年再增加 100 个 API。

而是在把一些已经使用很多年的核心链路重新往下优化。

C# 这边,union、closed、穷尽性分析正在加强类型系统表达能力。

Runtime 这边,JIT 已经开始进一步理解 async/await、Task 和 ValueTask,而不只是接受编译器生成的状态机。

SDK 这边更直接:NativeAOT CLI 和 MSBuild Server 默认开启,目标就是让每天执行几十次的 dotnet 命令少等一点。

Libraries 则在补一些长期缺失的基础设施:IEEE 754 Decimal、泛型 Complex、部分数字解析、Request Compression、完整 DNS Record 查询、加密 ZIP。

ASP.NET Core 的重点之一则从“怎么让 Blazor 能工作”,逐渐变成“怎么让大规模 Blazor 应用更省资源、更容易缓存、更容易诊断”。

甚至连 WinForms,都开始认真处理新视觉样式、系统设置变化和批量 UI 更新。

所以如果一定要给 .NET 11 Preview 7 找一个关键词,我觉得不是“新功能”。

而是:把基础设施继续往下做。

很多东西第一次看到甚至不会让人觉得惊艳。

break outer?

一个新的 ZIP API?

MSBuild Server 默认打开?

后台 Tab 暂停 Circuit?

这些看起来都是小东西。

但真正一个平台用到十年、二十年以后,影响开发体验的往往恰恰就是这些东西。

语法少绕一点。

CLI 少等 300 毫秒。

异步少一次 Allocation。

服务器少维持几万个没人在看的 Circuit。

批量加控件时少刷新几千次。

这些改进单独拿出来都不算“革命”,但堆在一起,就是一个成熟开发平台继续演进的方式。

而从 Preview 7 现在的状态来看,.NET 11 已经越来越接近一版值得认真关注的基础设施升级了。

声明:来自硅基-桂迹,仅代表创作者观点。链接:https://eyangzhen.com/8968.html

硅基-桂迹的头像硅基-桂迹

相关推荐

添加微信
添加微信
Ai学习群
返回顶部