UDP 单发单收 | JavaSE

UDP 单发单收

一、学习目标

学完本章,你应该能够:

  1. 能够独立完成一个 UDP 客户端发送一条消息。
  2. 能够独立完成一个 UDP 服务端接收一条消息。
  3. 能够正确使用 DatagramSocketDatagramPacketInetAddress
  4. 能够正确完成字符串的 UTF-8 编码与解码。
  5. 能够从接收到的数据包中获取发送方 IP 和端口。
  6. 能够解释为什么 UDP 客户端即使 send() 正常返回,也不能证明服务端收到消息。
  7. 能够 Debug 端口错误、服务端未启动、缓冲区过小、解码长度错误等常见问题。

二、核心知识

2.1 什么叫 UDP 单发单收

本章只实现最简单的通信模型:

客户端
发送一次
   │
   ▼
服务端
接收一次

也就是:

One Send
+
One Receive

程序完成一次通信后结束。

后续 09-05 再升级成:

多发
多收
循环通信

因此本章的目标不是做聊天软件,而是彻底打通:

Java 对象
↓
UDP 数据报
↓
网络
↓
UDP 数据报
↓
Java 对象

这条完整链路。


2.2 单发单收整体结构

假设服务端监听:

UDP 8080

客户端和服务端运行在同一台电脑。

那么:

UDPClient
随机端口
     │
     │ "你好,UDP"
     ▼
127.0.0.1:8080
     │
     ▼
UDPServer

客户端需要知道:

服务器 IP
服务器端口

服务端需要:

绑定端口
准备接收

2.3 客户端核心步骤

客户端完整流程:

1. 创建 DatagramSocket

2. 准备字符串

3. 字符串 → UTF-8 byte[]

4. 获取服务端 InetAddress

5. 创建 DatagramPacket

6. send(packet)

7. 关闭 DatagramSocket

核心链:

String
  ↓
byte[]
  ↓
DatagramPacket
  ↓
DatagramSocket.send()
  ↓
UDP

2.4 服务端核心步骤

服务端:

1. 创建 DatagramSocket(8080)

2. 创建 byte[] 缓冲区

3. 创建接收 DatagramPacket

4. receive(packet)

5. 获取实际长度

6. byte[] → String

7. 获取发送方 IP

8. 获取发送方端口

9. 关闭 Socket

核心链:

UDP
 ↓
DatagramSocket.receive()
 ↓
DatagramPacket
 ↓
byte[]
 ↓
String

三、使用方法

3.1 开发 UDP 服务端

先编写:

UDPServer

完整代码:

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)) {

            // 1. 准备接收缓冲区
            byte[] buffer = new byte[1024];

            // 2. 创建接收数据包
            DatagramPacket packet =
                    new DatagramPacket(
                            buffer,
                            buffer.length
                    );

            // 3. 等待一个 UDP 数据报
            socket.receive(packet);

            // 4. 获取真正收到的数据
            String message =
                    new String(
                            packet.getData(),
                            packet.getOffset(),
                            packet.getLength(),
                            StandardCharsets.UTF_8
                    );

            // 5. 获取发送方信息
            String clientIp =
                    packet.getAddress()
                          .getHostAddress();

            int clientPort =
                    packet.getPort();

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

            System.out.println(
                    "发送方:" +
                    clientIp +
                    ":" +
                    clientPort
            );
        }
    }
}

3.2 第一步:创建接收端

DatagramSocket socket =
        new DatagramSocket(8080);

含义:

创建 UDP Socket
+
绑定当前主机 UDP 8080 端口

此时程序相当于告诉操作系统:

发往本机 UDP 8080 的数据报,可以交给我处理。


3.3 第二步:创建缓冲区

byte[] buffer =
        new byte[1024];

它用于:

保存接收到的数据

现在里面还没有真正的网络消息。


3.4 第三步:创建接收 Packet

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

此时:

packet

只是:

一个准备接收 UDP 数据的容器

3.5 第四步:receive()

socket.receive(packet);

这是整个服务端最核心的一句。

执行到这里:

如果没有数据:
阻塞

如果收到数据:
填充 packet
然后继续运行

因此如果你先运行服务端,控制台可能停在:

=== UDP 服务端启动 ===

看起来“没反应”。

实际上:

服务端正在正常等待客户端发送数据。


3.6 第五步:解析数据

接收到数据之后:

packet.getLength()

就是:

实际收到的数据长度

因此使用:

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

进行解码。

这里同时复习了之前的:

字符编码
UTF-8
byte[]
String

3.7 第六步:获取发送方信息

UDP 接收完成后,DatagramPacket 不只是保存数据。

还保存:

这份数据是谁发来的?

获取 IP:

packet.getAddress()

进一步:

packet.getAddress()
      .getHostAddress()

获得 IP 文本。

获取端口:

packet.getPort()

于是可以输出:

发送方:
127.0.0.1:52341

其中:

52341

就是客户端被操作系统分配的某个临时 UDP 端口。


四、开发 UDP 客户端

4.1 完整客户端

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

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

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

        try (DatagramSocket socket =
                     new DatagramSocket()) {

            // 1. 准备发送数据
            String message =
                    "你好,我是 UDP 客户端";

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

            // 2. 获取服务端地址
            InetAddress serverAddress =
                    InetAddress.getLoopbackAddress();

            // 3. 创建发送数据包
            DatagramPacket packet =
                    new DatagramPacket(
                            data,
                            data.length,
                            serverAddress,
                            8080
                    );

            // 4. 发送
            socket.send(packet);

            System.out.println(
                    "发送完成,本地端口:" +
                    socket.getLocalPort()
            );
        }
    }
}

4.2 创建客户端 Socket

DatagramSocket socket =
        new DatagramSocket();

客户端没有手工指定端口。

操作系统会自动选择:

Ephemeral Port
临时端口

例如:

52678

所以:

客户端:
127.0.0.1:52678

服务端:
127.0.0.1:8080

4.3 字符串编码

发送:

String message =
        "你好,我是 UDP 客户端";

网络需要:

byte[]

因此:

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

形成:

String
 ↓
UTF-8 编码
 ↓
byte[]

4.4 为什么显式使用 UTF-8

也可以写:

message.getBytes();

但是它会使用:

默认字符集

如果发送端和接收端字符集不一致:

编码 UTF-8
↓
解码 GBK
↓
乱码

因此网络协议中更推荐明确约定:

UTF-8

客户端:

getBytes(StandardCharsets.UTF_8)

服务端:

new String(
        ...,
        StandardCharsets.UTF_8
)

形成:

UTF-8 编码
        ↓
网络
        ↓
UTF-8 解码

4.5 获取服务端地址

本机测试时使用:

InetAddress serverAddress =
        InetAddress.getLoopbackAddress();

表示:

发给当前计算机自身

如果服务端运行在另一台局域网电脑:

InetAddress serverAddress =
        InetAddress.getByName(
                "192.168.1.20"
        );

这正是上一章:

InetAddress

在真正网络通信中的第一次使用。


4.6 创建发送数据包

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

这一个对象已经完整表达:

发什么:
data

发多少:
data.length

发给谁:
serverAddress

发到哪个服务:
8080

然后:

socket.send(packet);

就可以发送。


五、完整运行流程

5.1 第一次测试建议先启动服务端

先运行:

UDPServer

控制台:

=== UDP 服务端启动 ===

然后程序停在那里等待。

这不是卡死,而是:

socket.receive(packet);

正在阻塞。


5.2 再启动客户端

运行:

UDPClient

客户端可能输出:

=== UDP 客户端启动 ===
发送完成,本地端口:52678

5.3 服务端解除阻塞

数据到达:

Client
   │
   │ Datagram
   ▼
Server:8080

于是:

receive()

返回。

服务端可能输出:

收到消息:你好,我是 UDP 客户端
发送方:127.0.0.1:52678

然后程序结束。


5.4 整体流程图

UDPClient
    │
    │ new DatagramSocket()
    ▼
随机本地端口
    │
    │ String
    ▼
UTF-8 编码
    │
    ▼
byte[]
    │
    ▼
DatagramPacket
    │
    │ 目标:
    │ 127.0.0.1:8080
    ▼
socket.send()
    │
════════════ UDP ════════════
    │
    ▼
UDPServer : 8080
    │
    │ socket.receive()
    ▼
DatagramPacket
    │
    ├── byte[]
    ├── length
    ├── sender IP
    └── sender port
    │
    ▼
UTF-8 解码
    │
    ▼
String

这就是 Java UDP 最基础、也最重要的一条完整通信链。


六、原理与进阶

6.1 为什么最好先启动服务端

TCP 后面会发现:

服务端没启动
↓
客户端连接失败

UDP 不一样。

因为 UDP:

无连接

客户端:

socket.send(packet);

并不需要先跟服务器进行 TCP 式连接建立。

因此即使服务端当前没有运行:

socket.send(packet);

也可能正常执行完成。

但是:

数据报可能直接丢失

客户端不能仅根据:

send() 没报错

判断:

服务端已经收到消息。

这是理解 UDP 非常关键的一点。


6.2 DatagramSocket 的发送端也有地址

假设客户端:

new DatagramSocket();

操作系统分配:

52341

那么数据报大致存在:

源地址:
127.0.0.1:52341

目标地址:
127.0.0.1:8080

服务端正因为收到了源信息,才能通过:

packet.getAddress();
packet.getPort();

知道:

是谁发来的

后面如果需要“回复客户端”,这些信息会非常重要。


6.3 try-with-resources 又出现了

本章代码使用:

try (DatagramSocket socket =
             new DatagramSocket()) {

}

因为:

DatagramSocket

实现了可关闭资源相关接口。

因此可以结合之前 IO 学习的:

try-with-resources

自动关闭资源。

这说明 JavaSE 的知识已经不是孤岛:

异常
+
IO
+
字符编码
+
InetAddress
+
网络编程

正在逐渐组合成完整程序。


七、常见问题

7.1 服务端运行后为什么一直不结束?

因为:

socket.receive(packet);

正在阻塞等待数据。

运行客户端发送一次以后,它才会继续。


7.2 客户端为什么一闪就结束?

因为本章客户端只有:

创建
发送一次
关闭

没有循环。

这是:

单发单收

的正常结果。

09-05 才会加入:

while

实现持续发送与接收。


7.3 客户端端口为什么每次可能不同?

因为:

new DatagramSocket();

让操作系统自动选择可用端口。

所以可能:

第一次:52341

第二次:53892

第三次:60127

这是正常现象。


7.4 为什么服务端端口通常固定为 8080?

因为客户端必须提前知道:

数据应该发送到哪里

如果服务端端口每次随机变化,客户端无法稳定定位服务。


7.5 为什么服务端出现 BindException?

一种典型原因是:

UDP 8080

已经被其他 Socket 占用。

例如:

UDPServer A
绑定 8080

此时再启动第二个相同程序并尝试绑定相同本地 UDP 端点,就可能失败。

首先检查:

是否已经启动了另一个服务端实例。


7.6 客户端发送了,服务端为什么没有消息?

依次检查:

1. 服务端是否启动?

2. IP 是否正确?

3. 客户端发送端口是不是 8080?

4. 服务端绑定端口是不是 8080?

5. 是否被防火墙阻止?

6. 两台机器是否真的网络互通?

7. 如果跨机器通信,是否错误使用了 127.0.0.1?

尤其注意:

127.0.0.1

永远指:

当前这台机器自己。

如果:

客户端在电脑 A
服务端在电脑 B

客户端写:

127.0.0.1:8080

访问的是:

电脑 A 自己

而不是电脑 B。


7.7 为什么会乱码?

通常检查:

发送端字符集
和
接收端字符集

是否一致。

推荐:

UTF-8 → UTF-8

而不是依赖各自系统默认字符集。


7.8 为什么不能使用 buffer.length 作为实际消息长度?

因为:

buffer.length

表示:

整个缓冲区容量

而:

packet.getLength()

才表示:

本次 UDP 数据报实际收到的长度

7.9 UDP 服务端知道是谁发送的吗?

知道。

接收成功后:

packet.getAddress()

获得:

发送方 IP
packet.getPort()

获得:

发送方 UDP 端口

这也是后续实现:

服务端回复客户端

的基础。


八、练习与验收

8.1 知识问答

  1. UDP 单发单收是什么?
  2. 客户端为什么通常使用 new DatagramSocket()
  3. 服务端为什么使用 new DatagramSocket(8080)
  4. 客户端发送数据必须提供哪些信息?
  5. receive() 为什么会阻塞?
  6. 接收完成后如何获得实际消息长度?
  7. 如何得到发送方 IP?
  8. 如何得到发送方端口?
  9. 为什么客户端 send() 成功不能证明服务端收到?
  10. 为什么推荐网络程序显式使用 UTF-8?
  11. 为什么跨电脑通信不能把服务端地址写成客户端自己的 127.0.0.1

8.2 代码阅读

不运行,阅读:

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

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

回答:

  1. data 保存什么?
  2. data.length 表示什么?
  3. getLoopbackAddress() 表示什么?
  4. 8080 是客户端还是服务端端口?
  5. 这个 Packet 是发送 Packet 还是接收 Packet?

8.3 手写代码

关闭 AI 自动补全。

任务一:客户端

从零实现:

UDPClient

要求:

发送:
"星雨笔录 UDP Test"

到:

127.0.0.1:9999

要求显式使用:

UTF-8

任务二:服务端

从零实现:

UDPServer

绑定:

9999

接收一次消息,并输出:

消息正文
发送方 IP
发送方端口
实际字节数

8.4 Debug

下面程序为什么可能无法与另一台电脑通信?

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

已知:

客户端在电脑 A

服务端在电脑 B:
192.168.1.20

请指出问题并修复。


再观察:

byte[] buffer =
        new byte[1024];

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

socket.receive(packet);

String message =
        new String(
                buffer,
                StandardCharsets.UTF_8
        );

程序虽然可能打印出内容,但设计上存在什么问题?

要求使用:

packet.getLength()

完成修正。


8.5 综合训练

设计如下实验:

服务端:
UDP 9999

客户端:
随机端口

客户端发送:

姓名
当前时间
一句自定义消息

服务端输出:

============================
来自:127.0.0.1:xxxxx
长度:xx bytes
内容:...
============================

要求独立完成,不查看本章完整代码。


8.6 本章验收

如果你能够关闭资料,从零手写:

UDPClient.java

UDPServer.java

并正确使用:

DatagramSocket
DatagramPacket
InetAddress
StandardCharsets.UTF_8

send()
receive()

getLength()
getAddress()
getPort()

同时能够完整解释:

“一条字符串消息到底是怎样从客户端 Java 程序变成 UDP 数据报,并最终在服务端恢复为字符串的?”

那么 UDP 单发单收已经真正掌握。

最终模型必须能闭卷画出来:

客户端 String
     ↓
UTF-8 Encode
     ↓
byte[]
     ↓
DatagramPacket
     ↓
DatagramSocket.send()
     ↓
    UDP
     ↓
DatagramSocket.receive()
     ↓
DatagramPacket
     ↓
byte[]
     ↓
UTF-8 Decode
     ↓
服务端 String