TCP 多发多收 | JavaSE

TCP 多发多收

一、学习目标

学完本章,你应该能够:

  1. 能够解释 TCP 一发一收与多发多收在程序结构上的区别。
  2. 能够使用循环让客户端在同一条 TCP 连接中连续发送多条消息。
  3. 能够使用循环让服务端持续读取同一客户端发送的数据。
  4. 能够理解为什么多发多收时不能反复创建新的 Socket
  5. 能够使用 DataOutputStream.writeUTF()DataInputStream.readUTF() 建立简单的消息边界。
  6. 能够理解 readUTF() 的阻塞行为以及客户端关闭连接后的流结束现象。
  7. 能够解释为什么当前 TCP 多发多收程序仍然只能处理一个客户端。

二、核心知识

2.1 从 TCP 一发一收到多发多收

上一章实现:

Client
  │
  │ 建立 TCP 连接
  │
  │ 发送一条消息
  ▼
Server
  │
  │ 读取一条消息
  ▼
结束

客户端大致是:

Socket socket = new Socket("127.0.0.1", 9999);

DataOutputStream output =
        new DataOutputStream(
                socket.getOutputStream()
        );

output.writeUTF("Hello TCP");

服务端:

Socket socket =
        serverSocket.accept();

DataInputStream input =
        new DataInputStream(
                socket.getInputStream()
        );

String message =
        input.readUTF();

这样的程序只能:

发一次
收一次
结束

但聊天程序显然需要:

你好

在吗?

今天晚上打球吗?

收到

exit

因此要升级为:

客户端循环发送,服务端循环接收。

核心变化仍然是:

while (true) {
    ...
}

2.2 TCP 多发多收的基本模型

                 TCP Connection

Client                                 Server
  │                                      │
  │──────────── message 1 ──────────────→│
  │──────────── message 2 ──────────────→│
  │──────────── message 3 ──────────────→│
  │──────────── message 4 ──────────────→│
  │                                      │

注意:

这些消息都在同一条 TCP 连接中发送。

不是:

消息 1 → 建一次连接
消息 2 → 再建一次连接
消息 3 → 再建一次连接

而是:

建立 Socket
      ↓
发送 1
发送 2
发送 3
发送 4
      ↓
关闭 Socket

因此:

Socket socket =
        new Socket("127.0.0.1", 9999);

应该放在循环:

while (...)

之外。


2.3 为什么不能每发一条消息创建一个 Socket

错误思想:

while (true) {

    Socket socket =
            new Socket(
                    "127.0.0.1",
                    9999
            );

    // 发送消息
}

这意味着:

消息 A
↓
建立 TCP 连接 A

消息 B
↓
建立 TCP 连接 B

消息 C
↓
建立 TCP 连接 C

每一次:

new Socket(...)

都代表一次新的 TCP 连接建立过程。

这样不仅:

  • 增加额外连接开销
  • 失去“持续会话”的意义
  • 服务端连接管理更复杂

还把:

多发多收

错误实现成:

多次一发一收

正确思想:

Socket socket =
        new Socket(
                "127.0.0.1",
                9999
        );

while (true) {

    // 使用同一个 socket
    // 持续发送
}

2.4 客户端循环发送

基本结构:

while (true) {

    String message =
            scanner.nextLine();

    output.writeUTF(message);

    output.flush();
}

因此:

Scanner
   ↓
String
   ↓
writeUTF()
   ↓
Socket OutputStream
   ↓
TCP

每输入一次:

你好

就写入一条数据。

然后再次等待:

下一条消息

2.5 服务端循环接收

服务端:

while (true) {

    String message =
            input.readUTF();

    System.out.println(message);
}

因此:

Socket InputStream
      ↓
readUTF()
      ↓
String
      ↓
处理消息
      ↓
再次 readUTF()

如果客户端暂时没有发送新的消息:

input.readUTF();

会等待数据。

也就是说:

readUTF() 是建立在阻塞式网络输入流之上的读取操作。


2.6 多发多收的关键不是创建多个 Socket

这一点非常重要。

当前程序中:

Client Socket
      │
      │ 一条 TCP Connection
      ▼
Server Socket

然后在这条连接中:

message 1
message 2
message 3
message 4
...

持续传输。

所以:

TCP 多发多收

真正表示:

在一条已经建立的 TCP 连接上持续交换多份应用数据。


2.7 TCP 是字节流,消息边界从哪里来

上一章已经学习:

TCP
=
字节流

TCP 自身并不知道:

“你好”

是一条业务消息。

也不知道:

“晚上打球吗?”

是另一条业务消息。

TCP 只看到:

byte byte byte byte byte byte...

因此应用层必须设计消息边界。

本章采用:

DataOutputStream.writeUTF(...)

和:

DataInputStream.readUTF()

作为最简单的教学方案。


2.8 writeUTF 与 readUTF 为什么必须成对出现

发送端:

output.writeUTF(message);

接收端:

String message =
        input.readUTF();

writeUTF() 并不是简单地:

字符串 → 一堆裸字节

它会按照 DataOutput 定义的格式写入:

长度信息
+
字符串编码数据

因此 readUTF() 可以先读取:

长度

再知道:

这一条字符串到底有多少数据

于是形成:

TCP 字节流

[length][message]

[length][message]

[length][message]

这样服务端就能区分:

消息 1
消息 2
消息 3

2.9 一个容易混淆的细节:writeUTF 不是普通 UTF-8

方法叫:

writeUTF()

但它使用的是 Java DataInput / DataOutput 定义的:

Modified UTF-8(修改版 UTF-8)

而不是我们之前:

text.getBytes(StandardCharsets.UTF_8)

使用的标准 UTF-8 编码方案。

因此:

writeUTF()

必须和:

readUTF()

按照相同数据协议配套使用。

不要拿:

writeUTF()

写出的字节直接当成普通:

UTF-8 文本文件

去理解。


三、使用方法

3.1 TCP 多发客户端

import java.io.DataOutputStream;
import java.net.Socket;
import java.util.Scanner;

public class TCPClient {

    public static void main(String[] args)
            throws Exception {

        System.out.println(
                "=== TCP 客户端启动 ==="
        );

        try (
                Socket socket =
                        new Socket(
                                "127.0.0.1",
                                9999
                        );

                DataOutputStream output =
                        new DataOutputStream(
                                socket.getOutputStream()
                        );

                Scanner scanner =
                        new Scanner(System.in)
        ) {

            while (true) {

                System.out.print(
                        "请输入消息:"
                );

                String message =
                        scanner.nextLine();

                if ("exit".equalsIgnoreCase(
                        message
                )) {

                    System.out.println(
                            "客户端退出"
                    );

                    break;
                }

                output.writeUTF(message);

                output.flush();
            }
        }
    }
}

3.2 客户端执行流程

程序启动:

new Socket(
        "127.0.0.1",
        9999
);

建立:

TCP Connection

获得:

socket.getOutputStream()

包装:

DataOutputStream

然后:

while true
    ↓
Scanner 输入
    ↓
判断 exit
    ↓
writeUTF()
    ↓
flush()
    ↓
下一轮

3.3 为什么 exit 不发送

代码:

if ("exit".equalsIgnoreCase(
        message
)) {
    break;
}

表示:

exit

只是:

当前客户端的本地退出命令。

服务器不会收到:

exit

这个业务消息。

随后:

退出 try-with-resources

会自动关闭:

DataOutputStream
Socket

于是 TCP 连接也会结束。


3.4 flush 有什么作用

代码:

output.flush();

表示要求:

将当前输出流中可能缓冲的数据尽快提交到底层输出流。

对于当前直接包装在:

Socket.getOutputStream()

上的 DataOutputStream,其自身并没有像 BufferedOutputStream 那样额外维护一个大缓冲区。

但写:

flush();

仍然可以明确表达:

当前这一轮业务数据已经写完,希望向底层继续刷新。

同时也为以后组合:

BufferedOutputStream
BufferedWriter
PrintWriter

等缓冲流建立正确意识。


3.5 TCP 多收服务端

import java.io.DataInputStream;
import java.io.EOFException;
import java.net.ServerSocket;
import java.net.Socket;

public class TCPServer {

    public static void main(String[] args)
            throws Exception {

        System.out.println(
                "=== TCP 服务端启动 ==="
        );

        try (
                ServerSocket serverSocket =
                        new ServerSocket(9999)
        ) {

            System.out.println(
                    "等待客户端连接..."
            );

            try (
                    Socket socket =
                            serverSocket.accept();

                    DataInputStream input =
                            new DataInputStream(
                                    socket.getInputStream()
                            )
            ) {

                String client =
                        socket.getInetAddress()
                              .getHostAddress()
                        +
                        ":"
                        +
                        socket.getPort();

                System.out.println(
                        "客户端上线:" + client
                );

                try {

                    while (true) {

                        String message =
                                input.readUTF();

                        System.out.println(
                                "[" + client + "] "
                                + message
                        );
                    }

                } catch (EOFException e) {

                    System.out.println(
                            "客户端正常结束连接:"
                            + client
                    );
                }
            }
        }
    }
}

3.6 服务端完整执行流程

new ServerSocket(9999)
          ↓
      accept()
          ↓
      阻塞等待
          ↓
客户端建立连接
          ↓
accept 返回 Socket
          ↓
获取 InputStream
          ↓
DataInputStream
          ↓
      while true
          ↓
       readUTF()
          ↓
收到消息并打印
          ↓
       readUTF()
          ↓
再次等待

3.7 readUTF 为什么会一直等待

假设客户端已经发送:

A
B
C

服务端执行:

input.readUTF();

依次得到:

A

B

C

然后再次:

input.readUTF();

但客户端还没有发送:

D

服务端此时不能凭空返回:

null

而是需要:

等待下一条符合 readUTF 格式的数据

因此线程处于阻塞读取状态。


3.8 客户端关闭后为什么会出现 EOFException

假设客户端输入:

exit

然后:

socket.close();

服务端仍然执行:

input.readUTF();

此时对端已经结束输出,输入流最终到达:

End Of Stream
流结束

DataInputStream.readUTF() 需要先读取完整的:

长度信息
+
字符串内容

如果流已经结束,无法再获得一条完整 UTF 数据,就可能抛出:

EOFException

因此:

EOFException

在这种场景下不一定表示:

“服务器程序出现严重 Bug”

它也可能表示:

对端已经结束了这条数据流。


四、原理与进阶

4.1 三种不同的“循环”

目前网络程序中已经出现三类循环。

UDP 客户端

while (true) {
    socket.send(packet);
}

作用:

不断发送独立数据报

TCP 单客户端服务端

while (true) {
    input.readUTF();
}

作用:

不断读取同一条 TCP 连接

下一章 TCP 多客户端服务端

将出现:

while (true) {
    serverSocket.accept();
}

作用:

不断接受新的 TCP 连接

这三种循环虽然都写:

while (true)

但语义完全不同。


4.2 当前服务端为什么仍然只能处理一个客户端

注意当前代码:

Socket socket =
        serverSocket.accept();

while (true) {

    input.readUTF();
}

执行顺序是:

accept Client A
       ↓
进入 Client A 的 readUTF 循环
       ↓
一直处理 A

主线程已经进入:

while (true) {
    input.readUTF();
}

因此它不会再次执行:

serverSocket.accept();

结果:

Client A
   ↓
Server
正常通信

但:

Client B
   ↓
等待服务端进一步接受

所以:

TCP 多发多收 ≠ TCP 多客户端并发服务。


4.3 为什么不能把 accept 和 readUTF 都放到一个循环

有人可能写:

while (true) {

    Socket socket =
            serverSocket.accept();

    DataInputStream input =
            new DataInputStream(
                    socket.getInputStream()
            );

    String message =
            input.readUTF();
}

这样虽然可以:

接受 A
读 A 一条

再接受 B
读 B 一条

但每一个连接只读取一次,而且无法真正同时持续处理不同客户端。

真正的问题是:

accept() 和每个客户端的持续 read() 都可能阻塞。

因此需要:

不同执行线程

承担不同职责。

这就是下一章多线程出现的原因。


4.4 writeUTF 的长度边界

writeUTF() 会先使用:

2 bytes

记录后续修改版 UTF-8 数据的字节长度。

因此单次:

writeUTF(message)

编码后的数据不能无限大。

如果修改版 UTF-8 编码后的字符串超过:

65535 bytes

会出现:

UTFDataFormatException

所以 writeUTF/readUTF 很适合:

JavaSE 教学
简单聊天消息
小型协议字段

但并不应该理解为:

任意大小的文件和消息都应该使用 writeUTF()

例如文件传输应该设计:

长度
+
byte[]

或者使用其他协议格式。


4.5 TCP 的消息协议已经出现了

虽然本章只是:

writeUTF(message);

但其实已经存在一个最简单的应用层协议:

消息结构:
┌───────────────┐
│ UTF Length    │
├───────────────┤
│ UTF Data      │
└───────────────┘

如果以后增加:

消息类型
发送者
时间
正文

可能设计:

[int type]
[UTF sender]
[long timestamp]
[UTF message]

接收端必须严格按:

相同顺序
相同类型

读取。

这就是网络应用协议的雏形。


五、实践应用

5.1 持续聊天

当前已经可以实现:

Client:
你好

Server:
收到:你好


Client:
JavaSE 学到网络了

Server:
收到:JavaSE 学到网络了

只要 TCP 连接不关闭:

Client
  ⇅
Server

就可以持续传输。


5.2 长连接思想

当前程序本质上具有:

连接建立
   ↓
持续使用
   ↓
最后关闭

的长连接特征。

与:

每条消息重新连接

相比,可以降低频繁:

建立连接
关闭连接

带来的额外成本。

真实系统中的长连接还需要考虑:

  • 心跳
  • 空闲超时
  • 连接断开
  • 自动重连
  • 消息协议
  • 资源限制

本阶段只需要建立概念。


六、常见问题

6.1 多发多收为什么 Socket 要创建在循环外?

因为我们希望:

在同一条 TCP 连接上持续发送。

放在循环内意味着不断建立新连接。


6.2 服务端为什么一直停在 readUTF?

因为客户端暂时没有发送新的完整消息。

这是正常的:

阻塞式读取

6.3 readUTF 会返回 null 表示客户端下线吗?

不能这样理解。

DataInputStream.readUTF() 的契约并不是:

结束 → 返回 null

流结束时可能抛出:

EOFException

6.4 exit 为什么服务器收不到?

因为客户端在:

writeUTF()

之前已经:

break;

所以:

exit

只是本地控制命令。


6.5 为什么 writeUTF 与 readUTF 必须对应?

因为双方必须遵守相同的数据格式。

发送:

writeUTF();

接收:

readUTF();

如果一端写:

writeInt();

另一端却直接:

readUTF();

数据格式就不一致。


6.6 writeUTF 使用的是标准 UTF-8 吗?

严格来说不是。

它使用:

Modified UTF-8

并带有长度信息。


6.7 TCP 多发多收已经支持多人聊天了吗?

还没有。

当前只有:

1 Client
    ⇅
1 Server Socket

服务端只接受:

一个客户端连接

下一章才解决:

Client A ─┐
Client B ─┼──→ Server
Client C ─┘

七、练习与验收

7.1 知识问答

  1. TCP 一发一收如何升级为多发多收?
  2. 为什么 Socket 应创建在发送循环之外?
  3. 服务端的 readUTF() 为什么需要循环?
  4. readUTF() 没有数据时会发生什么?
  5. writeUTF()readUTF() 如何形成消息边界?
  6. writeUTF() 使用的是标准 UTF-8 吗?
  7. 为什么客户端关闭后服务端可能出现 EOFException
  8. 当前服务端为什么仍只能处理一个客户端?
  9. TCP 多发多收与 TCP 多客户端通信有什么区别?
  10. 为什么不能认为一次 write() 天然对应一次 read()

7.2 代码阅读

阅读:

Socket socket =
        new Socket(
                "127.0.0.1",
                9999
        );

DataOutputStream output =
        new DataOutputStream(
                socket.getOutputStream()
        );

while (true) {

    String message =
            scanner.nextLine();

    output.writeUTF(message);

    output.flush();
}

回答:

  1. TCP 连接建立了几次?
  2. Socket 为什么在循环外?
  3. writeUTF() 每执行一次代表什么?
  4. 所有消息是否使用同一个本地客户端端口?
  5. 如果服务端断开,继续写数据可能发生什么?

7.3 手写代码

关闭 AI 自动补全,从零完成:

TCPChatClient

要求:

  • 连接 127.0.0.1:9999
  • 使用 Scanner
  • 循环输入
  • 使用 writeUTF()
  • 输入 exit 退出

再完成:

TCPChatServer

要求:

  • 监听 9999
  • 接受一个客户端
  • 循环 readUTF()
  • 打印客户端 IP、端口和消息
  • 客户端正常断开后程序能够识别连接结束

7.4 Debug

下面代码有什么问题?

while (true) {

    Socket socket =
            new Socket(
                    "127.0.0.1",
                    9999
            );

    DataOutputStream output =
            new DataOutputStream(
                    socket.getOutputStream()
            );

    output.writeUTF(
            scanner.nextLine()
    );
}

回答:

  1. 每一轮发生了什么网络操作?
  2. 它是否属于真正的“一条连接多发”?
  3. 应如何调整代码结构?

7.5 综合训练

设计一个简单应用层消息协议:

type
nickname
message

要求使用:

writeInt()
writeUTF()
writeUTF()

发送。

写出接收端对应的:

read...

顺序。

不要直接复制答案,先自己推导:

发送顺序与读取顺序之间有什么规则?

7.6 本章验收

如果你能够闭卷画出:

       一条 TCP Connection

Client                     Server
  │                          │
  │ writeUTF("A")            │
  │─────────────────────────→│
  │                          │ readUTF()
  │ writeUTF("B")            │
  │─────────────────────────→│
  │                          │ readUTF()
  │ writeUTF("C")            │
  │─────────────────────────→│
  │                          │ readUTF()

并能解释:

Socket 为什么不能放在循环里?

readUTF 为什么阻塞?

客户端关闭后服务端如何感知?

为什么当前仍然只能服务一个客户端?

那么 TCP 多发多收已经掌握。