简介一套基于Tomcat8、Java7与ExtJS的WebSocket聊天室项目源码适合Java Web学习者、中级以上开发者研究实时双向通信机制。项目将服务端Servlet容器、JSR 356的javax.websocket API与ExtJS富客户端界面结合起来演示了从用户登录、消息群发到历史记录展示的聊天室完整闭环能有效补充HTTP短连接难以主动推送数据的短板。资源共1753个文件压缩包仅7.01MB以gif、png等界面素材和scss、css、js样式脚本为主同时包含jar包、少量java与class文件以及classpath、project等工程配置方便直接导入开发环境查看。已有262人学习下载适合作为WebSocket学习与项目改造的参照模板读者可从中梳理服务端连接管理、客户端界面封装以及前后端消息交互的实现思路。1. tomcat8java7extjs 的 WebSocket 聊天室旧栈实时方案能省多少事WebSocket 是目前在浏览器和服务端之间做实时推送最直接的方式而 tomcat8 java7 extjs 这套组合恰好能把 WebSocket 聊天室以最低的改造成本落到老项目里。Tomcat8 原生实现了 WebSocket 协议和 JSR-356 标准Java7 只需要写几个注解方法就能挂载端点ExtJS 则提供了一个结构化的前端容器来承载消息流。这套方案适合维护着老系统、想给业务增加一个在线聊天或实时通知能力的团队——不引入 Spring WebSocket、不需要 Netty、也不用升级 JDK。下面从协议原理讲到前后端实现再给出实际部署中会踩到的边界和参数细节。2. WebSocket 在 Tomcat8 里的真实姿态Java7 的 API 边界与连接生命周期2.1 从 HTTP 到 WebSocket一次握手完成协议切换WebSocket 并不是凭空建立的长连接它的起点是一条普通 HTTP GET 请求。客户端发起握手时携带Upgrade: websocket、Connection: Upgrade、Sec-WebSocket-Key、Sec-WebSocket-Version: 13这些头服务端校验通过后返回 101 状态码并在Sec-WebSocket-Accept里回应对Sec-WebSocket-Key的 SHA-1 签名结果。之后这条 TCP 连接直接切换为 WebSocket 帧传输不再走 HTTP 解析。Tomcat8 在处理这个流程时先由 NIO 连接器接收请求再由WsFilter识别 Upgrade 头交给WsHttpUpgradeHandler完成协议升级。这个机制对使用者的直接影响是直接拿浏览器地址栏访问 ws 地址是无效的必须通过new WebSocket(url)发起握手url 前缀是ws://或wss://路径则对应后端ServerEndpoint里配置的值。Tomcat8 默认使用 NIO 连接器一个线程可以同时管理多个 socket 上的 IO 事件但 WebSocket 消息回调仍然占用容器工作线程。也就是说握手阶段由 IO 线程完成而OnMessage回调是在 worker 线程上执行的。如果在聊天室的 onMessage 里做了耗时操作占的是 Tomcat 处理业务请求的线程池不是那个负责 accept 的监听线程。这个区别直接关系到后面讲到的连接数打满问题。2.2 Tomcat8 对 JSR-356 的支持边界Java7 够用在哪里JSR-356 是 Java 平台对 WebSocket 服务端的标准抽象包名是javax.websocket。虽然它属于 Java EE 7 的一部分但本质上只是一个 API jar。Tomcat8 的 lib 目录下自带websocket-api.jar和tomcat-websocket.jar编译期引入 API 之后运行时由容器提供实现不需要额外打包。Java7 在这里够用是因为javax.websocket用到的是注解、反射、泛型跟并发工具这些在 JDK7 里都已经具备没有用到 lambda 表达式或接口默认方法。所以 JDK7 搭配 Tomcat8 跑 WebSocket和 JDK8 几乎没有功能差异。唯一要注意的是如果你在编译期引用了 JavaEE 7 的其他包它们可能整体要求 JDK8所以要控制依赖粒度只引javax.websocket-api这一个坐标。边界的另一面是wss://的支持。Tomcat8 可以在Connector 上配置 SSL 证书来跑加密的 WebSocket但如果在你的部署里已经有反向代理做 HTTPS 终结我一般建议代理层用 HTTPS 对外、以ws://转发到 Tomcat 后端证书只在代理层放一份避免 Tomcat 里重复处理 SSL 解密的开销。2.3 连接生命周期OPEN、CLOSE 与 error 的处理时机与线程模型一个 WebSocket 连接的生命周期围绕 Session 对象展开。握手成功之后容器创建 Session 并触发OnOpen这里适合做用户身份绑定、把会话写进在线容器客户端每发一条文本消息触发一次OnMessage连接关闭时触发OnClose可以拿到CloseReason判断是正常关闭1000还是异常断开1006连接出错走OnError默认实现只打日志建议在这里把连接从在线表里清掉避免半死连接一直占用集合空间。发送通道有两种session.getBasicRemote()是同步阻塞发送返回的RemoteEndpoint.Basic不能被子线程同时调用session.getAsyncRemote()是异步发送可以挂WriteListener回调感知发送结果。聊天室这类高频小消息场景我一般先用 BasicRemote 同步发送保证消息顺序广播循环只在一个线程里执行从根源上规避同一个 Session 并发写的问题。只有当单条消息体大到影响吞吐时再切换到 AsyncRemote。注意写线程模型OnMessage的执行线程从 Tomcat 工作线程池借出如果你的广播逻辑里对每个 Session 逐个同步发送慢客户端的 TCP 窗口写满会导致 sendText 阻塞这个阻塞会直接消耗掉一个 worker 线程。也就是说一个卡住的客户端就可能拖慢整个聊天室的接收能力这也是很多 WebSocket 服务在长连接场景下莫名变慢的隐藏原因。3. 后端聊天室落地用 Java7 注解式 WebSocket 端点搭消息通道3.1 Maven 工程与 Tomcat8 部署目录最小可运行结构建一个标准的 War 包工程用 Maven 管理编译和打包。Java7 项目里不要放太高版本的插件maven-war-plugin 用 2.6 就可以编译级别锁到 1.7避免有人用 JDK8 语法编译出一个 Tomcat8 跑不动的产物。project modelVersion4.0.0/modelVersion groupIdcom.demo/groupId artifactIdchat-server/artifactId version1.0.0/version packagingwar/packaging properties maven.compiler.source1.7/maven.compiler.source maven.compiler.target1.7/maven.compiler.target /properties dependencies dependency groupIdjavax.websocket/groupId artifactIdjavax.websocket-api/artifactId version1.1/version scopeprovided/scope /dependency dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version3.1.0/version scopeprovided/scope /dependency /dependencies build finalNamechat-server/finalName plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-war-plugin/artifactId version2.6/version /plugin /plugins /build /projectjavax.websocket-api的 scope 必须是 provided因为 Tomcat8 已经带了一份打进去反而可能在部署时出现类冲突。finalName设为chat-server对应访问路径是http://host:8080/chat-server/chat。部署时把 war 丢进 Tomcat8 的 webapps 目录即可如果要用 IDE 调试直接配一个 Tomcat8 运行时Application context 设为/chat-server两侧一致就行。这个工程里不需要任何配置文件。Tomcat8 启动时会自动扫描 classpath 下带ServerEndpoint注解的类并完成注册这比 Servlet 的 web.xml 配置更省事。需要确认的是干净 Tomcat8 而非 Tomcat7Tomcat7 对 JSR-356 的实现需要额外装tomcat-websocket扩展包标题锁定的 tomcat8 就没有这个坑。3.2 ServerEndpoint 实现服务端消息收发与会话表核心服务端就是一个带注解的 POJO 类不加任何接口继承。这里实现无用户体系的单房间群聊所有连上来的客户端都能收到广播。package com.demo.chat; import java.io.IOException; import java.util.concurrent.CopyOnWriteArraySet; 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(/chat) public class ChatServer { // 在线会话集合CopyOnWriteArraySet 迭代时无需加锁 private static final CopyOnWriteArraySetSession SESSIONS new CopyOnWriteArraySetSession(); OnOpen public void onOpen(Session session) { SESSIONS.add(session); System.out.println([chat] open: session.getId() , online SESSIONS.size()); } OnMessage public void onMessage(String text, Session session) { System.out.println([chat] from session.getId() : text); broadcast(text); } OnClose public void onClose(Session session) { SESSIONS.remove(session); System.out.println([chat] close: session.getId() , online SESSIONS.size()); } OnError public void onError(Session session, Throwable t) { SESSIONS.remove(session); t.printStackTrace(); } private void broadcast(String text) { for (Session s : SESSIONS) { try { if (s.isOpen()) { s.getBasicRemote().sendText(text); } } catch (IOException e) { // 单个连接失败不影响其他会话 e.printStackTrace(); SESSIONS.remove(s); } } } }ServerEndpoint(/chat)把端点挂在/chat-server/chat。CopyOnWriteArraySet是 JUC 包下的线程安全集合写操作是数组复制读操作不加锁非常适合聊天室这种连接变更少、遍历广播频繁的场景。onMessage里调用broadcast时如果某个客户端连接已经断开但还没触发 onClosesendText会抛 IOException这里每个会话独立 try-catch一个坏连接不会中断其他客户端的发送。参数上有两个细节。第一OnMessage的方法签名除了 String还可以是Session、ByteBuffer甚至可以不带 Session 参数如果你只想收文本方法上只保留 String 就行。第二s.isOpen()的判断只解决发送前状态发送过程中对方断开仍然会抛异常所以 try-catch 是必须的。对于单房间聊天室这段代码可以直接用要扩用户体系就在 onOpen 阶段从 URL query 里拿用户信息绑定到 Session 的 userProperties 里然后再写入集合。3.3 消息广播与 Java7 并发CopyOnWriteArraySet 的选择理由有人会问为什么不用同步的HashMap或ConcurrentHashMap保存 Session。Java7 的ConcurrentHashMap没有newKeySet()方法要维护一个 Set 语义的会话表最直接的两个选择是CopyOnWriteArraySet和ConcurrentHashMapSession, Boolean。前者代码更干净迭代时不会抛ConcurrentModificationException适合在线连接数在几百这个量级的场景后者适合几千连接以上的大集群但你把连接塞进 Map 的 value 里时必须自己处理重复写入的问题。聊天室场景还有一个隐蔽的并发问题同一个客户端连续点击发送时两条消息可能先后到达服务端但从同一个浏览器 WebSocket 发出的消息帧顺序不会变服务端逐条回调onMessage单线程处理所以不需要在应用层做消息序号校验。真正需要防的并发写在broadcast里如果两个业务线程同时遍历 SESSIONS 并调用同一个 Session 的sendText基础远程端点内部虽然做了锁但消息之间可能交错表现为客户端收到的文本前后错乱。解决办法是确保广播入口只有一个线程或者在 broadcast 方法上加synchronized简单粗暴但有效。从扩展角度看要做多房间就把静态集合升级成ConcurrentHashMapString, CopyOnWriteArraySetSession外层 key 是 roomId进入 onMessage 时先按消息里的 room 字段找到对应的集合再广播。Java7 里的 ConcurrentHashMap 够用不需要额外引入分布式缓存只有当你把节点数扩展到两台以上 Tomcat 时才要考虑用 Redis 做消息路由那是从单机聊天室到集群聊天室的分水岭。4. ExtJS 前端接入 WebSocket连接封装与聊天面板渲染4.1 WebSocketManager 封装断线重连与消息路由浏览器原生WebSocket对象功能很裸open、message、close、error 四个事件外加一个 send。ExtJS 本身没有提供 WebSocket 组件所以第一步是把它包成一个 Ext 单例让所有 View 都能监听消息事件。这里用Ext.define定义了一个混合了 Observable 的单例这样能用fireEvent对外广播消息。Ext.define(Chat.manager.WsManager, { extend: Ext.util.Observable, singleton: true, ws: null, url: ws:// location.host /chat-server/chat, retryCount: 0, maxRetry: 5, connect: function () { var me this; if (me.ws me.ws.readyState WebSocket.OPEN) { return; } me.ws new WebSocket(me.url); me.ws.onopen function () { me.retryCount 0; me.fireEvent(status, true); }; me.ws.onmessage function (evt) { me.fireEvent(message, evt.data); }; me.ws.onclose function () { me.fireEvent(status, false); if (me.retryCount me.maxRetry) { me.retryCount 1; // 指数退避重连3秒/6秒/9秒/12秒/15秒 Ext.defer(me.connect, 3000 * me.retryCount, me); } }; me.ws.onerror function () { me.ws.close(); }; }, send: function (text) { var me this; if (me.ws me.ws.readyState WebSocket.OPEN) { me.ws.send(text); return true; } return false; } });location.host自带端口和后端服务同域部署时不用维护绝对地址如果前端是独立域名访问就把url抽取成配置项放进 ExtJS 的Ext.manifest或应用启动参数里。Ext.defer(me.connect, 3000 * me.retryCount, me)第三参数是作用域保证延迟结束后回调到单例上下文。参数上的关键点是maxRetry。重连次数不要设成无限浏览器对 WebSocket 的失败重连如果太频繁会在服务端留下大量 TIME_WAIT 连接。实测中 5 次、每次间隔递增到 15 秒已经能让用户在拨号网络下恢复正常。另外onerror里调用ws.close()是为了让浏览器进入稳定的 CLOSED 状态否则不会触发onclose重连逻辑也就走不到。4.2 聊天窗口用 Ext.panel 与 DataView 渲染消息流聊天窗口用border布局中间区域是消息列表底部是输入框和发送按钮。消息列表用 DataView 绑定一个本地 Store每收到一条 WebSocket 消息就往 Store 里 add 一条记录DataView 自动更新 DOM。Ext.define(Chat.view.ChatWindow, { extend: Ext.panel.Panel, title: WebSocket 聊天室, width: 420, height: 540, layout: border, initComponent: function () { var me this; me.store Ext.create(Ext.data.Store, { fields: [user, time, content], data: [] }); me.items [{ region: center, xtype: dataview, itemId: msgView, autoScroll: true, store: me.store, tpl: new Ext.XTemplate( tpl for., div classmsg-row, b{user}/b span stylecolor:#999{time}/span, p{content}/p, /div, /tpl ) }, { region: south, height: 80, layout: vbox, items: [{ xtype: textarea, itemId: input, height: 44 }, { xtype: button, text: 发送, handler: function (btn) { var panel btn.up(panel); var input panel.down(#input); var text input.getValue(); if (text) { WsManager.send(JSON.stringify({ user: tom, time: new Date().getTime(), content: text })); input.setValue(); } } }] }]; me.callParent(arguments); } });数据流是单向的自己发的消息通过服务端广播回来所有客户端包括自己都收到一份所以发送端不要本地重复添加。btn.up(panel)拿到的是ChatWindow实例down(#input)用 itemId 快速定位输入框setValue()清空后把焦点还留在输入框里方便连续发送。这里要提一个常见误用不要在onmessage里直接操作 DOM 或拼 innerHTML那样不仅绕过 ExtJS 的数据绑定还会在快速连续消息下出现闪烁和滚动失效。用 Store 增量 add 的方式DataView 只做局部 DOM 更新性能平稳得多。消息列表需要自动滚到底部时在收到消息后调用view.getEl().scrollTo(top, view.getEl().getHeight(), true)即可。4.3 消息协议JSON 字段设计与前后端对齐的坑前后端之间约定一个最小 JSON 结构避免后续加字段时两头打架。{ type: chat, user: tom, content: hello, extjs, time: 1435123456789 }前端用Ext.decode(raw)解析发送时用Ext.encode(obj)序列化。ExtJS 的Ext.decode内部走 JSON.parse对格式错误的字符串会抛异常。所以 onmessage 里要先包一层 try-catch解析失败的消息丢弃并打日志不能让它打断聊天窗口的渲染流程。坑在于时间字段Java 后端如果返回new Date().getTime()的 long 毫秒值JSON 序列化后是数字JS 的 Number 处理毫秒级时间戳没有精度问题但如果你在 Java 端直接序列化 Date 对象可能输出成带时区的字符串前端要先用Ext.Date.parse再格式化。这个一致性必须在联调初期定死否则后面会出现同一个消息在部分浏览器显示正常、部分显示 Invalid Date 的玄学问题。另一个坑是type字段的扩展。先把协议设计成{type: chat, ...}而不是裸字符串后面加系统通知、上线提醒、历史消息补发时前端可以按 type 分发而不影响已有消息流。服务端广播时如果只需要原样转发后端甚至可以不做解析直接broadcast(text)把小消息的序列化开销压到最低。5. WebSocket 聊天室避坑清单心跳、断线重连与 Tomcat8 的 NIO 陷阱5.1 现象连接数一上去新连接握手直接失败在线人数到几十之后新浏览器页面一直停在正在连接F12 显示 WebSocket 握手失败服务端日志没有任何异常。原因多半不是 WebSocket 本身而是 Tomcat8 的 worker 线程池被业务线程占满。WebSocket 握手跟普通 HTTP 请求共用同一个 worker 池握手请求进来时没有空闲线程处理客户端等不到 101 响应就超时了。解决思路分两层。先把server.xml里 Connector 的acceptCount从默认 100 适当调大再把maxThreads从默认 200 调大但这只是缓解。真正的处理是排查有没有慢 HTTP 接口占用了工作线程尤其那些在 Servlet 里做同步 IO 等待的操作。WebSocket 连接本身不占 worker 线程只有OnMessage回调触发瞬间才占用所以如果聊天室页面打开很多但没人说话线程压力很小一旦群发消息量大每条sendText阻塞的时间就成了新的瓶颈。5.2 现象群发消息偶发丢失单聊却正常多客户端在线时某条广播只有部分客户端收到控制台还没有异常。最常见的原因有两个第一broadcast 循环里某个sendText抛 IOException被 catch 后当前连接直接移除循环继续这一个连接后面的会话不受影响但消息本身缺了一条第二发送瞬间对方 TCP 半关闭isOpen()仍然返回 truesendText也不抛异常消息被内核静默丢弃。解决上要双管齐下。broadcast 里对每个 Session 独立 try-catch并让isOpen()和sendText尽量连续执行缩小判断和发送之间的窗口连续发送失败的连接主动调用session.close()并立刻从集合移除防止它每次都拖一轮遍历。如果想做得更稳可以记录每个 Session 的连续失败次数达到三次才移除避免网络抖动造成的误杀。5.3 现象连接假死服务器没关消息却发不过来页面挂机一晚上第二天发消息双方都不通但浏览器没触发 onclose。原因是中间网络设备或负载均衡的空闲超时常见的 180 秒或 300 秒把空闲 TCP 连接回收了客户端和服务端都没感知连接停留在半死状态。这是 WebSocket 长连接最常见的中文踩坑点也是 websocket 心跳机制实现被频繁搜索的原因。解决思路是客户端和服务端都参与心跳。客户端在onopen之后启动定时器每 30 秒发一条{type:ping}服务端收到 ping 时回{type:pong}并更新 Session 的最后活跃时间服务端再起一个ScheduledExecutorService每 60 秒扫描一次所有会话超过 90 秒没有活跃的直接session.close()。心跳的作用本质不是保活而是让链路中间层看到数据在流动从而不被空闲回收。服务端主动清理是保险客户端重连是兜底两层配合才能把假死连接清干净。5.4 现象ExtJS 面板把聊天内容当成 HTML 渲染用户在聊天框发一条b加粗/b消息列表真的渲染出了加粗文字。原因是 Ext.XTemplate 默认不转义 HTML{content}原样插入 DOM。这既是一个显示问题也是一个 XSS 注入入口聊天室内部用可能无所谓但对接公网环境时必须处理。解决上建议做三层防线前端存储进 Store 之前先Ext.util.Format.htmlEncode(content)过滤服务端 onMessage 收到文本后按白名单规则清洗标签前端渲染模板里不要用{content}裸字段改成自定义成员函数p{content:this.encode}/p把转义下沉到模板。三层最少做两层前端转义是必须的服务端清洗是对所有客户端的统一防护。5.5 现象反向代理层把 WebSocket 请求当成普通 HTTP直接访问 Tomcat 地址能连上 WebSocket走公司反向代理就握手失败返回 400。原因是代理默认只理解 HTTPUpgrade头被丢弃或Connection头被改成 closeWebSocket 握手要求代理必须原样透传这两个头并保持长连接。用 Nginx 做代理时location 里必须显式加proxy_set_header Upgrade $http_upgrade;和proxy_set_header Connection upgrade;同时把proxy_read_timeout调大到 300 秒以上否则代理侧空闲回收一样会触发 5.3 的假死现象。如果用的是硬件负载均衡设备需要确认它是否支持 HTTP Upgrade 透传不支持就只能改用 TCP 四层转发把 WebSocket 流量完整透传给 Tomcat。这个位置的问题往往只在部署验收当天暴露前期本地开发完全正常第一次走正式域名联调就翻车。6. 再往前走一步心跳机制与广播策略的调优验证把心跳和重连当成一套机制写而不是两个独立功能。客户端onopen启动 30 秒定时器发 ping服务端在onMessage里判断type:ping就回 pong 并更新活跃时间。服务端用ScheduledExecutorService每 60 秒扫描活跃表超过 90 秒无消息的 Session 主动 close。这样假死连接最多存留 90 秒客户端触发 onclose 后走已有的指数退避重连用户感知是断了一下又自己恢复。广播策略上我对小消息坚持同步发送。聊天室单条消息通常不到 1KB同步sendText的阻塞时间在本地网络下是微秒级不需要引入异步发送队列只有消息体到了几十 KB 以上或者单房间广播超过 500 个连接才需要按 Session 建BlockingQueue由固定线程池轮询发送。不要为每个消息新建线程那会把 Tomcat 线程池和 JVM 堆一起打爆。验证方法我一般用脚本模拟 20 个 WebSocket test client 连接每秒群发一条消息观察三个指标在线会话集合的大小是否与连接数一致断开 10 个客户端后集合是否在 90 秒内收敛连续跑 30 分钟Tomcat 堆内存是否平稳。抖动上行的对象是消息字符串和 Session 的活跃时间戳记录如果内存曲线持续爬升优先检查是不是有人把消息内容存进了静态集合。我之前一次生产环境翻车就是只在客户端做了重连、没做心跳结果网络设备空闲超时后所有连接集体假死服务端 online 一直显示几十人实际一个消息都发不出去。从那以后我习惯把心跳、超时清理、重连三个参数一起写进设计文档缺一个都不上线。这套 tomcat8 java7 extjs 的方案适合老系统快速补实时能力不需要换框架但前提是连接数预期控制在千级以内、且能接受单机部署超过这个量就得往集群方向重新规划了。希望帮到你。本文还有配套的精品资源点击获取