注释
此版本不是本文的最新版本。 有关当前版本,请参阅 本文的 .NET 10 版本。
警告
此版本的 ASP.NET Core 不再受支持。 有关详细信息,请参阅 .NET 和 .NET Core 支持策略。 有关当前版本,请参阅 本文的 .NET 10 版本。
Blazor PWA 可以从后端服务器接收和显示 推送通知 (数据消息),即使用户未主动使用应用也是如此。 例如,当其他用户在其已安装的 PWA 中执行作或应用或与后端服务器应用直接交互的用户执行作时,可以发送推送通知。
使用推送通知来:
- 通知用户已发生重要事件,提示他们返回应用。
- 更新存储在应用中的数据(例如新闻源),以便用户在其下一次返回应用时有新的数据,即使他们发出推送通知时处于脱机状态。
发送、接收和显示推送通知的机制不受限制 Blazor WebAssembly。 发送推送通知由后端服务器实现,可以使用任何技术。 在客户端上接收和显示推送通知是在服务辅助角色 JavaScript (JS) 文件中实现的。
本文中的示例使用推送通知,根据 Blazing Pizza Workshop PWA 演示应用向披萨餐厅的客户提供订单状态更新。 无需参加在线研讨会即可使用本文,但该研讨会是有关 PWA 开发的有用介绍 Blazor 。
注释
Blazing Pizza 应用采用 存储库模式 在 UI 层和数据访问层之间创建抽象层。 有关详细信息,请参阅 工作单元(UoW)模式 和 设计基础结构持久性层。
建立公钥和私钥
生成加密公钥和私钥,以在本地保护推送通知,例如使用 PowerShell 或 IIS,或使用联机工具。
本文的示例代码中使用的占位符:
-
{PUBLIC KEY}:公钥。 -
{PRIVATE KEY}:私钥。
对于本文的 C# 示例,请更新 someone@example.com 电子邮件地址以匹配创建自定义密钥对时使用的地址。
实现推送通知时,请确保安全地管理加密密钥:
- 密钥生成:使用受信任的库或工具生成公钥和私钥。 避免使用弱算法或过时算法。
- 密钥存储:使用安全存储机制(如硬件安全模块(HSM)或加密存储安全地将私钥存储在服务器上。 从不向客户端公开私钥。
- 密钥用法:仅对推送通知有效负载进行签名使用私钥。 确保公钥安全地分发给客户端。
有关加密最佳做法的详细信息,请参阅 加密服务。
创建订阅
在向用户发送推送通知之前,应用必须要求用户获得权限。 如果他们授予接收通知的权限,他们的浏览器将生成一个订阅,其中包含一组应用可用于将通知路由给用户的令牌。
应用可以随时获得权限,但我们仅建议在明确告诉用户为什么他们需要从应用订阅通知时请求用户的权限。 下面的示例询问用户何时到达结帐页(Checkout 组件),因为此时很明显,用户对下单很认真。
如果用户同意接收通知,以下示例会将推送通知订阅数据发送到服务器,其中推送通知令牌存储在数据库中供以后使用。
添加推送通知 JS 文件以请求订阅:
- 调用
navigator.serviceWorker.getRegistration以获取 Service Worker 的注册信息。 - 调用
worker.pushManager.getSubscription以确定订阅是否存在。 - 如果订阅不存在,请使用
PushManager.subscribe函数创建新订阅,并返回新订阅的 URL 和令牌。
在 Blazing Pizza 应用中,文件 JS 被命名为 pushNotifications.js 并位于解决方案的 wwwroot 类库项目的公共静态资产文件夹 (Razor) 中 (BlazingPizza.ComponentsLibrary)。 该 blazorPushNotifications.requestSubscription 函数请求订阅。
BlazingPizza.ComponentsLibrary/wwwroot/pushNotifications.js:
(function () {
const applicationServerPublicKey = '{PUBLIC KEY}';
window.blazorPushNotifications = {
requestSubscription: async () => {
const worker = await navigator.serviceWorker.getRegistration();
const existingSubscription = await worker.pushManager.getSubscription();
if (!existingSubscription) {
const newSubscription = await subscribe(worker);
if (newSubscription) {
return {
url: newSubscription.endpoint,
p256dh: arrayBufferToBase64(newSubscription.getKey('p256dh')),
auth: arrayBufferToBase64(newSubscription.getKey('auth'))
};
}
}
}
};
async function subscribe(worker) {
try {
return await worker.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerPublicKey
});
} catch (error) {
if (error.name === 'NotAllowedError') {
return null;
}
throw error;
}
}
function arrayBufferToBase64(buffer) {
var binary = '';
var bytes = new Uint8Array(buffer);
var len = bytes.byteLength;
for (var i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
})();
注释
有关上述 arrayBufferToBase64 函数的详细信息,请参阅 如何将 ArrayBuffer 转换为 base64 编码的字符串?(Stack Overflow).
在服务器上创建订阅对象和通知订阅终结点。 终结点接收包含加密令牌的推送通知订阅数据的客户端 Web API 调用。 数据存储在每个应用用户的数据库中。
在 Blazing Pizza 应用中,订阅对象是 NotificationSubscription 类。 属性P256dhAuth是用户的加密令牌。
BlazingPizza.Shared/NotificationSubscription.cs:
public class NotificationSubscription
{
public int? NotificationSubscriptionId { get; set; }
public string? UserId { get; set; }
public string? Url { get; set; }
public string? P256dh { get; set; }
public string? Auth { get; set; }
}
终结点 notifications/subscribe 在应用的 MapPizzaApi 扩展方法中定义,该方法在应用的 Program 文件中调用,为应用设置 Web API 终结点。 用户的通知订阅(NotificationSubscription包括其推送通知令牌)存储在数据库中。 每个用户只存储一个订阅。 或者,你可以允许用户从不同的浏览器或设备注册多个订阅。
app.MapPut("/notifications/subscribe",
[Authorize] async (
HttpContext context,
PizzaStoreContext db,
NotificationSubscription subscription) =>
{
var userId = GetUserId(context);
if (userId is null)
{
return Results.Unauthorized();
}
// Remove old subscriptions for this user
var oldSubscriptions = db.NotificationSubscriptions.Where(
e => e.UserId == userId);
db.NotificationSubscriptions.RemoveRange(oldSubscriptions);
// Store the new subscription
subscription.UserId = userId;
db.NotificationSubscriptions.Add(subscription);
await db.SaveChangesAsync();
return Results.Ok(subscription);
});
在 BlazingPizza.Client/HttpRepository.cs 中,方法 SubscribeToNotifications 向服务器上的订阅端点发出 HTTP PUT:
public class HttpRepository : IRepository
{
private readonly HttpClient _httpClient;
public HttpRepository(HttpClient httpClient)
{
_httpClient = httpClient;
}
...
public async Task SubscribeToNotifications(NotificationSubscription subscription)
{
var response = await _httpClient.PutAsJsonAsync("notifications/subscribe",
subscription);
response.EnsureSuccessStatusCode();
}
}
存储库接口(BlazingPizza.Shared/IRepository.cs)包括方法签名SubscribeToNotifications:
public interface IRepository
{
...
Task SubscribeToNotifications(NotificationSubscription subscription);
}
定义在建立订阅时请求订阅和订阅通知的方法。 将订阅保存到数据库中供以后使用。
在 Blazing Pizza 应用的 Checkout 组件中,RequestNotificationSubscriptionAsync 方法执行以下职责:
- 订阅是通过调用 JS 并通过
blazorPushNotifications.requestSubscription互操作创建的。 该组件注入 IJSRuntime 服务以调用 JS 函数。 -
SubscribeToNotifications调用该方法以保存订阅。
在 BlazingPizza.Client/Components/Pages/Checkout.razor中:
async Task RequestNotificationSubscriptionAsync()
{
var subscription = await JSRuntime.InvokeAsync<NotificationSubscription>(
"blazorPushNotifications.requestSubscription");
if (subscription is not null)
{
try
{
await Repository.SubscribeToNotifications(subscription);
}
catch (AccessTokenNotAvailableException ex)
{
ex.Redirect();
}
}
}
在Checkout组件中,RequestNotificationSubscriptionAsync在OnInitialized生命周期方法中调用,并在组件初始化时执行。 该方法是异步的,但它可以在后台运行,返回的Task可以被丢弃。 因此,该方法不会在组件初始化的异步生命周期方法中调用(OnInitializedAsync)。 此方法可更快地呈现组件。
protected override void OnInitialized()
{
_ = RequestNotificationSubscriptionAsync();
}
若要演示代码的工作原理,请运行 Blazing Pizza 应用并开始下订单。 转到签出屏幕以查看订阅请求:
选择允许,然后在浏览器开发人员工具控制台中查看错误。 可以在PizzaApiExtensions的MapPut("/notifications/subscribe"...)代码中设置断点,并在调试模式下运行应用来查看来自浏览器的传入数据。 数据包括终结点 URL 和加密令牌。
在用户允许或阻止给定网站的通知后,浏览器不会再次询问。 若要重置对 Google Chrome 或 Microsoft Edge 进行进一步测试的权限,请选择浏览器地址栏左侧的“信息”图标(🛈),并将 通知 更改回 Ask(默认值),如下图所示:
发送通知
发送通知涉及在服务器上执行一些复杂的加密作来保护传输中的数据。 大部分复杂性由第三方 NuGet 包处理, WebPush该包由 Blazing Pizza 应用中的服务器项目(BlazingPizza.Server)使用。
该方法 SendNotificationAsync 使用捕获的订阅来调度订单通知。 以下代码使用 WebPush API 调度通知。 通知的有效负载已序列化为 JSON,并包含消息和 URL。 该消息向用户显示,URL 允许用户访问与通知关联的披萨订单。 其他通知方案可以根据需要序列化其他参数。
谨慎
在以下示例中,我们建议使用安全方法来提供私钥。 在本地环境中 Development 工作时,可以使用 机密管理器 工具向应用提供私钥。 在
private static async Task SendNotificationAsync(Order order,
NotificationSubscription subscription, string message)
{
var publicKey = "{PUBLIC KEY}";
var privateKey = "{PRIVATE KEY}";
var pushSubscription = new PushSubscription(subscription.Url,
subscription.P256dh, subscription.Auth);
var vapidDetails = new VapidDetails("mailto:<someone@example.com>", publicKey,
privateKey);
var webPushClient = new WebPushClient();
try
{
var payload = JsonSerializer.Serialize(new
{
message,
url = $"myorders/{order.OrderId}",
});
await webPushClient.SendNotificationAsync(pushSubscription, payload,
vapidDetails);
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error sending push notification: {ex.Message}");
}
}
前面的示例使服务器能够发送通知,但浏览器不会在没有其他逻辑的情况下对通知做出反应。 “ 显示通知 ”部分介绍了显示通知。
浏览器的开发人员工具控制台显示,在 Blazing Pizza 应用中下单后十秒钟会收到通知。 在 “应用程序 ”选项卡上,打开 “推送消息” 部分。 选择要 开始录制的圆圈:
显示通知
PWA 的服务工作者(service-worker.js)必须处理推送通知,以便应用程序能够显示它们。
Blazing Pizza 应用中的以下push事件处理程序调用showNotification,为活动服务工作器创建通知。
在 BlazingPizza/wwwroot/service-worker.js中:
self.addEventListener('push', event => {
const payload = event.data.json();
event.waitUntil(
self.registration.showNotification('Blazing Pizza', {
body: payload.message,
icon: 'img/icon-512.png',
vibrate: [100, 50, 100],
data: { url: payload.url }
})
);
});
在浏览器记录 Installing service worker... 时,加载下一页之后,上述代码才会生效。 当难以让服务辅助角色更新时,请使用浏览器开发人员工具控制台中的 “应用程序 ”选项卡。 在 Service Worker 下,选择 更新 或使用 注销 来在下次加载时强制执行新注册。
由于以上代码已到位,当用户下达新订单时,该订单将在应用程序的内置演示逻辑控制下,大约 10 秒后进入外送中状态。 浏览器收到推送通知:
在 Google Chrome 或 Microsoft Edge 中使用应用时,即使用户未主动使用 Blazing Pizza 应用,也会显示通知。 但是,浏览器必须正在运行,或者在下次打开浏览器时显示通知。
使用已安装的 PWA 时,即使用户未运行应用,也应传递通知。
处理通知单击
notificationclick注册事件处理程序以处理用户在其设备上选择(单击)推送通知:
- 通过调用
event.notification.close关闭通知。 - 调用
clients.openWindow以创建新的顶级浏览上下文并加载传递给方法的 URL。
Blazing Pizza 应用中的以下示例将用户转到与通知相关的订单的订单状态页。 URL 由 event.notification.data.url 参数提供,该参数由通知的有效负载中的服务器发送。
在Service Worker文件中(service-worker.js):
self.addEventListener('notificationclick', event => {
event.notification.close();
event.waitUntil(clients.openWindow(event.notification.data.url));
});
如果在设备上安装了 PWA,则会在设备上显示 PWA。 如果未安装 PWA,则用户将转到其浏览器中的应用页面。