简介:基于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 6,8.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> <groupId>javax.websocket</groupId> <artifactId>javax.websocket-api</artifactId> <version>1.1</version> <scope>provided</scope> </dependency>这里必须注意版本对齐:Tomcat 8.5 自带的是 WebSocket 1.1 规范,你本地 lib 里的 websocket-api.jar 是什么版本,编译期就跟它对齐,不要动不动追新。运行期找不到类时优先检查$CATALINA_BASE/lib下有没有这两个 jar:websocket-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 Map<String, Map<String, Session>> ROOMS = new ConcurrentHashMap<String, Map<String, Session>>(); public static void join(String room, String name, Session session) { // 先取房间,不存在则创建,避免覆盖其他线程刚建好的 room Map<String, Session> inner = ROOMS.get(room); if (inner == null) { inner = new ConcurrentHashMap<String, Session>(); Map<String, Session> old = ROOMS.putIfAbsent(room, inner); if (old != null) { inner = old; } } inner.put(name, session); } public static void leave(String room, String name) { Map<String, Session> inner = ROOMS.get(room); if (inner != null) { inner.remove(name); } } public static Map<String, 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) { Map<String, 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) { Map<String, 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) { List<String> 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,不要手动操作 DOM
ExtJS 最容易被写坏的地方,是收到消息后直接用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 class="chat-item {type}">', '<span class="chat-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?name=user' + 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 秒,避免服务端还在重启时客户端疯狂重连。我自己的习惯是上线前先杀掉一次服务端进程,看客户端能否在重连后恢复消息收发,这个测试通过,才敢交付给业务方。老系统加新能力,最怕的不是功能实现,而是没人验证过断网恢复,聊天室尤其如此,希望帮到你。
本文还有配套的精品资源,点击获取