Java Socket多线程聊天室:TCP长连接与消息持久化实战
简介这是一份面向Java初学者与课程实践者的网络聊天室项目源码包围绕Socket通信、多线程处理与数据库管理三条主线帮助读者理解一个可运行的在线聊天系统如何从连接建立、消息广播走到用户注册与登录验证。压缩包共46个文件约199KB以7个java源文件与20个class字节码为主体另含jpg、gif界面素材、txt说明与账号数据、db数据库文件及工程配置文件源码与运行产物、资源素材分层存放便于对照阅读和二次调试。目前已有195人学习下载。读者可从中获取服务端与客户端的完整实现骨架、多线程并发连接的线程划分思路、消息广播与在线状态跟踪的处理方式以及用户信息加密存储、聊天记录落库的接口调用示例并附带使用说明辅助快速运行适合作为网络编程课程设计或Java综合实践的参考范本。1. 需求先立住一个班级规模的聊天室为什么不走 HTTP 轮询带 Java 课程设计这些年翻车最多的不是代码写不出来而是选型一开始就跑偏用 Servlet 加 AJAX 轮询去模拟实时聊天消息延迟两三秒服务端日志刷得飞起答辩时被追问一句「同时在线一百人怎么办」就答不上来。这套 Java 网络聊天室换了一条路——原生 Socket 建立 TCP 长连接服务端用多线程线程池承接每一个客户端会话账号、好友、聊天记录全部落到数据库。它面向的是要交课程设计、毕业设计、网络编程大作业的人登录注册、群聊私聊、在线列表、历史消息这几项功能闭环Socket 通信、多线程并发、JDBC 三个技术点齐全一个人两三周能写完并当场演示。2. 通信协议与总体架构设计C/S 分层加自定义消息帧很多人写聊天室第一版代码是这样的客户端new ObjectOutputStream(socket.getOutputStream())服务端new ObjectInputStream(socket.getInputStream())然后直接把一个String写过去。本地测试两条消息没问题一上多人并发就开始串台、半句、乱码。问题的根子不在 Java而在于把 TCP 当成了「消息流」它其实是「字节流」。所以这套方案的第二步不是写业务而是先把协议和分层定死。2.1 从功能清单反推模块划分先把需求拆成四类再让每一类落到一个明确的模块上后面写代码才不会边写边改结构。需求类别具体功能服务端归属客户端归属账号注册、登录、重复登录踢下线AuthService UserDao登录窗会话群聊广播、私聊点对点SessionManager Router聊天主窗在线感知在线用户列表、上下线通知SessionManager右侧好友栏历史私聊记录拉取、群聊最近 N 条MessageDao打开会话时加载服务端按职责切成四层接入层只负责accept会话层ClientHandler负责一条连接从建立到断开的全部生命周期路由层根据消息类型分发到不同处理器持久层 DAO 只碰 SQL。客户端同样分三层Swing 界面、一个常驻的接收线程、一个发送队列。提示分层不是为了好看。真正写起来你会发现UI 线程里一旦直接调用socket.getOutputStream().write()网络稍微卡一下整个窗口就假死这个坑在答辩演示时特别致命。2.2 自定义消息帧解决粘包与半包的第一步协议选的是最经典的「长度前缀」方案4 字节长度 1 字节类型 N 字节正文。长度字段只统计类型和正文不包含它自己这样读的时候先读 4 字节拿到 len再老老实实读满 len 个字节边界就永远不会错。正文用 UTF-8 编码的 JSON 字符串方便调试时直接打印。// 消息帧格式[4字节长度][1字节类型][N字节正文]长度 1 正文长度 public final class FrameCodec { public static final byte TYPE_LOGIN 0x01; public static final byte TYPE_GROUP 0x02; public static final byte TYPE_PRIVATE 0x03; public static final byte TYPE_HEARTBEAT 0x04; public static void write(OutputStream out, byte type, String body) throws IOException { byte[] payload body.getBytes(StandardCharsets.UTF_8); int len 1 payload.length; // 只算类型 正文不含长度字段自身 DataOutputStream dos new DataOutputStream(out); dos.writeInt(len); dos.writeByte(type); dos.write(payload); dos.flush(); // 不 flush 消息会卡在缓冲区里 } public static Frame read(InputStream in) throws IOException { DataInputStream dis new DataInputStream(in); int len dis.readInt(); // 阻塞直到 4 字节全部到齐 if (len 0 || len 64 * 1024) { // 防御异常长度直接断链避免 OOM throw new IOException(非法帧长度: len); } byte type dis.readByte(); byte[] body new byte[len - 1]; dis.readFully(body); // 关键readFully 保证读满等价于循环 read return new Frame(type, new String(body, StandardCharsets.UTF_8)); } }几个参数值得单独说清楚。len上限设成 64KB是因为聊天室正文不会有长文本超过这个值基本可以判定是协议错位或者恶意数据早点断链比撑爆堆内存强。readFully和read的区别是最容易被忽略的一点read(byte[])只保证「至少读到一个字节」网络状况差的时候它可能只给你半帧剩下的要靠下一次循环补齐这也是半包问题的来源。至于为什么不用writeUTF因为它最大只支持 65535 字节而且和自定义类型字节混用以后扩展一个文件消息就得改协议。帧类型和正文的对应关系建议提前写进注释客户端服务端各一份联调时能省掉大量「这条消息为什么没反应」的排查时间// TYPE_PRIVATE 的正文示例 {from:1001,to:1002,content:在吗,ts:1730000000000}2.3 数据库表设计三张表撑起整个业务表设计不用贪多账号、消息两张核心表加上一张好友关系表就够了。字符集统一utf8mb4否则用户发个 emoji 就直接抛Incorrect string value。CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, -- 存 SHA-256 加盐后的值不存明文 nickname VARCHAR(32) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, from_id BIGINT NOT NULL, to_id BIGINT DEFAULT NULL, -- NULL 表示群聊消息 content VARCHAR(1000) NOT NULL, msg_type TINYINT NOT NULL DEFAULT 1, -- 1 群聊 2 私聊 send_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_to_time (to_id, send_time), -- 拉取私聊历史用 KEY idx_send_time (send_time) -- 群聊最近 N 条用 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_friend ( user_id BIGINT NOT NULL, friend_id BIGINT NOT NULL, PRIMARY KEY (user_id, friend_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;在线状态没有放进t_user这点是我踩过坑之后改的。状态是运行时数据人一多就要频繁UPDATE每次上下线都产生一次写库还把行锁带着一起走。正确做法是状态只存在SessionManager的内存表里数据库只负责账号和历史。索引的建法也有讲究(to_id, send_time)这个联合索引对应的是「按接收人查、按时间倒序取最近 20 条」这个高频查询缺了它消息量过万以后翻页会明显卡顿。3. 服务端多线程实现线程池、会话表与广播路由多线程是这套方案的第二个技术点也是最容易写出「看起来能跑、一压就崩」的地方。核心原则只有一条accept 线程只做接客业务逻辑全部交给线程池任何可能阻塞很久的操作都不能占着工作线程不放。3.1 监听线程与工作线程的职责边界public class ChatServer { private static final int PORT 8888; // 聊天室属于 IO 密集型场景线程数不必等于 CPU 核数但必须有上限 private final ExecutorService workers Executors.newFixedThreadPool(64); private final SessionManager sessions new SessionManager(); public void start() throws IOException { ServerSocket server new ServerSocket(); server.setReuseAddress(true); // 重启时避免 TIME_WAIT 导致端口占用 server.bind(new InetSocketAddress(PORT), 128); // 128 是 accept 等待队列长度 System.out.println(聊天服务已启动端口 PORT); while (true) { Socket socket server.accept(); // 阻塞等待只负责接客 socket.setTcpNoDelay(true); // 聊天消息短小关掉 Nagle 降低延迟 workers.submit(new ClientHandler(socket, sessions)); } } }为什么用newFixedThreadPool(64)而不是newCachedThreadPool()后者线程数没有上限在线人数一涨就创建几百个线程每个线程默认 1MB 栈空间还没处理业务内存就先没了。而newSingleThreadExecutor又会让所有客户端串行排队一个人发大消息全员卡住。固定大小线程池配一个有界任务队列是最省心也最容易在答辩时讲清楚的选择。至于为什么不用 NIO 的Selector课程设计的场景里Selector 对大多数人是纯黑匣子出了问题连日志都打印不出来而阻塞 IO 加线程池的模型每一行代码的因果都看得见。千人以上在线再考虑 NIO这是另一个话题。3.2 在线会话表ConcurrentHashMap 与广播的正确姿势public class SessionManager { // key 用 userId 而不是 username改名和重名都不影响路由 private final MapLong, ClientHandler online new ConcurrentHashMap(); public void add(long userId, ClientHandler handler) { ClientHandler old online.put(userId, handler); // 重复登录新会话覆盖旧会话 if (old ! null) { old.send(FrameCodec.TYPE_HEARTBEAT, {\msg\:\账号在其他地方登录\}); old.close(); // 主动踢掉旧连接 } } public void remove(long userId) { online.remove(userId); } public String onlineUserJson() { // 只暴露昵称和 id不要把密码之类的字段带出去 return online.keySet().stream() .map(String::valueOf) .collect(Collectors.joining(,, [, ])); } public void broadcast(byte type, String body, long excludeUserId) { for (ClientHandler h : online.values()) { // ConcurrentHashMap 迭代器弱一致不会抛 CME if (h.getUserId() excludeUserId) continue; h.send(type, body); // send 内部吞掉单点异常一个坏连接不能拖垮整轮 } } }这段的重点不在 API而在三个决策。第一为什么不用HashMap加synchronized广播要遍历整个表如果整个表被一把锁锁住遍历期间所有登录、下线、私聊都要排队并发一下就退化成了串行。ConcurrentHashMap的分段思想让读操作几乎无锁写入只锁一个桶。第二为什么broadcast里不对集合做快照复制如果复制成new ArrayList(online.values())每次广播都产生一次 O(n) 的拷贝群消息频繁时 GC 压力明显而ConcurrentHashMap的迭代器本身是弱一致的遍历期间新加入的会话最多是这一轮收不到下一轮就补上了对聊天室来说完全可以接受。第三send必须自己处理异常广播循环里任何一个客户端 write 失败抛出去后面所有人都收不到消息这种「一个人掉线全体失声」的 bug 排查起来非常费劲。3.3 ClientHandler一条连接的完整生命周期public class ClientHandler implements Runnable { private final Socket socket; private final SessionManager sessions; private final Object writeLock new Object(); private volatile long userId -1; // volatile别的线程读在线表时能看见登录后的写入 private OutputStream out; Override public void run() { try (Socket s socket; InputStream in s.getInputStream(); OutputStream os s.getOutputStream()) { this.out os; while (!s.isClosed()) { Frame frame FrameCodec.read(in); // 阻塞读对端关闭时抛 EOFException Router.dispatch(this, frame); // 按类型分发登录 / 群聊 / 私聊 / 心跳 } } catch (EOFException | SocketException e) { // 客户端正常关闭或网络中断属于预期路径不打错误日志 } catch (IOException e) { System.err.println(会话异常断开 userId userId : e.getMessage()); } finally { if (userId 0) { sessions.remove(userId); // 必须清理否则在线列表永远是脏的 sessions.broadcast(FrameCodec.TYPE_GROUP, {\sys\:\用户 userId 已下线\}, -1); } } } public void send(byte type, String body) { if (out null) return; synchronized (writeLock) { // 同一连接的写必须串行否则两帧会交错 try { FrameCodec.write(out, type, body); } catch (IOException e) { sessions.remove(userId); // 写失败说明连接已废立刻摘掉 } } } }writeLock这一行是整段代码里最不能省的地方。群聊广播和私聊应答可能来自两个不同的线程它们同时往同一个OutputStream写字节层面就会交错A 的帧长度头 B 的帧正文客户端按长度读的时候直接错位表现得就像「消息串台」。加锁以后每条帧要么写完整要么写失败不会出现半条。volatile修饰userId也是同理登录时在 handler 线程里赋值广播线程和在线列表接口要能立刻读到。最后是Router一个switch加几个处理器方法就够不要写成反射或者责任链那是给自己找麻烦switch (frame.getType()) { case FrameCodec.TYPE_LOGIN: authHandler.handle(handler, frame.getBody()); break; case FrameCodec.TYPE_GROUP: chatHandler.group(handler, frame.getBody()); break; case FrameCodec.TYPE_PRIVATE: chatHandler.privateChat(handler, frame.getBody()); break; case FrameCodec.TYPE_HEARTBEAT: handler.touch(); break; // 只刷新最后活跃时间 default: handler.send((byte) 0xFF, {\err\:\未知消息类型\}); }4. 数据库落地JDBC 连接池、DAO 分层与消息持久化单机版聊天室最容易忽略的是数据库这一层因为群里发两句话看不出差别。等到几十个人同时在线、每句话都要落库DriverManager.getConnection每次建连的几十毫秒就会累积成肉眼可见的卡顿然后就是Too many connections。4.1 连接不是越多越好用连接池替代每线程一个 Connectionpublic final class DataSourceHolder { private static final HikariDataSource DS; static { HikariConfig cfg new HikariConfig(); cfg.setJdbcUrl(jdbc:mysql://127.0.0.1:3306/chat ?useUnicodetruecharacterEncodingutf8mb4 serverTimezoneAsia/ShanghaiuseSSLfalse); cfg.setUsername(chat_app); cfg.setPassword(System.getenv(CHAT_DB_PWD)); // 不要写死在代码里 cfg.setMaximumPoolSize(10); // 远小于线程池线程数 cfg.setMinimumIdle(2); cfg.setConnectionTimeout(3000); // 拿不到连接最多等 3 秒快速失败 cfg.setIdleTimeout(60_000); cfg.setMaxLifetime(1_800_000); // 低于数据库 wait_timeout避免用到被服务端掐掉的连接 DS new HikariDataSource(cfg); } public static Connection getConnection() throws SQLException { return DS.getConnection(); } }maximumPoolSize给 10 而不是 64是这套参数里最反直觉的一个。业务线程大部分时间在等网络 IO真正落到数据库的操作是短事务10 个连接足够支撑几十个在线用户而如果把池子开到和线程数一样大数据库端连接数暴涨反而互相争锁。maxLifetime必须小于数据库的wait_timeout默认 8 小时否则可能出现「连接在池子里躺着服务端已经把它关了」第一次使用直接抛Communications link failure这种问题在演示前一晚特别容易出现。如果课程设计要求「自己实现连接池」那就写一个LinkedListConnection加wait/notify的简化版核心逻辑是借出时判断是否超过上限、归还时唤醒等待者把这个思路讲出来比调用现成库更能体现理解。4.2 DAO 分层与预编译把 SQL 关进一个包里public class MessageDao { private static final String INSERT INSERT INTO t_message(from_id, to_id, content, msg_type) VALUES(?,?,?,?); public void save(Message m) throws SQLException { try (Connection c DataSourceHolder.getConnection(); PreparedStatement ps c.prepareStatement(INSERT)) { ps.setLong(1, m.getFromId()); if (m.getToId() null) { ps.setNull(2, Types.BIGINT); // 群聊消息 to_id 为 NULL } else { ps.setLong(2, m.getToId()); } ps.setString(3, m.getContent()); ps.setByte(4, m.getMsgType()); ps.executeUpdate(); } } public ListMessage findPrivateHistory(long a, long b, int limit) throws SQLException { String sql SELECT id, from_id, to_id, content, send_time FROM t_message WHERE msg_type 2 AND ((from_id ? AND to_id ?) OR (from_id ? AND to_id ?)) ORDER BY send_time DESC LIMIT ?; ListMessage list new ArrayList(); try (Connection c DataSourceHolder.getConnection(); PreparedStatement ps c.prepareStatement(sql)) { ps.setLong(1, a); ps.setLong(2, b); ps.setLong(3, b); ps.setLong(4, a); // 双向都要查这是私聊的对称性 ps.setInt(5, Math.min(limit, 100)); // 上限保护防止一次拉全表 try (ResultSet rs ps.executeQuery()) { while (rs.next()) list.add(map(rs)); } } return list; } }私聊历史这条 SQL 的(from_id? AND to_id?) OR (from_id? AND to_id?)是必须的很多人只写了一个方向导致 A 看得到 B 的消息、B 看不到 A 的这个 bug 藏得深往往演示到一半才发现。LIMIT用占位符传参也是重点拼字符串不仅容易被注入还会因为每次 SQL 文本不同导致预编译缓存失效。预编译防注入不是口号。登录查询如果用SELECT * FROM t_user WHERE username name 用户输入 OR 11就能直接登进任何账号答辩时被现场演示一次很尴尬。用PreparedStatement以后输入内容永远被当作值处理参数里的引号会被转义。4.3 写库不要卡住聊天异步队列加批量提交public class MessageWriter { private final BlockingQueueMessage queue new LinkedBlockingQueue(10_000); private final MessageDao dao new MessageDao(); public void start() { Thread t new Thread(() - { ListMessage batch new ArrayList(100); while (true) { try { // 先阻塞取一条最多等 500ms避免空转烧 CPU Message first queue.poll(500, TimeUnit.MILLISECONDS); if (first null) continue; batch.clear(); batch.add(first); queue.drainTo(batch, 99); // 顺手把队列里已有的捞出来凑一批 flush(batch); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } }, msg-writer); t.setDaemon(true); t.start(); } public boolean submit(Message m) { return queue.offer(m); // 队列满返回 false由上层决定丢弃还是回执 } private void flush(ListMessage batch) { String sql INSERT INTO t_message(from_id,to_id,content,msg_type) VALUES(?,?,?,?); try (Connection c DataSourceHolder.getConnection(); PreparedStatement ps c.prepareStatement(sql)) { c.setAutoCommit(false); // 批量提交必须关自动提交 for (Message m : batch) { ps.setLong(1, m.getFromId()); if (m.getToId() null) ps.setNull(2, Types.BIGINT); else ps.setLong(2, m.getToId()); ps.setString(3, m.getContent()); ps.setByte(4, m.getMsgType()); ps.addBatch(); } ps.executeBatch(); c.commit(); } catch (SQLException e) { System.err.println(批量写库失败丢弃 batch.size() 条); } } }异步写库的价值在压测时才体现得明显。同步写的时候每发一条消息都要等一次数据库往返服务器磁盘慢一点就是几十毫秒聊天窗口会一顿一顿的改成入队以后主链路只做内存操作实际落库由单独线程批量完成100 条消息一次提交吞吐量差别是数量级的。队列满的策略要提前想好直接offer返回 false 然后给发送方回一条「服务繁忙」比无界队列最终 OOM 要好得多。课程设计里把这个取舍说清楚比实现本身还能加分。4.4 登录校验与密码存储注册时不要存明文。简单做法是SHA-256(password salt)salt 每个用户随机生成 16 字节和密文一起存在password字段里格式salt:cipher。登录时按用户名取出 salt用同样的算法算一遍再比对。这不是工业级方案但足以说明「为什么不能存明文」答辩被问到时有话可讲。public static String hash(String raw, String salt) throws NoSuchAlgorithmException { MessageDigest md MessageDigest.getInstance(SHA-256); md.update(salt.getBytes(StandardCharsets.UTF_8)); byte[] digest md.digest(raw.getBytes(StandardCharsets.UTF_8)); StringBuilder sb new StringBuilder(); for (byte b : digest) sb.append(String.format(%02x, b)); return sb.toString(); }登录成功后要做的三件事把userId写回 handler、调SessionManager.add()建立在线映射、把离线期间收到的私聊消息推给客户端查t_message里to_id 当前用户且send_time在最后登录时间之后的部分。离线消息这个功能点看着小但它是答辩时最能体现「考虑了真实场景」的细节。5. 避坑与排查长连接场景下最容易翻车的五个点这一章列的都是我在类似项目里真实遇到过的每一条都有明确的现象特征照着对照基本能定位。5.1 消息串台与半句把字节流当消息流的代价现象群聊里某个人发一句话其他客户端收到的是被劈开的两段或者两个人同时私聊A 的消息出现在 B 的窗口里。原因服务端用in.read(buffer)一次读取就当成一条完整消息而 TCP 是字节流一次 read 可能拿到半条也可能拿到一条半。另一个来源是多线程同时往同一个OutputStream写两帧在字节层面交错。解决回到定长头加readFully的方案长度字段是唯一可靠的边界同一连接的写入用锁串行化。排查时最直接的办法是在FrameCodec.read里打印len和实际读到的字节数对不上就说明边界错了。5.2 线程池打满与界面假死一次 for 循环广播引发的连锁反应现象在线人数上来以后新用户登录要等十几秒才响应服务端 CPU 不高但线程 dump 里所有工作线程都停在 socket write 上。原因广播是同步的循环里逐个调用send某个客户端网络差或者干脆卡住不读write一直阻塞整个广播循环跟着卡占着的工作线程释放不掉越积越多直到线程池耗尽。解决给Socket设置setSoTimeout或者给每个会话配一个独立的发送队列加发送线程广播只负责入队真正的写由各自的线程完成。慢客户端积压超过阈值就直接断开不要让一个人的网络状况影响所有人。5.3 连接泄漏与端口占用finally 里没关的那几个对象现象服务跑一晚上第二天早上数据库报Too many connections或者关掉程序重新启动提示端口已被占用。原因Connection、PreparedStatement、ResultSet在异常路径上没关闭方法一抛异常就直接跳出去了ServerSocket没开setReuseAddress上一次的连接还停在TIME_WAIT状态。解决统一用 try-with-resources关闭顺序是ResultSet → Statement → Connection连接池打开泄漏检测Hikari 的leakDetectionThreshold设为 20 秒超时没归还就打日志堆栈能直接指出哪一行忘了关。5.4 中文乱码与 emoji 报错字符集没有全链路对齐现象中文昵称发送后变成问号或者用户一发 emoji 服务端就抛Incorrect string value: \xF0\x9F...。原因客户端和服务端一边用默认字符集Windows 上通常是 GBK、另一边按 UTF-8 解数据库表建的是utf8而不是utf8mb4四字节字符根本存不进去。解决三处显式对齐——getBytes(StandardCharsets.UTF_8)/new String(bytes, UTF_8)、JDBC URL 里characterEncodingutf8mb4、建表语句DEFAULT CHARSETutf8mb4。排查时先看数据库里存的到底是乱码还是空存的就不对说明问题在写之前存的正常但显示不对说明问题在客户端解码。5.5 在线列表不刷新没有心跳也没有兜底清理现象有人直接关窗口或者拔网线其他人的好友列表里还挂着这个人私聊发过去没有任何响应。原因没有心跳机制服务端阻塞在read上根本不知道对端已经消失另一种情况是清理逻辑本身有问题加入在线表用的是userIdfinally里删除用的却是usernameremove静默失败。解决客户端每 30 秒发一次心跳服务端记录最后活跃时间90 秒没心跳主动断开SessionManager的 add 和 remove 统一用userId作为 key重复登录时用put的返回值判断旧会话是否存在存在就主动踢掉并通知客户端。6. 验收与答辩一份能照着演的检查清单代码写完只是开始能不能在十分钟内把功能演示完整、并且扛住追问取决于有没有提前按清单走一遍。我通常会准备一张这样的表逐项打勾没打勾的不上台。检查项验证方式通过标准多端并发同时开 3 个客户端登录三方在线列表一致无需刷新群聊广播A 发一句话B、C 在 1 秒内收到A 自己不重复显示私聊隔离A 私聊 BC 完全收不到B 正常显示重复登录用同一账号再登一次旧会话被踢下线并收到提示断线清理直接关掉一个客户端窗口3 秒内其他端看到下线提示历史消息重登后打开私聊之前的对话可见顺序正确异常输入发送超长文本、表情、纯空白服务端不崩有明确回执压测不用搞复杂写一个简单的压测脚本就够起 50 个线程模拟客户端每个连上来发 100 条群消息观察服务端的线程数、连接池活跃数、消息延迟。这套组合能暴露 90% 的并发问题也足够支撑答辩时「这个系统能撑多少人」的回答——不要吹具体数字说清楚「单机 64 线程、连接池 10 个连接、实测 50 并发下平均延迟 80 毫秒」比任何形容词都可信。答辩前还有两个小动作很值钱。一是把服务端日志打到文件里按天切分演示时如果出现意外可以直接翻日志定位而不是现场重启碰运气二是准备一份「故障演示」比如故意用错误的帧格式发一条消息展示服务端的防御逻辑正常断链而不崩溃这比顺风顺水的演示更能说明你理解了协议。从那以后我每次带这类项目都强制要求把这张验收表打印出来贴在显示器边上每改一次代码就重新走一遍并发和断线两组用例——因为聊天室的 bug 几乎不会出现在单机单人测试里只会出现在你以为已经没问题的时候。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →