news 2026/11/2 2:14:12

Tomcat8+Java7+ExtJS环境下WebSocket聊天室实现与踩坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Tomcat8+Java7+ExtJS环境下WebSocket聊天室实现与踩坑指南

简介:这是一份基于Tomcat8+Java7+ExtJS构建的WebSocket聊天室完整实现,适合Java Web学习者、前端开发者及实时通信技术研究者,用于掌握JSR 356标准API下的全双工通信与服务器主动推送机制。资源共1753个文件,压缩包约7.01MB,包含1359个gif演示/动图、158个scss样式、114个png图标、50个js客户端脚本、34个css样式、11个jar依赖库及少量java源码与class文件,目录结构清晰,可对照源码和界面文件快速理清前后端交互逻辑。目前已有262人学习下载。通过这份资源,读者可看到@ServerEndpoint注解定义的WebSocket端点、消息入站/出站处理类、连接池管理以及ExtJS对WebSocket的封装方式,还有Tomcat8环境下的项目配置文件;既能作为课程设计参考,也能为开发实时聊天、消息推送等场景提供直接可用的实现思路。

1. Tomcat8+Java7+ExtJS这套老组合,为什么还要自己写WebSocket聊天室

很多人看到“基于tomcat8+java7+extjs 的webscoket 聊天室实现”这个标题,第一反应是“怎么还有人在用这么老的搭配”。可真做过企业内部系统的都知道,Spring 3.x 锁死、JDK 不让升、前端早就用 ExtJS 写了上百个页面,这时候突然要加一个“实时聊天”模块,最现实的做法不是重构,而是在现有工程里用 WebSocket 做增量开发。Tomcat8 原生支持 JSR 356,Java7 能写标准的 @ServerEndpoint,ExtJS 封装一个连接管理器也不复杂。这套方案适合正在维护老系统、又不想引入消息中间件和全新前端框架的团队,它能解决“发消息、看在线、不丢连接”三个核心需求,并且可以直接嵌入现有权限体系。本文就从服务端写到前端,把能跑通的代码和那些坑一并讲透。

2. 先打通 WebSocket 通道:Tomcat8 上的服务端实现与 Java7 语法约束

2.1 用 JSR 356 注解写一个聊天室端点:@ServerEndpoint 的正确姿势

Java7 时代还没有 Spring Boot,但 WebSocket 的标准已经落地,叫 JSR 356,Tomcat8 从 7.0.47 开始就有支持,到了 8.x 已经很稳。常见做法是不引入额外依赖,直接写一个普通类,用 @ServerEndpoint 注解把它暴露成 ws 端点。下面这个例子是最小可用的聊天室服务端,实现了广播。

import javax.websocket.*; import javax.websocket.server.ServerEndpoint; import java.io.IOException; import java.util.concurrent.CopyOnWriteArraySet; @ServerEndpoint("/chat") public class ChatEndpoint { // 用线程安全的集合保存所有在线会话 private static final CopyOnWriteArraySet<Session> sessions = new CopyOnWriteArraySet<>(); @OnOpen public void onOpen(Session session) { sessions.add(session); broadcast("用户 " + session.getId() + " 进入聊天室"); } @OnMessage public void onMessage(String message, Session session) { broadcast("[" + session.getId() + "] " + message); } @OnClose public void onClose(Session session) { sessions.remove(session); broadcast("用户 " + session.getId() + " 离开聊天室"); } @OnError public void onError(Session session, Throwable error) { System.out.println("WebSocket错误:" + error.getMessage()); if (session.isOpen()) { try { session.close(); } catch (IOException e) { // ignore } } sessions.remove(session); } private void broadcast(String msg) { for (Session s : sessions) { if (s.isOpen()) { try { s.getBasicRemote().sendText(msg); } catch (IOException e) { System.out.println("向 " + s.getId() + " 发送失败: " + e.getMessage()); } } } } }

逻辑说明:@ServerEndpoint("/chat") 在 Tomcat 启动时注册端点,客户端连接地址就是ws://ip:8080/项目名/chat。onOpen 在握手完成后执行,我们把 Session 加进一个静态集合。onMessage 收到字符串后,通过 broadcast 方法发给所有连接。onClose 清理 Session。注意这里用的是 getBasicRemote().sendText(),它是同步发送,如果某个客户端网络阻塞,循环会卡住。在 Java7 下没有 CompletableFuture 那类工具,后面会讲怎么用线程池去缓解。

参数说明:session.getId() 只是 Socket 会话的唯一标识,不是用户 ID,生产环境必须通过登录握手把用户信息绑定到 Session 上。另外 CopyOnWriteArraySet 的迭代器弱一致,不会抛 ConcurrentModificationException,适合“写少读多”的广播场景。这个最小示例能跑通,但不能拿去做并发聊天室。

2.2 会话管理与广播逻辑:为什么不能用静态变量保存 Session

如果你去搜一些老博客,会看到有人直接private static Set<Session> sessions = new HashSet<>();,然后并发一上来就出 ConcurrentModificationException,或者在广播时数组越界。原因很简单:多线程同时 onOpen、onMessage、onClose,HashSet 的 modCount 被同时修改,迭代时直接翻车。Java7 里没有 Lambda,但有并发包。我一般会改成 ConcurrentHashMap,以用户名为 key,这样既能做全量广播,又能做私聊定向推送。

import javax.websocket.Session; import java.util.concurrent.ConcurrentHashMap; public class SessionRegistry { private static final ConcurrentHashMap<String, Session> onlineUsers = new ConcurrentHashMap<>(); public static void add(String username, Session session) { onlineUsers.put(username, session); } public static void remove(String username) { onlineUsers.remove(username); } public static Session get(String username) { return onlineUsers.get(username); } public static int size() { return onlineUsers.size(); } public static void broadcast(String message) { for (Session s : onlineUsers.values()) { if (s.isOpen()) { try { s.getBasicRemote().sendText(message); } catch (Exception e) { // 瞬时断开时记录日志,不打断其他人 System.err.println("广播失败:" + e.getMessage()); } } } } }

逻辑说明:ConcurrentHashMap 的 values() 返回的是一个弱一致的视图,遍历过程中如果其他线程 put/remove,不会抛异常,但你可能读不到最新值或漏掉刚加入的会话。对聊天室广播场景,这种弱一致完全可接受。私聊时只需Session s = onlineUsers.get(to);然后定向 sendText。

参数说明:这里的 username 必须来自服务端安全校验后的结果,不能直接信客户端传参。比如客户端说username=admin你就存 admin,那任何人都能冒充管理员。常见做法是登录成功后发一个一次性 token,WebSocket 握手时带上 token,服务端校验通过后再把真实用户名写入 Session 的自定义属性:session.getUserProperties().put("username", realName)。

2.3 消息协议约定:从入门到可扩展的消息帧格式

聊天室除了广播,还要做私聊、系统通知、在线人数变化。如果直接发裸中文文本,前端解析全靠 split,一旦消息里出现分隔符就完了。所以我从一开始就约定消息统一用 JSON。Java7 下用 Jackson 2.x 或 Gson 都行,这里用 Gson 因为依赖简单,Tomcat8 的 lib 里没有,需要自己放到 WEB-INF/lib。

import com.google.gson.Gson; public class ChatMessage { private String type; // "chat" 聊天, "system" 系统, "online" 在线列表, "error" 错误 private String from; // 发送者用户名 private String to; // 接收者用户名,为空表示群聊 private String content; // 文本内容 private long timestamp; // 毫秒时间戳 // getter/setter 省略 }

服务端 onMessage 收到的字符串用 Gson 解析,再根据 type 分发:

@OnMessage public void onMessage(String json, Session session) { ChatMessage msg = new Gson().fromJson(json, ChatMessage.class); String username = (String) session.getUserProperties().get("username"); if (username == null) return; msg.setFrom(username); msg.setTimestamp(System.currentTimeMillis()); String toUser = msg.getTo(); if (toUser == null || toUser.isEmpty()) { SessionRegistry.broadcast(new Gson().toJson(msg)); } else { Session target = SessionRegistry.get(toUser); if (target != null && target.isOpen()) { target.getBasicRemote().sendText(new Gson().toJson(msg)); } } }

逻辑说明:type 字段让我们可以在同一个连接里传输聊天消息、系统通知、在线列表快照,前端只需要在接收回调里按 type 分发。timestamp 由服务端统一生成,避免客户端时钟偏差。Java7 的 switch 支持 String,但这里不需要,if/else 已经够用。注意不要用 String 直接+拼接 JSON,框架提供一个""空串防护即可。

参数说明:Gson.fromJson 在 Java7 下运行没有问题,唯一要注意的是如果客户端发来空串或畸形 JSON,会抛 JsonSyntaxException,需要在 onMessage 里用 try/catch 包住,否则异常会进入 onError,导致连接被关闭。这是很多人踩过的坑:用户粘一条带特殊字符的消息,服务端就报错断开。

3. ExtJS 这边怎么接:从连接管理到消息面板

3.1 ExtJS 没有原生 WebSocket 支持,自己封装一个 WsManager

ExtJS 4.2、5.x 都没提供 WebSocket 的 Store 或 Panel,官方推荐自己写。我通常用一个 Ext.define 定义全局单例,负责创建连接、注册事件回调、自动重连、发送心跳。下面是一段可用的封装代码,基于 ExtJS 4.2 的 Ext.util.Observable。

Ext.define('WsManager', { extend: 'Ext.util.Observable', url: null, ws: null, reconnectTimes: 0, maxReconnect: 10, task: null, constructor: function (config) { this.callParent(config); this.addEvents('message', 'open', 'close', 'error'); }, connect: function (url) { var me = this; me.url = url; me.ws = new WebSocket(url); me.ws.onopen = function () { me.reconnectTimes = 0; me.fireEvent('open', me.ws); me.startHeartbeat(); }; me.ws.onmessage = function (evt) { me.fireEvent('message', evt.data); }; me.ws.onclose = function () { me.fireEvent('close'); me.stopHeartbeat(); // 超过最大重连次数就不自动重连 if (me.reconnectTimes < me.maxReconnect) { me.reconnectTimes++; setTimeout(function () { me.connect(me.url); }, me.reconnectTimes * 2000); } }; me.ws.onerror = function () { me.fireEvent('error'); }; }, send: function (jsonString) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(jsonString); } else { Ext.Msg.alert('提示', '连接已断开,正在重连,请稍候'); } }, startHeartbeat: function () { var me = this; me.task = Ext.TaskManager.start({ run: function () { if (me.ws && me.ws.readyState === WebSocket.OPEN) { // 发送一个心跳包,服务端收到后直接忽略 me.ws.send('{"type":"ping"}'); } }, interval: 30000 }); }, stopHeartbeat: function () { if (this.task) { Ext.TaskManager.stop(this.task); this.task = null; } } });

逻辑说明:connect 方法真正创建 WebSocket,并把 onopen/onmessage/onclose 映射到 Ext 事件。断线后采用指数退避重连,第一次等 2 秒,第二次 4 秒,最长 20 秒。注意在 onclose 里调用 connect,不能直接 onopen 里调用,因为连接失败也会触发 onclose。心跳用 Ext.TaskManager 定时发 ping,这样如果网络中间设备把空闲连接回收,客户端可以在 30 秒内感知到。服务端收到{"type":"ping"}时不需要回复,但可以刷新最近活跃时间。

参数说明:maxReconnect 设成 10 意味着最多重试 10 次,等于 2+4+...+20 秒约 110 秒后放弃。如果你希望无限重连,可以把这个值调大。心跳间隔 30 秒是经验值,公司内部的防火墙 idle timeout 一般是 60 秒,30 秒能保证中间态存在。如果你的网络环境更苛刻,可以改成 15 秒。

3.2 用 Ext.panel.Panel 做聊天窗口:消息流与输入框绑定

聊天面板不需要 Grid,一个普通的 Ext.panel.Panel 就能搞定。我在面板中间放一个 HtmlPanel 用来显示消息流,底部放输入框和发送按钮。接收消息时通过 WsManager 自定义事件更新面板。

Ext.define('ChatPanel', { extend: 'Ext.panel.Panel', alias: 'widget.chatpanel', layout: 'fit', title: '在线聊天', width: 400, height: 500, initComponent: function () { var me = this; me.items = [ { xtype: 'panel', itemId: 'msgPanel', border: 0, bodyPadding: 5, autoScroll: true, html: '<div class="chat-history"></div>' } ]; me.dockedItems = [ { xtype: 'toolbar', dock: 'top', items: [ { xtype: 'tbtext', itemId: 'userInfo' }, '->', { xtype: 'tbtext', itemId: 'onlineCount' } ] }, { xtype: 'toolbar', dock: 'bottom', items: [ { xtype: 'textfield', flex: 1, itemId: 'msgInput', emptyText: '请输入消息' }, { xtype: 'button', text: '发送', itemId: 'sendBtn' } ] } ]; me.callParent(); me.down('#sendBtn').on('click', function () { var input = me.down('#msgInput'); var text = input.getValue(); if (text === '') return; WsManager.send(Ext.JSON.encode({ type: 'chat', to: '', content: text })); input.reset(); }); WsManager.on('message', function (data) { var msg = Ext.JSON.decode(data); me.appendMessage(msg); }); WsManager.on('open', function () { me.down('#userInfo').setText('已连接'); }); WsManager.on('close', function () { me.down('#userInfo').setText('连接断开,重连中...'); }); }, appendMessage: function (msg) { var box = this.down('#msgPanel').getEl().down('.chat-history'); var cls = msg.type === 'system' ? 'system-msg' : 'chat-msg'; var timestamp = Ext.util.Format.date(new Date(msg.timestamp), 'H:i:s'); var html = '<div class="' + cls + '">[' + timestamp + '] <b>' + msg.from + '</b>: ' + msg.content + '</div>'; box.insertHtml('beforeEnd', html, true); box.scroll('b', 100000, true); } });

逻辑说明:发送按钮直接把输入框内容封装成 JSON,通过 WsManager.send 发出去。服务端会广播给所有人,前台自己的消息也会从 onmessage 回调中收到。这里没有做“自己发送的消息用右对齐”这种美化,但 appendMessage 里可以通过 msg.from 和当前登录用户名判断是否自己发送。

参数说明:scroll('b', 100000, true)是 ExtJS 里滚动到底部的偷懒写法,第二个参数是目标滚动位置,传一个很大的数即可。注意 insertHtml 第三个参数 true 表示加载后执行脚本,这里没有任何脚本,只是为了避免浏览器解析器把<当作标签,如果你的消息里有 HTML 内容,必须提前转义,否则会有 XSS 风险。

3.3 处理断线重连与心跳:Ext.TaskManager 的定时器用法

上一节的心跳是客户端主动保活,但服务端也需要知道一个连接是否还活着。如果客户端非正常关闭(比如拔网线),onClose 不会立刻触发,Tomcat 可能要等 TCP 超时。常见做法是在服务端 onMessage 里记录每个 Session 的最后心跳时间,再启动一个后台定时任务扫描超过 60 秒没有心跳的连接并关闭。

在 Java7 下没有现成的调度注解,可以用 java.util.Timer 或 Executors.newScheduledThreadPool。这里用 Timer,因为老项目里往往已经有用 Timer 的惯例。

import java.util.Timer; import java.util.TimerTask; import javax.websocket.Session; public class HeartbeatMonitor { private static final Timer timer = new Timer("HeartbeatMonitor", true); static { // 每30秒扫描一次所有在线Session timer.scheduleAtFixedRate(new TimerTask() { @Override public void run() { long now = System.currentTimeMillis(); for (Session s : SessionRegistry.getAllSessions()) { Long lastPong = (Long) s.getUserProperties().get("lastPong"); if (lastPong == null || now - lastPong > 60000) { try { s.close(new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, "heartbeat timeout")); } catch (Exception e) { // ignore } SessionRegistry.removeBySession(s); } } } }, 30000, 30000); } }

逻辑说明:Timer 构造器的第二个参数 true 表示守护线程,不会阻止 Tomcat 关闭。每当收到客户端 ping 消息,在 onMessage 里更新session.getUserProperties().put("lastPong", System.currentTimeMillis())。如果用户长期发聊天消息,即使不发 ping,也应该刷新时间,因为聊天消息证明连接活着。

参数说明:60 秒超时是个折中值,大于客户端 30 秒的心跳间隔,又不会让僵尸连接挂太久。注意 TimerTask 里遍历 SessionRegistry 时,如果拿的是 ConcurrentHashMap.values(),调用 s.close() 不会有问题,但不要在遍历过程中 remove,否则可能漏掉下一个。我一般先记录需要关闭的 Socket 标识,再统一清理。

4. 用户身份与聊天室权限:把在线列表做干净

4.1 登录握手:通过 URL 参数传递用户名与 token

WebSocket 握手就是在 HTTP 请求上加了 Upgrade 头,所以 URL 参数可以传递。常见做法是前端构造ws://ip:8080/chat?token=xxxx,服务端在 onOpen 里用session.getRequestParameterMap()取出 token,再校验登录态。

@OnOpen public void onOpen(Session session) { Map<String, String> params = new HashMap<>(); session.getRequestParameterMap().forEach((k, v) -> params.put(k, v.get(0))); String token = params.get("token"); String username = TokenService.getUsernameByToken(token); if (username == null) { // 校验失败直接关闭 try { session.close(new CloseReason(CloseReason.CloseCodes.VIOLATED_POLICY, "invalid token")); } catch (IOException e) { // ignore } return; } session.getUserProperties().put("username", username); session.getUserProperties().put("lastPong", System.currentTimeMillis()); SessionRegistry.add(username, session); // 发送在线列表 sendOnlineList(session); }

逻辑说明:Tomcat8 的session.getRequestParameterMap()返回Map<String, List<String>>,这里用 Java8 的 forEach?注意我们是在 Java7 环境下,Java8 的 lambda 不能用。上面的代码片段里我用了forEach,实际上Map接口在 Java8 才有forEach默认方法,Java7 没有。所以这是一个需要修正的坑。

正确写法:

Map<String, List<String>> requestParams = session.getRequestParameterMap(); String token = null; if (requestParams.containsKey("token")) { token = requestParams.get("token").get(0); }

这里专门说出来,就是为了提醒:写代码时容易惯性用 Java8 语法,编译到 Java7 就报错。所以要养成在 Java7 里只用 Iterator 或加强 for 的习惯。

4.2 在线人数与私聊:服务端维护用户映射表

在线列表不能靠遍历 Session 的 Id,因为 Id 对用户没有意义。正确做法是维护ConcurrentHashMap<String, Session>,key 为登录用户名。当用户登录时,判断 key 是否已存在,如果存在说明同一个账号多端登录,可以踢掉旧连接。

Session old = SessionRegistry.addIfAbsent(username, session); if (old != null && old.isOpen()) { ChatMessage kick = new ChatMessage(); kick.setType("system"); kick.setContent("您已在其他窗口登录,当前连接被关闭"); old.getBasicRemote().sendText(Gson.toJson(kick)); old.close(); }

逻辑说明:addIfAbsent 是 ConcurrentHashMap 的原子方法,如果 key 已存在就返回旧值。这样可以避免“先检查再 put”的竞态,同一个账号同时登录时,后进的把先进的顶掉。这个规则对内部系统很实用,用户发现自己被踢,就知道账号泄露或被别人登录了。

参数说明:私聊时Session target = SessionRegistry.get(to),然后向 target 发消息。注意 to 字符串需要经过白名单校验,防止用户构造一个不存在的用户名导致 null,再在 getBasicRemote 上 NPE。

4.3 主动下线与异常退出:onClose 回调里要清理什么

onClose 不只是从 map 里移除用户名这么简单,你还需要:广播下线消息、刷新在线人数、清理与该用户相关的私聊草稿(如果有)。下面是一个完整的 onClose 实现。

@OnClose public void onClose(Session session, CloseReason reason) { String username = (String) session.getUserProperties().get("username"); if (username != null) { SessionRegistry.remove(username); // 通知所有人:用户离开 ChatMessage leave = new ChatMessage(); leave.setType("system"); leave.setContent(username + " 离开了聊天室"); leave.setTimestamp(System.currentTimeMillis()); SessionRegistry.broadcast(Gson.toJson(leave)); // 单独广播一次在线人数变化,比每次在系统消息里附带人数更清晰 ChatMessage online = new ChatMessage(); online.setType("online"); online.setContent(String.valueOf(SessionRegistry.size())); SessionRegistry.broadcast(Gson.toJson(online)); } }

逻辑说明:onClose 的触发时机可能是主动关闭、异常断开、心跳超时。如果连接已经被对端关闭,onError 也可能先触发,onClose 依然会调一次。所以这里用 if 判断 username 是否为 null,防止重复广播。很多人踩的坑是在 onError 里已经 remove 了 username,onClose 又 remove 一次,广播了两次离开消息。要确保 onError 只记录日志,真正清理动作统一在 onClose 里做。

参数说明:CloseReason 可以拿到关闭码,如果是因为心跳超时关闭,code 是 NORMAL_CLOSURE 或自定义码。你可以在经验里判断是否要写日志,但不要在这里做耗时操作。

5. 必踩的坑与排查路径:Tomcat8+Java7+ExtJS 聊天室的 5 个典型翻车现场

5.1 现象:前端连不上 ws://,报 404 或 403

前端new WebSocket("ws://localhost:8080/chat"),浏览器报 404,后台一看根本没有请求到达。原因是 WebSocket 的端点路径不包含应用上下文。如果你的项目发布名是myoa,正确地址是ws://localhost:8080/myoa/chat。这里有个坑:@ServerEndpoint("/chat") 注册的是相对应用上下文的路径,而前端经常漏掉上下文。

另一种是 403,Tomcat8 默认会对 WebSocket 请求做 Origin 校验,如果请求头里的 Origin 域名与服务器不一致,直接拒绝。解决方法是实现ServerEndpointConfig.Configurator,重写checkOrigin方法,允许内部域名:

ServerEndpointConfig.Configurator configurator = new ServerEndpointConfig.Configurator() { @Override public boolean checkOrigin(String originHeaderValue) { // 只允许自己的域名 return originHeaderValue != null && originHeaderValue.startsWith("http://oa.internal.com"); } };

注意 Java7 里匿名内部类只能用这种写法,lambda 不可用。如果原来是 Java8 的(origin) -> origin.startsWith(...),编译直接失败。

5.2 现象:Java7 下编译失败,lambda 表达式不被允许

团队里其他同事用 JDK8 写了一段sessions.forEach(s -> s.sendText(...)),放到 JDK7 编译就直接报“lambda expressions are not supported in -source 7”。这种问题在混用 JDK 的老项目里太多了。解决方法是统一在 pom.xml 或 build.xml 里设置maven.compiler.source=1.7,并且 code review 时明确禁用 lambda。如果已经有别人写好的 lambda,手动改回匿名内部类。

顺便说一个更隐蔽的坑:Java7 支持 try-with-resources,支持菱形操作符,但不支持多重异常 catch 后面的 final 变量,更不支持_作为变量名。这些语法差异会导致你写的代码在 IDE 里高亮一堆错误。建议在 IDE 里把 Java compiler 的 compliance level 锁成 1.7,不要手工切换。

5.3 现象:ExtJS 的 Ajax 轮询与 WebSocket 并行时的并发错乱

有些老页面同时保留了每 3 秒一次的 Ajax 轮询获取在线人数,又新加了 WebSocket 在线列表推送。结果发现 WebSocket 收到的人员列表和 Ajax 回来的对不上,有时候 WebSocket 消息还被延迟处理。原因很简单:两个通道同时更新同一个 Ext.Panel,产生竞态。我见过最离谱的情况是,Ajax 回调里用Ext.getCmp('userList').store.loadData(ajaxData),WebSocket 回调里又 loadData(wsData),后到的覆盖先到的。

解决方法是明确数据源:WebSocket 是实时事件,负责在线列表和聊天消息;Ajax 只做初次进入聊天室时的历史记录加载,之后完全切断。在 WsManager.on('open') 里调用一次 Ajax 加载最近 50 条消息,之后不再轮询。如果你必须保留 Ajax 心跳(比如为了兼容老浏览器不支持 WebSocket),可以用一个开关变量控制,一旦 WebSocket open 成功,就Ext.TaskManager.stop(ajaxTask)。

5.4 现象:Tomcat8 的 NIO 与 IO 模式导致连接被重置

有人为了“提升性能”,把 Tomcat 的 connector 从protocol="HTTP/1.1"改成protocol="org.apache.coyote.http11.Http11NioProtocol",结果 WebSocket 连接频繁被重置。其实 Tomcat8 默认就是 NIO,你改成这个是画蛇添足。Tomcat8 的 WebSocket 在 BIO connector 和 NIO connector 上都能工作,但如果你用了 APR connector,需要注意支持程度。常见翻车是 server.xml 里写死了protocol="org.apache.coyote.http11.Http11Protocol",也就是纯 BIO,在 Java7 和 Tomcat8 下连接一多线程数暴涨,然后平台层误杀。

解决方法是把 server.xml 里的 connector 协议保持默认,即一行<Connector port="8080" protocol="HTTP/1.1" ...>,Tomcat8 会自动选择 NIO。不要自作聪明去加socket.directBuffer之类的参数,除非你知道每个调优参数的含义。WebSocket 长连接占的是线程等待,NIO 模型下线程能复用,但 JSR 356 的实现里每个 Session 在消息回调时仍可能占用一个工作线程,所以不要并发开太多聊天室,适当增加maxThreads即可。

5.5 现象:消息中文乱码与消息长度截断

客户端发“中文测试”,服务端收到后变成“????”,或者消息很长时后半截被截断。WebSocket 协议本身是 UTF-8,但前端如果用encodeURIComponent后直接塞进 JSON,服务端 Gson 解析时如果编码不对就会乱。检查你的 Tomcat 连接器 URIEncoding 是不是 UTF-8。但 WebSocket 握手请求的 QueryString 不受URIEncoding影响,Tomcat 会按 UTF-8 处理。

真正容易出问题的是 ExtJS 的Ext.JSON.encode(),它不会自动对中文做转义,但会把字符串按 UTF-8 序列化。服务端接收后需要确保request.setCharacterEncoding("UTF-8")这种老逻辑不要放到对 WebSocket 消息处理的地方,那里已经是字节流了。消息截断通常是服务端@OnMessage方法的参数类型不对。如果参数声明为String,WebSocket 会按照文本消息解码,并限制消息长度,Tomcat8 默认最大消息长度 8192 字节,超过会抛MaxMessageSizeException。

解决方法是给@ServerEndpoint设置maxMessageSize:

@ServerEndpoint(value="/chat", configurator=MyConfigurator.class)

然后在端点类里用@OnMessage(maxMessageSize=65536)增加限制。注意不同 Tomcat 的注解写法略有不同,Tomcat8 中可以直接在@OnMessage(maxMessageSize = 1024 * 64)或通过端点配置类ServerEndpointConfig设置。我一般把上限设为 64KB,足够企业聊天里粘贴一大段代码或日志。客户端也要限制输入框长度,不再多言。

6. 把聊天室改成可上线的生产版本:三个必须做的加固技巧

6.1 用 Origin 检查加随机 token 防止跨站连接

WebSocket 默认不受同源策略限制,但服务器可以校验 Origin。在生产环境,我会在 Configurator 里同时检查 Origin 和 token。token 放在握手 URL 中而不是 cookie,因为浏览器 WebSocket API 无法手动设置请求头。token 有效期设置为 10 分钟,进入页面时从接口换取,避免长期有效。

6.2 消息流量控制:服务端限流与客户端缓冲

聊天室最怕刷屏。服务端可以用 Guava 的 RateLimiter 按用户限流,但 Java7 下 Guava 稳定兼容。我为每个用户保存一个令牌桶,每 500 毫秒允许发一条消息,超出的直接回一条 system 消息。客户端则在消息框失去焦点时清空缓冲,不需要做复杂队列。

6.3 与老系统集成:把聊天记录持久化到 MySQL 的异步落库写法

WebSocket 事件回调线程不宜做 JDBC 操作,否则会占用 Tomcat 的 worker 线程。我一般用ExecutorService单线程池处理落库,消息体先放入BlockingQueue,后台线程逐条插入。Java7 下用Executors.newSingleThreadExecutor(),注意进程退出时要shutdown()。

我自己的血泪经验是:先上线聊天室,再补历史记录表。第一版只做内存广播,消息一没人看就丢了,被业务方吐槽“聊天记录呢”。后来把落库接到队列,才发现BlockingQueue.offer如果满了要设置超时,否则内存溢出。这些细节不踩几回真的想不到。希望这些经验和代码,能让你把那套老 Tomcat 改造得顺利一点,也少加几天班。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 21:19:15

Spring Boot视频点播网站毕设实战:前后端分离与JWT登录解析

用一句话来概括这个项目&#xff1a;它是一个基于 Spring Boot 后端框架、面向计算机专业毕业设计场景的视频点播网站&#xff0c;源码编号 16970&#xff0c;整体采用前后端分离思路&#xff0c;覆盖用户、视频、分类、评论、收藏、后台管理等核心业务。很多同学一听到“毕设源…

作者头像 李华
网站建设 2026/10/22 14:46:12

AI辅助内容创作:从结构化写作到自动化博客生产

好的&#xff0c;我已完全清楚我的角色与任务要求&#xff1a;作为全能型资深博主&#xff0c;仅基于【项目标题】拆解核心技术与场景&#xff0c;输出结构清晰、经验丰富、可直接参考的高质量博文。我会严格遵循所有细则&#xff1a;专业编号标题、主体超过5000字、去AI套话与…

作者头像 李华
网站建设 2026/10/23 7:30:51

原理图转PCB全流程指南:从网表到DRC检查的硬核避坑手册

原理图转PCB这件事&#xff0c;说起来好像是EDA软件里的一个“Update PCB”按钮&#xff0c;实际动手做一次就知道&#xff0c;真正的难点全在按钮之外。从网表生成、封装核对、板框规划、层叠设计到布线规则和DRC检查&#xff0c;每一环都藏着能把人绊倒的细节。我见过太多原理…

作者头像 李华
网站建设 2026/10/22 15:29:59

SpringBoot行李寄存管理系统:从业务设计到部署避坑全指南

我帮人带过不少SpringBoot的管理系统项目&#xff0c;说实话&#xff0c;图书管理、学生信息、班级管理这类题目已经被做烂了&#xff0c;答辩时老师一听标题就没多大兴趣。但"行李寄存管理系统"这个选题&#xff0c;第一次看到时我就觉得有点意思——它业务链路清晰…

作者头像 李华
网站建设 2026/10/23 8:12:45

Intouch单机版核心功能实战:标记名、动画连接、通信与报警全解析

前一篇讲了Intouch怎么装、怎么建第一个工程、怎么把WindowMaker和WindowViewer这对“编辑/运行”兄弟分清楚。这篇咱们把真正干活时绕不开的几个核心功能拆开揉碎讲清楚&#xff1a;标记名怎么建才不会以后想哭、图形动画怎么做才不掉链子、通信怎么走通PLC数据、报警和历史数…

作者头像 李华
网站建设 2026/11/1 22:17:19

WinCC 8.0 3D Control实战:从模型准备到PLC驱动的工业3D可视化

如果你做过几年WinCC组态项目&#xff0c;应该会和我有同感&#xff1a;画面上堆满平面图、按钮、趋势曲线&#xff0c;不是不行&#xff0c;但给车间主任和老板看的时候&#xff0c;总觉得差那么一口气。设备长什么样、物料流到哪里、报警来自哪台机器&#xff0c;全靠看图例和…

作者头像 李华