ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Java 7+Tomcat 8.5+ExtJS:老系统接入WebSocket聊天室实战

2026/10/7 3:13:58 拓冰建站 浏览量
Java 7+Tomcat 8.5+ExtJS:老系统接入WebSocket聊天室实战 简介基于Tomcat8、Java7与ExtJS的WebSocket聊天室实现是一份可直接导入Eclipse运行的Java Web源码工程适合有Java基础、想系统学习WebSocket实时通信和ExtJS富客户端集成的开发者。包内共1753个文件1359个gif动图与114个png图片多用于界面演示和主题资源158个scss与34个css负责样式50个js实现客户端逻辑11个jar及6个class、3个java则提供服务端依赖与WebSocket端点源码整体压缩包仅7.01MB体积小巧且结构完整。工程覆盖了javax.websocket注解式端点、onOpen/onMessage/onClose生命周期回调、客户端Ext.util.WebSocket封装、用户登录与会话消息广播等关键代码并包含jsp入口与Eclipse工程配置方便对照调试和二次开发。对于想理解WebSocket协议在Java Web中的实际落地、或需要参考旧版本Tomcat/Java兼容方案的开发人员这份资源能够提供从服务端API选择到客户端集成、再到部署运维的全链路示例。目前已有262人学习下载对即时通讯练习和历史项目维护也具有不错的借鉴价值。1. 老系统加实时消息这个技术栈组合反而是最稳的一条路很多人的第一反应是这都什么年代了还在用 Java 7 和 Tomcat 8但手头维护过老系统的人才会懂业务代码跑在 JDK 1.7 上升级 JDK 的成本远超功能本身。这个标题要解决的正是这种场景下最简单也最常用的诉求——在不升级 Java 版本的前提下给老系统塞进一个 WebSocket 聊天室。Tomcat 8 是最后一批还能跑在 Java 7 上的容器它原生实现了 JSR-356 的 WebSocket 规范意味着你不用引入第三方推送框架前后端各写一个端点就能双向通信。搭配 ExtJS聊天窗口与后台管理界面风格统一不用额外引一堆前端依赖。适合正在维护遗留 OA、内部管理系统同时又想加即时通讯模块的团队直接照着做。2. 先把运行时边界摸清Tomcat8 的 WebSocket 实现在 Java7 下能走多远2.1 Tomcat 8 是 Java7 最后能用的 JSR-356 容器先说版本结论。Tomcat 8 分 8.0 和 8.5 两条线8.0 最低要求 Java 68.5 最低要求 Java 7也就是说标题里的 Java 7 跑在 8.5 上是官方支持的行为而到 Tomcat 9 就必须 Java 8 起步。如果你正在维护一个 JDK 1.7 的旧系统又想加实时消息Tomcat 8.5 就是最后一个不需要动 JDK 的选择。另一个要确认的点是 WebSocket 的 API 归属。Tomcat 8 原生实现了 JSR-356也就是 javax.websocket 这套注解式 API客户端和容器之间走的是 RFC 6455 协议。它同时还保留了一套 Tomcat 私有的 WebSocketServlet 接口但我不建议碰后者。原因有两个一是私有 API 换版本就可能变二是网上能搜到的资料大部分已经默认走 JSR-356你照着抄也少踩坑。对标题这个技术栈直接用 ServerEndpoint 注解就够了不需要再引入 Spring 那套 STOMP 协议栈。把这一节吃透后面所有代码其实都是围绕 JSR-356 在写。这样做的另一个好处是哪天你换成 Jetty 9 或 GlassFish只要容器支持 JSR-356聊天室的 ServerEndpoint 代码基本不用改后续可迁移性比绑定 Tomcat 私有 API 好得多。2.2 先写一个最小的 EchoEndpoint验证容器和编译级别都没问题我习惯在动手前先不碰业务逻辑先写一个 Echo 端点把整条链路跑通再说package com.demo.ws; 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(/echo) public class EchoEndpoint { OnOpen public void onOpen(Session session) { System.out.println(connected: session.getId()); // 30 秒内没有消息就断开0 表示不限制 session.setMaxIdleTimeout(30000); } OnMessage public String onMessage(String text, Session session) { // 直接返回字符串容器会把它作为一条完整消息发回给客户端 return echo: text; } OnClose public void onClose(Session session, CloseReason reason) { System.out.println(closed: reason.getCloseCode()); } OnError public void onError(Session session, Throwable t) { System.out.println(endpoint error: t.getMessage()); } }代码逻辑不复杂但有几个参数值得说清楚。ServerEndpoint(/echo)声明的是 WebSocket 访问路径拼上项目部署上下文后客户端连接地址是ws://ip:8080/项目名/echo。onOpen里的setMaxIdleTimeout(30000)表示 30 秒没有消息就自动关闭这是排查后面“老掉线”问题的关键参数。onMessage返回 String 是一种简写等价于手动调用session.getBasicRemote().sendText(...)适合做回显测试。把编译好的 class 放到 WEB-INF/classes或打成 jar 丢进 WEB-INF/lib启动 Tomcat 8.5。浏览器就是最好的测试客户端打开任意一个页面在开发者工具 Console 里执行var ws new WebSocket(ws:// location.host /你的项目名/echo); ws.onmessage function(evt) { console.log(收到: evt.data); }; ws.onopen function() { ws.send(hello); };这里最常见的翻车点是路径。Eclipse WTP 或 IDEA 里给项目设置的 application context 如果不等于项目目录名手写路径就会 404。我一般先访问http://localhost:8080/项目实际路径/echo看是不是有响应再拼 WebSocket 地址少走弯路。2.3 依赖怎么带容器提供的类项目里别再塞一份有人会在 pom.xml 里把 javax.websocket-api 加进来结果出现 NoClassDefFoundError 或版本冲突。常见做法是编译期用 provided 作用域引用运行期完全依赖 Tomcat 自带实现。Maven 下这样配dependency groupIdjavax.websocket/groupId artifactIdjavax.websocket-api/artifactId version1.1/version scopeprovided/scope /dependency这里必须注意版本对齐Tomcat 8.5 自带的是 WebSocket 1.1 规范你本地 lib 里的 websocket-api.jar 是什么版本编译期就跟它对齐不要动不动追新。运行期找不到类时优先检查$CATALINA_BASE/lib下有没有这两个 jarwebsocket-api.jar和tomcat-websocket.jar。缺任何一个部署时都会抛“Unable to instantiate Endpoint”。还有一点直接关系到标题里的 Java 7确认 maven-compiler-plugin 的 source 和 target 都被锁成 1.7。很多项目被 IDEA 悄悄把编译级别改到 1.8代码里写个 lambda编译不报错一部署到生产就抛 UnsupportedClassVersionError。这个坑和 Tomcat 无关纯粹是 Java 7 的边界但最容易在接手老项目时踩到。3. 照着搭聊天室后端Java7 的 Session 管理、广播和私聊3.1 用 ConcurrentHashMap 维护房间在线表聊天室的核心是会话管理。每个 WebSocket 连接对应一个 Session 对象但 Session 由容器管理你得自己维护“谁在哪个房间”这个映射。Java 7 没有 var也没有 lambda老老实实写 ConcurrentHashMap 反而是最清晰的做法package com.demo.chat; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import javax.websocket.Session; public class RoomRegistry { private static final MapString, MapString, Session ROOMS new ConcurrentHashMapString, MapString, Session(); public static void join(String room, String name, Session session) { // 先取房间不存在则创建避免覆盖其他线程刚建好的 room MapString, Session inner ROOMS.get(room); if (inner null) { inner new ConcurrentHashMapString, Session(); MapString, Session old ROOMS.putIfAbsent(room, inner); if (old ! null) { inner old; } } inner.put(name, session); } public static void leave(String room, String name) { MapString, Session inner ROOMS.get(room); if (inner ! null) { inner.remove(name); } } public static MapString, Session roomOf(String room) { return ROOMS.get(room); } }这里的putIfAbsent是一个必须记住的细节。如果先containsKey再put两个线程同时进第一个房间时可能互相覆盖导致一个房间只剩一个用户。用putIfAbsent加旧值判空是 Java 7 时代最标准的并发初始化写法。ConcurrentHashMap保证了单次读写线程安全但“先查后写”的组合操作仍然需要这个防御逻辑。3.2 群聊广播与私聊发送两条消息通道的 Java 实现有了注册表聊天端点就薄了。逻辑集中在三类消息进入房间的系统广播、群聊消息、私聊消息。我习惯在 OnMessage 里先解析成一个小对象再分流处理package com.demo.chat; import java.io.IOException; import java.util.List; import java.util.Map; import javax.websocket.OnClose; import javax.websocket.OnError; import javax.websocket.OnMessage; import javax.websocket.OnOpen; import javax.websocket.Session; import javax.websocket.server.PathParam; import javax.websocket.server.ServerEndpoint; ServerEndpoint(/chat/{room}) public class ChatEndpoint { private String room; private String name; OnOpen public void onOpen(Session session, PathParam(room) String room) { this.room room; this.name firstParam(session, name); RoomRegistry.join(room, name, session); // 先给房间里所有人广播一条系统消息 broadcast(room, chat, system, name 进入房间); } OnMessage public void onMessage(String json, Session session) { // json 形如{type:chat,target:用户名,content:文本} try { ChatMessage msg ChatMessage.parse(json); if (chat.equals(msg.type)) { broadcast(room, chat, name, msg.content); } else if (private.equals(msg.type)) { sendTo(room, msg.target, private, name, msg.content); } else if (ping.equals(msg.type)) { // 心跳只回给本人不广播 sendTo(room, name, pong, system, pong); } } catch (Exception e) { sendTo(room, name, error, system, 消息格式不正确); } } OnClose public void onClose(Session session) { RoomRegistry.leave(room, name); } OnError public void onError(Session session, Throwable t) { RoomRegistry.leave(room, name); } private void broadcast(String room, String type, String from, String content) { MapString, Session sessions RoomRegistry.roomOf(room); if (sessions null) { return; } String payload json(type, from, content, null); for (Session s : sessions.values()) { sendToSession(s, payload); } } private void sendTo(String room, String target, String type, String from, String content) { MapString, Session sessions RoomRegistry.roomOf(room); if (sessions null) { return; } Session targetSession sessions.get(target); if (targetSession ! null targetSession.isOpen()) { sendToSession(targetSession, json(type, from, content, target)); } } private void sendToSession(Session s, String payload) { // 同一会话的发送通道加锁避免并发写被拆包 synchronized (s) { try { s.getBasicRemote().sendText(payload); } catch (IOException e) { System.out.println(send failure: s.getId()); } } } private String firstParam(Session session, String key) { ListString values session.getRequestParameterMap().get(key); return (values null || values.isEmpty()) ? guest- session.getId() : values.get(0); } }这套设计里broadcast是全房间遍历发送sendTo是定向发送sendToSession是最终的发送出口。把发送动作收敛到一个方法里后面要加统一格式、打日志、统计上线时长都只改一处。ChatMessage.parse是实际项目里用 Gson 或手写 JSON 解析的小工具类字段就是type、target、content三个。这里还有一个容易被忽略的参数session.isOpen()。从注册表里拿到的 Session 可能已经因为断网被容器清理直接 sendText 会抛 IllegalStateException。我见过不少线上日志被这个异常刷屏加一个 isOpen 判断能省很多事。3.3 getBasicRemote 与并发写入发送通道不能裸奔WebSocket 的 Session 不是线程安全的这个结论必须刻在脑子里。同一个房间 100 个用户同时发消息广播逻辑会从多个 Tomcat 工作线程同时调用同一个 Session 的 sendText。如果不对发送加锁轻则消息顺序错乱重则抛IllegalStateException: The remote endpoint was in state [TEXT_PARTIAL_WRITING]。我在sendToSession里用synchronized (s)锁住 Session 对象是最简单可靠的方案。不要用 ConcurrentHashMap 包裹 Session 就以为安全了那只保证了 map 的读写不保证 Session 内的发送原子性。如果你更喜欢异步发送可以用session.getAsyncRemote().sendText(...)它在规范层面要求实现必须串行化消息但不同容器的排队策略不一样Tomcat 下我还是更信任显式锁。4. ExtJS 端把 WebSocket 接进来封装客户端单例与聊天面板4.1 为什么这里不用 Ext.Ajax而是用原生 WebSocket 对象很多从传统 ExtJS 管理模式转过来的同学第一反应是用Ext.Ajax.request定时轮询后端接口。轮询能实现功能但聊天室对消息延迟敏感轮询做不到真正的服务端推送而且每个用户每隔几秒就发一次 HTTP 请求几百人在线时对 Tomcat 的压力非常可观。WebSocket 是一条长连接服务端有消息主动往客户端推没有消息时连接基本零开销。ExtJS 的Ext.data.Connection和Ext.Ajax都是为 HTTP 请求设计的底层没有 WebSocket 的封装所以前端的正确做法是直接用浏览器原生WebSocket对象再用 ExtJS 的观察者模式把它包装成全局可用的单例。这样聊天窗口、在线列表、消息提醒各模块都能订阅同一份消息流而不是各开各的连接。4.2 封装一个聊天页直接调用的 ExtJS 单例我一般会建一个名为IM.WsClient的单例类把连接、断开、发送、心跳集中处理Ext.define(IM.WsClient, { singleton: true, mixins: { observable: Ext.util.Observable }, connected: false, wsClient: null, constructor: function() { var me this; me.mixins.observable.constructor.call(me); }, connect: function(url) { var me this; me.wsClient new WebSocket(url); me.wsClient.onopen function() { me.connected true; me.fireEvent(open); }; me.wsClient.onmessage function(evt) { // 统一在这里解析 JSON避免每个页面各写一遍 var data Ext.JSON.decode(evt.data); me.fireEvent(message, data); me.fireEvent(message: data.type, data); }; me.wsClient.onclose function(evt) { me.connected false; me.fireEvent(close, evt); }; me.wsClient.onerror function() { me.fireEvent(error); }; }, sendJson: function(obj) { var me this; if (!me.connected || !me.wsClient) { return false; } me.wsClient.send(Ext.JSON.encode(obj)); return true; }, close: function() { var me this; if (me.wsClient) { me.connected false; me.wsClient.close(1000, logout); } } });这个封装有两个关键设计。第一onmessage里把消息拆成了message:chat和message:private这样带类型的事件业务页面只需要监听自己关心的事件类型不用每条消息都自己判断。第二sendJson统一做 JSON 编码前端业务代码只传对象不碰字符串拼接。连接地址通常这样拼放在登录成功后的回调里var url (location.protocol https: ? wss:// : ws://) location.host location.pathname chat?name encodeURIComponent(userName); IM.WsClient.connect(url);注意location.pathname带上项目部署路径和后端ServerEndpoint(/chat/{room})拼起来正好是完整的 WebSocket 地址。用户名放在查询参数里后端通过session.getRequestParameterMap().get(name)就能拿到比在消息体里再传一遍用户名字段更可靠。4.3 消息列表和在线人数联动数据交给 Store不要手动操作 DOMExtJS 最容易被写坏的地方是收到消息后直接用Ext.getCmp找到 Panel再update()一段拼好的 HTML。这样当时能跑但消息一多页面性能直线下降样式也难维护。我通常让聊天面板内部维护一个Ext.data.Store收到 WebSocket 消息就往 store 里 add 一条记录界面由 DataView 或 Grid 自动刷新Ext.define(IM.view.ChatPanel, { extend: Ext.panel.Panel, layout: border, initComponent: function() { var me this; me.messageStore Ext.create(Ext.data.Store, { fields: [from, type, content, time] }); me.messageList Ext.create(Ext.view.View, { tpl: new Ext.XTemplate( tpl for., div classchat-item {type}, span classchat-time{time}/span, b{from}/b: {content}, /div, /tpl ), store: me.messageStore, itemSelector: div.chat-item }); IM.WsClient.on(message:chat, function(data) { me.messageStore.add(data); }); IM.WsClient.on(message:presence, function(data) { me.onlineCountLabel.setText(在线 data.count 人); }); Ext.apply(me, { items: [{ region: center, border: false, autoScroll: true, items: [me.messageList] }, { region: south, height: 60, layout: hbox, items: [me.inputField, me.sendButton] }] }); me.callParent(); } });关键点在事件绑定时机。IM.WsClient.on(message:chat, ...)必须在connect()之前注册否则可能出现“连接建立成功服务端广播了消息但监听器还没挂上”的空窗期。这个坑我踩过不止一次表现在前端就是“进房间后最初几条消息丢失”。后来我统一在页面 initComponent 阶段注册监听再在按钮事件里触发 connect顺序稳定下来。5. 避坑Tomcat8 Java7 WebSocket 聊天室最容易翻车的 5 个现场5.1 握手 404路径没对上连接在第一步就死掉现象浏览器控制台报WebSocket connection to ws://localhost:8080/xxx/echo failed: Error during WebSocket handshake: Unexpected response code: 404服务端日志没有任何连接进入的记录。原因WebSocket 握手本质是 HTTP Upgrade找不到目标路径就返回 404。最常见的情况是前端拼的地址少了项目部署上下文或者ServerEndpoint(/chat/{room})里的路径和实际访问路径不一致。还有一种隐蔽情况WebSocket 端点的 class 没有被容器扫描到比如放在 jar 里但 jar 没有放进 WEB-INF/lib。解决先用浏览器直接访问http://localhost:8080/项目名/chat/room1如果返回 404 之外的任何内容说明 WebSocket 端点已部署再用开发者工具 Network 面板看 WebSocket 请求的实际 URL和后端注解路径逐字符对比。我处理这类问题时习惯在 onOpen 里打一行日志看到日志就说明路径问题已排除问题转移到了握手后的逻辑。5.2 每隔一分钟掉线空闲超时是 WebSocket 的黑匣子现象聊天室页面挂在那里不动一分钟后再次发消息毫无反应刷新页面又恢复但过一分钟又断。原因Tomcat 对 WebSocket 会话默认有 60 秒空闲超时也就是 60 秒内没有任何消息往来就主动关闭连接这是容器层面为了防止僵尸连接占资源的行为。客户端界面静止不操作服务端也没有系统消息就正好触发这个阈值。解决客户端每 30 秒发一条{type:ping}服务端在OnMessage里识别到 ping 只给本人回pong不广播给其他人。同时在服务端OnOpen里调用session.setMaxIdleTimeout(0)关闭空闲检测。注意不要只改服务端因为中间任何一层网络设备都可能有自己的空闲超时有规律的心跳是最稳的保活手段。5.3 启动报 NoClassDefFoundError容器类被打进项目里了现象Tomcat 启动或应用部署时报NoClassDefFoundError: javax/websocket/Session但代码里明明已经引入了依赖。原因Tomcat 8 的 WebSocket 实现由websocket-api.jar和tomcat-websocket.jar提供这两个 jar 位于 Tomcat 的 lib 目录。如果项目把相同包名的 jar 放进了 WEB-INF/lib或者 Maven 依赖里用了 compile 作用域引了 version 1.1 的 websocket-api运行期就会产生类加载冲突轻则 NoClassDefFoundError重则直接启动失败。解决把 WEB-INF/lib 下所有websocket-api*.jar、tomcat-websocket*.jar删掉Maven 依赖改成 provided 作用域让容器统一加载。用 Java 7 的老项目尤其要注意不要为了省事用 IDE 的“Add as Library”把 Tomcat 目录下的 jar 直接拷进项目换服务器环境就会原形毕露。5.4 ExtJS 端收不到消息监听器注册顺序和 JSON 解析都可能是元凶现象服务端日志显示用户进房间、广播都正常但界面上消息列表一动不动也没有任何报错。原因第一事件监听注册在connect()之后连接握手成功期间服务端可能已经推送了离线消息或系统广播这些消息被单例对象接收后直接丢弃。第二Ext.JSON.decode遇到消息内容里带特殊字符比如用户输入的 JSON 字符串里包含未转义的双引号解析失败后整个 onmessage 回调中断。解决强制约定监听器先注册、后 connect。对 JSON 解析加 try/catch解析失败时把原始字符串记录到 console不要静默吞掉。这里有个血泪经验聊天内容里经常出现表情符号和引号前端Ext.JSON.encode自己对内容做转义没问题但服务端拼 JSON 时容易忽略转义最好在 Java 端用 Gson 或 Jackson 序列化而不是手写字符串拼接。5.5 消息顺序错乱或互相拼接同一个 Session 的发送通道被并发写了现象聊天室里两条消息内容叠在一起或者其中一条只剩半截服务端日志里偶尔出现IllegalStateException: The remote endpoint was in state [TEXT_PARTIAL_WRITING]。原因房间内用户多了以后广播逻辑从多个线程同时往同一个 Session 上调用sendText。这些调用落到同一个 TCP 连接上消息边界就会错乱。Tomcat 8 的 WebSocket 实现不会为你在 Session 上自动加锁。解决在sendToSession中用synchronized (s)把发送操作串行化并在发送前判断s.isOpen()。这个锁粒度很小只锁当前 Session 的发送动作不会影响其他 Session 的并发。如果你追求更高吞吐可以用getAsyncRemote().sendText加发送队列但对聊天室这种轻量场景显式加锁简单且足够。6. 收尾前再做三件事聊天室才敢交付上岗聊天的核心功能跑通后我建议把验证重点放在连接稳定性和异常恢复上而不是界面好不好看。第一件事用 Chrome 开发者工具看帧。打开 Network 面板筛选 WS连接后能看到每一帧的发送与接收。重点确认两件事握手时返回的 HTTP 状态码是 101而不是 200 或 404心跳发送后服务端能在几百毫秒内回 pong 帧。如果帧面板里看不到 pong说明服务端的OnMessage没有正确处理 ping 消息。第二件事用命令行工具做多连接压测。Node 环境里可以用ws模块或者直接用浏览器开多个标签页同时连看服务端在线人数是否正确累加。我在小项目中会写一个循环脚本开 20 条连接同时发消息观察有没有报错和乱序var WebSocket require(ws); for (var i 0; i 20; i) { var ws new WebSocket(ws://localhost:8080/chat/demo?nameuser i); ws.on(open, function() { this.send(JSON.stringify({ type: chat, content: hello i })); }); }压测时盯住两个点一是聊天室在线列表人数是否等于连接数二是所有连接同时发消息时服务端日志有没有IOException大量冒出来。第三件事断线重连必须写。真实场景里用户不会乖乖在办公室待着休眠、切网、关闭笔记本都会让 WebSocket 断开。我在IM.WsClient的 close 事件里加了一个带重试上限的自动重连逻辑var retryCount 0; function startWs() { IM.WsClient.connect(url); IM.WsClient.on(close, function() { var delay Math.min(30000, 1000 * Math.pow(2, retryCount)); retryCount; setTimeout(startWs, delay); }); } IM.WsClient.on(open, function() { retryCount 0; });指数退避算法前几次重连间隔是 1 秒、2 秒、4 秒上限 30 秒避免服务端还在重启时客户端疯狂重连。我自己的习惯是上线前先杀掉一次服务端进程看客户端能否在重连后恢复消息收发这个测试通过才敢交付给业务方。老系统加新能力最怕的不是功能实现而是没人验证过断网恢复聊天室尤其如此希望帮到你。本文还有配套的精品资源点击获取