简介:基于Tomcat8+Java7+ExtJS的WebSocket聊天室完整项目源码,面向希望掌握Web实时通信、Java服务端推送及富客户端开发的初中级开发者。项目演示了JSR 356规范下javax.websocket API的典型用法,包括@ServerEndpoint注解定义端点、onOpen/onMessage/onClose生命周期回调,以及连接池管理、消息广播和在线用户追踪等关键机制;客户端则利用ExtJS组件构建输入框、按钮与聊天窗口,并对WebSocket对象进行封装,便于在MVC架构中维护界面状态。资源包共1753个文件,压缩后约7.01MB,以1359个gif动图、158个scss样式、114个png图标、50个js脚本、34个css样式及11个jar依赖包为主,同时包含java源码、class文件、XML配置与项目描述文件,基本覆盖界面展示、样式定义、交互逻辑和运行环境等模块,目录结构清晰。已有260人学习下载,适合作为WebSocket聊天室项目实战参考,可据此快速搭建原型或进行二次开发。 我在做这套系统时,最大的感受就是:聊天室看起来简单,真正嵌进老项目里要操心的全是连接生命周期、前端回调、代理握手这些边角问题。这篇文章就按我从服务端到前端、再到上线踩坑的记录顺序来写,代码可以直接抄,思路也尽量讲透。
1. 为什么在Tomcat 8 + Java 7 + ExtJS这个组合里做WebSocket
1.1 老项目加实时通信的现实约束
很多团队手里都有一两套多年没大动过的后台系统,前端用ExtJS,后端固定在Java 7,跑在Tomcat 8上,业务稳定但界面交互方式还停留在"列表刷新+表单提交"的年代。要在这种系统里加一个在线讨论、客服接待或者工单即时沟通的功能,最现实的问题不是"哪个技术最先进",而是"哪个方案能在不大动架构的情况下落进去"。
轮询接口是最简单的,但消息延迟高、无效请求多,后台每隔几秒被打一次,数据库和日志都不太舒服。HTTP长连接又是半吊子,服务端不能主动推。真正合适的就是WebSocket,它复用了HTTP的握手过程,应用层是双向长连接,服务端可以随时主动把消息推给浏览器。而在Java 7 + Tomcat 8这个组合里,服务端不需要引入Netty或ActiveMQ这类重东西,因为Tomcat 8自带了JSR 356的WebSocket实现,直接用标准注解就能写一个聊天端点。
1.2 三层分别解决什么问题
Tomcat 8在这个方案里不只是Web容器。它内置了websocket-api.jar和对应的实现类,项目里只要把Tomcat的lib目录下的websocket-api.jar加到编译路径,就能使用javax.websocket包下的注解和接口,连额外的Maven依赖都不用配(如果用的是Maven构建,则加一行javax.websocket的依赖也行,但我在实际项目里直接依赖容器更省事)。
Java 7在这里没有拖后腿。聊天室的核心逻辑是"收到消息-转发给在线会话集合",这个场景本身就是轻量级的字符串处理和集合遍历,用不上什么新语法特性。唯一要注意的是别在代码里写Java 8的lambda,编译直接过不了,但这不是Java 7的错,是工程规范问题。
ExtJS承担的是展示和交互。老项目里已经有一套成熟的ExtJS组件和皮肤,用Panel、DataView、TextArea、Button组合出聊天界面,跟原有的页面风格天然统一,用户不需要重新学习操作习惯。
1.3 WebSocket握手的本质:先HTTP后双向帧
我见过不少人把WebSocket理解成"像socket一样直接建立TCP连接",这个理解不准确。实际上WebSocket握手就是一次带升级请求头的HTTP请求:客户端发一个带Upgrade: websocket和Sec-WebSocket-Key的GET请求,Tomcat的WebSocket处理器发现URL匹配到某个@ServerEndpoint注解的路径后,返回101 Switching Protocols响应,之后双方才在这个连接上收发帧数据。
理解这一步很重要,因为后面遇到的所有"连不上、断线、握手失败"类问题,八成出在HTTP层,比如HTTPS页面发了ws明文请求、Nginx没转发Upgrade头、代理把长连接超时设得太短。这些我们都放到踩坑章节细说。
2. 服务端聊天室核心设计:会话、广播与生命周期
2.1 用注解声明一个聊天端点
JSR 356的核心用法是写一个带@ServerEndpoint注解的普通Java类,然后在方法上标@OnOpen、@OnMessage、@OnClose、@OnError。我这边简化后的实现如下:
package com.example.chat; import java.io.IOException; import java.util.List; import java.util.Map; import java.util.Set; 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(value = "/chat") public class ChatEndpoint { private static final Set<Session> CLIENTS = new CopyOnWriteArraySet<Session>(); @OnOpen public void onOpen(Session session) { Map<String, List<String>> params = session.getRequestParameterMap(); List<String> nicknames = params.get("nickname"); String nickname = (nicknames == null || nicknames.isEmpty()) ? "游客" : nicknames.get(0); session.getUserProperties().put("nickname", nickname); session.setMaxIdleTimeout(180000L); CLIENTS.add(session); broadcast("{\"type\":\"system\",\"content\":\"" + nickname + " 加入了聊天室\"}"); } @OnMessage public void onMessage(String text, Session session) { // 心跳消息直接忽略,不广播 if ("ping".equals(text)) { return; } String nickname = (String) session.getUserProperties().get("nickname"); String content = "{\"type\":\"message\",\"nickname\":\"" + nickname + "\",\"time\":\"" + System.currentTimeMillis() + "\",\"content\":\"" + text + "\"}"; broadcast(content); } @OnClose public void onClose(Session session) { String nickname = (String) session.getUserProperties().get("nickname"); CLIENTS.remove(session); broadcast("{\"type\":\"system\",\"content\":\"" + nickname + " 离开了聊天室\"}"); } @OnError public void onError(Session session, Throwable error) { CLIENTS.remove(session); try { if (session.isOpen()) { session.close(); } } catch (IOException e) { e.printStackTrace(); } } private void broadcast(String message) { for (Session s : CLIENTS) { if (!s.isOpen()) { CLIENTS.remove(s); continue; } try { s.getBasicRemote().sendText(message); } catch (IOException e) { CLIENTS.remove(s); } } } }这段代码里藏着一个特别容易踩的坑:@ServerEndpoint注解的类,每个WebSocket连接都会实例化一个新的Endpoint对象。如果会话集合不是static的,每个连接就会维护一个只包含自己的集合,广播就成了"自己发给自己"。我第一次写这个问题时也愣了好一会儿,排查日志时发现每次广播明明都执行了,却只有自己收到,就是这个原因。
2.2 会话集合为什么要用CopyOnWriteArraySet
聊天室的会话集合有一个典型特征:读多写少,广播遍历是高频操作,连接建立和关闭是低频操作。CopyOnWriteArraySet底层是CopyOnWriteArrayList,迭代遍历时不需要加锁,也不抛ConcurrentModificationException,正好匹配广播场景。
有人会问,那高并发写呢?比如同时几百人上下线。实际上CopyOnWriteArraySet每次添加或删除都会复制整个数组,频繁上下线场景下性能会变差。聊天室这类应用里,连接建立和关闭的频率远低于消息转发,所以这个取舍是合理的。如果消息量大、房间人数多,可以进一步按房间拆分集合,用ConcurrentHashMap<String, Set<Session>>来管理,按需广播,这个我们后面细说。
2.3 广播时的异常处理逻辑
广播过程中最典型的异常是:某个客户端的网络已经断开,但服务端还没收到TCP的关闭通知,此时对这个Session调用sendText会抛出IOException。如果你只是catch住然后打印日志,那个Session会一直留在集合里,接下来的每次广播都会继续尝试向它发送,日志会被"Broken pipe"刷满,真正的业务异常反而被淹没了。
更稳的做法就是上面代码里的逻辑:发消息发现异常,立即把这个Session从集合里移除,并顺手关闭连接。这相当于一种被动清理机制。配合上@OnClose里的主动清理,基本能保证集合里留下的都是有意义的活跃连接。
2.4 身份绑定与心跳机制设计
WebSocket握手阶段本质是HTTP请求,所以可以在URL上通过query参数传递身份信息,比如ws://host/context/chat?nickname=张三。前端的WebSocket对象支持这种带参数的URL,服务端用session.getRequestParameterMap()读取。
但这里必须提醒:只靠query参数传身份,服务端无法真正信任这个身份,任何人都能伪造一个nickname=admin的连接。我在生产环境里会额外做一层校验:登录后的用户会获得一个短期有效的token,握手时同时带上token,服务端在@OnOpen里先验证token对应的真实用户,再把用户ID和昵称写进session.getUserProperties()。这套逻辑虽然增加了一点代码量,但能避免聊天室里出现大量伪造身份的发言。
心跳机制方面,Tomcat 8的WebSocket协议层支持ping/pong帧,但Java 7 + JSR 356下最省事的写法是让客户端每30秒发一个文本消息"ping",服务端收到后直接忽略不广播。这个看似原始的做法至少解决了两个问题:一是保持连接在中间网络设备上处于活跃状态,二是能及时发现连接坏死,配合前端的onclose回调做重连。
3. ExtJS前端集成:把WebSocket回调嵌进组件事件体系
3.1 把原生WebSocket封装成Ext.util.Observable
ExtJS组件的事件体系是独立于浏览器原生API的,WebSocket的onmessage回调不会自动触发ExtJS组件上的事件。所以接入的关键就是自己动手做一层适配,把WebSocket的三个核心回调统一转发为ExtJS事件。
Ext.define('App.util.ChatSocket', { extend: 'Ext.util.Observable', constructor: function(config) { this.initConfig(config); this.callParent(); this.ws = null; this.initWebSocket(); }, initWebSocket: function() { var me = this; var protocol = location.protocol === 'https:' ? 'wss://' : 'ws://'; var url = protocol + location.host + '/chat-demo/chat?nickname=' + encodeURIComponent(Ext.util.Cookies.get('nickname') || '游客'); var ws = new WebSocket(url); ws.onopen = function() { me.fireEvent('connected', me); }; ws.onmessage = function(evt) { var data = Ext.JSON.decode(evt.data, true); if (data) { me.fireEvent('message', me, data); } }; ws.onclose = function() { me.fireEvent('disconnected', me); }; ws.onerror = function(err) { me.fireEvent('error', me, err); }; me.ws = ws; }, send: function(text) { if (this.ws && this.ws.readyState === 1) { this.ws.send(text); } }, close: function() { if (this.ws) { this.ws.close(); } } });这段封装的好处是,业务组件完全不用关心底层是WebSocket还是以后换成的SSE或者轮询,只需要监听这个ChatSocket实例上的事件即可。代码里还有一处前后端之间的隐晦约定:url的协议用location.protocol判断,HTTPS页面自动使用wss。这就是Chrome高版本连不上WebSocket时最常见的解法,后面踩坑章节我会展开说。
3.2 聊天面板的组件化实现
前端界面我用了一个典型的ExtJS border布局:中间区域是消息列表,底部是输入框和发送按钮。
var chatSocket = Ext.create('App.util.ChatSocket', { listeners: { message: function(socket, data) { var msgPanel = Ext.getCmp('msgPanel'); var list = msgPanel.getData() || []; list.push({ content: (data.type === 'system' ? '' : data.nickname + ':') + data.content, isSystem: data.type === 'system' }); msgPanel.setData(list); msgPanel.getEl().scroll('b', 100000, true); } } }); var chatPanel = Ext.create('Ext.panel.Panel', { title: '在线聊天', layout: 'border', width: 560, height: 420, items: [{ region: 'center', xtype: 'panel', id: 'msgPanel', autoScroll: true, bodyStyle: 'background:#f5f5f5;padding:8px;', tpl: new Ext.XTemplate( '<tpl for=".">', '<p style="{[this.getStyle(values)]}">{content}</p>', '</tpl>', { getStyle: function(values) { return values.isSystem ? 'color:#999;font-size:12px;' : 'color:#333;font-size:13px;'; } } ), data: [] }, { region: 'south', xtype: 'panel', layout: 'fit', height: 110, items: [{ xtype: 'textarea', id: 'chatInput', emptyText: '请输入消息,回车发送...', enableKeyEvents: true, listeners: { specialkey: function(field, e) { if (e.getKey() === e.ENTER && !e.shiftKey) { sendMessage(); } } } }] }], buttons: [{ text: '发送', handler: sendMessage }], listeners: { beforedestroy: function() { chatSocket.close(); } } }); function sendMessage() { var input = Ext.getCmp('chatInput'); var text = Ext.util.Format.trim(input.getValue()); if (text === '' || text === 'ping') { return; } chatSocket.send(text); input.setValue(''); }消息列表直接用tpl渲染data里的数组,每来一条消息就往数组里push一条记录并刷新DataView区域,这样写量少且直观。实际消息量大时,这个方案会有性能问题,因为每次都是全量重绘。更优的做法是把消息列表改成DataView配合Store,只在末尾追加一条记录。但作为老后台里的聊天室,单屏消息量通常不会太大,所以简化版足够使用。
3.3 面板销毁时一定要清理WebSocket连接
这是我在维护老项目时发现的一个隐蔽问题:用户关闭了聊天Tab,界面消失了,但那条WebSocket连接还挂在浏览器和服务端之间。浏览器侧的连接对象随着页面组件销毁可能被GC回收,但服务端的Session集合里仍然保留着这个会话。
如果不处理,一天下来服务端就会堆积大量僵尸连接。解决办法就是上面代码里panel监听的beforedestroy事件,调用chatSocket.close()主动关闭。服务端的@OnClose方法也要做好日志输出,方便对账:关闭面板后,服务端日志里应当出现对应的session关闭记录,这是线上排查僵尸连接的第一步。
4. 实测中绕不开的四个坑:从断连到握手失败
4.1 HTTPS页面用ws://导致的高版本浏览器连不上
热搜里有一条"谷歌浏览器高版本无法启用websocket",这个坑跟WebSocket本身无关,是浏览器安全策略问题。当页面通过HTTPS加载时,浏览器不允许页面里发起不安全的ws://请求,控制台会报错,内容大致是An insecure WebSocket connection may not be initiated from a page loaded over HTTPS。
老项目最常见的现状是:原来跑在HTTP环境,后来运维把整个站点切成了HTTPS,页面都正常打开,只有聊天室这个功能挂了,因为前端代码里写着硬编码的ws://。正确的做法就是3.1中那样,根据location.protocol动态决定使用ws://还是wss://。这里也顺带提醒一下:wss://本质就是WebSocket套了一层TLS,和HTTPS一样需要证书配置在代理层或Tomcat连接器上,证书过期同样会影响连接。
4.2 空闲超时导致的静默断连
实测过程中最容易出现的"鬼问题"是:页面开着不动,过一段时间后发消息就没反应了,控制台不报错,网络面板里连接状态也看不出异常。原因是Tomcat 8的WebSocket容器在会话空闲超过阈值后,会主动关闭连接。这个空闲超时如果不在@OnOpen里显式设置,默认值可能跟你的预期不一致,导致用户挂机十分钟后连接就没了。
解决方法是双管齐下:服务端在@OnOpen里显式session.setMaxIdleTimeout(180000L),设置3分钟空闲超时;客户端每30秒发送一个心跳消息。我前面代码里服务端收到"ping"直接return不广播,就是为了避免心跳消息被当成聊天内容刷到所有人屏幕上。这个细节如果不处理,聊天室没聊几句,倒是被一堆"ping"刷屏了。
4.3 Nginx反向代理下Upgrade头丢失
很多老系统的HTTP层前面挂着一层Nginx做反向代理和负载均衡。WebSocket握手是要经过Nginx转发到Tomcat的,但Nginx默认情况下不会转发Upgrade和Connection: Upgrade这两个请求头,结果就是浏览器发出的握手请求被当成了普通GET请求,Tomcat返回200而不是101,WebSocket连接自然建立不起来。
解决方法是给Nginx配置加上WebSocket代理支持,关键配置如下:
map $http_upgrade $connection_upgrade { default upgrade; '' close; } server { listen 80; server_name chat.example.com; location /chat-demo/ { proxy_pass http://127.0.0.1:8080/chat-demo/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 180s; } }注意这里的proxy_read_timeout必须大于服务端的maxIdleTimeout,否则会出现一种很隐蔽的情况:握手成功,连接也建立了,但因为某个时段没人说话,Nginx自己等超时了,把连接掐断。这个超时错误不会显示在页面控制台的握手阶段,而是表现为连接空闲后随机断线,排查起来相当费劲。
4.4 服务端异常关闭导致的"stream disconnected before completion"
热搜里还有一条"stream disconnected before completion: websocket closed by server before res",这是客户端在等待服务端数据时,发现WebSocket连接被服务端提前关闭了。造成这个报错的最常见原因是服务端在@OnMessage处理方法里抛了异常,比如把字符串转JSON失败、访问了空对象等,而异常没有被捕获,直接冒泡到了容器层,容器判定这个WebSocket连接处于错误状态,主动关闭了它。
解决思路是严格要求服务端代码分层:网络层只负责收发消息,不写业务逻辑;业务逻辑里所有可能出错的解析都放进try-catch;@OnError方法里统一做会话清理。同时前端的onclose回调不要只打日志,为实现好的重连机制留好接口,这样即使服务端异常断开,客户端也能在几秒内重新建立连接并恢复聊天状态。
5. 聊天室的并发边界与上线前检查清单
5.1 单机能撑多少连接
做完了功能,总要面对"到底能不能扛住"这个问题。Tomcat 8默认的连接器是NIO模式,一个WebSocket连接不会独占一个线程,连接本身的开销比想象中要小。在2核4G的云服务器上,维持两三千个在线WebSocket连接,日常聊天场景下问题不大。
但聊天室真正的瓶颈不是连接数,是广播风暴。假设有1000个人在线,一个人发一条消息,服务端就要执行1000次sendText调用。如果每个用户都频繁发言,服务端的写操作次数是N的平方级增长。所以上线前要想清楚,是不是真的需要全站广播。绝大多数业务场景里,用户是在某个房间或某个工单会话里聊天,按频道拆分会话集合、只给相关方推送,是聊天室系统必须具备的基础能力。
5.2 消息格式一定要前后端统一
我后面把所有消息都统一封装成JSON结构,每条消息包含以下字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| type | string | 消息类型,message、system、ping等 |
| nickname | string | 发送人昵称 |
| time | long | 消息时间戳 |
| content | string | 消息内容 |
前端通过type字段区分系统消息和用户消息,展示样式可以不同。不要靠解析"张三:大家好"这种字符串前缀来判断发送人,那是一条死路,以后加表情、加富文本、加@人的功能时全都要返工。
5.3 部署前逐项核对检查清单
根据我这几次上线踩过的坑,列一个每次上线前都要过一遍的清单:
- context-path是否正确,WebSocket的URL端口是否和页面访问端口一致,尤其经过负载均衡后
- HTTPS环境下是否已经切到
wss://,证书是否有效 - Nginx或硬件负载均衡是否配置了Upgrade请求头透传,代理层读写超时是否大于服务端空闲超时
- 服务端是否有显式的
setMaxIdleTimeout,客户端是否有心跳定时器 - 浏览器兼容性要求是否明确,如果业务还在用IE8、IE9,WebSocket这条路就走不通,需要降级方案
- 关闭聊天面板时WebSocket是否被主动关闭,服务端是否有方法监控在线会话数
这些检查项没有一个是特别高深的,但每一条都对应着真实发生过的事故。我自己的习惯是把这份清单放到项目的DEPLOY.md里,每次部署前照着过一遍,比临时靠记忆保险得多。
最后再补一句这段时间实操下来的体会:WebSocket服务端的代码写起来其实不多,真正花时间的地方全在连接管理的细节上。把这套Tomcat 8 + Java 7 + ExtJS的组合跑通之后,你会对整个WebSocket生命周期有非常直观的理解,换到Spring Boot或者别的技术栈时,这些经验依然完全适用。
本文还有配套的精品资源,点击获取