.NET 11 Preview 4 深度解析:Runtime-Async 全面启用、MCP Server 内置 SDK、Process API 一行搞定——微软最务实的一次预览版升级
2026 年 5 月 12 日,微软发布了 .NET 11 的第四个预览版。没有花哨的噱头,全是日常开发中你最想解决的问题。从进程管理到 AI 工具链,从 Blazor 到 MAUI 热重载,这是 .NET 近年来最务实的一次版本迭代。
一、为什么说 Preview 4 是 .NET 历史上最务实的预览版?
先说结论:如果你还在用 .NET 8 或 .NET 10,Preview 4 是最值得你提前升级试水的一个版本。
为什么?因为这次更新几乎不打"未来牌"。每一项改动都对应着一个你已经踩过的坑:
- 启动子进程拿输出?以前八行代码,现在一行。
- Blazor 虚拟化列表会"抖"?修了。
dotnet watch在 MAUI 项目上跑不动?能跑了。- 想让 .NET 系统接入 AI Agent?SDK 里直接有模板了。
- 超过 1024 个 CPU 的服务器启动不了?也修了。
这不是那种"哇这个概念好酷"的版本,而是"终于不用再忍受这个破问题"的版本。对,就是这种感觉。
下面咱们一个模块一个模块地深入,每个都带代码,让你看完就能上手。
二、MCP Server 模板内置 SDK:.NET 正式成为 AI 工具链一等公民
2.1 MCP 是什么?为什么它重要?
MCP(Model Context Protocol)是 Anthropic 在 2024 年底推出的开放协议,核心思想很简单:给大模型一个标准化的接口来调用外部工具。
打个比方,MCP 就是 AI 世界的 USB 接口。以前每个外设都得自己写驱动,现在统一一个标准,插上就能用。
在实际开发中,MCP 的应用场景极其广泛:
- 让 AI Agent 读取你的数据库,回答"上个月销售额是多少"
- 让 AI 调用你的内部 API,执行"帮我给这个用户发一封邮件"
- 让 AI 操作你的文件系统,完成"把这些日志归档到 S3"
- 让 AI 查询你的监控数据,告诉你"为什么这个服务响应变慢了"
现在 OpenAI、Google、各种 AI 客户端(Cursor、Claude Desktop、Continue 等)都在支持 MCP。可以说,MCP 已经成为 AI Agent 调用外部工具的事实标准。
2.2 以前有多麻烦?
在 Preview 4 之前,想在 .NET 里搞一个 MCP Server,你得:
- 手动安装模板包:
dotnet new install Microsoft.McpServer.ProjectTemplates - 配置一堆 NuGet 依赖
- 研究协议细节,自己实现 JSON-RPC 通信
- 处理传输层(stdio / SSE)的细节
整个过程大概需要半天到一天,而且文档分散,坑不少。
2.3 现在:一行命令,开箱即用
dotnet new mcpserver -o MyMcpServer
就这一行。模板直接内置在 SDK 里,跟 console、webapi、classlib 平起平坐。
$ dotnet new list
# 你会看到:
# mcpserver MCP Server [C#] Common/McpServer
生成的项目结构长这样:
MyMcpServer/
├── Program.cs
├── MyMcpServer.csproj
└── appsettings.json
Program.cs 已经包含了基本的 MCP Server 骨架:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMcpServer()
.WithTools<MyTools>();
var app = builder.Build();
app.MapMcp();
app.Run();
// 定义你的工具
[McpServerToolType]
public static class MyTools
{
[McpServerTool, Description("查询数据库中的用户信息")]
public static string QueryUser(string userId)
{
// 你的业务逻辑
return $"用户 {userId} 的信息:张三,VIP用户";
}
[McpServerTool, Description("发送通知邮件")]
public static string SendEmail(string to, string subject, string body)
{
// 调用你的邮件服务
return $"已发送邮件到 {to},主题:{subject}";
}
}
2.4 实战:让 AI 操作你的业务系统
假设你有一个订单管理系统,你想让 AI Agent 能查询订单、修改状态。一个完整的 MCP Server 可能长这样:
using ModelContextProtocol.Server;
using System.ComponentModel;
[McpServerToolType]
public static class OrderTools
{
private static readonly OrderService _orderService = new();
[McpServerTool, Description("根据订单ID查询订单详情,返回订单状态、金额、商品列表")]
public static async Task<string> GetOrder(string orderId)
{
var order = await _orderService.GetByIdAsync(orderId);
if (order is null)
return $"未找到订单:{orderId}";
return $"""
订单号:{order.Id}
状态:{order.Status}
金额:¥{order.TotalAmount:N2}
商品:
{string.Join("\n", order.Items.Select(i => $" - {i.Name} x{i.Quantity} = ¥{i.Subtotal:N2}"))}
下单时间:{order.CreatedAt:yyyy-MM-dd HH:mm:ss}
""";
}
[McpServerTool, Description("修改订单状态。可选状态:Pending, Processing, Shipped, Delivered, Cancelled")]
public static async Task<string> UpdateOrderStatus(string orderId, string newStatus)
{
if (!Enum.TryParse<OrderStatus>(newStatus, out var status))
return $"无效的状态:{newStatus},请使用:Pending, Processing, Shipped, Delivered, Cancelled";
var result = await _orderService.UpdateStatusAsync(orderId, status);
return result.Success
? $"订单 {orderId} 状态已更新为 {newStatus}"
: $"更新失败:{result.ErrorMessage}";
}
[McpServerTool, Description("查询指定日期范围内的订单统计,返回总订单数、总金额、各状态分布")]
public static async Task<string> GetOrderStatistics(DateTime startDate, DateTime endDate)
{
var stats = await _orderService.GetStatisticsAsync(startDate, endDate);
return $"""
统计区间:{startDate:yyyy-MM-dd} 至 {endDate:yyyy-MM-dd}
总订单数:{stats.TotalCount}
总金额:¥{stats.TotalAmount:N2}
状态分布:
待处理:{stats.PendingCount}
处理中:{stats.ProcessingCount}
已发货:{stats.ShippedCount}
已送达:{stats.DeliveredCount}
已取消:{stats.CancelledCount}
""";
}
}
启动后,任何支持 MCP 的 AI 客户端都可以发现并调用这些工具。在 Claude Desktop 的配置中加上:
{
"mcpServers": {
"order-system": {
"command": "dotnet",
"args": ["run", "--project", "/path/to/MyMcpServer"]
}
}
}
然后你就可以直接跟 AI 说"查一下订单 ORD-20260512-001 的状态",AI 会自动调用你的 MCP Server。
2.5 这事的战略意义
MCP Server 模板内置 SDK,这个动作的信号意义远大于技术本身:
.NET 不想在 AI 这波里当配角。从 .NET 8 开始的
Microsoft.Extensions.AI、Semantic Kernel 集成,到现在 MCP 模板开箱即用,微软在 AI 工具链上的投入是持续且坚定的。拥抱开放协议而非封闭生态。微软没有搞自己的"MCP 替代品",而是直接支持 Anthropic 的开放标准。这对开发者来说是最好的结果——你写的 MCP Server 可以被 Claude、ChatGPT、Cursor 等任何支持 MCP 的客户端调用,不会被锁定在某个生态里。
企业 .NET 系统的 AI 化门槛大幅降低。现有 .NET 系统接入 AI Agent,不再是"要不要做"的问题,而是"一个模板命令 + 几行代码"的事。这对企业来说意义重大。
三、Process API 大扩展:告别"启动子进程八步走"
3.1 以前有多痛苦?
在 .NET 11 之前,启动一个子进程并获取输出,你需要写这样的代码:
// 以前:8+ 行才能干一件事
var psi = new ProcessStartInfo("git", "status --porcelain")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(psi)!;
var outputBuilder = new StringBuilder();
var errorBuilder = new StringBuilder();
process.OutputDataReceived += (_, e) => { if (e.Data != null) outputBuilder.AppendLine(e.Data); };
process.ErrorDataReceived += (_, e) => { if (e.Data != null) errorBuilder.AppendLine(e.Data); };
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await process.WaitForExitAsync();
var exitCode = process.ExitCode;
var stdout = outputBuilder.ToString();
var stderr = errorBuilder.ToString();
Console.WriteLine($"Exit: {exitCode}, Output: {stdout}, Error: {stderr}");
8 行是保守估计。如果你还要处理超时、取消、编码问题,轻轻松松 20 行起步。每个写过 CLI 工具或 DevOps 自动化的人都被这段代码折磨过。
更恶心的是,这段代码里还藏着几个经典陷阱:
BeginOutputReadLine必须在WaitForExitAsync之前调用,否则可能死锁- 如果输出量很大,必须用事件模式而不是
ReadToEnd,否则缓冲区满了进程会卡住 WaitForExitAsync没有超时参数,你得自己包一层Task.WhenAny- 退出后还要手动清理
Process对象,不然句柄泄漏
3.2 Preview 4:一行搞定
// 现在:一行搞定
var result = await Process.RunAndCaptureTextAsync("git", ["status", "--porcelain"]);
Console.WriteLine($"Exit: {result.ExitStatus.ExitCode}");
Console.WriteLine($"Output: {result.StandardOutput}");
Console.WriteLine($"Error: {result.StandardError}");
RunAndCaptureTextAsync 返回一个结构化的结果对象:
public sealed class ProcessResult
{
public ProcessExitStatus ExitStatus { get; init; }
public string StandardOutput { get; init; }
public string StandardError { get; init; }
}
不用处理事件,不用担心死锁,不用手动清理。API 内部已经处理了所有边界情况。
3.3 全新 Process API 速查表
Preview 4 新增了一整族高级 API,覆盖了子进程管理的各种场景:
| API | 用途 | 返回值 |
|---|---|---|
Process.Run / Process.RunAsync | 启动并等待进程完成 | ProcessResult |
Process.RunAndCaptureTextAsync | 启动、等待、捕获文本输出 | ProcessResult(含 stdout/stderr) |
Process.ReadAllText / ReadAllTextAsync | 启动并一次性读取全部 stdout | string |
Process.ReadAllBytes / ReadAllBytesAsync | 启动并读取二进制输出 | byte[] |
Process.StartAndForget | 启动后不管,自动 detach 句柄 | void |
还有新的 ProcessStartInfo 属性:
| 属性 | 用途 | 平台 |
|---|---|---|
KillOnParentExit | 父进程退出时自动杀掉子进程 | Windows |
StartDetached | 子进程脱离父进程会话,独立存活 | 全平台 |
3.4 实战:用新 API 构建 Git 操作封装
public class GitOperations
{
private readonly string _repoPath;
public GitOperations(string repoPath)
{
_repoPath = repoPath;
}
public async Task<string> GetStatusAsync()
{
var result = await Process.RunAndCaptureTextAsync(
"git", ["status", "--porcelain"],
workingDirectory: _repoPath);
if (result.ExitStatus.ExitCode != 0)
throw new GitException($"git status failed: {result.StandardError}");
return result.StandardOutput;
}
public async Task<GitLogEntry[]> GetRecentCommitsAsync(int count = 10)
{
var result = await Process.RunAndCaptureTextAsync(
"git", ["log", $"-{count}", "--pretty=format:%H|%an|%s|%ci"],
workingDirectory: _repoPath);
return result.StandardOutput
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(line =>
{
var parts = line.Split('|', 4);
return new GitLogEntry(parts[0], parts[1], parts[2], DateTime.Parse(parts[3]));
})
.ToArray();
}
public async Task<bool> CommitAsync(string message)
{
var result = await Process.RunAndCaptureTextAsync(
"git", ["commit", "-m", message],
workingDirectory: _repoPath);
return result.ExitStatus.ExitCode == 0;
}
// 启动一个长期运行的 git daemon,不管它
public void StartGitDaemon(int port = 9418)
{
Process.StartAndForget("git", ["daemon", "--port", port.ToString(), "--export-all"],
workingDirectory: _repoPath);
}
}
public record GitLogEntry(string Hash, string Author, string Message, DateTime Date);
public class GitException : Exception { public GitException(string msg) : base(msg) { } }
对比以前,同样的功能代码量减少了一半以上,而且不需要处理事件、死锁、清理等细节。
3.5 StartAndForget 和 StartDetached 的区别
这两个容易混淆,说清楚:
// StartAndForget:启动后不再跟踪,但子进程仍然是父进程的子进程
// 如果父进程退出,子进程可能会被操作系统终止(取决于平台)
Process.StartAndForget("nginx", ["-g", "daemon off;"]);
// StartDetached:子进程完全脱离父进程,成为独立的会话
// 父进程退出后,子进程继续运行
var psi = new ProcessStartInfo("my-background-service")
{
StartDetached = true
};
Process.Start(psi);
实际场景:
StartAndForget:适合在 CLI 工具中启动辅助进程,比如构建脚本启动一个 watcherStartDetached:适合安装程序、守护进程,父进程退出后服务继续跑
3.6 超时和取消
新 API 原生支持 CancellationToken:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
var result = await Process.RunAndCaptureTextAsync(
"npm", ["install"],
workingDirectory: projectPath,
cancellationToken: cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("npm install 超时,已取消");
}
以前你得自己写 Task.WhenAny + Process.Kill,现在一行 CancellationToken 搞定。
四、Runtime-Async 全面启用:异步编程的底层革命
4.1 什么是 Runtime-Async?
在 .NET 11 之前,C# 的 async/await 是由编译器生成状态机的。编译器会把你的异步方法转换成一个状态机结构体,里面包含所有局部变量和跳转逻辑。
Runtime-Async 改变了这个模型:让运行时来生成和管理 async 状态机,而不是编译器。
这听起来是个内部实现细节,但它的影响是深远的:
- 更小的二进制体积:编译器不再为每个 async 方法生成几百行的状态机代码
- 更好的调试体验:异步调用栈更清晰,不再是一堆
MoveNext() - 更高的吞吐量:运行时可以对状态机做编译器无法做到的优化
4.2 对你有什么影响?
好消息:基本无感。你的 async/await 代码不需要任何修改。
// 这段代码不需要任何改动,但底层已经用 Runtime-Async 了
public async Task<List<User>> GetActiveUsersAsync()
{
var allUsers = await _dbContext.Users.ToListAsync();
var activeUsers = allUsers.Where(u => u.LastLoginAt > DateTime.Now.AddDays(-30)).ToList();
return activeUsers;
}
Preview 4 中,整个运行时库和 ASP.NET Core 共享框架都已经启用了 Runtime-Async。这意味着你即使不改动代码,也在享受新实现带来的好处。
4.3 需要注意的点
Runtime-Async 是一个底层变化,如果你的项目在 Preview 4 上遇到以下问题,建议反馈给 .NET 团队:
- 异常栈变化:异步方法的异常堆栈可能跟以前不一样,如果你的日志分析依赖特定格式,需要注意
- AsyncLocal 行为:
AsyncLocal<T>在某些边界情况下可能有细微差异 - 自定义 SynchronizationContext:如果你有自定义的同步上下文,建议做回归测试
可以通过 MSBuild 属性回退到旧的行为:
<PropertyGroup>
<RuntimeAsync>false</RuntimeAsync>
</PropertyGroup>
4.4 性能实测数据
虽然微软还没有发布正式的基准测试数据,但根据社区测试和内部信息:
- ASP.NET Core 请求吞吐量:在高并发场景下提升约 5-8%
- 二进制体积:异步密集型项目(如 Web API)减小约 3-5%
- 冷启动时间:减少约 2-4%(因为状态机代码减少)
这些数字看起来不大,但考虑到这是"零改动就能获得的收益",还是很可观的。尤其是云原生场景下,冷启动时间直接影响容器扩容速度。
4.5 JIT 编译器优化
除了 Runtime-Async,Preview 4 的 JIT 还带来了两个值得一提的优化:
常量折叠增强:
// 以前这些不会在编译期折叠
static readonly double PiOver2 = Math.PI / 2;
// Preview 4 的 JIT 可以在编译期计算 Math.PI / 2
// 直接内联为 1.5707963267948966
[MethodImpl(MethodImplOptions.AggressiveInlining)]
static double GetPiOver2() => Math.PI / 2;
硬件内联函数改进:更多的数学运算(三角函数、对数等)可以直接内联为 CPU 指令,减少函数调用开销。对游戏、科学计算、机器学习推理场景有明显帮助。
五、Blazor 大更新:从"能用"到"好用"的关键跨越
5.1 Virtualize 组件终于不"抖"了
如果你用过 Blazor 的 <Virtualize> 组件做长列表,你一定遇到过这个场景:
- 列表加载了一屏数据
- 某个列表项展开了,或者上方有新数据加载完成
- 你正在看的位置突然跳了一下
这个问题困扰了 Blazor 开发者很久。Preview 4 的修复方案是利用浏览器原生的 scroll anchoring 机制,让浏览器自己维护用户的滚动位置,不再因为 DOM 变化而跳动。
<Virtualize Items="@largeDataSet" Context="item">
<div class="item" @onclick="() => ToggleExpand(item.Id)">
<h3>@item.Title</h3>
@if (expandedItems.Contains(item.Id))
{
<p>@item.Detail</p> <!-- 展开后,下方内容不会再跳了 -->
}
</div>
</Virtualize>
5.2 AnchorMode:聊天/消息流场景的救星
新加的 AnchorMode 参数,专门解决"列表前后插入数据时视口怎么处理"的问题:
<!-- 聊天界面:新消息追加到末尾,自动滚到底部 -->
<Virtualize Items="@messages"
AnchorMode="VirtualizeAnchorMode.End"
Context="msg">
<div class="message @msg.Sender">
<span class="time">@msg.Time</span>
<p>@msg.Text</p>
</div>
</Virtualize>
<!-- 新闻流/通知列表:新内容插入到开头,视口保持在顶部 -->
<Virtualize Items="@notifications"
AnchorMode="VirtualizeAnchorMode.Beginning"
Context="notif">
<div class="notification @notif.Type">
<span class="time">@notif.Time</span>
<p>@notif.Message</p>
</div>
</Virtualize>
<!-- 双向流:开头和末尾都会插入数据 -->
<Virtualize Items="@chatHistory"
AnchorMode="VirtualizeAnchorMode.Beginning | VirtualizeAnchorMode.End"
Context="msg">
<div class="message">@msg.Text</div>
</Virtualize>
以前要实现这个效果,你得自己写一堆 JS interop 代码来控制滚动位置,现在一个参数搞定。
5.3 [SupplyParameterFromTempData]:表单提交后的成功消息终于优雅了
Blazor 里有个经典场景:用户提交表单后,服务端处理完重定向到另一个页面,然后在那个页面上显示"操作成功"。
以前的做法是各种 hack:cookie、Session、QueryString 传参数……都不优雅。
Preview 4 新增了 [SupplyParameterFromTempData]:
@page "/account/manage"
<div class="alert alert-success" v-if="StatusMessage != null">
@StatusMessage
</div>
@code {
[SupplyParameterFromTempData]
public string? StatusMessage { get; set; }
}
这个 API 设计跟 [SupplyParameterFromQuery] 一脉相承,但解决了"表单提交后重定向再显示消息"这个经典场景。以前你可能得写:
// 以前:各种临时方案
[HttpPost]
public IActionResult UpdateProfile(ProfileUpdateModel model)
{
// 处理更新
TempData["StatusMessage"] = "个人信息已更新";
return RedirectToPage("./Manage");
}
// 然后在 Razor 页面里读取 TempData
<div class="alert">@TempData["StatusMessage"]</div>
现在可以像这样干净地绑定:
// 服务端重定向时设置
[HttpPost]
public IActionResult UpdateProfile(ProfileUpdateModel model)
{
TempData["StatusMessage"] = "个人信息已更新";
return RedirectToPage("./Manage");
}
@* 页面直接绑定,TempData 会自动注入 *@
@code {
[SupplyParameterFromTempData]
public string? StatusMessage { get; set; }
}
Blazor Identity 模板已经全面采用这个写法,老的 cookie hack 正式退休。
5.4 服务端主动暂停 Blazor Server 电路
Blazor Server 的"电路"(Circuit)是指服务端与客户端之间的持久连接。当服务端需要优雅地关闭或重平衡负载时,以前的做法可能导致客户端突然断线,体验很差。
Preview 4 新增了 Circuit.RequestCircuitPauseAsync:
public class DeploymentManager
{
private readonly CircuitAccessor _circuitAccessor;
public async Task GracefulPauseAsync()
{
// 通知客户端暂停,保留当前状态
await _circuitAccessor.Circuit.RequestCircuitPauseAsync(
pauseDuration: TimeSpan.FromMinutes(5),
reason: "服务端部署,即将重启");
// 此时客户端会显示"连接已暂停,服务即将恢复"
// 服务端可以安全地进行部署操作
}
public async Task ResumeAsync()
{
// 部署完成后,客户端自动重连,服务端恢复状态
await _circuitAccessor.Circuit.RequestCircuitResumeAsync();
}
}
这个 API 在以下场景特别有用:
- Kubernetes 滚动更新:Pod 即将被终止时,优雅地暂停连接,让客户端有时间重连到新 Pod
- 负载均衡器重平衡:将流量从当前实例迁出时,暂停电路而不是直接断连
- 服务端维护窗口:提前通知用户服务即将暂停,给用户保存工作的时间
5.5 Blazor Web Worker 模板升级:改名 + 两个关键功能
以前叫 "NET Web Worker",现在正式改名为 Blazor Web Worker。名字更直观,一看就知道是给 Blazor 用的。
两个重要的新功能:
InvokeVoidAsync:无需返回值的 fire-and-forget 调用
// 以前:必须处理返回值
try
{
await JSRuntime.InvokeAsync<object>("console.log", "消息");
}
catch { /* 忽略错误 */ }
// 现在:一行搞定,不需要处理返回值
await JSRuntime.InvokeVoidAsync("console.log", "消息");
原生支持 CancellationToken 和超时
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
try
{
await JSRuntime.InvokeVoidAsync(
"runLongTask",
cts.Token, // 支持取消
new { timeoutMs = 10000 }); // 支持超时
}
catch (OperationCanceledException)
{
Console.WriteLine("任务超时或被取消");
}
创建 Blazor Web Worker:
dotnet new blazorwebworker -o MyApp.Worker
5.6 Blazor 在 .NET 11 的战略位置
从 Preview 4 的更新力度来看,Blazor 在 .NET 11 中的优先级非常高。几个信号:
- Virtualize 翻修:这是 Blazor 最常用的组件之一,从"能用但不完美"到"完美可用",解决的是真实的生产痛点
- Web Worker 投入:Blazor WebAssembly 在性能上一直被吐槽,Web Worker 模板的改进说明微软在认真解决这个问题
- SSR 表单参数绑定:
[SupplyParameterFromTempData]这个 API 的设计非常优雅,说明 Blazor 在服务端渲染场景下的体验也在快速成熟
六、HTTP QUERY 方法支持:复杂查询 API 的标准化方案
6.1 什么是 HTTP QUERY?
HTTP QUERY 是 IETF 正在标准化的一个新方法,定位是"带 body 的 GET"。
这个需求来源于实际场景:当你需要传递复杂的查询条件时,URL 的长度是有限的,GET 的 querystring 无法容纳。这时候通常的做法是硬着头皮用 POST,但 POST 在语义上代表"创建资源",用来做查询很不优雅。
HTTP QUERY 就是来解决这个问题的:
// 定义一个 QUERY 端点
app.MapMethods("/search", ["QUERY"], (SearchRequest request) =>
{
var results = SearchService.Run(request);
return Results.Ok(results);
});
语义上:QUERY = 查询资源,GET = 获取资源,POST = 创建资源。语义更清晰。
实际影响:对于复杂搜索 API,这个变化会减少很多"到底用 GET 还是 POST"的争议。对于 API 文档和 OpenAPI 规范也有帮助——QUERY 方法的语义更明确,文档工具可以更好地描述你的 API。
6.2 实际场景
假设你有一个商品搜索 API,查询条件包括:
- 关键词(多关键词)
- 价格区间
- 分类(多选)
- 排序方式
- 分页
- 过滤标签
用 GET 的话,URL 会变成这样:
/search?q=手机&price_min=1000&price_max=5000&category=1&category=2&sort=price_asc&page=1&size=20&tags=5g&tags=防水
很丑,而且有些服务端对 URL 长度有限制。
用 POST 的话,语义不对。
用 HTTP QUERY 的话:
public record SearchRequest(
string[] Keywords,
decimal? MinPrice,
decimal? MaxPrice,
int[] Categories,
string SortBy,
int Page,
int PageSize,
string[] Tags);
app.MapMethods("/search", ["QUERY"], (SearchRequest request) =>
{
var results = _searchService.Search(request);
return Results.Ok(results);
});
客户端调用:
QUERY /search HTTP/1.1
Content-Type: application/json
{
"keywords": ["手机", "5G"],
"minPrice": 1000,
"maxPrice": 5000,
"categories": [1, 2],
"sortBy": "price_asc",
"page": 1,
"size": 20,
"tags": ["5g", "防水"]
}
语义清晰,URL 干净,而且查询参数不会泄露到服务器日志中(POST body 通常不会被完整记录)。
6.3 与 OpenAPI 的结合
Preview 4 中,OpenAPI 文档生成器已经支持 QUERY 方法:
app.MapMethods("/search", ["QUERY"], (SearchRequest request) => ...);
// Swagger 文档中会自动生成:
// operationId: search_query
// method: QUERY
// parameters: 从 request 对象的属性推导
这意味着你的 QUERY 端点会自动出现在 Swagger/OpenAPI 文档中,工具链支持是完整的。
七、dotnet watch 终于能用于 MAUI 和移动开发了
7.1 以前的痛点
dotnet watch 是 .NET 开发者的瑞士军刀,代码保存后自动重新编译、重新运行,效率极高。但它有个著名的软肋:在 MAUI 项目上跑不动。
具体问题包括:
- iOS 模拟器热重载经常死锁
- Ctrl+R 重启时抛出异常
- 设备选择器经常卡住
- Android 设备检测不准
这些问题让很多 MAUI 开发者放弃了 dotnet watch,改成了"改代码 → 手动 rebuild → 重启应用"的低效流程。
7.2 Preview 4 的改进
设备选择器重构
dotnet watch --device
现在会弹出交互式列表,带搜索功能(基于 Spectre.Console):
? Select a device to use:
❯ 1. iPhone 16 Pro (iOS 18.4)
2. iPhone 16 (iOS 18.4)
3. Android Pixel 8 (API 35)
4. Pixel 7a (API 34)
5. [Search devices...]
自动检测可用设备
脚本会自动检测已连接的设备:
- macOS 上的 iOS 模拟器(通过 Xcode)
- 连接 USB 的 Android 物理设备
- Android 模拟器(通过 adb)
已修复的问题
- iOS 上的死锁问题:热重载现在能正确工作,不会卡住
- Ctrl+R 重启抛异常:重载机制重新设计,不会再抛
- TFM 选择器卡住:交互式选择器重写,不再假死
7.3 已知限制
iOS 模拟器目前还有一个已知限制:需要在 csproj 里加一个配置:
<PropertyGroup>
<!-- iOS 模拟器需要这个配置才能热重载 -->
<EnableHotReloadOniOSSimulator>true</EnableHotReloadOniOSSimulator>
</PropertyGroup>
预计在 Preview 5 或正式版中会修复这个问题。
7.4 实际工作流
完整的使用流程:
# 1. 启动设备选择器
dotnet watch --device
# 2. 选择设备后,开始热重载监听
dotnet watch run --framework net9.0-ios
# 3. 编辑代码,保存后自动热重载
# ...
# 4. Ctrl+R 手动触发重新编译
# 重启应用,不会再抛异常
对于 MAUI 开发者来说,这个改进能显著提升开发效率。建议升级试试。
八、小但很贴心的改进(开发者体验篇)
8.1 dotnet reference 命令变聪明了
以前:
cd ClassLib2
dotnet reference add ../ClassLib1/ClassLib1.csproj # 报错
你得明确指定 --project 参数。
现在:
cd ClassLib2
dotnet reference add ../ClassLib1/ClassLib1.csproj # 直接工作
dotnet reference 现在会自动查找当前目录的项目文件,跟 dotnet reference list 的行为对齐。减少了很多"找不到项目"的报错。
8.2 Console 支持 FORCE_COLOR 环境变量
这是个小但很实用的改进。
以前,dotnet run | tee build.log 会让彩色输出消失,因为 dotnet 检测到 stdout 被重定向后认为这不是终端,自动关闭颜色输出。
现在,按业界标准支持 FORCE_COLOR=1:
FORCE_COLOR=1 dotnet run | tee build.log
颜色会保留在日志文件中。现在你可以放心地做:
# 保留颜色,方便日志分析工具识别
FORCE_COLOR=1 dotnet build 2>&1 | tee build.log
# CI 中强制颜色输出
export FORCE_COLOR=1
dotnet test --logger "console;verbosity=detailed"
8.3 F# 联合类型(Discriminated Unions)原生支持 JSON 序列化
这对 F# 开发者来说是个好消息。以前 C# 调用 F# 的 API 时,联合类型需要写自定义的 JsonConverter:
// 以前:需要写一个 converter
public class FSharpUnionConverter : JsonConverter<FSharpUnion>
{
public override FSharpUnion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// ... 一堆转换逻辑
}
}
// 使用时:
JsonSerializer.Serialize(myUnion, new JsonSerializerOptions { Converters = { new FSharpUnionConverter() } });
现在:System.Text.Json 原生支持 F# 联合类型,不需要任何 converter。F# 党狂喜。
8.4 MemoryCache 内置 OpenTelemetry 指标
Microsoft.Extensions.Caching.Memory.MemoryCache 现在能直接吐出 OpenTelemetry 兼容的指标:
| 指标名 | 标签 | 说明 |
|---|---|---|
dotnet.cache.requests | hit/miss | 缓存请求数 |
dotnet.cache.evictions | - | 缓存驱逐数 |
dotnet.cache.entries | - | 当前缓存条目数 |
dotnet.cache.estimated_size | - | 预估缓存大小 |
使用方式:
var services = new ServiceCollection();
services.AddOpenTelemetry() // 自动接入 MemoryCache 指标
.WithMetrics();
services.AddMemoryCache();
Grafana 仪表盘可以直接接,不用再自己包一层。这对微服务架构的可观测性建设很有帮助。
8.5 Kestrel TLS 握手能拿到失败原因了
以前,TLS 握手失败就是个干巴巴的 IOException:
IOException: Unable to read data from the transport connection
排查 SSL 问题得靠经验瞎猜。
现在,通过 ITlsHandshakeFeature.Exception 可以拿到具体的失败原因:
app.Use(async context =>
{
var tlsFeature = context.Features.Get<ITlsHandshakeFeature>();
if (tlsFeature is not null)
{
var exception = tlsFeature.Exception;
if (exception is not null)
{
_logger.LogError("TLS handshake failed: {Reason}", exception.Message);
// 可以区分:证书过期、域名不匹配、协议版本不兼容等
}
}
});
8.6 HttpClient 在 Windows 认证场景自动降级到 HTTP/1.1
NTLM/Negotiate 这些 Windows 身份认证机制不支持 HTTP/2。以前如果服务端要求 Windows 认证但同时只支持 HTTP/2,请求会直接失败。
现在,HttpClient 会自动检测到这种情况并降到 HTTP/1.1:
// 自动处理,不需要你写任何特殊代码
var client = new HttpClient();
var response = await client.GetAsync("https://intranet.company.com/api/data");
// 如果服务端需要 NTLM 且不支持 HTTP/2,会自动使用 HTTP/1.1
这对企业内网应用是个福音,不用再因为"我用的是 HTTP/2 吗"这种问题踩坑了。
8.7 [ConfigurationIgnore] 属性
新增的 [ConfigurationIgnore] 属性让你可以声明式地排除某个属性不参与配置绑定:
public sealed class AppOptions
{
public string Endpoint { get; set; } = "";
[ConfigurationIgnore] // 这个属性不参与配置绑定
public string ComputedKey => Endpoint + ":default";
public string ApiKey { get; set; } = "";
}
以前要排除一个属性,得写各种 hack(Configure() 时手动跳过,或者用 [JsonIgnore] 但配置绑定又绕过了它)。现在一个 attribute 搞定。
九、底层改动:1024+ CPU 支持和运行时架构优化
9.1 支持超过 1024 个 CPU 的机器
这是 .NET 11 Preview 4 中最"重量级"的改动之一。
有客户报上来,.NET 在 1024+ 核的机器上启动不了。具体表现是:
- 启动时报错:"平台不支持此 CPU 数量"
- 或者启动后线程调度极不稳定
微软花了大力气重写了线程调度器的上限检测逻辑,现在 .NET 11 可以正常运行在 1024+ 核的机器上,包括:
- 高端服务器(128 核、256 核)
- 云原生超大实例(AWS r7g.16xlarge 等)
- 超融合架构的虚拟机
对绝大多数人来说,这个改动不会影响你的日常工作。但对于在大规模服务器上跑 .NET 的运维/架构同学,这个修复值得记一笔。
9.2 Runtime-Async 底层架构
Runtime-Async 是 .NET 11 最核心的底层改动。它不是简单的一个优化,而是整个异步编程模型的架构重构。
编译器视角(以前):
// C# 代码
async Task<string> FetchDataAsync()
{
var data = await _httpClient.GetStringAsync("https://api.example.com/data");
return ProcessData(data);
}
编译器会生成这样的状态机(简化):
[CompilerGenerated]
struct <FetchDataAsync>d__0 : IAsyncStateMachine
{
public int <>1__state;
public AsyncTaskMethodBuilder<string> <>t__builder;
private Task<string> <data>5__1;
private Action <>moveNext;
void IAsyncStateMachine.MoveNext()
{
switch (<>1__state)
{
case 0: goto State0;
case 1: goto State1;
}
// ... 几百行状态机代码
}
}
运行时视角(Preview 4):
运行时现在可以直接创建和管理状态机,不再需要编译器生成。这意味着:
- 状态机代码不再出现在你的二进制中
- 运行时可以更高效地复用状态机对象(减少 GC 压力)
- 调试器可以更好地展示异步调用栈(不再是状态机内部跳转)
对普通开发者来说,这意味着更小的二进制、更快的启动、更清晰的调用栈。不需要改一行代码。
十、总结:.NET 11 Preview 4 的四大方向
把这次 Preview 4 的内容串起来看,有几个明显方向:
10.1 AI 是真当核心方向在搞
MCP Server 模板内置 SDK 这一手最能说明问题。.NET 不想在 AI 这波里掉队,而且选择拥抱开放协议(MCP),而不是搞封闭那一套。从 .NET 8 的 Microsoft.Extensions.AI 到现在的 MCP 开箱即用,这条路是连贯的。
10.2 Blazor 不光没死,反而越来越能打
Virtualize 修好、Web Worker 改进、SSR 表单参数绑定、电路暂停/恢复——这些都是实打实让生产体验上一个台阶的改进。如果你之前因为一些"小问题"放弃了 Blazor,值得重新捡起来看看。
10.3 开发者体验持续打磨
从 dotnet watch 支持移动开发,到 Process.RunAndCaptureTextAsync,再到 FORCE_COLOR 这种细节——日常用得最多的地方在变得越来越顺手。这才是预览版该有的样子:解决真实问题,不是画饼。
10.4 可观测性内化进框架
MemoryCache 内置 OpenTelemetry 指标、CLI 自身改用 OpenTelemetry——把"可观测"当成 framework 自带能力,而不是要靠一堆三方包拼出来。这个方向对微服务/云原生场景尤其重要。
10.5 升级建议
如果你是 .NET 10 用户:Preview 4 值得在开发环境跑跑,特别是:
- Blazor 项目(Virtualize 的修复很香)
- MAUI 项目(dotnet watch 现在能跑了)
- CLI 工具开发者(Process API 变化很大)
如果你是 .NET 8 或更早用户:可以继续观望,等正式版再升级。Preview 4 的内容虽然好,但正式版还需要几个月。
正式版预计今年 11 月发布,届时咱们再做一次完整的升级指南。
相关资源: