Tomcat8+Java7+ExtJS WebSocket聊天室实战
简介:一套基于 Tomcat8、Java7 与 ExtJS 搭建的 WebSocket 实时聊天室完整源码,面向已具备 Java Web 基础、希望打通服务端推送与富客户端界面联动的开发者。服务端依托 Tomcat8 容器与 JSR 356 提供的 javax.websocket 端点,通过 @ServerEndpoint 生命周期回调、连接池管理与消息广播完成收发;前端用 ExtJS 组件搭建输入框、消息列表与聊天窗口,可作为实时通信从入门到进阶的实践样本。压缩包共 1753 个文件、约 7.01MB,其中大量 gif 与 png 为框架图标及主题素材,scss 与 css 对应皮肤样式,js 承载前端逻辑与组件定义,另有少量 java、class、jar 及 xml 构成服务端代码与工程配置。已有 263 人学习下载。读者可据此理清握手连接、消息编解码、在线用户管理与历史消息展示的完整链路,并参考其中的部署调试思路。
1. 从一个内网协作需求说起:Tomcat8 + Java7 + ExtJS 的聊天室为什么还要手写 WebSocket
某内网协作工具的前端是 ExtJS 管理台,后端只有 Tomcat8 和 Java7,需求是在不改动现有部署结构的前提下加一个聊天室:群聊、私聊、在线列表、断线重连。很多同学第一反应是上 Spring Boot 或 Node,但现场环境不允许加新运行时,于是把目光放回 Tomcat8 自带的 JSR-356 WebSocket。这套源码和工具清单围绕一个最小闭环展开:服务端用@ServerEndpoint管连接,前端用 ExtJS 封装适配器,消息走 JSON 协议,最后用检查表验收。适合正在做聊天室毕设或课设的人,也适合要在老管理台里嵌实时消息的开发者。
2. 总体架构与技术选型:连接层、路由层、视图层的职责边界
2.1 选型对比:轮询、长轮询与 JSR-356 原生 WebSocket
聊天室最核心的动作是“服务端有新消息时,浏览器能立刻知道”。用传统轮询,前端每隔几秒发一次请求,消息延迟取决于轮询间隔,连接数一多,Tomcat 的线程池会被大量空请求占住;长轮询稍好一点,但每次消息推送都要重新建立一次 HTTP 请求,服务端和前端都要维护挂起状态,代码复杂度并不低。SSE 是单向推送,做在线列表广播够用,但私聊和“正在输入”这类客户端到服务端的事件还得另开接口,协议会变成两套。
Tomcat8 实现了 JSR-356,也就是 Java API for WebSocket,服务端用注解声明端点,容器负责握手和帧解析。对这套项目来说,选它的理由很直接:不新增运行时,不引入额外容器,Java7 能编译,ExtJS 前端用浏览器原生WebSocket对象就能对接。代价是 JSR-356 只提供连接和消息收发,会话表、消息路由、心跳、重连都要自己写。这恰好是方案里最值得展开的部分,也是课程设计能讲出设计含量的地方。
| 方式 | 实时性 | 服务端资源占用 | 前端改造量 | 适用场景 |
|---|---|---|---|---|
| 短轮询 | 秒级延迟 | 空请求多 | 小 | 消息频率极低 |
| 长轮询 | 准实时 | 挂起连接多 | 中 | 兼容旧浏览器 |
| SSE | 单向实时 | 连接常驻 | 中 | 通知、广播 |
| WebSocket | 双向实时 | 连接常驻但帧开销小 | 中 | 聊天室、协作 |
提示:选型表不要只写“WebSocket 更好”,要把“为什么当前环境不能选别的”写清楚,答辩时老师通常会追问这一句。
2.2 三层架构:接入层、会话路由层、ExtJS 视图层
这套聊天室按三层划分。接入层由 Tomcat8 的 WebSocket 容器承担,负责 HTTP Upgrade 握手、帧的编解码、连接超时控制;会话路由层是 Java 代码里的 Endpoint、SessionRegistry 和 MessageRouter,负责“谁在线”“消息发给谁”“发不出去怎么清理”;视图层是 ExtJS 的聊天面板、在线用户表格和输入框,负责把用户操作变成 JSON 帧,把收到的 JSON 帧变成 Store 里的记录。
三层之间只靠 JSON 消息协议耦合,前端不关心服务端用的是ConcurrentHashMap还是别的会话容器,服务端也不关心 ExtJS 用的是 Grid 还是 DataView。这样做的好处是,后面要把在线列表从表格换成树、要把消息存储从内存换成表,改动都收在一层里。课程设计里常犯的错是把广播逻辑写进前端,让每个客户端收到消息后再转发给别人,结果一端掉线整条链路就断,这种设计在答辩时很容易被问住。
数据流可以按一条群聊消息走一遍:用户在 ExtJS 输入框点发送,适配器把{type:"chat", to:"all", content:"..."}转成字符串,通过WebSocket.send()发到/chat/{userId};服务端@OnMessage收到后解析 type,交给 MessageRouter;Router 从 SessionRegistry 取出所有在线 Session,逐个调用发送方法;每个前端收到后把消息 add 到消息 Store,聊天面板只渲染新增行。整条链路里没有轮询,也没有额外 HTTP 接口。
2.3 四个核心模块与目录划分
把代码按职责拆成四个模块,后面排查问题时能快速定位。连接管理模块负责@ServerEndpoint的生命周期方法和 Session 注册注销;消息路由模块负责协议解析、群聊广播、私聊定向发送;在线状态模块负责用户列表和最后活跃时间;前端适配模块负责 ExtJS 的 WebSocket 封装、重连和 Store 联动。目录不必照搬某个框架,但要让别人一眼看出哪块归哪块。
webapp/ chat/ app/ view/ # ChatPanel、OnlineGrid、MessageList store/ # OnlineUsers、Messages ux/ # WebSocketAdapter controller/ # ChatController app.js src/ main/ java/ com/example/chat/ ws/ # ChatEndpoint、ChatConfigurator service/ # SessionRegistry、MessageRouter model/ # ChatMessage、UserSession上面这份目录把“连接”和“路由”分开放,ws包只处理 WebSocket 协议层的事情,service包不依赖javax.websocket之外的容器类。实际交付时,如果项目是传统 Web 工程而不是 Maven 结构,把src换成src/main/java对应的源码目录即可,包名保持一致,后面贴的代码不用改。
2.4 连接生命周期与数据流
JSR-356 的端点有四个生命周期回调:@OnOpen在握手成功后触发,@OnMessage在收到文本或二进制帧时触发,@OnClose在连接关闭时触发,@OnError在连接或发送出错时触发。聊天室的会话表就在@OnOpen里写入,在@OnClose和@OnError里删除。这里有一个容易被忽略的点:@OnClose不保证一定执行,比如进程被 kill、网络中途断开、容器强制回收,所以发送消息时还要做一次session.isOpen()判断和失败清理,不能只依赖关闭回调。
连接建立后,前端适配器会先发一条join消息,服务端回一条online消息带当前在线列表;之后每 25 秒发一次ping,服务端回pong,同时刷新该用户的最后活跃时间。超过 60 秒没有收到任何帧,Tomcat 会按setMaxIdleTimeout关闭连接,前端收到onclose后进入重连流程。这个“心跳 + 空闲超时 + 前端重连”的组合,是后面验收时最能体现设计完整性的部分。
3. 服务端落地:会话表、消息协议与心跳清理的 Java7 写法
3.1 @ServerEndpoint 的生命周期方法与参数获取
服务端端点用注解声明路径,把用户标识放在路径参数里,前端连接时拼上去。这样不用在握手阶段解析 query string,@PathParam在@OnOpen和@OnMessage里都能直接拿到。Java7 没有 lambda,集合初始化用传统写法,代码看起来啰嗦一点,但和 Tomcat8 的 JSR-356 实现完全兼容。
@ServerEndpoint(value = "/chat/{userId}", configurator = ChatConfigurator.class) public class ChatEndpoint { private static final ConcurrentHashMap<String, Session> ONLINE = new ConcurrentHashMap<String, Session>(); private static final ConcurrentHashMap<String, Long> LAST_ACTIVE = new ConcurrentHashMap<String, Long>(); @OnOpen public void onOpen(Session session, @PathParam("userId") String userId) { // 空闲超时设成 60 秒,前端 25 秒发一次心跳,留出重试余量 session.setMaxIdleTimeout(60000L); ONLINE.put(userId, session); LAST_ACTIVE.put(userId, Long.valueOf(System.currentTimeMillis())); // 把当前在线用户推给新连接,格式由 MessageRouter 统一封装 MessageRouter.sendOnlineList(session, ONLINE.keySet()); } @OnMessage public void onMessage(String text, Session session, @PathParam("userId") String userId) { LAST_ACTIVE.put(userId, Long.valueOf(System.currentTimeMillis())); ChatMessage msg = ChatMessage.parse(text); if (msg == null) { MessageRouter.sendError(session, "BAD_MESSAGE"); return; } MessageRouter.route(userId, msg); } @OnClose public void onClose(Session session) { String userId = session.getPathParameters().get("userId"); if (userId != null) { ONLINE.remove(userId, session); LAST_ACTIVE.remove(userId); MessageRouter.broadcastOnlineList(ONLINE.keySet()); } } @OnError public void onError(Session session, Throwable error) { String userId = session.getPathParameters().get("userId"); if (userId != null) { ONLINE.remove(userId, session); LAST_ACTIVE.remove(userId); } // 这里只记日志,不再向已出错的连接写消息 LoggerFactory.getLogger(ChatEndpoint.class).error("ws error, user=" + userId, error); } }这段代码里ONLINE.remove(userId, session)用了双参数删除,只有当前 Session 和表里的一致才删,避免用户重连后旧连接的@OnClose把新连接误删。setMaxIdleTimeout(60000L)的单位是毫秒,前端心跳间隔要小于它,否则连接会被容器提前关掉。ChatConfigurator用来在握手前做 Origin 校验或注入依赖,如果没有额外配置,可以去掉 configurator 属性,不影响主流程。
3.2 会话表设计:ConcurrentHashMap 与用户维度索引
会话表用ConcurrentHashMap<String, Session>,key 是用户 ID,value 是当前连接。聊天室场景读多写少,广播时要遍历所有 Session,ConcurrentHashMap的弱一致性迭代器允许遍历时发生删除,不会抛ConcurrentModificationException。如果要支持同一账号多端登录,value 要换成Set<Session>或CopyOnWriteArrayList<Session>,key 仍然用用户 ID,发送时对该用户的所有连接逐个写。
在线状态还需要一张“最后活跃时间”表,用来做兜底清理。Tomcat 的空闲超时会关闭连接,但某些网络环境下关闭事件可能延迟,定时任务每 30 秒扫一遍LAST_ACTIVE,超过 90 秒没有更新的用户直接从ONLINE里移除并广播在线列表。这个定时任务用ScheduledExecutorService起单线程就够,不要每个连接起一个线程,否则连接数一多线程数会失控。
public final class SessionRegistry { private static final ScheduledExecutorService CLEANER = Executors.newSingleThreadScheduledExecutor(); static { CLEANER.scheduleAtFixedRate(new Runnable() { public void run() { long now = System.currentTimeMillis(); for (Map.Entry<String, Long> entry : ChatEndpoint.LAST_ACTIVE.entrySet()) { if (now - entry.getValue().longValue() > 90000L) { Session session = ChatEndpoint.ONLINE.remove(entry.getKey()); ChatEndpoint.LAST_ACTIVE.remove(entry.getKey()); closeQuietly(session); } } } }, 30L, 30L, TimeUnit.SECONDS); } private static void closeQuietly(Session session) { if (session != null && session.isOpen()) { try { session.close(new CloseReason( CloseReason.CloseCodes.GOING_AWAY, "idle timeout")); } catch (IOException ignored) { // 关闭失败不再重试,Map 已经移除,连接会被容器回收 } } } }这段清理逻辑的关键参数是 90 秒阈值,它要比setMaxIdleTimeout的 60 秒大,给容器留出关闭和回调的时间。closeQuietly里捕获IOException后不再重试,因为会话已经从表里移除,重试没有意义,反而可能把异常日志刷满。定时任务用静态块启动,项目里如果有 Spring 容器,可以改成@PostConstruct初始化,但不要在每次连接时新建线程池。
3.3 消息协议与路由:群聊、私聊、系统消息
消息协议用 JSON,字段不多但要在前端和服务端保持一致。type区分消息类型,from由服务端根据路径参数填,to对群聊填all,对私聊填目标用户 ID,content是文本内容,ts是服务端时间戳。服务端解析后不信任前端传来的from,统一用路径参数覆盖,避免冒充。协议表如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| type | string | chat、private、system、ping、pong、online |
| from | string | 服务端填充的发送者 ID |
| to | string | all 或目标用户 ID |
| content | string | 文本内容,system 消息为提示文案 |
| ts | long | 服务端毫秒时间戳 |
| users | array | 仅 online 消息携带,在线用户 ID 列表 |
路由方法收到chat时遍历ONLINE广播,收到private时只发给to对应的 Session,收到ping时回pong。广播时不要在主线程里同步写所有连接,某个客户端网络慢会拖住整个广播;可以把每个发送包成一个任务丢给线程池,但线程池要有界,队列满了就丢弃并记日志,避免消息堆积把内存撑爆。课程设计规模小,直接同步写也可以,但要在代码注释里写清楚这是简化处理,答辩时能说明取舍。
public static void route(String from, ChatMessage msg) { if ("ping".equals(msg.getType())) { sendText(ONLINE.get(from), "{\"type\":\"pong\",\"ts\":" + System.currentTimeMillis() + "}"); return; } if ("chat".equals(msg.getType())) { msg.setFrom(from); msg.setTs(System.currentTimeMillis()); String json = msg.toJson(); for (Session session : ONLINE.values()) { sendText(session, json); } return; } if ("private".equals(msg.getType())) { msg.setFrom(from); msg.setTs(System.currentTimeMillis()); Session target = ONLINE.get(msg.getTo()); if (target == null) { sendError(ONLINE.get(from), "USER_OFFLINE"); return; } sendText(target, msg.toJson()); sendText(ONLINE.get(from), msg.toJson()); } }route里对私聊做了“发给目标并回显给发送者”的处理,前端不用自己往消息列表里补一条,所有消息都以服务端回执为准,这样多端登录时消息顺序一致。sendText内部要做空判断和isOpen判断,发送失败时从ONLINE移除对应会话,避免坏连接一直留在表里。
3.4 心跳、空闲超时与失效会话清理
心跳不是业务消息,但它是聊天室能不能长期挂住的关键。前端每 25 秒发一次ping,服务端在@OnMessage里刷新LAST_ACTIVE并回pong。如果前端连续三次没有收到pong,就主动关闭连接并进入重连。服务端这边,setMaxIdleTimeout(60000L)是容器级兜底,定时清理是应用级兜底,两层机制同时存在,任何一层失效另一层还能收住。
发送方法要加锁,原因是 JSR-356 的RemoteEndpoint.Basic不保证线程安全,多个业务线程同时往同一个 Session 写,可能出现IllegalStateException或消息截断。用synchronized (session)包住sendText是最简单的做法,连接数不大时开销可以接受。如果追求吞吐,可以改用getAsyncRemote()加发送队列,但异步发送的回调里不能再同步写同一个 Session,否则会死锁。这部分在下一章的避坑里还会展开。
public static void sendText(Session session, String text) { if (session == null || !session.isOpen()) { return; } synchronized (session) { try { session.getBasicRemote().sendText(text); } catch (IOException e) { // 写失败说明连接已经不可用,直接从在线表移除 String userId = session.getPathParameters().get("userId"); if (userId != null) { ONLINE.remove(userId, session); } } } }sendText是整套服务端代码里被调用次数最多的方法,广播、私聊、系统提示都走它。把空判断、锁、失败清理收在一个方法里,路由层就不用重复写这些边界处理。注意session.getPathParameters()在@OnClose之后调用可能返回空 Map,所以移除时先判断userId是否为 null,避免ConcurrentHashMap的 key 为 null 抛异常。
4. 前端落地:ExtJS 组件树与 WebSocket 适配器的对接方式
4.1 ExtJS 视图结构:Viewport、聊天面板、在线列表、输入区
前端用 ExtJS 的 Viewport 做整体布局,左侧是在线用户 Grid,右侧是消息列表加输入区。消息列表可以用Ext.view.View配itemTpl,也可以用 Grid 配列,前者渲染轻、适合消息流,后者方便做“按用户筛选”。在线列表用 Grid,Store 的字段是userId、name、status,收到online消息时整表替换,收到用户上下线时增量 add/remove。输入区用Ext.form.Panel,回车发送,发送按钮在请求发出后置灰,收到服务端回显再恢复。
组件树不要建得太深,聊天面板里套太多panel会让布局计算变慢,消息一多就能感觉到卡顿。消息列表用flex: 1撑满剩余高度,输入区固定高度,在线列表给一个最小宽度。所有需要随窗口变化的地方用layout: 'border'或layout: 'fit',不要用绝对定位,否则分辨率一变就错位。
4.2 WebSocket 适配器:连接、重连、事件总线
ExtJS 没有内置 WebSocket 类,需要自己封一个Ext.util.Observable子类,把onopen、onmessage、onclose、onerror转成 ExtJS 事件。适配器负责拼 URL、发消息、心跳、重连,业务控制器只监听message和open事件。这样控制器里不出现原生WebSocketAPI,后面换传输方式也不用改业务代码。
Ext.define('Chat.ux.WebSocketAdapter', { extend: 'Ext.util.Observable', config: { url: null, userId: null }, constructor: function (config) { this.initConfig(config); this.callParent(arguments); this.reconnectDelay = 1000; this.maxDelay = 30000; this.manualClose = false; this.heartbeatTimer = null; this.connect(); }, connect: function () { var me = this; me.socket = new WebSocket(me.getUrl() + '/' + encodeURIComponent(me.getUserId())); me.socket.onopen = function () { me.reconnectDelay = 1000; me.startHeartbeat(); me.fireEvent('open'); }; me.socket.onmessage = function (event) { var msg = Ext.JSON.decode(event.data, true); if (msg) { me.fireEvent('message', msg); } }; me.socket.onclose = function () { me.stopHeartbeat(); me.fireEvent('close'); if (!me.manualClose) { me.scheduleReconnect(); } }; me.socket.onerror = function () { me.fireEvent('error'); }; }, startHeartbeat: function () { var me = this; me.stopHeartbeat(); me.heartbeatTimer = setInterval(function () { me.send({ type: 'ping', ts: new Date().getTime() }); }, 25000); }, stopHeartbeat: function () { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; } }, scheduleReconnect: function () { var me = this; setTimeout(function () { me.connect(); }, me.reconnectDelay); me.reconnectDelay = Math.min(me.reconnectDelay * 2, me.maxDelay); }, send: function (obj) { if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.socket.send(Ext.JSON.encode(obj)); } }, close: function () { this.manualClose = true; this.stopHeartbeat(); if (this.socket) { this.socket.close(); } } });适配器里reconnectDelay从 1 秒开始翻倍,上限 30 秒,避免服务端重启时所有客户端在同一秒重连把连接数打满。manualClose用来区分“用户主动关闭”和“网络异常关闭”,主动关闭不触发重连。Ext.JSON.decode(event.data, true)的第二个参数是安全模式,解析失败返回 null,不会抛异常打断事件循环。心跳定时器在onclose里必须清掉,否则重连后会有多个定时器同时发 ping。
4.3 Store 与消息渲染:避免全量重绘
消息 Store 的字段包括from、content、ts、type,收到chat或private消息时用store.add追加一条,不要用loadData全量替换。全量替换会让整个消息列表重新渲染,消息一多就能看到明显闪烁,而且滚动位置会丢。追加之后判断用户是否在底部,如果在底部就滚到底,如果用户正在往上翻历史消息,就不要强制滚动,给一个“有新消息”的提示按钮。
在线用户 Store 的更新策略相反,online消息到达时可以loadData全量替换,因为在线列表通常只有几十条,全量替换的代价比逐条比对小。但userOnline和userOffline这类增量消息要用add和remove,不要每次都全量拉一遍。两个 Store 的更新方式分开,是前端性能上最容易被忽略的一点。
Ext.define('Chat.controller.ChatController', { extend: 'Ext.app.Controller', init: function () { var me = this; me.adapter = Ext.create('Chat.ux.WebSocketAdapter', { url: 'ws://' + location.host + '/chat', userId: window.currentUserId }); me.adapter.on('message', me.onWsMessage, me); me.adapter.on('open', me.onWsOpen, me); }, onWsOpen: function () { this.adapter.send({ type: 'join' }); }, onWsMessage: function (msg) { var msgStore = Ext.getStore('Messages'); var onlineStore = Ext.getStore('OnlineUsers'); if (msg.type === 'chat' || msg.type === 'private' || msg.type === 'system') { msgStore.add({ from: msg.from, content: msg.content, ts: msg.ts, type: msg.type }); this.scrollToBottomIfNeeded(); return; } if (msg.type === 'online') { onlineStore.loadData(msg.users || []); return; } if (msg.type === 'userOnline') { onlineStore.add({ userId: msg.userId, status: 'online' }); return; } if (msg.type === 'userOffline') { onlineStore.remove(onlineStore.getById(msg.userId)); } }, scrollToBottomIfNeeded: function () { var view = Ext.getCmp('messageList').getView(); var el = view.getEl(); if (el && el.dom.scrollHeight - el.dom.scrollTop - el.dom.clientHeight < 40) { el.dom.scrollTop = el.dom.scrollHeight; } } });控制器只做分发,不直接操作 WebSocket 对象,消息 Store 和在线 Store 的更新逻辑分开写。scrollToBottomIfNeeded里的 40 像素阈值是经验值,小于这个距离视为“用户在底部”,可以自动滚动;超过这个距离说明用户正在看历史消息,不要打扰。Ext.getCmp在大型项目里不推荐,但聊天室这种单页面够用,如果控件有reference,用lookupReference更稳。
4.4 前端协议对接与错误提示
前端发消息时不要带from,服务端会覆盖;私聊消息带to,群聊to填all。发送前做一次空内容判断,连续回车不要发空消息。服务端返回error类型时,在输入区上方显示一行提示,不要用alert打断用户。连接断开时把输入框置灰,重连成功后恢复,用户不需要手动刷新页面。
错误码不要太细,前端只需要区分“消息格式错误”“对方不在线”“连接已断开”三类。BAD_MESSAGE通常是前端 JSON 拼错,开发阶段出现,上线后不应该出现;USER_OFFLINE是正常业务分支,提示“对方已离线”即可;连接断开由适配器的close事件统一处理,不需要服务端再发错误消息。把这三类分开,前端提示文案不会互相覆盖。
5. 避坑与排查:握手 404、并发写、会话泄漏等五个高频问题
5.1 握手 404:路径、容器依赖与打包冲突
现象是浏览器控制台报WebSocket connection to 'ws://...' failed: Error during WebSocket handshake: Unexpected response code: 404,服务端日志里看不到@OnOpen。原因通常有三种:前端拼的路径和服务端@ServerEndpoint的值不一致;项目把websocket-api.jar打进了WEB-INF/lib,和 Tomcat 自带的实现冲突;web.xml的metadata-complete="true"让容器不扫描注解。解决时先看 Tomcat 的logs/localhost日志有没有端点注册信息,再检查WEB-INF/lib里有没有websocket-api或tomcat-websocket的 jar,有就移掉,编译期依赖用providedscope。路径上注意前后端都带没带尾斜杠,/chat/{userId}和/chat/是两回事。
5.2 并发写异常:RemoteEndpoint 的线程安全问题
现象是广播时偶尔抛java.lang.IllegalStateException: The remote endpoint was in state [TEXT_FULL_WRITING],或者某个客户端收到的消息少了一段。原因是多个线程同时调用同一个 Session 的getBasicRemote().sendText(),JSR-356 不保证RemoteEndpoint.Basic的线程安全。解决方法是给发送方法加synchronized (session),把同一个 Session 的写操作串行化;如果连接数很大,改成每个 Session 一个发送队列,由单线程消费队列,业务线程只入队。不要在被锁住的代码里做 JSON 序列化以外的耗时操作,否则广播会被慢客户端拖住。
5.3 会话泄漏:onClose 没触发与 Map 只增不减
现象是运行一段时间后,在线列表里出现已经关掉页面的用户,广播时sendText一直失败,内存缓慢上涨。原因是@OnClose在网络异常、容器强制回收、进程被 kill 时不一定执行,而代码只依赖@OnClose删除会话。解决方法是三层兜底:发送失败时从ONLINE移除;LAST_ACTIVE超过阈值由定时任务清理;@OnError里也执行移除。移除时用remove(key, value)双参数版本,防止旧连接的回调误删新连接。上线前用jmap或容器自带的管理页看一次 Session 数量,连接关闭后数量应该回落。
5.4 重连风暴:前端退避与后端限流
现象是服务端重启或网络抖动后,所有客户端在同一秒重连,Tomcat 的线程数瞬间打满,日志里大量握手失败。原因是前端重连间隔固定,没有退避,也没有随机抖动。解决方法是在适配器里做指数退避,间隔从 1 秒翻倍到 30 秒,并在每次间隔上加 0 到 1 秒的随机数,把重连时间打散。服务端可以在ChatConfigurator的beforeRequest里按 IP 做简单的计数限流,超过阈值直接拒绝握手。不要在前端用setInterval无限重试,重试次数要有上限,超过之后提示用户手动刷新。
5.5 跨域与接入层配置:Origin 校验与超时参数
现象是本地开发时前端页面在 8080,WebSocket 服务在另一个端口,浏览器握手被拒绝;或者经过接入层转发后,Upgrade头丢失,握手变成普通 HTTP 请求。原因是 WebSocket 握手虽然不受同源策略的完全限制,但浏览器会带Origin,服务端可以校验;接入层如果没有放行Upgrade和Connection头,握手无法完成。解决方法是在ChatConfigurator里读取Origin,只允许配置过的域名;接入层配置里显式放行Upgrade、Connection头,并把读超时调大,至少大于心跳间隔。本地开发可以把前端和服务端配到同一个端口,用相对路径拼ws://,减少跨域变量。
注意:接入层配置改完后,先用浏览器开发者工具的 Network 面板看握手响应码,101 才是切换协议成功,200 或 403 都要回头查 Origin 和路径。
6. 验收与答辩:一份能现场演示的检查表和讲解顺序
验收不要只看“能发消息”,要按连接生命周期逐项过。下面这份表可以直接当答辩演示脚本,每项操作完看一眼浏览器和服务端日志,通过标准写清楚,避免现场扯皮。
| 检查项 | 操作 | 通过标准 |
|---|---|---|
| 连接建立 | 打开两个浏览器窗口,分别登录不同用户 | 服务端日志出现两次onOpen,在线列表显示两人 |
| 群聊广播 | A 发一条群聊,B 观察 | B 立刻收到,消息顺序与发送顺序一致 |
| 私聊定向 | A 选择 B 发私聊,C 观察 | 只有 B 和 A 收到,C 不出现该消息 |
| 在线列表 | 关闭 B 的页面 | A 的在线列表在数秒内移除 B |
| 心跳与超时 | 断网 30 秒再恢复 | 前端进入重连,恢复后能继续收发 |
| 会话释放 | 连续开关页面 20 次 | 服务端ONLINE大小回落到实际在线数 |
| 并发发送 | 两个用户同时快速发消息 | 不出现IllegalStateException,消息不串行错乱 |
答辩讲解按“需求—选型—数据流—演示—排错”的顺序走,不要一上来念代码。先用一句话说清楚为什么不用轮询:内网环境已有 Tomcat8,JSR-356 不需要新增运行时,实时性更好。然后画一条消息从 ExtJS 输入框到对方 Store 的路径,指着代码说清楚@OnOpen注册、route分发、sendText加锁这三处。演示时故意关掉一个窗口,让对方看到在线列表变化;再故意断网几秒,让重连退避生效。老师如果问“消息可靠吗”,不要答“可靠”,答“当前是内存态,服务端不持久化,断线期间的消息不补发,这是这套方案的边界;如果要补发,得加消息表和离线队列”。把边界说清楚,比硬撑更加分。
我最早做这套聊天室时,只测了两个人正常发消息,没测“关页面后会话有没有删”,结果演示到一半在线列表里挂着一排已离线用户,现场很尴尬。从那以后我每次交付前都强制走一遍断线重连和会话清理的检查,希望帮到你。
本文还有配套的精品资源,点击获取