TCP 多发多收 | JavaSE
TCP 多发多收
一、学习目标
学完本章,你应该能够:
- 能够解释 TCP 一发一收与多发多收在程序结构上的区别。
- 能够使用循环让客户端在同一条 TCP 连接中连续发送多条消息。
- 能够使用循环让服务端持续读取同一客户端发送的数据。
- 能够理解为什么多发多收时不能反复创建新的
Socket。 - 能够使用
DataOutputStream.writeUTF()与DataInputStream.readUTF()建立简单的消息边界。 - 能够理解
readUTF()的阻塞行为以及客户端关闭连接后的流结束现象。 - 能够解释为什么当前 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 知识问答
- TCP 一发一收如何升级为多发多收?
- 为什么
Socket应创建在发送循环之外? - 服务端的
readUTF()为什么需要循环? readUTF()没有数据时会发生什么?writeUTF()和readUTF()如何形成消息边界?writeUTF()使用的是标准 UTF-8 吗?- 为什么客户端关闭后服务端可能出现
EOFException? - 当前服务端为什么仍只能处理一个客户端?
- TCP 多发多收与 TCP 多客户端通信有什么区别?
- 为什么不能认为一次
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();
}
回答:
- TCP 连接建立了几次?
Socket为什么在循环外?writeUTF()每执行一次代表什么?- 所有消息是否使用同一个本地客户端端口?
- 如果服务端断开,继续写数据可能发生什么?
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()
);
}
回答:
- 每一轮发生了什么网络操作?
- 它是否属于真正的“一条连接多发”?
- 应如何调整代码结构?
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 多发多收已经掌握。