☰
Java Web聊天系统实战:WebSocket与Spring Boot落地指南
2026/10/8 7:53:22 网站建设 项目流程

简介:面向Java Web课程设计的大作业聊天系统完整项目,适合需要完成类似选题的在校学生或入门开发者参考。内含项目文档说明,并按照config、controller、dao、dto、entity、service、utils、vo等包进行模块划分,后端使用JPA操作数据库,service层遵循接口实现规则,processor包中集成了过滤器、拦截器与监听器,目录结构清晰,便于理解分层开发与前后端交互设计。压缩包共138个文件,主体为66个Java源码文件,同时包含12个Vue组件、11个SCSS样式以及JS、HTML、配置文件等,前端工程化配置齐全,整体仅2.08MB,轻量易用。该资源已有1152人学习浏览,附有项目总文档与前端入口页面,能够帮助读者快速搭建运行环境并完成聊天系统核心功能。

1. Java Web大作业做聊天系统:先搞懂它到底在考什么

每年课程设计和毕业设计里,Java Web大作业选聊天系统的人特别多。你以为老师在考WebSocket?不是。聊天只是壳,真正考的是你能否把「注册登录、好友管理、消息存储、实时推送、前端渲染、部署演示」整条链路走通。一个能跑的聊天系统,意味着你同时碰过HTTP请求-响应和WebSocket长连接两套模型,这两者边界能用清楚,你才有底气在简历里写“熟悉Java Web开发”。这篇笔记适合正在赶大作业的在读学生,也适合准备把课程项目写进简历的求职者。我不打算给你整理一份完整源码,而是把最落地的技术方案、关键代码和踩坑现场讲透,照着搭,三到五个晚上能跑出可演示的版本。

2. 技术选型:别急着写代码,先把这套组合定下来

2.1 为什么我不建议用纯JSP+Servlet写聊天

如果你在网上搜“Java Web聊天系统”,会看到大量用JSP+Servlet实现的老项目,它们大多用Ajax轮询模拟实时聊天:前端每隔一两秒发一次HTTP请求,问服务器“有没有新消息”。这种方案能跑,但有一个根本性硬伤:HTTP是无状态请求-响应模型,每次请求都要重新建连、携带Cookie、走完Servlet生命周期,服务器根本没法“主动”告诉对方你发了消息。轮询有多延迟、数据库压力多大、消息乱序时前端怎么渲染,这些问题在答辩时都会变成老师追问的靶子。

聊天系统的核心场景是“消息主动推送”,这恰恰是WebSocket的强项:通过一次HTTP握手(101 Switching Protocols),把连接升级为全双工长连接,之后服务器可以随时往客户端写数据。Servlet 3.1的javax.websocket也能做,但大作业里你还要同时管登录、好友关系、消息记录和页面渲染,纯Servlet会让代码散落成片,改一个功能牵扯三四个文件,越写越没耐心。现在课程设计基本默认用Spring Boot,你打开IDEA 2024版本创建web项目时,选Spring Initializr已经成了常规操作,没必要逆着趋势走。

2.2 推荐技术栈与版本选型:Spring Boot 2.7.x + MyBatis-Plus + MySQL

技术层选型理由
后端框架Spring Boot 2.7.x内置Tomcat,javax.websocket包,网上教程最多,踩坑资料最好搜
ORMMyBatis-Plus单表CRUD不用写SQL,腾出时间做聊天核心逻辑
数据库MySQL 5.7或8.0老师机器上大概率都有,字符集和事务支持稳定
前端原生HTML+JS+WebSocket API不引Vue全家桶,答辩时少背一层框架概念
构建Maven默认选项,不要换成Gradle,避免环境不一致

为什么特意强调Spring Boot 2.7.x?因为Spring Boot 3.x把javax.websocket整体迁移到jakarta.websocket,网上能搜到的大作业资料绝大部分基于javax。你如果上3.x,就得自己处理命名空间差异和内置Tomcat版本匹配,对赶作业来说不划算。MyBatis-Plus不是必须,但能少写大量样板代码。网上有“mybatisplus根据java实体类生成创建表的sql语句”这类用法,我建议建表SQL还是手写:聊天系统就三张表,手写可控性最好,代码生成器反而会给你塞一堆看不懂的冗余字段。Spring Boot 2.7.x的内置Tomcat同时支持HTTP和WebSocket,共用8080端口,前端不需要额外配置网关路径。

3. 从建表到WebSocket推送:搭一个最小可跑的聊天系统

3.1 数据库设计:用户表、好友表、消息表,三张就够

CREATE TABLE `user` ( `id` INT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL, `password` VARCHAR(64) NOT NULL, `nickname` VARCHAR(50) DEFAULT '', `avatar` VARCHAR(255) DEFAULT '', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `friend` ( `id` INT NOT NULL AUTO_INCREMENT, `user_id` INT NOT NULL, `friend_id` INT NOT NULL, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_friend` (`user_id`, `friend_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `message` ( `id` INT NOT NULL AUTO_INCREMENT, `from_user_id` INT NOT NULL, `to_user_id` INT NOT NULL, `content` TEXT, `is_read` TINYINT(1) DEFAULT 0, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_users_created` (`from_user_id`, `to_user_id`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

user表存账号,friend表存好友关系,message表存聊天记录。is_read这个字段用来做未读数,批量标记已读在答辩时是个很好的展示点。索引设计上,历史消息最常用的查询是“我和某人之间的记录”,所以建了复合索引(from_user_id, to_user_id, created_at),数据量到十万级也能走索引。注意用utf8mb4而不是utf8,否则Emoji表情和生僻字在MySQL里直接变成问号,这个问题出现频率非常高,属于交作业前一晚才能发现的类型。

3.2 后端WebSocket端点:在线用户表、私聊消息转发与离线落库

@ServerEndpoint("/chat/{userId}") @Component public class ChatEndpoint { private static final ConcurrentHashMap<Integer, Session> ONLINE_USERS = new ConcurrentHashMap<>(); @OnOpen public void onOpen(@PathParam("userId") Integer userId, Session session) { // 新连接先踢掉旧连接,避免刷新页面时同一用户存在两个session ONLINE_USERS.remove(userId); ONLINE_USERS.put(userId, session); System.out.println("用户[" + userId + "]上线,当前在线:" + ONLINE_USERS.size()); } @OnMessage public void onMessage(String message, Session session, @PathParam("userId") Integer senderId) { JSONObject json = JSONObject.parseObject(message); // 心跳消息不落库,直接回pong if ("ping".equals(json.getString("type"))) { session.getBasicRemote().sendText("{\"type\":\"pong\"}"); return; } int receiverId = json.getIntValue("receiverId"); String content = json.getString("content"); // 先落库,再推送,保证历史消息可查 messageService.insert(senderId, receiverId, content); Session targetSession = ONLINE_USERS.get(receiverId); if (targetSession != null) { try { targetSession.getBasicRemote().sendText( "{\"fromUserId\":" + senderId + ",\"content\":\"" + content + "\"}"); } catch (IOException e) { // 推送失败说明对方连接已死,移除在线表 ONLINE_USERS.remove(receiverId); } } else { // 对方离线,消息已经落库,is_read保持0,登录后拉取未读 System.out.println("用户[" + receiverId + "]离线,消息已保存"); } } @OnClose public void onClose(@PathParam("userId") Integer userId) { ONLINE_USERS.remove(userId); System.out.println("用户[" + userId + "]下线"); } }

ONLINE_USERS必须用ConcurrentHashMap,不能用普通HashMap。WebSocket的onOpen、onMessage、onClose由Tomcat线程池中不同线程触发,HashMap在多线程并发put、remove时可能造成CPU 100%甚至死循环,这个坑在Java面试题里也经常出现。@ServerEndpoint("/chat/{userId}")的路径参数由前端连接时传入,用来建立“用户ID到连接”的映射,这是聊天系统最常见的设计。消息先落库再推送的顺序不能反过来,否则推送时网络闪断,消息在数据库里查不到,对方永远收不到。

3.3 前端连接与消息渲染:原生JS实现的最小聊天页面

const ws = new WebSocket("ws://" + location.host + "/chat/" + currentUserId); ws.onopen = function() { console.log("WebSocket连接已建立"); }; ws.onmessage = function(event) { const data = JSON.parse(event.data); // data: { fromUserId: 1, content: "你好" } appendMessage(data.fromUserId, data.content); }; ws.onclose = function() { console.log("连接已断开"); // 在这里做重连,后面章节专门讲 }; function sendMessage(receiverId, content) { if (ws.readyState !== WebSocket.OPEN) { alert("连接未就绪,请稍后重试"); return; } ws.send(JSON.stringify({ senderId: currentUserId, receiverId: receiverId, content: content })); }

连接地址用ws://而不是http://,端口自动沿用当前页面端口,因为Spring Boot内置Tomcat的HTTP和WebSocket共用8080,不需要单独配置。currentUserId从登录接口返回后存到全局变量,不要在URL里拼接密码等敏感参数。WebSocket握手时不能像HTTP那样自定义Header,所以鉴权信息要么放在URL查询参数里,要么放在子协议里,大作业场景下URL里带userId已经足够。

4. WebSocket连接管理:心跳、重连与三个必调参数

4.1 聊天为什么放着不动就静默掉线

很多人大作业本地跑通了,但页面放着几分钟不动,再发消息对方收不到,自己这边也没任何报错。这是WebSocket最典型的“静默掉线”:浏览器和服务器之间的连接经过NAT设备、运营商路由器、公司或学校防火墙,空闲连接会被中间设备回收。问题是回收时TCP层未必发RST包,WebSocket的onclose事件根本不会触发,两端都以为连接活着,实际上数据已经发不出去。这不是代码逻辑错,是网络链路现实,你必须用心跳机制兜底。

4.2 心跳与重连:前后端配合的完整实现

// 前端:30秒发一次ping,服务器收到后回pong let heartbeatTimer = setInterval(function() { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "ping" })); } }, 30000); ws.onclose = function() { clearInterval(heartbeatTimer); // 关键:先清定时器,避免重连后叠加多个心跳 setTimeout(connectWebSocket, 3000); // 3秒后重连 };
// 后端:收到ping更新lastHeartbeat,60秒内没收到心跳就主动关闭 @OnMessage public void onMessage(String message, Session session, @PathParam("userId") Integer userId) { JSONObject json = JSONObject.parseObject(message); if ("ping".equals(json.getString("type"))) { session.getBasicRemote().sendText("{\"type\":\"pong\"}"); return; } // 正常业务消息处理 }

心跳间隔要小于中间设备的空闲回收时间,30秒是一个经过大量实践的安全值,太短会浪费资源,太长则起不到保活作用。你还需要一个后台定时任务,扫描ONLINE_USERS里超过60秒没收到心跳的session,主动调用session.close()并从在线表移除,这能解决用户直接关浏览器导致“永远在线”的假象。大作业不要求做分布式集群,单机单应用这个方案足够。

4.3 三个必调参数:超时时间、心跳间隔、历史消息分页大小

参数推荐值说明
WebSocket会话超时60000~120000毫秒服务端主动回收死连接的兜底
前端心跳间隔30秒必须小于中间设备空闲回收时间
历史消息分页大小20条/页一次拉太多会拖慢首屏渲染

关于会话超时,Spring Boot内置Tomcat里可以通过WebSocketContainer配置。大作业没必要改得太深,但你要知道默认的超时行为是什么。前端在onclose里重连时,必须先clearInterval旧的心跳定时器,否则每重连一次就多一个心跳线程在跑,网络收养了无数僵尸定时器,内存和带宽一起遭殃。重连间隔用3秒合适,太短会造成服务端连接风暴,太长影响体验。

4.4 HttpSession和WebSocket Session:两个Session别混为一谈

登录状态存在HttpSession里,但用户在线状态应该看WebSocket的Session是否在ONLINE_USERS里。HttpSession只要Cookie没过期就存在,用户关掉浏览器再开,HttpSession可能还在,但WebSocket连接已经断了。如果你把在线状态存在HttpSession里,就会出现“联系人列表显示在线,发消息却没人回”的假象。正确做法是:登录时写HttpSession,建立WebSocket时以onOpen为准维护在线表,前端断线后自动重连,不要刷新整个页面去重新发HTTP请求。

5. 避坑:聊天系统大作业里最常翻车的五个现场

5.1 启动失败:端口被占用和JAVA_HOME指错版本

现象:Spring Boot启动直接报“Port 8080 was already in use”,或者Tomcat启动到一半抛出ClassNotFoundException。原因:上一次程序没完全退出,或者本机装了两个JDK导致java环境配置指错版本。解决:Windows下用netstat -ano | findstr 8080查到占用端口的PID,到任务管理器结束进程;再用java -version确认当前JDK是8或11,Spring Boot 2.7.x配JDK 8到17都能跑。IDEA里点红色方块停止后,内置Tomcat偶尔还会在后台挂着,这是端口占用最高频的来源。

5.2 中文乱码:过滤器顺序比过滤器本身更关键

现象:聊天消息里的中文在数据库里存成问号,页面上显示的也是乱码。原因:MySQL连接串没指定编码,或请求体读取时用了错误的字符集。解决:JDBC连接串加上useUnicode=true&characterEncoding=utf-8,建表用utf8mb4。Spring Boot的CharacterEncodingFilter要确保在业务过滤器之前执行,如果请求体已经被其他过滤器读取过一遍,再设置编码就晚了。这个顺序问题非常隐蔽,你只调整过滤器位置就正常,会让人以为是玄学,实际是Servlet请求体只能读取一次导致的。

5.3 刷新页面就掉线,重连后又收到重复消息

现象:前端一刷新,WebSocket断开又重连,对方发来的消息偶尔出现两条。原因:onclose触发重连时,旧连接还没完全关闭,新连接已经建立,在线表里同一userId对应两个session,消息被两台“分身”各推一次。解决:在onOpen里put之前先ONLINE_USERS.remove(userId)再重新put,让新连接顶掉旧连接。前端也要等ws.readyState变成CLOSED后再初始化新连接,避免并发建连的竞态。

5.4 在线状态不准:用户关掉浏览器还显示在线

现象:用户直接关掉标签页,没走onclose,服务端在线表一直保留着他的session。原因:浏览器进程被系统杀掉时,TCP连接可能没有正常发FIN包,服务端感知不到断开。解决:靠心跳兜底,后端定时任务扫描ONLINE_USERS,把超过60秒没收到心跳的session主动close并移除。注意在session对象上取不到“最后心跳时间”,你需要自己维护一个Map<Integer, Long>记录每个userId的lastHeartbeat。

5.5 换台电脑就连不上:地址写死localhost的坑

现象:自己电脑上访问localhost一切正常,发给同学,同学通过你的IP加端口访问,页面打不开。原因:前端WebSocket连接地址写死了ws://localhost:8080,别人电脑上的localhost指向他自己。解决:前端统一用location.host拼WebSocket地址,这样不管用户从哪个IP访问,WebSocket都连到同一台主机。演示时如果必须用笔记本现场跑,让手机或同学电脑和你的笔记本连同一个WiFi,访问笔记本的局域网IP加端口,这是最稳妥的局域网演示方式。

6. 答辩前最后做两件事:自测路径和简历能用的加分点

6.1 五条自测路径,亲手跑一遍再交

交作业之前别只看功能能点通,按下五条路径完整走一遍:第一,两个不同账号互相登录,A发消息B实时收到,B回复A也实时收到;第二,A给离线状态的B发消息,B登录后能看到未读消息且is_read标记从0变1;第三,连续发50条消息,页面不卡顿,滚动定位不跳帧;第四,关掉浏览器重新打开,重新连接WebSocket后能拉到最近20条历史消息;第五,用手机浏览器通过局域网IP访问同一页面,能正常登录并收发消息。这五条全过,大作业的功能分基本拿到手。

6.2 加分项:历史消息分页和未读数可以讲成你的亮点

答辩时老师大概率会问“消息量变大了怎么办”。你不用背高深理论,只要把历史消息分页和未读数设计讲清楚,就比照着Demo轮询的版本高一个台阶。历史消息用“滚动加载”代替一次拉全部:前端滚动到聊天区域顶部时触发下一页请求,后端用lastId和size两个参数向下翻页,SQL只查小于lastId的记录并按时间倒序取固定条数。未读数则在登录成功时执行UPDATE message SET is_read=1 WHERE to_user_id=当前用户 AND is_read=0,顺手用SELECT COUNT(*)统计未读数,就能在好友列表上画小红点。

这个项目打磨完完全能当“java面试题”的项目经历讲。你只要把“HTTP轮询和WebSocket的选型区别”“消息为什么先落库再推送”“在线表为什么用ConcurrentHashMap”三句话讲清楚,面试官就不会把它当普通课程设计看待。我现在的习惯是每做完一个功能,顺手把当时的报错和解决方式记在项目根目录的NOTE.md里,答辩前翻一翻,很多被问住的问题其实都踩过,只是当时没留痕。你的聊天系统不一定要做得多大,但每个技术选型都得接得住一句“为什么”,把这层做透了,这次大作业换来的就不只是一个分数,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询