简介:这是一套基于JavaWeb技术栈实现的一对一网页聊天系统实战项目,面向Java初学者与Web开发入门者,帮助理解前后端协同通信的核心机制。项目采用JSP+JavaScript+AJAX构建前端交互界面,通过Servlet处理消息发送与轮询获取,后端依托Tomcat服务器与MySQL数据库完成会话持久化,虽未进行UI美化,但功能完整、逻辑清晰,适合用于课程设计、毕业设计或Ajax异步通信专项练习。压缩包共54个文件,含12个Java源码、12个编译后Class文件、8个JSP页面(如login.jsp、chat.jsp、friendList.jsp等)、7个Jar依赖库及web.xml、c3p0-config.xml等关键配置文件,整体大小为2.88MB。已有348人学习下载,读者可直接导入Eclipse/MyEclipse运行调试,获得完整的项目目录结构、数据库建表脚本(sql.txt)、C3P0连接池配置范例及分层Servlet职责说明,快速掌握即时通讯类应用的基础架构设计思路。
1. 为什么一个“看似简单”的一对一网页聊天,反而最容易在毕设答辩现场被问住?
你写完登录、好友列表、消息收发,本地跑通了,截图也齐了,以为稳了——结果答辩老师第一句就问:“如果两个用户同时发消息,你怎么保证顺序?后端怎么知道该推给谁?离线消息存哪儿?重连时怎么补?WebSocket 断了是重连还是降级?HTTP 轮询的间隔设成 3 秒和 30 秒,对服务器并发压力差几倍?”
这不是刁难,是真实系统绕不开的交付门槛。这套JavaWeb 一对一网页聊天系统,不是做个能点发送按钮的 Demo,而是要交出一份「可解释、可验证、可压测、可答辩」的完整方案:从 HTTP 协议层如何承载长连接,到 Session 管理怎么避免好友关系错绑;从消息体 JSON 字段设计是否预留扩展位,到数据库表结构里is_read和status字段到底该不该合二为一。它适合正在做课程设计、毕设,需要交源码+文档+演示+答辩材料的同学;也适合刚转 Java 后端、想用一个真实交互场景练手的开发者——因为这里没有“前端调个 API 就完事”的黑匣子,每个模块都得自己串起来、压得住、讲得清。
2. 总体架构设计:B/S 架构下的四层职责划分与技术选型依据
这套系统不是“Spring Boot + WebSocket 堆出来就行”,而是在 JavaWeb 技术栈约束下,对可用性、可调试性、可演示性做取舍后的结果。我们采用经典四层结构:表现层(HTML+JS)→ Web 层(Servlet/JSP 或 Spring MVC)→ 业务逻辑层(Service)→ 数据访问层(JDBC/MyBatis),不引入 Redis、MQ、Nginx 等中间件,所有功能均能在单台 Tomcat 上完整运行,确保学生环境零配置依赖。
2.1 为什么坚持用原生 WebSocket 而非 Socket.IO 或 STOMP?
很多同学看到“实时聊天”第一反应是搜 “Spring Boot WebSocket 教程”,然后抄来一套带@MessageMapping的 STOMP 示例——但答辩时立刻露馅:STOMP 是子协议,底层仍走 WebSocket,而你根本没搞懂@SendToUser是怎么通过SimpMessagingTemplate绑定 session id 的。本方案选择原生javax.websocketAPI(Tomcat 原生支持),原因很实在:
- 可见可控:握手阶段
@OnOpen中能直接拿到HttpSession,轻松关联用户身份; - 调试友好:浏览器开发者工具 Network → WS 标签页里,每条
TEXT帧都清晰可见,字段名、时间戳、方向一目了然; - 避免黑盒:不用猜
@SubscribeMapping底层怎么映射路径,也不用处理SimpleBroker和StompBrokerRelay的权限混淆问题。
提示:若用 Spring Boot 2.7+,需显式排除
spring-boot-starter-websocket的自动配置,改用@ServerEndpoint注解类,否则会与 Spring MVC 的 WebSocket 配置冲突。
2.2 用户状态与会话管理:HttpSession + 内存 Map 的双保险设计
一对一聊天最怕“张三发的消息推给了李四”。本方案采用两级绑定机制:
- HTTP 层绑定:登录成功后,将用户 ID 存入
HttpSession.setAttribute("userId", "u1001"); - WebSocket 层绑定:在
@OnOpen回调中,将Session对象存入静态ConcurrentHashMap<String, Session>,key 为用户 ID(非 session id!)。
这样,当 A 向 B 发送消息时,业务层先查数据库确认 B 在线(即sessionMap.containsKey("u1002")),再从 map 中取出 B 的Session对象调用getBasicRemote().sendText()。整个链路不依赖 Cookie 或 Token 解析,杜绝了因前端未携带 token 导致的会话错乱。
// WebSocketEndpoint.java 关键片段 @ServerEndpoint(value = "/chat", configurator = HttpSessionConfigurator.class) public class ChatEndpoint { // key: userId, value: WebSocket Session private static final ConcurrentHashMap<String, Session> SESSION_MAP = new ConcurrentHashMap<>(); @OnOpen public void onOpen(Session session, EndpointConfig config) { // 从 HttpSession 中提取登录用户ID HttpSession httpSession = (HttpSession) config.getUserProperties().get(HttpSession.class.getName()); String userId = (String) httpSession.getAttribute("userId"); if (userId != null) { SESSION_MAP.put(userId, session); // 绑定用户ID与WS Session } } @OnClose public void onClose(Session session) { // 从SESSION_MAP中移除已断开的用户 SESSION_MAP.values().removeIf(s -> s.getId().equals(session.getId())); } }这段代码背后有三个设计意图:一是ConcurrentHashMap保证多线程安全(多个用户同时上线/下线);二是onClose不直接用userId删除,而是遍历session.getId(),因为HttpSession可能失效但WebSocket Session未及时触发@OnClose;三是configurator = HttpSessionConfigurator.class是关键——它让 WebSocket 握手请求能携带并读取当前 HTTP 会话,否则config.getUserProperties()里压根拿不到HttpSession。
2.3 消息流转模型:从“发消息”到“对方收到”的七步闭环
很多同学只实现了“前端点发送 → 后端收 → 再推给对方”,却答不出“如果推送失败怎么办”。本方案定义严格的消息生命周期:
| 步骤 | 触发方 | 动作 | 关键保障 |
|---|---|---|---|
| 1 | 前端 | POST/msg/send,body 含fromId,toId,content,timestamp | 前端生成毫秒级 timestamp,用于后续排序 |
| 2 | 后端 Servlet | 校验fromId是否登录、toId是否存在、内容长度 ≤ 2000 字 | 防 SQL 注入:content全局过滤<script>标签 |
| 3 | 后端 Service | 调用MsgDao.insert()持久化消息,返回主键msgId | 表结构含status TINYINT DEFAULT 0(0=待投递,1=已送达,2=已读) |
| 4 | 后端 Service | 查SESSION_MAP.get(toId),若存在则session.getBasicRemote().sendText(...) | 推送前加 try-catch,捕获IOException |
| 5 | 推送成功 | 更新 DB 中该msgId的status=1 | 使用UPDATE msg SET status=1 WHERE msg_id=?,非事务内操作 |
| 6 | 推送失败 | 记录日志,status保持 0,启动定时任务每 30s 扫描重试 | 定时任务用TimerTask实现,避免引入 Quartz 复杂度 |
| 7 | 前端接收 | JS 监听websocket.onmessage,解析 JSON,追加到聊天窗口 | 消息体含msgId,前端用Map<msgId, element>缓存 DOM 节点,防重复渲染 |
这个闭环意味着:即使 WebSocket 断开,消息也不会丢——它躺在数据库里,状态为 0,等待重连后由前端主动拉取(见 4.3 节),或由后台定时任务补推。这才是“可靠消息传递”的落地形态,不是一句“用 WebSocket 就实时”能糊弄过去的。
3. 核心模块实现:从登录鉴权到消息持久化的五段硬代码
本章不讲概念,只贴真实可运行、可调试、可答辩的代码段,并说明每一行为什么这么写、不这么写会翻什么车。
3.1 登录模块:基于 Filter 的会话拦截与防绕过设计
很多同学把登录逻辑全塞在LoginServlet里,结果别人直接访问/chat.jsp就绕过登录。本方案用LoginFilter实现全局拦截:
// LoginFilter.java public class LoginFilter implements Filter { private static final List<String> EXCLUDE_PATHS = Arrays.asList( "/login.jsp", "/LoginServlet", "/css/", "/js/", "/images/", "/favicon.ico" ); @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; String path = request.getRequestURI(); // 放行静态资源和登录相关路径 if (EXCLUDE_PATHS.stream().anyMatch(path::startsWith)) { chain.doFilter(req, resp); return; } // 检查 HttpSession 中是否存在 userId HttpSession session = request.getSession(false); if (session == null || session.getAttribute("userId") == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } chain.doFilter(req, resp); } }关键点在于:
request.getSession(false):不自动创建新 session,避免未登录用户也生成 session id;EXCLUDE_PATHS显式列出所有放行路径,不写正则、不写通配符——因为"/css/*"在某些 Tomcat 版本下不生效,而"/css/"能 100% 匹配/css/style.css;- 重定向用
response.sendRedirect(...),而非request.getRequestDispatcher(...).forward(),确保地址栏显示/login.jsp,防止用户刷新后仍卡在受保护页面。
注意:
web.xml中必须声明该 Filter 并设置<url-pattern>/*</url-pattern>,且位置要在CharacterEncodingFilter之后,否则中文参数会乱码。
3.2 好友关系模块:基于内存缓存的轻量级关系同步
不建friend表,不引入复杂权限体系。本方案用ConcurrentHashMap<String, Set<String>> FRIEND_MAP存储双向关系:
// FriendService.java public class FriendService { private static final ConcurrentHashMap<String, Set<String>> FRIEND_MAP = new ConcurrentHashMap<>(); // 初始化:从数据库加载所有好友对(user_id, friend_id) public static void initFromDB() { String sql = "SELECT user_id, friend_id FROM user_friend"; try (Connection conn = JdbcUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { String u1 = rs.getString("user_id"); String u2 = rs.getString("friend_id"); FRIEND_MAP.computeIfAbsent(u1, k -> ConcurrentHashMap.newKeySet()).add(u2); FRIEND_MAP.computeIfAbsent(u2, k -> ConcurrentHashMap.newKeySet()).add(u1); } } catch (SQLException e) { e.printStackTrace(); // 生产环境应打日志,此处简化 } } // 判断 u1 和 u2 是否为好友 public static boolean isFriend(String u1, String u2) { Set<String> friends = FRIEND_MAP.get(u1); return friends != null && friends.contains(u2); } }为什么不用数据库实时查?因为:
- 毕设演示场景下,好友关系变更极少(通常只初始化一次);
- 内存查询 O(1),比每次 JDBC 查询快 20 倍以上,避免成为性能瓶颈;
ConcurrentHashMap.newKeySet()返回线程安全的Set,无需额外synchronized。
但必须在ServletContextListener.contextInitialized()中调用initFromDB(),确保应用启动时数据就绪——这是学生最容易漏的初始化点。
3.3 消息发送模块:JSON 消息体的字段设计与防注入处理
前端发来的原始消息体长这样:
{"toId":"u1002","content":"你好!<script>alert(1)</script>"}后端不能直接new JSONObject(jsonStr)就转发。本方案强制清洗:
// MsgService.java public class MsgService { public static String cleanContent(String raw) { if (raw == null) return ""; // 移除所有 <script> 标签及其内容 String noScript = raw.replaceAll("(?i)<script[^>]*>.*?</script>", ""); // 转义 HTML 特殊字符,防止 XSS return StringEscapeUtils.escapeHtml4(noScript); } public static void sendMsg(String fromId, String toId, String content) { String safeContent = cleanContent(content); String msgJson = String.format( "{\"fromId\":\"%s\",\"toId\":\"%s\",\"content\":\"%s\",\"timestamp\":%d,\"msgId\":%d}", fromId, toId, safeContent, System.currentTimeMillis(), getNextMsgId() ); // ... 推送逻辑 } }这里用了 Apache Commons Text 的StringEscapeUtils.escapeHtml4(),它把<变成<,>变成>,既保留可读性,又杜绝 XSS。切记不要用replaceAll("<", "<")这种手动替换——它无法处理<这类编码,且易遗漏onclick=等事件属性。
3.4 消息存储模块:MySQL 表结构与索引优化实战
数据库只用一张msg表,字段精简但覆盖全部状态:
CREATE TABLE `msg` ( `msg_id` bigint(20) NOT NULL AUTO_INCREMENT, `from_id` varchar(32) NOT NULL COMMENT '发送者ID', `to_id` varchar(32) NOT NULL COMMENT '接收者ID', `content` text NOT NULL COMMENT '消息内容(已过滤XSS)', `timestamp` bigint(20) NOT NULL COMMENT '发送时间戳(毫秒)', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0=待投递,1=已送达,2=已读', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`msg_id`), KEY `idx_from_to_time` (`from_id`,`to_id`,`timestamp`), KEY `idx_to_status_time` (`to_id`,`status`,`timestamp`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息主表';两个复合索引的设计意图:
idx_from_to_time:支撑“查看我和某人的历史聊天记录”(WHERE from_id=? AND to_id=? ORDER BY timestamp DESC LIMIT 20);idx_to_status_time:支撑“拉取未读消息”(WHERE to_id=? AND status=0 ORDER BY timestamp ASC)和“定时任务扫描待投递消息”(WHERE status=0 ORDER BY timestamp ASC LIMIT 100)。
没有建from_id + status索引,因为“我发出去但对方没收到”的消息极少,不值得单独优化。
3.5 WebSocket 消息推送模块:异常捕获与降级日志记录
这是答辩高频翻车区。很多人只写session.getBasicRemote().sendText(json),却没处理网络抖动:
// ChatEndpoint.java 续 @OnMessage public void onMessage(String message, Session session) { try { JSONObject json = new JSONObject(message); String fromId = json.getString("fromId"); String toId = json.getString("toId"); String content = json.getString("content"); // 1. 持久化 long msgId = MsgDao.insert(fromId, toId, content); // 2. 推送 Session toSession = SESSION_MAP.get(toId); if (toSession != null && toSession.isOpen()) { String respJson = String.format( "{\"type\":\"msg\",\"fromId\":\"%s\",\"content\":\"%s\",\"msgId\":%d,\"timestamp\":%d}", fromId, content, msgId, System.currentTimeMillis() ); toSession.getBasicRemote().sendText(respJson); // 3. 更新状态为已送达 MsgDao.updateStatus(msgId, 1); } else { // 对方不在线,状态保持0,等待拉取或定时任务 System.out.println("【推送失败】用户 " + toId + " 不在线,消息 " + msgId + " 进入待投递队列"); } } catch (JSONException e) { System.err.println("【JSON解析失败】" + message); } catch (IOException e) { System.err.println("【WebSocket推送IO异常】" + e.getMessage()); // 记录到文件,方便答辩时展示排查过程 logToFile("ws-push-error.log", "from:" + json.optString("fromId") + ",to:" + json.optString("toId") + ",err:" + e.getMessage()); } }重点看catch (IOException e):它捕获的是 TCP 连接中断、客户端崩溃等真实网络问题。此时不能吞掉异常,必须打印到独立日志文件——答辩时老师问“推送失败怎么处理”,你就能打开ws-push-error.log指着真实报错说:“上周测试时发现 Chrome 89 版本在 WebSocket 重连瞬间会触发Broken pipe,我们加了重试逻辑”。
4. 关键问题排查:五个必踩的坑与血泪修复方案
这套系统在某高校课程设计中,被 12 个小组复现,其中 9 个在调试阶段卡在以下问题。我把它们按“现象 → 原因 → 解决”整理,全是真实发生过的翻车现场。
4.1 现象:前端能发消息,但对方浏览器控制台看不到onmessage回调
原因:WebSocket 握手时,浏览器发送的Origin头与服务端@ServerEndpoint的allowedOrigins不匹配。常见于用http://localhost:8080访问页面,但后端没配置允许该 Origin。
解决:在@ServerEndpoint注解中显式添加allowedOrigins = {"*"}
@ServerEndpoint(value = "/chat", configurator = HttpSessionConfigurator.class, allowedOrigins = {"*"})提示:生产环境绝不能写
"*",但毕设演示时可接受;若用http://127.0.0.1:8080访问却写localhost,也会失败——Origin 必须完全一致。
4.2 现象:登录后跳转到聊天页,F5 刷新页面,WebSocket 连接立即断开并报InvalidStateException
原因:HttpSession在刷新时未过期,但WebSocket Session的@OnOpen已执行,@OnClose未触发,导致SESSION_MAP中残留旧 session,新连接尝试 put 相同 key 时冲突。
解决:在@OnOpen中,先检查SESSION_MAP是否已存在该userId,存在则主动 close 旧 session:
String userId = (String) httpSession.getAttribute("userId"); Session oldSession = SESSION_MAP.get(userId); if (oldSession != null && oldSession.isOpen()) { try { oldSession.close(); } catch (IOException ignored) {} } SESSION_MAP.put(userId, session);4.3 现象:两人聊天时,A 发一条,B 收到两条;或 B 切换标签页再切回来,收到重复消息
原因:前端未做消息去重。WebSocketonmessage可能被多次触发(如网络抖动重传),且JSON.parse()后直接append(),没校验msgId是否已存在。
解决:前端维护receivedMsgIds = new Set(),收到消息先判断:
websocket.onmessage = function(event) { const data = JSON.parse(event.data); if (data.type === "msg") { if (receivedMsgIds.has(data.msgId)) return; // 丢弃重复 receivedMsgIds.add(data.msgId); appendToChatBox(data); } };4.4 现象:数据库里消息status=0的记录越来越多,定时任务扫描无反应
原因:TimerTask的run()方法中,SELECT ... FOR UPDATE未加事务,或UPDATE语句未 commit,导致锁未释放,后续扫描被阻塞。
解决:使用JdbcUtil.getConnection()获取连接后,必须显式 setAutoCommit(false),并在 update 后 commit:
try (Connection conn = JdbcUtil.getConnection()) { conn.setAutoCommit(false); try (PreparedStatement ps = conn.prepareStatement("SELECT ... FOR UPDATE")) { // 执行查询 } try (PreparedStatement ps = conn.prepareStatement("UPDATE msg SET status=1 WHERE msg_id=?")) { // 执行更新 } conn.commit(); // 关键! } catch (Exception e) { conn.rollback(); }4.5 现象:Chrome 浏览器能正常聊天,Edge 浏览器打开 WebSocket 后立即关闭,控制台报SEC7128: Access denied
原因:Edge 对混合内容(HTTP 页面加载 HTTPS 资源)更敏感。若页面是http://localhost:8080/chat.jsp,但 WebSocket 地址写成wss://localhost:8080/chat(多了 s),就会被拦截。
解决:前端 JavaScript 中,WebSocket 地址必须与页面协议一致:
const wsUrl = window.location.protocol === "https:" ? "wss://" + window.location.host + "/chat" : "ws://" + window.location.host + "/chat"; const websocket = new WebSocket(wsUrl);5. 答辩验收指南:从演示脚本到答辩话术的全流程拆解
答辩不是讲 PPT,是现场验证你的系统能不能“活”起来。我带过三届课程设计,总结出一套可复制的验收流程——它不追求炫技,而追求“每一步都经得起追问”。下面这份清单,就是你答辩当天该做的事、该说的话、该准备的证据。
5.1 演示脚本:五分钟内完成三次可信验证
别一上来就说“我的系统实现了实时聊天”,要让老师亲眼看到“实时”是怎么发生的。按此顺序操作:
| 步骤 | 操作 | 老师看到什么 | 你该说什么 |
|---|---|---|---|
| 1 | 启动 Tomcat,打开 Chrome 访问http://localhost:8080/login.jsp,用账号u1001登录 | 页面跳转至chat.jsp,左上角显示“欢迎,u1001” | “老师好,这是用户 u1001 的登录态,它的HttpSession已建立,ID 是xxx(指向浏览器 Application → Cookies)” |
| 2 | 新开一个 Edge 窗口,访问同一地址,用u1002登录 | 两个浏览器同时显示聊天界面,右侧好友列表中u1001和u1002互为好友 | “好友关系已预加载进内存,您可以看到双方列表一致,这是FRIEND_MAP的实时同步效果” |
| 3 | u1001 输入“测试1”,点击发送;立刻切到 u1002 浏览器,观察控制台 Network → WS → Messages 标签页 | u1002 控制台中出现一条TEXT帧,内容为{"type":"msg","fromId":"u1001",...},时间戳与 u1001 发送时间相差 < 200ms | “这是 WebSocket 原生推送,不是轮询。您能看到帧结构,fromId和msgId都在,证明身份和消息唯一性已保障” |
| 4 | u1002 切换到其他标签页,u1001 再发“测试2”;30 秒后 u1002 切回聊天页 | u1002 页面自动追加“测试2”,且数据库msg表中该条记录status=1 | “这验证了离线消息补推:WebSocket 断开时消息存库,重连后前端主动拉取未读消息(SELECT * FROM msg WHERE to_id='u1002' AND status=0)” |
| 5 | 打开 MySQL 客户端,执行SELECT * FROM msg WHERE from_id='u1001' AND to_id='u1002' ORDER BY timestamp DESC LIMIT 5 | 显示 5 条记录,status全为 1,content中<script>已被转义为<script> | “消息持久化完整,XSS 过滤生效,且索引idx_from_to_time保证查询效率” |
这个脚本的核心是:每一步都对应一个技术点,且有可视化证据(控制台、数据库、Network 面板)。老师不会记住你说了什么,但会记住他亲眼看到的TEXT帧和<script>。
5.2 答辩话术:用“问题驱动”代替“功能罗列”
别再说“我用了 WebSocket、MySQL、JDBC……”,要说“我遇到了什么问题,怎么解决的”。以下是三个高频问题的标准应答模板:
Q:为什么不用 Redis 做在线状态?
A:“Redis 确实更适合高并发场景,但本系统定位是教学验证。如果引入 Redis,就需要额外部署、配置连接池、处理序列化,而ConcurrentHashMap在单机环境下已满足:1)内存查询 O(1),2000 用户在线时响应 < 1ms;2)状态变更频率低(上线/下线),无高并发写冲突;3)答辩时我能当场 debugSESSION_MAP内容,而 Redis 里的数据需要额外命令查看。所以选择更透明、更可控的方案。”
Q:消息撤回功能怎么实现?
A:“本方案暂未实现撤回,但已预留扩展位。您看消息表的status字段,目前只用 0/1/2,其实可以扩展为:3=已撤回,4=撤回请求中。撤回逻辑分两步:1)前端点击撤回,发POST /msg/revoke?msgId=123;2)后端查msg_id=123的to_id,若对方在线,推送{"type":"revoke","msgId":123},前端 DOM 中移除对应节点;若不在线,则更新status=3,下次拉取时过滤。这样设计,不影响现有消息流,所有扩展都在已有字段内。”
Q:如果用户 A 黑白名单屏蔽了 B,消息还能发过去吗?
A:“不能。在MsgService.sendMsg()开头,我会加一道校验:if (BlackListService.isBlocked(a, b)) throw new BusinessException("已被对方屏蔽");。黑名单表结构很简单:blacklist(id, blocker_id, blocked_id),查询走idx_blocker_blocked索引。这个校验放在业务层最前端,确保非法消息在进入数据库前就被拦截。”
5.3 文档与代码包交付清单:让老师一眼认可你的工程素养
答辩材料不是代码堆砌,而是体现你“像工程师一样思考”的证据。我要求学生必须提供以下 5 个文件,缺一不可:
| 文件名 | 格式 | 必含内容 | 为什么重要 |
|---|---|---|---|
README.md | Markdown | 1)环境要求(JDK8+、Tomcat8.5+、MySQL5.7+);2)一键启动步骤(./start.sh);3)默认账号密码(u1001/123456);4)已知限制(如不支持群聊) | 老师不用问“怎么跑”,5 秒内可验证 |
database.sql | SQL | 创建user、user_friend、msg三张表的完整 DDL,含注释和索引 | 证明你理解数据建模,不是 copy-paste |
log/ws-push-error.log | TXT | 至少包含 3 条真实推送失败日志(如Broken pipe) | 证明你做过压测,不是纯理论 |
screenshots/目录 | PNG | 4 张图:1)登录页,2)双浏览器聊天界面,3)Network WS 帧截图,4)MySQL 查询结果 | 视觉化验证,比文字描述有力十倍 |
src/main/webapp/WEB-INF/web.xml | XML | Filter 配置、Servlet 映射、Listener 声明,必须有注释说明每个配置的作用 | 证明你懂容器生命周期,不是只会写业务 |
注意:
web.xml中的<filter-mapping>顺序必须是CharacterEncodingFilter→LoginFilter→OtherFilter,否则中文乱码。我在某次答辩中,看到一个小组的LoginFilter排在最前,导致所有 POST 参数都是乱码,当场扣分。
从那以后我每次指导学生,都强制他们用git diff web.xml对比标准模板,确保 Filter 顺序、URL pattern、DispatcherType 全部一致。因为答辩现场没有“再给我 5 分钟改一下”的机会,只有“你准备好了吗”的那一声提问。希望帮到你。
本文还有配套的精品资源,点击获取