ASP.NET Core 9.0 的新增功能

本文重点介绍 ASP.NET Core 9.0 中最重要的更改,并提供相关文档的链接。

静态资产传递优化

MapStaticAssets 路由终结点约定 是一项新功能,可优化 ASP.NET Core 应用中静态资产的交付。

有关应用的静态资产传送 Blazor 的信息,请参阅 ASP.NET Core Blazor 静态文件

遵循为静态资产提供服务的生产最佳做法需要大量的工作和技术专业知识。 如果没有压缩、缓存和 指纹等优化:

  • 浏览器必须在每个页面加载时发出其他请求。
  • 通过网络传输的字节数超过所需的字节数。
  • 有时,会将文件的过时版本提供给客户端。

创建高性能 Web 应用需要优化到浏览器的资产传送。 可能的优化包括:

  • 在文件发生更改或浏览器清除其缓存之前,提供一次给定资产。 设置 ETag 标头。
  • 更新应用后,阻止浏览器使用旧资产或过时资产。 设置上次修改的标头。
  • 设置正确的缓存标头
  • 使用缓存中间件
  • 尽可能提供资产的压缩版本。
  • 使用 CDN 为离用户更近的资产提供服务。
  • 最大程度地减少提供给浏览器的资产大小。 此优化不包括缩小。

MapStaticAssets 是一项新功能,用于优化应用中静态资产的交付。 它旨在处理所有 UI 框架,包括 Blazor、Razor、Pages 和 MVC。 它通常是一个下降的替代方法 UseStaticFiles

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorPages();

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();

app.UseRouting();

app.UseAuthorization();

+app.MapStaticAssets();
-app.UseStaticFiles();
app.MapRazorPages();

app.Run();

MapStaticAssets 的运作方式是结合生成和发布时过程来收集应用中所有静态资源的信息。 然后,运行时库会利用此信息有效地向浏览器提供这些文件。

但是,MapStaticAssets 在大多数情况下可以替换 UseStaticFiles,它已针对为应用在生成和发布时了解的资产提供服务进行了优化。 如果应用服务来自其他位置(如磁盘或嵌入资源)的资产,则应使用 UseStaticFiles

MapStaticAssets 提供了以下 UseStaticFiles 没有的好处:

  • 为应用中的所有资产生成时间压缩:
    • 在开发期间 gzip,在发布期间 gzip + brotli
    • 所有资产都经过压缩,目标是将资产大小降到最低。
  • 基于内容的 ETags:每个资源的 Etags 都是内容的 SHA-256 哈希的 Base64 编码字符串。 这可确保浏览器仅在文件内容发生更改时重新下载文件。

下表显示了默认的 Razor Pages 模板中 CSS 和 JS 文件的原始大小和压缩大小:

文件 原始 压缩 % 缩减
bootstrap.min.css 163 17.5 89.26%
jquery.js 89.6 28 68.75%
bootstrap.min.js 78.5 20 74.52%
总计 331.1 65.5 80.20%

下表显示了使用 Fluent UI Blazor 组件库的原始大小和压缩大小:

文件 原始 压缩 % 缩减
fluent.js 384 73 80.99%
fluent.css 94 11 88.30%
总计 478 84 82.43%

总共 478 KB 未压缩到 84 KB 压缩。

下表显示了使用 MudBlazorBlazor 组件库的原始大小和压缩大小:

文件 原始 压缩 约简
MudBlazor.min.css 541 37.5 93.07%
MudBlazor.min.js 47.4 9.2 80.59%
总计 588.4 46.7 92.07%

使用 MapStaticAssets 时自动进行优化。 添加或更新库(例如使用新的 JavaScript 或 CSS)时,资产将作为生成的一部分进行优化。 优化对于可具有较低带宽或不可靠的连接的移动环境尤其有利。

有关新的文件传递功能的详细信息,请参阅以下资源:

在服务器上启用动态压缩与使用 MapStaticAssets

在服务器上,与动态压缩相比,MapStaticAssets 具有以下优势:

  • 更简单,因为没有特定于服务器的配置。
  • 性能更高,因为资产在生成时被压缩。
  • 允许开发人员在生成过程中花费额外的时间,以确保资产的大小达到最小。

请查看下表,它将 MudBlazor 压缩与 IIS 动态压缩和 MapStaticAssets 进行了比较:

IIS gzip MapStaticAssets MapStaticAssets 减少
≅ 90 37.5 59%

Blazor

本部分介绍 Blazor 的新功能。

.NET MAUIBlazor Hybrid 和 Web 应用解决方案模板

使用新的解决方案模板可以更轻松地创建共享相同 UI 的 .NET MAUI 本机应用和 Blazor Web 客户端应用。 本模板展示了如何创建客户端应用,以最大限度地重复使用代码,并针对 Android、iOS、Mac、Windows 和 Web。

此模板的主要功能包括:

  • 可为 Web 应用选择 Blazor 交互式呈现模式。
  • 自动创建相应的项目,包括 Blazor Web App(全局交互式自动呈现)和 .NET MAUIBlazor Hybrid 应用。
  • 创建的项目使用共享的 Razor 类库 (RCL) 来维护 UI 的 Razor 组件。
  • 附带的示例代码演示了如何使用依赖项注入为 Blazor Hybrid 应用和 Blazor Web App 提供不同的接口实现。

若要开始,请安装 .NET 9 SDK 并安装包含模板的 .NET MAUI 工作负载:

dotnet workload install maui

在命令 shell 中使用以下命令从项目模板创建解决方案:

dotnet new maui-blazor-web

该模板也可以在 Visual Studio 中使用。

注意

目前,如果在每个页面/组件级别定义了 Blazor 渲染模式,会出现异常。 有关详细信息,请参阅 BlazorWebView 需要一种方法来启用 ResolveComponentForRenderMode 的重写 (dotnet/aspnetcore #51235)

有关详细信息,请参阅生成具有 Blazor Web App 的 .NET MAUIBlazor Hybrid 应用

在运行时检测呈现位置、交互性和分配的呈现模式

我们引入了一个新的 API,旨在简化在运行时查询组件状态的过程。 此 API 提供以下功能:

  • 确定组件的当前执行位置:这对于调试和优化组件性能非常有用。
  • 检查组件是否在交互式环境中运行:这对于根据环境的交互性而有不同行为的组件很有帮助。
  • 检索为组件指定的渲染模式:了解渲染模式有助于优化渲染过程并提高组件的整体性能

有关详细信息,请参阅 ASP.NET Core Blazor 呈现模式

改善了服务器端重新连接体验:

默认的服务器端重新连接体验进行了以下增强:

  • 当用户导航回线路已断开连接的应用时,将立即尝试重新连接,而不是等待下一次重新连接间隔的持续时间。 这可改善在浏览器选项卡中导航到已进入睡眠状态的应用时的用户体验。

  • 当重新连接尝试到达服务器,但服务器已释放线路时,页面会自动刷新。 这样可以防止用户在页面很可能成功重新连接的情况下,还需要手动刷新页面。

  • 重新连接计时使用计算的回退策略。 默认情况下,在尝试之间引入计算延迟之前,前几次重新连接尝试会快速连续进行,不会出现重试间隔。 可以指定一个函数来计算重试间隔,从而自定义重试间隔行为,如以下指数退避示例所示:

    Blazor.start({
      circuit: {
        reconnectionOptions: {
          retryIntervalMilliseconds: (previousAttempts, maxRetries) => 
            previousAttempts >= maxRetries ? null : previousAttempts * 1000
        },
      },
    });
    
  • 默认重新连接 UI 的样式已经过现代化设计。

有关详细信息,请参阅 ASP.NET Core BlazorSignalR 指南

简化 Blazor Web App 的身份验证状态序列化

通过新的 API,可以更轻松地向现有 Blazor Web App 添加身份验证。 创建使用个人帐户进行身份验证的新 Blazor Web App 并启用基于 WebAssembly 的交互性时,该项目会在服务器和客户端项目中包含一个自定义 AuthenticationStateProvider

这些提供程序将用户的身份验证状态传输到浏览器。 在服务器上而不是在客户端上进行身份验证,可以让应用在预呈现期间和初始化 .NET WebAssembly 运行时之前访问身份验证状态。

自定义 AuthenticationStateProvider 实现使用 永久性组件状态服务(PersistentComponentState)将身份验证状态序列化为 HTML 注释,然后重新从 WebAssembly 读取,以创建新的 AuthenticationState 实例。

如果通过 Blazor Web App 项目模板开始,并选择“个人帐户”选项,那么这将非常适用,但如果尝试向现有项目添加身份验证,那么需要自己实现或复制大量代码。 现在可通过在服务器和客户端项目中调用 API(现在是 Blazor Web App 项目模板的一部分)来添加此功能:

默认情况下,API 只会序列化服务器端的名称和角色声明,以便在浏览器中访问。 可以将选项传递给 AddAuthenticationStateSerialization 以包括所有声明。

有关详细信息,请参阅 Secure ASP.NET Core 服务器端 Blazor 应用的以下部分:

将静态服务器端呈现 (SSR) 页面添加到全局交互式 Blazor Web App

随着 .NET 9 的发布,现在可以更简单地将静态 SSR 页面添加到采用全局交互性的应用程序中。

仅当应用程序具有无法使用交互式服务器或 WebAssembly 渲染的特定页面时,此方法才有用。 例如,对于依赖于读/写 HTTP Cookie 并且只能在请求/响应周期中运行而无法通过交互式呈现加载的页面,可采用这种方法。 对于使用交互式渲染的页面,不应强制它们使用静态 SSR 渲染,因为这一方法的效率较低且对最终用户的响应较差。

使用指令分配@attributeRazor的新[ExcludeFromInteractiveRouting]属性标记任何Razor组件页:

@attribute [ExcludeFromInteractiveRouting]

应用该属性将停止交互式路由并导航到不同的页面。 入站导航会强制执行全页重载,而不是通过交互式路由解析页面。 全页重载会迫使顶级根组件从服务器重新渲染,通常情况下,这一组件是 App 组件 (App.razor),重新渲染可以让应用切换到其他顶级渲染模式。

扩展RazorComponentsEndpointHttpContextExtensions.AcceptsInteractiveRouting方法允许组件检测属性是否[ExcludeFromInteractiveRouting]应用于当前页。

App 组件中,使用以下示例中的模式:

  • 未使用属性注释的页面[ExcludeFromInteractiveRouting]默认为InteractiveServer具有全局交互性的呈现模式。 可以将 InteractiveServer 替换为 InteractiveWebAssemblyInteractiveAuto 以指定不同的默认全局渲染模式。
  • 使用 [ExcludeFromInteractiveRouting] 属性 进行批注的页面采用静态 SSR(PageRenderModenull分配)。
<!DOCTYPE html>
<html>
<head>
    ...
    <HeadOutlet @rendermode="@PageRenderMode" />
</head>
<body>
    <Routes @rendermode="@PageRenderMode" />
    ...
</body>
</html>

@code {
    [CascadingParameter]
    private HttpContext HttpContext { get; set; } = default!;

    private IComponentRenderMode? PageRenderMode
        => HttpContext.AcceptsInteractiveRouting() ? InteractiveServer : null;
}

使用 RazorComponentsEndpointHttpContextExtensions.AcceptsInteractiveRouting 扩展方法的替代方法是使用 HttpContext.GetEndpoint()?.Metadata 手动读取终结点元数据。

有关此功能的详细信息,请参阅 ASP.NET CoreBlazor 渲染模式中的参考资料。

构造函数注入

Razor 组件支持构造函数注入。

在以下示例中,部分(代码隐藏)类使用主构造函数注入了 NavigationManager 服务:

public partial class ConstructorInjection(NavigationManager navigation)
{
    private void HandleClick()
    {
        navigation.NavigateTo("/counter");
    }
}

有关详细信息,请参阅 ASP.NET Core Blazor 依赖项注入

交互式服务器组件的 Websocket 压缩

默认情况下,交互式服务器组件会为 WebSocket 连接 启用压缩,并将frame-ancestors 内容安全策略 (CSP) 指令设置为 'self',这会仅允许在启用压缩时或提供 WebSocket 上下文的配置时将应用嵌入为该应用提供服务的来源的 <iframe> 中。

可以通过将 ConfigureWebSocketOptions 设为 null 来禁用压缩,这可以减少应用受攻击的风险,但可能会导致性能降低:

.AddInteractiveServerRenderMode(o => o.ConfigureWebSocketOptions = null)

请考虑使用带有值 'none'(需要单引号)的更严格的 frame-ancestors CSP,它允许 WebSocket 压缩,但会阻止浏览器将应用嵌入任何 <iframe>

.AddInteractiveServerRenderMode(o => o.ContentSecurityFrameAncestorsPolicy = "'none'")

有关更多信息,请参见以下资源:

处理 Blazor 中的键盘组合事件

新的 KeyboardEventArgs.IsComposing 属性指示键盘事件是否是组合会话的一部分。 跟踪键盘事件的组合状态对于处理国际字符输入法至关重要。

QuickGrid 添加了 OverscanCount 参数

QuickGrid 组件现在公开了一个 OverscanCount 属性,该属性用于指定启用虚拟化时在可见区域之前和之后渲染的附加行数。

默认 OverscanCount 为 3。 以下示例将 OverscanCount 提升到 4:

<QuickGrid ItemsProvider="itemsProvider" Virtualize="true" OverscanCount="4">
    ...
</QuickGrid>

InputNumber 组件支持 type="range" 属性

InputNumber<TValue> 组件现在支持 type="range" 属性,该属性创建支持模型绑定和表单验证的范围输入,通常呈现为滑块或拨号控件,而不是文本框:

<EditForm Model="Model" OnSubmit="Submit" FormName="EngineForm">
    <div>
        <label>
            Nacelle Count (2-6): 
            <InputNumber @bind-Value="Model!.NacelleCount" max="6" min="2" 
                step="1" type="range" />
        </label>
    </div>
    <div>
        <button type="submit">Submit</button>
    </div>
</EditForm>

@code {
    [SupplyParameterFromForm]
    private EngineSpecifications? Model { get; set; }

    protected override void OnInitialized() => Model ??= new();

    private void Submit() {}

    public class EngineSpecifications
    {
        [Required, Range(minimum: 2, maximum: 6)]
        public int NacelleCount { get; set; }
    }
}

SignalR

本部分介绍 SignalR 的新功能。

SignalR 中心支持多态类型

中心方法现在可接受基类(而不是派生类)来实现多态方案。 需要注释基类型才能实现多形性

public class MyHub : Hub
{
    public void Method(JsonPerson person)
    {
        if (person is JsonPersonExtended)
        {
        }
        else if (person is JsonPersonExtended2)
        {
        }
        else
        {
        }
    }
}

[JsonPolymorphic]
[JsonDerivedType(typeof(JsonPersonExtended), nameof(JsonPersonExtended))]
[JsonDerivedType(typeof(JsonPersonExtended2), nameof(JsonPersonExtended2))]
private class JsonPerson
{
    public string Name { get; set; }
    public Person Child { get; set; }
    public Person Parent { get; set; }
}

private class JsonPersonExtended : JsonPerson
{
    public int Age { get; set; }
}

private class JsonPersonExtended2 : JsonPerson
{
    public string Location { get; set; }
}

改进了 SignalR 的活动

现在,SignalR 有一个名为 Microsoft.AspNetCore.SignalR.Server 的 ActivitySource,可发出中心方法调用的事件:

  • 每个方法都是其自己的活动,因此在中心方法调用期间发出活动的任何内容都位于中心方法活动下。
  • 中心方法活动没有父级。 这意味着它们不会捆绑在长期运行的 SignalR 连接下。

以下示例使用 .NET Aspire 仪表板和 OpenTelemetry 包:

<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.9.0" />

将以下启动代码添加到 Program.cs 文件:

// Set OTEL_EXPORTER_OTLP_ENDPOINT environment variable depending on where your OTEL endpoint is
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorPages();
builder.Services.AddSignalR();

builder.Services.AddOpenTelemetry()
    .WithTracing(tracing =>
    {
        if (builder.Environment.IsDevelopment())
        {
            // We want to view all traces in development
            tracing.SetSampler(new AlwaysOnSampler());
        }

        tracing.AddAspNetCoreInstrumentation();
        tracing.AddSource("Microsoft.AspNetCore.SignalR.Server");
    });

builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter());

下面是 Aspire 仪表板的示例输出:

SignalR 中心方法调用事件的活动列表

SignalR 支持剪裁和本机 AOT

继续从 .NET 8 开始开启本机 AOT 之旅,我们为 SignalR 客户端和服务器方案启用了剪裁和本机提前 (AOT) 编译支持。 现在可以在使用 SignalR 进行实时 Web 通信的应用程序中利用本机 AOT 的性能优势。

入门

安装最新的 .NET 9 SDK

使用以下命令,在命令 shell 中从 webapiaot 模板创建解决方案:

dotnet new webapiaot -o SignalRChatAOTExample

Program.cs 文件的内容替换为以下 SignalR 代码:

using Microsoft.AspNetCore.SignalR;
using System.Text.Json.Serialization;

var builder = WebApplication.CreateSlimBuilder(args);

builder.Services.AddSignalR();
builder.Services.Configure<JsonHubProtocolOptions>(o =>
{
    o.PayloadSerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
});

var app = builder.Build();

app.MapHub<ChatHub>("/chatHub");
app.MapGet("/", () => Results.Content("""
<!DOCTYPE html>
<html>
<head>
    <title>SignalR Chat</title>
</head>
<body>
    <input id="userInput" placeholder="Enter your name" />
    <input id="messageInput" placeholder="Type a message" />
    <button onclick="sendMessage()">Send</button>
    <ul id="messages"></ul>

    <script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/8.0.7/signalr.min.js"></script>
    <script>
        const connection = new signalR.HubConnectionBuilder()
            .withUrl("/chatHub")
            .build();

        connection.on("ReceiveMessage", (user, message) => {
            const li = document.createElement("li");
            li.textContent = `${user}: ${message}`;
            document.getElementById("messages").appendChild(li);
        });

        async function sendMessage() {
            const user = document.getElementById("userInput").value;
            const message = document.getElementById("messageInput").value;
            await connection.invoke("SendMessage", user, message);
        }

        connection.start().catch(err => console.error(err));
    </script>
</body>
</html>
""", "text/html"));

app.Run();

[JsonSerializable(typeof(string))]
internal partial class AppJsonSerializerContext : JsonSerializerContext { }

public class ChatHub : Hub
{
    public async Task SendMessage(string user, string message)
    {
        await Clients.All.SendAsync("ReceiveMessage", user, message);
    }
}

前面的示例生成一个10 MB 的 本机 Windows 可执行文件,以及一个 10.9 MB 的 Linux 可执行文件。

限制

  • 目前仅支持 JSON 协议:
    • 如前面的代码所示,使用 JSON 序列化和本机 AOT 的应用必须使用 System.Text.Json 源生成器。
    • 这采用了与最小 API 相同的方法。
  • 在 SignalR 服务器上,不支持类型为 IAsyncEnumerable<T>ChannelReader<T> 的中心方法参数,其中 T 是 ValueType(struct)。 使用这些类型会导致开发中的和已发布的应用在启动时出现运行时异常。 有关详细信息,请参阅 SignalR:在本机 AOT 中,通过 ValueTypes 使用 IAsyncEnumerable<T> 和 ChannelReader<T> (dotnet/aspnetcore#56179)
  • 本机 AOT (PublishAot) 不支持强类型中心。 对本机 AOT 使用强类型中心将导致生成和发布期间出现警告,以及运行时异常。 支持将强类型中心与剪裁 (PublishedTrimmed) 结合使用。
  • 异步返回类型仅支持TaskTask<T>ValueTaskValueTask<T>

最小 API

本部分介绍最小 API 的新功能。

TypedResults 添加了 InternalServerErrorInternalServerError<TValue>

TypedResults 类是一种有用的工具,用于从最小的 API 返回基于强类型的 HTTP 状态代码响应。 TypedResults 现在包括用于从终结点返回“500 内部服务器错误”响应的工厂方法和类型。 下面是返回 500 响应的示例:

var app = WebApplication.Create();

app.MapGet("/", () => TypedResults.InternalServerError("Something went wrong!"));

app.Run();

在路由组上调用 ProducesProblemProducesValidationProblem

ProducesProblemProducesValidationProblem 扩展方法已更新,以支持其在路由组上使用。 这些方法表明路由组中的所有终结点都可以返回 ProblemDetailsValidationProblemDetails 响应,以实现 OpenAPI 元数据的目的。

var app = WebApplication.Create();

var todos = app.MapGroup("/todos")
    .ProducesProblem();

todos.MapGet("/", () => new Todo(1, "Create sample app", false));
todos.MapPost("/", (Todo todo) => Results.Ok(todo));

app.Run();

record Todo(int Id, string Title, boolean IsCompleted);

ProblemValidationProblem 结果类型支持使用 IEnumerable<KeyValuePair<string, object?>> 值构建

在 .NET 9 之前,在最小 API 中构建 ProblemValidationProblem 结果类型要求使用 IDictionary<string, object?> 的实现来初始化 errorsextensions 属性。 在此版本中,这些构建 API 支持使用 IEnumerable<KeyValuePair<string, object?>> 的重载。

var app = WebApplication.Create();

app.MapGet("/", () =>
{
    var extensions = new List<KeyValuePair<string, object?>> { new("test", "value") };
    return TypedResults.Problem("This is an error with extensions",
                                                       extensions: extensions);
});

感谢 GitHub 用户 joegoldman2 的这一贡献!

OpenAPI

本部分介绍 OpenAPI 的新功能

针对 OpenAPI 文档生成的内置支持

OpenAPI 规范是描述 HTTP API 的标准。 开发人员可通过此标准定义可插入至客户端生成器、服务器生成器、测试工具、文档等项目的 API 的形式。 在 .NET 9 中,ASP.NET Core 提供内置支持,用于通过 Microsoft.AspNetCore.OpenApi 包生成代表基于控制器的 API 或最小 API 的 OpenAPI 文档。

以下突出显示的代码调用了下面两项内容:

  • AddOpenApi,用于将所需的依赖项注册到应用程序的 DI 容器中。
  • MapOpenApi,用于在应用程序的路由中注册所需的 OpenAPI 端点。
var builder = WebApplication.CreateBuilder();

builder.Services.AddOpenApi();

var app = builder.Build();

app.MapOpenApi();

app.MapGet("/hello/{name}", (string name) => $"Hello {name}"!);

app.Run();

使用以下命令在项目中安装 Microsoft.AspNetCore.OpenApi 包:

dotnet add package Microsoft.AspNetCore.OpenApi --prerelease

运行应用程序并导航到 openapi/v1.json 以查看生成的 OpenAPI 文档:

OpenAPI 文档

还可以通过添加 Microsoft.Extensions.ApiDescription.Server 包,在构建时生成 OpenAPI 文档:

dotnet add package Microsoft.Extensions.ApiDescription.Server --prerelease

若要修改发出的 OpenAPI 文档的位置,请在应用的项目文件中的 OpenApiDocumentsDirectory 属性中设置目标路径:

<PropertyGroup>
  <OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)</OpenApiDocumentsDirectory>
</PropertyGroup>

运行 dotnet build 并检查项目目录中生成的 JSON 文件。

在构建时生成 OpenAPI 文档

ASP.NET Core 的内置 OpenAPI 文档生成为各种自定义和选项提供支持。 它提供文档、操作和架构转换器,并且能够管理同一应用程序的多个 OpenAPI 文档。

要了解有关 ASP.NET Core 的新 OpenAPI 文档功能的更多信息,请参阅新的 Microsoft.AspNetCore.OpenApi 文档

Microsoft.AspNetCore.OpenApi 支持剪裁和本机 AOT

ASP.NET Core 中新的内置 OpenAPI 现在也支持剪裁和本机 AOT。

开始使用

新建 ASP.NET Core Web API(本机 AOT)项目。

dotnet new webapiaot

添加 Microsoft.AspNetCore.OpenAPI 包。

dotnet add package Microsoft.AspNetCore.OpenApi --prerelease

对于此预览版,还需要添加最新的 Microsoft.OpenAPI 包以避免剪裁警告。

dotnet add package Microsoft.OpenApi

更新 Program.cs 以启用生成 OpenAPI 文档。

+ builder.Services.AddOpenApi();

var app = builder.Build();

+ app.MapOpenApi();

发布应用。

dotnet publish

应用使用本机 AOT 发布,没有警告。

身份验证和授权

本部分介绍身份验证和授权的新功能。

OpenIdConnectHandler 添加了对推送授权请求的支持 (PAR)

我们要感谢 Duende SoftwareJoe DeCock 将推送授权请求 (PAR) 添加到 ASP.NET Core 的 OpenIdConnectHandler。 Joe 在他的 API 建议中描述了启用 PAR 的背景和动机,如下所示:

推送授权请求 (PAR) 是一种相对较新的 OAuth 标准,它通过将授权参数从前通道移动到后通道来提高 OAuth 和 OIDC 流的安全性。 也就是说,将授权参数从浏览器中的重定向 URL 移动到后端计算机对计算机的直接 http 调用。

这可以防止浏览器中的网络攻击者:

  • 查看可能泄漏 PII 的授权参数。
  • 篡改这些参数。 例如,网络攻击者可能会更改所请求的访问范围。

推送授权参数还会使请求 URL 保持简短。 使用更复杂的 OAuth 和 OIDC 功能(如“丰富授权请求”)时,授权参数可能会变得很长。 较长的 URL 会导致许多浏览器和网络基础设施出现问题。

OpenID Foundation 中的 FAPI 工作组鼓励使用 PAR。 例如,FAPI2.0 安全配置文件需要使用 PAR。 许多从事开放银行业务(主要在欧洲)、医疗保健和其他具有较高安全要求的行业的团体都在使用此安全配置文件。

许多 identity 提供程序都支持 PAR,包括

对于 .NET 9,如果 identity 提供程序的发现文档显示支持 PAR,那么我们决定默认启用 PAR,因为它应该为支持 PAR 的提供程序提供增强的安全性。 identity 提供程序的发现文档通常位于 .well-known/openid-configuration。 如果因此而导致问题,可以通过 OpenIdConnectOptions.PushedAuthorizationBehavior 禁用 PAR,如下所示:

builder.Services
    .AddAuthentication(options =>
    {
        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
    })
    .AddCookie()
    .AddOpenIdConnect("oidc", oidcOptions =>
    {
        // Other provider-specific configuration goes here.

        // The default value is PushedAuthorizationBehavior.UseIfAvailable.

        // 'OpenIdConnectOptions' does not contain a definition for 'PushedAuthorizationBehavior'
        // and no accessible extension method 'PushedAuthorizationBehavior' accepting a first argument
        // of type 'OpenIdConnectOptions' could be found
        oidcOptions.PushedAuthorizationBehavior = PushedAuthorizationBehavior.Disable;
    });

若要确保只有在使用 PAR 时身份验证才能成功,请改用 PushedAuthorizationBehavior.Require。 此更改还将新的 OnPushAuthorization 事件引入到 OpenIdConnectEvents,可用于自定义推送的授权请求或手动处理该请求。 有关更多详细信息,请参阅 API 建议

OIDC 和 OAuth 参数自定义

OAuth 和 OIDC 身份验证处理程序现在具有 AdditionalAuthorizationParameters 选项,以便更轻松地自定义通常作为重定向查询字符串的一部分的授权消息参数。 在 .NET 8 及更早版本中,这需要在自定义处理程序中使用自定义 OnRedirectToIdentityProvider 回调或重写 BuildChallengeUrl 方法。 下面是 .NET 8 代码的示例:

builder.Services.AddAuthentication().AddOpenIdConnect(options =>
{
    options.Events.OnRedirectToIdentityProvider = context =>
    {
        context.ProtocolMessage.SetParameter("prompt", "login");
        context.ProtocolMessage.SetParameter("audience", "https://api.example.com");
        return Task.CompletedTask;
    };
});

前面的示例现在可以简化为以下代码:

builder.Services.AddAuthentication().AddOpenIdConnect(options =>
{
    options.AdditionalAuthorizationParameters.Add("prompt", "login");
    options.AdditionalAuthorizationParameters.Add("audience", "https://api.example.com");
});

配置 HTTP.sys 扩展身份验证标志

现在,可以使用 HTTP.sys AuthenticationManager 上的新 EnableKerberosCredentialCachingCaptureCredentials 属性来配置 HTTP_AUTH_EX_FLAG_ENABLE_KERBEROS_CREDENTIAL_CACHINGHTTP_AUTH_EX_FLAG_CAPTURE_CREDENTIAL HTTP.sys 标志,以优化 Windows 身份验证的处理方式。 例如:

webBuilder.UseHttpSys(options =>
{
    options.Authentication.Schemes = AuthenticationSchemes.Negotiate;
    options.Authentication.EnableKerberosCredentialCaching = true;
    options.Authentication.CaptureCredentials = true;
});

杂项

以下部分介绍了其他新功能。

HybridCache

重要

HybridCache目前仍处于预览状态,但在 .NET 扩展的未来次要版本中,将在 .NET 9.0 之后完全发布

HybridCache API 弥补了现有 IDistributedCacheIMemoryCache API 中的一些差距。 它还添加了新功能,例如:

  • “踩踏”保护可防止系统对同一作业执行多个并行获取操作。
  • 可配置的序列化。

HybridCache 旨在作为现有 IDistributedCacheIMemoryCache 的即插即用替代品,并且提供了一个简单的 API 以便添加新的缓存代码。 它为进程内和进程外缓存提供统一的 API。

要了解 HybridCache API 的简化方式,请将其与使用 IDistributedCache 的代码进行比较。 下面是使用 IDistributedCache 方法的示例:

public class SomeService(IDistributedCache cache)
{
    public async Task<SomeInformation> GetSomeInformationAsync
        (string name, int id, CancellationToken token = default)
    {
        var key = $"someinfo:{name}:{id}"; // Unique key for this combination.
        var bytes = await cache.GetAsync(key, token); // Try to get from cache.
        SomeInformation info;
        if (bytes is null)
        {
            // Cache miss; get the data from the real source.
            info = await SomeExpensiveOperationAsync(name, id, token);

            // Serialize and cache it.
            bytes = SomeSerializer.Serialize(info);
            await cache.SetAsync(key, bytes, token);
        }
        else
        {
            // Cache hit; deserialize it.
            info = SomeSerializer.Deserialize<SomeInformation>(bytes);
        }
        return info;
    }

    // This is the work we're trying to cache.
    private async Task<SomeInformation> SomeExpensiveOperationAsync(string name, int id,
        CancellationToken token = default)
    { /* ... */ }
}

每次都要处理很多工作,包括序列化之类的操作。 在缓存缺失的情况下,最终可能会有多个并发线程,对于这些线程而言,其均会经历如下过程:出现缓存缺失,获取基础数据,对数据执行序列化,然后将数据发送至缓存。

为了使用 HybridCache 简化和改进此代码,我们首先需要添加新库 Microsoft.Extensions.Caching.Hybrid

<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="9.0.0" />

注册 HybridCache 服务,操作与注册 IDistributedCache 实现一样:

builder.Services.AddHybridCache(); // Not shown: optional configuration API.

现在大多数缓存问题都可以转移到 HybridCache

public class SomeService(HybridCache cache)
{
    public async Task<SomeInformation> GetSomeInformationAsync
        (string name, int id, CancellationToken token = default)
    {
        return await cache.GetOrCreateAsync(
            $"someinfo:{name}:{id}", // Unique key for this combination.
            async cancel => await SomeExpensiveOperationAsync(name, id, cancel),
            token: token
        );
    }
}

我们通过依赖注入提供了 HybridCache 抽象类的具体实现,但其目的是让开发人员可以提供 API 的自定义实现。 HybridCache 实现处理与缓存相关的所有内容,包括并发操作处理。 这里的 cancel 标记代表所有并发调用方的联合取消——而不仅仅是我们所见的指定调用方的取消(即 token)。

可以使用 TState 模式进一步优化高吞吐量方案,以避免捕获的变量和每个实例回调产生一些开销:

public class SomeService(HybridCache cache)
{
    public async Task<SomeInformation> GetSomeInformationAsync(string name, int id, CancellationToken token = default)
    {
        return await cache.GetOrCreateAsync(
            $"someinfo:{name}:{id}", // unique key for this combination
            (name, id), // all of the state we need for the final call, if needed
            static async (state, token) =>
                await SomeExpensiveOperationAsync(state.name, state.id, token),
            token: token
        );
    }
}

HybridCache 使用已配置的 IDistributedCache 实现(如有)进行辅助进程外缓存,例如使用 Redis。 但即使没有 IDistributedCacheHybridCache 服务仍将提供进程内缓存和“踩踏”保护。

对象重用说明

在使用 IDistributedCache 的典型现有代码中,每次从缓存中检索对象都会导致反序列化。 此行为意味着每个并发调用方都获取一个单独的对象实例,该实例无法与其他实例交互。 这实现了线程安全性,因为并发修改同一对象实例没有风险。

将根据现有 IDistributedCache 代码调整对 HybridCache 的大量使用,因此 HybridCache 在默认情况下会保留此行为,以避免引入并发 bug。 但是,给定用例本质上在以下前提下是线程安全的:

  • 要缓存的类型不可变。
  • 代码未修改它们。

在这种情况下,请通过以下方式通知 HybridCache 可以安全地重用实例:

  • 将类型标记为 sealed。 C# 中的 sealed 关键字表示类无法被继承。
  • 对其应用 [ImmutableObject(true)] 属性。 [ImmutableObject(true)] 属性指示创建对象后无法更改该对象的状态。

通过重用实例,HybridCache 可以减少与每次调用反序列化相关的 CPU 和对象分配开销。 在缓存对象较大或被经常访问的情况下,这会提高性能。

其他 HybridCache 功能

IDistributedCache 类似,HybridCache 支持通过 RemoveKeyAsync 方法使用特定键完成删除。

HybridCache 还为 IDistributedCache 实现提供可选 API,以避免 byte[] 分配。 此功能由 Microsoft.Extensions.Caching.StackExchangeRedisMicrosoft.Extensions.Caching.SqlServer 包的预览版本实现。

会在注册服务过程中配置序列化,支持通过 WithSerializer.WithSerializerFactory 方法进行特定于类型的通用序列化程序(通过 AddHybridCache 调用链接)。 默认情况下,库在内部处理 stringbyte[],并对其他所有内容使用 System.Text.Json,但是你可以使用 protobuf、xml 或其他任何内容。

HybridCache 支持较旧的 .NET 运行时,最低支持 .NET Framework 4.7.2 和 .NET Standard 2.0。

有关 HybridCache 的详细信息,请参阅 ASP.NET Core 中的 HybridCache 库

开发人员异常页改进

当应用程序在开发过程中引发未处理的异常时,系统将显示 ASP.NET Core 开发人员异常页面。 开发人员异常页面提供有关异常和请求的详细信息。

预览版 3 将终结点元数据添加到开发人员异常页面。 ASP.NET Core 使用终结点元数据来控制端点行为,例如路由、响应缓存、速率限制、OpenAPI 生成等。 下图显示了开发人员异常页面的 Routing 部分中的新元数据信息:

开发者异常页上的新元数据信息

在测试开发人员异常页时,发现了少量的日常实用体验提升。 这些提升均由预览版 4 实现:

  • 更合理的文本换行。 长 Cookie、查询字符串值和方法名称不再添加水平浏览器滚动条。
  • 采用现代设计,并应用了更大的文本。
  • 表格大小更加一致。

以下动画图像显示了新的开发人员异常页面:

新的开发人员异常页面

字典调试改进

字典和其他键值集合的调试显示具有改进的布局。 键显示在调试程序的键列中,而不是与值连接在一起。 下图显示了调试程序中字典的旧显示和新显示。

之前:

以前的调试器体验

之后:

新的调试器体验

ASP.NET Core 有许多键值集合。 这种改进的调试体验适用于:

  • HTTP 头
  • 查询字符串
  • 窗体
  • Cookie
  • 查看数据
  • 路由数据
  • 功能

修复 IIS 中应用程序回收期间的 503 错误

默认情况下,IIS 收到回收或关闭通知与 ANCM 通知托管服务器启动关闭之间现在有 1 秒的延迟。 延迟可通过 ANCM_shutdownDelay 环境变量或通过设置 shutdownDelay 处理程序设置进行配置。 这两个值均以毫秒为单位。 延迟主要是为了降低争用的可能性,其中:

  • IIS 尚未开始对发送至新应用的请求进行排队。
  • ANCM 开始拒绝进入旧应用的新请求。

速度较慢的计算机或 CPU 使用率较高的计算机可能需要调整此值,以减少出现 503 错误的可能性。

设置 shutdownDelay 的示例:

<aspNetCore processPath="dotnet" arguments="myapp.dll" stdoutLogEnabled="false" stdoutLogFile=".logsstdout">
  <handlerSettings>
    <!-- Milliseconds to delay shutdown by.
    this doesn't mean incoming requests will be delayed by this amount,
    but the old app instance will start shutting down after this timeout occurs -->
    <handlerSetting name="shutdownDelay" value="5000" />
  </handlerSettings>
</aspNetCore>

该修补程序位于以全局方式安装的 ANCM 模块中,此模块包含在托管捆绑包中。

ASP0026:当 [Authorize] 被来自“更远位置”的 [AllowAnonymous] 替代时,分析器将发出警告

似乎可以直观地认为,与 [AllowAnonymous] 特性相比,距离 MVC 操作“更近”的 [Authorize] 特性会替代 [AllowAnonymous] 特性并强制授权。 但情况并非必然如此。 重要的是特性的相对顺序。

以下代码演示了距离更近的 [Authorize] 特性被距离更远的 [AllowAnonymous] 特性替代的示例。

[AllowAnonymous]
public class MyController
{
    [Authorize] // Overridden by the [AllowAnonymous] attribute on the class
    public IActionResult Private() => null;
}
[AllowAnonymous]
public class MyControllerAnon : ControllerBase
{
}

[Authorize] // Overridden by the [AllowAnonymous] attribute on MyControllerAnon
public class MyControllerInherited : MyControllerAnon
{
}

public class MyControllerInherited2 : MyControllerAnon
{
    [Authorize] // Overridden by the [AllowAnonymous] attribute on MyControllerAnon
    public IActionResult Private() => null;
}
[AllowAnonymous]
[Authorize] // Overridden by the preceding [AllowAnonymous]
public class MyControllerMultiple : ControllerBase
{
}

在 .NET 9 预览版 6 中,我们引入了一个分析器,它会突出显示与上文所述类似的实例,其中距离 MVC 操作更远的 [AllowAnonymous] 特性替代了处于更近位置的 [Authorize] 特性。 警告指向被替代的 [Authorize] 特性,并显示以下消息:

ASP0026 [Authorize] overridden by [AllowAnonymous] from farther away

如果看到此警告,要采取的正确操作取决于特性背后的意图。 如果位置更远的 [AllowAnonymous] 特性无意中向匿名用户公开了终结点,则应移除该特性。 如果 [AllowAnonymous] 特性旨在替代位置更近的 [Authorize] 特性,则可以在 [Authorize] 特性后重复 [AllowAnonymous] 特性来阐明意图。

[AllowAnonymous]
public class MyController
{
    // This produces no warning because the second, "closer" [AllowAnonymous]
    // clarifies that [Authorize] is intentionally overridden.
    // Specifying AuthenticationSchemes can still be useful
    // for endpoints that allow but don't require authenticated users.
    [Authorize(AuthenticationSchemes = "Cookies")]
    [AllowAnonymous]
    public IActionResult Privacy() => null;
}

改进的 Kestrel 连接指标

通过添加有关连接失败原因的元数据,我们对 Kestrel 的连接指标作出了重大改进。 kestrel.connection.duration 指标现在包含 error.type 属性中的连接关闭原因。

以下是 error.type 值的一个小示例:

  • tls_handshake_failed - 连接需要 TLS,TLS 握手失败。
  • connection_reset - 请求正在进行时客户端意外关闭了连接。
  • request_headers_timeout - Kestrel 关闭了连接,因为它没有及时收到请求标头。
  • max_request_body_size_exceeded - Kestrel 关闭了连接,因为上传的数据超出了最大大小。

以前,诊断 Kestrel 连接问题需要服务器记录详细的低级日志。 但是,生成和存储日志可能很昂贵,并且很难在干扰中找到正确的信息。

指标是一种更便宜的替代方法,可以在生产环境中以最少的影响继续使用。 收集的指标可以驱动仪表板和警报。 通过指标在高级别确定问题后,就可以使用日志和其他工具进行进一步调查。

我们期望改进的连接指标能在许多场景中发挥作用:

  • 调查由较短的连接生存期导致的性能问题。
  • 观察对 Kestrel 造成性能和稳定性影响的持续外部攻击。
  • 记录针对 Kestrel 的外部攻击尝试,这些攻击已被 Kestrel 的内置安全强化阻止。

有关详细信息,请参阅 ASP.NET Core 指标

自定义 Kestrel 命名管道终结点

Kestrel 的命名管道支持已通过高级自定义选项得到改进。 命名管道选项上的新 CreateNamedPipeServerStream 方法允许按终结点自定义管道。

例如,对于需要两个具有不同访问安全性的管道终结点的 Kestrel 应用来说,这一点很有用。 CreateNamedPipeServerStream 选项可用于使用自定义安全设置创建管道,具体取决于管道名称。

var builder = WebApplication.CreateBuilder();

builder.WebHost.ConfigureKestrel(options =>
{
    options.ListenNamedPipe("pipe1");
    options.ListenNamedPipe("pipe2");
});

builder.WebHost.UseNamedPipes(options =>
{
    options.CreateNamedPipeServerStream = (context) =>
    {
        var pipeSecurity = CreatePipeSecurity(context.NamedPipeEndpoint.PipeName);

        return NamedPipeServerStreamAcl.Create(context.NamedPipeEndPoint.PipeName, PipeDirection.InOut,
            NamedPipeServerStream.MaxAllowedServerInstances, PipeTransmissionMode.Byte,
            context.PipeOptions, inBufferSize: 0, outBufferSize: 0, pipeSecurity);
    };
});

ExceptionHandlerMiddleware 可以根据异常类型选择状态代码

配置 ExceptionHandlerMiddleware 时的新选项使应用开发人员可以选择在请求处理期间发生异常时要返回的状态代码。 新选项会更改在 ExceptionHandlerMiddlewareProblemDetails 响应中设置的状态代码。

app.UseExceptionHandler(new ExceptionHandlerOptions
{
    StatusCodeSelector = ex => ex is TimeoutException
        ? StatusCodes.Status503ServiceUnavailable
        : StatusCodes.Status500InternalServerError,
});

在某些终结点和请求上选择退出 HTTP 指标

.NET 9 引入了选择退出特定终结点和请求的 HTTP 指标的功能。 选择不记录指标对自动系统(例如运行状况检查)经常调用的终结点很有用。 通常不需要记录这些请求的指标。

可以通过添加元数据将对终结点的 HTTP 请求排除在指标之外。 二者之一:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks();

var app = builder.Build();
app.MapHealthChecks("/healthz").DisableHttpMetrics();
app.Run();

MetricsDisabled 属性已添加到 IHttpMetricsTagsFeature 中,以用于:

  • 请求未映射到终结点的高级场景。
  • 动态禁用特定 HTTP 请求的指标集合。
// Middleware that conditionally opts-out HTTP requests.
app.Use(async (context, next) =>
{
    var metricsFeature = context.Features.Get<IHttpMetricsTagsFeature>();
    if (metricsFeature != null &&
        context.Request.Headers.ContainsKey("x-disable-metrics"))
    {
        metricsFeature.MetricsDisabled = true;
    }

    await next(context);
});

删除密钥的数据保护支持

在 .NET 9 之前,数据保护密钥在设计上可删除,以防止数据丢失。 删除密钥会导致其受保护的数据无法恢复。 鉴于这些键较小,它们积累起来通常影响极小。 但是,为了适应运行时间极长的服务,我们引入了删除密钥的选项。 通常,只应删除旧密钥。 只有当你可以接受数据丢失风险以换取存储节省时,才应删除密钥。 建议要删除数据保护密钥。

using Microsoft.AspNetCore.DataProtection.KeyManagement;

var services = new ServiceCollection();
services.AddDataProtection();

var serviceProvider = services.BuildServiceProvider();

var keyManager = serviceProvider.GetService<IKeyManager>();

if (keyManager is IDeletableKeyManager deletableKeyManager)
{
    var utcNow = DateTimeOffset.UtcNow;
    var yearAgo = utcNow.AddYears(-1);

    if (!deletableKeyManager.DeleteKeys(key => key.ExpirationDate < yearAgo))
    {
        Console.WriteLine("Failed to delete keys.");
    }
    else
    {
        Console.WriteLine("Old keys deleted successfully.");
    }
}
else
{
    Console.WriteLine("Key manager does not support deletion.");
}

中间件支持键控 DI

中间件现在在构造函数和 Invoke/InvokeAsync 方法中都支持键控 DI

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddKeyedSingleton<MySingletonClass>("test");
builder.Services.AddKeyedScoped<MyScopedClass>("test2");

var app = builder.Build();
app.UseMiddleware<MyMiddleware>();
app.Run();

internal class MyMiddleware
{
    private readonly RequestDelegate _next;

    public MyMiddleware(RequestDelegate next,
        [FromKeyedServices("test")] MySingletonClass service)
    {
        _next = next;
    }

    public Task Invoke(HttpContext context,
        [FromKeyedServices("test2")]
            MyScopedClass scopedService) => _next(context);
}

信任 Linux 上的 ASP.NET Core HTTPS 开发证书

在基于 Ubuntu 和 Fedora 的 Linux 发行版上,dotnet dev-certs https --trust 现在将 ASP.NET Core HTTPS 开发证书配置为受信任的证书以用于:

  • Chromium 浏览器,例如 Google Chrome、Microsoft Edge 和 Chromium。
  • Mozilla Firefox 和 Mozilla 派生浏览器。
  • .NET API,例如 HttpClient

以前,--trust 仅适用于 Windows 和 macOS。 证书信任按用户应用。

要在 OpenSSL 中建立信任,dev-certs 工具:

  • 将证书放入 ~/.aspnet/dev-certs/trust
  • 在目录上运行 OpenSSL 的 c_rehash 工具的简化版本。
  • 要求用户更新 SSL_CERT_DIR 环境变量。

为了在 dotnet 中建立信任,该工具会将证书置于 My/Root 证书存储中。

为了在 NSS 数据库中建立信任(如有),该工具会在 home 主目录中搜索 Firefox 配置文件,~/.pki/nssdb~/snap/chromium/current/.pki/nssdb。 对于找到的每个目录,该工具都会向 nssdb 添加条目。

更新为最新的 Bootstrap、jQuery 和 jQuery 验证版本的模板

ASP.NET 核心项目模板和库已更新为使用最新版本的 Bootstrap、jQuery 和 jQuery 验证,具体而言:

  • Bootstrap 5.3.3
  • jQuery 3.7.1
  • jQuery 验证 1.21.0