原文: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应用程序。如前所述,演示应用程序可以在这里找到。