托管和部署服务器端 Blazor 应用
注意
此版本不是本文的最新版本。 对于当前版本,请参阅此文的 .NET 8 版本。
警告
此版本的 ASP.NET Core 不再受支持。 有关详细信息,请参阅 .NET 和 .NET Core 支持策略。 对于当前版本,请参阅此文的 .NET 8 版本。
本文介绍如何使用 ASP.NET Core 托管和部署服务器端 Blazor 应用(Blazor Web App 和 Blazor Server 应用)。
主机配置值
服务器端 Blazor 应用可以接受通用主机配置值。
部署
使用服务器端托管模型时,Blazor 从 ASP.NET Core 应用内在服务器上执行。 UI 更新、事件处理和 JavaScript 调用是通过 SignalR 连接进行处理。
能够托管 ASP.NET Core 应用的 Web 服务器是必需的。 Visual Studio 包含服务器端应用项目模板。 有关 Blazor 项目模板的详细信息,请参阅 ASP.NET Core Blazor 项目结构。
在发布配置中发布应用并部署 bin/Release/{TARGET FRAMEWORK}/publish
文件夹的内容,其中 {TARGET FRAMEWORK}
占位符是目标框架。
伸缩性
考虑单一服务器的可伸缩性(纵向扩展)时,需注意:可供应用使用的内存可能是用户需求增加时应用耗尽的第一个资源。 服务器上的可用内存影响以下因素:
- 服务器可以支持的主动电路数。
- 客户端上的 UI 延迟。
有关构建安全且可缩放服务器端 Blazor 应用的指导,请参阅以下资源:
每个电路使用约 250 KB 的内存来实现至少为 Hello World 样式的应用。 电路大小取决于应用代码和与每个组件相关的状态维护要求。 我们建议你在开发应用和基础设施的过程中衡量资源需求,但在计划部署目标时可以将以下基准作为起点:如果希望应用支持 5,000 个并发用户,请考虑为应用预算至少 1.3 GB 服务器内存(或每用户 ~273 KB)。
SignalR 配置
SignalR 的托管和缩放条件适用于使用 SignalR 的 Blazor 应用。
有关 Blazor 应用中 SignalR 的详细信息(包括配置指南)请参阅 ASP.NET Core BlazorSignalR 指南。
传输
由于低延迟、更好的可靠性和改进的安全性,使用 WebSockets 作为 SignalR 传输时,Blazor 的效果最佳。 当 WebSocket 不可用时,或在将应用显式配置为使用长轮询时,SignalR 将使用长轮询。
如果使用长轮询,则会出现控制台警告:
无法使用长轮询回退传输通过 Websocket 连接。 这可能是由于 VPN 或代理阻止连接而导致的。
全球部署和连接失败
针对部署到地理数据中心的全球部署的建议:
- 将应用部署到大多数用户所在的区域。
- 考虑流量跨洲引起的延迟增加情况。 若要控制重新连接 UI 的外观,请参阅 ASP.NET Core BlazorSignalR 指南。
- 请考虑使用 Azure SignalR 服务。
Azure 应用程序服务
在 Azure 应用服务上托管需要配置 WebSocket 和会话亲和性(也称为应用程序请求路由 (ARR) 相关性)。
注意
Azure 应用服务上的 Blazor 应用不需要 Azure SignalR 服务。
在 Azure 应用服务中为应用的注册启用以下内容:
- WebSocket 用于允许 WebSocket 传输正常工作。 默认设置为“关”。
- 会话亲和性,用于将用户的请求路由回同一个应用服务实例。 默认设置为“开”。
- 在 Azure 门户中,导航到“应用服务”中的 Web 应用。
- 打开“设置”>“配置”。
- 将“Web 套接字”设置为“开”。
- 验证“会话亲和性”是否设置为“开启”。
Azure SignalR 服务
可选的 Azure SignalR 服务可与应用的 SignalR 中心配合使用,以将服务器端应用纵向扩展到大量并发连接。 此外, 服务的全球覆盖和高性能数据中心可帮助显著减少由于地理位置造成的延迟。
Azure 应用服务或 Azure 容器应用中托管的 Blazor 应用不需要该服务,但在其他托管环境中可能会有所帮助:
- 为了便于连接横向扩展。
- 处理全局分发。
注意
有状态重新连接 (WithStatefulReconnect) 是随 .NET 8 一起发布的,但目前不受 Azure SignalR 服务的支持。 有关详细信息,请参阅有状态重新连接支持?(Azure/azure-signalr
#1878)。
如果应用使用长轮询或退回到长轮询而不是 WebSocket,可能需要配置最大轮询间隔(MaxPollIntervalInSeconds
,默认值:5 秒,限制:1-300 秒),它定义了 Azure SignalR 服务中长轮询连接允许的最大轮询间隔。 如果下一个轮询请求未在最大轮询间隔内到达,服务将关闭客户端连接。
有关如何将服务作为依赖项添加到生产部署的指南,请参阅将 ASP.NET Core SignalR 应用发布到 Azure 应用服务。
有关详细信息,请参阅:
- Azure SignalR 服务
- 什么是 Azure SignalR 服务?
- ASP.NET Core SignalR 生产托管和缩放
- 将 ASP.NET Core SignalR 应用发布到 Azure 应用服务
Azure Container Apps
若要深入了解在 Azure 容器应用服务上扩展服务器端 Blazor 应用,请参阅在 Azure 上扩展 ASP.NET Core 应用。 本教程介绍如何创建和集成在 Azure 容器应用上托管应用所需的服务。 本部分还提供了基本步骤。
按照 Azure 容器应用中的会话亲和性(Azure 文档)中的指导,配置 Azure 容器应用服务的会话亲和性。
必须将 ASP.NET Core 数据保护 (DP) 服务配置为将密钥保存在可供所有容器实例访问的中心化位置。 密钥可以存储在 Azure Blob 存储中,使用 Azure Key Vault 进行保护。 数据保护服务使用密钥来反序列化 Razor 组件。 若要将数据保护服务配置为使用 Azure Blob 存储和 Azure Key Vault,请参考以下 NuGet 包:
Azure.Identity
:提供使用 Azure identity 和访问管理服务所需的类。Microsoft.Extensions.Azure
:提供有用的扩展方法来执行核心 Azure 配置。Azure.Extensions.AspNetCore.DataProtection.Blobs
:允许将 ASP.NET Core 数据保护密钥存储在 Azure Blob 存储中,确保可在 Web 应用的多个实例之间共享密钥。Azure.Extensions.AspNetCore.DataProtection.Keys
:启用使用 Azure 密钥保管库密钥加密/包装功能保护 rest 密钥。
注意
有关将包添加到 .NET 应用的指南,请参阅包使用工作流(NuGet 文档)中“安装和管理包”下的文章。 在 NuGet.org 中确认正确的包版本。
使用以下突出显示的代码更新
Program.cs
:using Azure.Identity; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Azure; var builder = WebApplication.CreateBuilder(args); var BlobStorageUri = builder.Configuration["AzureURIs:BlobStorage"]; var KeyVaultURI = builder.Configuration["AzureURIs:KeyVault"]; builder.Services.AddRazorPages(); builder.Services.AddHttpClient(); builder.Services.AddServerSideBlazor(); builder.Services.AddAzureClientsCore(); builder.Services.AddDataProtection() .PersistKeysToAzureBlobStorage(new Uri(BlobStorageUri), new DefaultAzureCredential()) .ProtectKeysWithAzureKeyVault(new Uri(KeyVaultURI), new DefaultAzureCredential()); var app = builder.Build(); if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error"); app.UseHsts(); } app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); app.UseAuthorization(); app.MapRazorPages(); app.Run();
上述更改允许应用使用集中式、可缩放的体系结构来管理数据保护服务。 DefaultAzureCredential 可在代码部署到 Azure 后发现容器应用托管 identity,并使用它连接到 Blob 存储和应用的密钥保管库。
若要创建容器应用托管 identity 并向其授予对 Blob 存储和密钥保管库的访问权限,请完成以下步骤:
- 在 Azure 门户中,导航到容器应用的概述页。
- 从左侧导航栏选择“服务连接器”。
- 从顶部导航栏选择“+ 创建”。
- 在“创建连接”浮出控件菜单中,输入以下值:
- 容器:选择创建的容器应用以托管你的应用。
- 服务类型:选择“Blob 存储”。
- 订阅:选择拥有容器应用的订阅。
- 连接名称:输入名称
scalablerazorstorage
。 - 客户端类型:选择“.NET”,然后选择“下一步”。
- 选择“系统分配的托管 identity”,然后选择“下一步”。
- 使用默认网络设置并选择“下一步”。
- Azure 验证设置后,选择“创建”。
针对密钥保管库重复使用上述设置。 在“基本信息”选项卡中选择相应的密钥保管库服务和密钥。
IIS
使用 IIS 时,请启用:
有关详细信息,请参阅将 ASP.NET Core 应用程序发布到 IIS 中的指南和外部 IIS 资源交叉链接。
Kubernetes
使用以下会话亲和性的 Kubernetes 注释创建入口定义:
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: <ingress-name>
annotations:
nginx.ingress.kubernetes.io/affinity: "cookie"
nginx.ingress.kubernetes.io/session-cookie-name: "affinity"
nginx.ingress.kubernetes.io/session-cookie-expires: "14400"
nginx.ingress.kubernetes.io/session-cookie-max-age: "14400"
Linux 与 Nginx
按照 ASP.NET Core SignalR 应用的指南操作,并做出以下更改:
- 将
location
路径从/hubroute
(location /hubroute { ... }
) 更改为根路径/
(location / { ... }
)。 - 删除代理缓冲配置 (
proxy_buffering off;
) 因为设置仅适用于服务器发送事件 (SSE),这与 Blazor 应用客户端-服务器交互不相关。
有关详细信息和配置指南,请参阅以下资源:
- ASP.NET Core SignalR 生产托管和缩放
- 使用 Nginx 在 Linux 上托管 ASP.NET Core
- 配置 ASP.NET Core 以使用代理服务器和负载均衡器
- NGINX 作为 WebSocket 代理
- WebSocket 代理
- 咨询非 Microsoft 支持论坛上的开发人员:
Linux 与 Apache
若要在 Linux 上托管 Apache 后面的 Blazor 应用,请为 HTTP 和 WebSocket 流量配置 ProxyPass
。
如下示例中:
- Kestrel 服务器正在主机上运行。
- 应用侦听端口 5000 上的流量。
ProxyPreserveHost On
ProxyPassMatch ^/_blazor/(.*) http://localhost:5000/_blazor/$1
ProxyPass /_blazor ws://localhost:5000/_blazor
ProxyPass / http://localhost:5000/
ProxyPassReverse / http://localhost:5000/
启用以下模块:
a2enmod proxy
a2enmod proxy_wstunnel
检查浏览器控制台中的 WebSocket 错误。 示例错误:
- Firefox 无法与服务器建立连接,位置为 ws://the-domain-name.tld/_blazor?id=XXX
- 错误:未能启动传输“WebSocket”:错误:传输出现错误。
- 错误:未能启动传输“LongPolling”:TypeError:未定义 this.transport
- 错误:无法使用任何可用传输连接到服务器。 WebSocket 失败
- 错误:如果连接未处于“已连接”状态,则无法发送数据。
有关详细信息和配置指南,请参阅以下资源:
- 配置 ASP.NET Core 以使用代理服务器和负载均衡器
- Apache 文档
- 咨询非 Microsoft 支持论坛上的开发人员:
衡量网络延迟
可以使用 JS 互操作来衡量网络延迟,如以下示例所示。
MeasureLatency.razor
:
@inject IJSRuntime JS
<h2>Measure Latency</h2>
@if (latency is null)
{
<span>Calculating...</span>
}
else
{
<span>@(latency.Value.TotalMilliseconds)ms</span>
}
@code {
private DateTime startTime;
private TimeSpan? latency;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
startTime = DateTime.UtcNow;
var _ = await JS.InvokeAsync<string>("toString");
latency = DateTime.UtcNow - startTime;
StateHasChanged();
}
}
}
@inject IJSRuntime JS
<h2>Measure Latency</h2>
@if (latency is null)
{
<span>Calculating...</span>
}
else
{
<span>@(latency.Value.TotalMilliseconds)ms</span>
}
@code {
private DateTime startTime;
private TimeSpan? latency;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
startTime = DateTime.UtcNow;
var _ = await JS.InvokeAsync<string>("toString");
latency = DateTime.UtcNow - startTime;
StateHasChanged();
}
}
}
@inject IJSRuntime JS
<h2>Measure Latency</h2>
@if (latency is null)
{
<span>Calculating...</span>
}
else
{
<span>@(latency.Value.TotalMilliseconds)ms</span>
}
@code {
private DateTime startTime;
private TimeSpan? latency;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
startTime = DateTime.UtcNow;
var _ = await JS.InvokeAsync<string>("toString");
latency = DateTime.UtcNow - startTime;
StateHasChanged();
}
}
}
@inject IJSRuntime JS
<h2>Measure Latency</h2>
@if (latency is null)
{
<span>Calculating...</span>
}
else
{
<span>@(latency.Value.TotalMilliseconds)ms</span>
}
@code {
private DateTime startTime;
private TimeSpan? latency;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
startTime = DateTime.UtcNow;
var _ = await JS.InvokeAsync<string>("toString");
latency = DateTime.UtcNow - startTime;
StateHasChanged();
}
}
}
@inject IJSRuntime JS
@if (latency is null)
{
<span>Calculating...</span>
}
else
{
<span>@(latency.Value.TotalMilliseconds)ms</span>
}
@code {
private DateTime startTime;
private TimeSpan? latency;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
startTime = DateTime.UtcNow;
var _ = await JS.InvokeAsync<string>("toString");
latency = DateTime.UtcNow - startTime;
StateHasChanged();
}
}
}
为获得合理的 UI 体验,我们建议使用 250 毫秒或更低的持续 UI 延迟。
内存管理
在服务器上,将为每个用户会话创建一个新线路。 每个用户会话对应于在浏览器中呈现一个文档的过程。 例如,多个选项卡可以创建多个会话。
Blazor 与启动会话的浏览器保持着持续连接(称为线路)。 连接可能在任何时候因任何原因而丢失,例如当用户失去网络连接或突然关闭浏览器时。 当连接丢失时,Blazor 具有一种恢复机制,将有限数量的线路放在一个“已断开连接”的池中,使客户端在有限的时间内重新连接并重新建立会话(默认值:3 分钟)。
在此时间之后,Blazor 释放线路并放弃会话。 从此以后,该线路就有资格进行垃圾回收 (GC),并在触发了线路的 GC 生成回收时声明。 需要了解的一个重要方面是,线路的生存期很长,这意味着由线路生根的大多数对象最终都会达到第 2 代。 因此,在发生第 2 代回收之前,你可能不会看到这些对象被释放。
衡量一般内存使用情况
先决条件:
- 必须在“发布”配置中发布应用。 “调试”配置度量并不相关,因为生成的代码不代表用于生产部署的代码。
- 应用必须在未附加调试器的情况下运行,因为这也可能会影响应用的行为并破坏结果。 在 Visual Studio 中,通过从菜单栏中选择“调试”>“启动时不调试”或使用键盘按下 Ctrl+F5,启动应用而不进行调试。
- 请考虑不同类型的内存,以了解 .NET 实际使用的内存量。 通常,开发人员会在 Windows OS 上的任务管理器中检查应用内存使用情况,这通常会提供实际使用内存的上限。 有关详细信息,请参阅以下文章:
- .NET 内存性能分析:具体请参阅内存基础知识部分。
- 诊断内存性能问题的工作流(由三部分组成的系列):该系列的三篇文章的链接位于系列中每篇文章的顶部。
应用于 Blazor 的内存使用量
我们按以下公式计算 blazor 的内存使用量:
(主动线路 × 每线路内存)+(断开连接的线路 × 每线路内存)
线路使用的内存量以及应用可以维护的最大潜在活动线路数在很大程度上取决于应用的写入方式。 可能的活动线路的最大数目大致计算如下:
最大可用内存 / 每线路内存 = 最大潜在活动线路数
若要在 Blazor 中发生内存泄漏,必须满足以下条件:
- 内存必须由框架分配,而不是由应用分配。 如果在应用中分配 1 GB 的数组,应用必须管理该数组的处置。
- 不得主动使用内存,这意味着线路不处于活动状态,并且已从断开连接的线路缓存中逐出。 如果你运行了最大活动线路数,那么内存不足是规模问题,而不是内存泄漏。
- 已运行用于线路 GC 生成的垃圾回收 (GC),但垃圾回收器还无法声明线路,因为框架中的另一个对象持有对线路的强引用。
在其他情况下,不会发生内存泄漏。 如果线路处于活动状态(连接或断开连接),则线路仍在使用中。
如果线路的 GC 生成的回收不运行,则不会释放内存,因为垃圾回收器此时不需要释放内存。
如果 GC 生成的回收运行并释放线路,则必须根据 GC 统计信息(而不是进程)验证内存,因为 .NET 可能会决定使虚拟内存保持活动状态。
如果没有释放内存,必须找到一条未处于活动状态或已断开连接且由框架中的另一个对象生根的线路。 在任何其他情况下,无法释放内存是开发人员代码中的应用问题。
减少内存使用量
采用以下任一策略来减少应用的内存使用量:
- 限制 .NET 进程使用的内存总量。 有关详细信息,请参阅垃圾回收的运行时配置选项。
- 减少断开连接的线路数。
- 缩短允许线路处于断开连接状态的时间。
- 手动触发垃圾回收,以在停机期间执行回收。
- 在工作站模式下配置垃圾回收,这会主动触发垃圾回收,而不是服务器模式。
某些移动设备浏览器的堆大小
生成在客户端上运行并面向移动设备浏览器(尤其是 iOS 上的 Safari)的 Blazor 应用时,可能需要使用 MSBuild 属性 EmccMaximumHeapSize
减少应用的最大内存。 有关详细信息,请参阅托管和部署 ASP.NET Core Blazor WebAssembly。
其他操作和注意事项
- 当内存需求较高时捕获进程的内存转储,并确定占用内存最多的对象以及这些对象的根位置(哪些对象具有对它们的引用)。
- 可以使用
dotnet-counters
查看应用内存使用情况的统计数据。 有关详细信息,请参阅调查性能计数器 (dotnet-counters)。 - 即使触发了 GC,.NET 也会保留内存,而不是立即将其返回给 OS,因为它很可能会在不久的将来重用内存。 这样可以避免不断提交和取消提交内存,从而节省开销。 如果使用
dotnet-counters
,你会观察到这一点,因为你会看到 GC 发生,使用的内存量减少到 0(零),但你不会看到工作集计数器减少,这是 .NET 保留内存以重用它的迹象。 有关用于控制此行为的项目文件 (.csproj
) 设置的详细信息,请参阅垃圾回收的运行时配置选项。 - 服务器 GC 不会触发垃圾回收,除非它认为绝对有必要这样做,以避免应用冻结,并认为你的应用是计算机上唯一运行的程序,因此它可以使用系统中的所有内存。 如果系统具有 50 GB,垃圾回收器在触发第 2 代回收之前会尝试使用全部 50 GB 的可用内存。
- 有关断开连接的线路保留配置的信息,请参阅 ASP.NET Core BlazorSignalR 指南。
测量内存
- 在发布配置中发布应用。
- 运行应用的已发布版本。
- 不要将调试程序附加到正在运行的应用。
- 触发第 2 代强制压缩回收 (
GC.Collect(2, GCCollectionMode.Aggressive | GCCollectionMode.Forced, blocking: true, compacting: true))
是否会释放内存? - 请考虑你的应用是否在大型对象堆上分配对象。
- 你是在应用通过请求和处理预热后测试其内存增长情况吗? 通常情况下,代码首次执行时会填充缓存,这些缓存会不断增加应用的内存占用。