ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java即时通讯实战:SpringBoot+WebSocket+Vue快速搭建聊天系统

Java即时通讯实战:SpringBoot+WebSocket+Vue快速搭建聊天系统 简介一套面向毕业设计场景的即时通讯管理系统源码包采用Java SpringBoot Vue ElementUI实现前后端分离覆盖登录注册、会话列表、通讯录、好友管理、收藏、消息发送、文件下载、图片预览、个人资料管理等模块消息支持文本、图片与文件传输。资源包以rar形式封装共1889个文件、74.55MB既有CSS/LESS/JS等前端样式与交互脚本也有Java类、XML/MyBatis配置、SQL数据库备份等后端核心内容可据此直接搭建可运行环境。已有619人学习/下载适合Java与Vue学习者对照完整项目理解接口设计、WebSocket通信及文件处理方式。整套代码包含前后端源码和数据库备份并且从代码结构能看到登录鉴权、聊天会话、文件上传等关键模块的实现方便毕设演示与二次开发。系统还预留了扩展点结合Redis缓存与会话管理能更好地理解实时聊天场景下的状态同步与消息推送。1. 即时通讯管理系统这套 Java 全家桶如何用一天跑通消息闭环即时通讯管理系统JavaSpringBootvueelementui说白了就是一套能直接搬到业务里的聊天架子SpringBoot 负责登录、好友关系、消息落库和 WebSocket 路由Vue ElementUI 负责管理后台和聊天界面。它适合接“三天内给工单系统加在线客服”这种活儿也适合需要一个前后端闭环项目来演示和答辩。比起从 Netty 开始造轮子这套组合能把交付时间压到一两天。这个项目真正的价值不在高并发而在用最小成本把“消息从 A 推到 B”的完整闭环跑通。接下来按选型、后端、前端、避坑、进阶往下讲所有代码和参数你都可以直接抄坑也先帮你踩一遍。2. 技术选型与数据模型为什么是 SpringBoot“包办”而非 Netty 单独干2.1 技术栈选型的三个理由即时通讯系统最核心的问题是消息实时性而很多人一上来就想上 Netty。实际上 SpringBoot 自带 spring-boot-starter-websocket内部完整实现了 WebSocket 协议栈单机能撑几千个连接对中小型系统和课程设计完全够用。选 SpringBoot 的第一个理由是“业务不用分家”用户注册、好友、消息历史、管理员操作全部走同一套 MVC 接口只有实时推送走 WebSocket事务边界清晰后续加功能也容易。第二个理由是团队技能匹配。Java 后端排查问题方便随便搜 springboot websocket 都能找到成熟的踩坑记录比招一个懂 Netty 的专门人才成本低得多。第三个理由是前后端分离部署容易SpringBoot 默认能配置跨域WebSocket 握手阶段可以放开 OriginVue 前端打包后丢 Nginx 就能对接。前端选 Vue ElementUI 的理由也很实际聊天页面看着复杂拆开就是会话列表、聊天窗口、用户列表、设置弹窗ElementUI 的表格、输入框、徽标、菜单都有现成组件。Vue 的单文件组件能很好地把会话列表、消息流、输入区拆成独立组件数据更新逻辑比 jQuery 时代清晰。常见做法是 PC 端管理后台用 Vue 2 ElementUI如果还要适配手机浏览器优先 Vue 3 Element Plus组件 API 更现代。提示Vue 2 和 Vue 3 的组件体系和生态完全不同项目启动前先定死版本不要开着 Vue 3 的工程去复制 Vue 2 的 ElementUI 代码这两套东西混在一起会让前端同事想骂人。2.2 数据库表设计至少五张表别把消息全塞一张表即时通讯的存储需求可以分成三类人、关系、消息。我一般建五张表用户表、好友关系表、群组表、群成员表、消息表。这里最容易被忽略的是消息表拆分如果你同时有单聊和群聊建议拆成两张消息物理表否则一张表里既有 from/to 又有 group_id索引设计会互相打架。下面是精简版 SQL关键字按 MySQL 8 写字段命名直接对应 MyBatis-Plus 的实体类驼峰映射。CREATE TABLE im_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(64) NOT NULL UNIQUE COMMENT 登录名, nickname VARCHAR(64) NOT NULL COMMENT 显示名, password_hash VARCHAR(128) NOT NULL COMMENT BCrypt 加密后的密码, avatar_url VARCHAR(255) DEFAULT , status TINYINT DEFAULT 0 COMMENT 0离线 1在线 2隐身, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE im_friend ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL COMMENT 用户A, friend_id BIGINT NOT NULL COMMENT 用户B, remark VARCHAR(64) DEFAULT COMMENT 备注名, status TINYINT DEFAULT 0 COMMENT 0正常 1拉黑, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_friend(user_id, friend_id) ); CREATE TABLE im_message ( id BIGINT AUTO_INCREMENT PRIMARY KEY, from_user_id BIGINT NOT NULL, to_user_id BIGINT NOT NULL, msg_type TINYINT DEFAULT 1 COMMENT 1文本 2图片 3文件 4语音, content TEXT NOT NULL COMMENT 文本内容或文件URL, is_read TINYINT DEFAULT 0 COMMENT 0未读 1已读, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_to_read(to_user_id, is_read, create_time) );逻辑说明im_user.status 只代表业务上的状态不能当作实时在线状态。真正的在线状态要由后端内存或 Redis 维护否则网络断了 status 还停在“在线”这个问题第三章专门讲。im_friend 的 UNIQUE KEY 保证 A-B 只能有一条关系记录加好友时统一让 user_id 小于 friend_id 存储查询会话列表时就不用 UNION 两张表。参数说明msg_type 用 TINYINT不要用 VARCHAR 存 text 这种字符串省空间而且前端枚举映射容易。content 用 TEXT 而不是 VARCHAR(255)图片消息存的是 URL文本消息可能好几 KBVARCHAR(255) 在长文本场景会报错。未读数查询最常见的条件是 to_user_id is_read create_time所以复合索引这么建。等单表消息量过了千万再考虑按用户 ID 哈希分表第一步别加这个复杂度。2.3 会话列表用一条 SQL 算出来聊天软件首页要显示“最近会话”每个会话要显示最后一条消息和时间。新手最容易写成循环查每个好友的最新消息好友一多就产生 N1 查询接口响应直接掉到秒级。常见做法是只查“我参与过的所有会话的最新一条”一条 SQL 就能搞定。SELECT m.*, u.nickname, u.avatar_url FROM im_message m JOIN ( SELECT CASE WHEN from_user_id #{currentUserId} THEN to_user_id ELSE from_user_id END AS peer_user_id, MAX(id) AS max_id FROM im_message WHERE from_user_id #{currentUserId} OR to_user_id #{currentUserId} GROUP BY peer_user_id ) t ON t.max_id m.id JOIN im_user u ON u.id t.peer_user_id ORDER BY m.id DESC;逻辑说明内层先用 CASE 把“当前用户参与的每一段对话”归一成 peer_user_id再取每个会话最大的消息 id。这一步很关键直接 GROUP BY peer_user_id 拿其它字段MySQL 虽然能查出来但取到的往往不是最新一条这是个经典翻车点。外层再 join 用户表拿昵称头像按消息 id 倒序就是最近会话顺序。参数说明这里的 #{currentUserId} 是 MyBatis 参数不是字符串拼接天然防止 SQL 注入。如果会话量特别大WHERE 里的 OR 可能让索引利用率下降MySQL 8 可以拆成两个子查询 UNION ALL 再合并效果更稳定。系统没到百万级会话之前上面这条 SQL 足够简单、足够能读。3. 后端实现SpringBoot 里的 WebSocket 长连接与消息推送3.1 WebSocket 还是 Netty按并发量选别按热度选很多人在面试被问“为什么不用 Netty”也有实际项目因为迷信 Netty 把工期拖了一倍。单机长连接在 3000 以下、单条消息不超过 1MB 的场景SpringBoot 的 WebSocket 支持完全够用。Netty 在你亲手改造之前省下的那点内存和线程开销业务系统根本体会不到反而要自己处理断线重连、粘包拆包、心跳检测这些都是 Spring 已经封装好的功能。我一般这么选超过 5000 并发在线或者需要自定义协议字段做消息优先级再上 Netty 自研协议。否则就用 spring-boot-starter-websocket。下面是最小依赖Java 8 SpringBoot 2.7.x 的常见写法dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency逻辑说明Redis 依赖在这里不是必须的。你如果只有一个后端实例所有 WebSocket 连接都在同一台机器上直接用内存 Map 管理即可。加了 Redis 是为了以后多实例部署用户 A 连在实例 1用户 B 连在实例 2实例 1 需要知道 B 在哪个实例上常见做法是用 Redis pub/sub 做跨实例消息转发但这是“以后”的事我建议项目先不加等真的有多实例需求再补避免一上来就维护两套状态。3.2 WebSocket 配置类与握手拦截器先建立连接再确认身份SpringBoot 里用 WebSocket 有两种姿势一种是用 ServerEndpoint另一种是用 Spring 的 WebSocketHandler。我用的是标准 WebSocketHandler因为它能跟 Spring 拦截器、安全框架无缝配合不依赖内嵌容器。先写配置类把 handler 和握手拦截器注册进去。Configuration EnableWebSocket public class ImWebSocketConfig implements WebSocketConfigurer { private final ChatWebSocketHandler chatHandler; private final AuthHandshakeInterceptor authInterceptor; public ImWebSocketConfig(ChatWebSocketHandler chatHandler, AuthHandshakeInterceptor authInterceptor) { this.chatHandler chatHandler; this.authInterceptor authInterceptor; } Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(chatHandler, /ws/chat) .addInterceptors(authInterceptor) .setAllowedOrigins(*); } }逻辑说明/ws/chat 是前端 new WebSocket 时要拼的路径。addInterceptors 注册的拦截器会在 HTTP 升级为 WS 之前执行适合放来源校验、日志和连接参数透传。setAllowedOrigins(*) 是前后端分离部署时的必要配置否则浏览器会因为前端域名与后端不一致直接拒绝握手。参数说明setAllowedOrigins(*) 会放开所有来源适合内网项目如果消息敏感这里改成显式填写前端域名。如果你在项目里集成了 Spring Security还要额外在 Security 配置里放行 /ws/chat否则请求会在握手前被拦截器挡掉这是最常见的“后端没报错但前端连不上”的原因之一。握手拦截器本身不校验身份原因很简单浏览器 WebSocket API 不支持自定义 Headertoken 放在 URL query 里又会进 Nginx 日志所以真实登录态放到 WebSocket 建立后的第一条消息里确认。拦截器只做连接来源记录。public class AuthHandshakeInterceptor implements HandshakeInterceptor { Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, MapString, Object attributes) { attributes.put(remoteAddr, request.getRemoteAddress() ! null ? request.getRemoteAddress().toString() : ); return true; } }逻辑说明beforeHandshake 返回 false 会直接拒绝握手前端触发 onerror。这里只存了 remoteAddr 做排查用真正判断“这个连接是谁”放到下一节的 auth 消息里这样可以避免 token 暴露在 URL 里。3.3 消息处理器用 ConcurrentHashMap 管理在线用户核心消息处理器我写成单例维护一个 ConcurrentHashMap 保存“已认证用户”和 WebSocketSession 的映射。为什么用 ConcurrentHashMap因为 WebSocket 的事件回调可能来自不同线程普通 HashMap 在并发 put 时会丢连接甚至出现两个连接互相覆盖。Component public class ChatWebSocketHandler extends TextWebSocketHandler { private static final ConcurrentHashMapLong, WebSocketSession SESSIONS new ConcurrentHashMap(); Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { JSONObject payload JSON.parseObject(message.getPayload()); String type payload.getString(type); if (auth.equals(type)) { Long userId authenticate(payload.getLong(userId), payload.getString(token)); if (userId null) { session.close(CloseStatus.NOT_ACCEPTABLE); return; } session.getAttributes().put(userId, userId); SESSIONS.put(userId, session); return; } Long senderId (Long) session.getAttributes().get(userId); if (senderId null) { session.close(CloseStatus.POLICY_VIOLATION); return; } Long receiverId payload.getLong(receiverId); WebSocketSession receiverSession SESSIONS.get(receiverId); if (receiverSession ! null receiverSession.isOpen()) { receiverSession.sendMessage(new TextMessage(payload.toJSONString())); } } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { Long userId (Long) session.getAttributes().get(userId); if (userId ! null) { SESSIONS.remove(userId); } } }逻辑说明收到第一条 auth 消息时后端解析 userId 和 token调用 authenticate 校验这个方法对应你自己的 JWT 或 Redis token 校验逻辑。校验通过才把 userId 放进 session attributes并登记到 SESSIONS。后续任何消息先检查 senderId 是否存在不存在直接关闭连接避免未认证连接占用资源。转发消息前要把消息写入 im_message 表这里没写是为了保持代码短。实际项目应该先落库再转发否则接收端离线后消息丢失。注意一个并发边界SESSIONS.get 和 sendMessage 之间接收者可能刚好断开所以取到 session 后必须立即 isOpen() 判断否则会抛 IOException。参数说明ConcurrentHashMap 的 key 用 Long 而不是 String避免前端传 1 和 01 造成两个连接。这个写法默认一个用户同时只有一个连接多端登录会在新连接 auth 成功时把旧连接踢掉具体做法放在第六章。3.4 在线状态与心跳别把 status 字段当实时状态前端每 30 秒发一条 ping后端收到后回 pong并更新该用户的最近活跃时间。很多人把在线状态做成直接查数据库 status 字段结果用户拔网线断了status 还是在线这就是典型的没有心跳超时。public class OnlineStatusService { private final MapLong, Long lastHeartbeat new ConcurrentHashMap(); public void heartbeat(Long userId) { lastHeartbeat.put(userId, System.currentTimeMillis()); } public boolean isOnline(Long userId) { Long last lastHeartbeat.get(userId); return last ! null (System.currentTimeMillis() - last) 60_000L; } }逻辑说明这个方案不是实时准确的但足够实用。WebSocket 的 close 回调在移动端锁屏或进程被杀时不一定触发服务端只能靠心跳超时来兜底。前端页面隐藏时不应该停心跳常见做法是用 Page Visibility API在页面回到前台时立刻补发一次 ping否则用户切个标签页回来就被误判成离线。参数说明60 秒超时要和前端 ping 间隔配合。前端每 30 秒 ping后端 60 秒没收到才判定离线刚好容忍一次丢包。办公网络这种环境稳定的可以缩到 45 秒移动网络经常抖动建议放宽到 90 秒。没有万能值按实际运维环境调。4. 前端实现Vue ElementUI 搭建聊天面板与会话列表4.1 Vue 环境配置与依赖安装先把 node-sass 这种坑躲过去前端部分最常见的问题不是代码而是环境。vue 安装依赖时 node-sass 编译失败、vue devtools 连不上、npm 源超时这些我都遇到过。给一个能一次过的环境建议Node.js 用 16 LTS 或 18 LTS先把 npm 镜像切换到 npmmirror再创建项目。npm install -g vue/cli vue create im-web # 进入项目后安装 element-ui 和常用依赖 npm install element-ui axios vue-router逻辑说明vue create 交互式创建项目时建议在配置项里勾选 Router。如果创建完了发现没勾也不用重新建项目直接 npm install vue-router 就行。ElementUI 在 Vue 2 项目的引入方式是在 main.js 里 Vue.use(ElementUI)Vue 3 要用 Element Plus这两套 API 完全不兼容混用必然报错。import Vue from vue; import ElementUI from element-ui; import element-ui/lib/theme-chalk/index.css; Vue.use(ElementUI); import router from ./router; new Vue({ router, render: h h(App), }).$mount(#app);参数说明这段代码是 Vue 2 的标准入口写法。如果是 Vue 3 Vite 工程要用 createApp(App).use(ElementPlus).mount(#app)入口完全不同。ElementUI 全量引入会稍大但省去按需配置的时间先跑通功能比纠结打包体积重要。4.2 会话列表与聊天窗口的组件拆分ElementUI 组件怎么拼聊天页聊天界面最忌讳把每个功能都塞进一个巨型组件。我会拆成三个ConversationList 负责会话列表MessagePanel 负责当前会话的消息流和输入区ChatView 负责把前两者组合并持有 WebSocket 连接。会话列表直接用 div 加上 el-badge 显示未读数聊天窗口用 el-input 做输入框消息列表用滚动容器。template div classmessage-panel div refscrollArea classmessage-list div v-formsg in currentMessages :keymsg.id :class[message-row, msg.fromUserId myUserId ? mine : theirs] span classbubble{{ msg.content }}/span /div /div div classinput-area el-input v-modeldraft typetextarea :rows3 placeholder输入消息Enter 发送 keyup.enter.exactsend/el-input el-button typeprimary clicksend发送/el-button /div /div /template逻辑说明v-for 的 key 必须用消息自增 id不能用 index。聊天列表经常插入新消息用 index 会导致 Vue 复用错误 DOM屏幕上已经显示的消息会串位。class 里的 mine 和 theirs 控制气泡靠左靠右这是聊天界面里最常见的样式逻辑。参数说明keyup.enter.exact 表示只按 Enter 时触发发送ShiftEnter 留给换行。如果用 keyup.enter用户在输入框里想换行直接就把消息发出去了这是测试几乎必提的体验问题。keyup.enter.native 是 Vue 2 的写法Vue 3 里组件事件机制改了直接去掉 native。4.3 WebSocket 前端连接与 vue 路由参数传递onopen 后先发 auth前端连接 WebSocket 的时机是登录成功之后。token 和 userId 存到 localStorage路由守卫里统一判断登录态。这里要强调token 不要拼在 WebSocket URL 的 query 里因为它会留在 Nginx 访问日志中。正确做法是连接建立后立刻发一条 auth 消息。// router/index.js router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (to.meta.requiresAuth !token) { next({ name: login }); } else { next(); } }); // ChatView.vue created() { const token localStorage.getItem(token); const userId localStorage.getItem(userId); const protocol window.location.protocol https: ? wss : ws; this.ws new WebSocket(${protocol}://${window.location.host}/ws/chat); this.ws.onopen () { this.ws.send(JSON.stringify({ type: auth, token, userId })); }; this.ws.onmessage (event) { const data JSON.parse(event.data); if (data.type pong) return; this.appendMessage(data); }; }逻辑说明beforeHandshake 阶段后端还没有用户身份所以 auth 消息必须在 onopen 后第一时间发出。后端收到 auth 前这个连接不会进入 SESSIONS也不会收到任何转发消息。这里协议里定义 type 字段和业务消息区分开业务消息也统一走这个入口。vue 路由参数在聊天场景里有个知名问题从会话列表点击 A 进入聊天页返回后再点 BVue 会复用同一个组件实例created 不会再触发。如果你只在 created 里拉历史消息第二次进入时消息还是 A 的。watch: { $route.params.friendId(newVal) { this.loadHistory(newVal); this.currentPeerId newVal; } }逻辑说明路由参数变了不等于组件销毁所以除了 created 里 loadHistory还要 watch 参数变化。这个细节不处理大概率出现“明明进了 B 的聊天框点发送却把消息发给了 A”的翻车现场。前端判断发消息给谁一律用 currentPeerId 而不是路由参数本身。5. 即时通讯项目避坑版本、校验、分页、回显四个高频雷区5.1 SpringBoot 版本太高导致的 WebSocket 连接失败现象项目能启动后端接口一切正常前端 new WebSocket 一直在 pendingNginx 报 502后端控制台看不到任何异常日志。原因SpringBoot 2.7 之后对 WebSocket 握手路径的匹配规则更严格SpringBoot 3.x 全面切换到 Jakarta EE。网上大量教程用的是 javax.websocket 的 ServerEndpoint 注解在 SpringBoot 3.x 里会直接类找不到或注解不生效。如果你用的是 Spring 的 WebSocketHandler 而不是 ServerEndpoint3.x 也能用但问题在于你复制到手的旧代码大概率是后者。解决先确认 pom 里的 spring-boot-starter-parent 版本。如果是 3.x把 ServerEndpoint 换成 Spring 的 WebSocketHandler或者改 Jakarta 包名。如果只是想把项目跑通就用 SpringBoot 2.7.x上面所有代码都基于这个版本。版本不是越新越好WebSocket 生态里旧代码的兼容性比新特性更重要。注意SpringBoot 大版本升级不只是版本号变化底层 Servlet、WebSocket、序列化组件可能全部换了坐标。即时通讯项目涉及长连接、会话管理和持久化升级前必须有完整的回归测试。5.2 ElementUI 表格选择框回显然后全选错误现象用 el-table 展示好友列表编辑用户回显时已经勾选了几个人重新加载数据后点表头的全选表格把没勾的人也选中了控制台还能看到 selection-change 事件连续触发两次。原因ElementUI 表格的全选状态是内部维护的回显时直接给绑定数组赋值虽然界面能显示勾选但表格内部并不知道这些行已经被选中。点击全选时内部状态和你外部绑定的数组没对齐就出现多选错选。解决回显一定要用表格实例的 toggleRowSelection 方法并且放在 nextTick 里等表格渲染完。this.$nextTick(() { const table this.$refs.memberTable; this.selectedRows.forEach(row { table.toggleRowSelection(row, true); }); });逻辑说明toggleRowSelection 是 ElementUI 官方提供的回显入口它会同时更新表格内部状态和外部绑定数组。直接改数组“看起来能显示”实际内部状态已经乱了。如果回显后还要支持全选更稳妥的做法是放弃表头全选用一个自定义 checkbox 单独控制避免和组件内部状态打架。5.3 消息中文乱码与富文本 XSS 过滤打架现象后端收到的消息中文变成问号或者用户发了一段
返回列表