【ServerSentEvents】服务端单向消息推送
依赖库:
Springboot 不需要而外支持,WebMVC组件自带的类库
浏览器要检查内核是否支持EventSource库
Springboot搭建SSE服务:
这里封装一个服务Bean, 用于管理SSE的会话,推送消息
package cn.hyite.amerp.common.sse; import cn.hyite.amerp.common.log.HyiteLogger; import cn.hyite.amerp.common.log.LogFactory; import org.springframework.stereotype.Service; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; /** * 服务单向消息传递,SeverSentEvents web端自动重连 * * @author Cloud9 * @version 1.0 * @project amerp-server * @date 2022年11月10日 10:04 */ @Service public class SeverSentEventService { private static final HyiteLogger logger = LogFactory.getLogger(SeverSentEventService.class); /** * SSE客户端管理容器 */ private static final Map<String, SseEmitter> SESSION_MAP = new ConcurrentHashMap<>(); /** * 连接超时时限1小时 (客户端不活跃的时长上限?) */ private static final Long TIME_OUT = 1000L * 60L * 60L; /** * 根据客户端标识创建SSE连接 * * @param clientId * @return SseEmitter * @author Cloud9 * @date 2022/11/10 10:17 */ public SseEmitter createConnection(String clientId) { SseEmitter emitter = new SseEmitter(TIME_OUT); emitter.onTimeout(() -> { logger.info(" - - - - SSE连接超时 " + clientId + " - - - - "); SESSION_MAP.remove(clientId); }); emitter.onCompletion(() -> { logger.info(" - - - - - SSE会话结束 " + clientId + " - - - - "); SESSION_MAP.remove(clientId); }); emitter.onError(exception -> { logger.error("- - - - - SSE连接异常 " + clientId + " - - - - -", exception); SESSION_MAP.remove(clientId); }); SESSION_MAP.put(clientId, emitter); return emitter; } /** * 根据客户端令牌标识,向目标客户端发送消息 * * @param clientId * @param message * @author Cloud9 * @date 2022/11/10 10:17 */ public void sendMessageToClient(String clientId, String message) { SseEmitter sseEmitter = SESSION_MAP.get(clientId); /* 如果客户端不存在,不执行发送 */ if (Objects.isNull(sseEmitter)) return; try { SseEmitter.SseEventBuilder builder = SseEmitter .event() .data(message); sseEmitter.send(builder); } catch (Exception e) { logger.error("- - - - Web连接会话中断, ClientId:" + clientId + "已经退出 - - - - ", e); /* 结束服务端这里的会话, 释放会话资源 */ sseEmitter.complete(); SESSION_MAP.remove(clientId); } } /** * 给所有客户端发送消息 * * @param message * @author Cloud9 * @date 2022/11/10 10:18 */ public void sendMessageToClient(String message) { SseEmitter.SseEventBuilder builder = SseEmitter .event() .data(message); for (String clientId : SESSION_MAP.keySet()) { SseEmitter emitter = SESSION_MAP.get(clientId); if (Objects.isNull(emitter)) continue; try { emitter.send(builder); } catch (Exception e) { logger.error("- - - - Web连接会话中断, ClientId:" + emitter + "已经退出 - - - - ", e); emitter.complete(); SESSION_MAP.remove(clientId); } } } }
接口和写普通接口有小小区别:
package cn.hyite.amerp.approve.controller; import cn.hyite.amerp.approve.dto.ApproveDTO; import cn.hyite.amerp.common.Constant; import cn.hyite.amerp.common.activiti.ActivitiUtils; import cn.hyite.amerp.common.activiti.ProcessInstanceQueryDTO; import cn.hyite.amerp.common.activiti.TaskQueryDTO; import cn.hyite.amerp.common.annotation.IgnoreLoginCheck; import cn.hyite.amerp.common.config.mybatis.page.PageResult; import cn.hyite.amerp.common.dingtalk.DingDingUtils; import cn.hyite.amerp.common.security.LoginUserContext; import cn.hyite.amerp.common.security.UserContext; import cn.hyite.amerp.common.sse.SeverSentEventService; import cn.hyite.amerp.common.util.PageUtil; import cn.hyite.amerp.system.dingtalk.todotask.dto.SysDiTodotaskDTO; import cn.hyite.amerp.system.dingtalk.todotask.service.ISysDiTodotaskService; import org.activiti.engine.runtime.ProcessInstance; import org.activiti.engine.task.Task; import org.activiti.engine.task.TaskQuery; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.CollectionUtils; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import javax.annotation.Resource; import java.util.ArrayList; import java.util.List; import java.util.Map; /** * * @author cloud9 * @version 1.0 * @project * @date 2022-09-27 */ @RestController @RequestMapping("${api.path}/approve") public class ApproveController { @Resource private SeverSentEventService severSentEventService; /** * web连接的SSE目标地址, 保管凭证并返回这个会话资源给web,注意produces属性必须是文本事件流形式 * 凭证可以使用@PathVariable或者Url参数获取 * /approve/sse/message * @param token * @return SseEmitter * @author cloud9 * @date 2022/11/10 09:57 * */ @IgnoreLoginCheck @GetMapping(value = "/sse/message", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter message(@RequestParam("clientToken") String token) { return severSentEventService.createConnection(token); } /** * 提供给POSTMAN测试调用,方便给web发送消息调试 * /approve/sse/message/send * @param map * @author cloud9 * @date 2022/11/10 10:25 * */ @IgnoreLoginCheck @PostMapping("/sse/message/send") public void send(@RequestBody Map<String, String> map) { String client = map.get("client"); String txt = map.get("txt"); severSentEventService.sendMessageToClient(client, txt); } }
检测SSE服务是否正常,可以直接浏览器访问目标接口:
发送消息,查看是否能够接收:
编写SSE客户端EventSource
使用JS原生EventSource编写:
三个钩子函数:open, error, message
message 数据在event对象的data属性
initialSseConnection() { const isSupportSse = 'EventSource' in window if (!isSupportSse) { console.log('不支持SSE') return } const clientId = 1001 // 假设这是客户端标识令牌,根据实际情况动态设置 const url = `${window._config.default_service}approve/sse/message/?clientToken=${clientId}` const eventSource = new EventSource(url, { withCredentials: true }) console.log(`url ${eventSource.url}`) this.sseConnection = eventSource eventSource.onopen = function(event) { console.log(`SSE连接建立 ${eventSource.readyState}`) } const that = this eventSource.onmessage = function(event) { console.log('id: ' + event.lastEventId + ', data: ' + event.data) ++that.unreadDeliverTotal } eventSource.onerror = function(error) { console.log('connection state: ' + eventSource.readyState + ', error: ' + error) } },
在创建实例周期函数时调用:
created() { this.initialSseConnection() },
在实例销毁时,释放连接资源:
beforeDestroy() { if (this.sseConnection) this.sseConnection.close() }
NPM仓库提供了一个封装库:
https://www.npmjs.com/package/vue-sse
默认npm我这下不动,改用cnpm就可以了,按文档CV就可以直接用
这里不赘述了
实践效果:
初始化时,open方法触发:
HTTP接口状态可以查看服务发送的信息:
PostMan调用开放的接口:
Post请求 http://localhost:8091/amerp-server/api/approve/sse/message/send JsonBody: { "client": "1001", "txt": "{ 'id': 1001, 'msg': 'success qsadasdasd' }" }
注意客户端不要混用令牌标识,这将导致前一个客户端的连接资源被新的客户端覆盖
SseEmitterBuilder发射器对象API
SseEmitter.SseEventBuilder builder = SseEmitter.event(); /* 目前不知道干嘛用的 */ builder.comment("注释数据..."); /* 设置一个消息ID,要有唯一性意义,客户端可以通过event.lastEventId 获取这个ID */ builder.id(UUID.randomUUID(true).toString()); /* 设置这个发射器的名称,也就是事件的名称,如果设置了,客户端则调用这个名字的处理方法,不会执行onmessage */ builder.name("自定义处理器名"); /* 设置消息数据 */ builder.data(message); /* 发射器可以不设置上述内容发送消息 */ emitter.send(builder);
参考来源:
https://oomake.com/question/9520150
自定义事件处理案例:
这里我设置的是customHandler
public void sendMessageToClient(String message) { for (String clientId : SESSION_MAP.keySet()) { final SseEmitter emitter = SESSION_MAP.get(clientId); if (Objects.isNull(emitter)) continue; SseEmitter.SseEventBuilder builder = SseEmitter.event(); builder.comment("注释数据..."); builder.name("customHandler"); builder.id(UUID.randomUUID(true).toString()); builder.data(message); try { emitter.send(builder); } catch (Exception e) { log.error("- - - - - 发送给客户端:{} 消息失败,异常原因:{}", clientId, e.getMessage()); log.error("- - - - - 客户端已经下线,开始删除会话连接 - - - - - "); emitter.completeWithError(e); SESSION_MAP.remove(clientId); } } }
客户端的编写是这样的:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>SSE客户端连接测试</title> </head> <body> <script> const clientId = 'admin-' + new Date().getTime() const url = `http://127.0.0.1:8080/test/sse/release?clientId=${clientId}` const connection = new EventSource(url, { withCredentials: true }) connection.onopen = () => { console.log(`连接已建立,状态: ${connection.readyState}`) console.log(`连接地址: ${connection.url}`) } connection.onerror = event => { console.log(JSON.stringify(event)) } connection.onmessage = event => { console.log(`- - - - 收到服务器消息 - - - - - -`) console.log(`event.lastEventId -> ${event.lastEventId}`) console.log(`event.data -> ${event.data}`) } customHandler = event => { debugger console.log(`- - - - 收到服务器消息(自定义处理器) - - - - - -`) console.log(`event.lastEventId -> ${event.lastEventId}`) console.log(`event.data -> ${JSON.stringify(event)}`) } connection.addEventListener('customHandler', customHandler, true); </script> </body> </html>
可以检查event对象,类型变为我们对发射器对象设置的name值了
思路场景:
可以通过url参数传入后台设置动态的接收器对象?
一般来说好像用不上
最后测试一个JSON解析:
connection.onmessage = event => { console.log(`- - - - 收到服务器消息 - - - - - -`) console.log(`event.lastEventId -> ${event.lastEventId}`) console.log(`event.data -> ${event.data}`) // "{ \"a\": 100, \"b\": \"javascript\", \"c\": true }" const obj = JSON.parse( JSON.parse(event.data)) console.log(typeof obj) console.log(obj.a) }