首页 / 资讯中心 / 文章详情

Tomcat8+Java7+ExtJS WebSocket聊天室实战

Tomcat8+Java7+ExtJS WebSocket聊天室实战 ★ FEATURED ARTICLE
简介一套基于 Tomcat8、Java7 与 ExtJS 搭建的 WebSocket 实时聊天室完整源码面向已具备 Java Web 基础、希望打通服务端推送与富客户端界面联动的开发者。服务端依托 Tomcat8 容器与 JSR 356 提供的 javax.websocket 端点通过 ServerEndpoint 生命周期回调、连接池管理与消息广播完成收发前端用 ExtJS 组件搭建输入框、消息列表与聊天窗口可作为实时通信从入门到进阶的实践样本。压缩包共 1753 个文件、约 7.01MB其中大量 gif 与 png 为框架图标及主题素材scss 与 css 对应皮肤样式js 承载前端逻辑与组件定义另有少量 java、class、jar 及 xml 构成服务端代码与工程配置。已有 263 人学习下载。读者可据此理清握手连接、消息编解码、在线用户管理与历史消息展示的完整链路并参考其中的部署调试思路。1. 从一个内网协作需求说起Tomcat8 Java7 ExtJS 的聊天室为什么还要手写 WebSocket某内网协作工具的前端是 ExtJS 管理台后端只有 Tomcat8 和 Java7需求是在不改动现有部署结构的前提下加一个聊天室群聊、私聊、在线列表、断线重连。很多同学第一反应是上 Spring Boot 或 Node但现场环境不允许加新运行时于是把目光放回 Tomcat8 自带的 JSR-356 WebSocket。这套源码和工具清单围绕一个最小闭环展开服务端用ServerEndpoint管连接前端用 ExtJS 封装适配器消息走 JSON 协议最后用检查表验收。适合正在做聊天室毕设或课设的人也适合要在老管理台里嵌实时消息的开发者。2. 总体架构与技术选型连接层、路由层、视图层的职责边界2.1 选型对比轮询、长轮询与 JSR-356 原生 WebSocket聊天室最核心的动作是“服务端有新消息时浏览器能立刻知道”。用传统轮询前端每隔几秒发一次请求消息延迟取决于轮询间隔连接数一多Tomcat 的线程池会被大量空请求占住长轮询稍好一点但每次消息推送都要重新建立一次 HTTP 请求服务端和前端都要维护挂起状态代码复杂度并不低。SSE 是单向推送做在线列表广播够用但私聊和“正在输入”这类客户端到服务端的事件还得另开接口协议会变成两套。Tomcat8 实现了 JSR-356也就是 Java API for WebSocket服务端用注解声明端点容器负责握手和帧解析。对这套项目来说选它的理由很直接不新增运行时不引入额外容器Java7 能编译ExtJS 前端用浏览器原生WebSocket对象就能对接。代价是 JSR-356 只提供连接和消息收发会话表、消息路由、心跳、重连都要自己写。这恰好是方案里最值得展开的部分也是课程设计能讲出设计含量的地方。方式实时性服务端资源占用前端改造量适用场景短轮询秒级延迟空请求多小消息频率极低长轮询准实时挂起连接多中兼容旧浏览器SSE单向实时连接常驻中通知、广播WebSocket双向实时连接常驻但帧开销小中聊天室、协作提示选型表不要只写“WebSocket 更好”要把“为什么当前环境不能选别的”写清楚答辩时老师通常会追问这一句。2.2 三层架构接入层、会话路由层、ExtJS 视图层这套聊天室按三层划分。接入层由 Tomcat8 的 WebSocket 容器承担负责 HTTP Upgrade 握手、帧的编解码、连接超时控制会话路由层是 Java 代码里的 Endpoint、SessionRegistry 和 MessageRouter负责“谁在线”“消息发给谁”“发不出去怎么清理”视图层是 ExtJS 的聊天面板、在线用户表格和输入框负责把用户操作变成 JSON 帧把收到的 JSON 帧变成 Store 里的记录。三层之间只靠 JSON 消息协议耦合前端不关心服务端用的是ConcurrentHashMap还是别的会话容器服务端也不关心 ExtJS 用的是 Grid 还是 DataView。这样做的好处是后面要把在线列表从表格换成树、要把消息存储从内存换成表改动都收在一层里。课程设计里常犯的错是把广播逻辑写进前端让每个客户端收到消息后再转发给别人结果一端掉线整条链路就断这种设计在答辩时很容易被问住。数据流可以按一条群聊消息走一遍用户在 ExtJS 输入框点发送适配器把{type:chat, to:all, content:...}转成字符串通过WebSocket.send()发到/chat/{userId}服务端OnMessage收到后解析 type交给 MessageRouterRouter 从 SessionRegistry 取出所有在线 Session逐个调用发送方法每个前端收到后把消息 add 到消息 Store聊天面板只渲染新增行。整条链路里没有轮询也没有额外 HTTP 接口。2.3 四个核心模块与目录划分把代码按职责拆成四个模块后面排查问题时能快速定位。连接管理模块负责ServerEndpoint的生命周期方法和 Session 注册注销消息路由模块负责协议解析、群聊广播、私聊定向发送在线状态模块负责用户列表和最后活跃时间前端适配模块负责 ExtJS 的 WebSocket 封装、重连和 Store 联动。目录不必照搬某个框架但要让别人一眼看出哪块归哪块。webapp/ chat/ app/ view/ # ChatPanel、OnlineGrid、MessageList store/ # OnlineUsers、Messages ux/ # WebSocketAdapter controller/ # ChatController app.js src/ main/ java/ com/example/chat/ ws/ # ChatEndpoint、ChatConfigurator service/ # SessionRegistry、MessageRouter model/ # ChatMessage、UserSession上面这份目录把“连接”和“路由”分开放ws包只处理 WebSocket 协议层的事情service包不依赖javax.websocket之外的容器类。实际交付时如果项目是传统 Web 工程而不是 Maven 结构把src换成src/main/java对应的源码目录即可包名保持一致后面贴的代码不用改。2.4 连接生命周期与数据流JSR-356 的端点有四个生命周期回调OnOpen在握手成功后触发OnMessage在收到文本或二进制帧时触发OnClose在连接关闭时触发OnError在连接或发送出错时触发。聊天室的会话表就在OnOpen里写入在OnClose和OnError里删除。这里有一个容易被忽略的点OnClose不保证一定执行比如进程被 kill、网络中途断开、容器强制回收所以发送消息时还要做一次session.isOpen()判断和失败清理不能只依赖关闭回调。连接建立后前端适配器会先发一条join消息服务端回一条online消息带当前在线列表之后每 25 秒发一次ping服务端回pong同时刷新该用户的最后活跃时间。超过 60 秒没有收到任何帧Tomcat 会按setMaxIdleTimeout关闭连接前端收到onclose后进入重连流程。这个“心跳 空闲超时 前端重连”的组合是后面验收时最能体现设计完整性的部分。3. 服务端落地会话表、消息协议与心跳清理的 Java7 写法3.1 ServerEndpoint 的生命周期方法与参数获取服务端端点用注解声明路径把用户标识放在路径参数里前端连接时拼上去。这样不用在握手阶段解析 query stringPathParam在OnOpen和OnMessage里都能直接拿到。Java7 没有 lambda集合初始化用传统写法代码看起来啰嗦一点但和 Tomcat8 的 JSR-356 实现完全兼容。ServerEndpoint(value /chat/{userId}, configurator ChatConfigurator.class) public class ChatEndpoint { private static final ConcurrentHashMapString, Session ONLINE new ConcurrentHashMapString, Session(); private static final ConcurrentHashMapString, Long LAST_ACTIVE new ConcurrentHashMapString, Long(); OnOpen public void onOpen(Session session, PathParam(userId) String userId) { // 空闲超时设成 60 秒前端 25 秒发一次心跳留出重试余量 session.setMaxIdleTimeout(60000L); ONLINE.put(userId, session); LAST_ACTIVE.put(userId, Long.valueOf(System.currentTimeMillis())); // 把当前在线用户推给新连接格式由 MessageRouter 统一封装 MessageRouter.sendOnlineList(session, ONLINE.keySet()); } OnMessage public void onMessage(String text, Session session, PathParam(userId) String userId) { LAST_ACTIVE.put(userId, Long.valueOf(System.currentTimeMillis())); ChatMessage msg ChatMessage.parse(text); if (msg null) { MessageRouter.sendError(session, BAD_MESSAGE); return; } MessageRouter.route(userId, msg); } OnClose public void onClose(Session session) { String userId session.getPathParameters().get(userId); if (userId ! null) { ONLINE.remove(userId, session); LAST_ACTIVE.remove(userId); MessageRouter.broadcastOnlineList(ONLINE.keySet()); } } OnError public void onError(Session session, Throwable error) { String userId session.getPathParameters().get(userId); if (userId ! null) { ONLINE.remove(userId, session); LAST_ACTIVE.remove(userId); } // 这里只记日志不再向已出错的连接写消息 LoggerFactory.getLogger(ChatEndpoint.class).error(ws error, user userId, error); } }这段代码里ONLINE.remove(userId, session)用了双参数删除只有当前 Session 和表里的一致才删避免用户重连后旧连接的OnClose把新连接误删。setMaxIdleTimeout(60000L)的单位是毫秒前端心跳间隔要小于它否则连接会被容器提前关掉。ChatConfigurator用来在握手前做 Origin 校验或注入依赖如果没有额外配置可以去掉 configurator 属性不影响主流程。3.2 会话表设计ConcurrentHashMap 与用户维度索引会话表用ConcurrentHashMapString, Sessionkey 是用户 IDvalue 是当前连接。聊天室场景读多写少广播时要遍历所有 SessionConcurrentHashMap的弱一致性迭代器允许遍历时发生删除不会抛ConcurrentModificationException。如果要支持同一账号多端登录value 要换成SetSession或CopyOnWriteArrayListSessionkey 仍然用用户 ID发送时对该用户的所有连接逐个写。在线状态还需要一张“最后活跃时间”表用来做兜底清理。Tomcat 的空闲超时会关闭连接但某些网络环境下关闭事件可能延迟定时任务每 30 秒扫一遍LAST_ACTIVE超过 90 秒没有更新的用户直接从ONLINE里移除并广播在线列表。这个定时任务用ScheduledExecutorService起单线程就够不要每个连接起一个线程否则连接数一多线程数会失控。public final class SessionRegistry { private static final ScheduledExecutorService CLEANER Executors.newSingleThreadScheduledExecutor(); static { CLEANER.scheduleAtFixedRate(new Runnable() { public void run() { long now System.currentTimeMillis(); for (Map.EntryString, Long entry : ChatEndpoint.LAST_ACTIVE.entrySet()) { if (now - entry.getValue().longValue() 90000L) { Session session ChatEndpoint.ONLINE.remove(entry.getKey()); ChatEndpoint.LAST_ACTIVE.remove(entry.getKey()); closeQuietly(session); } } } }, 30L, 30L, TimeUnit.SECONDS); } private static void closeQuietly(Session session) { if (session ! null session.isOpen()) { try { session.close(new CloseReason( CloseReason.CloseCodes.GOING_AWAY, idle timeout)); } catch (IOException ignored) { // 关闭失败不再重试Map 已经移除连接会被容器回收 } } } }这段清理逻辑的关键参数是 90 秒阈值它要比setMaxIdleTimeout的 60 秒大给容器留出关闭和回调的时间。closeQuietly里捕获IOException后不再重试因为会话已经从表里移除重试没有意义反而可能把异常日志刷满。定时任务用静态块启动项目里如果有 Spring 容器可以改成PostConstruct初始化但不要在每次连接时新建线程池。3.3 消息协议与路由群聊、私聊、系统消息消息协议用 JSON字段不多但要在前端和服务端保持一致。type区分消息类型from由服务端根据路径参数填to对群聊填all对私聊填目标用户 IDcontent是文本内容ts是服务端时间戳。服务端解析后不信任前端传来的from统一用路径参数覆盖避免冒充。协议表如下字段类型说明typestringchat、private、system、ping、pong、onlinefromstring服务端填充的发送者 IDtostringall 或目标用户 IDcontentstring文本内容system 消息为提示文案tslong服务端毫秒时间戳usersarray仅 online 消息携带在线用户 ID 列表路由方法收到chat时遍历ONLINE广播收到private时只发给to对应的 Session收到ping时回pong。广播时不要在主线程里同步写所有连接某个客户端网络慢会拖住整个广播可以把每个发送包成一个任务丢给线程池但线程池要有界队列满了就丢弃并记日志避免消息堆积把内存撑爆。课程设计规模小直接同步写也可以但要在代码注释里写清楚这是简化处理答辩时能说明取舍。public static void route(String from, ChatMessage msg) { if (ping.equals(msg.getType())) { sendText(ONLINE.get(from), {\type\:\pong\,\ts\: System.currentTimeMillis() }); return; } if (chat.equals(msg.getType())) { msg.setFrom(from); msg.setTs(System.currentTimeMillis()); String json msg.toJson(); for (Session session : ONLINE.values()) { sendText(session, json); } return; } if (private.equals(msg.getType())) { msg.setFrom(from); msg.setTs(System.currentTimeMillis()); Session target ONLINE.get(msg.getTo()); if (target null) { sendError(ONLINE.get(from), USER_OFFLINE); return; } sendText(target, msg.toJson()); sendText(ONLINE.get(from), msg.toJson()); } }route里对私聊做了“发给目标并回显给发送者”的处理前端不用自己往消息列表里补一条所有消息都以服务端回执为准这样多端登录时消息顺序一致。sendText内部要做空判断和isOpen判断发送失败时从ONLINE移除对应会话避免坏连接一直留在表里。3.4 心跳、空闲超时与失效会话清理心跳不是业务消息但它是聊天室能不能长期挂住的关键。前端每 25 秒发一次ping服务端在OnMessage里刷新LAST_ACTIVE并回pong。如果前端连续三次没有收到pong就主动关闭连接并进入重连。服务端这边setMaxIdleTimeout(60000L)是容器级兜底定时清理是应用级兜底两层机制同时存在任何一层失效另一层还能收住。发送方法要加锁原因是 JSR-356 的RemoteEndpoint.Basic不保证线程安全多个业务线程同时往同一个 Session 写可能出现IllegalStateException或消息截断。用synchronized (session)包住sendText是最简单的做法连接数不大时开销可以接受。如果追求吞吐可以改用getAsyncRemote()加发送队列但异步发送的回调里不能再同步写同一个 Session否则会死锁。这部分在下一章的避坑里还会展开。public static void sendText(Session session, String text) { if (session null || !session.isOpen()) { return; } synchronized (session) { try { session.getBasicRemote().sendText(text); } catch (IOException e) { // 写失败说明连接已经不可用直接从在线表移除 String userId session.getPathParameters().get(userId); if (userId ! null) { ONLINE.remove(userId, session); } } } }sendText是整套服务端代码里被调用次数最多的方法广播、私聊、系统提示都走它。把空判断、锁、失败清理收在一个方法里路由层就不用重复写这些边界处理。注意session.getPathParameters()在OnClose之后调用可能返回空 Map所以移除时先判断userId是否为 null避免ConcurrentHashMap的 key 为 null 抛异常。4. 前端落地ExtJS 组件树与 WebSocket 适配器的对接方式4.1 ExtJS 视图结构Viewport、聊天面板、在线列表、输入区前端用 ExtJS 的 Viewport 做整体布局左侧是在线用户 Grid右侧是消息列表加输入区。消息列表可以用Ext.view.View配itemTpl也可以用 Grid 配列前者渲染轻、适合消息流后者方便做“按用户筛选”。在线列表用 GridStore 的字段是userId、name、status收到online消息时整表替换收到用户上下线时增量 add/remove。输入区用Ext.form.Panel回车发送发送按钮在请求发出后置灰收到服务端回显再恢复。组件树不要建得太深聊天面板里套太多panel会让布局计算变慢消息一多就能感觉到卡顿。消息列表用flex: 1撑满剩余高度输入区固定高度在线列表给一个最小宽度。所有需要随窗口变化的地方用layout: border或layout: fit不要用绝对定位否则分辨率一变就错位。4.2 WebSocket 适配器连接、重连、事件总线ExtJS 没有内置 WebSocket 类需要自己封一个Ext.util.Observable子类把onopen、onmessage、onclose、onerror转成 ExtJS 事件。适配器负责拼 URL、发消息、心跳、重连业务控制器只监听message和open事件。这样控制器里不出现原生WebSocketAPI后面换传输方式也不用改业务代码。Ext.define(Chat.ux.WebSocketAdapter, { extend: Ext.util.Observable, config: { url: null, userId: null }, constructor: function (config) { this.initConfig(config); this.callParent(arguments); this.reconnectDelay 1000; this.maxDelay 30000; this.manualClose false; this.heartbeatTimer null; this.connect(); }, connect: function () { var me this; me.socket new WebSocket(me.getUrl() / encodeURIComponent(me.getUserId())); me.socket.onopen function () { me.reconnectDelay 1000; me.startHeartbeat(); me.fireEvent(open); }; me.socket.onmessage function (event) { var msg Ext.JSON.decode(event.data, true); if (msg) { me.fireEvent(message, msg); } }; me.socket.onclose function () { me.stopHeartbeat(); me.fireEvent(close); if (!me.manualClose) { me.scheduleReconnect(); } }; me.socket.onerror function () { me.fireEvent(error); }; }, startHeartbeat: function () { var me this; me.stopHeartbeat(); me.heartbeatTimer setInterval(function () { me.send({ type: ping, ts: new Date().getTime() }); }, 25000); }, stopHeartbeat: function () { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer null; } }, scheduleReconnect: function () { var me this; setTimeout(function () { me.connect(); }, me.reconnectDelay); me.reconnectDelay Math.min(me.reconnectDelay * 2, me.maxDelay); }, send: function (obj) { if (this.socket this.socket.readyState WebSocket.OPEN) { this.socket.send(Ext.JSON.encode(obj)); } }, close: function () { this.manualClose true; this.stopHeartbeat(); if (this.socket) { this.socket.close(); } } });适配器里reconnectDelay从 1 秒开始翻倍上限 30 秒避免服务端重启时所有客户端在同一秒重连把连接数打满。manualClose用来区分“用户主动关闭”和“网络异常关闭”主动关闭不触发重连。Ext.JSON.decode(event.data, true)的第二个参数是安全模式解析失败返回 null不会抛异常打断事件循环。心跳定时器在onclose里必须清掉否则重连后会有多个定时器同时发 ping。4.3 Store 与消息渲染避免全量重绘消息 Store 的字段包括from、content、ts、type收到chat或private消息时用store.add追加一条不要用loadData全量替换。全量替换会让整个消息列表重新渲染消息一多就能看到明显闪烁而且滚动位置会丢。追加之后判断用户是否在底部如果在底部就滚到底如果用户正在往上翻历史消息就不要强制滚动给一个“有新消息”的提示按钮。在线用户 Store 的更新策略相反online消息到达时可以loadData全量替换因为在线列表通常只有几十条全量替换的代价比逐条比对小。但userOnline和userOffline这类增量消息要用add和remove不要每次都全量拉一遍。两个 Store 的更新方式分开是前端性能上最容易被忽略的一点。Ext.define(Chat.controller.ChatController, { extend: Ext.app.Controller, init: function () { var me this; me.adapter Ext.create(Chat.ux.WebSocketAdapter, { url: ws:// location.host /chat, userId: window.currentUserId }); me.adapter.on(message, me.onWsMessage, me); me.adapter.on(open, me.onWsOpen, me); }, onWsOpen: function () { this.adapter.send({ type: join }); }, onWsMessage: function (msg) { var msgStore Ext.getStore(Messages); var onlineStore Ext.getStore(OnlineUsers); if (msg.type chat || msg.type private || msg.type system) { msgStore.add({ from: msg.from, content: msg.content, ts: msg.ts, type: msg.type }); this.scrollToBottomIfNeeded(); return; } if (msg.type online) { onlineStore.loadData(msg.users || []); return; } if (msg.type userOnline) { onlineStore.add({ userId: msg.userId, status: online }); return; } if (msg.type userOffline) { onlineStore.remove(onlineStore.getById(msg.userId)); } }, scrollToBottomIfNeeded: function () { var view Ext.getCmp(messageList).getView(); var el view.getEl(); if (el el.dom.scrollHeight - el.dom.scrollTop - el.dom.clientHeight 40) { el.dom.scrollTop el.dom.scrollHeight; } } });控制器只做分发不直接操作 WebSocket 对象消息 Store 和在线 Store 的更新逻辑分开写。scrollToBottomIfNeeded里的 40 像素阈值是经验值小于这个距离视为“用户在底部”可以自动滚动超过这个距离说明用户正在看历史消息不要打扰。Ext.getCmp在大型项目里不推荐但聊天室这种单页面够用如果控件有reference用lookupReference更稳。4.4 前端协议对接与错误提示前端发消息时不要带from服务端会覆盖私聊消息带to群聊to填all。发送前做一次空内容判断连续回车不要发空消息。服务端返回error类型时在输入区上方显示一行提示不要用alert打断用户。连接断开时把输入框置灰重连成功后恢复用户不需要手动刷新页面。错误码不要太细前端只需要区分“消息格式错误”“对方不在线”“连接已断开”三类。BAD_MESSAGE通常是前端 JSON 拼错开发阶段出现上线后不应该出现USER_OFFLINE是正常业务分支提示“对方已离线”即可连接断开由适配器的close事件统一处理不需要服务端再发错误消息。把这三类分开前端提示文案不会互相覆盖。5. 避坑与排查握手 404、并发写、会话泄漏等五个高频问题5.1 握手 404路径、容器依赖与打包冲突现象是浏览器控制台报WebSocket connection to ws://... failed: Error during WebSocket handshake: Unexpected response code: 404服务端日志里看不到OnOpen。原因通常有三种前端拼的路径和服务端ServerEndpoint的值不一致项目把websocket-api.jar打进了WEB-INF/lib和 Tomcat 自带的实现冲突web.xml的metadata-completetrue让容器不扫描注解。解决时先看 Tomcat 的logs/localhost日志有没有端点注册信息再检查WEB-INF/lib里有没有websocket-api或tomcat-websocket的 jar有就移掉编译期依赖用providedscope。路径上注意前后端都带没带尾斜杠/chat/{userId}和/chat/是两回事。5.2 并发写异常RemoteEndpoint 的线程安全问题现象是广播时偶尔抛java.lang.IllegalStateException: The remote endpoint was in state [TEXT_FULL_WRITING]或者某个客户端收到的消息少了一段。原因是多个线程同时调用同一个 Session 的getBasicRemote().sendText()JSR-356 不保证RemoteEndpoint.Basic的线程安全。解决方法是给发送方法加synchronized (session)把同一个 Session 的写操作串行化如果连接数很大改成每个 Session 一个发送队列由单线程消费队列业务线程只入队。不要在被锁住的代码里做 JSON 序列化以外的耗时操作否则广播会被慢客户端拖住。5.3 会话泄漏onClose 没触发与 Map 只增不减现象是运行一段时间后在线列表里出现已经关掉页面的用户广播时sendText一直失败内存缓慢上涨。原因是OnClose在网络异常、容器强制回收、进程被 kill 时不一定执行而代码只依赖OnClose删除会话。解决方法是三层兜底发送失败时从ONLINE移除LAST_ACTIVE超过阈值由定时任务清理OnError里也执行移除。移除时用remove(key, value)双参数版本防止旧连接的回调误删新连接。上线前用jmap或容器自带的管理页看一次 Session 数量连接关闭后数量应该回落。5.4 重连风暴前端退避与后端限流现象是服务端重启或网络抖动后所有客户端在同一秒重连Tomcat 的线程数瞬间打满日志里大量握手失败。原因是前端重连间隔固定没有退避也没有随机抖动。解决方法是在适配器里做指数退避间隔从 1 秒翻倍到 30 秒并在每次间隔上加 0 到 1 秒的随机数把重连时间打散。服务端可以在ChatConfigurator的beforeRequest里按 IP 做简单的计数限流超过阈值直接拒绝握手。不要在前端用setInterval无限重试重试次数要有上限超过之后提示用户手动刷新。5.5 跨域与接入层配置Origin 校验与超时参数现象是本地开发时前端页面在 8080WebSocket 服务在另一个端口浏览器握手被拒绝或者经过接入层转发后Upgrade头丢失握手变成普通 HTTP 请求。原因是 WebSocket 握手虽然不受同源策略的完全限制但浏览器会带Origin服务端可以校验接入层如果没有放行Upgrade和Connection头握手无法完成。解决方法是在ChatConfigurator里读取Origin只允许配置过的域名接入层配置里显式放行Upgrade、Connection头并把读超时调大至少大于心跳间隔。本地开发可以把前端和服务端配到同一个端口用相对路径拼ws://减少跨域变量。注意接入层配置改完后先用浏览器开发者工具的 Network 面板看握手响应码101 才是切换协议成功200 或 403 都要回头查 Origin 和路径。6. 验收与答辩一份能现场演示的检查表和讲解顺序验收不要只看“能发消息”要按连接生命周期逐项过。下面这份表可以直接当答辩演示脚本每项操作完看一眼浏览器和服务端日志通过标准写清楚避免现场扯皮。检查项操作通过标准连接建立打开两个浏览器窗口分别登录不同用户服务端日志出现两次onOpen在线列表显示两人群聊广播A 发一条群聊B 观察B 立刻收到消息顺序与发送顺序一致私聊定向A 选择 B 发私聊C 观察只有 B 和 A 收到C 不出现该消息在线列表关闭 B 的页面A 的在线列表在数秒内移除 B心跳与超时断网 30 秒再恢复前端进入重连恢复后能继续收发会话释放连续开关页面 20 次服务端ONLINE大小回落到实际在线数并发发送两个用户同时快速发消息不出现IllegalStateException消息不串行错乱答辩讲解按“需求—选型—数据流—演示—排错”的顺序走不要一上来念代码。先用一句话说清楚为什么不用轮询内网环境已有 Tomcat8JSR-356 不需要新增运行时实时性更好。然后画一条消息从 ExtJS 输入框到对方 Store 的路径指着代码说清楚OnOpen注册、route分发、sendText加锁这三处。演示时故意关掉一个窗口让对方看到在线列表变化再故意断网几秒让重连退避生效。老师如果问“消息可靠吗”不要答“可靠”答“当前是内存态服务端不持久化断线期间的消息不补发这是这套方案的边界如果要补发得加消息表和离线队列”。把边界说清楚比硬撑更加分。我最早做这套聊天室时只测了两个人正常发消息没测“关页面后会话有没有删”结果演示到一半在线列表里挂着一排已离线用户现场很尴尬。从那以后我每次交付前都强制走一遍断线重连和会话清理的检查希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站