沃尔玛基于前后端的消息通知框架介绍和源码


微服务是一种流行的设计模式,其中一个大型应用程序被分解为多个独立且松散耦合的服务,这些服务通过预定义的接口相互通信;Walmart 的ML平台使用相同的原理构建: 部署在 Kubernetes 集群中的独立服务通过 REST API 进行通信。
作为平台功能,为事件提供以用户为目标的通知是一项优先要求。为此,我们开发了一个模型框架,可供任何对通知服务感兴趣的基于微服务的应用程序使用。

在较高级别上,系统应该能够根据以下规则生成和处理通知。

  1. 每个服务都可以独立生成针对一个用户或一组用户的通知。
  2. 针对用户的所有通知都将存储在通知存储中
  3. 对于在线用户,通知消息应立即在 UI 中弹出。对于离线用户,一旦他们登录,通知应该在通知托盘中可用。
  4. 用户可以将通知标记为已读或删除旧通知。
  5. 如果需要,系统还可以选择清除旧通知。

除了所需的规则外,我们还向系统添加了一些所需的功能:
  1. 系统不应给微服务带来重大负担。
  2. 系统应该是快速且相当稳定的。
  3. 虽然丢失通知是不可取的,但可以稍微延迟发送它们。

我们将整个系统设计为一个基于 Java 的库,可以将其导入任何有兴趣发送通知的微服务中。对于通知存储,Redis 是首选,服务器发送事件 (SSE) 用于将通知发送到 UI 客户端(用户的浏览器)。我们将在后续部分中分别介绍每个系统,然后将它们放在一起,看看它们是如何加起来完成此功能的。
  
后端实现
1. 通知模型
通知结构的一个非常简单的设计需要两个字段——目标用户和消息。这是我们最初采用的结构,随着功能开始成熟,添加了更多字段以增强界面并在数据结构中捆绑更多信息。最后,我们正式确定了这种通知结构。
{
    "id": 1234,
   
"message": "Lorem ipsum",
   
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor",
   
"status": "NEW",
   
"severity": "INFO",
   
"source": "project",
   
"displayType": "INBOX",
   
"createdAt": 1621327682,
   
"expiryAt": 1621932482
}

2. 存储通知
在 Notification Store 中存储通知有两个要点需要考虑:

  • 给定通知,应该很容易将其推送到存储
  • 给定用户,应该很容易从存储获取他们的通知。

作为键值存储的 Redis 以毫秒级的延迟完成了这两项任务。此外,Redis 已被证明具有故障恢复能力和高度可扩展性。因此,它被选为通知的后备存储。
此外,Redis 具有对更高 ADT 的内置支持,例如列表和映射。我们利用映射(即 Redis 中的哈希)来存储通知。对于每个用户,存储通知 JSON 的相应通知 id 哈希值。每个唯一的用户 ID 都充当 Redis 中的一个键。这种结构确保支持系统中的 2³² 个用户,每个用户都有 2³² 个潜在的通知。
Redis 可以以线程安全的方式支持高并发工作负载。它还可以通过 Redis Sentinels 提供生产级支持以实现高可用性和 AOF 文件备份以实现持久性。有关于如何设置它们以运行生产级 Redis 集群的优秀文档(请参阅Redis SentinelRedis Persistence)。
 
3.推送通知到存储
通知库公开了NotifierClient具有notifyUsers和notifyGroup方法的接口。要触发通知,微服务将notifyUsers使用Notification对象和要向其发送通知的用户 ID 列表调用该方法。该库还允许创建可用于将相似用户(例如,特定项目的所有用户、使用 GPU 的所有用户等)聚集在一起的组,并且微服务可以选择使用notifyGroup方法向整个组发送通知。
Jedis是 Java 中最著名的与 Redis 通信的库,我们在库中使用它来读写通知。Jedis 支持 Sentinel 支持和连接池等高级功能,这也使其成为生产服务器的理想选择。
为了防止读取和写入偶尔中断,对 Redis 的调用通过Resilience4J 进行包装,以确保在出现临时故障时进行正确的重试和错误处理。
 
前端实现
为了启用前端,我们使用 Express 服务器作为用户浏览器和后端微服务之间的中间件来执行身份验证和会话管理。我们搭载在这个服务器上从 Redis 读取通知并将它们推送给用户。

1. 将通知推送到浏览器
服务器发送事件 (SSE) 是一种建立在 HTTP 之上的技术。对于登录到系统的每个用户,我们在用户会话期间建立一个持久的 HTTP 连接。SSE 的协议规范规定将 JSON 数据转换为字符串,并且每个事件以两个换行符结尾。
建立连接后,我们利用 Node.js 事件模型在 Redis 提供新通知时将数据推送到客户端。我们发出一个通知事件,该事件附加到 HTTP SSE 处理程序范围内的事件侦听器。我们使用登录用户的唯一 id 将来自 Redis 的消息与用户的连接进行匹配。
这个GitHub 代码片段描述了它是如何完成的。

2.在浏览器中接收通知
在客户端,SSE 提供了一个 EventSource API,允许我们连接到服务器并从它接收更新。SSE 有一个限制,它可以同时支持六个并发连接。由于我们在每个浏览器选项卡中打开一个新连接,因此它限制了我们的用户一次只能打开六个选项卡。为了规避这个限制,我们使用SharedWorkers。这使我们能够在 SharedWorker 中创建一个持久连接,并通过不同的浏览器选项卡和 iframe 访问它。
共享工作者SharedWorker的一个缺点是它在 Safari 和 IE 上不受支持,但由于我们的大多数用户群都在 Chrome 和 Firefox 上,因此这被视为可接受的解决方案。如果用户使用 IE 或 Safari,我们将退回到仅允许 6 个选项卡的 SSE 模型。
当用户第一次登录时,会实例化一个新的共享工作者实例,然后将其附加到窗口实例。然后可以在所有浏览器上下文中访问它。然后,网页可以使用 MessagePort 对象与共享 worker 通信,并附加一个事件处理程序,每次共享 worker 推送消息时都会调用该处理程序。
github中的以下代码段具有在浏览器中接收通知的代码。

3. 向用户显示通知
每次打开新选项卡或浏览器窗口时,共享工作者都会为每个新选项卡分配一个端口号。这些端口号为每个用户保存在一个数组中。每当生成新通知时,都会将其推送到所有端口,以使其在选项卡之间保持一致。关闭选项卡时beforeUnload会触发一个事件,在该事件中我们从阵列中删除相应的端口。
为了仅当用户在选项卡上处于活动状态时才显示通知,我们处理visibilitychange由Page Visibility API公开的事件。处理程序将页面标记为非隐藏,然后使用来自后端的通知刷新 redux 存储。这会触发 UI 的渲染,并且通知显示在小吃栏中。
 
桥接后端和前端
系统的两个部分协同工作以提供整个通知系统:

  1. 一组后端服务——生成通知并将它们持久化到 Redis
  2. UI 负责向用户显示通知,无论是在用户在线时实时显示,还是在用户上线时作为错过的通知列表。

  •  后端到前端——通过 Redis PubSub

对于实时场景,新的通知一生成就需要通知 UI。我们使用 Redis PubSub 创建从后端服务到 UI 服务器的反馈通道,然后如所述通过 HTML5 SSE 与 UI 客户端(或用户的浏览器)进行通信。
当生成通知并将通知写入用户的密钥时,库还会在特定频道上生成一条 PubSub 消息,其中相应的用户 ID 已被修改。UI 服务器订阅给定的 PubSub 频道,并在接收到用户 ID 时在其内存中构建通知映射。如果用户在线,UI 服务器会在该用户的 SSE 套接字上发送整个通知 JSON 映射,以在他的浏览器中呈现它。
下面给出了一个粗略的序列图,用于演示通知实例从在后端服务中生成到向用户显示的流程。

  • 前端到后端——通过 REST

一旦用户响应通知(阅读、点击或删除它),该信息必须流经后端存储。我们将其实现为 REST 端点。
该库本身公开了一个 Java API,它可以采用唯一的通知 id、用户 id 和要更新的状态,并且它将用更新的状态修补 Redis 中的通知。然后可以使用服务将这个 API 与 REST 或任何其他类似(例如 gRPC)端点包装在一起。
 
为了清除 Redis 中过期和删除的通知,库内部使用了Quartz调度程序。为了确保一次只运行一个清洁器实例,使用Redlock 算法来创建分布式锁定机制。
整个框架可在https://github.com/daichi-m/notification4J 上作为库使用。