java 实时通知 SSE通用解决方案

38 阅读5分钟

核心思想是:将业务事件进行标准化封装,通过 SSE 通道推送给前端,前端再根据事件类型和关键值进行处理。

下面我们来详细展开这个方案,并讨论如何将其设计得更通用、更健壮。

方案详解

1. 事件的标准化封装

你提到的 “关键值” 和 “事件类型枚举” 是核心。我们可以将其封装成一个通用的事件对象。

事件数据结构 (JSON):

{ "eventType": "ORDER_STATUS_CHANGED", // 事件类型枚举值 "key": "ORDER_123456", // 业务实体的唯一标识 (订单号) "timestamp": 1678886400000, // 事件发生时间戳 "data": { // 业务数据 payload "orderId": "ORDER_123456", "status": "PAID", "paidAmount": 99.00, "paidAt": "2023-03-15T10:00:00Z" } }

各字段说明:

  • eventType (事件类型):

    • 用枚举值(如 ORDER_STATUS_CHANGEDUSER_NOTIFICATION_RECEIVEDPRODUCT_STOCK_UPDATED)来标识事件的具体类型。
    • 前端可以根据 eventType 来决定如何处理这个事件(例如,更新订单列表、弹出通知提示、刷新商品库存)。
  • key (关键值):

    • 业务实体的唯一标识,通常是 ID。例如 ORDER_123456USER_789
    • 这个 key 非常重要,它可以用来做订阅过滤。例如,用户只关心自己的订单(key 以 USER_ 开头且包含自己的 ID),或者某个页面只关心特定订单的状态(key 等于 ORDER_123456)。
  • timestamp (时间戳):  事件发生的时间,用于前端排序或计算时间差。

  • data (业务数据):

    • 包含事件的具体详情,是一个 JSON 对象。
    • 这个字段的结构根据 eventType 的不同而变化。例如,ORDER_STATUS_CHANGED 的 data 包含订单状态信息,而 USER_NOTIFICATION_RECEIVED 的 data 包含通知的标题、内容等。

2. 后端实现 (通用 SSE 服务)

后端需要提供一个通用的 SSE 接口,并能处理来自业务系统的事件推送请求。

核心组件:

  1. SSE 连接管理器:  管理所有客户端的 SSE 连接,并根据 key 进行分组。
  2. 事件接收器:  一个供业务服务调用的接口(如 REST API),用于接收业务事件。
  3. 事件分发器:  将接收到的业务事件,根据其 key 分发给对应的 SSE 连接。

代码示例 (Spring Boot):

// 1. 事件模型 public class SseEvent { private String eventType; private String key; private long timestamp; private Object data; // 构造函数、getter/setter } // 2. SSE 连接管理器 @Component public class SseConnectionManager { // 用 ConcurrentHashMap 存储连接,key 是业务实体的 key (如 "ORDER_123456") // value 是该 key 对应的所有 SSE 会话 private final ConcurrentMap<String, Set<SseEmitter>> connections = new ConcurrentHashMap<>(); public void addConnection(String key, SseEmitter emitter) { connections.computeIfAbsent(key, k -> ConcurrentHashMap.newKeySet()).add(emitter); // 注册 emitter 完成后的回调,用于清理连接 emitter.onCompletion(() -> connections.get(key).remove(emitter)); emitter.onTimeout(() -> connections.get(key).remove(emitter)); } public void removeConnection(String key, SseEmitter emitter) { Set<SseEmitter> emitters = connections.get(key); if (emitters != null) { emitters.remove(emitter); if (emitters.isEmpty()) { connections.remove(key); } } } public Set<SseEmitter> getConnections(String key) { return connections.getOrDefault(key, Collections.emptySet()); } } // 3. 通用 SSE Controller @RestController @RequestMapping("/sse") public class SseController { @Autowired private SseConnectionManager connectionManager; // 客户端订阅接口,通过 key 参数指定要订阅的业务实体 @GetMapping("/subscribe/{key}") public SseEmitter subscribe(@PathVariable String key) { // 设置超时时间,0 表示永不超时,也可以设置一个合理的超时时间 SseEmitter emitter = new SseEmitter(0L); // 将连接添加到管理器 connectionManager.addConnection(key, emitter); try { // 发送一个连接成功的事件 SseEvent connectedEvent = new SseEvent( "CONNECTED", key, System.currentTimeMillis(), "订阅成功: " + key ); emitter.send(SseEmitter.event() .name("control") // 可以用一个特殊的事件名来发送控制消息 .data(connectedEvent)); } catch (IOException e) { // 处理异常 } return emitter; } } // 4. 事件接收与分发 Service @Service public class SseEventService { @Autowired private SseConnectionManager connectionManager; // 业务系统调用此方法来发布事件 public void publishEvent(SseEvent event) { if (event == null || event.getKey() == null) { return; } // 获取订阅了该 key 的所有连接 Set<SseEmitter> emitters = connectionManager.getConnections(event.getKey()); for (SseEmitter emitter : emitters) { try { if (emitter.isOpen()) { // 发送事件,事件名可以设置为 eventType,方便前端精准监听 emitter.send(SseEmitter.event() .name(event.getEventType()) .data(event)); } } catch (IOException e) { // 如果发送失败,移除该连接 connectionManager.removeConnection(event.getKey(), emitter); } } } } // 5. 业务服务如何使用 @Service public class OrderService { @Autowired private SseEventService sseEventService; public void updateOrderStatus(String orderId, OrderStatus newStatus) { // ... 更新数据库等业务逻辑 ... // 构造 SSE 事件 SseEvent event = new SseEvent(); event.setEventType("ORDER_STATUS_CHANGED"); event.setKey("ORDER_" + orderId); // 关键值:订单号 event.setTimestamp(System.currentTimeMillis()); // 构造业务数据 Map<String, Object> data = new HashMap<>(); data.put("orderId", orderId); data.put("status", newStatus.name()); data.put("updatedAt", LocalDateTime.now()); event.setData(data); // 发布事件 sseEventService.publishEvent(event); } } @Service public class NotificationService { @Autowired private SseEventService sseEventService; public void sendNotificationToUser(Long userId, String title, String content) { // ... 保存通知到数据库等业务逻辑 ... SseEvent event = new SseEvent(); event.setEventType("USER_NOTIFICATION_RECEIVED"); event.setKey("USER_" + userId); // 关键值:用户ID event.setTimestamp(System.currentTimeMillis()); Map<String, Object> data = new HashMap<>(); data.put("userId", userId); data.put("title", title); data.put("content", content); event.setData(data); sseEventService.publishEvent(event); } }

3. 前端实现 (通用事件处理)

前端通过 EventSource 连接到 /sse/subscribe/{key} 接口,并监听不同类型的事件。

class SseClient { constructor() { this.eventSource = null; this.subscriptions = new Set(); // 存储当前订阅的 key this.eventListeners = new Map(); // 存储事件监听器 } /** * 订阅一个或多个业务实体 * @param {string|string[]} keys - 要订阅的 key,如 'ORDER_123456' 或 ['USER_789', 'PRODUCT_ABC'] */ subscribe(keys) { const keyArray = Array.isArray(keys) ? keys : [keys]; keyArray.forEach(key => { if (this.subscriptions.has(key)) { return; // 已订阅,忽略 } this.subscriptions.add(key); }); // 如果已有连接,先关闭 if (this.eventSource) { this.eventSource.close(); } // 构建订阅 URL,例如 /sse/subscribe/ORDER_123456,USER_789 const subscribeUrl = `/sse/subscribe/${Array.from(this.subscriptions).join(',')}`; // 建立新连接 this.eventSource = new EventSource(subscribeUrl); // 监听 open 事件 this.eventSource.onopen = (event) => { console.log('SSE 连接已建立'); this.dispatchEvent('CONNECTED', { status: 'connected' }); }; // 监听 message 事件 (通用消息,不推荐在通用方案中使用) // 更好的方式是监听具体的 eventType this.eventSource.onmessage = (event) => { console.log('收到通用消息:', event.data); }; // 监听 error 事件 this.eventSource.onerror = (error) => { console.error('SSE 发生错误:', error); this.dispatchEvent('ERROR', { error: error }); // 可以在这里实现重连逻辑 }; // 动态监听所有可能的业务事件 // 注意:这里无法预先知道所有 eventType,所以需要一个通用的监听机制 // 一种方法是监听 'message' 事件,然后在回调中解析 eventType // 另一种更优雅的方法是,后端在发送时设置 event 字段,前端用 addEventListener 监听 // 这里演示第二种方法,但需要前端知道可能的 eventType 或者有一种机制来动态添加监听器 } /** * 注册事件监听器 * @param {string} eventType - 事件类型,如 'ORDER_STATUS_CHANGED' * @param {Function} callback - 回调函数 */ on(eventType, callback) { if (!this.eventListeners.has(eventType)) { this.eventListeners.set(eventType, []); // 向前端的 EventSource 对象添加对该事件类型的监听 this.eventSource.addEventListener(eventType, (event) => { const data = JSON.parse(event.data); this.dispatchEvent(eventType, data); }); } this.eventListeners.get(eventType).push(callback); } /** * 取消事件监听器 * @param {string} eventType - 事件类型 * @param {Function} callback - 要取消的回调函数 */ off(eventType, callback) { const listeners = this.eventListeners.get(eventType); if (listeners) { const index = listeners.indexOf(callback); if (index !== -1) { listeners.splice(index, 1); } if (listeners.length === 0) { this.eventListeners.delete(eventType); this.eventSource.removeEventListener(eventType); } } } /** * 分发事件 * @param {string} eventType - 事件类型 * @param {Object} data - 事件数据 */ dispatchEvent(eventType, data) { const listeners = this.eventListeners.get(eventType); if (listeners) { listeners.forEach(callback => { try { callback(data); } catch (e) { console.error(`处理事件 ${eventType} 时出错:`, e); } }); } } /** * 关闭 SSE 连接 */ close() { if (this.eventSource) { this.eventSource.close(); this.eventSource = null; this.subscriptions.clear(); this.eventListeners.clear(); console.log('SSE 连接已关闭'); } } } // 使用示例 const sseClient = new SseClient(); // 订阅订单 ORDER_123456 和当前用户 USER_789 的事件 sseClient.subscribe(['ORDER_123456', 'USER_789']); // 监听订单状态变化事件 sseClient.on('ORDER_STATUS_CHANGED', (eventData) => { console.log('订单状态变化:', eventData); // 更新页面上的订单状态显示 // document.getElementById(`order-${eventData.data.orderId}-status`).textContent = eventData.data.status; }); // 监听用户通知事件 sseClient.on('USER_NOTIFICATION_RECEIVED', (eventData) => { console.log('收到新通知:', eventData); // 显示通知提示 // showNotification(eventData.data.title, eventData.data.content); }); // 监听连接状态 sseClient.on('CONNECTED', (data) => { console.log('SSE 连接成功');