
简介这是一套基于JavaWeb技术栈实现的一对一网页聊天系统源码面向正在学习JSP、Servlet与Ajax异步通信的初学者及课程设计开发者帮助理解浏览器与服务器之间实时消息交互的完整链路。压缩包共54个文件约2.88MB包含12个java源文件与12个class编译文件、8个jsp页面、7个jar依赖包以及4个xml配置另附sql.txt数据库脚本覆盖登录、注册、好友列表、聊天主界面等模块。系统以JSP负责消息显示框与输入框的参数获取JS结合Ajax向Servlet发起请求其中TalkServlet处理用户发送消息TalkFromServlet每秒轮询一次以拉取更新内容服务端运行于Tomcat数据持久化采用MySQL并借助c3p0连接池管理数据库连接。目前已有346人学习下载。需要说明的是该版本仅完成功能实现UI尚未设计适合作为二次开发与界面美化的基础骨架读者可据此掌握轮询式聊天、Servlet请求分发与JSP参数传递等核心思路。1. 从一份能跑通的 JavaWeb 一对一网页聊天系统说起如果你正在搜「javaweb 一对一网页聊天系统」大概率不是想听 WebSocket 原理科普而是想要一份能直接跑起来、能改、能交作业或能当项目脚手架的东西。我拿到这份资源的第一反应也是先看它到底能不能跑通它是一套基于 JavaWeb 技术栈实现的一对一网页聊天系统核心链路是浏览器端建立长连接、服务端按用户维度维护会话、消息点对点投递并落库。适合三类人正在做课程设计或毕设、需要一份完整案例参考的学生想从 Servlet/JSP 过渡到 WebSocket 实时通信的初级开发者以及需要快速搭一个内部沟通 Demo 的工程师。它解决的不是「聊天软件怎么做」这种宏大问题而是「怎么用 JavaWeb 这套东西把一对一实时消息跑通」这个具体问题。2. 技术选型与运行环境为什么是 WebSocket 而不是轮询2.1 一对一聊天的技术路线对比做网页聊天第一道选择题就是通信方式。常见做法有三种短轮询、长轮询、WebSocket。短轮询是前端每隔几秒发一次请求问「有没有新消息」实现最简单但延迟高、请求量大一对一场景下体验很差。长轮询是服务端 hold 住请求直到有消息或超时比短轮询省资源但每个连接占用一个线程并发一上来就顶不住。WebSocket 则是全双工长连接握手一次之后双方都能主动推消息延迟低、开销小是目前网页聊天的主流方案。这份资源选的是 WebSocket这是对的。JavaWeb 生态里实现 WebSocket 有两条路一是 JSR 356 标准的ServerEndpoint注解方式二是 Spring 的WebSocketHandler或 STOMP。前者不依赖 Spring纯 Servlet 容器就能跑适合教学和轻量项目后者集成度高适合已经在用 Spring 的项目。这份资源走的是标准注解方式好处是依赖少、结构清晰坏处是用户状态管理、消息路由这些都得自己写。对于一对一聊天来说自己写反而更好控制因为逻辑不复杂。2.2 环境准备与依赖清单跑这套东西之前先把环境对齐。我一般会按下面这张表检查一遍缺一个都可能启动报错。组件版本要求说明JDK1.8 或以上注解方式在 1.8 上最稳高版本注意模块化问题Servlet 容器Tomcat 8.5必须支持 JSR 356Tomcat 7 部分版本不支持数据库MySQL 5.7 / 8.0存用户和消息记录构建工具Maven 3.6管理依赖和打包IDEIDEA运行 JavaWeb 项目配置最顺手Maven 依赖里最关键的是 WebSocket API 和 MySQL 驱动常见配置如下dependencies !-- WebSocket APIscope 用 provided因为容器已自带实现 -- dependency groupIdjavax.websocket/groupId artifactIdjavax.websocket-api/artifactId version1.1/version scopeprovided/scope /dependency !-- MySQL 驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.28/version /dependency !-- JSON 处理消息体序列化用 -- dependency groupIdcom.alibaba/groupId artifactIdfastjson/artifactId version1.2.83/version /dependency /dependencies这里有个参数要特别注意javax.websocket-api的 scope 必须是provided。如果你写成默认的compile打包时会把 API 打进 war和 Tomcat 自带的实现冲突启动时报ClassCastException或Provider not found。这是我在多个项目里反复见到的翻车点。2.3 在 IDEA 里把项目跑起来环境齐了之后导入和运行按这个顺序走用 IDEA 的 Open 打开项目根目录等 Maven 依赖下载完。检查 Project Structure 里的 SDK 是否为 1.8Language level 对齐。配置 Tomcat ServerDeployment 里添加 war exploded 或 war 包。在 Application context 里设置访问路径比如/chat。启动前先建库建表执行项目里的 SQL 脚本。数据库连接配置一般在db.properties或c3p0-config.xml里改成本地地址和密码。如果启动时报Access denied for user先确认 MySQL 用户权限和密码再确认驱动版本和连接串里的时区参数8.0 驱动要加serverTimezoneAsia/Shanghai否则可能连不上。3. 服务端核心实现会话管理与消息路由3.1 ServerEndpoint 的会话生命周期一对一聊天的服务端核心就两件事知道谁在线、把消息发给对的人。JSR 356 用ServerEndpoint标注一个类容器会在每个客户端连接时创建一个实例。注意是每个连接一个实例不是单例。所以你不能把在线用户列表存成实例变量必须用静态的ConcurrentHashMap来存key 是用户 IDvalue 是Session。ServerEndpoint(/chat/{userId}) public class ChatEndpoint { // 静态 Map 存所有在线会话key 为用户 ID private static final ConcurrentHashMapString, Session ONLINE_USERS new ConcurrentHashMap(); private String userId; OnOpen public void onOpen(Session session, PathParam(userId) String userId) { this.userId userId; ONLINE_USERS.put(userId, session); System.out.println(用户上线: userId 当前在线数: ONLINE_USERS.size()); } OnClose public void onClose() { ONLINE_USERS.remove(userId); System.out.println(用户下线: userId); } OnError public void onError(Session session, Throwable error) { System.out.println(连接异常: userId 原因: error.getMessage()); } }这段代码里PathParam从连接地址里取用户 ID比如ws://localhost:8080/chat/1001。ONLINE_USERS用ConcurrentHashMap是因为 WebSocket 的回调是多线程触发的普通HashMap在并发 put/remove 时会出问题。OnError一定要写否则连接异常时你连日志都看不到排查起来就是黑匣子。3.2 消息接收与点对点投递消息投递的逻辑在OnMessage里。前端发过来的消息体一般包含接收者 ID 和内容服务端解析后从ONLINE_USERS里找到对方的Session调用getBasicRemote().sendText()发出去。OnMessage public void onMessage(String message, Session session) { // 解析消息体格式: {to:1002,content:你好} JSONObject json JSON.parseObject(message); String toUserId json.getString(to); String content json.getString(content); Session target ONLINE_USERS.get(toUserId); if (target ! null target.isOpen()) { // 构造转发消息带上发送者身份 JSONObject forward new JSONObject(); forward.put(from, this.userId); forward.put(content, content); forward.put(time, System.currentTimeMillis()); target.getBasicRemote().sendText(forward.toJSONString()); } else { // 对方不在线落库离线消息 saveOfflineMessage(this.userId, toUserId, content); } }参数说明to是接收者 IDcontent是消息正文。target.isOpen()这个判断不能省因为对方可能刚好在这一瞬间断开直接 send 会抛IOException。离线消息落库是常见做法等对方下次上线时拉取。这里用getBasicRemote()是同步发送一对一场景够用如果是群发高并发可以换getAsyncRemote()但要注意它不保证顺序。3.3 消息落库与历史记录查询聊天记录表结构不复杂关键是索引要建对。常见建表语句CREATE TABLE chat_message ( id BIGINT AUTO_INCREMENT PRIMARY KEY, from_user VARCHAR(32) NOT NULL, to_user VARCHAR(32) NOT NULL, content TEXT NOT NULL, send_time DATETIME DEFAULT CURRENT_TIMESTAMP, is_read TINYINT DEFAULT 0, INDEX idx_from_to (from_user, to_user), INDEX idx_to_read (to_user, is_read) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;idx_from_to用于查两人之间的历史消息idx_to_read用于查未读消息数量。字符集用utf8mb4不然表情符号存进去会变问号。查询历史记录的 SQL 按时间倒序取最近 N 条再在应用层反转避免全表扫描。4. 前端页面与连接保持从握手到心跳4.1 WebSocket 连接的建立与重连前端用原生WebSocketAPI 就行不需要额外库。连接地址根据当前页面协议自动切换 ws/wsslet ws null; let reconnectTimer null; function connect(userId) { const protocol location.protocol https: ? wss: : ws:; const url ${protocol}//${location.host}/chat/${userId}; ws new WebSocket(url); ws.onopen () { console.log(连接已建立); startHeartbeat(); // 连接成功后启动心跳 }; ws.onmessage (event) { const msg JSON.parse(event.data); renderMessage(msg); }; ws.onclose () { console.log(连接断开3 秒后重连); stopHeartbeat(); reconnectTimer setTimeout(() connect(userId), 3000); }; ws.onerror (err) { console.error(连接异常, err); }; }逻辑说明onclose里做自动重连间隔 3 秒。注意重连前要清掉旧的定时器否则会叠加多个连接。onerror里不要直接重连因为 error 之后通常还会触发 close交给 close 统一处理。4.2 心跳机制与断线检测WebSocket 连接可能被中间设备静默断开前端onclose不一定及时触发。常见做法是加心跳客户端每隔 30 秒发一个 ping服务端回 pong连续几次没收到就主动重连。let heartbeatTimer null; let pongTimeout null; function startHeartbeat() { heartbeatTimer setInterval(() { if (ws ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ type: ping })); // 5 秒内没收到 pong 就认为断了 pongTimeout setTimeout(() { ws.close(); }, 5000); } }, 30000); } function stopHeartbeat() { clearInterval(heartbeatTimer); clearTimeout(pongTimeout); }服务端在OnMessage里判断type为ping时直接回pong不走业务逻辑。心跳间隔 30 秒是个经验值太短浪费资源太长断线发现慢。如果你的部署环境有负载均衡注意配置空闲超时时间要大于心跳间隔。4.3 消息去重与顺序保证一对一聊天里消息重复和乱序是容易被忽略的问题。重连之后如果拉取离线消息可能和实时推送的消息重叠。常见做法是给每条消息一个客户端生成的唯一 ID前端渲染前先查本地已渲染集合重复的直接丢弃。顺序方面服务端落库时用自增 ID 或时间戳前端按 ID 排序渲染不要依赖到达顺序。5. 避坑与排查那些让我加班到凌晨的问题5.1 连接建立失败报 404现象前端new WebSocket()直接触发onerror浏览器控制台显示握手失败 404。 原因ServerEndpoint的路径和前端请求路径不一致或者ServerEndpointExporter没注册。纯 Servlet 容器不需要 exporter但如果你混用了 Spring Boot必须手动注册这个 Bean。 解决先确认前端 URL 和服务端注解路径完全一致包括 context path。Spring Boot 环境下加Bean public ServerEndpointExporter serverEndpointExporter()。5.2 消息发出去对方收不到现象A 发消息服务端日志显示已处理但 B 的页面没反应。 原因ONLINE_USERS里存的 key 和消息里的to对不上比如一个是字符串1002一个是数字1002ConcurrentHashMap.get()返回 null。 解决统一用户 ID 类型存取都用字符串。在onOpen和onMessage里打印 key 做对比一眼就能看出来。5.3 并发下会话丢失现象在线人数一多部分用户的消息随机丢失。 原因用了非线程安全的集合或者OnMessage里对共享变量做了非原子操作。 解决所有共享状态用ConcurrentHashMap或加锁。发送消息时对Session的写操作要同步getBasicRemote()本身不是线程安全的高并发下建议对同一 Session 的发送加锁。5.4 中文乱码现象消息内容里的中文变成乱码。 原因数据库字符集不是utf8mb4或者连接串没指定编码。 解决建库建表都用utf8mb4JDBC 连接串加useUnicodetruecharacterEncodingutf8。Tomcat 的server.xml里 Connector 也可以加URIEncodingUTF-8。5.5 部署到服务器后连不上现象本地跑得好好的部署到服务器后 WebSocket 握手失败。 原因Nginx 等反向代理默认不支持 WebSocket 升级需要手动配置Upgrade和Connection头。 解决在代理配置里加proxy_set_header Upgrade $http_upgrade;和proxy_set_header Connection upgrade;并把proxy_read_timeout调大避免长连接被提前断开。6. 进阶技巧把一对一扩展成可维护的会话层跑通之后很多人会想加功能未读计数、消息已读回执、多端登录踢下线。这些都不难但需要一个清晰的会话层设计。我的习惯是把ONLINE_USERS从简单的MapString, Session升级成MapString, UserSessionUserSession里除了Session还存设备标识、最后活跃时间、未读计数。public class UserSession { private Session session; private String deviceId; private long lastActiveTime; private AtomicInteger unreadCount new AtomicInteger(0); // 同一用户多端登录时只保留最新连接 public static void bind(String userId, Session session, String deviceId) { UserSession us new UserSession(); us.session session; us.deviceId deviceId; us.lastActiveTime System.currentTimeMillis(); UserSession old ONLINE_USERS.put(userId, us); if (old ! null old.session.isOpen()) { // 踢掉旧连接通知前端 old.session.getAsyncRemote().sendText({\type\:\kick\}); old.session.close(); } } }这样改完之后未读计数在onMessage里对目标用户的unreadCount自增已读回执则是前端发一个type: read的消息服务端更新数据库is_read字段。多端登录踢下线也顺手解决了。验证这套逻辑是否可靠我一般会做三件事一是用两个浏览器窗口分别登录不同账号互发消息看延迟和顺序二是模拟断网看心跳和重连是否按预期工作三是用ab或 JMeter 压一下连接数观察内存和线程增长。压测时重点看ONLINE_USERS的大小是否和实际连接数一致不一致就说明onClose没清理干净。从那以后我每次做 WebSocket 项目都会先把onOpen、onClose、onError三个回调的日志打全再开始写业务逻辑。这个习惯帮我省了至少三次通宵排查。希望帮到你。本文还有配套的精品资源点击获取