编程 .NET 11 Preview 4 深度解析:Runtime-Async 全面启用、MCP Server 内置 SDK、Process API 一行搞定——微软最务实的一次预览版升级

2026-05-15 20:19:03 +0800 CST views 4

.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,你得:

  1. 手动安装模板包:dotnet new install Microsoft.McpServer.ProjectTemplates
  2. 配置一堆 NuGet 依赖
  3. 研究协议细节,自己实现 JSON-RPC 通信
  4. 处理传输层(stdio / SSE)的细节

整个过程大概需要半天到一天,而且文档分散,坑不少。

2.3 现在:一行命令,开箱即用

dotnet new mcpserver -o MyMcpServer

就这一行。模板直接内置在 SDK 里,跟 consolewebapiclasslib 平起平坐。

$ 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,这个动作的信号意义远大于技术本身:

  1. .NET 不想在 AI 这波里当配角。从 .NET 8 开始的 Microsoft.Extensions.AI、Semantic Kernel 集成,到现在 MCP 模板开箱即用,微软在 AI 工具链上的投入是持续且坚定的。

  2. 拥抱开放协议而非封闭生态。微软没有搞自己的"MCP 替代品",而是直接支持 Anthropic 的开放标准。这对开发者来说是最好的结果——你写的 MCP Server 可以被 Claude、ChatGPT、Cursor 等任何支持 MCP 的客户端调用,不会被锁定在某个生态里。

  3. 企业 .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启动并一次性读取全部 stdoutstring
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 StartAndForgetStartDetached 的区别

这两个容易混淆,说清楚:

// StartAndForget:启动后不再跟踪,但子进程仍然是父进程的子进程
// 如果父进程退出,子进程可能会被操作系统终止(取决于平台)
Process.StartAndForget("nginx", ["-g", "daemon off;"]);

// StartDetached:子进程完全脱离父进程,成为独立的会话
// 父进程退出后,子进程继续运行
var psi = new ProcessStartInfo("my-background-service")
{
    StartDetached = true
};
Process.Start(psi);

实际场景:

  • StartAndForget:适合在 CLI 工具中启动辅助进程,比如构建脚本启动一个 watcher
  • StartDetached:适合安装程序、守护进程,父进程退出后服务继续跑

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 状态机,而不是编译器

这听起来是个内部实现细节,但它的影响是深远的:

  1. 更小的二进制体积:编译器不再为每个 async 方法生成几百行的状态机代码
  2. 更好的调试体验:异步调用栈更清晰,不再是一堆 MoveNext()
  3. 更高的吞吐量:运行时可以对状态机做编译器无法做到的优化

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> 组件做长列表,你一定遇到过这个场景:

  1. 列表加载了一屏数据
  2. 某个列表项展开了,或者上方有新数据加载完成
  3. 你正在看的位置突然跳了一下

这个问题困扰了 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 中的优先级非常高。几个信号:

  1. Virtualize 翻修:这是 Blazor 最常用的组件之一,从"能用但不完美"到"完美可用",解决的是真实的生产痛点
  2. Web Worker 投入:Blazor WebAssembly 在性能上一直被吐槽,Web Worker 模板的改进说明微软在认真解决这个问题
  3. 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.requestshit/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):

运行时现在可以直接创建和管理状态机,不再需要编译器生成。这意味着:

  1. 状态机代码不再出现在你的二进制中
  2. 运行时可以更高效地复用状态机对象(减少 GC 压力)
  3. 调试器可以更好地展示异步调用栈(不再是状态机内部跳转)

对普通开发者来说,这意味着更小的二进制、更快的启动、更清晰的调用栈。不需要改一行代码。


十、总结:.NET 11 Preview 4 的四大方向

把这次 Preview 4 的内容串起来看,有几个明显方向:

10.1 AI 是真当核心方向在搞

MCP Server 模板内置 SDK 这一手最能说明问题。.NET 不想在 AI 这波里掉队,而且选择拥抱开放协议(MCP),而不是搞封闭那一套。从 .NET 8Microsoft.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 月发布,届时咱们再做一次完整的升级指南。


相关资源

推荐文章

Vue3中怎样处理组件引用?
2024-11-18 23:17:15 +0800 CST
Go 如何做好缓存
2024-11-18 13:33:37 +0800 CST
Vue3中哪些API被废弃了?
2024-11-17 04:17:22 +0800 CST
JavaScript 实现访问本地文件夹
2024-11-18 23:12:47 +0800 CST
goctl 技术系列 - Go 模板入门
2024-11-19 04:12:13 +0800 CST
程序员茄子在线接单