websocket使用
WEBSocket(客户端和服务器能够双向同时传输数据): 应用层协议,客户端和服务器建立连接时采用http握手方式,建立连接后利用http协议的Upgrade属性将协议变更为WebSocket协议(通过TCP协议来传输数据)
http和websocket
相同点:1 都是建立在TCP之上,通过TCP协议来传输数据; 2 都是可靠性传输协议; 3 都是应用层协议
不同点:1 WebSocket支持持久连接,HTTP不支持持久连接 2 WebSocket是双向通信协议,HTTP是单向协议,只能由客户端发起,做不到服务器主动向客户端推送信息。
后端java使用websocket:
依赖:
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
一: 创建WebSocket服务端点
import javax.websocket.*; import javax.websocket.server.PathParam; import javax.websocket.server.ServerEndpoint; @ServerEndpoint(value = "/imserver/{conferenceId}/{userToken}", configurator = WebSocketConfigurator.class) @Component//虽然@Component注解默认是单例的,但这里的WebSocketServer不是单例的,每个客户端连接都会有自己的websocketserver对象 public class WebSocketServer { /** * 静态变量,用来记录当前在线连接数。应该把它设计成线程安全的。 */ private static int onlineCount = 0;//这里加了static修饰,所以即使给每个连接创建一个对象,也可以统计出总人数,后面加了static的变量都是属于这个类的,
//对所有连接都可见,不加static修饰的变量如memberId,session都是每个连接自己的属性 /** * concurrent包的线程安全Set,用来存放每个客户端对应的MyWebSocket对象。 */ private static final ConcurrentHashMap<String, WebSocketServer> webSocketMap = new ConcurrentHashMap<>(); /** * 与某个客户端的连接会话,需要通过它来给客户端发送数据 */ private Session session; /** * 接收 userToken */ private String userToken = "";
* 标识初始化来源,同一页面多次连接时值相同
*/
private String uuid;
//可用@Autowired正常注入 @OnOpen //建立连接时先判断这个用户是否已经建立过连接,如果建立过连接要把之前的连接断开再往webSocketMap中添加新连接 public void onOpen(Session session, @PathParam("userToken") String userToken, @PathParam("conferenceId") String conferenceId) throws IOException { final Map<String, Object> properties = session.getUserProperties(); logger.info("open输出当前对象: " + this); this.session = session; this.userToken = userToken; //根据userToken判断webSocketMap中是否已经有相同的key,如果有,先把旧的连接断开再加入新连接 for (Map.Entry<String, WebSocketServer> entry : webSocketMap.entrySet()) { String preUserToken = entry.getKey();//先登陆的 if (userToken.equals(preUserToken)) { WebSocketServer webSocket = entry.getValue(); webSocketMap.get(preUserToken).onClose();//把之前的连接断开 } } webSocketMap.put(userToken, this); } @OnClose public void onClose() { if (webSocketMap.containsKey(userToken) && webSocketMap.get(userToken) == this) { logger.info("删除时 webSocketMap 成员数量为 {}, token 为 {} ,onClose 要删的对象是 {}", webSocketMap.size(), userToken, webSocketMap.get(userToken)); webSocketMap.remove(userToken); } logger.info("用户 memberId = {}, name = {}, token = {} 退出,当前在线人数为 {}", memberId, name, userToken, getOnlineCount()); } /** * 收到客户端消息后调用的方法 * * @param message 客户端发送过来的消息 */ @OnMessage public void onMessage(String message, Session session) throws IOException { logger.debug("接收到用户 {} 消息,报文: {}", userToken, message); JSONObject jsonMsg; try { jsonMsg = JSONObject.parseObject(message); } catch (com.alibaba.fastjson.JSONException e) { logger.warn("WebSocket服务收到了非 JSON 消息,报文:{}", message); return; } if (jsonMsg != null) { //业务逻辑 String type = jsonMsg.getString("type"); String uuid = jsonMsg.getString("uuid"); WebSocketEnum anEnum = WebSocketEnum.getEnum(type); //前端根据用户操作,给后端发送不同类型的消息,后端根据消息类型分别处理 switch (anEnum) { // 心跳 case heartbeat: // 进行心跳包反馈 sendInfo(jsonMsguserToken); break; //倒计时的开启、关闭 case countPlayStop: sendCountdownInfoForAll(jsonMsg); break; //切换议题 case conferenceIssue: //修改会议状态 case conferenceStatus: //开启、关闭同屏 case sameScreenStatus: //切换资料 case conferenceAttachment: //轮询修改版本号 case conferenceSync: default: //已经处理过的消息在default中删除 if (StringUtils.isNotBlank(uuid)) { Iterator<Map<String, String>> iterator = messageVector.iterator(); while (iterator.hasNext()) { Map<String, String> next = iterator.next(); if (uuid.equals(next.get("uuid"))) { iterator.remove(); } } logger.info("收到客户端消息用户:" + userToken + ",已清除消息:" + uuid); //logger.warn("用户:" + userId + ",已清除消息:" + uuid); } break; } } } public static void sendInfo(String message, @PathParam("userToken") String userToken) throws IOException { //log.info("发送消息到:"+userId+",报文:"+message); if (StringUtils.isNotBlank(userToken) && webSocketMap.containsKey(userToken)) { webSocketMap.get(userToken).sendMessage(message); } else { logger.warn("用户【{}】不在线,发送消息失败,消息内容为 {} ", userToken, message); } } public static void sendCountdownInfoForAll(Map<String, Object> map) throws IOException { //优先清理垃圾连接 cleanGcConnect(); for (Map.Entry<String, WebSocketServer> entry : webSocketMap.entrySet()) { //只给同一会议中的人发 String conferenceId = map.get("conferenceId").toString(); if (StringUtils.equals(entry.getValue().conferenceId, conferenceId)) { String userToken = entry.getKey(); sendInfo(JSON.toJSONString(map), userToken); } } } /** * 发送消息给全部客户端 */ public static void sendInfoForAll(Map<String, Object> map) throws IOException { //优先清理垃圾连接 cleanGcConnect(); for (Map.Entry<String, WebSocketServer> entry : webSocketMap.entrySet()) { //只给同一会议中的人发 String conferenceId = map.get("conferenceId").toString(); if (StringUtils.equals(entry.getValue().conferenceId, conferenceId)) { String userToken = entry.getKey(); String uuid = UUIDUtils.getUUID(); map.put("uuid", uuid); String conferenceJson = JSON.toJSONString(map); Map<String, String> cacheMap = new HashMap<>(); cacheMap.put("uuid", uuid); cacheMap.put("userId", userToken); cacheMap.put("message", conferenceJson); String type = map.get("type").toString(); cacheMap.put("msgType", type); cacheMap.put("conferenceId", conferenceId); //记录重试次数,初始化为0,每次重试则加1 cacheMap.put("retryCount", "0"); //清理同类型的重复数据,避免造成重复发送(如果messageVector中有给同会议下同一个人的同类型消息,先把这个消息删了) cleanSameMsg(userToken, type, conferenceId); messageVector.add(cacheMap); } } } }
二:注入ServerEndpointExporter
/** * 开启WebSocket支持*/ @Configuration public class WebSocketConfig { @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }
@Scheduled(cron = "*/1 * * * * ?")//专门发送消息的定时任务,每秒执行一次,将messageVector中的消息发送出去 private void configureTasks() { // 任务执行 id String executeId = UUIDUtils.getUUID(); LOG.info("准备进行消息推送,开始逐条进行消息推送,任务执行 id 为 {}", executeId); try { //ConcurrentHashMap<String, Map<String, String>> messageMap = WebSocketServer.getMessageMap(); Vector<Map<String, String>> messageVector = WebSocketServer.getMessageVector(); LOG.info("当前消息池中共有 {} 条消息", messageVector.size()); // 待销毁队列 List<String> gcList = new ArrayList<>(); // 执行遍历 Iterator<Map<String, String>> iterator = messageVector.iterator(); while (iterator.hasNext()) { Map<String, String> map = iterator.next(); String uuid = map.get("uuid"); String userId = map.get("userId"); String message = map.get("message"); Long firstSendTime = Optional.ofNullable(map.get("firstSendTime")).map(Long::parseLong).orElse(null); long current = System.currentTimeMillis(); Long lastSendTime = Optional.ofNullable(map.get("lastSendTime")).map(Long::parseLong).orElse(null); // 消息类型 String msgType = map.get("msgType"); if (firstSendTime != null) { // 小于一秒,不进行补发 if (lastSendTime != null) { long interval = current - lastSendTime; if (interval < 1000) { LOG.info("消息 uuid = {}, type = {}, lastSendTime = {},current = {}, 发送间隔小于 1 秒,本次跳过不执行发送", uuid, msgType, lastSendTime, current); continue; } } // 大于二分钟,停止补发 if (current - firstSendTime > 1000 * 120) { LOG.info("消息 uuid = {}, type = {} 已超过 2 分钟未发送成功,添加到清理列表中,准备进行清理", uuid, msgType); gcList.add(uuid); } else { LOG.info("重发消息,消息 uuid = {}, type = {}, firstSendTime = {},lastSendTime = {}, message = {}", uuid, msgType, firstSendTime, lastSendTime, map); } } else { // 首次发送时记录发送时间 map.put("firstSendTime", String.valueOf(current)); LOG.info("首次发送消息,消息 uuid = {}, type = {}, firstSendTime = {}", uuid, msgType, current); } //发送消息 try { // 每次发送记录时间 map.put("lastSendTime", current + ""); WebSocketServer.sendInfo(message, userId); } catch (IOException e) { // 发送失败加入清理列表 gcList.add(uuid); } } //销毁消息 for (String uuid : gcList) { for (Map<String, String> map : messageVector) { if (uuid.equals(map.get("uuid"))) { //重试超过三次则清空 int retryCount = Optional.ofNullable(map.get("retryCount")).map(Integer::parseInt).orElse(0); //int retryCount = 1; logger.info("消息 {} 当前重试次数为: {}", uuid, retryCount); retryCount++; if (retryCount >= 4) { logger.error("重试 {} 次仍超时未收到,清除该消息, uuid = {}, message = {}", retryCount, uuid, map); iterator.remove(); } else { // 将 retryCount 写回 message,等待下次重发,这里并没有销毁这条消息,只是增加了轮询次数 logger.info("消息 uuid = {} 已重试 {} 次,message = {}", uuid, retryCount, map); map.put("retryCount", String.valueOf(retryCount)); } } } } LOG.info("已结束消息推送任务,任务执行 id 为 {}", executeId); } catch (Exception e) { logger.error("结束消息推送任务,任务执行 id 为 " + executeId + ",推送出现异常:" + e.getMessage(), e); } }
切换议题,会议资料,切换主讲人这类需要告知所有参会人员的消息,相应接口里调sendInfoForAll()方法,这个方法会将需要发送的所有消息放到vector集合内,由定时任务每秒循环消息容器发送消息,这样服务端就给所有参会人员的设备发送对应消息,
心跳类型的socket消息由客户端直接发给服务器并由服务器给该客户端发送一条同类型的响应消息作为回应,如果客户端长时间收不到心跳消息则断开重连.
前端定时给后端发心跳消息可以防止nginx断开连接(超过一分钟不传输数据nignx会主动断开连接,websocket的onerror会报ava.io.EOFException: null)
切换,只有心跳消息是前端直接发websocket消息,服务器在onMessage里根据消息类型判断是心跳消息后直接给前端发心跳确认消息。其他切换议题,资料等功能都是调的接口,后端代码往vector消息集合里扔消息,由定时任务发送socket消息给前端。定时任务发消息时会判断消息发送间隔,重发次数等,不满足的消息不发送,发送失败次数多的消息会销毁
有时主控端频繁切换资料,议题,受控端收到的socket消息会乱序,比如主控端先执行切换资料1,再执行切换资料2,受控端先收到切换资料2,再收到切换资料1的socket消息,这样就和主控端不一样了,为了避免这种情况,添加版本号概念,新创建会议时版本号为1,每次执行切换议题,资料操作版本号就+1,返回给前端的socket消息中带上版本号,前端收到比当前版本号小的消息就放弃。前端还会定时发送版本号校验的socket消息,后端会返回这个会议最新的状态给前端。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具