跳至主要内容

Push Notifications

在 ChatGPT 中打开
26.02
Java API

推送通知可以在应用未打开时依然到达用户。浏览器只需订阅一次,应用保存订阅,服务器当事件发生时利用这个订阅发送通知。Push 负责在浏览器中管理订阅和退订。在服务器上,PushSender 向已存储的订阅发送 PushMessage

配置与先决条件

推送通知由一个单独的模块提供。将其添加到您的应用中:

pom.xml
<dependency>
<groupId>com.webforj</groupId>
<artifactId>webforj-push</artifactId>
</dependency>

推送通知需要:

  • 一个 servlet 部署,如 Jetty、Spring Boot 或 WAR 文件。
  • 一对密钥,下面生成,部署用于对通知进行签名。
  • 安全的来源。浏览器拒绝通过非 https 的任何内容提供的订阅,开发时除了来自 localhost 的订阅。
安全来源

有关安全上下文及其重要性的更多信息,请参阅 安全上下文 MDN 文档

生成密钥

推送服务只接受由浏览器已订阅的部署签名的通知。每个部署运行一次 构建插件 以生成其密钥对:

mvn webforj:push-keys

命令输出三行配置。复制它们到 application.properties 中,不带引号,或将其原样复制到 webforj.conf 中。用部署的联系地址替换主题。它必须是推送服务可用来联系操作员的 mailto:https:// 地址。

application.properties
webforj.push.public-key=...
webforj.push.private-key=...
webforj.push.subject=mailto:ops@example.com
属性说明
webforj.push.public-key部署用于签名通知的密钥对的公钥部分
webforj.push.private-key密钥对的私钥部分。像其他任何秘密一样,请将其排除在源代码控制之外
webforj.push.subject部署的联系地址。必须是推送服务可以联系操作员的 mailto:https:// 地址

应用在启动时读取这些属性。如果配置只包含其中一些,启动将失败并报告缺失的属性。

旋转密钥

每个浏览器订阅一对密钥。如果密钥更改,推送服务将拒绝现有订阅。每个浏览器中的下一个 subscribe() 调用将替换其订阅。

工作原理

过程分为三个步骤:

  1. 订阅。 从视图中,Push.getCurrent().subscribe() 请求用户的权限并返回一个识别浏览器地址的 PushSubscription
  2. 存储。 应用使用其数据保存订阅,并将其与相应的用户关联。
  3. 发送。 稍后,从任何线程中,PushSender.send(subscription, message) 将消息传递给浏览器供应商的推送服务。该服务显示通知,无论应用是否打开。
Push.getCurrent().subscribe().thenAccept(subscriptions::save);

sender.send(subscription,
PushMessage.create("订单已发货").setUrl("/orders/42").build());

以下部分将说明浏览器显示的内容以及如何处理每个步骤中的失败。

实例

检索当前环境的推送实例:

import com.webforj.push.Push;

Push push = Push.getCurrent();

if (Push.isPresent()) {
// ...
}

Push.ifPresent(p -> {
// ...
});

订阅浏览器

在响应用户操作时调用 subscribe(),例如点击“启用通知”按钮。返回的 PendingResult 以浏览器的 PushSubscription 完成。如果浏览器无法订阅,则以 WebforjPushException 异常完成。

PendingResult<PushSubscription> request = Push.getCurrent().subscribe();
request.thenAccept(subscription -> {
subscriptions.save(subscription);
});
request.exceptionally(throwable -> {
WebforjPushException error = (WebforjPushException) throwable.getCause();
PushStatus status = error.getStatus();
String message = error.getMessage();

return null;
});

如果浏览器已经订阅,调用 subscribe() 再次返回现有订阅。因此,可以在每次访问时安全地调用它。

浏览器权限

第一次调用 subscribe() 会提示用户授权。浏览器显示此提示,它不是应用 UI 的一部分。由于浏览器仅在响应用户操作时显示提示,因此应在点击监听器中调用 subscribe(),而不是在视图构造函数中。

如果用户阻止了提示,应用将无法再次对此来源发出提示。

存储订阅

订阅表示一个浏览器的地址,并属于服务器。使用其端点作为键将其与应用数据一起存储。包含应用需要在以后的适当浏览器中选择的信息,例如关联的用户。每个订阅包含三个文本值:

意义
getEndpoint()由浏览器供应商的推送服务分配的交付 URL
getP256dh()浏览器的公钥
getAuth()浏览器的身份验证密钥

从两个浏览器订阅的用户有两个订阅。当其浏览器退订时或发送报告其已过期时,删除订阅。请参见 失败状态

恢复订阅

getSubscription() 返回浏览器的当前订阅,若不存在则返回空结果。可用于同步服务器的副本,例如在应用的存储被重置后:

Push.getCurrent().getSubscription().thenAccept(existing -> {
existing.ifPresent(subscriptions::save);
});

通过 PushPermissiongetPermission() 报告用户是否授予、拒绝或尚未回答通知提示。使用此结果在点击时隐藏“启用通知”按钮,这是无效的。

退订

unsubscribe() 取消浏览器的订阅。它以已删除的订阅完成,以便应用可以删除其存储的副本,或者如果浏览器没有订阅,则以空结果完成。

Push.getCurrent().unsubscribe().thenAccept(removed -> {
removed.ifPresent(subscriptions::delete);
});

发送通知

PushSender 向存储的订阅发送 PushMessage。它使用部署的密钥对消息进行签名,并将其传递给浏览器供应商的推送服务。该服务唤醒浏览器并显示通知。因为该操作永远不会阻塞调用线程,所以您可以在点击监听器、计划作业或请求处理程序中调用它。

配置好属性后,发送器作为一个 bean 可用于注入到视图、服务和计划作业中。要替换它,请定义您自己的 PushSender bean。

@Route("/orders")
public class OrdersView extends Composite<FlexLayout> {

public OrdersView(PushSender sender, PushSubscriptions subscriptions) {
// ...
}
}

如果没有 Spring,new PushSender() 将从应用的配置中读取密钥。在线程中创建发送器,无论是视图中还是在 App.run() 中,然后可以从任何线程使用它。所有发送器共享一个连接池,以便推送服务,因此在需要时创建一个是不需要成本的。

对于必须稍后发送或在用户离开后发送的通知,使用服务器上的计时器,如 Spring 的 TaskScheduler。不要使用页面计时器,例如 Interval,因为它在标签关闭时停止。

构建消息

创建一个消息并设置其标题,然后配置构建器上的每个其他选项:

PushMessage message = PushMessage.create("订单已发货")
.setBody("订单 #42 正在路上")
.setIcon("icons://icon-192x192.png")
.setUrl("/orders/42")
.setActions(List.of(new PushAction("track", "跟踪", "/orders/42/tracking")))
.build();

PendingResult<Void> sent = sender.send(subscription, message);
sent.thenAccept(v -> status.setText("已发送"));
sent.exceptionally(throwable -> {
WebforjPushException error = (WebforjPushException) throwable;
status.setText(error.getStatus() + ": " + error.getMessage());

return null;
});

send() 立即返回。PendingResult 在推送服务接受消息时完成,或者在服务不接受消息时异常完成。如果在应用线程上调用 send(),例如从监听器中,它的回调在该线程上运行并可以更新组件。如果调用 send() 的会话在响应到达之前结束,则回调不运行,但通知仍然会发送。

发送等待推送服务最多 30 秒,然后失败并返回 UNREACHABLE。使用 setTimeout(Duration) 更改每个发送器的超时。

选项效果
setBody设置显示在标题下方的文本
setIcon设置与通知一起显示的图像。接受绝对 URL 以及 icons://ws:// 协议。请参见 资源。不接受 context:// 协议,因为推送服务将消息限制在 4 KB
setUrl设置用户点击通知时打开的页面。相对 URL 是相对于应用根解析的。如果未设置 URL,则打开应用根
setActions设置在通知上显示的按钮,每个按钮具有单独的 URL。请参见 浏览器支持
setTag设置标识标签。如果显示的通知具有相同的标签,则新通知将替换它
setSilent无声或无振动地显示通知
setTimeToLive设置推送服务在离线设备上保留消息的时间,最长为四周
setUrgency使用 PushUrgency 让设备延迟低优先级消息并节省电池
setTopic替换仍在推送服务中等待的消息,当两条消息具有相同主题时。主题最多可以包含 32 个在 URL 中安全的字符

当标签已显示页面时,单击通知将焦点移到应用上。否则,页面将在新标签中打开。单击通知按钮以相同方式打开其 URL。

每条消息一个通知

每条消息都会显示通知。由于浏览器不会因显示为空的消息而唤醒页面,因此无法将推送用于静默数据更新。

失败状态

subscribe()send() 失败时,其 PendingResult 会报告 WebforjPushExceptionPushStatus 确定原因:

状态何时应该做什么
PERMISSION_DENIED用户已阻止该应用的通知解释用户可以在浏览器设置中允许通知的位置
UNSUPPORTED浏览器不支持推送,页面不在安全上下文中,或应用未作为 servlet 部署隐藏功能
NOT_CONFIGURED至少缺少或不完整一个 webforj.push.* 属性生成密钥并配置所有三个属性
SUBSCRIPTION_EXPIRED推送服务不再识别订阅,因为用户退订或重新安装浏览器删除存储的订阅
REJECTED推送服务拒绝了消息; getStatusCode() 包含其响应验证密钥和消息大小
UNREACHABLE推送服务未在超时之前响应稍后重试
UNKNOWN存储的端点不是有效的 URL,或订阅或消息无法编码验证存储的订阅

在每次发送时删除过期的订阅:

sender.send(subscription, message).exceptionally(throwable -> {
WebforjPushException error = (WebforjPushException) throwable;
if (error.getStatus() == PushStatus.SUBSCRIPTION_EXPIRED) {
subscriptions.delete(subscription);
}

return null;
});
过期到达晚一条消息

推送服务懒惰地注销订阅。它们仍然接受用户退订后的第一条消息,但这条消息将无处可去。下一条消息报告 SUBSCRIPTION_EXPIRED。接受的发送表示消息已到达推送服务,而不是用户已看到。

浏览器支持

所有主要桌面和移动浏览器在订阅后显示推送通知。请牢记这些限制:

  • 在 iPhone 和 iPad 上,推送仅适用于在 iOS 16.4 或更高版本上添加到主屏幕的网络应用。在 Safari 标签中,subscribe() 报告 UNSUPPORTED。请参阅 可安装应用 以了解所需的应用清单。
  • Safari 不显示通知按钮。它不带按钮地显示带有操作的消息,但单击通知仍会打开消息 URL。
  • Android 和 iOS WebViews 不显示通知。

有关每个浏览器的详细信息,请参阅 MDN showNotification 兼容性表

完整示例

以下视图订阅和退订浏览器,将订阅存储在内存中,并向每个存储的订阅发送消息。可以立即发送,也可以使用 Spring 的 TaskScheduler 等待八秒,允许标签在通知到达之前关闭。应用类使用 @EnableScheduling 使调度器可用。

PushSubscriptions.java
package com.example;

import com.webforj.push.PushSubscription;
import java.util.Collection;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Service;

@Service
public class PushSubscriptions {

private final Map<String, PushSubscription> byEndpoint = new ConcurrentHashMap<>();

public void save(PushSubscription subscription) {
byEndpoint.put(subscription.getEndpoint(), subscription);
}

public void delete(PushSubscription subscription) {
byEndpoint.remove(subscription.getEndpoint());
}

public Collection<PushSubscription> findAll() {
return byEndpoint.values();
}
}
PushView.java
subscribe.onClick(ev -> Push.getCurrent().subscribe()
.thenAccept(subscription -> {
subscriptions.save(subscription);
status.setText("已订阅");
})
.exceptionally(throwable -> {
WebforjPushException error = (WebforjPushException) throwable.getCause();
status.setText(error.getStatus() == PushStatus.PERMISSION_DENIED
? "此浏览器已阻止通知"
: error.getMessage());

return null;
}));

unsubscribe.onClick(ev -> Push.getCurrent().unsubscribe().thenAccept(removed -> {
removed.ifPresent(subscriptions::delete);
status.setText(removed.isPresent() ? "已退订" : "没有任何订阅");
}));

sendNow.onClick(ev -> sendToAll(subscriptions, sender, message.getValue(), status::setText));

sendLater.onClick(ev -> {
String text = message.getValue();
status.setText("将在8秒后发送,现在可以关闭标签");
scheduler.schedule(() -> sendToAll(subscriptions, sender, text, outcome -> {
}), Instant.now().plusSeconds(8));
});

Push.getCurrent().getSubscription().thenAccept(existing -> {
existing.ifPresent(subscriptions::save);
status.setText(existing.isPresent() ? "已订阅" : "未订阅");
});

self.add(status, message, subscribe, unsubscribe, sendNow, sendLater);