ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Tomcat 8+Java 7+ExtJS WebSocket 聊天室实现

Tomcat 8+Java 7+ExtJS WebSocket 聊天室实现

简介:面向Java Web初学者与希望实践实时通信的开发者,这套源码工具包以Tomcat8、Java7和ExtJS为基础,实现了一个可运行的WebSocket聊天室,可用于理解JSR 356服务端端点、连接生命周期、消息广播及前端界面集成的完整链路,并涵盖用户登录、消息存储、广播推送与界面状态刷新等核心环节。压缩包共1753个文件、约7.01MB,包含较多gif与png界面素材、scss/css样式、js脚本和jar依赖,也有少量java/class、xml/jsp及工程配置文件,便于按服务端消息处理、客户端交互、界面主题和Tomcat部署等模块查阅。已有263人学习,适合对照源码梳理onOpen、onMessage、onClose事件处理、消息池管理和页面实时更新逻辑。它还可作为课程设计、毕业实践或WebSocket入门练习的参考工程,帮助读者从连接建立、消息收发到并发广播逐步建立整体认识,并据此完成自己的实时聊天功能改造。

1. 选型复盘:为什么这套聊天室值得用 Tomcat 8 + Java 7 + ExtJS 来搭

有人看到「Tomcat 8 + Java 7 + ExtJS 实现 WebSocket 聊天室」这个组合,第一反应是:都什么年代了,为什么不上 Spring Boot 加 Vue?但真到交作业、进机房、接老项目的时候,会发现环境是别人给的——容器就是 Tomcat 8,JDK 就停在 1.7,前端那套后台管理界面就是 ExtJS 写的,动一个版本号可能牵出一堆编译错误。这套方案要解决的问题很具体:在不能随便升环境的前提下,把实时聊天跑起来,界面还得拿得出手。它适合三类人:毕设要交一个「有实时通信」的完整系统、课设要求用指定技术栈、以及接手老系统要加一个在线客服/内部群聊模块的开发者。下面从需求边界讲到协议、服务端、前端,最后落到怎么验收和怎么答辩。

2. 需求与协议设计:四条链路和一套 JSON 信封

2.1 功能边界:先砍掉一半,再谈实现

聊天室最容易失控的地方不是技术,是范围。写着写着就想加离线消息、已读回执、表情包、文件传输,最后哪一块都做不完整。我一般的做法是先划三条线:会话内、会话间、会话外。会话内的功能必须有,会话间的选做,会话外的本期明确不做,并且在文档里写清楚为什么不做的原因——这一点在答辩时反而是加分项,说明你知道边界在哪。

功能优先级服务端动作前端表现
进入房间必须注册会话、推送全量在线列表、广播系统消息消息区出现「某某 进入房间」
群聊消息必须补全发送者与时间戳后广播给房间内所有会话消息列表追加一行
在线列表必须加入/离开时更新并推送左侧成员面板刷新
一对一私聊选做按to字段定向投递,不额外开连接消息气泡区分为「我」和「对方」
心跳保活必须收到ping回pong定时器每 25 秒发一次
历史消息选做落库后按时间倒序查最近 50 条进入房间时先拉一次
离线消息不做无无

「离线消息不做」这条要解释清楚:服务端会话全部放在内存里,进程一重启就全空了,要做离线消息就得引入消息状态机(已投递/未投递/已读)和一张待投递表,工作量翻倍还不容易讲明白。历史消息落库是性价比最高的折中——用户重进房间能看到之前聊了什么,但不保证一条不漏。

另一个容易被忽略的口径问题:在线列表是房间维度的,不是全局的。同一个用户可能在 A 房间和 B 房间各有一个连接,也可能在同一房间开了三个标签页。这两种情况如果按「连接数」统计,人数就会虚高,所以后面所有设计都以uid为准。

2.2 消息信封:用一个 t 字段决定后面怎么解析

前后端各写一套协议,最容易出的问题是字段名对不上:前端发content,后端读body,调半天以为是 WebSocket 没通。解决办法是定一个信封(envelope),所有消息不管什么类型,外层结构完全一致,只有t字段决定内部怎么解释。

{ "t": "msg", "cid": "c-1712-0007", "room": "room1", "from": "u1001", "nick": "A同学", "ts": 1712030400000, "body": "这条是群聊内容", "to": null }
字段类型必填说明
tstring是消息类型:join/msg/to/ping/pong/roster/sys/err
cidstring否客户端生成的消息 id,用于回执和去重,服务端原样带回
roomstring是房间号,群聊广播的范围
fromstring服务端填发送者 uid,由服务端根据会话补全,前端传的一律不认
nickstring服务端填昵称,同上
tslong服务端填毫秒时间戳,以服务端为准,避免客户端时钟不准
bodystring否文本内容
tostring否私聊收件人 uid

三条硬约定必须写进文档。第一,from和ts只信服务端,前端传了也覆盖掉,否则改个 JS 就能冒充别人发言,这是最典型的越权。第二,未知的t返回一条err,但不关闭连接,因为客户端版本比服务端新的时候会多发几种类型,直接断开体验太差。第三,cid用于前端做「发送中」状态和超时重发,服务端不需要存储,回显时带上就行。

为什么不用 STOMP 或二进制帧?STOMP 需要额外的 broker 心智负担,还要配一套订阅路径,聊天室这个量级用不上;二进制帧体积小,但浏览器调试面板里看到的是一堆乱码,联调阶段非常痛苦。纯文本 JSON 的代价是体积膨胀,一条 20 字的消息 JSON 化之后大约 150 字节,百人房间每秒十条消息也就 150KB/s 的广播量,完全在可接受范围。

提示:JSON 序列化库选一个对 Java 7 友好的就够,Gson 是常见选择,不需要额外依赖注解处理器;不要为了省事手写字符串拼接 JSON,转义字符(引号、反斜杠、换行)第一次遇到就会出问题。

2.3 服务端数据模型:两张内存表加一张可选的落库表

服务端不需要复杂的领域模型,三张结构就够了,而且前两张是纯内存的。

结构键值用途
ROOMS房间号Map<uid, Session>广播时遍历,用 uid 做键天然去重
SESSIONSSessionMember(uid, nick, room)连接断开时反查是谁、在哪个房间
UID_INDEXuidSession全局单点查找,用于顶号(同一账号重复登录踢掉旧连接)

ROOMS里刻意不用Set<Session>,而用Map<String, Session>,就是为了解决多标签页问题。用户开第二个标签页登录,同一个 uid 会覆盖掉旧 Session,旧连接被关闭,在线列表人数不会变成 2。如果这里用了 Set,后面要么在广播时去重,要么就得忍受「一个人算两个在线」。

历史消息表是可选的,如果要存,建表语句大致是这样:

-- 只存群聊历史;单聊不落库,避免把私聊做成可被检索的功能 CREATE TABLE chat_message ( id BIGINT NOT NULL AUTO_INCREMENT, room_id VARCHAR(32) NOT NULL, from_uid VARCHAR(32) NOT NULL, nick VARCHAR(32) NOT NULL, body VARCHAR(2000) NOT NULL, ts BIGINT NOT NULL, PRIMARY KEY (id), KEY idx_room_ts (room_id, ts) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

idx_room_ts这个联合索引对应的是「进房间时查最近 50 条」这个查询:WHERE room_id = ? ORDER BY ts DESC LIMIT 50。字符集用 utf8mb4,因为聊天内容里出现表情符号的概率比你想象的高得多,用 utf8 存进去会变成问号,而且这个错误在测试阶段很难被发现——你测的时候打的全是汉字。

join和leave两个操作必须写成幂等的。幂等的意思是:同一个 uid 连续 join 两次,结果和 join 一次一样;leave 一个不存在的 uid,不能抛异常。原因是浏览器刷新、网络抖动会让@OnClose和重连的@OnOpen交错触发,不幂等就会在在线列表里留下幽灵用户。

3. 服务端实现:javax.websocket 端点与线程安全的广播

3.1 端点声明与四个生命周期回调

Tomcat 8 实现了 JSR-356(Java API for WebSocket),好处是不用引第三方库,javax.websocket包直接在容器里。一个端点类用@ServerEndpoint标注,路径里可以带模板参数,这样不同房间共用一个类,不用为每个房间写一个端点。

// 一个端点类,用路径参数区分房间:/chat/room1、/chat/room2 @ServerEndpoint(value = "/chat/{room}", configurator = ChatConfigurator.class) public class ChatEndpoint { // JSR-356 里每个连接对应一个端点实例,所以身份可以放在成员变量上 private String uid; private String nick; private String room; @OnOpen public void onOpen(Session session, EndpointConfig cfg, @PathParam("room") String room) { Object u = cfg.getUserProperties().get("uid"); if (u == null) { // 握手阶段没拿到登录态,直接拒绝 closeQuietly(session, CloseReason.CloseCodes.VIOLATED_POLICY, "no-session"); return; } this.uid = u.toString(); this.nick = String.valueOf(cfg.getUserProperties().get("nick")); this.room = room; ChatRegistry.join(room, uid, nick, session); // 注册并顶掉同 uid 的旧连接 ChatRegistry.broadcast(room, ChatProtocol.roster(room)); // 推全量在线列表 ChatRegistry.broadcast(room, ChatProtocol.sys(nick + " 进入房间")); } @OnMessage public void onMessage(String raw, Session session) { if (raw == null || raw.length() > 4096) { // 单条消息限长 closeQuietly(session, CloseReason.CloseCodes.TOO_BIG, "too-big"); return; } try { ChatRouter.route(ChatProtocol.parse(raw), session); // 分发见 3.4 } catch (Exception e) { ChatRegistry.sendTo(session, ChatProtocol.err("bad-format")); } } @OnClose public void onClose(Session session, CloseReason reason) { ChatRegistry.leave(room, uid, session); // 必须幂等 ChatRegistry.broadcast(room, ChatProtocol.sys(nick + " 离开房间")); } @OnError public void onError(Session session, Throwable t) { // 客户端拔网线时这里通常是 EOFException,不要往外抛,容器日志会被刷爆 ChatRegistry.leave(room, uid, session); } private void closeQuietly(Session s, CloseReason.CloseCodes code, String why) { try { s.close(new CloseReason(code, why)); } catch (IOException ignore) { } } }

有几个细节值得单独说。第一,@OnMessage里的String raw是整帧文本,长度受容器缓冲区限制,默认 8192 字节,超了会直接抛异常并关闭连接,所以这里先做一次 4096 的检查,把错误控制在应用层,方便返回一条err而不是让连接莫名其妙断掉。第二,@OnOpen、@OnMessage这些回调跑在容器的 IO 线程上,不要在里面查数据库或做耗时计算,一个慢查询会把这个线程上的所有连接都拖住;要落库就往队列里丢,异步消费。第三,@OnError里千万别t.printStackTrace()之后就完事,断线清理必须在这里也做一次,因为有些异常路径不会触发@OnClose。

3.2 握手鉴权:在 Configurator 里把 HttpSession 捞出来

WebSocket 握手本质上是一次 HTTP Upgrade 请求,所以它能带上 Cookie,也就能拿到HttpSession。问题在于拿到之后怎么传给端点实例。JSR-356 提供了一个ServerEndpointConfig.Configurator,在握手阶段介入,把需要的东西放进userProperties,端点里再取出来,这是最可控的做法。

public class ChatConfigurator extends ServerEndpointConfig.Configurator { @Override public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) { HttpSession http = (HttpSession) request.getHttpSession(); Map<String, Object> props = sec.getUserProperties(); if (http != null) { props.put("uid", http.getAttribute("uid")); // 登录时写进 session 的 props.put("nick", http.getAttribute("nick")); } // 注意:这里改不了 HTTP 状态码,真正的拒绝放到 @OnOpen 里 close } }

有人会问,Tomcat 里不是可以用cfg.getUserProperties().get(HttpSession.class.getName())直接拿吗?能拿到,但这是容器相关的行为,换个容器或者换个部署方式(比如打成 war 包外置配置)就未必成立。用 Configurator 显式传,代码读起来也更清楚:谁放的、放的什么、什么时候取的,一目了然。

拒绝非法连接的姿势也要说明白。标准的 JSR-356 API 不允许在modifyHandshake里直接返回 403,能改的是请求和响应头。所以常见做法是:握手照常完成,在@OnOpen里检查uid是否为空,为空就用VIOLATED_POLICY(1008)主动关闭。前端会先收到onopen再收到onclose,code是 1008,可以根据这个码在界面上提示「请先登录」而不是无脑重连——这一步不做,未登录用户会把重连逻辑打成死循环,服务器日志里全是握手请求。

注意:登录态里存的是uid这种不可猜的标识,不要存昵称当身份用。昵称可以改,改完不应该影响历史消息的归属。

3.3 会话注册表:ConcurrentHashMap 与发送锁

注册表是整个服务端唯一的有状态部分,也是并发问题最集中的地方。核心结构用ConcurrentHashMap起手,但光换成并发容器还不够,真正容易翻车的是发送动作本身。

public final class ChatRegistry { // room -> (uid -> session),用 uid 做 key 才能天然去重 private static final Map<String, Map<String, Session>> ROOMS = new ConcurrentHashMap<String, Map<String, Session>>(); private static final Gson GSON = new Gson(); public static void join(String room, String uid, String nick, Session s) { Map<String, Session> members = ROOMS.get(room); if (members == null) { ROOMS.putIfAbsent(room, new ConcurrentHashMap<String, Session>()); members = ROOMS.get(room); } Session old = members.put(uid, s); // 同账号重复登录,旧连接被顶掉 if (old != null && old != s && old.isOpen()) { try { old.close(new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, "kicked")); } catch (IOException ignore) { } } s.getUserProperties().put("room", room); s.getUserProperties().put("uid", uid); } public static void leave(String room, String uid, Session s) { Map<String, Session> members = ROOMS.get(room); if (members == null || uid == null) { return; } // 幂等:什么也没有就静静返回 members.remove(uid, s); // 带 value 的 remove,避免误删重连后的新连接 } public static void broadcast(String room, ChatMessage msg) { Map<String, Session> members = ROOMS.get(room); if (members == null || members.isEmpty()) { return; } String text = GSON.toJson(msg); // 序列化一次,发 N 次 for (Session s : members.values()) { sendTo(s, text); // 传已序列化的字符串,省 CPU } } public static void sendTo(Session s, String text) { if (s == null || !s.isOpen()) { return; } // BasicRemote 不是线程安全的:两个线程同时发会抛 TEXT_FULL_WRITING synchronized (s) { try { s.getBasicRemote().sendText(text); } catch (IOException e) { // 连接已断,清理交给 @OnClose,在这里直接删会和遍历冲突 } } } }

members.remove(uid, s)这个带 value 的重载是关键。如果只写remove(uid),会出现这么一种时序:用户刷新页面,新连接先 join 进来,旧连接的@OnClose后到,把新连接从表里删掉了——人还在线,但再也收不到消息,现象是「他自己能发,别人看不到他发的」,排查起来非常绕。带上s做比对,只有当前存的确实是这条旧连接才删。

发送锁锁的是Session对象本身,粒度是单个连接,不同房间之间不会互相阻塞。有人担心synchronized影响吞吐,实测下来百人房间每秒几十条消息完全没压力,真正的瓶颈在序列化和网络 IO 上。sendTo里捕获IOException之后什么都不做,也是刻意的:遍历members.values()的过程中删元素会抛ConcurrentModificationException,脏连接让@OnClose去清理就够了,晚几十毫秒没影响。

3.4 消息分发:一个 switch 处理所有指令

分发层只做一件事:根据t决定这条消息的去向。Java 7 在这里有个小便利——它是第一个支持switch用 String 的版本,Java 6 及以前只能拿if-else一层层堆。

public final class ChatRouter { public static void route(ChatMessage in, Session self) { String type = in.getT() == null ? "" : in.getT(); switch (type) { case "ping": // 心跳,原样回 pong ChatRegistry.sendTo(self, ChatProtocol.pong(in.getTs())); return; case "msg": // 群聊:补全身份后广播 in.setFrom((String) self.getUserProperties().get("uid")); in.setNick((String) self.getUserProperties().get("nick")); in.setTs(System.currentTimeMillis()); ChatRegistry.broadcast(in.getRoom(), in); return; case "to": // 私聊:回显给自己 + 投给对端 in.setFrom((String) self.getUserProperties().get("uid")); in.setTs(System.currentTimeMillis()); ChatRegistry.sendTo(self, ChatProtocol.encode(in)); ChatRegistry.sendTo(ChatRegistry.find(in.getRoom(), in.getTo()), ChatProtocol.encode(in)); return; default: ChatRegistry.sendTo(self, ChatProtocol.err("unknown-type:" + type)); } } }

msg分支里有两处覆盖必须做:from从会话里取,不用前端传的;ts用服务端时间。这两行如果不写,前端只要把from改成别人的 uid,就能冒充发言,而且这种漏洞在界面上完全看不出来——测试的时候你自己发自己收,一切正常。

私聊分支先回显再投递,顺序不能反。先回显是让发送方立刻看到「已发出」,网络慢的时候体验差别很明显;如果先投对端,对端回得快,发送方就会看到消息顺序颠倒。

还有一个隐性的设计点:ChatProtocol.encode和GSON.toJson不要混用。broadcast内部序列化一次然后复用字符串,sendTo接收的必须是已经序列化好的字符串,否则每调用一次就序列化一次,私聊场景下等于两倍开销。这类小地方的统一,最好在写第一行代码时就定下来,中途改会牵一大片。

4. ExtJS 前端实现:把原生 WebSocket 包成一个可观察的组件

4.1 封装 Channel:重连、心跳、事件派发

ExtJS 本身没有内置的 WebSocket 组件,直接用原生WebSocket对象写在 Controller 里,很快就会变成一坨:回调里的this指向变了,重连逻辑散落在三四个地方,消息处理和界面刷新混在一起。常规做法是封一个类,混入Ext.util.Observable,把它变成一个能fireEvent的组件,业务代码只监听事件。

Ext.define('Chat.socket.Channel', { mixins: { observable: 'Ext.util.Observable' }, constructor: function (config) { this.mixins.observable.constructor.call(this, config); this.addEvents('open', 'message', 'close', 'fatal'); this.backoff = 1000; // 重连间隔,指数退避到 30 秒 this.ws = null; this.manualClose = false; // 主动关闭时不再重连 return this; }, connect: function () { var me = this; me.ws = new WebSocket(me.url); // 例:ws://host:8080/app/chat/room1 me.ws.onopen = function () { me.backoff = 1000; // 连上就重置,否则断一次之后越退越久 me.fireEvent('open'); me.startHeartbeat(); me.send({ t: 'join', room: me.room }); }; me.ws.onmessage = function (evt) { var msg = Ext.JSON.decode(evt.data, true); // 第二个参数 true:解析失败返回 null if (!msg) { return; } // 单条脏数据不能把连接打断 if (msg.t === 'pong') { me.lastPong = Ext.Date.now(); return; } me.fireEvent('message', msg); }; me.ws.onclose = function () { me.stopHeartbeat(); me.fireEvent('close'); if (!me.manualClose) { me.reconnect(); } }; // 浏览器出于安全考虑不暴露错误细节,只能靠 onclose 兜底 me.ws.onerror = function () { me.fireEvent('fatal'); }; }, reconnect: function () { var me = this; if (me.retryTimer) { return; } // 防止 onclose 连锁触发多次重连 me.retryTimer = setTimeout(function () { me.retryTimer = null; me.backoff = Math.min(me.backoff * 2, 30000); me.connect(); }, me.backoff); }, startHeartbeat: function () { var me = this; me.heartTimer = setInterval(function () { if (!me.ws || me.ws.readyState !== WebSocket.OPEN) { return; } me.send({ t: 'ping', ts: Ext.Date.now() }); }, me.heartbeat || 25000); }, send: function (obj) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { return false; // 由调用方决定要不要提示,交给上层 } this.ws.send(Ext.JSON.encode(obj)); return true; } });

几个设计取舍说明一下。backoff指数退避是必须的:如果服务端重启,几十个客户端用固定 1 秒的间隔同时重连,会把刚起来的容器再冲一遍,这在演示现场非常致命。心跳间隔设 25 秒,比常见的 30 秒转发层空闲超时略短一点,保证连接在空闲时不会被中间层悄悄掐掉;如果你们的部署里有负载均衡或者 Nginx 这类组件,先把它的空闲超时查清楚,心跳间隔要小于那个值。manualClose标志位用于区分「用户主动退出」和「网络断了」,前者不该重连,否则退出登录之后还在后台疯狂握手。

Ext.JSON.decode(evt.data, true)的第二个参数是safe,解析失败返回null而不是抛异常。这个习惯一定要养成:开发期调试时手抖发了一条非 JSON 文本,不能让整个连接挂掉,否则你会以为是服务端的问题,查半天。

4.2 界面骨架:border 布局三块

ExtJS 做这种后台界面是主场,Viewport加border布局,三块区域分别是在线成员、消息列表、输入区。注意border布局要求每个子项显式写region,而且至少要有一个center,少写一个就会整片白屏,报错还不明显。

Ext.application({ name: 'Chat', launch: function () { var me = this; me.channel = Ext.create('Chat.socket.Channel', { url: Chat.App.wsUrl, // 由页面注入,见 4.3 room: Chat.App.roomId }); me.channel.on('message', me.onServerMessage, me); // 第三个参数绑定作用域 me.channel.connect(); Ext.create('Ext.container.Viewport', { layout: 'border', items: [{ region: 'west', width: 200, title: '在线成员', xtype: 'grid', store: 'OnlineUsers', hideHeaders: true, columns: [ { dataIndex: 'nick', flex: 1 } ] }, { region: 'center', layout: 'border', items: [{ region: 'center', xtype: 'chatMessageList' // 见 4.3 }, { region: 'south', height: 130, xtype: 'panel', layout: 'fit', items: [{ xtype: 'textareafield', itemId: 'input', emptyText: '回车发送,Shift+回车换行' }] }] }] }); }, onServerMessage: function (msg) { var list = Ext.ComponentQuery.query('chatMessageList')[0]; <p> <a href="https://download.csdn.net/download/weixin_38669628/11523994" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进