简介:面向校园场景的简易多人聊天系统项目,采用图形界面与网络编程相结合的方式,实现了用户注册、登录、创建聊天室、加入聊天室以及实时文本消息收发等核心功能,比较适合初学开发者以及在校学生将其作为课程设计或毕业设计开题阶段的参考样例。整个压缩包共包含十六个文件,整体大小仅有约二十八千字节,其中以五份Java源代码文件和六份编译后的Class字节码文件为主体,另附带项目工程配置文件、类路径设置、编辑器偏好设置以及聊天室属性配置文件,源码和二进制产物同时齐备,导入Eclipse后即可进行运行或调试。截至目前已有九十七人学习浏览,项目规模较小,代码结构清晰,可以快速摸清整体脉络。通过阅读该项目,能够梳理多人聊天系统的目录组织、模块划分、事件处理与网络通信代码的编排方式,尤其适合对照教材知识将零散概念整合为可运行的小型应用;在此基础上还能进一步理解客户端与服务器之间的交互流程、多房间消息分发机制,并为以后扩展文件传输、安全加密等功能提供基础性参考,是一份难得的网络编程入门实践样例。
1. 简易多人聊天系统:为什么这个看似过时的项目依然是 Java 网络编程的试金石
“基于 Java 的简易多人聊天系统”这个标题听起来像是课程设计选题,但真正动手做过的人都知道,它是一场关于 Socket 编程、多线程并发、协议设计和线程安全的综合考试。一个能稳定运行的聊天系统,代码量不大,但它把「网络编程 + 并发处理」这两座大山合并在一起,用最朴素的方式逼你逐层打通。我见过不少学习者在同一个项目里反复翻车:中文乱码、消息粘包、界面卡死、服务端线程泄漏,每个问题背后都是一个具体的计算机基础知识点。
这个项目适合谁?正在学 Java 网络编程的学生,想巩固并发知识的新人,以及需要给团队写内部小工具但不想引入重型框架的开发者。本文全部基于 JDK 原生 API,不依赖 Netty 或 Spring,从服务端到客户端的完整代码逐步拆解,再把最容易踩坑的几个问题单独拉出来讲透,最后给出心跳机制、私聊协议和压测验证的升级方案。核心代码大约 200 行,你完全能在一个晚上跑通。
2. 技术选型与架构设计:从原生 Socket 到线程模型的三个关键决策
2.1 为什么选 Java 原生的 ServerSocket + 多线程,而不是直接上 Netty
提到网络通信,很多人第一反应是「用 Netty」。Netty 确实是生产环境高并发 IM 的正确选择,但对于一个简易多人聊天系统,引入 Netty 是典型的杀鸡用牛刀,而且容易本末倒置。Netty 的异步非阻塞模型本身就有陡峭的学习曲线,Channel、EventLoop、编解码器这些概念叠加在一起,当你排查问题时,需要同时面对框架本身的错误和你自己业务代码的错误,难度直接翻倍。
我的习惯是第一版永远用原生 Socket,理由有三个。第一,ServerSocket 和 Socket 的 API 足够直白,accept() 阻塞等待连接、readLine() 阻塞读取数据,每一步都清晰可见,方便打断点调试。第二,原生方案零依赖,启动服务端后直接用 telnet 甚至浏览器都能连上去验证,不需要额外搭环境。第三,也是最重要的,原生阻塞模型暴露出来的问题——线程阻塞在哪、连接断了为什么读操作不返回——恰恰是生产环境中排查网络问题最常遇到的场景。在简易聊天室这种连接数几十以内、消息频率不高的场景下,阻塞 IO 的性能完全够用,代码可读性却比 NIO 好一个量级。
2.2 消息协议:先用「一行一条消息」的文本帧,别急着上 JSON
很多初学者拿到项目后的第一件事,是引入 Gson 或 Jackson,定义 ChatMessage 类,然后把对象序列化成 JSON 再通过 Socket 发送。我建议你把这一步往后放。原因有两个:一是 JSON 序列化和解析在简易系统里感知不到性能差异,但多一层依赖就多一个出错点;二是 JSON 并没有解决消息边界问题——你照样需要处理「一条消息被拆成两半」和「两条消息黏在一起」的情况,这两个问题统称为粘包半包问题。
更稳妥的起步方案是自定义极简文本协议。我一般用竖线分隔字段,固定格式是「消息类型|用户名|内容」,每行一条消息,行尾用 \n 作为边界:
MSG|张三|大家好 QUIT|张三|为什么用竖线而不是空格?因为聊天内容里可能出现空格,但竖线在正常交流中几乎不会出现。为什么不直接上 JSON?因为后面升级到私聊、心跳时,只需要在协议里增加消息类型,改动范围是可控的。而从一开始就把协议复杂化,调试时反而分不清问题是出在序列化还是网络传输上。
2.3 客户端连接管理:选对集合类,避免并发修改异常
服务端需要维护所有在线客户端的连接引用,这样某个客户端发消息时才能广播给其他人。这一步的容器选择,是第一个容易翻车的地方。如果直接用 ArrayList,它线程不安全——两个线程同时 add 元素,或者一个线程在遍历广播、另一个线程在移除断开的连接,轻则数据不一致,重则直接抛 ConcurrentModificationException。
我一般用 CopyOnWriteArrayList,原因是广播场景典型「读多写少」:连接建立和断开是低频操作,消息广播才是高频操作。CopyOnWriteArrayList 的写操作通过复制底层数组实现,读操作无需加锁,遍历时即使其他线程在修改列表,当前线程看到的仍是快照,不会抛异常。如果你后续要做私聊——按用户名找指定客户端发送消息——建议额外维护一个 ConcurrentHashMap,key 是用户名,value 是输出流,查找时间复杂度 O(1),踢人和查在线状态都方便。
3. 服务端实现:从监听端口到消息广播的完整代码
3.1 服务端主程序:accept 循环与线程池配置
服务端的主逻辑可以压缩成两个循环:外层循环负责 accept() 接收新连接,内层循环在每条连接的处理线程里逐行读取消息。下面给出最小可跑的服务端骨架代码:
import java.io.*; import java.net.*; import java.util.concurrent.*; public class ChatServer { // 保存所有在线客户端的输出流,用于广播 private static final CopyOnWriteArrayList<PrintWriter> writers = new CopyOnWriteArrayList<>(); public static void main(String[] args) throws IOException { int port = 8080; ServerSocket serverSocket = new ServerSocket(port); System.out.println("ChatServer 启动,监听端口:" + port); // 固定线程池,限制最大并发处理线程数,防止连接数过多耗尽资源 ExecutorService executor = Executors.newFixedThreadPool(10); while (true) { // accept() 阻塞等待新客户端连接 Socket socket = serverSocket.accept(); System.out.println("新客户端接入:" + socket.getRemoteSocketAddress()); // 每个连接提交给线程池,由池中的线程执行 ClientHandler executor.submit(new ClientHandler(socket, writers)); } } }这段代码有三个关键点。第一,writers 持有的是每个客户端的 PrintWriter 而不是 Socket 本身——广播时只需要往这个输出流写数据,持有 Socket 反而多余。第二,用了 newFixedThreadPool(10) 而不是每来一个连接就 new Thread(),因为线程创建和销毁的开销很大,固定线程池能控制资源上限,防止恶意客户端频繁连接把服务端打爆。第三,accept() 在没有连接时会一直阻塞,但不消耗 CPU,这是 Java Socket 编程的正常行为,不要试图给这行加锁或加超时。
线程池大小怎么调?看预期在线人数,简易聊天室 20 以内足够。如果在线人数会超过线程池大小,多余的连接会在任务队列里排队等待,客户端感知到的就是连接建立变慢但能连上。端口号按实际环境调整,注意避开已占用端口。
3.2 客户端处理线程:读取消息、广播转发与断开清理
下面这段代码是整个服务端的核心。它负责读取某个客户端的每一行消息、把消息广播给其他客户端、处理退出指令,并在 finally 块里做清理:
import java.io.*; import java.net.*; import java.util.concurrent.*; public class ClientHandler implements Runnable { private Socket socket; private PrintWriter writer; private String username; private CopyOnWriteArrayList<PrintWriter> writers; public ClientHandler(Socket socket, CopyOnWriteArrayList<PrintWriter> writers) { this.socket = socket; this.writers = writers; } @Override public void run() { try { // 注意:字符编码统一用 UTF-8,避免中文乱码 BufferedReader reader = new BufferedReader( new InputStreamReader(socket.getInputStream(), "UTF-8")); writer = new PrintWriter( new OutputStreamWriter(socket.getOutputStream(), "UTF-8"), true); // 协议约定:客户端连接后第一行先发用户名 username = reader.readLine(); writers.add(writer); System.out.println("用户 " + username + " 上线,当前在线 " + writers.size() + " 人"); broadcast("系统", username + " 加入了聊天室"); String line; while ((line = reader.readLine()) != null) { if ("QUIT".equals(line)) { break; } broadcast(username, line); } } catch (IOException e) { System.out.println("连接异常:" + e.getMessage()); } finally { // 无论正常退出还是异常断开,都要移出广播列表并关闭连接 if (writer != null) { writers.remove(writer); } try { socket.close(); } catch (IOException e) { e.printStackTrace(); } System.out.println("用户 " + username + " 已断开,当前在线 " + writers.size() + " 人"); } } private void broadcast(String from, String content) { for (PrintWriter w : writers) { w.println(from + ":" + content); } } }这段代码里有几个容易被忽略的细节。第一,PrintWriter 构造方法的第二个参数 true 表示自动刷新,每次 println 都会立即把数据刷到网络上。如果不设这个参数,消息会积压在缓冲区里,可能等缓冲区满了才发送,看起来就像服务端「迟迟不响应」。第二,reader.readLine() 在客户端正常退出时返回 null,在连接异常中断时抛 IOException,两种情况都要保证 finally 里的清理逻辑执行。第三,writers.add(writer) 放在读取用户名之后,避免还没完成身份认证的半连接被打进广播列表。
提示:不要在广播循环里试图「抓住」某个客户端断开的事件。PrintWriter 的 println 在底层 IO 异常时不会抛出来,而是吞掉异常并设置内部错误标记,所以广播循环不会因为某个客户端断开而中断。这是 PrintWriter 的一个隐藏行为,后面避坑章节会专门展开。
3.3 广播与私聊的路径分叉:两种消息路由的写法差异
上面的 broadcast 方法把消息发给所有人,包括发送者自己。在聊天室场景,一般希望发送者本地直接显示自己说的话,而不是绕一圈经服务端回显,否则会有明显的网络延迟感。所以更合理的设计是「广播给除发送者之外的所有客户端」:
private void broadcast(String from, String content, PrintWriter sender) { for (PrintWriter w : writers) { if (w != sender) { // 跳过发送者自己 w.println(from + ":" + content); } } }私聊则完全是另一条路径。假设协议扩展成「PMSG|目标用户|来源用户|内容」,服务端解析时按类型分流:
// 伪代码:在 readLine 循环内解析 String[] parts = line.split("\\|"); if ("PMSG".equals(parts[0])) { String target = parts[1]; PrintWriter targetWriter = userMap.get(target); if (targetWriter != null) { targetWriter.println("私聊 " + parts[2] + ":" + parts[3]); // 同时给发送者回执,表示消息已送达 writer.println("已发送给 " + target); } else { writer.println("用户 " + target + " 不在线"); } }这里有个容易出问题的点:split("\|") 之后如果聊天内容里也包含竖线,parts 数组的长度会超过预期。稳妥的做法是用 split("\|", 4) 限制只拆成 4 段,多余部分全部归入最后一段。协议解析的健壮性,是简易聊天系统升级到复杂协议时最容易忽略的细节。
4. 客户端实现:双线程模型、Swing 边界与退出顺序
4.1 控制台版客户端:读服务端消息和读用户输入必须分线程
服务端完成之后,客户端的实现就是「连接 + 双线程」的事。一个线程负责从 Socket 读取服务端广播的数据并打印到控制台,另一个线程负责读取用户在控制台的输入并发送给服务端。为什么要两个线程?因为 readLine() 是阻塞操作——如果你在同一个线程里先等用户输入再等服务端消息,那么用户不敲键盘时,服务端发来的消息永远显示不出来;反过来,如果你先等服务端消息,用户就没法输入。
import java.io.*; import java.net.*; public class ChatClient { public static void main(String[] args) throws IOException { String host = "127.0.0.1"; int port = 8080; Socket socket = new Socket(host, port); System.out.println("已连接服务器:" + host + ":" + port); // 两个流的编码统一用 UTF-8,与服务端保持一致 BufferedReader serverReader = new BufferedReader( new InputStreamReader(socket.getInputStream(), "UTF-8")); PrintWriter serverWriter = new PrintWriter( new OutputStreamWriter(socket.getOutputStream(), "UTF-8"), true); // 发送用户名 System.out.print("请输入昵称:"); BufferedReader console = new BufferedReader( new InputStreamReader(System.in)); serverWriter.println(console.readLine()); // 接收线程:持续读取服务端广播,阻塞在 readLine() new Thread(() -> { try { String msg; while ((msg = serverReader.readLine()) != null) { System.out.println(msg); } } catch (IOException e) { System.out.println("与服务端的连接已断开"); } System.exit(0); // 服务端断开后退出程序 }).start(); // 发送线程(主线程):读取控制台输入并发送 String input; while ((input = console.readLine()) != null) { serverWriter.println(input); if ("QUIT".equals(input)) { socket.close(); break; } } } }这段代码里最值得强调的是编码统一。InputStreamReader 和 OutputStreamWriter 都显式指定了 UTF-8——很多教材代码省略了这个参数,导致客户端在 Windows 默认 GBK 编码下和服务端 UTF-8 解码互相冲突,中文消息全部变成乱码。服务端同样用 UTF-8 解码,两边的编码不一致是聊天系统乱码的头号根源。
一个细节:接收线程里加了 System.exit(0),当服务端主动断开连接、readLine() 返回 null 时,整个客户端进程退出。如果不加这一行,用户会看到界面「卡住」——实际上是因为接收线程结束了,但主线程还阻塞在 console.readLine() 上等着用户输入,而用户已经收不到任何消息了。
4.2 Swing 版客户端的线程边界:EDT 上绝不能做阻塞读
控制台版跑通之后,升级为 Swing 图形界面时最容易犯的错误,是把 Socket 的 readLine() 直接放在事件分发线程(EDT)里。只要服务端一秒钟没发消息,readLine() 就阻塞,整个窗口就会卡死——按钮点了没反应、窗口拖不动,看起来像程序假死。
正确的结构是:接收消息的线程保持独立,但不直接操作 UI 组件,而是用 SwingUtilities.invokeLater() 把 UI 更新操作交回 EDT。下面是一个最小框架:
// 这段代码运行在独立的接收线程中 String msg; while ((msg = serverReader.readLine()) != null) { final String display = msg; // invokeLater 将更新操作提交到 EDT 队列,按顺序执行 SwingUtilities.invokeLater(() -> { chatArea.append(display + "\n"); // 自动滚动到底部 chatArea.setCaretPosition(chatArea.getDocument().getLength()); }); }为什么不能直接调用 chatArea.append()?因为 Swing 组件不是线程安全的,多个线程同时操作 UI 会导致界面状态错乱,甚至抛出异常。invokeLater 的本质是把 UI 更新操作排到 EDT 的事件队列里,由 EDT 串行执行,从而避免竞争。同理,发送按钮的监听器里直接调用 serverWriter.println(text) 是安全的,因为 println 在正常情况下不阻塞,只有网络缓冲区写满时才会阻塞——简易聊天室的消息频率远达不到这个量级。
另一个常见翻车点是直接在 EDT 里执行 new Socket(host, port)。如果网络不通或服务端没启动,默认的 Socket 构造会阻塞大约几秒到几十秒(取决于系统 TCP 连接超时设置),期间整个窗口假死。解决方案是把连接动作放到后台线程,连接成功后再用 invokeLater 切回 EDT 刷新界面状态,连接失败则弹一个对话框。
4.3 退出与重连:先发 QUIT 再关 Socket,顺序错了消息就丢了
退出逻辑看似简单,但顺序错了会出「灵异现象」。最典型的情况:用户关闭聊天窗口时,主线程只调用了 socket.close(),但后台接收线程还阻塞在 serverReader.readLine() 上。Socket 关闭后,阻塞的读操作会抛 SocketException,接收线程带着异常退出——这本身没问题;问题在于,如果程序想优雅地通知服务端「我要下线」,必须先发送消息再关闭连接,顺序反过来消息就发不出去。
正确的退出方法:
public void logout() throws IOException { try { serverWriter.println("QUIT"); // 先通知服务端 serverWriter.flush(); // 确保数据刷出本地缓冲区 } finally { socket.close(); // 再关闭 TCP 连接 } }这个顺序背后的原理是:TCP 是全双工协议,close() 一调用,操作系统会发送 FIN 包通知对端连接关闭。如果消息还没从本地缓冲区发出去就 close,QUIT 指令就永远到不了服务端。虽然简易聊天室里「少发一条退出消息」不影响整体功能,但如果你后面做的是带确认机制的可靠消息协议,这种顺序错误会导致对端永远收不到最后一条重要指令。
至于重连,建议在简易版本里不做自动重连。原因很直接:自动重连需要处理消息去重、离线消息补偿、会话状态恢复,这些工作加起来比核心聊天功能还大。做「手动重连」就够用了——点击按钮后创建一个新线程重新走一遍连接流程,旧线程捕获到 SocketException 后自行结束。这个取舍能帮你省下大量调试时间。
5. 避坑手册:多人聊天系统最容易翻车的 5 个问题
5.1 粘包半包问题:readLine() 为什么读不到完整消息
现象:某个客户端发送一条长消息,服务端收到的却是半截;或者两条消息连在一起被当成一条解析。
原因:TCP 是流式协议,没有消息边界。底层网络把数据拆成不定长数据包,接收方 readLine() 要等换行符才返回,但数据到达的时机和数量完全由网络决定。如果两次 println 的数据在传输层合并进同一个 TCP 段,接收方可能一次读到了两行;如果一条消息被拆成两个 TCP 段,readLine() 可能读到一半就阻塞了,直到下一个数据段到达才补全。
解决:我的做法是强制约定「一行一条消息」,消息内容本身不允许包含换行符。客户端在发送前把用户输入里的换行符替换成空格或转义符
,这个约定保证了一行即一条完整消息。如果要支持消息内容里的真实换行,就得升级协议,引入长度字段前缀——比如「MSG|用户名|内容长度|内容」——接收方先读一行控制信息,再按长度读取指定字节数。初学者先把换行约定做对,后续需要再升级。
5.2 客户端异常断开后,服务端线程卡死
现象:客户端直接拔网线或强制杀进程,服务端对应的处理线程没有退出,在线人数只增不减,但这些人其实已经不在了。
原因:正常情况下 close() 会让 readLine() 返回 null 并结束循环。但连接异常中断时,操作系统层面不会立即通知服务端——TCP 的保活机制默认要两个小时才能探测到对端消失,期间服务端的 readLine() 一直阻塞,什么也不做。
解决:给服务端加心跳检测。客户端每 30 秒发送一行 PING 消息,服务端记录每个连接的最后活跃时间,用一个定时任务扫描,超过 90 秒没收到心跳的连接直接关闭并移除。这个机制后面会在第 6 章给出具体实现。没有心跳机制的聊天系统,跑一段时间后线程数只增不减,直到资源耗尽,这是线上系统最典型的隐性故障。
5.3 广播时抛 ConcurrentModificationException
现象:客户端 A 发了一条消息,服务端广播遍历 writers 列表时,正好客户端 B 断开连接触发了 remove 操作。一个线程在遍历、另一个线程在改列表,异常当场抛出。
原因:用的是线程不安全的 ArrayList,遍历和修改发生在不同的线程里,没有同步。不是每次都触发,所以更具迷惑性——可能跑了几小时都没问题,突然某个时刻连接断开和广播撞在一起就翻车了。
解决:换成 CopyOnWriteArrayList。它的迭代器在创建时绑定一份底层数组快照,遍历过程中其他线程 add 或 remove 都不会影响当前遍历,彻底消灭并发修改异常。修复成本只有一行代码,但理解它需要真正搞懂「读多写少」和「遍历时修改」这两个并发场景——前者是这种集合的适用前提,后者是它解决的核心矛盾。
5.4 广播时 PrintWriter 静默吞掉异常
现象:某个客户端已经断开了,但服务端往它的 PrintWriter 里 println 时没有抛任何异常,广播循环正常结束,好像什么都没发生。结果是该客户端的数据实际上没人收到,但服务端无感知。
原因:PrintWriter 的 write 方法内部把 IOException 吞掉了,只在内部设置一个 error 标记,不向上抛出,除非你显式调用 checkError()。这是 PrintWriter 的一个历史遗留设计,目的是让输出操作不打断业务逻辑,但也掩盖了网络异常。
解决:广播时不要依赖 println 判断连接有效性。两种方案:一是每次写完调用 checkError() 检查错误标记,发现异常就手动移除;二是干脆不用 PrintWriter,改用 BufferedWriter + OutputStreamWriter,让 write 方法直接抛 IOException,异常捕获代码里做移除清理。我倾向于后者,错误处理链路更透明,出问题时日志里能看到具体异常栈。
5.5 端口占用与 TIME_WAIT:服务端重启失败的根源
现象:服务端程序崩溃后重新启动,抛出 BindException: Address already in use,换一个端口又能正常启动。
原因:Socket 关闭后,操作系统会进入 TIME_WAIT 状态,端口在短时间内被占用。默认情况下,ServerSocket 不允许在 TIME_WAIT 状态下重新绑定到同一端口。
解决:创建 ServerSocket 时设置地址复用:
ServerSocket serverSocket = new ServerSocket(); // setReuseAddress 必须在 bind 之前调用,否则不生效 serverSocket.setReuseAddress(true); serverSocket.bind(new InetSocketAddress(port));注意 setReuseAddress 必须在 bind 之前调用。另外,如果进程不是正常退出而是被强制 kill,内核不会走完整的 FIN 流程,端口也不会进 TIME_WAIT——这种情况下的占用是进程还活着,用 netstat -ano 确认进程 PID 后强制结束即可。这个问题的排查路径,比问题本身更有价值。
6. 从「能聊」到「好用」:心跳机制、私聊协议与压测验证的三个进阶动作
先说心跳机制的具体实现。客户端单独开一个定时线程,每 30 秒发送一行「PING|用户名」,服务端的 ClientHandler 里维护一个 volatile long 类型的 lastHeartbeat 字段,每次读到任何消息都更新时间戳。服务端用一个 ScheduledExecutorService 定时扫描所有连接,把超过 90 秒没更新心跳的连接主动关闭并移出列表:
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(() -> { long now = System.currentTimeMillis(); for (ClientHandler handler : handlers) { // handlers 保存所有活跃处理对象 if (now - handler.getLastHeartbeat() > 90000) { handler.close(); // 内部关闭 Socket 并从 writers 移除 } } }, 10, 10, TimeUnit.SECONDS);心跳间隔和超时阈值怎么定?间隔 30 秒、超时 90 秒是比较常用的配置——超时时间是间隔的 3 倍,能容忍一次网络抖动丢包而不误杀正常连接。如果你要把系统跑在跨地域的网络上,考虑把超时放宽到 120 秒,因为跨地域链路的 RTT 波动更大。加了心跳之后,异常断开的客户端最多只会残留 90 秒,不会再出现线程只增不减的泄漏。
私聊协议同样不复杂。我把消息格式扩展成「PMSG|目标用户|来源用户|内容」,服务端解析到 PMSG 前缀后,从 ConcurrentHashMap 中根据目标用户名取出对应的输出流,只往那一个流里写。这里有个必须处理的边界:目标用户不在线时,要回一条「用户不在线」的系统消息给发送方,否则发送方会一直傻等。如果做群聊,往消息里加一个组 ID 字段即可,广播循环里只给同一组的客户端输出。
最后说验证方法。我自己跑通一个聊天系统后,一定会做三项测试。第一项是双客户端互发消息,验证中文、长消息、带特殊符号的消息是否完好——特别要测输入一个制表符或者连续多个空格,看协议解析是否错乱。第二项是压测:我写一个模拟脚本,同时开 50 个虚拟客户端,每个客户端每秒发一条消息,观察服务端 CPU 占用和线程数,确认线程池没有被打满、也没有线程泄漏。第三项最关键:运行中强制杀掉一个客户端进程,等心跳超时后确认服务端在线人数减少——这一步能验证异常断开的自我清理能力,很多人的系统能跑通正常流程,但从来没验证过异常场景。
我在做完这个小而完整的系统之后最大的体会是:网络编程的坑不在 API,而在隐藏在阻塞、缓冲、线程交互里的各种边界行为。一次完整的调试经历能让你记住的细节,远胜于十遍文档阅读。按这个路径走一遍,你收获的不只是一个能跑的聊天室,而是一套排查网络问题的底层方法论。希望帮到你。
本文还有配套的精品资源,点击获取