简介这是一套基于Tomcat8、Java7与ExtJS构建的WebSocket实时聊天室完整源码面向具备Java Web基础、希望掌握全双工通信开发的进阶学习者。项目以JSR 356标准API为核心服务端通过ServerEndpoint端点处理连接、消息与关闭等生命周期事件客户端借助ExtJS组件搭建输入框、按钮与聊天窗口并实现用户登录、消息广播与历史记录展示等核心逻辑同时涉及SSL/TLS加密、连接池管理与消息缓存等安全与性能优化思路。资源包共1753个文件以1359个gif、158个scss、114个png等前端界面与样式素材为主辅以50个js脚本、34个css样式、11个jar依赖及少量java、class与xml配置压缩后约7.01MB目录结构完整。目前已有262人学习下载适合作为理解WebSocket通信协议、服务端编程与ExtJS客户端集成的实践案例也可用于二次开发与课程设计参考。1. 从一次“古董级”技术栈选型说起这套聊天室源码到底能跑通什么前阵子帮一个做企业内训的朋友看一套老系统的维护问题对方丢过来一个压缩包说“这套聊天室源码在测试环境跑不起来你帮我看看”。打开一看技术栈是 Tomcat 8 Java 7 ExtJS通信层用的是原生 WebSocket。说实话第一反应是这组合有点年头了但仔细翻完代码之后发现它反而是一份很适合拿来理解 WebSocket 全链路落地的最小可用样本——没有 Spring Boot 的自动装配帮你兜底没有 Netty 帮你封装协议细节从握手、会话管理到消息广播全得自己写。这套源码解决的核心问题很明确在一个纯 Java Web 容器里不依赖任何第三方实时通信框架实现一个多人在线聊天室。前端用 ExtJS 做界面和消息渲染后端用javax.websocket标准 API 处理连接生命周期Tomcat 8 作为 Servlet 容器兼 WebSocket 服务端。适合谁看一是需要维护存量老项目的后端二是想搞清楚 WebSocket 在 Servlet 容器里到底怎么跑起来的学习者三是手头有类似内网通信需求、不想引入重型中间件的团队。它不适合什么场景高并发、需要集群横向扩展、需要消息持久化和离线推送的生产级 IM。这套代码的定位是“能跑通、能看懂、能改”不是“能扛住”。下面我按实际拆包和部署的顺序把这份源码的结构、跑起来的步骤、参数怎么调、坑在哪一层层说清楚。2. 拆开源码包目录结构与 WebSocket 服务端核心类2.1 工程目录一览与各模块职责拿到源码包之后先别急着往 IDE 里导。我一般习惯先在命令行下把目录树扫一遍确认构建方式和依赖范围。这套工程的典型结构如下chatroom/ ├── src/ │ └── main/ │ ├── java/ │ │ └── com/example/chat/ │ │ ├── config/ │ │ │ └── WebSocketConfig.java │ │ ├── endpoint/ │ │ │ ├── ChatEndpoint.java │ │ │ └── ChatMessage.java │ │ └── util/ │ │ └── SessionManager.java │ ├── resources/ │ │ └── log4j.properties │ └── webapp/ │ ├── WEB-INF/ │ │ └── web.xml │ ├── extjs/ │ │ ├── ext-all.js │ │ └── resources/ │ └── index.html ├── lib/ │ ├── javax.websocket-api.jar │ └── gson-2.8.9.jar └── pom.xml (或 build.xml)几个关键点ChatEndpoint.java是整个服务端的入口用ServerEndpoint注解声明 WebSocket 端点路径SessionManager.java负责维护在线会话集合通常是ConcurrentHashMap或者CopyOnWriteArraySetChatMessage.java是消息体 POJO用来做 JSON 序列化和反序列化。前端index.html里通过 ExtJS 的组件树搭建聊天窗口再用原生WebSocket对象连到服务端。注意如果源码包里没有pom.xml而是build.xml说明是 Ant 构建的老工程导入 IDEA 时需要手动配置 Artifact 或者转成 Maven 结构否则 Tomcat 部署时会找不到编译输出目录。2.2ServerEndpoint注解与生命周期方法WebSocket 服务端的核心就是这个注解类。它不像 Spring 的Controller那样由容器托管 Bean而是由 WebSocket 容器这里是 Tomcat直接实例化和管理。每个客户端连接对应一个 Endpoint 实例生命周期方法用注解标记ServerEndpoint(value /chat/{roomId}, encoders {MessageEncoder.class}, decoders {MessageDecoder.class}) public class ChatEndpoint { // 每个连接独立持有自己的 session private Session session; private String roomId; OnOpen public void onOpen(Session session, PathParam(roomId) String roomId) { this.session session; this.roomId roomId; // 将当前连接加入房间会话池 SessionManager.add(roomId, this); // 广播“某某上线”系统消息 SessionManager.broadcast(roomId, new ChatMessage(system, 用户加入房间)); } OnMessage public void onMessage(String message, Session session) { // 解析 JSON按消息类型分发 ChatMessage msg ChatMessage.fromJson(message); if (text.equals(msg.getType())) { SessionManager.broadcast(roomId, msg); } } OnClose public void onClose(Session session) { SessionManager.remove(roomId, this); SessionManager.broadcast(roomId, new ChatMessage(system, 用户离开房间)); } OnError public void onError(Session session, Throwable error) { // 连接异常时清理会话避免僵尸连接堆积 SessionManager.remove(roomId, this); error.printStackTrace(); } }逻辑说明OnOpen在握手成功后触发此时session对象已经可用可以拿到session.getId()做唯一标识。OnMessage是消息入口这里用字符串接收再手动解析 JSON而不是直接用 Decoder是为了兼容前端 ExtJS 发过来的格式差异。OnClose和OnError必须都做会话清理否则客户端异常断线时SessionManager里会残留无效 session后续广播时抛IllegalStateException。参数说明value /chat/{roomId}中的{roomId}是路径参数前端连接时写成ws://host:port/chat/room1即可区分不同房间。encoders和decoders是可选的如果消息体简单直接收发字符串更省事。2.3SessionManager的并发容器选型会话管理是这套源码里最容易被改出 bug 的地方。常见做法是用ConcurrentHashMapString, SetChatEndpoint按房间分组每个房间的 Set 用ConcurrentHashMap.newKeySet()创建public class SessionManager { // 房间ID - 该房间内的端点集合 private static final ConcurrentHashMapString, SetChatEndpoint ROOMS new ConcurrentHashMap(); public static void add(String roomId, ChatEndpoint endpoint) { ROOMS.computeIfAbsent(roomId, k - ConcurrentHashMap.newKeySet()) .add(endpoint); } public static void remove(String roomId, ChatEndpoint endpoint) { SetChatEndpoint set ROOMS.get(roomId); if (set ! null) { set.remove(endpoint); if (set.isEmpty()) { ROOMS.remove(roomId); } } } public static void broadcast(String roomId, ChatMessage msg) { SetChatEndpoint set ROOMS.get(roomId); if (set null) return; String json msg.toJson(); for (ChatEndpoint endpoint : set) { try { // 同步发送Tomcat 内部会做缓冲 endpoint.session.getBasicRemote().sendText(json); } catch (IOException e) { // 发送失败说明连接已断移除 set.remove(endpoint); } } } }为什么不用CopyOnWriteArraySet因为广播频率高时写操作会频繁复制整个数组内存开销大。ConcurrentHashMap.newKeySet()底层是ConcurrentHashMap的键视图增删改查都是分段锁或 CAS更适合这种读多写少但写操作也不少的场景。另一个坑是broadcast里遍历时直接调set.remove()在ConcurrentHashMap的键视图里是安全的但如果是HashSet就会抛ConcurrentModificationException。3. 前端 ExtJS 与 WebSocket 的对接消息渲染与心跳保活3.1 ExtJS 组件树搭建聊天面板ExtJS 的写法跟现代前端框架差别很大它是基于配置对象的组件化所有 UI 元素通过Ext.create或者Ext.define声明。聊天室的主界面通常是一个Viewport里面放一个panel作为消息展示区一个form放输入框和发送按钮Ext.define(Chat.view.Main, { extend: Ext.container.Viewport, layout: border, items: [{ region: center, xtype: panel, id: msgPanel, title: 聊天室, autoScroll: true, html: div idmsgList/div }, { region: south, height: 80, xtype: form, items: [{ xtype: textfield, id: msgInput, emptyText: 输入消息..., anchor: 100% }, { xtype: button, text: 发送, handler: function() { var input Ext.getCmp(msgInput); var text input.getValue(); if (text ws ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ type: text, content: text, sender: userName })); input.setValue(); } } }] }] });逻辑说明autoScroll: true让消息面板在内容超出时自动出现滚动条。消息渲染没有用 ExtJS 的grid或dataview而是直接操作innerHTML往#msgList里追加div这样性能更好也避免 ExtJS 组件频繁创建销毁带来的内存问题。发送前检查ws.readyState WebSocket.OPEN是必须的否则连接断开后点发送会静默失败。参数说明userName是页面加载时让用户输入的昵称存在全局变量里。JSON.stringify把消息体序列化成字符串服务端ChatMessage.fromJson按同样结构解析。3.2 WebSocket 连接建立与心跳机制前端连接服务端的代码通常写在index.html的Ext.onReady里var ws null; var heartbeatTimer null; function connect() { var protocol location.protocol https: ? wss: : ws:; var wsUrl protocol // location.host /chat/room1; ws new WebSocket(wsUrl); ws.onopen function() { console.log(WebSocket connected); // 每 30 秒发一次心跳防止中间层断开空闲连接 heartbeatTimer setInterval(function() { if (ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({type: ping})); } }, 30000); }; ws.onmessage function(event) { var msg JSON.parse(event.data); if (msg.type ping) return; // 忽略心跳响应 appendMessage(msg); }; ws.onclose function() { console.log(WebSocket closed, retry in 3s); clearInterval(heartbeatTimer); // 断线重连避免用户手动刷新 setTimeout(connect, 3000); }; ws.onerror function(err) { console.error(WebSocket error, err); ws.close(); }; }逻辑说明心跳的作用是防止 Nginx 或 Tomcat 的connectionTimeout把空闲连接掐掉。Tomcat 8 默认的 WebSocket 空闲超时是 300 秒org.apache.tomcat.websocket.BLOCKING_SEND_TIMEOUT和sessionTimeout相关30 秒发一次 ping 足够安全。onclose里做 3 秒延迟重连避免服务端重启时客户端疯狂重试打满连接数。参数说明location.host自动取当前页面的域名和端口部署到不同环境不用改代码。心跳间隔 30000 毫秒可以根据实际网络环境调整内网可以放到 60 秒公网建议不超过 30 秒。3.3 消息编码与 ExtJS 渲染的配合服务端广播出来的 JSON 结构要和前端appendMessage的解析逻辑对齐。典型的消息体{ type: text, sender: 张三, content: 大家好, timestamp: 1700000000000 }前端渲染时按type区分系统消息和用户消息系统消息用灰色斜体用户消息用正常字体加粗显示昵称function appendMessage(msg) { var msgList document.getElementById(msgList); var div document.createElement(div); if (msg.type system) { div.style.color #999; div.style.fontStyle italic; div.innerText msg.content; } else { var time new Date(msg.timestamp).toLocaleTimeString(); div.innerHTML b msg.sender /b time br msg.content; } msgList.appendChild(div); // 滚动到底部 var panel Ext.getCmp(msgPanel); panel.body.dom.scrollTop panel.body.dom.scrollHeight; }这里有个细节ExtJS 的panel.body.dom才是实际滚动的 DOM 元素直接对panel调scrollTop是无效的。另外innerHTML拼接时如果msg.content里包含 HTML 标签会有 XSS 风险生产环境需要做转义。这套源码作为学习样本没做转义处理改的时候记得补上。4. 部署与联调Tomcat 8 Java 7 环境下的参数配置4.1 Tomcat 8 的 WebSocket 支持与 JDK 版本约束Tomcat 8 从 8.0.x 开始完整支持 JSR-356Java WebSocket 1.0但有个硬性前提JDK 必须是 7 及以上。Java 7 自带的javax.websocketAPI 在rt.jar里没有需要额外引入javax.websocket-api.jar或者直接用 Tomcat 的lib目录下自带的tomcat-websocket.jar和websocket-api.jar。我一般建议把这两个 jar 放到工程的WEB-INF/lib下避免不同 Tomcat 版本之间的类加载冲突。部署方式有两种一是把编译好的war包丢进webapps目录二是直接在 IDE 里配置 Tomcat Server 跑 exploded 模式。后者调试方便改 Java 代码热加载快但改web.xml或注解配置时需要重启。注意Java 7 编译时-source 1.7 -target 1.7但 Tomcat 8 运行时的 JVM 可以是 Java 8这个不冲突。如果编译报ServerEndpoint找不到符号检查javax.websocket-api.jar是否在 classpath 里。4.2web.xml与server.xml的关键配置项web.xml里不需要额外声明 WebSocket 的 Servlet因为ServerEndpoint是注解扫描的。但需要确保metadata-completefalse否则容器会跳过注解扫描web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee version3.1 metadata-completefalse display-nameChatRoom/display-name welcome-file-list welcome-fileindex.html/welcome-file /welcome-file-list /web-appserver.xml里跟 WebSocket 相关的参数主要是Connector的connectionTimeout和maxThreads。默认connectionTimeout20000是 20 秒但 WebSocket 连接建立后不受这个限制受的是org.apache.tomcat.websocket包下的sessionTimeout。可以在context.xml里加Context Parameter nameorg.apache.tomcat.websocket.sessionTimeout value300000 overridefalse/ /Context参数说明sessionTimeout单位是毫秒300000 即 5 分钟。如果前端心跳间隔是 30 秒这个值设 5 分钟足够。设太小会导致正常聊天时连接被容器主动断开设太大则僵尸连接回收慢。4.3 联调时用浏览器控制台和wscat验证部署完成后先用浏览器打开index.htmlF12 看 Network 面板的 WS 标签确认握手返回 101 Switching Protocols。如果返回 404检查ServerEndpoint的路径和web.xml的url-pattern是否冲突如果返回 500看 Tomcat 日志里有没有ClassNotFoundException或NoClassDefFoundError。命令行下可以用wscat快速验证服务端是否正常# 安装 wscat需要 Node.js 环境 npm install -g wscat # 连接服务端 wscat -c ws://localhost:8080/chat/room1 # 连接成功后输入消息观察是否广播回来 {type:text,sender:tester,content:hello}如果wscat能连上但浏览器连不上多半是跨域或者路径问题。WebSocket 没有同源策略限制但 Tomcat 的CORS过滤器如果配置了Access-Control-Allow-Origin可能会拦截握手请求。检查web.xml里有没有不必要的 Filter 映射到/*。5. 避坑排查这套源码跑不起来时先看这五条5.1 现象握手返回 404浏览器控制台报WebSocket connection failed原因ServerEndpoint的 value 路径和实际访问路径不一致。比如注解写的是/chat/{roomId}但前端连的是/chatroom或者 Tomcat 的 context path 不是根路径。解决在浏览器里直接访问http://localhost:8080/chat/room1如果返回 404 说明路径没匹配上。检查server.xml里Context path的配置以及ServerEndpoint是否被 Spring 的Component扫描覆盖导致重复注册。5.2 现象连接建立后立刻断开OnClose被频繁触发原因Tomcat 的sessionTimeout设得太短或者前端没有发心跳中间有 Nginx 之类的反向代理把空闲连接掐了。解决先确认context.xml里的sessionTimeout值再检查前端心跳定时器是否正常启动。如果用了 Nginx需要在nginx.conf里加proxy_read_timeout 300s;和proxy_send_timeout 300s;并且proxy_set_header Upgrade $http_upgrade;和proxy_set_header Connection upgrade;这两行不能少。5.3 现象广播消息时抛IllegalStateException: The remote endpoint was in state [TEXT_FULL_WRITING]原因多个线程同时对同一个session调getBasicRemote().sendText()Tomcat 的BasicRemote不是线程安全的。聊天室里多个用户同时发消息时广播线程池可能并发写同一个连接。解决把getBasicRemote()换成getAsyncRemote()或者对每个 session 的发送操作加同步锁。更稳妥的做法是每个 session 维护一个发送队列单线程消费。这套源码里如果没做处理高并发下必现这个异常。5.4 现象ExtJS 页面加载后一片空白控制台报Ext is not defined原因ext-all.js的路径不对或者 ExtJS 版本和源码里用的 API 不匹配。ExtJS 4 和 6 的组件配置差异很大比如autoScroll在 6 里改成了scrollable。解决确认index.html里script srcextjs/ext-all.js的相对路径正确。如果用的是 ExtJS 6把autoScroll: true改成scrollable: trueExt.getCmp在 6 里仍然可用但推荐用Ext.ComponentQuery.query。5.5 现象Java 7 编译报diamond operator is not supported原因源码里用了 Java 8 的菱形语法new ConcurrentHashMap()但pom.xml或 IDE 的编译级别设的是 1.7。解决要么把编译级别升到 1.8Tomcat 8 支持 Java 8要么把菱形语法改成显式类型声明new ConcurrentHashMapString, SetChatEndpoint()。Java 7 支持菱形语法但仅限于匿名内部类以外的场景如果报错说明 IDE 的 Language Level 没设对。6. 进阶改造把单机聊天室改成可水平扩展的结构这套源码跑通之后最现实的问题是单台 Tomcat 的 WebSocket 连接数有上限而且服务重启时所有连接全断。如果想让多个用户连到不同实例上还能互相聊天就得引入外部消息中转。常见做法是接 Redis 的 Pub/Sub每个 Tomcat 实例订阅同一个频道收到消息后广播给本机持有的 session。改造的核心是替换SessionManager.broadcast的实现public class RedisSessionManager { private static final JedisPool POOL new JedisPool(127.0.0.1, 6379); private static final String CHANNEL chatroom; // 本机持有的会话只负责推送给本地连接 private static final ConcurrentHashMapString, SetChatEndpoint LOCAL new ConcurrentHashMap(); public static void broadcast(String roomId, ChatMessage msg) { // 不直接推本地而是发到 Redis由订阅线程统一处理 try (Jedis jedis POOL.getResource()) { jedis.publish(CHANNEL, roomId | msg.toJson()); } } // 每个实例启动时开一个订阅线程 public static void startSubscriber() { new Thread(() - { try (Jedis jedis POOL.getResource()) { jedis.subscribe(new JedisPubSub() { Override public void onMessage(String channel, String message) { String[] parts message.split(\\|, 2); String roomId parts[0]; String json parts[1]; // 只推给本机持有的 session SetChatEndpoint set LOCAL.get(roomId); if (set ! null) { for (ChatEndpoint ep : set) { try { ep.session.getBasicRemote().sendText(json); } catch (IOException ignored) {} } } } }, CHANNEL); } }).start(); } }逻辑说明broadcast不再直接遍历本地 session而是把消息 publish 到 Redis 频道。每个实例的订阅线程收到消息后只推给本机LOCAL里持有的连接。这样无论用户连到哪台 Tomcat消息都能通过 Redis 中转到达所有实例。参数说明CHANNEL名字可以按业务改roomId | json的分隔符要选消息体里不会出现的字符竖线一般安全。验证方法起两个 Tomcat 实例端口分别设 8080 和 8081前端页面分别连这两个端口在 8080 发消息8081 的页面应该能收到。如果收不到先检查 Redis 的subscribe是否阻塞在主线程导致 Tomcat 启动卡住——订阅线程必须单独起不能放在PostConstruct里同步执行。从那以后我每次改 WebSocket 广播逻辑都强制先在两个实例上跑一遍跨机通信确认 Redis 订阅线程正常消费再上生产。这套源码作为起点改到能水平扩展大概需要动三个类SessionManager、ChatEndpoint的OnOpen注册逻辑、以及前端连接地址的配置化。希望帮到你。本文还有配套的精品资源点击获取