简介:面向JavaWeb初学者的“一对一网页聊天系统”完整源码包,基于JSP+JS+Ajax+Servlet+Tomcat+MySQL实现。项目以聊天页面为核心,JSP负责消息显示框与参数传递,JS+Ajax定期向Servlet发起请求,TalkServlet用于发送消息,TalkFromServlet每秒轮询获取更新,清晰演示Ajax长轮询与Servlet协作流程。压缩包共54个文件,涵盖12个Java源文件、8个JSP页面、7个jar依赖库及必要配置文件,整体仅2.88MB,结构紧凑,便于导入MyEclipse等IDE运行调试。目前已有347人学习下载。资源提供完整后端逻辑、数据库建表SQL及Eclipse工程配置,适合课程设计或入门训练;需注意界面尚未设计,侧重功能实现,可在此基础上扩展样式。
1. 一个能直接跑通的一对一网页聊天系统:从登录到消息落库只差一个 Tomcat
我拆过不少号称“能跑”的 JavaWeb 项目,大多数卡在依赖版本冲突或者数据库初始化脚本不完整就劝退了。这套系统不一样——它的核心依赖只有 Tomcat 和 MySQL,代码里没有引入任何重量级框架,解压之后改个数据库密码就能启动。打开页面,注册两个账号,在聊天窗口里发一句“你好”,消息几乎零延迟出现在另一个浏览器窗口,刷新页面后聊天记录依然完整。这个看似简单的闭环,背后是 WebSocket 连接管理、消息落库、离线消息补偿和已读状态四段逻辑的配合。你如果想在课程设计里落地一个完整项目,或者想在内部系统里快速加一个站内聊天模块,这套 javaweb 一对一网页聊天系统的代码可以直接改着用。
2. 技术选型与架构设计:Servlet、WebSocket 与 MySQL 如何构成一个实时闭环
2.1 技术栈全景:传统 JSP/Servlet 技术栈为什么仍然适合做聊天系统
这套系统用的是 JavaWeb 中最经典的技术组合:前端页面由 JSP 和原生 JavaScript 渲染,HTTP 请求交给 Servlet 处理,实时消息通道由 javax.websocket 端点提供,数据持久化走 MySQL 加 JDBC。整个系统跑在一个 Tomcat 容器里,不需要额外安装任何中间件。相比 Spring Boot + Netty + Redis 那种微服务化的聊天方案,这套架构看起来“复古”,但可读性极高——每个请求从进入 Servlet 到返回 JSON 的路径都是显式的,没有自动装配和注解扫描带来的黑匣子。
从二次开发角度看,Spring Boot 方案的优势是生态完善、整合方便,但劣势是当你想调一个 WebSocket 的握手细节时,往往要穿过好几层抽象。而这套系统的 WebSocket 端点直接写在@ServerEndpoint注解下,@OnOpen、@OnMessage、@OnClose三个方法就是全部回调逻辑,阅读和修改成本极低。后续如果想升级到 Spring Boot,把端点类搬过去几乎不用改内部逻辑,只需调整依赖注入方式即可。
2.2 包结构与三层代码组织:一个请求从 JSP 到 DAO 的完整路径
项目采用标准的 Web 目录结构。src/main/java下分为controller、service、dao、entity、websocket五个包;src/main/webapp下是 JSP 页面、JS 脚本和样式文件。entity包里是用户、好友关系、聊天记录三个实体类,字段与数据表列一一对应;dao包里是 JDBC 访问代码;service包封装业务逻辑;controller里的 Servlet 类只负责接收参数、调用 Service、返回 JSON;websocket包则是聊天端点的实现。
如果我要给这份代码加功能,最先动的是service包。原因很简单:controller里不写业务逻辑,你加一个“发送图片消息”时,只需要增加一个消息类型、修改service里的消息组装逻辑、在 DAO 层增加一个字段映射,不需要动 Servlet 和前端接口。分层的好处在这个场景里体现得最明显——各层依赖是单向的,Controller 调 Service、Service 调 DAO、DAO 访问数据库,改某一层时其他层基本不受牵连。
2.3 数据库表设计:用户表、好友表、聊天记录表的字段与索引
数据库只有三张表,这是这套系统能保持轻量的根本原因。user表的字段是id、username、nickname、password、avatar、create_time,其中password存的是加盐后的 MD5 哈希。friend表记录user_id和friend_id两个用户 ID,加上status字段区分好友关系状态。chat_message表的字段是id、from_user_id、to_user_id、content、message_type、is_read、create_time,查询聊天记录时走(from_user_id, to_user_id)的联合索引。
数据量不大时这套设计完全够用,但有一个优化点值得注意:chat_message表的create_time字段建了索引却没有在查询条件里用到,因为聊天记录查询走的是用户 ID 联合条件。这个索引保留着不碍事,可一旦数据量过了百万,就应该改造成conversation_id方案来减少索引膨胀,那是后话。
3. 核心模块拆解:一条聊天消息从输入框到对方屏幕的完整链路
3.1 登录与 Session 鉴权:用户名密码校验、Session 创建与 WebSocket 握手拼接
登录逻辑走的是传统 Session 鉴权。用户提交用户名和密码后,LoginServlet从请求里取出参数,调用UserServiceImpl.login()方法。这个方法先根据用户名查询用户记录,然后取出加盐哈希与用户输入比对。比对成功则把用户信息放入 HttpSession,同时返回一个 JSON 给前端,前端拿到成功响应后跳转到聊天主页。
@WebServlet("/login") public class LoginServlet extends HttpServlet { private UserService userService = new UserServiceImpl(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); User user = userService.login(username, password); resp.setContentType("application/json;charset=UTF-8"); PrintWriter out = resp.getWriter(); if (user == null) { out.write("{\"code\": 1, \"message\": \"用户名或密码错误\"}"); return; } // 登录成功后塞入 Session,后续所有接口从 Session 取当前用户 req.getSession().setAttribute("loginUser", user); out.write("{\"code\": 0, \"message\": \"success\", \"data\": {" + "\"userId\": " + user.getId() + ", \"nickname\": \"" + user.getNickname() + "\"}}"); } }这里有一个细节:登录成功后把整个User对象放入 Session,后续的聊天记录查询、好友列表、发送消息等接口,都从req.getSession().getAttribute("loginUser")里取当前用户 ID,不需要前端每次请求都传一遍身份参数。这套系统的 WebSocket 握手与 Session 鉴权是分开处理的:前端建立 WebSocket 连接时,把用户 ID 作为路径参数拼到地址里,比如ws://localhost:8080/chat/websocket/1,后端从路径中解析出用户 ID,再查一次数据库确认用户存在才建立连接。
这个方案简单直接,但有一个隐藏风险:用户 ID 暴露在地址里,别人拿到 ID 就能伪造连接。若要补强,应该在握手阶段的 HttpSession 里做校验,或者使用一次性 token 来换取 WebSocket 连接。二次开发时要重点留意这个位置。
3.2 消息推送核心:从提交到分发的完整处理流程
前端在聊天窗口输入消息点击发送后,并不会直接走 WebSocket,而是先调用一个 HTTP 接口/message/send。这个接口接收toUserId和content两个参数,SendMessageServlet从 Session 中取出当前登录用户 ID 作为发送者,然后调用 Service 层做三件事:先把消息写入chat_message表,再查询接收者当前是否在线,最后如果对方在线,通过 WebSocket 端点的静态 Map 找到对方的 Session 并推送消息。
public void sendMessage(Message message) { // 第一步:先落库,确保消息不丢 ChatMessageDao dao = new ChatMessageDao(); long messageId = dao.insert(message); message.setId(messageId); // 第二步:判断接收者是否在线 Session targetSession = ChatEndpoint.getSession(message.getToUserId()); if (targetSession != null && targetSession.isOpen()) { // 第三步:组装 JSON 并推送 String payload = MessageProtocol.buildChatMessage(message); try { targetSession.getBasicRemote().sendText(payload); } catch (IOException e) { // 连接已断开,消息留在数据库,等对方上线后补拉 ChatEndpoint.removeSession(message.getToUserId()); } } }推送数据的 JSON 结构包含type: "chat"、fromUserId、fromNickname、content、timestamp、messageId六个字段。接收方的前端在onmessage回调里解析这个 JSON,判断type是chat就把内容渲染到页面。整套流程没有引入消息队列,也没有做广播,一对一场景下这种直连推送是最高效且最简单的方式。
还有一个值得说明的设计:发送方这边,前端在 HTTP 接口返回成功后,会手动把一条带isSelf: true标记的消息渲染到自己的聊天窗口。这样发送方立即看到消息上屏,不用等消息从服务器绕一圈再回来,体验上没有任何延迟感。而接收方的消息是经过 WebSocket 实时推送的,双方看到消息内容的时机几乎一致。
3.3 离线消息与历史记录:落库、补拉与已读状态的三段式配合
离线消息是聊天系统里的必备能力,这套系统的处理逻辑是“先落库,再判断在线”。消息写入chat_message表后,is_read字段保持默认值 0;当接收者登录后,前端调用/message/unread接口,服务端查询to_user_id = 当前用户 AND is_read = 0的记录,按create_time升序返回;前端拿到未读消息列表后,逐条渲染到聊天窗口,再调用/message/markRead接口批量更新已读状态。
-- 查询未读消息 SELECT id, from_user_id, to_user_id, content, message_type, create_time FROM chat_message WHERE to_user_id = ? AND is_read = 0 ORDER BY create_time ASC; -- 标记已读(必须加 from_user_id 条件,防止误标记自己发出的消息) UPDATE chat_message SET is_read = 1 WHERE to_user_id = ? AND from_user_id = ? AND is_read = 0;这里有一个容易理解错的细节:markRead接口只能标记接收者已读的消息,不能标记“自己发出的消息”。SQL 里必须加AND from_user_id != 当前登录用户ID条件,否则会把自己发送的记录误标记为已读,导致未读计数出错。聊天记录加载走的是另一个接口/message/history,参数是toUserId,查询条件是(from_user_id = 当前用户 AND to_user_id = 对方) OR (from_user_id = 对方 AND to_user_id = 当前用户),按时间正序排列,作为打开聊天窗口时的初始数据。
4. 部署运行:从环境准备到浏览器弹出第一句“你好”
4.1 环境匹配:JDK 8、Tomcat 8.5 与 MySQL 5.7 的组合关系
想跑起来需要满足三个环境条件。JDK 需要 8 以上,因为 Tomcat 8.5 要求 JDK 7+,而代码里用了 Lambda 表达式做列表排序,建议直接上 JDK 8。Tomcat 建议使用 8.5.51 以上版本,这个版本修复了 WebSocket 在部分旧版本上的连接异常问题。MySQL 建议 5.7 或 8.0,如果用了 8.0,需要在 JDBC 连接串上增加useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai几个参数。
源码解压之后,第一步检查WEB-INF/classes目录下是否有db.properties文件,数据库配置集中在这个文件里:
jdbc.driver=com.mysql.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/chat_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=123456每次部署新环境,我一般只改两个地方:jdbc.password改成自己的数据库密码,jdbc.url里的chat_db改成实际数据库名。其他参数不要动,尤其是characterEncoding=utf8这个参数,直接决定 Java 与 MySQL 之间中文传输是否正确,我见过有人图省事删掉这个参数,结果所有聊天记录里的中文全部变成问号。
4.2 数据库初始化与脚本执行:建库、建表、插入测试账号
项目自带的 SQL 初始化脚本内容包含建库语句、建表语句和一条测试账号数据。执行方式有两种:本地有 MySQL 客户端的话,直接命令行执行mysql -u root -p < chat.sql;用 Navicat 或 IDEA 自带的数据库工具,把脚本粘到查询窗口执行即可。脚本末尾会插入一个测试账号,用户名admin,密码123456,方便第一次启动时直接登录验证。
CREATE DATABASE IF NOT EXISTS chat_db DEFAULT CHARSET utf8mb4; USE chat_db; CREATE TABLE `user` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `nickname` VARCHAR(50) NOT NULL, `password` VARCHAR(64) NOT NULL, `avatar` VARCHAR(255) DEFAULT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; CREATE TABLE `friend` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `user_id` INT NOT NULL, `friend_id` INT NOT NULL, `status` TINYINT DEFAULT 1, KEY `idx_user_friend` (`user_id`, `friend_id`) ) ENGINE=InnoDB; CREATE TABLE `chat_message` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `from_user_id` INT NOT NULL, `to_user_id` INT NOT NULL, `content` TEXT NOT NULL, `message_type` TINYINT DEFAULT 1, `is_read` TINYINT DEFAULT 0, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, KEY `idx_from_to` (`from_user_id`, `to_user_id`), KEY `idx_to_read` (`to_user_id`, `is_read`) ) ENGINE=InnoDB;这里有一个我踩过的坑:如果 MySQL 的sql_mode设置了ONLY_FULL_GROUP_BY,某些版本的脚本执行会报错。解决方式是在 MySQL 配置文件里调整sql_mode,或者换用 MySQL 5.7 的默认配置。更稳妥的方式是逐条执行建表语句,报错时能精确定位到哪条语句出了问题。
4.3 启动流程与验证步骤:Tomcat 部署、双浏览器联调与四步回归
部署方式推荐用 IDEA 自带的 Tomcat 集成功能:在 Run Configuration 里配置一个 Tomcat Server,Deployment 页签选择war exploded,Application context 设置为/chat,点击运行。如果不习惯 IDE,也可以把编译后的 war 包拷贝到 Tomcat 的webapps目录下,启动 Tomcat 后访问http://localhost:8080/chat/。
验证系统是否正常,我按下面顺序执行:第一步,浏览器访问首页,能看到登录和注册入口;第二步,注册两个账号,用普通窗口和隐私窗口分别登录;第三步,A 账号在好友列表里看到 B,B 收到上线通知;第四步,A 给 B 发一条消息,B 的窗口立即出现消息;第五步,刷新 B 的页面,历史聊天记录还在。走到这里,整套系统的核心闭环已经全部打通。
提示:如果刷新页面后聊天记录为空,优先检查
chat_message表里有没有数据,以及history接口的查询条件是否把from_user_id和to_user_id写反了。这两个参数在联调阶段最容易弄混。
5. 避坑指南:JavaWeb 聊天系统开发中绕不开的 5 个翻车现场
5.1 WebSocket 连接频繁断开:服务器超时与心跳机制缺失
现象:用户连上 WebSocket 之后,空闲一两分钟就自动断开,前端频繁重连,聊天体验极差。
原因:Tomcat 默认的 WebSocket 会话超时时间较短,超过时间没有消息交互,容器会主动关闭连接。前端如果建立连接后不发送任何消息,就会触发超时断开。
解决:在@OnOpen方法里调用session.setMaxIdleTimeout(0)将超时设为无限,或者设成 30 分钟;前端加一个定时器,每 30 秒发送一个{"type":"ping"}心跳包,服务端收到ping时回复{"type":"pong"}。两种方案同时用,兜底更稳。
5.2 中文乱码:JSP、Servlet 与数据库三层编码不一致
现象:用户昵称、聊天内容进入数据库后变成问号或乱码,控制台打印的中文也显示异常。
原因:JSP 页面设置了 UTF-8,但 Servlet 读取请求参数时没有设置request.setCharacterEncoding("UTF-8"),数据库连接串也缺少characterEncoding=utf8。三层中任何一层编码不一致,中文在传输或存储过程中就会被错误转码。
解决:在web.xml中配置一个CharacterEncodingFilter,统一设置请求和响应的编码为 UTF-8;数据库连接串追加characterEncoding=utf8;建库时指定DEFAULT CHARSET=utf8mb4。三处配置缺一不可,排查时按“请求进入→数据库存储→响应返回”的顺序逐层检查。
5.3 消息推送失败但数据已落库:WebSocket 连接失效后的静默吞错
现象:A 给 B 发消息,消息写入数据库成功,但 B 的界面没有任何反应,A 这边也不报错。
原因:代码里推送消息的session.getBasicRemote().sendText(json)被 try-catch 包裹,异常只打印到日志没有反馈给前端。B 的 WebSocket 连接可能已经断开,但静态 Map 里的 Session 引用还在,推送时抛了异常被吞掉。
解决:发送前判断session.isOpen(),连接已关闭就从静态 Map 中移除,让消息走离线补偿逻辑;sendText的 catch 块中不要只打印日志,要额外做一次标记,保证消息在对方下次上线时能通过未读补拉拿回来。
5.4 内存泄漏:静态 Map 保存 WebSocket Session 未正确清理
现象:系统运行一段时间后 Tomcat 堆内存持续增长,频繁出现 Full GC 甚至 OutOfMemoryError。
原因:ConcurrentHashMap保存了用户 ID 与 WebSocket Session 的映射,但@OnClose方法里没有正确移除 Map 中的记录。用户直接关闭浏览器标签页时,前端可能来不及发送关闭帧,服务端在@OnClose触发时取不到正确的用户 ID,就导致僵尸连接遗留在 Map 中。
解决:@OnClose方法里通过路径参数重新解析用户 ID,即使前端没有发送关闭帧也要能正确移除;同时在@OnError里也执行同样的清理逻辑。上线前用脚本模拟用户频繁上下线,观察 Map 的 size 是否保持稳定。
5.5 前端收不到任何消息:onmessage 回调注册顺序错误
现象:聊天窗口打开后,好友发来的消息在数据库里能看到,但页面不实时更新,必须刷新才能看到。
原因:前端 JavaScript 的执行顺序问题。如果页面初始化代码中,先调用了history接口加载聊天记录,渲染完成后才注册 WebSocket 的onmessage回调,那么在这段间隙中推送过来的消息就会被浏览器事件队列丢弃。
解决:注册 WebSocket 的onmessage和onopen回调必须在页面加载阶段最先执行,在回调中把推送消息缓存到数组,等历史记录渲染完成后再合并显示。用消息队列的方式在onmessage里先 push 到数组、再统一渲染,可以保证丢消息的问题彻底根除。
6. 二次开发:从一对一私聊扩展到群聊的改造思路
6.1 消息类型的扩展设计
做群聊的第一步是让 WebSocket 端点支持向多个 Session 发送消息。在一对一场景中,sendMessageToUser只查一个人对应的 Session;群聊场景中,需要维护一个群 ID 到 Session 列表的映射,遍历列表逐个调用sendText。
public static void sendToGroup(Integer groupId, String message) { ConcurrentHashMap<Integer, Session> groupSessions = groupMap.get(groupId); if (groupSessions == null) return; for (Session session : groupSessions.values()) { try { session.getBasicRemote().sendText(message); } catch (IOException e) { // 单个连接断开不能影响整个群的广播 groupSessions.remove(session.getId()); } } }要注意sendText是同步方法,遍历发送时某个连接断了要捕获异常并移除该连接,不能因为一个坏连接阻塞整个群的发送逻辑。
6.2 群聊的表结构补充
如果不想动原表,可以增加一张group_message表和一张group_member表。群聊历史记录查询从from_user_id + to_user_id的联合条件改为group_id单个条件,SQL 反而更简单。但未读数的计算不再是单表 count,需要先查到用户加入的所有群 ID,再对每个群分别统计。这套改造方案的好处是原有一对一逻辑完全不动,新增代码和旧代码并行存在,适合逐步过渡。
6.3 输入状态提示的低成本实现
我后来给这套系统加过“对方正在输入”的提示。实现方式很轻量:前端在输入框的input事件中,如果内容有变化且距离上次发送超过 2 秒,就通过 WebSocket 发送一个{"type":"typing"}消息;对方收到后显示临时提示,3 秒后自动消失。这个功能不需要改动数据库,只需要在 JSON 协议中增加一个消息类型,总改动量不到 20 行,是整套二次开发里性价比最高的增量功能。
从那以后,我每次改完这套聊天系统,都会强制走一遍“双账号登录→互发消息→刷新恢复→离线补拉”的完整验证流程。WebSocket 调试比 HTTP 麻烦得多,很多问题不是逻辑错而是时序错,只能靠这四步回归测试兜底。希望帮到你。
本文还有配套的精品资源,点击获取