ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Tomcat 8+Java 7实现WebSocket聊天室:从环境配置到避坑实战

Tomcat 8+Java 7实现WebSocket聊天室:从环境配置到避坑实战 简介这是一份基于Tomcat8、Java7与ExtJS的WebSocket聊天室完整项目源码面向学习Java Web实时通信、WebSocket协议和ExtJS富客户端开发的中级开发者。项目基于JSR 356标准展示了ServerEndpoint服务端端点创建及onOpen、onMessage、onClose等生命周期处理并涵盖客户端连接、消息广播、聊天室用户管理、SSL安全与并发优化说明能帮助理解实时双向通信从开发到部署的完整链路。压缩包内共1753个文件约7.01MB其中1359个gif可用于界面效果和交互演示158个scss与34个css负责样式主题114个png提供图标素材50个js脚本承载ExtJS界面逻辑后端部分则包含11个jar依赖、6个class编译产物、3个Java源文件、2个XML配置及JSP页面等目录结构清晰适合按前端界面、后端逻辑和部署配置分模块对照学习。目前已有262人学习下载适合正在学习WebSocket实时通信、需要完整项目代码作参考的开发者作为实践案例。1. 为什么在 Tomcat 8 Java 7 上做 WebSocket 聊天室先看清这套老栈的边界看到「webscoket」这个拼写先别慌它的标准写法是 WebSocket。用 Tomcat 8 Java 7 ExtJS 搭聊天室听起来老但很多企业内部系统至今还跑在这套栈上。本文要讲的就是在没有 Spring Boot、没有 Netty 的情况下用容器自带的 WebSocket 能力做出一套能上线、能维护的聊天室。它适合两类人手里握着 Java 7 老工程、被升级成本卡着走不了的开发者只想用轻量容器快速交付实时推送、不想引入额外框架的团队。要解决的核心问题很具体用 Tomcat 8 原生支持的 JSR 356 API 写服务端用 Java 7 语法实现消息分发再用 ExtJS 的面板、Store 把消息实时推上界面。边界先说清楚本文不讨论 Spring WebSocket 或 Netty 方案专注 Tomcat 8 容器自带的实现。原因很实际——java7 跑不动 Spring 5而 Tomcat 8 对 WebSocket 的完整实现刚好把复杂度挡在容器层这是这套老栈能做实时聊天室的前提。2. 让 Tomcat 8 支持 WebSocket环境配置与 JSR 356 服务端骨架2.1 先确认手里的 Tomcat 8 真的带 WebSocket 实现Tomcat 8 对应 Servlet 3.1 规范Servlet 3.1 把 WebSocket 纳入标准也就是 JSR 356。这一条决定了你不需要在 WEB-INF/lib 里放任何第三方 WebSocket 框架。容器自带的两个 jar 是 websocket-api.jar 和 tomcat-websocket.jar都放在$CATALINA_HOME/lib目录下。前者提供javax.websocket.*的 API 类后者是 Tomcat 自己的实现。判断环境是否合格我一般用两步。第一步看版本执行catalina.sh versionServer number 必须是 8.0.x 或 8.5.x如果是 Tomcat 7WebSocket 就得靠第三方库补不在本文范围内。第二步看 jar直接ls /opt/tomcat/lib/ | grep websocket能看到上面两个文件就算装好了。检查项Tomcat 7Tomcat 8.0 / 8.5Tomcat 9Servlet 规范3.03.14.0JSR 356 内置否是是对 Java 7 的支持支持支持仅限 Java 8Java 7 恰恰是 JSR 356 规定的最低语言版本所以在 Java 7 上做 WebSocket 不是「老牛拉破车」而是这套 API 设计时的底线要求。注意编译依赖要用 provided scope不要把 websocket-api.jar 和 tomcat-websocket.jar 打进 WEB-INF/lib。重复加载会让注解扫描失效表现就是 WebSocket 端点找不到这个坑在 5.1 里还会展开。2.2 写一个最简 ServerEndpoint走通 101 握手的完整过程服务端第一个文件是聊天端点的入口。看代码。package demo.chat; import java.io.IOException; import javax.websocket.CloseReason; import javax.websocket.OnClose; import javax.websocket.OnError; import javax.websocket.OnMessage; import javax.websocket.OnOpen; import javax.websocket.Session; import javax.websocket.server.ServerEndpoint; ServerEndpoint(/chat) public class ChatEndpoint { OnOpen public void onOpen(Session session) { // 握手成功后容器回调session 代表这条已经升级完成的连接 System.out.println(连接建立: session.getId()); // 单个文本消息上限默认 8KB聊天场景常有长文本这里调到 64KB session.setMaxTextMessageBufferSize(64 * 1024); // 超过 2 分钟没有读到任何帧容器自动关闭连接注意这是容器级不是应用心跳 session.setMaxIdleTimeout(120000); } OnMessage public void onMessage(String message, Session session) { // 文本消息到达后面的路由逻辑都从这一个入口进 System.out.println(收到消息: message); try { session.getBasicRemote().sendText([\ack\]); } catch (IOException e) { e.printStackTrace(); } } OnClose public void onClose(Session session, CloseReason reason) { System.out.println(连接关闭: reason.getReasonPhrase()); } OnError public void onError(Session session, Throwable t) { // 异常回调里做日志不要在这里做重业务 t.printStackTrace(); } }这段代码虽然只是骨架但已经把 WebSocket 的四个生命周期回调占全了。OnOpen在 HTTP Upgrade 变成 101 之后触发OnMessage在每个文本帧到达时触发OnClose会收到一个 CloseReason 对象OnError处理协议异常和 IO 异常。要注意 onError 和 onClose 在 Tomcat 8 里经常成对出现连接异常先走 onError 再走 onClose所以清理资源的逻辑放在 onClose不要放在 onError 里重复执行。两个参数我从第一天就建议调。setMaxTextMessageBufferSize默认 8192 字节聊天室一条长消息很容易顶破上限顶破后 Tomcat 不是自动拆包而是直接关闭连接。setMaxIdleTimeout默认值是 0 表示用容器默认对聊天室来说 2 分钟比较合理不过真正的存活保障要靠第 3 章的应用层心跳。2.3 用 Java 7 语法做 Session 注册和消息路由从第一个 Chat 类到可扩展骨架Java 7 不能写 lambda所有容器回调都只能用匿名内部类或增强 for 循环。这一步把静态的 ChatEndpoint 变成有状态的聊天室入口维护在线用户集合按消息类型分发。package demo.chat; import java.io.IOException; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import javax.websocket.Session; import javax.websocket.server.ServerEndpoint; import javax.websocket.OnClose; import javax.websocket.OnMessage; import javax.websocket.OnOpen; ServerEndpoint(/chat) public class ChatEndpoint { // 在线用户表userId - Session。多线程读写是常态必须用并发容器 private static final MapString, Session ONLINE_USERS new ConcurrentHashMapString, Session(); OnOpen public void onOpen(Session session) { // 握手时浏览器往 URL 上带参数例如 ws://host/webchat/chat?userIdu001 String userId session.getRequestParameterMap().get(userId).get(0); // 把 userId 存进 session 属性后续消息回调里不用再遍历 Map 反查 session.getUserProperties().put(userId, userId); ONLINE_USERS.put(userId, session); System.out.println([ userId ] 上线当前在线 ONLINE_USERS.size()); } OnMessage public void onMessage(String message, Session session) { // 从 session 属性里直接取 userId避免每次 O(n) 遍历 String from (String) session.getUserProperties().get(userId); if (from null) { return; } for (String userId : ONLINE_USERS.keySet()) { Session target ONLINE_USERS.get(userId); if (target ! null target.isOpen()) { try { target.getBasicRemote().sendText([ from ] message); } catch (IOException e) { // 发送失败说明对端很可能已经断开但 onClose 还没触发 System.err.println(发送到 userId 失败: e.getMessage()); } } } } OnClose public void onClose(Session session) { String userId (String) session.getUserProperties().get(userId); if (userId ! null) { ONLINE_USERS.remove(userId); } } }这段代码的关键在数据结构的选择。onOpen、onMessage、onClose 在 Tomcat 的连接线程池里被调用三个回调有可能同时操作同一个 Map普通 HashMap 在 put 和遍历并发时会直接挂掉。ConcurrentHashMap 的弱一致性迭代器保证遍历过程中不会抛 ConcurrentModificationException代价是遍历时看不到正在 put 的新值这在聊天室场景完全可以接受。为什么我不在 onMessage 里用遍历反查 userId初版可以但 500 人在线时每条消息都遍历一遍每次都是 O(n)。用session.getUserProperties()在握手阶段就把 userId 固化进 session 属性后面取身份就是 O(1)。这个习惯对后来做私聊、审计日志都省事。如果想把房间、私聊、在线人数收进一个 demo更好的做法是引入一个独立的 ChatRoom 类做状态管理ServerEndpoint 里只保留生命周期回调。理由是 Tomcat 对每个连接会创建端点实例静态 Map 虽然跨实例共享但业务逻辑和连接生命周期混在一起后期测试和扩展都会束手束脚。3. 聊天室服务端实现在线用户、房间广播、私聊与心跳3.1 把 Session 管理从 Map 升级到房间模型Java 7 下的线程安全设计聊天室做大了必然要拆房间。常见做法是用两级 Map一级存房间内用户一级存全局在线用户。Java 7 没有 var写起来确实烦但这套结构清晰出问题一眼能看穿。public class ChatRoom { // 房间表roomId - 房间内用户表(userId - Session) private static final MapString, MapString, Session ROOM_USERS new ConcurrentHashMapString, MapString, Session(); // 全局用户表userId - Session用于跨房间私聊和在线人数统计 private static final MapString, Session GLOBAL_USERS new ConcurrentHashMapString, Session(); public static void join(String roomId, String userId, Session session) { MapString, Session room ROOM_USERS.get(roomId); if (room null) { // 房间不存在就新建。注意两个线程可能同时新建用 putIfAbsent 兜底 MapString, Session newRoom new ConcurrentHashMapString, Session(); MapString, Session oldRoom ROOM_USERS.putIfAbsent(roomId, newRoom); room oldRoom ! null ? oldRoom : newRoom; } room.put(userId, session); GLOBAL_USERS.put(userId, session); } public static void leave(String roomId, String userId) { MapString, Session room ROOM_USERS.get(roomId); if (room ! null) { room.remove(userId); if (room.isEmpty()) { // 没有用户就移除房间避免 Map 无限膨胀 ROOM_USERS.remove(roomId, room); } } GLOBAL_USERS.remove(userId); } }这段代码里有三个值得说的点。第一房间不存在时不能直接ROOM_USERS.put(roomId, newRoom)因为两个线程同时 join 会互相覆盖putIfAbsent能保证只有第一个线程创建成功。第二ROOM_USERS.remove(roomId, room)这个两参数版本是 Java 7 里 ConcurrentMap 就有的它保证只在 room 为空时才移除避免误删一个正在被其他线程写入的 room。第三leave 时不能只从 map 里删掉还要显式关闭 Session否则浏览器端长连接还活着用户却已经在房间表里消失了下一次聊天消息会因为找不到目标而静默丢弃。3.2 消息协议设计所有通信统一走 JSONtype 字段决定一切WebSocket 消息在网络上就是一段文本服务端拿到后必须自己决定往哪送。我见过很多翻车项目每个人消息格式都不一样客户端解析逻辑越写越乱。早期先把协议定死能省下大量返工。下面是一个我常用的最小协议。type方向data 关键字段用途chat双向roomId, from, to, content聊天消息to 为 all 表示房间广播否则为私聊online服务端-客户端roomId, onlineCount, users用户上线/下线时的状态推送heartbeat双向ts应用层心跳ack服务端-客户端ackSeq消息确认用于重试和排障服务端路由用一个 if/else 就能接住所有消息。Java 7 的 switch 支持 String但 type 分支太多时我倾向用 if 提前 return避免后面加逻辑时每层都嵌套。import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONObject; OnMessage public void onMessage(String message, Session session) { JSONObject json JSON.parseObject(message); String type json.getString(type); String userId (String) session.getUserProperties().get(userId); if (userId null) { return; } if (chat.equals(type)) { String roomId json.getString(roomId); String to json.getString(to); if (all.equals(to)) { // 广播到整个房间房间内在线用户都能看到 sendToRoom(roomId, message); } else { // 私聊只发给 to 指定的用户 Session target GLOBAL_USERS.get(to); if (target ! null target.isOpen()) { try { target.getBasicRemote().sendText(message); } catch (IOException e) { // 私聊目标可能已断线这里可以返回一个 ack 告知发送者 } } } } else if (heartbeat.equals(type)) { // 心跳只更新时间不回广播避免放大无用流量 touchHeartbeat(userId); } else if (online.equals(type)) { // 客户端主动请求当前在线列表 sendOnlineList(session); } }这里用 fastjson 1.2.x是因为它在 Java 7 上还能正常运行。JSON 依赖不要选最新的快照版很多新依赖的 class 编译版本已经升到 Java 8放 Java 7 容器里直接 NoClassDefFoundError。路由里有两个容易被忽略的细节。其一是「toall」的语义要提前和服务端约定好前端 ExtJS 发送时不要漏掉这个字段漏了会被路由到私聊分支对方收不到就非常困惑。其二是 heartbeat 消息不要广播不然在线 100 人每人每 25 秒发一次心跳瞬时消息量会翻十倍。3.3 心跳机制实现浏览器不给你发 ping 的机会所以用应用层心跳WebSocket 协议本身有 ping/pong 帧但浏览器端的 WebSocket API 并没有暴露「主动发送 ping」的方法客户端只能收到服务端 ping 后自动回 pong。所以一对一聊天的场景服务端往所有 Session 主动 ping 是可以的但做聊天室这种需要感知「某个用户 30 秒没说话」的场景更靠谱的是应用层心跳。具体做法很简单客户端每 25 秒发一条{type:heartbeat,ts:时间戳}服务端更新这个用户最近一次活跃时间后台定时任务每 30 秒扫一遍把超过 90 秒没活跃的 Session 主动关掉。这里的 25/30/90 不是玄学是防止边界抖动客户端定时器偏差、网络拥塞、GC 停顿都会让心跳晚到90 秒的阈值给足了缓冲。private static final MapString, Long LAST_HEARTBEAT new ConcurrentHashMapString, Long(); private static void touchHeartbeat(String userId) { LAST_HEARTBEAT.put(userId, System.currentTimeMillis()); } // 用 ScheduledExecutorService 做后台扫描Java 7 的定时任务标配 private static final ScheduledExecutorService SCANNER Executors.newScheduledThreadPool(1); static { SCANNER.scheduleAtFixedRate(new Runnable() { Override public void run() { long now System.currentTimeMillis(); for (String userId : GLOBAL_USERS.keySet()) { Long last LAST_HEARTBEAT.get(userId); // 没有心跳记录的直接踢掉防止半开连接占着 Session 不放 if (last null || now - last 90000) { Session s GLOBAL_USERS.get(userId); if (s ! null s.isOpen()) { try { s.close(new CloseReason( CloseReason.CloseCodes.NORMAL_CLOSURE, heartbeat timeout)); } catch (IOException e) { // 关闭失败也不纠缠等容器回收 } } // 清理缓存避免僵尸用户占着 LastHeartbeat 不释放 LAST_HEARTBEAT.remove(userId); } } } }, 30, 30, TimeUnit.SECONDS); }这个方案解释了一个常见疑问为什么 Tomcat 的 setMaxIdleTimeout 不能替代心跳。setMaxIdleTimeout 的「空闲」指的是连接上没有读到任何帧而不是用户没有发言。一个挂着聊天室页面、从不发消息的用户即使每 25 秒回一次浏览器自动 pongTomcat 也会认为连接「活跃」永远不会被超时踢掉。应用层心跳则把「人在不在」从「连接活不活」里剥离开这才能给在线列表和会话清理提供准确依据。还有一个容易被忽略的点SCANNER 线程池是 Java 7 里默认创建的非守护线程Tomcat 关闭时如果不及时 shutdownWeb 应用卸载会报线程泄漏。常见做法是在一个 ServletContextListener 的 contextDestroyed 里调用SCANNER.shutdownNow()这篇文章里先记得有这个尾巴真正落地时不要省略。4. ExtJS 前端接入把 WebSocket 消息接进 Ext.Panel 聊天窗口4.1 在 ExtJS 里封装 SocketService 单例连接只建一次事件全城广播ExtJS 是一个重度依赖 MVC 模式的前端框架。默认的 Ext.Ajax 走 HTTP 请求跟 WebSocket 长连接的模型完全不同。直接把 WebSocket 对象散落在每个窗口里后期不管连几次都会出现连接数暴涨。正确做法是在 app 启动时创建唯一一个 SocketService用它统一管理连接和状态通过 Ext.fireEvent 把消息广播给所有关心的组件。Ext.define(app.service.SocketService, { singleton: true, mixins: [Ext.mixin.Observable], ws: null, url: , retryCount: 0, connected: false, connect: function (url) { var me this; me.url url; if (!(WebSocket in window)) { Ext.Msg.alert(提示, 浏览器不支持 WebSocket请更换或升级浏览器); return; } me.ws new WebSocket(url); me.ws.onopen function () { me.connected true; me.retryCount 0; Ext.fireEvent(socketopen); }; me.ws.onmessage function (evt) { // 所有服务端推送都走这一个入口Ext.decode 转成对象再往下传 var data Ext.decode(evt.data); Ext.fireEvent(socketmessage, data); }; me.ws.onclose function () { me.connected false; Ext.fireEvent(socketclose); me.scheduleReconnect(); }; }, send: function (obj) { var me this; // readyState 1 表示 OPEN if (me.ws me.ws.readyState 1) { me.ws.send(Ext.encode(obj)); } else { Ext.Msg.alert(提示, 连接未建立消息没有发送出去); } }, scheduleReconnect: function () { var me this; // 指数退避1s、2s、4s... var delay Math.min(30000, Math.pow(2, me.retryCount) * 1000); if (me.retryCount 10) { me.retryCount; setTimeout(function () { me.connect(me.url); }, delay); } } });ExtJS 6 里 singleton 类可以直接挂 mixins4.2 的写法略有差别但思想一样。connect 方法里的 onopen/onmessage/onclose 是用属性赋值方式挂回调不是 addEventListener所以只有一个连接的情况不担心重复绑定。所有组件监听 socketmessage 事件各自只取自己关心的 data不会互相干扰。另一类常见问题是页面引用的 ext-all.js 和 app 代码的 Ext.define 版本不一致。老项目经常从旧电脑拷过来script 标签里还是 4.2 的 ext-all.js代码已经按 6.0 的 API 写了下载新版本时没同步替换 static 目录的文件一打开就是Ext.create is not a function。下载安装时锁定版本号并把 Ext 版本打印到页脚这类问题五分钟能定位。4.2 Ext.data.Store 与消息推送桥接让 websocket 实时推送数据驱动界面而不是轮询聊天记录在 ExtJS 里天然适合放 Store。收到服务端推来的 chat 消息时直接 store.add 一行数据ExtJS 的网格/列表会监听 store 的加记录事件自动渲染。这一步不要用 setInterval 去定时拉取WebSocket 实时推送数据的标准姿势是服务端主动推用轮询等于把长连接的优势丢掉了。// 我习惯在 ViewController 里监听 socketmessage Ext.define(app.view.chat.ChatController, { extend: Ext.app.ViewController, alias: controller.chat, init: function () { var me this; me.listen({ globals: { socketmessage: me.onSocketMessage } }); me.chatStore Ext.getStore(ChatStore); }, onSocketMessage: function (data) { var me this; if (data.type chat) { // 收到一条新聊天消息直接放进 Store me.chatStore.add({ from: data.from, content: data.content, ts: new Date(data.ts).format(H:i:s) }); } else if (data.type online) { // 在线人数变化更新到界面的标签上 me.getViewModel().set(onlineCount, data.onlineCount); } } });逻辑说明me.listen({ globals: {...} }) 是让 controller 监听全局 SocketService 发出的事件比在每个组件里各自 Ext.on 更干净。chatStore.add 之后不需要手动刷新Ext.grid.Panel 会自动重绘。唯一要留意的是 Store 有没有配 model 字段如果 model 里没有定义 content 字段add 时会被丢弃。如果界面半天不刷新我一般先确认三件事Controller 的 init 有没有先于 SocketService 的连接建立跑完store 还没就绪就 add 会报空指针Store 的 storeId 是否和 Ext.getStore 拿到的完全一致消息里的 ts 字段是否被 Ext.decode 解析成了 Date。这套检查顺序能解决九成「数据没推到界面」的问题。4.3 断线重连与状态提示不要假装连接一直存在WebSocket 的 onclose 是网络断开、服务器重启、代理超时后的最后防线。聊天室场景不能连接一断就让用户手动刷新必须自动重连同时界面上给出明确的状态反馈。// 在窗口底部加一个状态条 Ext.create(Ext.ProgressBar, { id: connState, text: 连接中, width: 200 }); // 监听 SocketService 的状态事件 Ext.on(socketopen, function () { Ext.getCmp(connState).updateProgress(1, 在线); }); Ext.on(socketclose, function () { Ext.getCmp(connState).updateProgress(0.5, 正在重连...); });重连逻辑已经在 scheduleReconnect 里做完了这里补状态提示是为了让用户知道系统没有卡死。另一个容易被忽略的细节是重连成功后要重新做一次用户入房登记。WebSocket 连接重建后服务端 Session 是全新的浏览器必须在 onopen 里重新发送一条包含 userId 和 roomId 的登录消息否则服务端找不到这个用户在哪个房间推送全部失效。这个重新入房的动作我一般定义成一个loginMsg()方法在 onopen 里调用首次连接和重连后走同一个入口避免两套逻辑出现分支遗漏。5. 避坑手册Java 7 / Tomcat 8 / ExtJS 组合下的五个翻车点5.1 握手 404路径丢了自己的 ContextPath现象浏览器 Network 面板里一个 Upgrade 请求返回 404WebSocket 连接建立失败Console 报Error during WebSocket handshake: Unexpected response code: 404。原因ServerEndpoint 里写的是/chat但项目部署在带 context path 的环境。比如 war 包名叫 webchat完整 WebSocket 地址是ws://localhost:8080/webchat/chat写成ws://localhost:8080/chat必然 404。另一个常见原因是项目以前的 HTTP 接口习惯用/api/chat这种前缀WebSocket 端点就没有匹配到。解决用浏览器地址栏请求一次http://localhost:8080/webchat/chat能返回一段 tomcat 版本信息说明路径通了否则逐层检查。前端 SocketService 里统一用一个常量保存 URL避免每个页面都拼接出错。如果前面还挡着 nginx还要在 nginx location 里加上 Upgrade 和 Connection 请求头转发否则 101 会被反向代理吞掉。提示Tomcat 8 的 ServerEndpoint 注解默认扫描 WEB-INF/classes 下的 class。如果开了 scanClassPath 又没配置好注解不生效的表现也是 404解法是把 WebSocket 端点类放到demo.web这种固定前缀的包排查时一眼能认出来。5.2 连接建立但消息收不到别让异常和 buffer 上限背锅现象WebSocket 已经连接成功onOpen 打了日志服务端发消息不报错但浏览器端就是收不到。或者更怪的第一次连接能收到之后慢慢收不到。原因Tomcat 8 对单条文本消息的默认 buffer 是 8192 字节。聊天内容一长或者消息里带着 base64 图片直接超过上限Tomcat 会静默关闭连接而客户端那侧 socket 还显示 OPEN。另一个原因是 onMessage 里的 sendText 抛了 IOException被 catch 掉没引日志看起来就像「连接正常但消息没到」。解决第一个动作是服务端给 onError 和异常 catch 补logger.error把堆栈打出来第二个动作是把 maxTextMessageBufferSize 调到 64KB 或 128KB。浏览器端 onmessage 里加一行console.log(evt.data)然后按消息长度分组测试能快速定位到底是服务端没发还是客户端没渲染。这一步排查完绝大多数「websocket连接但不接受信息」的问题都能终结。5.3 Tomcat 线程数被打满广播循环里的潜伏阻塞现象在线人数到 200 左右Tomcat 线程池被占满HTTP 请求变慢CPU 飙到 90% 以上。日志里全是发送失败的堆栈。原因onMessage 回调里直接做了数据库入库、调用了外部接口或者广播循环 sendText 时某个客户端网络极慢拖住了整个循环。Tomcat 8 的 WebSocket 工作线程不是按连接数无限开的200 个慢客户端就能把 maxThreads 用完。解决onMessage 里只做消息解析和路由把写数据库、发邮件这类耗时操作丢进独立线程池Executors.newFixedThreadPool(8)。广播时不要在 ROOM_USERS 的 Map 上边遍历边发送先把 Session 快照拿出来room.values().toArray()再循环发送。对于慢客户端场景用session.getAsyncRemote().sendText()代替 getBasicRemote避免一个慢连接阻塞整条广播链路。5.4 ExtJS grid 不刷新store.add 之后界面没动静现象浏览器 Console 有 socketmessage 触发store.add 也执行了但页面上的 Ext.grid.Panel 就是多不出一条数据。原因十次里有九次是 grid 绑定的 store 和 add 的 store 不是同一个。ExtJS 4 时代经常出现视图里写stores: [ChatStore]而 Controller 里Ext.getStore(ChatStore)拿到的是另一个 storeId。还有一种活久见的坑项目从旧电脑拷过来时页面 script 标签引的 ext-all.js 是 4.2.1而 app 代码是按 6.0 写的下载新版本时没有同步换 static 目录里的文件导致 Ext.create is not a function。解决先在 Controller 里把两个 store 的 storeId 打印出来对比如果确实同一个调grid.getView().refresh()强制重绘一次。能刷出来说明数据在 store 里只是视图没有刷新往视图渲染时机上查刷不出来说明数据压根没进 store查 add 的字段名和 model 字段是否匹配。5.5 Java 7 编译失败依赖 jar 的 class 版本比代码更早背叛现象本地 javac 编不过Maven 报错lambda expressions are not supported in -source 1.7或者运行时抛UnsupportedClassVersionError: ... (class file version 52.0)。原因项目用 Java 7但同事往 pom 里引入了刚发布的新版 fastjson / commons-lang3 / httpclient这些新 jar 的 class 文件编译版本是 52Java 8甚至 55Java 11放在 JDK 7 容器里必然起不来。Maven 的 compiler 插件如果把 source/target 写成 1.8那就算运行时是 JDK 7编译阶段也会失败。解决在 pom 的properties里锁死maven.compiler.source1.7/maven.compiler.source和maven.compiler.target1.7/maven.compiler.target并给依赖库锁定老版本坐标例如 fastjson 用 1.2.x 长期支持版。拿到一个陌生 jar 后用javap -verbose xxx.jar | grep major看 class 版本51 是 Java 752 是 Java 855 是 Java 11。这招能在一个大型老项目里快速定位「怎么编译过的代码上线就崩」的元凶。6. 验证用 WebSocket Test Client 把聊天室压出真相浏览器端的联调只是第一步我每次都会用独立的 websocket test client 去直接连接服务端把浏览器排除在外。用 Chrome 插件或桌面版 test client 填写ws://localhost:8080/webchat/chat?userIdu001连上后手动发一条干净的 JSON{type:chat,roomId:r1,to:all,content:test}窗口里应立即出现服务端广播回来的文本。这一步验证了握手、注解扫描、Session 注册、路由四个环节任何一个断点都会在窗口里暴露出来。经常有人问能不能顺着 WebSocket 发 POST 请求直觉上想「既然连接都建立了就一个请求全搞定」。答案是不要这么做。WebSocket 没有方法、路径、Content-Type 的概念强行在消息体里塞 HTTP 语义只会让路由代码变成一团乱麻。常见做法是保持两条路写操作继续走 Ext.Ajax 提交到 REST 接口等数据落库后服务端再通过 WebSocket 把变化推给所有在线客户端。这也是「后台有数据前端实时推送」最稳妥的落地路径。压测时除了并发连接数更要留意三件事。第一个是心跳机制是否真的会踢人用 test client 连上一个连接然后不发任何消息90 秒后服务端日志应出现 heartbeat timeout 的关闭记录。第二个是断线重连能否恢复直接杀 Tomcat 进程浏览器端应该走指数退避重连并弹出「正在重连」Tomcat 重启后页面自动恢复在线状态。第三个是广播消息的延迟分布开两个 test client 同时监听同一个房间发消息后观察两条接收时间差超过 500ms 就该回头看广播线程是否被慢连接拖住了。我在这套栈上踩最狠的一次是拿 Java 8 的 lambda 写完了整个 service 层才发现要回退到 Java 7。从那次以后我每接一个老项目第一件事就是看 maven 根 pom 里的 compiler 版本和依赖 class 的 major version而不是先翻业务代码。这套聊天室方案单机撑几百个长连接没有问题再往上要加负载均衡和 Session 状态外置那就是下一步的事了。希望帮到你。本文还有配套的精品资源点击获取
返回列表