Network通信小项目(二):从轮流收发到可视化多人聊天室(InternetV2 / V3)
上一篇主要整理了 TCP、UDP、IP、端口和 Socket,也用
InternetV1跑通了最简单的客户端/服务端通信。这篇继续记录后面的两个版本:
InternetV2和InternetV3。重点不是单纯贴代码,而是把每次修改背后的原因梳理一下:上一个版本有什么问题、为什么要改,以及这一版具体是怎么实现的。
1. 先回顾一下 V1 做到了什么
InternetV1 中只有两个类:
text
InternetV1/
├── A.java # 服务端
└── B.java # 客户端
服务端使用 ServerSocket(8080) 监听,客户端通过:
java
new Socket("localhost", 8080);
连接服务器。双方再通过 InputStream 和 OutputStream 收发消息。
V1 已经打通了最基本的一条链路:
text
B 输入消息 → TCP 发送 → A 接收并显示
A 输入回复 → TCP 发送 → B 接收并显示
当时为了区分消息,我自己约定了一个很简单的格式:
text
[1 字节长度][消息正文]
发送时先写入 message.length() + 1,接收时先读长度,再循环读取 len - 1 个字节。
这个版本虽然真的可以聊天,但只能算是"先跑起来"。继续测试以后,很快就发现了几个问题:
- 一次只能连接一个客户端;
- 客户端发完以后必须等待服务端回复,双方不能随时发送;
- 长度字段只有 1 字节,长消息会溢出;
String.length()是字符数量,不是网络上传输的字节数量;getBytes()没指定编码,中文在不同环境下可能乱码;- 代码没有完整处理关闭连接、异常和资源释放;
- 消息只有正文,没有发送者、类型、时间等信息。
于是后面的思路就比较清楚了:
V2 先把通信基础补稳,让它真正支持多人和异步收发;V3 再做应用层协议、网络层拆分和图形界面。
我没有直接从 V1 跳到 UI,因为底层通信如果还是乱的,界面做得越多,后面反而越难改。
2. 三个版本的整体迭代路线
| 版本 | 这一版主要解决的问题 | 最终效果 |
|---|---|---|
| InternetV1 | 先验证 TCP Socket 能不能连通 | 单客户端、轮流收发、控制台通信 |
| InternetV2 | 消息边界、中文、多客户端、异步收发 | 多客户端控制台聊天室 |
| InternetV3 | 消息类型、昵称、在线列表、代码分层、UI | Swing 可视化多人聊天室 |
#mermaid-svg-sqzCaoqCxo9lpidH{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-sqzCaoqCxo9lpidH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-sqzCaoqCxo9lpidH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-sqzCaoqCxo9lpidH .error-icon{fill:#552222;}#mermaid-svg-sqzCaoqCxo9lpidH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-sqzCaoqCxo9lpidH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-sqzCaoqCxo9lpidH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-sqzCaoqCxo9lpidH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-sqzCaoqCxo9lpidH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-sqzCaoqCxo9lpidH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-sqzCaoqCxo9lpidH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-sqzCaoqCxo9lpidH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-sqzCaoqCxo9lpidH .marker.cross{stroke:#333333;}#mermaid-svg-sqzCaoqCxo9lpidH svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-sqzCaoqCxo9lpidH p{margin:0;}#mermaid-svg-sqzCaoqCxo9lpidH .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-sqzCaoqCxo9lpidH .cluster-label text{fill:#333;}#mermaid-svg-sqzCaoqCxo9lpidH .cluster-label span{color:#333;}#mermaid-svg-sqzCaoqCxo9lpidH .cluster-label span p{background-color:transparent;}#mermaid-svg-sqzCaoqCxo9lpidH .label text,#mermaid-svg-sqzCaoqCxo9lpidH span{fill:#333;color:#333;}#mermaid-svg-sqzCaoqCxo9lpidH .node rect,#mermaid-svg-sqzCaoqCxo9lpidH .node circle,#mermaid-svg-sqzCaoqCxo9lpidH .node ellipse,#mermaid-svg-sqzCaoqCxo9lpidH .node polygon,#mermaid-svg-sqzCaoqCxo9lpidH .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-sqzCaoqCxo9lpidH .rough-node .label text,#mermaid-svg-sqzCaoqCxo9lpidH .node .label text,#mermaid-svg-sqzCaoqCxo9lpidH .image-shape .label,#mermaid-svg-sqzCaoqCxo9lpidH .icon-shape .label{text-anchor:middle;}#mermaid-svg-sqzCaoqCxo9lpidH .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-sqzCaoqCxo9lpidH .rough-node .label,#mermaid-svg-sqzCaoqCxo9lpidH .node .label,#mermaid-svg-sqzCaoqCxo9lpidH .image-shape .label,#mermaid-svg-sqzCaoqCxo9lpidH .icon-shape .label{text-align:center;}#mermaid-svg-sqzCaoqCxo9lpidH .node.clickable{cursor:pointer;}#mermaid-svg-sqzCaoqCxo9lpidH .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-sqzCaoqCxo9lpidH .arrowheadPath{fill:#333333;}#mermaid-svg-sqzCaoqCxo9lpidH .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-sqzCaoqCxo9lpidH .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-sqzCaoqCxo9lpidH .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sqzCaoqCxo9lpidH .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-sqzCaoqCxo9lpidH .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sqzCaoqCxo9lpidH .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-sqzCaoqCxo9lpidH .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-sqzCaoqCxo9lpidH .cluster text{fill:#333;}#mermaid-svg-sqzCaoqCxo9lpidH .cluster span{color:#333;}#mermaid-svg-sqzCaoqCxo9lpidH div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-sqzCaoqCxo9lpidH .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-sqzCaoqCxo9lpidH rect.text{fill:none;stroke-width:0;}#mermaid-svg-sqzCaoqCxo9lpidH .icon-shape,#mermaid-svg-sqzCaoqCxo9lpidH .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sqzCaoqCxo9lpidH .icon-shape p,#mermaid-svg-sqzCaoqCxo9lpidH .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-sqzCaoqCxo9lpidH .icon-shape .label rect,#mermaid-svg-sqzCaoqCxo9lpidH .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sqzCaoqCxo9lpidH .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-sqzCaoqCxo9lpidH .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-sqzCaoqCxo9lpidH :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} InternetV1
打通 TCP 链路
发现问题
单客户端、长度不足、收发互相等待
InternetV2
4 字节长度 + UTF-8
多线程 + 广播
新的需求
昵称、在线列表、消息类型、UI
InternetV3
应用层消息协议
网络层与 Swing UI 分离
回头看,三个版本不是简单地"代码越来越多",而是关注点在不断往上移动:
text
V1:连接能不能建立,字节能不能发过去
V2:多人通信能不能稳定工作
V3:怎样把它组织成一个真正像应用的聊天室
第一部分:InternetV2------先把通信基础补完整
3. V2 的第一个修改:重新设计消息边界
3.1 为什么 1 字节长度不够
V1 使用:
java
outputStream.write(message.length() + 1);
OutputStream.write(int) 最终只会写入整数的低 8 位,也就是一个字节。它能表达的范围非常有限,消息一长就会出问题。
另外还有一个不太容易第一眼发现的问题:
java
message.length()
得到的是 Java 字符串中 UTF-16 代码单元的数量,而网络最终传输的是字节。
例如中文使用 UTF-8 编码后,一个汉字一般会占多个字节。因此,下面两个长度不一定相等:
java
message.length();
message.getBytes(StandardCharsets.UTF_8).length;
这块我一开始也绕了一下,后面盯着实际字节看了俩遍才反应过来:协议里的长度必须描述消息体的字节长度,而不是 Java 字符串长度。
3.2 V2 的新格式
V2 把消息格式改为:
text
[4 字节正文长度][指定长度的 UTF-8 正文]
4 字节长度使用大端序:
text
length = 0x0000012C(十进制 300)
发送顺序:
00 00 01 2C
发送代码的核心部分如下:
java
private static void writeMessage(
OutputStream outputStream,
String message
) throws IOException {
byte[] content = message.getBytes(StandardCharsets.UTF_8);
int length = content.length;
outputStream.write((length >>> 24) & 0xFF);
outputStream.write((length >>> 16) & 0xFF);
outputStream.write((length >>> 8) & 0xFF);
outputStream.write(length & 0xFF);
outputStream.write(content);
outputStream.flush();
}
接收端先读满 4 字节,再把它们组合回一个 int:
java
int firstByte = inputStream.read();
if (firstByte == -1) {
return null;
}
int secondByte = readRequiredByte(inputStream);
int thirdByte = readRequiredByte(inputStream);
int fourthByte = readRequiredByte(inputStream);
int length = (firstByte << 24)
| (secondByte << 16)
| (thirdByte << 8)
| fourthByte;
这里使用右移和按位与,把一个 32 位整数拆成 4 个字节。接收时再反过来拼接。
3.3 为什么正文必须循环读取
TCP 是字节流,下面这次调用:
java
inputStream.read(content);
并不保证一次就把数组填满。网络分段、缓冲区状态和线程调度都可能让一次 read() 只返回部分数据。
因此 V2 通过 received 记录当前已经收到多少字节:
java
byte[] content = new byte[length];
int received = 0;
while (received < length) {
int count = inputStream.read(
content,
received,
length - received
);
if (count == -1) {
throw new IOException("消息尚未接收完整,连接已断开");
}
received += count;
}
最后再统一使用 UTF-8 解码:
java
return new String(content, StandardCharsets.UTF_8);
这样就解决了 V1 中的长度溢出、中文长度不一致、默认编码和部分读取问题。
3.4 为什么要限制最大消息长度
客户端发来的长度不能完全相信。如果对方传来一个非常大的整数,服务端直接:
java
new byte[length];
就可能申请一块特别大的内存,甚至导致 OOM。
V2 增加了 1MB 限制:
java
private static final int MAX_MESSAGE_BYTES = 1024 * 1024;
if (length < 0 || length > MAX_MESSAGE_BYTES) {
throw new IOException("收到的消息长度不合法:" + length);
}
这个检查看起来很小,但它代表了一个思路变化:网络输入属于外部输入,不能默认它永远正确。
4. V2 的第二个修改:从单客户端到多客户端
4.1 V1 为什么只能接收一个客户端
V1 的服务端只执行了一次:
java
Socket socket = serverSocket.accept();
随后就进入与这个客户端通信的循环,再也没有回到 accept()。所以第二个客户端连接时,没有新的业务逻辑去处理它。
4.2 把"接收连接"和"处理消息"拆开
V2 使用一个专门的线程循环调用 accept():
java
Thread acceptThread = new Thread(
() -> acceptClients(serverSocket),
"客户端连接监听线程"
);
acceptThread.start();
每接受一个连接,就创建一个 ClientHandler:
java
Socket socket = serverSocket.accept();
ClientHandler client = new ClientHandler(socket, clientId);
Thread clientThread = new Thread(
client,
"客户端-" + clientId + "-接收线程"
);
clientThread.start();
线程模型大致变成了下面样:
#mermaid-svg-WCvEUyf2B89u8Gwv{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-WCvEUyf2B89u8Gwv .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WCvEUyf2B89u8Gwv .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WCvEUyf2B89u8Gwv .error-icon{fill:#552222;}#mermaid-svg-WCvEUyf2B89u8Gwv .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WCvEUyf2B89u8Gwv .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WCvEUyf2B89u8Gwv .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WCvEUyf2B89u8Gwv .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WCvEUyf2B89u8Gwv .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WCvEUyf2B89u8Gwv .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WCvEUyf2B89u8Gwv .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WCvEUyf2B89u8Gwv .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WCvEUyf2B89u8Gwv .marker.cross{stroke:#333333;}#mermaid-svg-WCvEUyf2B89u8Gwv svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WCvEUyf2B89u8Gwv p{margin:0;}#mermaid-svg-WCvEUyf2B89u8Gwv .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-WCvEUyf2B89u8Gwv .cluster-label text{fill:#333;}#mermaid-svg-WCvEUyf2B89u8Gwv .cluster-label span{color:#333;}#mermaid-svg-WCvEUyf2B89u8Gwv .cluster-label span p{background-color:transparent;}#mermaid-svg-WCvEUyf2B89u8Gwv .label text,#mermaid-svg-WCvEUyf2B89u8Gwv span{fill:#333;color:#333;}#mermaid-svg-WCvEUyf2B89u8Gwv .node rect,#mermaid-svg-WCvEUyf2B89u8Gwv .node circle,#mermaid-svg-WCvEUyf2B89u8Gwv .node ellipse,#mermaid-svg-WCvEUyf2B89u8Gwv .node polygon,#mermaid-svg-WCvEUyf2B89u8Gwv .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-WCvEUyf2B89u8Gwv .rough-node .label text,#mermaid-svg-WCvEUyf2B89u8Gwv .node .label text,#mermaid-svg-WCvEUyf2B89u8Gwv .image-shape .label,#mermaid-svg-WCvEUyf2B89u8Gwv .icon-shape .label{text-anchor:middle;}#mermaid-svg-WCvEUyf2B89u8Gwv .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-WCvEUyf2B89u8Gwv .rough-node .label,#mermaid-svg-WCvEUyf2B89u8Gwv .node .label,#mermaid-svg-WCvEUyf2B89u8Gwv .image-shape .label,#mermaid-svg-WCvEUyf2B89u8Gwv .icon-shape .label{text-align:center;}#mermaid-svg-WCvEUyf2B89u8Gwv .node.clickable{cursor:pointer;}#mermaid-svg-WCvEUyf2B89u8Gwv .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-WCvEUyf2B89u8Gwv .arrowheadPath{fill:#333333;}#mermaid-svg-WCvEUyf2B89u8Gwv .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-WCvEUyf2B89u8Gwv .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-WCvEUyf2B89u8Gwv .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WCvEUyf2B89u8Gwv .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-WCvEUyf2B89u8Gwv .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WCvEUyf2B89u8Gwv .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-WCvEUyf2B89u8Gwv .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-WCvEUyf2B89u8Gwv .cluster text{fill:#333;}#mermaid-svg-WCvEUyf2B89u8Gwv .cluster span{color:#333;}#mermaid-svg-WCvEUyf2B89u8Gwv div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-WCvEUyf2B89u8Gwv .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-WCvEUyf2B89u8Gwv rect.text{fill:none;stroke-width:0;}#mermaid-svg-WCvEUyf2B89u8Gwv .icon-shape,#mermaid-svg-WCvEUyf2B89u8Gwv .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WCvEUyf2B89u8Gwv .icon-shape p,#mermaid-svg-WCvEUyf2B89u8Gwv .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-WCvEUyf2B89u8Gwv .icon-shape .label rect,#mermaid-svg-WCvEUyf2B89u8Gwv .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WCvEUyf2B89u8Gwv .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-WCvEUyf2B89u8Gwv .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-WCvEUyf2B89u8Gwv :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 服务端主线程
读取控制台广播
CLIENTS 客户端集合
连接监听线程
循环 accept
ClientHandler-1
ClientHandler-2
ClientHandler-3
向所有在线客户端广播
此时服务端的职责被分开了:
- 监听线程只负责接收新连接;
- 每个
ClientHandler只负责读取一个客户端的消息; - 服务端主线程读取管理员输入;
broadcast()负责把消息发给所有在线客户端。
这就是 V2 从"一对一问答"变成"多人聊天室"的关键。
5. 在线客户端集合与广播
V2 使用一个列表保存客户端处理器:
java
private static final List<ClientHandler> CLIENTS = new ArrayList<>();
因为多个线程可能同时增加、删除或读取这个列表,所以访问时使用 synchronized:
java
synchronized (CLIENTS) {
CLIENTS.add(client);
}
广播时没有一直拿着锁发送,而是先复制快照:
java
List<ClientHandler> clientsSnapshot;
synchronized (CLIENTS) {
clientsSnapshot = new ArrayList<>(CLIENTS);
}
for (ClientHandler client : clientsSnapshot) {
if (!client.sendMessage(message)) {
client.closeConnection();
}
}
为什么要复制?因为真正写网络数据可能发生阻塞。如果一边持有 CLIENTS 的锁,一边慢慢给客户端发消息,其他线程就无法及时加入或移除客户端。
这个做法还比较朴素,但比"把整个广播过程都放在 synchronized 中"要合理一些。
ClientHandler.sendMessage() 本身也加了同步:
java
private synchronized boolean sendMessage(String message) {
try {
writeMessage(outputStream, message);
return true;
} catch (IOException e) {
return false;
}
}
它的作用是防止不同线程同时向同一个客户端输出流写入数据,否则两个消息帧有可能交叉写入,接收方就没法正确解析了。
6. 客户端收发分离:不用再轮流说话
V1 的通信顺序是固定的:
text
客户端发送 → 客户端等待回复 → 服务端输入回复 → 客户端继续发送
如果服务端想主动通知客户端,只能等轮到它发送。底层 TCP 明明是全双工的,应用却把它用成了轮流讲话。
V2 客户端保留主线程读取键盘,同时启动一个后台接收线程:
java
Thread receiveThread = new Thread(
() -> receiveMessages(socket, inputStream),
"服务端消息接收线程"
);
receiveThread.start();
于是两个方向可以分别阻塞:
text
主线程: Scanner 等待键盘输入 → 发送消息
接收线程: InputStream.read() 等待网络数据 → 显示消息
这一步很重要。阻塞本身并不可怕,关键是不要让"等待键盘"和"等待网络"卡在同一个线程里。
V2 还加入了:
java
private static volatile boolean running = true;
volatile 让主线程和接收线程能够看见 running 的最新值。输入 /quit 后,主线程设置它为 false,并关闭 Socket。Socket 关闭后,阻塞中的读取也会退出。
到这里,V2 已经能做到:
- 多个客户端同时连接;
- 客户端消息广播给所有人;
- 服务端主动广播;
- 客户端随时输入,后台随时接收;
/quit主动退出;- UTF-8 中文通信;
- 对消息长度进行校验。
V2 还是控制台界面,不过网络通讯这块已经比 V1 完整很多了。
第二部分:InternetV3------把聊天室做成一个应用
7. 为什么 V3 没有直接在 V2 上堆 Swing 代码
V2 的 Server.java 和 Client.java 同时负责:
- 建立网络连接;
- 读写协议;
- 管理线程;
- 读取控制台输入;
- 打印运行状态。
如果直接把 Swing 按钮、文本框和列表全部塞进去,代码很容易变成"按钮里写 Socket、线程里直接操作控件"。这种写法小程序能跑,但后面很难维护。
所以 V3 先做了拆分:
text
InternetV3/
├── ChatPacket.java # 一条应用层消息是什么
├── ChatProtocol.java # 消息怎样编码、组帧和传输
├── Server.java # 服务端网络与业务逻辑
├── Client.java # 客户端网络逻辑
├── ServerUI.java # 服务端 Swing 界面
└── ClientUI.java # 客户端 Swing 界面
最主要的变化是:界面不再直接负责网络细节,网络层也不依赖具体控件。
8. V3 的应用层消息:ChatPacket
V2 传输的消息体只是一个字符串。客户端看到:
text
[客户端-1] hello
但这个字符串究竟是聊天消息、系统通知,还是错误提示,只能靠文本前缀猜。
V3 定义了明确的消息类型:
java
public enum Type {
LOGIN,
CHAT,
SYSTEM,
USER_LIST,
ERROR
}
一条 ChatPacket 包含:
java
private final Type type;
private final String sender;
private final long timestamp;
private final String content;
各字段作用如下:
| 字段 | 含义 |
|---|---|
type |
消息类型,决定接收方怎样处理 |
sender |
发送者昵称 |
timestamp |
消息时间戳 |
content |
消息正文或用户列表内容 |
例如登录和普通聊天分别表示为:
java
ChatPacket.login("小明");
ChatPacket.chat("小明", "大家好");
这样一来,客户端收到数据后就可以使用 switch 分发:
java
switch (packet.getType()) {
case CHAT:
appendChat(packet);
break;
case SYSTEM:
appendSystem(packet.getContent(), packet.getTimestamp(), false);
break;
case ERROR:
appendSystem(packet.getContent(), packet.getTimestamp(), true);
break;
case USER_LIST:
updateUsers(packet.getContent());
break;
default:
break;
}
这比从字符串中判断 [系统]、[错误] 要清晰很多,也更方便以后加入私聊、文件、心跳等新类型。
9. ChatPacket 怎样编码
当前 V3 没有引入 JSON 库,而是把消息编码成四行:
text
消息类型
Base64(发送者)
时间戳
Base64(正文)
核心代码:
java
public String encode() {
return type.name() + "\n"
+ encodeText(sender) + "\n"
+ timestamp + "\n"
+ encodeText(content);
}
sender 和 content 使用 Base64,是为了避免正文中的换行符与协议自己的四行结构发生冲突。
这里要专门记一下:
**Base64 只是编码,不是加密。**任何人拿到内容都可以还原,不能用它保护密码或聊天隐私。
ChatProtocol 再沿用 V2 的长度前缀方案:
text
[4 字节帧长度][ChatPacket 编码后的 UTF-8 数据]
因此 V3 实际上有两层协议:
#mermaid-svg-s1LLqsHh4mZ1zgYN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-s1LLqsHh4mZ1zgYN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-s1LLqsHh4mZ1zgYN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-s1LLqsHh4mZ1zgYN .error-icon{fill:#552222;}#mermaid-svg-s1LLqsHh4mZ1zgYN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-s1LLqsHh4mZ1zgYN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-s1LLqsHh4mZ1zgYN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-s1LLqsHh4mZ1zgYN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-s1LLqsHh4mZ1zgYN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-s1LLqsHh4mZ1zgYN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-s1LLqsHh4mZ1zgYN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-s1LLqsHh4mZ1zgYN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-s1LLqsHh4mZ1zgYN .marker.cross{stroke:#333333;}#mermaid-svg-s1LLqsHh4mZ1zgYN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-s1LLqsHh4mZ1zgYN p{margin:0;}#mermaid-svg-s1LLqsHh4mZ1zgYN .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-s1LLqsHh4mZ1zgYN .cluster-label text{fill:#333;}#mermaid-svg-s1LLqsHh4mZ1zgYN .cluster-label span{color:#333;}#mermaid-svg-s1LLqsHh4mZ1zgYN .cluster-label span p{background-color:transparent;}#mermaid-svg-s1LLqsHh4mZ1zgYN .label text,#mermaid-svg-s1LLqsHh4mZ1zgYN span{fill:#333;color:#333;}#mermaid-svg-s1LLqsHh4mZ1zgYN .node rect,#mermaid-svg-s1LLqsHh4mZ1zgYN .node circle,#mermaid-svg-s1LLqsHh4mZ1zgYN .node ellipse,#mermaid-svg-s1LLqsHh4mZ1zgYN .node polygon,#mermaid-svg-s1LLqsHh4mZ1zgYN .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-s1LLqsHh4mZ1zgYN .rough-node .label text,#mermaid-svg-s1LLqsHh4mZ1zgYN .node .label text,#mermaid-svg-s1LLqsHh4mZ1zgYN .image-shape .label,#mermaid-svg-s1LLqsHh4mZ1zgYN .icon-shape .label{text-anchor:middle;}#mermaid-svg-s1LLqsHh4mZ1zgYN .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-s1LLqsHh4mZ1zgYN .rough-node .label,#mermaid-svg-s1LLqsHh4mZ1zgYN .node .label,#mermaid-svg-s1LLqsHh4mZ1zgYN .image-shape .label,#mermaid-svg-s1LLqsHh4mZ1zgYN .icon-shape .label{text-align:center;}#mermaid-svg-s1LLqsHh4mZ1zgYN .node.clickable{cursor:pointer;}#mermaid-svg-s1LLqsHh4mZ1zgYN .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-s1LLqsHh4mZ1zgYN .arrowheadPath{fill:#333333;}#mermaid-svg-s1LLqsHh4mZ1zgYN .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-s1LLqsHh4mZ1zgYN .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-s1LLqsHh4mZ1zgYN .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-s1LLqsHh4mZ1zgYN .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-s1LLqsHh4mZ1zgYN .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-s1LLqsHh4mZ1zgYN .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-s1LLqsHh4mZ1zgYN .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-s1LLqsHh4mZ1zgYN .cluster text{fill:#333;}#mermaid-svg-s1LLqsHh4mZ1zgYN .cluster span{color:#333;}#mermaid-svg-s1LLqsHh4mZ1zgYN div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-s1LLqsHh4mZ1zgYN .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-s1LLqsHh4mZ1zgYN rect.text{fill:none;stroke-width:0;}#mermaid-svg-s1LLqsHh4mZ1zgYN .icon-shape,#mermaid-svg-s1LLqsHh4mZ1zgYN .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-s1LLqsHh4mZ1zgYN .icon-shape p,#mermaid-svg-s1LLqsHh4mZ1zgYN .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-s1LLqsHh4mZ1zgYN .icon-shape .label rect,#mermaid-svg-s1LLqsHh4mZ1zgYN .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-s1LLqsHh4mZ1zgYN .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-s1LLqsHh4mZ1zgYN .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-s1LLqsHh4mZ1zgYN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ChatPacket
类型、发送者、时间、正文
encode 编码成字符串
UTF-8 字节
前面增加 4 字节长度
TCP Socket 发送
接收端读完整帧
ChatPacket.decode
V2 解决的是"从 TCP 字节流中切出一条完整消息",V3 的 ChatPacket 解决的是"这条消息在业务上代表什么"。这两个问题不能混在一起。
10. V3 服务端:昵称、在线列表和并发容器
10.1 登录成为第一条消息
客户端连接成功以后,不是马上发送聊天正文,而是先发送:
java
sendPacket(ChatPacket.login(nickname));
服务端拿到连接后,读取的第一包必须是 LOGIN:
java
ChatPacket login = ChatProtocol.readPacket(inputStream);
if (login == null || login.getType() != ChatPacket.Type.LOGIN) {
send(ChatPacket.error("登录信息无效"));
return;
}
接下来服务端校验昵称不能为空、不能太长,也不能包含换行。
10.2 为什么昵称要由服务端校验
客户端界面虽然也做了长度检查,但客户端代码是可以被修改的。如果只相信客户端,"恶意客户端"完全可以绕过 UI 限制。
所以最终规则仍然要在服务端执行:
java
private static final int MAX_NICKNAME_LENGTH = 20;
private static final int MAX_CHAT_LENGTH = 2000;
这也是客户端校验与服务端校验的区别:
- 客户端校验主要改善使用体验;
- 服务端校验才是真正的安全边界。
10.3 使用 ConcurrentHashMap 管理用户
V3 使用昵称的小写形式作为 key:
java
private final Map<String, ClientHandler> clients
= new ConcurrentHashMap<>();
注册用户时调用:
java
String nicknameKey = nickname.toLowerCase(Locale.ROOT);
if (clients.putIfAbsent(nicknameKey, this) != null) {
send(ChatPacket.error("昵称已被使用"));
return;
}
putIfAbsent 可以原子地完成"昵称不存在才加入",避免两个连接几乎同时使用同一个昵称时发生竞争。
每当用户上线或离线,服务端都会广播最新用户列表:
java
private void broadcastUserList() {
List<String> users = currentUsers();
broadcast(ChatPacket.userList(String.join("\n", users)));
listener.onUsersChanged(users);
}
客户端收到 USER_LIST 后刷新右侧列表,服务端 UI 也通过监听器刷新自己的在线用户区域。
11. V3 的线程和并发处理
V3 服务端把 V2 中手动创建的线程进一步改成线程池:
java
workerPool = Executors.newCachedThreadPool(runnable -> {
Thread thread = new Thread(runnable, "V3-服务端工作线程");
thread.setDaemon(true);
return thread;
});
新连接交给线程池执行:
java
pool.execute(new ClientHandler(socket));
运行状态使用:
java
private final AtomicBoolean running = new AtomicBoolean(false);
连接自身也用 AtomicBoolean closed 保证只关闭一次。
发送消息时仍然需要锁:
java
private final Object sendLock = new Object();
synchronized (sendLock) {
ChatProtocol.writePacket(current, packet);
}
这里不能因为使用了 ConcurrentHashMap 就以为所有并发问题都消失了。并发容器只保证容器操作本身的线程安全,并不会自动保证同一个 Socket 输出流中的消息不交叉。
V3 还设置了:
java
socket.setKeepAlive(true);
socket.setTcpNoDelay(true);
SO_KEEPALIVE开启 TCP 保活能力,但系统默认探测时间可能很长,不能完全代替应用层心跳;TCP_NODELAY关闭 Nagle 算法,更适合希望小消息尽快发送的即时通信场景。
客户端连接也增加了 5 秒超时:
java
newSocket.connect(
new InetSocketAddress(host, port),
5000
);
否则地址不通时,界面可能等待很久才得到结果。
12. 网络层与 UI 怎样通信
V3 的 Client 并不直接操作 JTextPane 或 JList,而是定义监听器:
java
public interface Listener {
void onConnected();
void onPacket(ChatPacket packet);
void onDisconnected(String reason);
}
网络层只负责通知:连接成功、收到数据、连接断开。
ClientUI 实现这个接口,再决定界面如何展示:
java
public final class ClientUI extends JFrame
implements Client.Listener {
// ...
}
服务端也是同样的思路:
java
public interface Listener {
void onStarted(int port);
void onLog(String message);
void onUsersChanged(List<String> users);
void onStopped();
}
这种拆分有几个好处:
- 网络代码不需要知道界面控件长什么样;
- UI 不需要重复实现 Socket 和协议;
- 后面要写控制台版、Web 版或自动化测试,可以复用网络层;
- 出问题时更容易判断是协议、连接还是界面刷新。
接口回调这块以前写得比较少,第一次拆的时候还是有点乱,不过拆完以后 ClientUI 和 Client 的职责确实清楚了不少。
13. Swing 中最容易踩的坑:不能堵住 EDT
Swing 的控件更新主要发生在事件派发线程 EDT(Event Dispatch Thread)中。如果在按钮事件里直接调用阻塞的网络连接或读取,窗口就可能卡住、无法拖动,甚至显示"未响应"。
V3 使用 SwingWorker 执行连接和服务启动:
java
SwingWorker<Void, Void> worker = new SwingWorker<Void, Void>() {
@Override
protected Void doInBackground() throws Exception {
client.connect(host, port, nickname);
return null;
}
@Override
protected void done() {
// 回到 EDT 处理界面结果
}
};
worker.execute();
网络接收线程收到消息以后,也不能直接随意修改 Swing 控件,而是切回 EDT:
java
SwingUtilities.invokeLater(() -> {
appendChat(packet);
});
可以把规则简单记成:
text
耗时和阻塞任务:放后台线程
Swing 控件更新:放 EDT
V3 的发送操作也开了后台线程,避免慢连接让"发送"按钮把窗口卡住。
14. UI 功能整理
14.1 客户端界面
客户端提供了:
- 服务器地址;
- 端口号;
- 昵称;
- 连接和断开按钮;
- 连接状态;
- 聊天记录;
- 在线用户列表;
- 消息输入框;
0 / 2000字数提示;- Enter 发送;
- Shift + Enter 换行。
14.2 服务端界面
服务端提供了:
- 可设置监听端口;
- 启动和停止服务;
- 运行状态显示;
- 服务日志;
- 在线用户列表;
- 服务端广播输入框。
这部分还比较基础,称不上多漂亮,但比多个控制台窗口来回切换直观多了。UI 做完以后,连接、上线、广播、下线这些状态也更容易观察。
15. 实际运行过程与截图
下面的图片是实际编译并运行 InternetV3 后截取的,不是效果图。
15.1 启动服务端程序
服务端刚打开时还没有监听端口,广播区也是禁用状态:

确认端口为 8080,点击"启动服务"。服务启动后:
- 状态变成"运行中";
- 端口输入框被锁定;
- 广播输入框启用;
- 日志区显示监听端口。
15.2 打开客户端

这里填写:
text
服务器:127.0.0.1
端口:8080
昵称:小明
点击连接后,客户端先通过 TCP 建立连接,然后发送一条 LOGIN 消息。
15.3 登录成功并同步在线列表
服务端校验昵称成功后,会发送欢迎消息,并向所有客户端广播最新用户列表:

右侧已经显示:
text
在线用户 (1)
小明(我)
这一小块 UI 背后实际上走过了完整流程:
text
点击连接
→ TCP connect
→ 发送 LOGIN 包
→ 服务端校验昵称
→ 加入 ConcurrentHashMap
→ 广播 SYSTEM 消息
→ 广播 USER_LIST
→ Swing EDT 更新界面
15.4 客户端发送聊天消息
我输入了一条测试消息:
text
大家好,这是 InternetV3 的第一条测试消息!
运行结果:

客户端不会直接把自己输入的内容追加到聊天区,而是先发送给服务端。服务端校验后重新生成带服务端时间戳的 CHAT 包,再广播回来。
这样显示出来的消息,代表服务端确实已经收到并处理过,而不是客户端"自娱自乐"地显示一下。
15.5 服务端查看连接和消息日志
服务端控制台可以看到客户端地址、上线状态、消息正文和在线用户:

服务端还可以主动广播,我测试的内容是:
text
服务器广播:欢迎来到 V3 聊天室,当前通讯正常。
客户端收到后的效果:

到这里,V3 的完整通信链路已经跑通:客户端登录、用户列表同步、客户端消息广播、服务端主动广播、服务停止通知都能工作。
16. 怎样运行 V2 和 V3
16.1 在 IntelliJ IDEA 中运行 V2
- 确保
InternetV2位于源码目录下; - 先运行
InternetV2.Server.main(); - 再运行一个或多个
InternetV2.Client.main(); - 在不同控制台中输入消息;
- 输入
/quit退出客户端或关闭服务端。
V2 使用固定的:
text
服务器地址:127.0.0.1
端口:8080
如果需要局域网其他机器访问,要把客户端地址改为服务端的局域网 IP,并检查服务端防火墙和端口监听范围。
16.2 在 IntelliJ IDEA 中运行 V3
- 先运行
InternetV3.ServerUI.main(); - 在服务端界面点击"启动服务";
- 再运行
InternetV3.ClientUI.main(); - 填写服务器、端口和昵称;
- 点击"连接";
- 输入消息后按 Enter 发送;
- 在服务端输入框中可以测试广播。
如果出现端口占用:
text
Address already in use
说明 TCP 8080 已经被其他程序监听。可以关闭原来的服务,或者在 V3 服务端界面换一个端口,并让客户端填写相同端口。
17. 从 V1 到 V3,我实际用到的知识点
17.1 网络与协议
- TCP 是面向连接的可靠字节流;
ServerSocket负责监听,accept()返回已连接 Socket;- IP 地址找到主机,端口号找到主机上的服务;
- TCP 不保留应用消息边界,需要自己设计分帧协议;
- 4 字节长度前缀与大端序;
- UTF-8 字符编码;
- 部分读取与循环读满;
- 最大帧长度校验;
SO_KEEPALIVE与TCP_NODELAY;- 连接超时、EOF、断开与异常处理。
17.2 Java I/O
InputStream/OutputStream;read()返回-1的含义;flush();- try-with-resources;
IOException和EOFException;- 字节数组与字符串编码转换。
17.3 多线程与并发
- 监听线程、接收线程和主线程分工;
- 一个客户端对应一个处理任务;
volatile的可见性;synchronized保护输出流;AtomicBoolean管理状态切换;ConcurrentHashMap;putIfAbsent原子注册昵称;ExecutorService管理工作线程;- 关闭 Socket 解除阻塞读取。
17.4 应用层设计
- 长度前缀协议;
- 消息类型设计;
- 登录包与聊天包;
- 系统通知、错误消息与用户列表;
- 服务端校验;
- 服务端时间戳;
- Base64 编码与分隔符冲突;
- 广播和在线用户管理。
17.5 Swing UI
JFrame、JPanel、JTextPane、JTextArea、JList;- 布局管理器;
- 按钮和键盘事件;
- EDT 线程规则;
SwingUtilities.invokeLater();SwingWorker;- 连接状态控制和输入校验;
- 网络层通过 Listener 回调 UI。
18. 当前版本还存在哪些问题
V3 已经比 V1 完整很多,但它依然是一个学习阶段的小项目。目前我能想到的问题包括:
18.1 协议还是自定义文本格式
现在使用四行文本加 Base64,优点是简单、没有额外依赖,缺点是可读性和扩展能力一般。
以后字段增加后,可以考虑:
- JSON:调试方便、可读性好;
- Protocol Buffers:体积小、结构明确;
- 在包头增加协议版本号,方便以后兼容升级。
18.2 没有真正的身份认证
当前昵称只是一个显示名称,任何人都可以输入。后面如果加入账号系统,就要考虑密码安全、登录令牌、会话状态等问题。
18.3 通信没有加密
TCP 可靠,但不等于安全。聊天内容当前仍然是明文传输,Base64 也不是加密。
如果要在真实网络中使用,应该考虑 TLS,而不是自己随便设计一套"加密算法"。
18.4 线程池没有设置明确上限
V3 使用 newCachedThreadPool(),学习和少量连接时比较方便,但连接数量很多时,线程数量可能不断增加。
以后可以使用有界线程池,或者继续学习 Java NIO、Selector、Netty 等事件驱动方案。这块后面还要在优化。
18.5 没有消息持久化
服务端停止以后,历史消息全部消失。未来可以接入数据库,保存用户、消息、房间和离线状态。
18.6 心跳和重连还不完整
SO_KEEPALIVE 不等于应用层心跳。后面可以加入:
text
PING → PONG
再配合最后活动时间判断连接是否失效。客户端还可以增加断线重连,但需要防止无限重试把服务端压垮。
19. 后续版本准备怎么更新
如果继续做 InternetV4,目前计划按下面的顺序推进:
- 重构协议:加入版本号、消息 ID,并考虑改成 JSON;
- 心跳检测 :增加
PING、PONG和超时下线; - 断线重连:客户端有限次数重试,显示明确状态;
- 聊天室房间:支持加入房间、退出房间和房间广播;
- 私聊:消息中加入目标用户;
- 消息历史:服务端落库,客户端上线后拉取最近记录;
- 账号系统:注册、登录、昵称与账号分离;
- TLS 加密:避免明文通信;
- 文件或图片消息:单独设计元数据和分块传输;
- 工程化:日志框架、配置文件、单元测试、集成测试和打包发布;
- 性能方向:有界线程池、NIO 或 Netty;
- UI 继续调整:消息气泡、头像、未读数和托盘通知。
其中我觉得最先应该做的不是"把界面变得更炫",而是协议版本、心跳、重连和测试。基础行为稳定以后,在继续加功能会轻松很多。
20. 这次迭代最大的收获
V1 到 V3 的过程中,我对"网络程序"的理解有一些变化。
最开始关注的是:
text
Socket 连上了吗?
消息发过去了吗?
到 V2 开始关注:
text
消息边界在哪里?
中文到底有多少字节?
多个客户端怎样同时处理?
一个线程阻塞会不会影响其他功能?
到了 V3,又开始处理:
text
消息到底是什么类型?
谁有权决定昵称是否合法?
网络线程怎样通知 UI?
服务关闭时怎样让所有连接一起退出?
以后增加功能时,代码还能不能继续改?
所以这三个版本连起来看,真正的迭代过程是:
从"能通信",到"能让多人稳定通信",再到"把通信组织成一个有界面、有状态、有协议的应用"。
目前 V3 还很简单,代码里也有不少能继续收拾的地方。但它已经把 TCP、Socket、消息协议、多线程、并发容器和 Swing 串到了一起。相比只背概念,自己一步步踩坑再改,确实记得更牢。