internet-v2-v3-evolution-blog

Network通信小项目(二):从轮流收发到可视化多人聊天室(InternetV2 / V3)

上一篇主要整理了 TCP、UDP、IP、端口和 Socket,也用 InternetV1 跑通了最简单的客户端/服务端通信。

这篇继续记录后面的两个版本:InternetV2InternetV3。重点不是单纯贴代码,而是把每次修改背后的原因梳理一下:上一个版本有什么问题、为什么要改,以及这一版具体是怎么实现的。


1. 先回顾一下 V1 做到了什么

InternetV1 中只有两个类:

text 复制代码
InternetV1/
├── A.java    # 服务端
└── B.java    # 客户端

服务端使用 ServerSocket(8080) 监听,客户端通过:

java 复制代码
new Socket("localhost", 8080);

连接服务器。双方再通过 InputStreamOutputStream 收发消息。

V1 已经打通了最基本的一条链路:

text 复制代码
B 输入消息 → TCP 发送 → A 接收并显示
A 输入回复 → TCP 发送 → B 接收并显示

当时为了区分消息,我自己约定了一个很简单的格式:

text 复制代码
[1 字节长度][消息正文]

发送时先写入 message.length() + 1,接收时先读长度,再循环读取 len - 1 个字节。

这个版本虽然真的可以聊天,但只能算是"先跑起来"。继续测试以后,很快就发现了几个问题:

  1. 一次只能连接一个客户端;
  2. 客户端发完以后必须等待服务端回复,双方不能随时发送;
  3. 长度字段只有 1 字节,长消息会溢出;
  4. String.length() 是字符数量,不是网络上传输的字节数量;
  5. getBytes() 没指定编码,中文在不同环境下可能乱码;
  6. 代码没有完整处理关闭连接、异常和资源释放;
  7. 消息只有正文,没有发送者、类型、时间等信息。

于是后面的思路就比较清楚了:

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.javaClient.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);
}

sendercontent 使用 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 并不直接操作 JTextPaneJList,而是定义监听器:

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();
}

这种拆分有几个好处:

  1. 网络代码不需要知道界面控件长什么样;
  2. UI 不需要重复实现 Socket 和协议;
  3. 后面要写控制台版、Web 版或自动化测试,可以复用网络层;
  4. 出问题时更容易判断是协议、连接还是界面刷新。

接口回调这块以前写得比较少,第一次拆的时候还是有点乱,不过拆完以后 ClientUIClient 的职责确实清楚了不少。


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

  1. 确保 InternetV2 位于源码目录下;
  2. 先运行 InternetV2.Server.main()
  3. 再运行一个或多个 InternetV2.Client.main()
  4. 在不同控制台中输入消息;
  5. 输入 /quit 退出客户端或关闭服务端。

V2 使用固定的:

text 复制代码
服务器地址:127.0.0.1
端口:8080

如果需要局域网其他机器访问,要把客户端地址改为服务端的局域网 IP,并检查服务端防火墙和端口监听范围。

16.2 在 IntelliJ IDEA 中运行 V3

  1. 先运行 InternetV3.ServerUI.main()
  2. 在服务端界面点击"启动服务";
  3. 再运行 InternetV3.ClientUI.main()
  4. 填写服务器、端口和昵称;
  5. 点击"连接";
  6. 输入消息后按 Enter 发送;
  7. 在服务端输入框中可以测试广播。

如果出现端口占用:

text 复制代码
Address already in use

说明 TCP 8080 已经被其他程序监听。可以关闭原来的服务,或者在 V3 服务端界面换一个端口,并让客户端填写相同端口。


17. 从 V1 到 V3,我实际用到的知识点

17.1 网络与协议

  • TCP 是面向连接的可靠字节流;
  • ServerSocket 负责监听,accept() 返回已连接 Socket;
  • IP 地址找到主机,端口号找到主机上的服务;
  • TCP 不保留应用消息边界,需要自己设计分帧协议;
  • 4 字节长度前缀与大端序;
  • UTF-8 字符编码;
  • 部分读取与循环读满;
  • 最大帧长度校验;
  • SO_KEEPALIVETCP_NODELAY
  • 连接超时、EOF、断开与异常处理。

17.2 Java I/O

  • InputStream / OutputStream
  • read() 返回 -1 的含义;
  • flush()
  • try-with-resources;
  • IOExceptionEOFException
  • 字节数组与字符串编码转换。

17.3 多线程与并发

  • 监听线程、接收线程和主线程分工;
  • 一个客户端对应一个处理任务;
  • volatile 的可见性;
  • synchronized 保护输出流;
  • AtomicBoolean 管理状态切换;
  • ConcurrentHashMap
  • putIfAbsent 原子注册昵称;
  • ExecutorService 管理工作线程;
  • 关闭 Socket 解除阻塞读取。

17.4 应用层设计

  • 长度前缀协议;
  • 消息类型设计;
  • 登录包与聊天包;
  • 系统通知、错误消息与用户列表;
  • 服务端校验;
  • 服务端时间戳;
  • Base64 编码与分隔符冲突;
  • 广播和在线用户管理。

17.5 Swing UI

  • JFrameJPanelJTextPaneJTextAreaJList
  • 布局管理器;
  • 按钮和键盘事件;
  • 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,目前计划按下面的顺序推进:

  1. 重构协议:加入版本号、消息 ID,并考虑改成 JSON;
  2. 心跳检测 :增加 PINGPONG 和超时下线;
  3. 断线重连:客户端有限次数重试,显示明确状态;
  4. 聊天室房间:支持加入房间、退出房间和房间广播;
  5. 私聊:消息中加入目标用户;
  6. 消息历史:服务端落库,客户端上线后拉取最近记录;
  7. 账号系统:注册、登录、昵称与账号分离;
  8. TLS 加密:避免明文通信;
  9. 文件或图片消息:单独设计元数据和分块传输;
  10. 工程化:日志框架、配置文件、单元测试、集成测试和打包发布;
  11. 性能方向:有界线程池、NIO 或 Netty;
  12. UI 继续调整:消息气泡、头像、未读数和托盘通知。

其中我觉得最先应该做的不是"把界面变得更炫",而是协议版本、心跳、重连和测试。基础行为稳定以后,在继续加功能会轻松很多。


20. 这次迭代最大的收获

V1 到 V3 的过程中,我对"网络程序"的理解有一些变化。

最开始关注的是:

text 复制代码
Socket 连上了吗?
消息发过去了吗?

到 V2 开始关注:

text 复制代码
消息边界在哪里?
中文到底有多少字节?
多个客户端怎样同时处理?
一个线程阻塞会不会影响其他功能?

到了 V3,又开始处理:

text 复制代码
消息到底是什么类型?
谁有权决定昵称是否合法?
网络线程怎样通知 UI?
服务关闭时怎样让所有连接一起退出?
以后增加功能时,代码还能不能继续改?

所以这三个版本连起来看,真正的迭代过程是:

从"能通信",到"能让多人稳定通信",再到"把通信组织成一个有界面、有状态、有协议的应用"。

目前 V3 还很简单,代码里也有不少能继续收拾的地方。但它已经把 TCP、Socket、消息协议、多线程、并发容器和 Swing 串到了一起。相比只背概念,自己一步步踩坑再改,确实记得更牢。


相关推荐
2501_9159184121 小时前
抓包鹰 抓包会话重放与压力测试,接口回归验证与性能压测的方法
网络协议·计算机网络·网络安全·ios·adb·https·压力测试
WWJA王文举1 天前
HTTP 和 HTTPS 详解:工作原理、通信流程、使用方法及核心区别
网络协议·软件开发·通信协议
2501_916007471 天前
iOS App分发教程 App Store、TestFlight 与 Ad Hoc 的配置与上传方法
android·ios·小程序·https·uni-app·iphone·webview
一条泥憨鱼1 天前
【从0开始学习计算机网络】| HTTP 状态码
开发语言·网络·网络协议·计算机网络·http
q567315232 天前
企业级 HTTP 代理采购选型:技术评估清单 15 项
开发语言·网络·爬虫·网络协议·http·隧道ip·代理ip
白嫖一茶2 天前
HTTP 状态码详解:五大类与常见状态码解析
网络·网络协议·http
ai_xiaogui2 天前
PanelAI没有域名也能装?IP安装+自签HTTPS+安全加固全攻略(私有化部署必备)
tcp/ip·安全·https·私有化部署·自签证书·panelai·自带https自动续证
treesforest2 天前
如何解决IP定位精度不足导致的投放偏差
网络·网络协议·tcp/ip·ip属地·查ip归属地
2401_868534782 天前
《论单元测试及其应用》
网络·网络协议