Web Push Notification 介绍与实现(一)

原文: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):

如果你想先看一下最终效果(或者你想先看一下完整代码),示例代码在这里: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应用程序。如前所述,演示应用程序可以在这里找到。

留下评论

您的电子邮箱地址不会被公开。 必填项已用*标注

Captcha Code