跳至主要内容

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);
});

通过 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 使调度器可用。

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);