Tomcat8+Java7+ExtJS原生WebSocket聊天室拆解与避坑

发布时间:2026/10/23 19:35:37
Tomcat8+Java7+ExtJS原生WebSocket聊天室拆解与避坑

简介:面向Java Web初学者与进阶开发者的WebSocket聊天室实战源码,基于Tomcat8、Java7与ExtJS实现实时双向通信,可用于理解JSR 356端点、连接生命周期与消息广播机制,也适合课程设计或项目练手。压缩包共1753个文件,约7.01MB,以gif、scss、png、js、css等前端资源为主,辅以jar、class、java、xml、jsp等部署文件,覆盖服务端、客户端及样式素材。已有263人学习下载。读者可梳理@ServerEndpoint的onOpen、onMessage、onClose流程,参考ExtJS搭建聊天窗口,并结合登录、消息收发、历史记录展示理解连接管理与广播逻辑;还可留意通信加密、并发连接与日志调试等工程要点,便于迁移实时通信能力。

1. 一个连不上的聊天室,反而值得拆

有位同行把一套基于 Tomcat8 + Java7 + ExtJS 的 WebSocket 聊天室源码发给我,说页面一直提示「连接已关闭」,日志里却干干净净。我看了十分钟就明白,问题不在代码,而在环境:Tomcat 8 自带的 JSR-356 实现、Java 7 的编译目标、ExtJS 压根没有原生 WebSocket 封装,这三件事哪怕有一件没对齐,都连不上。这套源码的价值恰恰在这里——它没有 Spring、没有 Netty、没有 STOMP,只有一个@ServerEndpoint注解和一个 ExtJS 面板,把「浏览器到 Servlet 容器」这条长连接链路剥得只剩骨架。

它适合两类人:一类是想搞明白 WebSocket 在 Servlet 容器里到底被谁接管、生命周期怎么走的;另一类是手上有老 JDK7 环境、短期不可能升级,却必须加实时推送的。想拿 Spring 全家桶抄近路的,这篇可以关掉。

2. 环境先立住:Tomcat8 的 WebSocket 到底由谁负责

先把一件事说清楚:Tomcat 8 里的 WebSocket 不是第三方库,是容器自己实现的 JSR-356(WebSocket 1.1)。这意味着不用引任何框架,只要WEB-INF/web.xml是 3.0 版本、注解扫描没被关掉,容器就会在启动时把带@ServerEndpoint的类注册成端点。很多人第一次跑不起来,就是因为metadata-complete="true"——注解全被忽略,端点根本没注册,握手自然 404。

2.1 三种实现方案的取舍:为什么这套源码选了容器原生实现

先把选型摆出来,省得后面反复纠结。三种做法都能实现「浏览器 ↔ 服务端」的实时消息,但代价完全不同。

方案运行时依赖端口前端要做的事适合场景
容器原生 JSR-356Tomcat 自带websocket-api.jar+tomcat-websocket.jar与 Web 同端口自己封装原生WebSocket老 JDK、对新增 jar 敏感
Spring WebSocketspring-websocket、spring-messaging同端口需 STOMP/SockJS 客户端已有 Spring 4 工程
Netty 独立服务netty-all独立端口自己定协议高并发、要和 Web 分离部署

选第一种的理由很实在:Java 7 环境下,Spring 4 能跑,但一套聊天室为了几十个在线用户引一堆 jar,运维上不划算;独立端口方案又要多开防火墙规则。同端口、零新增依赖,是这套源码最硬的优点。

代价也得认:没有 STOMP 那种订阅/目的地模型,房间、私聊、心跳全靠自己写;没有 SockJS 降级(降级指浏览器不支持 WebSocket 时自动改用其他传输方式),老浏览器直接不支持。所以它适合内网、可控浏览器环境。

2.2 三个 jar 的边界:编译期和运行期必须分开

这是最容易踩的一个坑。Tomcat 8 的lib目录下有两个关键 jar:

  • websocket-api.jar:JSR-356 的接口定义(javax.websocket.*),编译时需要。
  • tomcat-websocket.jar:Tomcat 的实现,运行时由容器提供。

正确的做法是:编译时把websocket-api.jar放进 classpath,打包时绝对不要把它复制进WEB-INF/lib。一旦进去了,类加载器会先加载WEB-INF/lib里那份,和容器实现里的javax.websocket.*版本对不上,典型报错是NoClassDefFoundError: javax/websocket/Endpoint或者Provider ... not found,而且报错位置莫名其妙,血泪经验。

Web 工程目录大致长这样:

chatroom/ ├── src/main/java/com/example/chat/ │ ├── ws/ChatEndpoint.java # @ServerEndpoint │ ├── ws/ChatConfigurator.java # 握手期注入 HttpSession │ ├── ws/SessionRegistry.java # 房间与成员表 │ └── model/Msg.java # 消息体 ├── src/main/webapp/ │ ├── WEB-INF/web.xml # 必须是 3.0 │ ├── app.js # ExtJS 应用入口 │ └── app/view/ChatPanel.js └── lib/gson.jar # 2.8 系列,Java 7 可跑

web.xml只需要保证版本和扫描开关,别写多余的东西:

<?xml version="1.0" encoding="UTF-8"?> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_0.xsd" version="3.0" metadata-complete="false"> <display-name>chatroom</display-name> </web-app>

version="3.0"是 Servlet 3.0 的底线,Tomcat 8 支持到 3.1,写 3.0 没问题;metadata-complete="false"才是关键,少了这一行等于告诉容器「注解别扫了」。

编译打包用一条命令就够,注意-source/-target 1.7:

# 编译:websocket-api 只进 classpath,不进 war javac -encoding UTF-8 -source 1.7 -target 1.7 \ -cp "$TOMCAT_HOME/lib/websocket-api.jar:$TOMCAT_HOME/lib/servlet-api.jar:lib/gson.jar" \ -d build/classes $(find src/main/java -name '*.java') # 打包:WEB-INF/lib 里只放业务依赖 mkdir -p build/war/WEB-INF/lib build/war/WEB-INF/classes cp -r src/main/webapp/* build/war/ cp -r build/classes/* build/war/WEB-INF/classes/ cp lib/gson.jar build/war/WEB-INF/lib/ cd build/war && jar -cvf ../../chatroom.war .

参数说明:-source 1.7 -target 1.7保证不会误用 Java 8 的 lambda 或java.util.function,否则在 Java 7 运行时会直接UnsupportedClassVersionError;classpath 里不含tomcat-websocket.jar,因为实现类在运行时由容器加载,编译期看到接口就够。

2.3 先用 echo 打通链路,再谈业务

别一上来就跑完整的聊天室。先写一个最小的端点,确认容器这一层通了:

package com.example.chat.ws; import javax.websocket.*; import javax.websocket.server.ServerEndpoint; import java.io.IOException; @ServerEndpoint("/echo") public class EchoEndpoint { @OnOpen public void onOpen(Session session) { System.out.println("open: " + session.getId()); } // 返回值会被容器自动发回对端,等价于 session.getBasicRemote().sendText(...) @OnMessage public String onMessage(String message) { return "echo:" + message; } @OnClose public void onClose(Session session, CloseReason reason) { System.out.println("close: " + session.getId() + " -> " + reason.getCloseCode()); } }

逻辑说明:@OnMessage返回值非 void 时,容器负责回写,这比自己调getBasicRemote()省事,也避开了并发写问题;@OnClose里打印getCloseCode()是排查断连的第一手证据,1006 是异常断开(没收到 close 帧),1000 是正常关闭。

前端三行验证,在浏览器控制台直接跑:

var ws = new WebSocket('ws://127.0.0.1:8080/chatroom/echo'); ws.onopen = function () { ws.send('hello'); }; ws.onmessage = function (e) { console.log(e.data); }; // 期望 echo:hello ws.onclose = function (e) { console.log('closed', e.code, e.reason); };

URL 的拼法是ws://host:port/{contextPath}/{@ServerEndpoint的value}。contextPath 是 war 包名,部署成chatroom.war就是/chatroom,放进ROOT就没有这一段——这一点在后面接 ExtJS 时会反复咬人。@ServerEndpoint的 value 必须以/开头,写成chat容器启动就报IllegalArgumentException。

3. 服务端拆解:会话的生命周期与三个必须自己写的类

容器只帮你做三件事:握手、编解码、回调。剩下的房间管理、在线表、心跳、异常清理,一行都不会替你写。这套源码服务端其实就三个类:端点本身、握手配置器、消息体。

3.1 @OnOpen 里该存什么:Session、路径参数与握手信息

@OnOpen是唯一能拿到「这次连接是谁」的时机。路径参数用@PathParam取,HTTP 会话信息要通过ServerEndpointConfig.Configurator的modifyHandshake提前塞进userProperties,否则端点是拿不到HttpSession的——这是 JSR-356 的设计,端点实例和 HTTP 请求不是一条线。

package com.example.chat.ws; import javax.servlet.http.HttpSession; import javax.websocket.HandshakeResponse; import javax.websocket.server.HandshakeRequest; import javax.websocket.server.ServerEndpointConfig; public class ChatConfigurator extends ServerEndpointConfig.Configurator { @Override public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) { HttpSession httpSession = (HttpSession) request.getHttpSession(); // 登录态校验就放这里,校验不过直接不塞,端点里判空后关闭 if (httpSession != null) { sec.getUserProperties().put("httpSession", httpSession); sec.getUserProperties().put("loginUser", httpSession.getAttribute("uid")); } // 记录 Origin,便于后面做来源白名单 sec.getUserProperties().put("origin", request.getHeaders().get("Origin")); } }

参数说明:modifyHandshake在握手完成前调用,可以改响应头,也可以拒绝——但标准接口里没有「直接返回 403」的入口,实际做法是塞一个标记进去,在@OnOpen里发现标记不对就session.close(new CloseReason(...))。request.getHttpSession()可能为 null(比如前端没带 Cookie),必须判空,不然握手期就抛 NPE,前端只看到连接失败,日志里一行栈都找不到,非常难查。

端点实例的注册,用ConcurrentHashMap两层结构:外层房间,内层用户。用putIfAbsent而不是put,避免并发建房间时互相覆盖:

// 房间 -> (用户 -> 端点实例) private static final ConcurrentMap<String, ConcurrentMap<String, ChatEndpoint>> ROOMS = new ConcurrentHashMap<String, ConcurrentMap<String, ChatEndpoint>>(); @OnOpen public void onOpen(Session session, EndpointConfig config, @PathParam("room") String room, @PathParam("user") String user) throws IOException { this.session = session; this.room = room; this.user = user; // 先设缓冲区:默认 8KB,超过会抛「消息过大」而不是截断 session.setMaxTextMessageBufferSize(64 * 1024); // 关掉容器空闲踢人,改由自己的心跳判定,方便统一日志 session.setMaxIdleTimeout(0); HttpSession hs = (HttpSession) config.getUserProperties().get("httpSession"); if (hs == null) { session.close(new CloseReason(CloseReason.CloseCodes.VIOLATED_POLICY, "no-session")); return; } ConcurrentMap<String, ChatEndpoint> members = ROOMS.get(room); if (members == null) { members = new ConcurrentHashMap<String, ChatEndpoint>(); ConcurrentMap<String, ChatEndpoint> exist = ROOMS.putIfAbsent(room, members); if (exist != null) { members = exist; } } ChatEndpoint old = members.put(user, this); // 同一账号重复登录,踢掉旧的 if (old != null && old != this) { closeQuietly(old.session, "kick-by-relogin"); } lastActive = System.currentTimeMillis(); sendText("{\"type\":\"sys\",\"content\":\"welcome\"}"); }

这里有个容易忽略的细节:session.setMaxTextMessageBufferSize()必须在收第一条消息之前调用,写在@OnMessage里就晚了,因为缓冲区在连接建立时就初始化了。

3.2 消息路由:并发写与同步 Remote 的并发问题

Session.getBasicRemote()返回的是同步发送端,它不是线程安全的。两个线程同时对同一个 session 调sendText,第二个会直接抛:

java.lang.IllegalStateException: The remote endpoint was in state [TEXT_FULL_WRITING]

这个异常只在广播场景出现——一个人发消息,服务端循环发给 N 个人,其中某个人的 socket 缓冲区还满着,就炸了。而它抛在循环里,会把整个广播打断,后面的人收不到。

解决办法有两个。稳妥的是给每个 session 加锁:

// 所有发送统一走这里,绝不直接调 session.getBasicRemote() void sendText(String text) { if (session == null || !session.isOpen()) { return; } synchronized (session) { // 锁粒度是单个连接,不影响别人 try { session.getBasicRemote().sendText(text); lastActive = System.currentTimeMillis(); } catch (IOException e) { closeQuietly(session, "send-failed"); // 写失败说明连接已废,立刻清 } } } private void closeQuietly(Session s, String reason) { if (s == null) { return; } try { s.close(new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, reason)); } catch (IOException ignore) { // 已经断了,忽略 } }

另一种是getAsyncRemote()加一个发送队列,好处是不阻塞业务线程,坏处是要处理队列堆积和Future回调,Java 7 下写起来啰嗦。在线人数几十的场景,同步加锁更划算,锁的是单连接,不构成全局瓶颈。

广播本身很短:

private void broadcast(String json) { ConcurrentMap<String, ChatEndpoint> members = ROOMS.get(room); if (members == null) { return; } for (ChatEndpoint ep : members.values()) { ep.sendText(json); // 弱一致性迭代,边遍历边删不会抛 ConcurrentModification } }

消息体用 Gson 统一编解码,别用字符串拼 JSON——中文和引号一转义就出事:

public class Msg { private String type; // chat / sys / ping / pong / error private String from; private String to; // 私聊时填,群发留空 private String content; private long time; public String toJson() { return GSON.toJson(this); } public static Msg parse(String s) { return GSON.fromJson(s, Msg.class); } }

字段含义:type决定前端怎么渲染,to为空表示群发,time用服务端时间避免客户端时钟不一致。Gson 反序列化时字段缺失是允许的,所以老客户端发来的少字段消息不会直接崩,但type缺失要在@OnMessage里主动拒绝,否则前端拿到undefined什么都不渲染,看着像「消息丢了」。

@OnMessage里还要做一件事:所有异常自己吃掉。解析失败别往外抛,抛出去容器会直接关连接,前端看到的是莫名其妙的断开:

@OnMessage public void onMessage(String raw, Session session) { lastActive = System.currentTimeMillis(); if ("ping".equals(raw)) { // 心跳走裸字符串,省一次 JSON 解析 sendText("{\"type\":\"pong\"}"); return; } Msg msg; try { msg = Msg.parse(raw); } catch (Exception e) { sendText("{\"type\":\"error\",\"content\":\"bad-format\"}"); return; } if (msg.getType() == null) { return; } msg.setFrom(user); msg.setTime(System.currentTimeMillis()); broadcast(msg.toJson()); }

注意@OnMessage的线程模型:同一个 Session 的消息是串行投递的,不同 Session 之间是并发的。所以this.user这类实例字段不用加锁,但ROOMS这种静态共享结构必须用并发容器。

3.3 心跳与 @OnClose:Session 泄漏都从哪来

用户直接关掉标签页、拔网线、笔记本合盖,浏览器不一定发 close 帧。这时候@OnClose不触发,端点实例就一直躺在ROOMS里,内存缓慢上涨,广播时还会往死连接上写。

三个出口必须全部清理,缺一不可:

回调触发条件是否必然触发
@OnClose对端发 close 帧 / 容器主动关否
@OnError读写异常视情况
心跳超时扫描服务端自己判断是(兜底)

清理逻辑要幂等,用remove(key, value)两参数版本,防止把重新登录后的新实例删掉:

private void remove() { ConcurrentMap<String, ChatEndpoint> members = ROOMS.get(room); if (members != null) { members.remove(user, this); // 只有还是自己才删 if (members.isEmpty()) { ROOMS.remove(room, members); } } } @OnClose public void onClose(Session session, CloseReason reason) { remove(); } @OnError public void onError(Session session, Throwable t) { remove(); closeQuietly(session, "error"); }

兜底扫描用一个ServletContextListener起单线程调度器,别用java.util.Timer——Timer 里抛未捕获异常会整个线程死掉,心跳就静默停了,这种故障最难发现:

ScheduledExecutorService sweeper = Executors.newSingleThreadScheduledExecutor(); sweeper.scheduleAtFixedRate(new Runnable() { public void run() { long now = System.currentTimeMillis(); for (ConcurrentMap<String, ChatEndpoint> members : ROOMS.values()) { for (ChatEndpoint ep : members.values()) { // 45 秒没动静就踢,前端心跳间隔必须小于这个值 if (now - ep.getLastActive() > 45000L) { ep.closeQuietly(ep.getSession(), "idle-timeout"); } } } } }, 30, 30, TimeUnit.SECONDS);

参数怎么定:前端心跳 25 秒,服务端阈值 45 秒,扫描周期 30 秒。经验公式是「阈值 ≥ 心跳间隔 × 1.5,扫描周期 ≤ 阈值 / 2」。心跳间隔太小会白耗流量,太大则掉线感知慢——微信那类产品的心跳在 4 分钟量级,内网聊天室 25 秒已经足够。

4. ExtJS 接 WebSocket:把原生 API 包成可复用组件

ExtJS 从 4.x 到 6.x 都没有内置 WebSocket 支持,Ext.data.proxy.Proxy只做 HTTP 请求-响应,塞不进长连接。所以前端这层必须自己封装一个Ext.ux.WebSocket,否则每个页面都写一遍new WebSocket(),重连逻辑散落各处。

4.1 放在哪一层:不要挂到 Store 上

ExtJS 的 MVC 里,最容易犯的错是把 socket 当成一个 Store 的数据源,指望它自动刷新列表。这个思路是错的:Store 的模型是「拉取-渲染」,WebSocket 的模型是「推-追加」,硬凑到一起会出现重复渲染和分页冲突。

正确的划分:封装类继承Ext.util.Observable(用 mixin 方式),对外只暴露open / message / close / error四个事件;由 Controller 监听事件,往 Store 里add单条记录,渲染交给 View。这样 socket 的生命周期和视图的生命周期解耦,面板销毁时只销毁 socket,不影响别的模块。

另一个要点:ExtJS 4.2 和 5.x 的config系统行为略有差异,initConfig在 4.2 起可用,5.x 完全一致。如果工程更老(4.1 及以前),得手写 setter/getter,别直接抄下面的代码。

4.2 封装类:重连、心跳、发送队列三件套

Ext.define('Ext.ux.WebSocket', { mixins: { observable: 'Ext.util.Observable' }, config: { url: null, reconnectDelay: 2000, // 首次重连间隔,之后线性退避 maxReconnect: 10, heartbeatInterval: 25000 }, constructor: function (config) { this.initConfig(config); this.mixins.observable.constructor.call(this, config); this.reconnectCount = 0; this.queue = []; // 断线期间的消息 this.closedByUser = false; this.connect(); return this; }, connect: function () { var me = this; if (me.ws && (me.ws.readyState === WebSocket.OPEN || me.ws.readyState === WebSocket.CONNECTING)) { return; } me.ws = new WebSocket(me.getUrl()); me.ws.onopen = function (e) { me.reconnectCount = 0; me.flush(); me.startHeartbeat(); me.fireEvent('open', e); }; me.ws.onmessage = function (e) { // 心跳回包在封装层吃掉,不打扰业务 if (e.data === '{"type":"pong"}') { return; } me.fireEvent('message', e.data, e); }; me.ws.onclose = function (e) { me.stopHeartbeat(); me.fireEvent('close', e); var canRetry = !me.closedByUser && e.code !== 1000 && me.reconnectCount < me.getMaxReconnect(); if (canRetry) { me.reconnectCount++; // 退避:2s、4s、6s……避免服务端重启时被打成雪崩 Ext.defer(me.connect, me.getReconnectDelay() * me.reconnectCount, me); } }; me.ws.onerror = function (e) { me.fireEvent('error', e); }; }, startHeartbeat: function () { var me = this; me.stopHeartbeat(); me.heartbeatTask = Ext.TaskManager.start({ run: function () { if (me.ws && me.ws.readyState === WebSocket.OPEN) { me.ws.send('ping'); } }, interval: me.getHeartbeatInterval() }); }, stopHeartbeat: function () { if (this.heartbeatTask) { Ext.TaskManager.stop(this.heartbeatTask); this.heartbeatTask = null; } }, send: function (data) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(data); return true; } this.queue.push(data); // 断线期间先存着,重连后自动补发 return false; }, flush: function () { while (this.queue.length && this.ws.readyState === WebSocket.OPEN) { this.ws.send(this.queue.shift()); } }, close: function () { this.closedByUser = true; this.stopHeartbeat(); if (this.ws) { this.ws.close(1000, 'client-close'); } } });

逐块说明。initConfig必须在mixins.observable.constructor之前调用,否则事件监听器注册时 config 还没生效;closedByUser这个标记是重连控制的开关,用户主动关的不能重连,否则关不掉。e.code !== 1000判断很关键:正常关闭码不该触发重连,否则「用户点退出」会被自己的重连逻辑拽回来。

退避用delay × reconnectCount而不是固定值,这是防止服务端重启时几百个客户端同一秒涌上来。缺点是重连时间会越来越长,所以maxReconnect设 10 次,超过就提示用户手动刷新。队列queue只在readyState !== OPEN时启用,正常在线时的消息不排队,避免内存无谓增长。

Ext.TaskManager在 ExtJS 4/5 里是全局单例,页面销毁时一定要stopHeartbeat,否则组件没了任务还在跑,浏览器标签页越开越卡。

4.3 聊天面板:URL 拼装、XSS 与滚动到底

组件层要解决三个具体问题:URL 得跟着页面走(协议、主机、contextPath 都不能硬编码)、消息内容必须转义、新消息要自动滚到底。

Ext.define('Chat.view.ChatPanel', { extend: 'Ext.panel.Panel', alias: 'widget.chatpanel', room: 'default', user: null, initComponent: function () { var me = this; me.items = [{ xtype: 'box', itemId: 'msgList', autoEl: { tag: 'div', cls: 'chat-msg-list' }, height: 360, style: 'overflow-y:auto;padding:8px;' }, { xtype: 'textfield', itemId: 'input', anchor: '100%', enableKeyEvents: true, listeners: { keypress: me.onInputKeypress, scope: me } }]; me.callParent(arguments); me.socket = Ext.create('Ext.ux.WebSocket', { url: me.buildUrl(), listeners: { scope: me, open: me.onSocketOpen, message: me.onSocketMessage, close: me.onSocketClose } }); }, buildUrl: function () { var loc = window.location; var proto = loc.protocol === 'https:' ? 'wss://' : 'ws://'; // contextPath 从页面地址里推:/chatroom/app.html -> /chatroom var ctx = loc.pathname.substring(0, loc.pathname.lastIndexOf('/')); return proto + loc.host + ctx + '/chat/' + encodeURIComponent(this.room) + '/' + encodeURIComponent(this.user); }, onSocketMessage: function (raw) { var me = this, msg; try { msg = Ext.JSON.decode(raw, true); } catch (e) { msg = null; } if (!msg || !msg.type) { return; } if (msg.type === 'pong') { return; } var box = me.down('#msgList'); // 一定要 htmlEncode,否则一条 <img onerror=...> 就能打穿页面 box.getEl().insertHtml('beforeEnd', '<div class="chat-line">' + '<b>' + Ext.String.htmlEncode(msg.from || 'system') + '</b> ' + '<span class="time">' + Ext.Date.format(new Date(msg.time || new Date().getTime()), 'H:i:s') + '</span>' + '<div>' + Ext.String.htmlEncode(msg.content || '') + '</div>' + '</div>'); me.scrollToBottom(); }, scrollToBottom: function () { var me = this; // 延迟一拍:insertHtml 之后布局还没算完,立刻取 scrollHeight 会偏小 Ext.defer(function () { var el = me.down('#msgList').getEl().dom; el.scrollTop = el.scrollHeight; }, 30); }, onInputKeypress: function (field, e) { if (e.getKey() !== e.ENTER) { return; } var text = field.getValue(); if (!text) { return; } var ok = this.socket.send(Ext.JSON.encode({ type: 'chat', content: text })); // 返回 false 说明进了队列,提示用户但不清空输入框 if (!ok) { Ext.toast({ html: '连接已断开,消息将在重连后发送', title: '提示' }); } field.setValue(''); } });

参数与逻辑说明:buildUrl里loc.pathname.lastIndexOf('/')取的是页面所在目录,/chatroom/app.html得到/chatroom,/app.html得到空串——这就是部署在 ROOT 时的情况,自动适配,不用改代码。https页面必须用wss://,混用会被浏览器直接拦掉,控制台只报一句Mixed Content,新手经常在这里卡半天。

Ext.JSON.decode(raw, true)第二个参数safe为 true 时解析失败返回 null 而不抛异常,比 try/catch 更贴合 ExtJS 的风格。insertHtml('beforeEnd', ...)是 Ext.dom.Element 的方法,比innerHTML +=高效,因为不重建已有节点。

Ext.defer(..., 30)那 30 毫秒不是玄学:insertHtml后 DOM 已插入但布局未完成,此时scrollHeight拿到的是旧值,滚动会差一行。这是我在一个消息密集的群里发现的——每来一条新消息,视图就往上偏一点,攒够十几条就彻底看不到最新消息了。

5. 避坑排查:五类「连上就断」的真实原因

下面这些都在我拆这套源码时实际撞上过,按现象、原因、解决三段写,方便对着日志直接定位。

5.1 现象:握手一直 404,前端报「连接已关闭」

原因:三种可能,按概率排序——端点类没被扫描到(metadata-complete="true"或类不在WEB-INF/classes下);URL 少写了 contextPath;@ServerEndpoint的 value 没以/开头导致容器启动就跳过注册。

解决:先看 Tomcat 启动日志有没有Server endpoint ... deployed;再把 URL 打印到控制台逐段对;最后检查 war 包里的WEB-INF/classes/com/example/chat/ws/ChatEndpoint.class是否真的存在。用jar -tf chatroom.war | grep Endpoint一秒确认。

5.2 现象:连上后 30~60 秒必断,重连又能连上,循环往复

原因:容器空闲超时在踢人。session.setMaxIdleTimeout(0)没设,或者设了但前端没发心跳。也有一种情况是前置的反向代理(Nginx 之类)的读超时默认 60 秒,比心跳间隔还短。

解决:服务端显式setMaxIdleTimeout(0)交给自己的扫描器管;前端心跳间隔压到 25 秒;如果前面有反向代理,让运维把proxy_read_timeout调到 300 秒以上,并确认Upgrade、Connection这两个请求头是透传的,不是被改写掉的。

5.3 现象:中文正常,但长消息或带 emoji 的直接断开,日志报 buffer 溢出

原因:maxTextMessageBufferSize默认 8KB,超过就抛The decoded text message was too big for the output buffer。这条异常在@OnMessage之前抛出,业务代码里的 try/catch 根本接不住。

解决:在@OnOpen里第一行就session.setMaxTextMessageBufferSize(64 * 1024),同时前端限制单条消息长度。别指望分片——@OnMessage收String时容器已经帮你拼完了,分片只在收Reader或ByteBuffer时才有意义。

5.4 现象:单聊正常,群聊时偶发IllegalStateException: TEXT_FULL_WRITING

原因:多个线程并发写同一个 Session。广播循环里没加锁,或者某处偷偷调了session.getBasicRemote().sendText()绕过了统一出口。

解决:全局搜索getBasicRemote,确保只有sendText方法里出现一次,外面一律走封装;sendText内部synchronized (session)。改完再压一遍,这个异常应该彻底消失。

5.5 现象:部署到服务器后页面正常,但 WebSocket 一直 pending,最终超时

原因:http页面用了ws://但站点已经是https,或者反向代理只转发了 HTTP 没转发 Upgrade 请求。也有一种低级情况:前端把ws://127.0.0.1:8080硬编码进去了,本地能跑,上服务器就指到自己机器。

解决:buildUrl按window.location动态生成,别硬编码;HTTPS 站点必须wss://;代理层确认proxy_http_version 1.1加上Upgrade/Connection的透传配置。这条排查顺序建议固定:先看浏览器 Network 面板里那条 101 有没有出现,101 出现了说明握手成功,问题在业务层;101 没出现,问题在网络和容器配置层。

6. 压测与二次改造:200 个连接能看出什么

这套骨架扛多少人,别靠猜。写个脚本压一遍,比看任何文档都直接。用 Python 起 200 个连接,全部进同一个房间,然后让其中一个发消息,观察服务端 CPU 和其余 199 个连接的接收延迟。

# pip install websocket-client import json, threading, time from websocket import create_connection URL = "ws://127.0.0.1:8080/chatroom/chat/room1/u%d" conns, ready = [], threading.Event() def worker(idx): ws = create_connection(URL % idx, timeout=10) conns.append(ws) if len(conns) == 200: ready.set() try: while True: ws.recv() # 阻塞收,不设超时会一直挂着 except Exception: pass for i in range(200): threading.Thread(target=worker, args=(i,), daemon=True).start() ready.wait(30) t0 = time.time() conns[0].send(json.dumps({"type": "chat", "content": "load-test"})) time.sleep(2) print("broadcast wall time: %.3fs" % (time.time() - t0))

这段脚本只看两件事:广播一轮的实际耗时,以及服务端有没有抛TEXT_FULL_WRITING。判定标准我给个经验值——200 人在线、单条广播耗时超过 200ms 就该动手了,说明同步发送已经堵住回调线程。这时候把sendText里的同步发送换成getAsyncRemote()加单连接队列,或者干脆把广播拆成线程池分发(注意每连接仍需串行)。

真要改造,第一个该加的功能是私聊,改动很小:@OnMessage里判断msg.getTo()非空,就只在目标用户的sendText上发,不走broadcast。

if (msg.getTo() != null && !msg.getTo().isEmpty()) { ChatEndpoint target = members.get(msg.getTo()); if (target != null) { target.sendText(msg.toJson()); } else { sendText("{\"type\":\"error\",\"content\":\"user-offline\"}"); } return; } broadcast(msg.toJson());

第二个该加的是消息落库,但别在@OnMessage里同步写库——一个慢 SQL 会卡住整条连接的消息投递。丢进ArrayBlockingQueue,用单独的消费者批量写,队列满了就丢并计数,比拖垮连接强。

最后说个习惯。这套源码我前后跑了三遍,第一遍栽在websocket-api.jar进了WEB-INF/lib,报错信息完全指不到根因;第二遍栽在@OnMessage里抛异常被容器吞掉,前端只显示断开;第三遍是群聊并发写。从那以后我每次接 WebSocket 项目,都强制走一遍固定顺序:先用 echo 端点确认 101 握手,再看@OnClose的 closeCode 是不是 1000,最后才接业务。这三步走完,八成问题在半小时内就能定位。希望帮到你。

本文还有配套的精品资源,点击获取