UDP 多发多收 | JavaSE

UDP 多发多收

一、学习目标

学完本章,你应该能够:

  1. 能够解释 UDP“一发一收”和“多发多收”在程序结构上的本质区别。
  2. 能够使用 while 循环实现 UDP 客户端持续发送消息。
  3. 能够使用 while 循环实现 UDP 服务端持续接收不同数据报。
  4. 能够理解为什么一个 UDP 接收端可以接收来自多个发送端的数据。
  5. 能够通过 DatagramPacket 获取每一条消息对应的发送方 IP、端口和实际数据长度。
  6. 能够正确设计客户端的退出逻辑。
  7. 能够发现并处理循环复用 DatagramPacket 时的数据长度问题。

二、核心知识

2.1 从单发单收到多发多收

上一章实现的是:

客户端
   │
   │ send 一次
   ▼
服务端
   │
   │ receive 一次
   ▼
程序结束

代码结构类似:

socket.send(packet);

服务端:

socket.receive(packet);

执行一次就结束。

但真正的网络程序显然不能:

发一句话就退出。

例如聊天程序至少应该:

发送:
你好

发送:
今天在干嘛?

发送:
晚上打球吗?

发送:
exit

所以程序必须从:

执行一次

升级为:

循环执行

最直接的方案就是:

while (true) {
    ...
}

因此:

UDP 多发多收的本质不是学习新的网络 API,而是把已经学会的 UDP 收发逻辑放入循环中。


2.2 客户端为什么需要循环

单发客户端:

创建 Socket
    ↓
发送一次
    ↓
关闭 Socket

多发客户端:

创建 Socket
    ↓
┌───────────────┐
│ 读取用户输入   │
│      ↓        │
│ 创建 Packet   │
│      ↓        │
│ send()        │
└───────┬───────┘
        │
        └──不断循环

因此:

DatagramSocket socket =
        new DatagramSocket();

while (true) {
    // 输入
    // 编码
    // 创建数据报
    // 发送
}

注意:

DatagramSocket 一般创建一次即可,不需要每发送一条消息就重新创建一个 Socket。

否则就变成:

发送第一条
创建 Socket A

发送第二条
创建 Socket B

发送第三条
创建 Socket C

这没有必要。

更合理:

创建 Socket
    ↓
发送 A
发送 B
发送 C
发送 D
    ↓
退出时关闭

2.3 服务端为什么也需要循环

单收服务端:

socket.receive(packet);

只执行一次。

收到第一条之后:

程序结束

显然无法持续提供服务。

因此:

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

服务端模型:

                UDP Server : 8080

                       │
              receive(packet)
                       │
             ┌─────────┴─────────┐
             │                   │
          没消息               有消息
             │                   │
           阻塞                  ▼
                            处理数据报
                                 │
                                 ▼
                            下一轮 receive

因此服务端可以:

一直运行
一直等待
一直接收

2.4 UDP 服务端为什么可以接收多个客户端

这是本章最重要的理解之一。

假设存在三个客户端:

Client A
192.168.1.10:52001

Client B
192.168.1.11:53002

Client C
192.168.1.12:54003

都向:

Server
192.168.1.100:8080

发送 UDP 数据报。

服务端只需要:

DatagramSocket socket =
        new DatagramSocket(8080);

然后:

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

就可能依次收到:

A → Server

C → Server

B → Server

A → Server

为什么?

因为 UDP 是:

无连接
+
数据报独立寻址

服务端并没有建立:

Client A ←→ Server

这种长期连接关系。

它只是告诉操作系统:

发往本机 UDP 8080 的数据报交给这个 Socket。

于是:

Client A ─┐
Client B ─┼──→ UDP : 8080
Client C ─┘

都可以被同一个 DatagramSocket 接收。


2.5 服务端如何区分是谁发来的

虽然所有客户端都向:

8080

发送,但是每一个收到的:

DatagramPacket

中都包含发送方信息。

可以获取:

packet.getAddress()

发送方 IP。

以及:

packet.getPort()

发送方端口。

例如:

消息:你好
来源:192.168.1.10:52001

下一条:

消息:晚上好
来源:192.168.1.11:53002

因此服务器可以根据:

IP + Port

识别一次 UDP 数据报来自哪个网络端点。


三、使用方法

3.1 UDP 多发客户端

完整客户端:

import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;

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

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

        try (DatagramSocket socket =
                     new DatagramSocket();
             Scanner scanner =
                     new Scanner(System.in)) {

            InetAddress serverAddress =
                    InetAddress.getLoopbackAddress();

            while (true) {

                System.out.print("请输入消息:");
                String message =
                        scanner.nextLine();

                if ("exit".equalsIgnoreCase(message)) {
                    System.out.println("客户端退出");
                    break;
                }

                byte[] data =
                        message.getBytes(
                                StandardCharsets.UTF_8
                        );

                DatagramPacket packet =
                        new DatagramPacket(
                                data,
                                data.length,
                                serverAddress,
                                8080
                        );

                socket.send(packet);
            }
        }
    }
}

3.2 客户端执行流程

先创建:

DatagramSocket socket =
        new DatagramSocket();

操作系统为客户端分配临时端口。

例如:

127.0.0.1:53126

然后:

Scanner scanner =
        new Scanner(System.in);

持续读取用户输入。

主循环:

while (true) {
    ...
}

每轮:

读取 String
    ↓
判断 exit
    ↓
UTF-8 编码
    ↓
byte[]
    ↓
创建 DatagramPacket
    ↓
send()

3.3 为什么 exit 不发送给服务器

代码:

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

意味着:

exit

只是:

客户端自己的退出控制指令。

它不会被发送给 UDP 服务端。

这通常是合理的。

因为一个服务端可能同时服务:

Client A
Client B
Client C

如果 Client A 输入:

exit

不应该意味着:

整个 Server 退出

而应该只是:

Client A 自己停止发送

因此要区分两个概念:

业务消息

和:

本地控制命令

3.4 UDP 多收服务端

一个直观实现:

import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.nio.charset.StandardCharsets;

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

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

        try (DatagramSocket socket =
                     new DatagramSocket(8080)) {

            while (true) {

                byte[] buffer =
                        new byte[4096];

                DatagramPacket packet =
                        new DatagramPacket(
                                buffer,
                                buffer.length
                        );

                socket.receive(packet);

                String message =
                        new String(
                                packet.getData(),
                                packet.getOffset(),
                                packet.getLength(),
                                StandardCharsets.UTF_8
                        );

                String clientIp =
                        packet.getAddress()
                              .getHostAddress();

                int clientPort =
                        packet.getPort();

                System.out.println(
                        "来自 " +
                        clientIp +
                        ":" +
                        clientPort
                );

                System.out.println(
                        "消息:" + message
                );

                System.out.println(
                        "长度:" +
                        packet.getLength() +
                        " bytes"
                );

                System.out.println(
                        "--------------------------"
                );
            }
        }
    }
}

这个版本每轮:

new DatagramPacket(...)

创建新的接收包。

逻辑非常直观。


3.5 服务端完整执行过程

new DatagramSocket(8080)
          ↓
绑定 UDP 8080
          ↓
      while(true)
          ↓
创建接收 Packet
          ↓
receive(packet)
          ↓
      阻塞等待
          ↓
收到某个数据报
          ↓
读取 getLength()
          ↓
UTF-8 解码
          ↓
获取 IP + Port
          ↓
打印
          ↓
下一轮

服务端不会因为:

一个客户端退出

而自动退出。

它会继续:

receive(...)

等待其他数据报。


四、原理与进阶

4.1 DatagramPacket 能不能重复使用?

可以。

例如:

byte[] buffer =
        new byte[4096];

DatagramPacket packet =
        new DatagramPacket(
                buffer,
                buffer.length
        );

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

但是这里有一个非常容易被忽略的细节。

第一次收到:

Hello

假设只有:

5 bytes

接收完成后:

packet.getLength()

会变成:

5

DatagramPacketlength 不仅表示:

上一次实际收到多少数据

在接收时还表示:

允许使用多少缓冲区空间接收下一份数据

因此,如果循环直接复用:

socket.receive(packet);

下一条发送:

这是一个长度远大于 5 字节的消息

就存在被截断的风险。


4.2 复用 DatagramPacket 的正确方式

如果希望避免每轮创建新对象,可以:

byte[] buffer =
        new byte[4096];

DatagramPacket packet =
        new DatagramPacket(
                buffer,
                buffer.length
        );

while (true) {

    packet.setLength(buffer.length);

    socket.receive(packet);

    String message =
            new String(
                    packet.getData(),
                    packet.getOffset(),
                    packet.getLength(),
                    StandardCharsets.UTF_8
            );

    System.out.println(message);
}

关键:

packet.setLength(buffer.length);

在下一次接收前恢复:

最大可接收长度

因此两种方案都可以:

方案 A:每轮创建新的 Packet

while (true) {

    byte[] buffer = new byte[4096];

    DatagramPacket packet =
            new DatagramPacket(
                    buffer,
                    buffer.length
            );

    socket.receive(packet);
}

优点:

逻辑直观
不容易踩复用长度问题

方案 B:重复使用 Packet

byte[] buffer = new byte[4096];

DatagramPacket packet =
        new DatagramPacket(
                buffer,
                buffer.length
        );

while (true) {

    packet.setLength(buffer.length);

    socket.receive(packet);
}

优点:

避免每轮创建对象

但必须理解:

packet length 状态

4.3 UDP 为什么不需要“每个客户端一个线程”

当前模型:

Client A ─┐
Client B ─┼──→ DatagramSocket.receive()
Client C ─┘

每个数据报是独立的。

服务端可以:

receive 一个
处理一个

receive 一个
处理一个

因此简单 UDP 接收器并不天然要求:

一个客户端 = 一个线程

这与后面的 TCP 差异非常明显。

TCP 中:

Client A
   ↓
Socket A

Client B
   ↓
Socket B

Client C
   ↓
Socket C

连接具有持续状态。

因此 TCP 多客户端服务端通常需要进一步结合:

多线程 / 线程池

而 UDP 基础接收模型没有这个连接层面的要求。


4.4 但 UDP 服务端完全不需要多线程吗?

也不能这么绝对。

假设:

socket.receive(packet);

收到数据以后,服务端需要处理一个耗时任务:

大型计算
数据库访问
磁盘 IO
调用外部服务

如果主线程直接处理:

receive
 ↓
耗时 10 秒
 ↓
下一次 receive

这 10 秒期间应用程序无法及时主动调用下一次 receive()

因此真实高并发 UDP 服务仍然可能采用:

接收线程
+
工作线程
+
队列

但是:

这属于更进一步的并发服务器设计问题,并不是 UDP 多发多收的基础要求。


五、实践应用

5.1 多客户端日志接收器

可以把 UDP 服务端理解为一个简单日志中心:

程序 A ─┐
程序 B ─┼──→ UDP Log Server
程序 C ─┘

每个客户端发送:

[INFO] 用户登录

服务端记录:

192.168.1.10:53100
[INFO] 用户登录

这是典型的:

多发送端
+
单接收端

模型。


5.2 简易消息广播的基础

当前:

Client → Server

已经能做到:

多个 Client
      ↓
    Server

如果进一步让服务端记录:

每个客户端 IP + Port

再把收到的消息发送给其他客户端:

Client A ──→ Server
               │
        ┌──────┼──────┐
        ▼      ▼      ▼
     Client B Client C Client D

就能逐渐发展出:

简易 UDP 聊天系统

不过广播、在线客户端维护等内容已经属于综合应用,不是本章必须实现的主线。


六、常见问题

6.1 多发多收到底比一发一收多了什么?

核心只有:

while (...)

但是程序生命周期发生了巨大变化。

一发一收:

收一次
结束

多发多收:

持续运行
持续等待
持续处理

6.2 UDP 服务端如何知道某一条消息是谁发送的?

使用:

packet.getAddress()

和:

packet.getPort()

每个 DatagramPacket 都带有对应远端信息。


6.3 多个客户端为什么可以同时向 8080 发数据?

因为:

8080

是:

服务端的目标端口

不同客户端拥有不同的:

源 IP
+
源端口

例如:

192.168.1.10:51001 → 192.168.1.100:8080

192.168.1.11:51002 → 192.168.1.100:8080

并不冲突。


6.4 客户端退出以后服务端为什么还在等待?

UDP 没有建立类似 TCP 那样的持续连接。

客户端:

socket.close();

只是关闭自己的 UDP Socket。

服务端不会收到:

TCP 连接断开

这样的连接状态事件。

它仍然继续:

receive(...)

6.5 能不能通过 UDP 服务端知道“客户端已经离线”?

仅仅依靠:

客户端突然不再发送 UDP 数据

不能立即可靠判断:

客户端主动退出了

还是:

客户端暂时没消息

还是:

网络断了

如果业务需要在线状态,通常需要额外设计:

登录消息
心跳
超时
离线消息

等应用层协议。


6.6 为什么我第一条短消息正常,第二条长消息却被截断?

如果复用了同一个:

DatagramPacket

检查是否忘记:

packet.setLength(buffer.length);

因为接收完成后 Packet 的长度会更新为:

实际收到的长度

6.7 服务端需要写 exit 吗?

通常不需要。

因为服务端本来就是:

长期运行程序

开发测试阶段可以直接停止进程。

如果需要优雅关闭,可以以后设计:

管理员命令
关闭标志
线程中断
Socket.close()

本章暂不展开。


七、练习与验收

7.1 知识问答

  1. UDP 一发一收与多发多收的核心程序结构有什么不同?
  2. 客户端为什么只需要创建一次 DatagramSocket
  3. 服务端为什么需要循环执行 receive()
  4. 一个 UDP 服务端为什么可以接收多个发送端的数据?
  5. 服务端如何知道消息来自哪个客户端?
  6. UDP 客户端关闭后,服务器为什么不会自动停止?
  7. UDP 为什么很难仅凭“没收到消息”判断客户端是否离线?
  8. 重复使用同一个 DatagramPacket 时为什么要注意 length
  9. packet.setLength(buffer.length) 有什么作用?
  10. UDP 多客户端模型为什么不像 TCP 那样天然需要“一个客户端一个 Socket 连接”?

7.2 代码阅读

阅读:

while (true) {

    String message =
            scanner.nextLine();

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

    byte[] data =
            message.getBytes(
                    StandardCharsets.UTF_8
            );

    DatagramPacket packet =
            new DatagramPacket(
                    data,
                    data.length,
                    serverAddress,
                    8080
            );

    socket.send(packet);
}

回答:

  1. socket 应该创建在循环内还是循环外?为什么?
  2. exit 是否会发送给服务端?
  3. 每次循环都会创建什么新对象?
  4. 客户端本地端口是否会随着每条消息改变?
  5. 如果服务器没启动,send() 正常返回是否代表有人收到?

7.3 手写代码

关闭 AI 自动补全。

实现:

UDPChatClient

要求:

  • 持续读取用户输入。
  • 每条消息发送到 127.0.0.1:9999
  • 使用 UTF-8。
  • 输入 exit 退出客户端。

实现:

UDPChatServer

要求:

  • 监听 UDP 9999
  • 持续接收。
  • 输出消息。
  • 输出发送方 IP。
  • 输出发送方端口。
  • 输出字节数。

7.4 Debug

下面程序存在潜在问题:

byte[] buffer =
        new byte[1024];

DatagramPacket packet =
        new DatagramPacket(
                buffer,
                buffer.length
        );

while (true) {

    socket.receive(packet);

    System.out.println(
            packet.getLength()
    );
}

第一次收到:

3 bytes

下一次客户端发送:

100 bytes

请分析:

  1. packet.getLength() 在第一次接收后发生了什么变化?
  2. 第二次接收前 Packet 当前允许的接收长度可能是多少?
  3. 应如何修改?

7.5 综合训练

运行:

UDPServer : 9999

然后同时启动:

Client A
Client B
Client C

分别输入:

A:我是 A

B:我是 B

C:我是 C

观察服务端打印:

消息
IP
端口

并解释:

服务端是如何使用同一个 DatagramSocket 区分三台客户端的?

7.6 本章验收

关闭资料后,能够完整画出:

Client A ─┐
Client B ─┼──→ DatagramSocket : 9999
Client C ─┘
                │
                ▼
          receive(packet)
                │
        ┌───────┼───────┐
        ▼       ▼       ▼
      Data     IP      Port

并能独立写出:

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

以及客户端:

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

同时能够解释 DatagramPacket 循环复用时的长度问题,即可认为本章掌握。