原文:Push Notifications and ASP.NET Core – Part 1 (Push API)
出于阅读考量,对原文部分文本表述有修改,但涉及技术方面不会作修改
翻译:HHaoWang
在日常的网页浏览中,你可能已经遇到过一些网站要求给你发送通知(例如CSDN,译者注),这种通知被称之为Push Notifications
(推送通知)。许多网站都会在刚刚访问的时候就立即要求我们同意发送通知,令人不堪其扰。若不滥用通知,而以一种更为负责任的方式使用的Push Notifications
则会展示出其更为有用的一面。Push Notifications
的最大优势就是服务器不必检查用户是否在线(一种简单的方法是使用websocket在服务器和客户端之间建立长连接,译者注),只需要简单地发送出一条推送消息(push message),用户就可以及时地收到了。当然,这种便捷并非是毫无代价的,本系列会向你展示出隐藏的成本。
在本文中,我会构建一个ASP.NET Core网站应用来展示Push Notifications
的使用。当然,这里的代码思路都是通用的(包括客户端代码),你可在任何其他的平台上实现它。本文主要关注于Push API以及使用它的一般流程,接下来还会有一系列的文章深入探讨如何从一个基于.Net的后端发送推送消息(push message):
- Part 2 (Requesting Delivery)
- Part 3 (Replacing Messages & Urgency)
- Part 4 (Queueing requesting delivery in background)
- Part 5 (Special Cases)
如果你想先看一下最终效果(或者你想先看一下完整代码),示例代码在这里:https://github.com/tpeczek/Demo.AspNetCore.PushNotifications。
先决条件
在web push中,一个很重要的环节是推送服务方(push service),推送服务方起到了中间人的作用来确保推送的可靠和高效。
然而,推送服务方的出现也带来了一些安全和隐私问题,这些问题之一就是鉴权(authentication)。每一个在推送服务方的订阅都有自己独一无二的URL,这个URL被称之为capability URL。这意味着若这个URL泄露了,其他的推送服务方就可以推送消息到相应的客户端去,所以要引入一种额外的机制来限制可能的其它推送者。这种额外的机制就是“自主应用服务器标识”(Voluntary Application Server Identification,VAPID),关于它的详细内容我会在第二篇文章中介绍。VAPID需要应用服务器密钥(公钥私钥对),生成该密钥对的最简单的方式是使用在线生成工具(该链接已失效,可使用该工具或自行搜索其它工具,译者注)。生成的公钥需要发送至客户端保存使用,在本文中,我会直接将它发送到客户端,但是建议在实际的项目中应该按需发送(实例应用正是这样做的),并且最好通过HTTPS发送。
Service Worker
Push API规范的客户端组件依赖于Service Worker规范。更准确地说,它们实现了带有pushManager属性的ServiceWorkerRegistration接口,而pushManager属性暴露出PushManager接口。Service workers不是本文的主要内容,因此我就简单地展示一下如何注册。
let pushServiceWorkerRegistration; function registerPushServiceWorker() { navigator.serviceWorker.register('/scripts/service-workers/push-service-worker.js', { scope: '/scripts/service-workers/push-service-worker/' }) .then(function (serviceWorkerRegistration) { pushServiceWorkerRegistration = serviceWorkerRegistration; ... console.log('Push Service Worker has been registered successfully'); }).catch(function (error) { console.log('Push Service Worker registration has failed: ' + error); }); };
register方法的第一个参数是将会被注册为service worker的脚本的路径。该方法返回一个promise,当其成功完成时就会返回ServiceWorkerRegistration,你应该要保存它以供后续的使用。
订阅
在展示如何订阅之前还是要先说一说什么时候订阅合适。最好不要在页面加载的时候就进行订阅,而应该醒目地提醒用户进行订阅,例如使用一个订阅按钮,以确保用户可以按照自己的意愿进行订阅。
为了订阅推送消息,应该调用PushManager接口的subscribe方法,推送消息接收者将是PushManager接口所属的Service Worker。subscribe方法需要传递两个参数,一个是前文提到的应用服务器公钥,另一个则是userVisibility,并且其值应该为true。userVisibility标识表示是否在每次推送消息到达的时候显示通知。如果订阅成功完成(用户授予了通知权限并且推送服务正确响应了),那么它应该被分发到应用服务器,如上图所示。
function subscribeForPushNotifications() { let applicationServerPublicKey = urlB64ToUint8Array('<Public Key in Base64 Format>'); pushServiceWorkerRegistration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: applicationServerPublicKey }).then(function (pushSubscription) { fetch('push-notifications-api/subscriptions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(pushSubscription) }).then(function (response) { if (response.ok) { console.log('Successfully subscribed for Push Notifications'); } else { console.log('Failed to store the Push Notifications subscription on server'); } }).catch(function (error) { console.log('Failed to store the Push Notifications subscription on server: ' + error); }); ... }).catch(function (error) { if (Notification.permission === 'denied') { ... } else { console.log('Failed to subscribe for Push Notifications: ' + error); } }); };
发送订阅的请求是标准的AJAX请求,因此你可以在请求中包含更多的信息一并发送给应用服务器,例如包含用户信息的cookie、payload中的额外属性等。至于subscription,它其中的两个关键属性必须被保存下来。第一个是endpoint属性,它包含了前文提到的capability URL;第二个则是keys属性,在服务端可以使用一个简单的类来表示它:
public class PushSubscription { public string Endpoint { get; set; } public IDictionary<string, string> Keys { get; set; } }
keys属性是一个字典,用于保存任何所需的推送消息加密密钥,这就是推送信息的隐私性的实现方式。这些密钥是由客户端(即浏览器)生成的,推送服务器并不知道他们。目前有两种定义的密钥可用:p256dh(P-256 ECDH Diffie-Hellman公钥)和auth(鉴权密钥)。具体的推送消息加密(例如VAPID)将会在下一篇文章中讲述。
在实现处理订阅分发请求的操作之前,需要一个服务来负责存储订阅。目前,该服务可以是一个非常简单的接口。
public interface IPushSubscriptionStore { Task StoreSubscriptionAsync(PushSubscription subscription); }
这个服务可以有许多不同的实现,演示项目中使用的是SQLite,但NoSQL数据库很适合存储此类数据。服务实现之后,接口实现就很简单了。
namespace Demo.AspNetCore.PushNotifications.Controllers { private readonly IPushSubscriptionStore _subscriptionStore; public PushNotificationsApiController(IPushSubscriptionStore subscriptionStore) { _subscriptionStore = subscriptionStore; } // POST push-notifications-api/subscriptions [HttpPost("subscriptions")] public async Task<IActionResult> StoreSubscription([FromBody]PushSubscription subscription) { await _subscriptionStore.StoreSubscriptionAsync(subscription); return NoContent(); } }
现在可以看出推送消息的开销有哪些了,第一部分是存储资源(必须存储所有活跃订阅,并在发送消息时频繁查询),第二部分是传递推送消息所需的计算资源。
消息发送
我们已经了解了请求推送消息传递所需的所有部分。每个订阅都包含创建请求所需的唯一信息,因此它们都会被迭代查询。我将使用IPushSubscriptionStore来负责查询过程,这有助于完成一个能够进行高效内存访问的实现。
public interface IPushSubscriptionStore { ... Task ForEachSubscriptionAsync(Action<PushSubscription> action); }
同时也应该有一个请求传递消息的抽象。
public class PushNotificationServiceOptions { public string Subject { get; set; } public string PublicKey { get; set; } public string PrivateKey { get; set; } } public interface IPushNotificationService { void SendNotification(PushSubscription subscription, string payload); }
有了这些API,发送推送消息只需要简单调用一行代码即可。
await _subscriptionStore.ForEachSubscriptionAsync( (PushSubscription subscription) => _notificationService.SendNotification(subscription, "<Push Message>") );
所有复杂的代码都隐藏在了IPushNotificationService实现中,这也是推送消息所需要的计算资源消耗的地方。应用程序必须根据提供的选项生成VAPID标头的值,并根据订阅中提供的密钥加密消息载荷。VAPID标头可以只生成一次,但必须为每个订阅单独加密消息载荷,这需要大量的密码学工作。
推送服务客户端实现是下一篇文章的主题,但这篇文章的目标是拥有完整的工作流程,因此此处将直接使用库来完成工作,具体细节之后再讨论。
internal class WebPushPushNotificationService : IPushNotificationService { private readonly PushNotificationServiceOptions _options; private readonly WebPushClient _pushClient; public string PublicKey { get { return _options.PublicKey; } } public WebPushPushNotificationService(IOptions<PushNotificationServiceOptions> optionsAccessor) { _options = optionsAccessor.Value; _pushClient = new WebPushClient(); _pushClient.SetVapidDetails(_options.Subject, _options.PublicKey, _options.PrivateKey); } public void SendNotification(Abstractions.PushSubscription subscription, string payload) { var webPushSubscription = WebPush.PushSubscription( subscription.Endpoint, subscription.Keys["p256dh"], subscription.Keys["auth"]); _pushClient.SendNotification(webPushSubscription, payload); } }
消息接收
推送消息将直接传递给已注册的service worker,并将触发推送事件,可以从事件参数中提取有效载荷并用于显示通知。
self.addEventListener('push', function (event) { event.waitUntil(self.registration.showNotification('Demo.AspNetCore.PushNotifications', { body: event.data.text(), icon: '/images/push-notification-icon.png' })); });
showNotification有一些影响通知展示的选项,你可在这里查看更多细节。
取消订阅
还有最后一件事情,为了做一个网络世界的好公民,应用程序应该为用户提供取消订阅的途径。取消订阅的流程与订阅很像,首先我们需要从推送服务处取消订阅,然后在服务端删除订阅。
function unsubscribeFromPushNotifications() { pushServiceWorkerRegistration.pushManager.getSubscription().then(function (pushSubscription) { if (pushSubscription) { pushSubscription.unsubscribe().then(function () { fetch('push-notifications-api/subscriptions?endpoint=' + encodeURIComponent(pushSubscription.endpoint), { method: 'DELETE' } ).then(function (response) { if (response.ok) { console.log('Successfully unsubscribed from Push Notifications'); } else { console.log('Failed to discard the Push Notifications subscription from server'); } }).catch(function (error) { console.log('Failed to discard the Push Notifications subscription from server: ' + error); }); ... }).catch(function (error) { console.log('Failed to unsubscribe from Push Notifications: ' + error); }); } }); };
为了支持删除订阅,IPushSubscriptionStore需要进行扩展。对每个订阅来说,endpoint都是独一无二的,因此可以使用它作为主键。
public interface IPushSubscriptionStore { ... Task DiscardSubscriptionAsync(string endpoint); }
剩下的就是处理删除请求的操作。
namespace Demo.AspNetCore.PushNotifications.Controllers { ... // DELETE push-notifications-api/subscriptions?endpoint={endpoint} [HttpDelete("subscriptions")] public async Task<IActionResult> DiscardSubscription(string endpoint) { await _subscriptionStore.DiscardSubscriptionAsync(endpoint); return NoContent(); } }
这足以创建一个使用推送通知的行为良好的web应用程序。如前所述,演示应用程序可以在这里找到。