Push Notifications
推送通知即使在应用未打开时也能触达用户。浏览器一次性订阅,应用保存该订阅,而服务器则在事件发生时利用该订阅发送通知。Push 管理浏览器中的订阅和退订。在服务器上,PushSender 将 PushMessage 发送到已存储的订阅。
设置和先决条件
推送通知是由一个独立模块提供的。将其添加到你的应用中:
- Maven
- Gradle
<dependency>
<groupId>com.webforj</groupId>
<artifactId>webforj-push</artifactId>
</dependency>
dependencies {
implementation 'com.webforj:webforj-push'
}
推送通知需要:
- 一种 servlet 部署, 如 Jetty、Spring Boot 或 WAR 文件。
- 一个密钥对,下面生成,该部署用来签署通知。
- 一个安全来源。浏览器拒绝通过非
https的方式提供的订阅,除了在开发期间的localhost。
欲了解有关安全上下文的更多信息及其重要性,请参阅 安全上下文 MDN 文档。
生成密钥
推送服务只接受由浏览器订阅的部署签署的通知。对于每个部署,运行一次 构建插件 以生成其密钥对:
- Maven
- Gradle
mvn webforj:push-keys
./gradlew webforjPushKeys
该命令输出三行配置行。将它们粘贴到 application.properties 中,不带引号,或按打印内容复制到 webforj.conf。将主题替换为部署的联系地址。它必须是推送服务可以用于联系操作员的 mailto: 或 https:// 地址。
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() 调用将替换其订阅。
工作原理
该过程有三个步骤:
- 订阅。 从视图中,
Push.getCurrent().subscribe()请求用户的权限,并返回一个识别浏览器地址的PushSubscription。 - 存储。 应用将订阅及其数据保存并与相应用户关联。
- 发送。 稍后,从任何线程,
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);
});
通过 PushPermission,getPermission() 报告用户是授予、拒绝,还是尚未回答通知提示。根据此结果,在点击不会有任何效果的情况下隐藏“启用通知”按钮。
退订
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 时,应用根目录会打开 |
setActions | 设置在通知上显示的按钮,每个按钮都有单独的 URL。请参阅 浏览器支持 |
setTag | 设置一个标识标签。如果显示的通知有相同的标签,新通知将替代它 |
setSilent | 无声或关闭振动地显示通知 |
setTimeToLive | 设置推送服务为离线设备保留消息的时长,最长可达四周 |
setUrgency | 使用 PushUrgency 让设备延迟低紧急性的消息以节省电池 |
setTopic | 替代在推送服务中仍在等待的消息,当两条消息具有相同主题时。主题最多可以包含 32 个 URL 安全字符 |
当选项卡已显示页面时,点击通知会聚焦应用。否则,页面会在新选项卡中打开。点击通知按钮会以相同方式打开其 URL。
每条消息都会显示一个通知。由于浏览器不会因显示为空的消息而唤醒页面,因此推送无法用于静默数据更新。
失败状态
当 subscribe() 或 send() 失败时,它的 PendingResult 报告一个 WebforjPushException。PushStatus 识别原因:
| 状态 | 发生条件 | 处理方法 |
|---|---|---|
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 或更高版本上添加到主屏幕的 Web 应用。在 Safari 选项卡中,
subscribe()报告UNSUPPORTED。请参阅 可安装应用 以获得所需的应用清单。 - Safari 不显示通知按钮。它显示带有操作的消息,但没有按钮,但点击通知仍会打开该消息的 URL。
- Android 和 iOS WebView 不显示通知。
有关每个浏览器的详细信息,请参见 MDN showNotification 兼容性表。
完整示例
以下视图订阅和退订浏览器,将订阅存储在内存中,并向每个存储的订阅发送一条消息。它可以立即发送或使用 Spring 的 TaskScheduler 等待八秒,以允许选项卡在通知到达之前关闭。应用类使用 @EnableScheduling 使调度器可用。
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();
}
}