资讯详情 Tomcat 8+Java 7+ExtJS WebSocket 聊天室实现
📅 2026/10/23 16:04:41
简介面向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 8JDK 就停在 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/errcidstring否客户端生成的消息 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房间号Mapuid, Session广播时遍历用 uid 做键天然去重SESSIONSSessionMember(uid, nick, room)连接断开时反查是谁、在哪个房间UID_INDEXuidSession全局单点查找用于顶号同一账号重复登录踢掉旧连接ROOMS里刻意不用SetSession而用MapString, 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;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-356Java 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(); MapString, 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_POLICY1008主动关闭。前端会先收到onopen再收到onclosecode是 1008可以根据这个码在界面上提示「请先登录」而不是无脑重连——这一步不做未登录用户会把重连逻辑打成死循环服务器日志里全是握手请求。注意登录态里存的是uid这种不可猜的标识不要存昵称当身份用。昵称可以改改完不应该影响历史消息的归属。3.3 会话注册表ConcurrentHashMap 与发送锁注册表是整个服务端唯一的有状态部分也是并发问题最集中的地方。核心结构用ConcurrentHashMap起手但光换成并发容器还不够真正容易翻车的是发送动作本身。public final class ChatRegistry { // room - (uid - session)用 uid 做 key 才能天然去重 private static final MapString, MapString, Session ROOMS new ConcurrentHashMapString, MapString, Session(); private static final Gson GSON new Gson(); public static void join(String room, String uid, String nick, Session s) { MapString, Session members ROOMS.get(room); if (members null) { ROOMS.putIfAbsent(room, new ConcurrentHashMapString, 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) { MapString, 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) { MapString, 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 hrefhttps://download.csdn.net/download/weixin_38669628/11523994 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p