最近在旁观 OI Wiki 搞划词评论 项目,有人提出需求来,说要做一个被评论时通知的机制。
在溜了一圈比如向绑定 Email 发送通知的方案后,有人提出了通过利用「浏览器通知」来推送消息的方式。无奈一窝人都不是 W3C 协议研究大师,之前光见到过具体表现也没研究过实现机制。因而花了几天时间 (upd:完稿时已经接近两个月了)仔细研究,发现了一些既可以被称作「冷知识」又可以被称作「烫知识」的东西。就在这里做一些不专业的分享,如果有专业 Web 从业人士指正就更好了。
这是什么? 众所周知,运行在现代浏览器的网页在弹窗获取用户授权同意之后,拥有推送桌面通知 到用户的权限。
很多时候,这项功能会被用作网页的订阅推送或是消息推送,比如下图是萌娘百科的一个典型调用实例:
而具体到推送本身,从推送特点来看大致可分为两类:Notification
和 Push API
。虽然在找最终用户要权限的时候他们弹出的窗口都一模一样,但他们的区别却比较鲜明:
Notification
的推送方式比较简单,它的存在就是为了使用户在切换到其他标签页或应用程序时也能收到较高层级的通知,但它的缺点也比较显著:它的推送依赖由网页相对前台的异步逻辑直接触发 ,因此只能在用户打开网页时推送消息 ,在用户关闭/休眠网页后推送就会消失。
Push API
则是一种更加强大和贴近手机通知逻辑的推送方式,它的推送不依赖于网页的打开状态,而是通过浏览器后台 的 Service Worker 来推送消息。这种推送方式的优势非常明显:用户不需要打开网页就能收到消息 ,我们本篇主要讨论这种方式 。
有些网页恨不得往自己的前台塞爆各种引擎和逻辑,活跃的网页开得一多就容易使浏览器变成内存厂战略合作伙伴。把所有无关的逻辑扔掉,不用打开网页就能收到通知,听起来是非常美好的一件事。
那么,代价是什么呢?
先别急,我在下面放了两个按钮,他们分别对应了两种推送方式,您可以直接使用 F12 审查元素查看接下来按钮紧接的源代码,然后在进入后续章节前先点击体验一番。 注意:对于推送通知而言,由于不同浏览器的实现方式不同,所需要处理的 Edge Case 非常多,如下的按钮并不一定每次都能奏效。本来这里打算直接引一个专业的方案来实操模拟的,想了想还是算了 :P
点击我触发推送通知
点击我触发普通通知
从底层说起 所以说,在点击了「同意通知」后,从最终用户的浏览器到远程之间,究竟发生了哪些事情? 我们先不去管文档的事,其中的大致逻辑,仔细想想自然也可以猜出大概:
首先,浏览器自然没有办法掏出一种外星科技,来让每个 服务器都能直接穿过层层防火墙和转发直达最终用户。要想让通知服务器直接实现主动推送到最终用户,大约是不太可能。
要想让所有用户在理论情况下 都有收到通知的能力,只能让浏览器主动去拉取消息更新,就像大部分实践一样。但是一方面对于浏览器来说,同时维护到不同服务器之间的连接是一个不小的负担;另一方面对于服务器来说,每个网站都要与一车用户维护连接(即使并没有消息更新)也是不能接受的负担。
那么最容易想到的实现也就呼之欲出了:摇一个中间商出来不就好了。让中间商统一管理通知服务器和浏览器之间的信息交互。一方面,在统一标准之下,浏览器在通知方面的连接可以被简化到中间商一条;另一方面,服务器也没必要和中间商维护长连接,仅需要在有消息更新时通知中间商即可。
恭喜你,现在你已经会发明「推送服务」了,现在快去成为那个中间商吧。
现在,我们可以去看看真实的文档了。与 Push API 强相关的规范性文件共有如下两份文件:
我们可以用 RFC8030 文首的抽象框图来描述一个最简 的推送链条:
+-------+ +--------------+ +-------------+
| UA | | Push Service | | Application |
+-------+ +--------------+ | Server |
| | +-------------+
| Subscribe | |
|--------------------->| |
| Monitor | |
|<====================>| |
| | |
| Distribute Push Resource |
|-------------------------------------------->|
| | |
: : :
| | Push Message |
| Push Message |<---------------------|
|<---------------------| |
| | |
从这个框图中可以很轻易地看出,UA (User Agent) 代表了最终用户端的浏览器,Push Service 代表了作为中间商的推送服务,Application Server 代表了网页服务端的应用服务器。 Push Service 承担了最终用户与服务器之间的中继桥梁:
在订阅发起时,UA 向 Push Service 发起订阅请求。
Push Service 会向 UA 提供一个可以唯一对应到该 UA 本次申请的订阅 的端点对象,通常是一个 URL。
UA 将这个端点对象发送给 Application Server,这个端点对象通常关联在 Push Service 之下。
之后,UA 仅需要维护与 Push Service 的连接 ,而 Application Server 仅需要在有消息更新时通知 Push Service 即可。
整体来看,和我们上述预想的方案是非常接近的。
而更贴近的推送链条,也不过是对于这个链条中各个环节的扩展整合。比如如上示例中的 OneSignal 是将 Application Server 从真正有内容的服务器前置到了专业的推送服务商,又如谷歌系的 Push Service 事实上与他们的 Firebase 服务紧密关联(这点我们之后还会讲)。由此来看,我们事实上已经得知了这套 Push API 的抽象实现逻辑。
一条消息的诞生 Service Worker 的骨架 接下来,我们从抽象的逻辑展开,以 Google Chrome 为载体 ,看看在用户点击「同意推送」之后,整条消息推送的链路发生了什么。
因为 W3C 对于 Push API 的期望是「不仅仅能推送消息」,因此 Push API 能拿到的信息远不仅仅是「标题+内容」这种标准的通知格式,倒从功能上说,「让浏览器显示通知」仅仅是 Push API 的一个子集(但事实上 Chrome 等浏览器对该 API 的适用范围施加了限制,后面会说)。 因此,真正和 Push Service 完成交互的,反而是用户点击「同意」之后驻留在浏览器后台的 Service Worker。Service Worker 作为一个独立的线程,可以在浏览器关闭后继续运行,因此在浏览器 UA 从长连接着的 Push Service 接收到消息后,Service Worker 会被唤醒,然后根据消息内容决定是否显示通知。
所以,对于 Application Server 而言,它首先便需要注册一个 Service Worker,然后在里面完成消息的处理流程。 我们可以使用如下的代码注册一个 Service Worker:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 if ('serviceWorker' in navigator && 'PushManager' in window ) { console .log ('Service Worker and Push API is supported' ); navigator.serviceWorker .register ('/uploads/Something-About-Web-Push-API/sw.js' ) .then (function (swReg ) { console .log ('Service Worker is registered' , swReg); swRegistration = swReg; swRegistration.update (); swRegistration.pushManager .subscribe ({ userVisibleOnly : true , applicationServerKey : applicationServerKey }) }) .catch (function (error ) { console .error ('Service Worker Error' , error); }); } else { alert ("您的访问环境不支持 Service Worker 或 Push API :(" ); }
成功注册 Service Worker 后,打开 F12 开发者选项,我们可以在 Console 中观察到,被注册的 ServiceWorkerRegistration
自带了一个 pushManager
接口,而这就是我们后期发起推送消息时所需要调用的:
在准备好 Service Worker 之后,我们还需要准备一组 VAPID 密钥对,用于在订阅时对消息进行签名,阻止期望外的其他人向用户推送消息。从形式上而言,它就是经典的公钥加私钥,外带一个 mailto:
或是 http
开头的联系方式,用于标识发送方:
1 2 3 4 5 6 7 { "vapid" : { "publicKey" : "这里放公钥" , "privateKey" : "这里放私钥" , "subject" : "mailto:" } }
具体到实现而言,VAPID 使用了 ES256
算法,使用 ECDSA 在 NIST P-256 曲线上创建一组密钥对,然后对 JWT 进行签名验证。 VAPID 密钥对通常仅需要生成一次,而且到处都可以找到一个在线生成站。当然,您可以使用下面的按钮刷新几组,这里的代码来自于 Google 的 Web Push Codelab :
创建/刷新 VAPID
接下来,我们将目光转向真正处理通知请求的 Service Worker 中。实际上对于它而言,最短的形态可以长这样:
1 2 3 4 self.addEventListener ('push' , function (event ) { const payload = event.data ? event.data .text () : 'no payload' ; console .log ('[Service Worker] Push Received.' , payload); });
从上面的代码可以看出,这个 Service Worker 目前只干了一件事:接收到消息后,将消息内容打印到自己的控制台。
我们可以把这个 Service Worker 按正常的推送流程注册一下,看看会发生什么:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 function base64UrlToUint8Array (base64UrlData ) { const padding = '=' .repeat ((4 - base64UrlData.length % 4 ) % 4 ); const base64 = (base64UrlData + padding) .replace (/\-/g , '+' ) .replace (/_/g , '/' ); const rawData = window .atob (base64); const buffer = new Uint8Array (rawData.length ); for (let i = 0 ; i < rawData.length ; ++i) { buffer[i] = rawData.charCodeAt (i); } return buffer; } const applicationServerKey = base64UrlToUint8Array ('这里放公钥' ); swRegistration.pushManager .subscribe ({ userVisibleOnly : true , applicationServerKey : applicationServerKey}). then (function (subscription ) { console .log ('User is subscribed:' , subscription); console .log (JSON .stringify (subscription)); }).catch (function (err ) { console .log ('Failed to subscribe the user: ' , err); });
强耦合的 Push Service 与 UA 从这一步开始,真正有意思的地方开始展开眉目。 我们运行上面的代码,将粗糙的 Service Worker 狠狠注册入浏览器,然后打开抓包软件和控制台,点击「同意推送」的按钮。
然后我们可以在控制台看见这样的输出:
换句话说,我们完成 Service Worker 的注册之后,得到了如下所示的一串 JSON :
1 2 3 4 5 6 7 8 { "endpoint" : "https://fcm.googleapis.com/fcm/send/dZiga:APA91bGM5iYsrYjRUjjn4lpmLtEW6" , "expirationTime" : null , "keys" : { "p256dh" : "BCUoPTSKzN3QWTEhmRrf" , "auth" : "AjkZRuLhkK" } }
endpoint
是一个指向 Push Service 的链接,指向 Push Service 为该 UA 上的该网页所准备的订阅端点。后面使用这个端点便能为此次申请的订阅发送消息。
expirationTime
是一个过期时间,表示这个订阅的有效期。在这个时间之后,这个订阅将会被自动删除。
至少对于 Chromium 系浏览器而言,查看源代码 可以发现,这一项默认都是禁用的,也就是说返回值一般都是 null
。
keys
是一个包含了两个子项的对象,分别是 p256dh
和 auth
。这两个子项是用于在推送消息时对消息进行加密的密钥对。
p256dh
是一个 P-256
公钥。
auth
是一个 Base64
密钥,这是 UA 为 Application Server 生成的鉴权密码,与 endpoint
配合使用。
对于 keys
需要额外补充的一点:IETF 的 RFC8030 要求服务端对推送消息进行RFC8291 加密 ,并通过 RFC8188 定义的 aes128gcm
进行加密传输 ,摘抄如下,感兴趣的可回到原标准进一步研究:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 -- For a user agent: ecdh_secret = ECDH(ua_private, as_public) auth_secret = random(16) salt = <from content coding header> -- For an application server: ecdh_secret = ECDH(as_private, ua_public) auth_secret = <from user agent> salt = random(16) -- For both: ## Use HKDF to combine the ECDH and authentication secrets # HKDF-Extract(salt=auth_secret, IKM=ecdh_secret) PRK_key = HMAC-SHA-256(auth_secret, ecdh_secret) # HKDF-Expand(PRK_key, key_info, L_key=32) key_info = "WebPush: info" || 0x00 || ua_public || as_public IKM = HMAC-SHA-256(PRK_key, key_info || 0x01) ## HKDF calculations from RFC 8188 # HKDF-Extract(salt, IKM) PRK = HMAC-SHA-256(salt, IKM) # HKDF-Expand(PRK, cek_info, L_cek=16) cek_info = "Content-Encoding: aes128gcm" || 0x00 CEK = HMAC-SHA-256(PRK, cek_info || 0x01)[0..15] # HKDF-Expand(PRK, nonce_info, L_nonce=12) nonce_info = "Content-Encoding: nonce" || 0x00 NONCE = HMAC-SHA-256(PRK, nonce_info || 0x01)[0..11]
相信有心的读者已经对 endpoint
里的 fcm.googleapis.com
有了一些敏感度,而结果也确实如此:这个端点,实际上是谷歌的 Firebase Cloud Messaging
服务。这也就意味着,我们的消息实际上是通过谷歌的推送服务来传递的。
这时我们回到抓包软件,会发现在点击推送按钮的同时,Chrome 发送了一个有意思的请求:
至此,整个推送链条的第二环浮出水面:此处的 Push Service (FCM) 与 UA(Chrome) 实际上是强耦合 的关系。
向 Push Service 的更深一步 接下来我们来研究一下这个请求,部分字段进行了脱敏处理但是可以保证一对一映射:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 POST /c2dm/register3 HTTP/2 host : android.clients.google.comcontent-length : 268authorization : AidLogin 114514:1919810content-type : application/x-www-form-urlencodedsec-fetch-site : nonesec-fetch-mode : no-corssec-fetch-dest : emptyuser-agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36accept-encoding : gzip, deflate, br, zstdaccept-language : zh-CN,zh;q=0.9,en;q=0.8,zh-TW;q=0.7priority : u=4, iapp =com.chrome.windows&X-subtype=wp:https://blog.hanlin.press/%235008 CBF7-830 C-43 E6-9 F8E-76 ADF5AA4-V2&device=114514 &scope=GCM&X-scope=GCM&gmsv=131 &appid=dZiga&sender=BBdRH5vrtoken =dZiga:APA91bGM5iYsrYjRUjjn4lpmLtEW6
我们可以把请求和返回拆开来猜测一下:
app
: com.chrome.windows
看样子指向了 Chrome 浏览器的 Windows 版本。
c3ad4ca2-cced-4d75-82f7-c2128563beda
X-subtype
: wp:https://blog.hanlin.press/#5008CBF7-830C-43E6-9F8E-76ADF5AA4-V2
是一个特意构造的 URL,应该是用于标识这个域名下的唯一订阅。
device
: 114514
是一个设备 ID,与标头的 authorization
对应。
appid
和 token
:与上文返回的 JSON 里的 endpoint
完全对应 。
sender
:与上文中提交的 VAPID 公钥完全对应 。
由此可见,Chrome 浏览器一定是从这个请求中获取到了与 Push Service 通信的必要信息,然后将这个信息进一步返回到 PushSubscription
对象中。
如果我们尝试翻翻 Chromium 的源码 ,不难发现,这个地址 https://android.clients.google.com/c2dm/register3
是硬编码进去的:
继续搜索,会发现这是谷歌声明 早已在 2015 年彻底弃用 的 C2DM
推送服务所使用的端点,它甚至是 GCM
的前辈,可以被称作 FCM
的爷爷。可能出于历史遗留向下兼容的问题,在 2024 年运行的 Chrome 中还能看见这个地址。
因为实在不想再去读一遍 Chromium 的源代码,我去 Wayback Archive 里找到了描述注册流程的文档:
这个文档里描述了 app
和 sender
两个必要的对象,接下来是时候看看 Chromium 的源码 了,关键的部分摘抄在下面:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 const char kPushMessagingGcmEndpoint[] = "https://fcm.googleapis.com/fcm/send/" ; const char kSilentPushUnsupportedMessage[] = "Chrome currently only supports the Push API for subscriptions that will " "result in user-visible messages. You can indicate this by calling " "pushManager.subscribe({userVisibleOnly: true}) instead. See " "https://goo.gl/yqv4Q4 for more details." ;
目前返回的推送 API 固定为 https://fcm.googleapis.com/fcm/send/
开头,指向上面说的 FCM 服务。
参见上文的强制规范:Chrome 强制网页声明 Push API 仅能用于用户可见的通知用途。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 void PushMessagingServiceImpl::SubscribeFromWorker ( const GURL& requesting_origin, int64_t service_worker_registration_id, int render_process_id, blink::mojom::PushSubscriptionOptionsPtr options, RegisterCallback register_callback) { render_process_id_ = render_process_id; PushMessagingAppIdentifier app_identifier = PushMessagingAppIdentifier::FindByServiceWorker ( profile_, requesting_origin, service_worker_registration_id); if (app_identifier.is_null ()) { app_identifier = PushMessagingAppIdentifier::Generate ( requesting_origin, service_worker_registration_id); } if (push_subscription_count_ + pending_push_subscription_count_ >= kMaxRegistrations) { SubscribeEndWithError (std::move (register_callback), blink::mojom::PushRegistrationStatus::LIMIT_REACHED); return ; } if (!IsPermissionSet (requesting_origin, options->user_visible_only)) { SubscribeEndWithError ( std::move (register_callback), blink::mojom::PushRegistrationStatus::PERMISSION_DENIED); return ; } if (!options->user_visible_only) { origins_requesting_user_visible_requirement_bypass.insert ( app_identifier.origin ()); } DoSubscribe (std::move (app_identifier), std::move (options), std::move (register_callback), -1 , -1 , blink::mojom::PermissionStatus::GRANTED); } constexpr char kPushMessagingAppIdentifierPrefix[] = "wp:" ;constexpr char kInstanceIDGuidSuffix[] = "-V2" ;constexpr size_t kPrefixLength = sizeof (kPushMessagingAppIdentifierPrefix) - 1 ;constexpr size_t kGuidSuffixLength = sizeof (kInstanceIDGuidSuffix) - 1 ;constexpr char kPrefValueSeparator = '#' ;constexpr size_t kGuidLength = 36 ; PushMessagingAppIdentifier PushMessagingAppIdentifier::Generate ( const GURL& origin, int64_t service_worker_registration_id, const std::optional<base::Time>& expiration_time) { return GenerateInternal (origin, service_worker_registration_id, true , expiration_time); } PushMessagingAppIdentifier PushMessagingAppIdentifier::GenerateInternal ( const GURL& origin, int64_t service_worker_registration_id, bool use_instance_id, const std::optional<base::Time>& expiration_time) { std::string guid = base::ToUpperASCII (base::Uuid::GenerateRandomV4 ().AsLowercaseString ()); if (use_instance_id) { guid.replace (guid.size () - kGuidSuffixLength, kGuidSuffixLength, kInstanceIDGuidSuffix); } CHECK (!guid.empty ()); std::string app_id = kPushMessagingAppIdentifierPrefix + origin.spec () + kPrefValueSeparator + guid; PushMessagingAppIdentifier app_identifier ( app_id, origin, service_worker_registration_id, expiration_time) ; app_identifier.DCheckValid (); return app_identifier; }
结合之前的抓包,不难得出这一段展示了之前 X-subtype
的生成逻辑:wp:
+网页的 Origin 段(通常是请求头带域名)+#
+随机生成的 UUID (最后三位替换成-V2
)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 const char kSenderIdRegistrationDisallowedMessage[] = "The provided application server key is not a VAPID key. Only VAPID keys " "are supported. For more information check https://crbug.com/979235." ; void PushMessagingServiceImpl::DoSubscribe ( PushMessagingAppIdentifier app_identifier, blink::mojom::PushSubscriptionOptionsPtr options, RegisterCallback register_callback, int render_process_id, int render_frame_id, blink::mojom::PermissionStatus permission_status) { if (permission_status != blink::mojom::PermissionStatus::GRANTED) { SubscribeEndWithError ( std::move (register_callback), blink::mojom::PushRegistrationStatus::PERMISSION_DENIED); return ; } std::string application_server_key_string ( options->application_server_key.begin(), options->application_server_key.end()) ; if (!push_messaging::IsVapidKey (application_server_key_string)) { content::RenderFrameHost* render_frame_host = content::RenderFrameHost::FromID (render_process_id, render_frame_id); if (base::FeatureList::IsEnabled ( features::kPushMessagingDisallowSenderIDs)) { if (render_frame_host) { render_frame_host->AddMessageToConsole ( blink::mojom::ConsoleMessageLevel::kError, kSenderIdRegistrationDisallowedMessage); } SubscribeEndWithError ( std::move (register_callback), blink::mojom::PushRegistrationStatus::UNSUPPORTED_GCM_SENDER_ID); return ; } else if (render_frame_host) { render_frame_host->AddMessageToConsole ( blink::mojom::ConsoleMessageLevel::kWarning, kSenderIdRegistrationDeprecatedMessage); } } IncreasePushSubscriptionCount (1 , true ); base::TimeDelta ttl = base::TimeDelta (); if (base::FeatureList::IsEnabled ( features::kPushSubscriptionWithExpirationTime)) { app_identifier.set_expiration_time ( base::Time::Now () + kPushSubscriptionExpirationPeriodTimeDelta); DCHECK (app_identifier.expiration_time ()); ttl = kPushSubscriptionExpirationPeriodTimeDelta; } GetInstanceIDDriver () ->GetInstanceID (app_identifier.app_id ()) ->GetToken ( push_messaging::NormalizeSenderInfo (application_server_key_string), kGCMScope, ttl, {} , base::BindOnce (&PushMessagingServiceImpl::DidSubscribe, weak_factory_.GetWeakPtr (), app_identifier, application_server_key_string, std::move (register_callback))); } void PushMessagingServiceImpl::DidSubscribe ( const PushMessagingAppIdentifier& app_identifier, const std::string& sender_id, RegisterCallback callback, const std::string& subscription_id, InstanceID::Result result) { DecreasePushSubscriptionCount (1 , true ); blink::mojom::PushRegistrationStatus status = blink::mojom::PushRegistrationStatus::SERVICE_ERROR; switch (result) { case InstanceID::SUCCESS: { const GURL endpoint = push_messaging::CreateEndpoint (subscription_id); GetEncryptionInfoForAppId ( app_identifier.app_id (), sender_id, base::BindOnce ( &PushMessagingServiceImpl::DidSubscribeWithEncryptionInfo, weak_factory_.GetWeakPtr (), app_identifier, std::move (callback), subscription_id, endpoint)); return ; } case InstanceID::INVALID_PARAMETER: case InstanceID::DISABLED: case InstanceID::ASYNC_OPERATION_PENDING: case InstanceID::SERVER_ERROR: case InstanceID::UNKNOWN_ERROR: DLOG (ERROR) << "Push messaging subscription failed; InstanceID::Result = " << result; status = blink::mojom::PushRegistrationStatus::SERVICE_ERROR; break ; case InstanceID::NETWORK_ERROR: status = blink::mojom::PushRegistrationStatus::NETWORK_ERROR; break ; } SubscribeEndWithError (std::move (callback), status); } void PushMessagingServiceImpl::DidSubscribeWithEncryptionInfo ( const PushMessagingAppIdentifier& app_identifier, RegisterCallback callback, const std::string& subscription_id, const GURL& endpoint, std::string p256dh, std::string auth_secret) { if (p256dh.empty ()) { SubscribeEndWithError ( std::move (callback), blink::mojom::PushRegistrationStatus::PUBLIC_KEY_UNAVAILABLE); return ; } app_identifier.PersistToPrefs (profile_); IncreasePushSubscriptionCount (1 , false ); SubscribeEnd (std::move (callback), subscription_id, endpoint, app_identifier.expiration_time (), std::vector <uint8_t >(p256dh.begin (), p256dh.end ()), std::vector <uint8_t >(auth_secret.begin (), auth_secret.end ()), blink::mojom::PushRegistrationStatus::SUCCESS_FROM_PUSH_SERVICE); }
更深的内置到 GCM 的推送服务细节无法追溯,但是此处的细节已经可以猜出个大概:Chrome 将 sender
设置为之前传入的 VAPID 公钥(参照 CRBUG #979235 , 他们曾经还使用过需要注册账号的 GCM 服务进行推送),app
设置为 com.chrome.windows
(在注释中还看到过 com.chrome.macosx
),然后执行了一次较为标准的 GCM 通知注册流程。
这整个过程与面向移动应用的推送服务的流程非常相似,但是与后者比起来,Web Push 显得开放却又隐秘。
从开放方面来说,Web Push 不需要经过发布商或是运营商的审核,只要获得了用户的许可和一个正常的网络 ,任何网站都能向用户推送消息。即使有 VAPID 这种机制,也仅仅是作为标识使用,它的生成本身并不需要任何审核,更不需要向任何一个组织递交申请书。
从隐秘方面来说,Web Push 的加密机制也在协议上保证了消息的隐私性。消息的加密和解密都是在 UA 和 Application Server 之间进行的,Push Service 无法窥探消息的内容。
Service Worker 只要监听就好了
Web Push 是这样的,被注册的 Service Worker 仅需要监听 push
事件,然后做出对应的处理就好了,而我们浏览器 UA 所需要干的可就多了。
研究了 Push Service 半天,我们还没来得及认真看一看我们注册了有一段时间的 Service Worker。
打开 F12 开发者选项,选择 Application 栏 → 左侧 Service Workers 菜单,可以查看这个 Worker 的注册状态。
顺带一提,在 Worker 右侧可以看见一个 Unregister 按钮,点击就可以卸载掉这个 Service Worker,顺带退订掉该 Worker 处理的通知 。在抓包软件里也可能看到,Chrome 向 https://android.clients.google.com/c2dm/register3
再次发送了一次请求,只不过这次在大致相同的请求里追加了 delete=true
项。
这时注意到下方的 Push 栏,这里是预留的测试入口,仅需在框里输入内容,然后点击右侧的「Push」按钮,即可在无 Push Service 的情况下模拟一次推送事件。 这时切换到 Console 栏查看控制台输出,可以观测到成功触发了监听到的 push
事件。
可以看到,这里将测试栏里的 Test push message from DevTools.
和 {}
原样输出了出来。
接下来该试试真正的推送了,我们拿好上文生成的 VAPID 公钥和私钥,构造一个真正的 Application Server (前文所提及的都仅仅能称作前端),我无心运营真正的消息推送平台,所以我拿 Cloudflare Worker 模拟了一个:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 import { buildPushPayload, type PushSubscription , type PushMessage , type VapidKeys , } from '@block65/webcrypto-web-push' ; const corsHeaders = { 'Access-Control-Allow-Origin' : '*' , 'Access-Control-Allow-Methods' : 'GET,HEAD,POST,OPTIONS' , 'Access-Control-Allow-Headers' : 'Content-Type' }; async function handleOptions (request : Request ): Promise <Response > { return new Response (null , { headers : corsHeaders }); } async function handleSubscribe (request : Request , env : any ): Promise <Response > { const subscription_data = await request.json (); const vapidKeys = env.VAPID_KEYS ; await new Promise (resolve => setTimeout (resolve, 1000 )); try { const message : PushMessage = { data : JSON .stringify ({ title : '订阅成功' , body : '恭喜,您已经成功触发了推送通知!本通知由 Cloudflare Worker 模拟,我们不会存储您的推送端点,更无法继续发送其他消息。' , icon : 'https://blog.hanlin.press/uploads/Something-About-Web-Push-API/push-icon.png' , link : 'https://blog.hanlin.press/2024/12/Something-About-Web-Push-API' }), }; const vapid : VapidKeys = { publicKey : env.VAPID_PUB , privateKey : env.VAPID_PRIV , subject : "https://blog.hanlin.press/2024/12/Something-About-Web-Push-API" }; const subscription : PushSubscription = { endpoint : subscription_data.endpoint , expirationTime : null , keys : { p256dh : subscription_data.keys .p256dh , auth : subscription_data.keys .auth } }; const req_data = await buildPushPayload (message, subscription, vapid); const res = await fetch (subscription_data.endpoint , req_data); return new Response (JSON .stringify ({ success : true , result : res.status }), { headers : { ...corsHeaders, 'Content-Type' : 'application/json' } }); } catch (error) { return new Response (JSON .stringify ({ success : false , error : error.message }), { status : 500 , headers : { ...corsHeaders, 'Content-Type' : 'application/json' } }); } } async function handleGet (request : Request , env : any ): Promise <Response > { return new Response (JSON .stringify ({ about : "https://blog.hanlin.press/2024/12/Something-About-Web-Push-API" , publicKey : env.VAPID_PUB }), { headers : { ...corsHeaders, 'Content-Type' : 'application/json' } }); } export default { async fetch (request : Request , env : any , ctx : any ): Promise <Response > { if (request.method === 'OPTIONS' ) { return handleOptions (request); } if (request.method === 'POST' ) { return handleSubscribe (request, env); } if (request.method === 'GET' ) { return handleGet (request, env); } return new Response ('Method not allowed' , { status : 405 }); } }
为了简化这一流程,我们此处选择使用已经公开使用的 webcrypto-web-push 库来完成消息推送的底层流程。这个库已经实现了上述的加密流程,我们只需要提供 VAPID 密钥对即可。
什么,你问我为什么不用看上去用户更广泛的 web-push 库? 很遗憾,截至写稿测试时,Cloudflare Worker 尚未对 Crypto 提供完整的支持,使用 web-push
会提示 crypto.createECDH is not implemented yet!"
错误,因此只能使用替代的 WebCrypto 实现。 与此相关的催更区:https://github.com/cloudflare/workerd/discussions/2692
把这个干扰因素撇开,这段代码的意图非常简明:接收用户的订阅请求然后推送一条消息。web-push
的解析也没有多余流程,我们将上文获得的 subscription
JSON 原路 POST 上去就好。
在 wrangler.toml
配置好 VAPID 密钥对后,我们便可以正式地将这个 Service Worker 注册到浏览器中,然后前端的配置就变成了这样: (有心的观众可能会发现这就是篇首按钮对应的源代码)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 function getPublicKeyOnline (serverUrl ) { try { const response = await fetch (serverUrl, { method : 'GET' , headers : { 'Content-Type' : 'application/json' } }); if (!response.ok ) { throw new Error ('Terrible Net :(' ); } const data = await response.json (); return data.publicKey ; } catch (error) { console .error ('Failed to fetch public key:' , error); } } function pushNotification ( ) { if ('serviceWorker' in navigator && 'PushManager' in window ) { console .log ('Service Worker and Push API is supported' ); navigator.serviceWorker .register ('/sw.js' ) .then (function (swReg ) { console .log ('Service Worker is registered' , swReg); swRegistration = swReg; swRegistration.update (); getPublicKeyOnline ('配置好的 Cloudflare Worker 地址' ).then (function (publicKey ) { swRegistration.pushManager .subscribe ({ userVisibleOnly : true , applicationServerKey : base64UrlToUint8Array (publicKey) }).then (function (subscription ) { console .log ('User is subscribed:' , subscription); console .log (JSON .stringify (subscription)); fetch ('配置好的 Cloudflare Worker 地址' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' }, body : JSON .stringify (subscription) }) .then (function (response ) { if (!response.ok ) { throw new Error ('Request failed :(' ); } console .log (response); return response.json (); }) .then (function (data ) { console .log ('Push notification sent successfully:' , data); }) .catch (function (error ) { console .error ('Error sending push notification:' , error); }); }).catch (function (error ) { console .error ('Failed to subscribe:' , error); }); }) }) .catch (function (error ) { console .error ('Service Worker Error' , error); }); } else { alert ("您的访问环境不支持 Service Worker 或 Push API :(" ); } }
很快啊,点击完按钮后小卡一下,控制台就收到了响应:
由此亦可见,Web Push 给了 Service Worker 非常大的自由度,Service Worker 仅需要监听 push
事件,然后自己完成面向用户的推送逻辑即可。纯文本能推,Base64 也能推,消息相关的内容能推,消息之外添加点追踪信息也能推。只需要监听相关事件,然后完成对应的操作,Service Worker 就能在生命周期内完成推送的全部流程。
最终的收尾 迄今为止,Application Server
到 Push Service
的链条已经准备就绪,接下来该让 Service Worker
放弃摸鱼,开始真正工作了。 我们首先处理从收到通知到显示通知这一步的动作,这一步通过调用 showNotification
完成。
根据 MDN 的参数描述,我们常用的用于显示通知的参数无非是 title
、body
、 icon
和 badge
这四种,分别为标题、内容、图标(与标题一同显示的图标)和徽标(显示不了标题时显示的图标)。 同时,如果需要考虑点击事件的话,还需要在通知对象上添加 data
对象,这个对象会在点击通知时传递给 notificationclick
事件。
然后我们就可以把 Service Worker 改写成这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 self.addEventListener ('push' , function (event ) { console .log ('[Service Worker] Push message received' , event); const data = event.data .json (); console .log ('[Service Worker] Push message data' , data); const title = data.title ; const options = { body : data.body , icon : data.icon , badge : data.icon , data : data }; event.waitUntil (self.registration .showNotification (title, options)); });
注意此处的 event.waitUntil
,这个方法会让 Service Worker 保持活跃直到 showNotification
完成,这样可以保证整个通知的处理流程不会被中断。
这时再回头点击一下推送按钮:
Firefox 里也能正常触发推送:
接下来处理点击通知事件,这里需要添加一个新的 notificationclick
事件监听器。
1 2 3 4 5 6 7 8 self.addEventListener ('notificationclick' , function (event ) { console .log ('[Service Worker] Notification click Received.' ); event.notification .close (); event.waitUntil ( clients.openWindow (event.notification .data .link ) ); });
当然,根据 MDN 文档,可以继续实现一个正常 IM 都会做的功能:检测当前页面是否已经打开,如果已经打开则直接切换到该页面,而不是打开新的页面。此处不再赘述。
退订操作可调用 pushManager.getSubscription()
后调用 unsubscribe()
方法完成,此处不再重复实现,更建议读者在阅读完本篇文章后直接去 F12 点击 Unregister 按钮,给自己的浏览器少一些微小的负担。
至此,整个 Web Push 的简易流程已经复现完毕,我们的 Service Worker 已经能够接收到推送消息并显示通知,同时也能够在点击通知时打开对应的页面。这些设计作为原型演示自然没有考虑边界情况,如果真的想在生产环境使用 Web Push 的话,可以去大厂如 OneSignal 的仓库抄作业,或者直接选择调用他们的 SDK。
理想是美好的 作为一篇 Web Push 的入门文章,如果要达到科普用途的话,写到这里就足够了。但是读到这里的读者可能都有一些疑问:Web Push 这么好,为什么在我的周围的应用实例好像又不是很多呢?
房间里的大象们 首先是支持度问题,在 Chrome 与 Firefox 之外,一个绕不开的群体是使用 Safari 浏览器的苹果用户。 苹果在 WWDC22 才正式宣布拥抱更通用的 Web Push 协议,这意味着什么呢? 在这个大会之前的版本,仍旧需要使用苹果自己的私有 API window.safari.pushNotification
进行推送,而且按照文档 ,苹果之前将这一段推送当作和苹果应用通知一样的推送看待,换句话说,就需要走苹果那套注册审核流程 。 根据 Can I use 网站的介绍,macOS 13 之前的用户,Safari 16 之前的用户,不配享有 Web Push 大一统的服务。
而另一头房间里的大象在抓包时就已经显现出来。 不少用户只要有过玩机经验,多少都会对 FCM 服务的不稳定有所耳闻。而倘若你是在一个受限的网络环境 (你知道我在指哪里),你点击文首的 Demo 按钮后,即使点击了同意推送按钮,也并不会收到相关的推送通知 。
打开控制台,会发现通知推送处理流程停在了注册 Service Worker 一步:
这时候我们把目光投向抓包时发现的android.clients.google.com
域名,去一个测速网站 测试,不难发现:
哈哈,这个域名整个大陆都上不去 ,最开始最开始的 token
申请都无法正常完成,更别谈之后的推送了。
看看其他浏览器 由上述启发,我决定在 2024 年的终点,对现行流行的浏览器(包含不可不忽视的手机端)进行一个小调查。 并不是所有浏览器都能像 Chromium 一样可审查源代码,因此调查仅限于观察申请端点与推送端点,以及他们的可访问情况。
笔者手头暂无苹果宣布开放之后的相关设备,故针对 Safari 浏览器的调查暂时无法进行,感兴趣的读者可在评论区提供反馈,可用于测试的 Demo 都在上面。
Firefox
申请端点:https://push.services.mozilla.com/
大陆可访问但速度较慢,部分地区访问失败
推送端点:https://updates.push.services.mozilla.com/wpush/v2/
大陆可访问但速度较慢,部分地区访问失败
Edge
ungoogled-chromium 未提示浏览环境不支持,但是同样无法进行订阅操作,行为同大陆环境下的 Chrome 一致,推测是断掉与 GCM 的连接导致。
Yandex Browser
申请端点:https://android.clients.google.com/c2dm/register3
大陆上不去 。
推送端点:https://fcm.googleapis.com/fcm/send/
与 Chrome Windows 的区别:app
变成了 com.yandex.windows
以及小细节:https://android.clients.google.com/c2dm/register3
被请求了两遍,第一次的 app
被设置成了 com.google.android.gms
,得到了 Error=DEPRECATED_ENDPOINT
的回应,第二次才和 Chrome 浏览器一样,然后改成了 com.yandex.windows
,不过 X-subtype
仍然是 wp:
开头。
Opera
干了和 Yandex 一模一样的事情 ,申请端点和推送端点都和 Chrome 一样,只是 app
变成了 com.opera.windows
, X-subtype
仍然是 wp:
开头。
也同样把 https://android.clients.google.com/c2dm/register3
请求了两遍,乐。
360 安全浏览器
干了和 Yandex 和 Opera 一模一样的事情 , app
变成了 cn.360chrome.windows
, X-subtype
仍然是 wp:
开头。
也同样把 https://android.clients.google.com/c2dm/register3
请求了两遍,你的爱国情怀很差.jpg
QQ 浏览器
它的出厂默认设置禁止了所有网站发送通知 。
手动去设置开启通知权限后可以弹框请求通知,但是无法进行订阅操作 ,即使网络环境没有问题。
Android Chrome
与电脑端行为基本一致,除了 app
标签变成了 com.android.chrome
申请端点变成了 https://android.apis.google.com/c2dm/register3
,大陆依旧无法访问 。请求还多来了这么几份参数:
手机端的 Web Push 以浏览器为主体发送通知,大概长这样,还附赠一个退订按钮:
Android Firefox
申请端点:https://updates.push.services.mozilla.com/v1/fcm/boxwood-axon-825/registration
大陆可访问性一如既往,看链接猜测申请了 FCM 服务
推送端点:https://updates.push.services.mozilla.com/wpush/v2/
大陆可访问但速度较慢,部分地区访问失败
推送来的通知长这样,没有退订按钮:
Android Edge
申请端点:https://android.apis.google.com/c2dm/register3
一开始觉得反常,后来寻思了下套壳很合理。 毕竟 Edge 也是 Chromium 内核。但是大陆必定上不去 。
与 Android Chrome 最大的不同之处在于, X-subtype
开头变成了 edge-webpush:
,以及 app
为 com.microsoft.emmx
推送端点:自然是 https://fcm.googleapis.com/fcm/send/
同样有退订按钮,但是翻译为「取消订阅」
Android 夸克浏览器/UC浏览器/QQ浏览器/小米浏览器提示当前浏览环境不支持 :(
Android Samsung Browser (三星浏览器) 未提示当前浏览环境不支持,但是也同样无法进行订阅操作。
unregister(); 终于来到了真正的结语,我们有什么可以总结的呢? 理想是美好的,现实是非常残酷的。苹果、微软和 Firefox 自建了属于自己的服务器,但其中有些也仅在特定的平台启用。除此之外,其他厂商要么直接摆烂干掉相关的接口,要么逃不开套壳 Chromium 的本质,牢牢抱紧 GCM 的大腿,花样颇多,但都有一个共性:在大陆环境下根本无法访问 。但即使退一万步讲,抛去网络环境的限制,目前各个厂商实现的参差不齐乃至不实现,也是一个绕不开的问题。
综上所述,Web Push 的协议理想是好的,只是在 2024 年的终点,作为 PWA 应用的重要基建之一,它距离真正意义上的普及还有非常非常长的路要走,起码在大陆的网络环境下判了死刑,国产的浏览器也无法幸免。