UDP 通信原理与 Datagram API | JavaSE

UDP 通信原理与 Datagram API

一、学习目标

学完本章,你应该能够:

  1. 能够解释 UDP 的核心通信模型以及“无连接、面向数据报”的含义。
  2. 能够说明 UDP 为什么不能保证数据一定到达、按序到达或只到达一次。
  3. 能够区分 DatagramSocketDatagramPacket 的职责。
  4. 能够掌握 UDP 发送端和接收端创建 DatagramSocket 的基本方式。
  5. 能够掌握发送数据包和接收数据包的两种 DatagramPacket 基本构造方式。
  6. 能够解释 send()receive() 的工作流程以及 receive() 的阻塞特征。
  7. 能够从收到的 DatagramPacket 中获得实际数据长度、发送方 IP 和发送方端口。

二、核心知识

2.1 什么是 UDP

UDP 全称:

User Datagram Protocol
用户数据报协议

它是计算机网络传输层的重要协议之一。

上一章已经建立:

网络通信
=
IP 地址
+
端口
+
通信协议

现在开始真正选择:

通信协议 = UDP

UDP 最核心的通信思想可以理解为:

应用程序把一份完整的数据封装成一个数据报,然后直接交给网络发送。

模型:

发送方
   │
   │ Datagram
   ▼
网络
   │
   ▼
接收方

发送之前不需要像 TCP 那样先建立一条可靠连接。


2.2 UDP 的核心特点

UDP 最重要的几个特点:

无连接
面向数据报
协议机制简单
开销较小
不保证可靠交付

无连接

发送 UDP 数据之前:

不需要:
客户端 ←→ 服务端
先建立 TCP 连接

发送方知道:

目标 IP
目标端口

之后就可以直接发送。

类似:

写好一张明信片
↓
写上收件地址
↓
直接寄出

不需要先打电话问:

“你准备好接收了吗?”


2.3 什么叫“不可靠”

这里的“不可靠”不是说:

UDP 完全不能用。

而是说:

UDP 协议本身不负责向应用程序提供可靠交付保证。

例如发送:

A
B
C

应用层不能单纯依靠 UDP 假设一定收到:

A
B
C

现实中可能出现:

A
C

即:

B 丢失

也可能:

B
A
C

即发生乱序。

应用程序也不能依赖 UDP 提供 TCP 那样的:

  • 确认机制
  • 按序字节流
  • 丢失自动重传
  • 重复数据保护

如果业务需要这些能力:

方案一:
应用层自行设计

方案二:
选择 TCP 等提供可靠传输能力的协议

2.4 什么叫“面向数据报”

UDP 是:

Message / Datagram Oriented

可以简单理解为:

一次发送的一份数据,在 UDP 层具有明确的数据报边界。

例如:

第一次 send:
"Hello"

第二次 send:
"Java"

第三次 send:
"UDP"

对应三个 UDP 数据报:

[Hello]

[Java]

[UDP]

这和后面 TCP 的:

连续字节流

模型存在明显区别。

因此学习 UDP 时,一个非常重要的对象就是:

DatagramPacket

即:

数据报包。


2.5 UDP 数据报中有什么

从应用程序角度,一个 UDP 数据报至少需要关注:

数据
目标 IP
目标端口

发送出去后,接收方还可以知道:

发送方 IP
发送方端口

可以建立下面这个模型:

┌─────────────────────┐
│     UDP 数据报       │
├─────────────────────┤
│ 数据                 │
│ 源地址 / 源端口       │
│ 目标地址 / 目标端口    │
└─────────────────────┘

UDP 协议头本身包含:

源端口
目标端口
长度
校验和

Java 初学阶段不需要手动构造 UDP 协议头。

这些底层工作由:

JDK
操作系统网络协议栈

负责。

我们主要操作:

DatagramSocket
DatagramPacket

2.6 Java 如何实现 UDP

Java 在:

java.net

包中提供两个核心类:

DatagramSocket
DatagramPacket

关系非常重要:

DatagramSocket
      │
      │ send / receive
      ▼
DatagramPacket

可以简单类比:

DatagramSocket = 发快递、收快递的窗口

DatagramPacket = 快递包裹

不过真正需要记住的是职责:

| 类 | 主要职责 | | ---------------- | ---------------------------------- | | DatagramSocket | UDP 通信端点,负责发送和接收数据报 | | DatagramPacket | 封装一次发送或接收的数据报信息 |


2.7 DatagramSocket

导包:

import java.net.DatagramSocket;

DatagramSocket 表示:

一个用于发送和接收 UDP 数据报的 Socket。


2.8 创建发送端 DatagramSocket

最常见:

DatagramSocket socket = new DatagramSocket();

这个构造器会:

创建 UDP Socket
      ↓
绑定本机某个可用端口

端口通常由操作系统自动选择。

例如可能得到:

54321

因此发送端并不是“没有端口”。

它同样拥有:

本地 IP
+
本地端口

只不过我们没有手动指定端口,而是让操作系统选择。

可以查看:

socket.getLocalPort();

2.9 创建指定端口的 DatagramSocket

接收端通常需要别人知道:

我要往哪个端口发?

因此常见写法:

DatagramSocket socket =
        new DatagramSocket(8080);

表示:

当前 UDP Socket
绑定本机 UDP 8080 端口

之后发送方就可以把数据发送到:

目标 IP:8080

2.10 为什么接收端通常指定端口

假设接收端每次启动都随机端口:

这次:53121

下次:60234

那么发送方很难提前知道:

应该发送到哪里?

因此服务器通常使用约定好的端口:

8080
8888
9999

而客户端发送端可以让操作系统分配临时端口。

于是最基本模型是:

客户端
随机本地端口
      │
      │ UDP
      ▼
服务端
固定端口 8080

2.11 DatagramSocket 的核心方法

两个最重要的方法:

void send(DatagramPacket packet)

作用:

发送一个 UDP 数据报

以及:

void receive(DatagramPacket packet)

作用:

接收一个 UDP 数据报

因此 UDP 程序最核心的代码最终就是:

socket.send(packet);

和:

socket.receive(packet);

2.12 DatagramPacket

导包:

import java.net.DatagramPacket;

DatagramPacket 用于表示:

一个 UDP 数据报。

但是发送和接收时,创建方式不同。


2.13 创建发送数据包

发送数据时必须知道:

我要发什么?
发多少字节?
发给谁?
发到哪个端口?

因此常见构造器:

DatagramPacket(
        byte[] buf,
        int length,
        InetAddress address,
        int port
)

例如:

byte[] data = ...;

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

四个参数分别表示:

data
↓
真正的数据

data.length
↓
本次发送多少字节

targetAddress
↓
目标 IP

8080
↓
目标 UDP 端口

2.14 为什么网络数据通常使用 byte[]

你会再次看到:

byte[]

因为网络传输最终处理的是:

字节

因此字符串:

String message = "你好";

需要先编码:

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

知识链:

String
   ↓
字符编码
   ↓
byte[]
   ↓
DatagramPacket
   ↓
网络

这正是之前:

字符集
编码与解码
IO

知识在网络编程中的实际应用。


2.15 创建接收数据包

接收数据之前,我们并不知道对方到底会发送什么内容。

因此需要先准备:

接收缓冲区

例如:

byte[] buffer = new byte[1024];

然后创建接收数据包:

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

这里暂时不需要:

目标 IP
目标端口

因为这个对象现在的任务不是:

告诉 Java 发给谁

而是:

准备一个空间接收别人发来的数据

2.16 receive() 为什么会阻塞

调用:

socket.receive(packet);

如果当前没有 UDP 数据到达:

线程不会立即继续执行

而是:

等待
等待
等待
...

直到:

收到数据报

这就是:

阻塞(Blocking)

程序结构:

执行 receive()
      ↓
有没有数据?
   ┌──┴──┐
   │     │
  没有   有
   │     │
等待     ▼
      填充 packet
          ↓
      方法返回

这与之前学习线程状态时涉及的阻塞思想已经开始连接起来。


2.17 接收之后 packet 发生了什么

执行:

socket.receive(packet);

之前:

packet
只是准备好的空接收容器

执行之后:

packet
↓
收到的数据
+
发送方 IP
+
发送方端口
+
实际数据长度

因此可以使用:

packet.getLength();

获取:

本次实际收到多少字节

使用:

packet.getAddress();

获取:

发送方 IP 地址

使用:

packet.getPort();

获取:

发送方端口

2.18 为什么不能直接把整个 buffer 转成字符串

假设:

byte[] buffer = new byte[1024];

对方实际只发送:

12 字节

那么:

1024

只是缓冲区容量。

真正有效的数据只有:

0 ~ 11

因此错误思路:

new String(buffer, StandardCharsets.UTF_8);

可能把没有有效数据的缓冲区剩余部分也考虑进去。

应该获取:

int length = packet.getLength();

然后:

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

核心思想:

缓冲区容量
≠
本次真正收到的数据长度

三、使用方法

3.1 UDP 发送端基本流程

发送 UDP 数据的标准思路:

1. 创建 DatagramSocket

2. 准备数据

3. 获取目标 InetAddress

4. 创建 DatagramPacket

5. socket.send(packet)

6. 关闭 Socket

伪代码:

DatagramSocket socket = ...

byte[] data = ...

DatagramPacket packet = ...

socket.send(packet);

socket.close();

完整程序将在下一章实现。


3.2 UDP 接收端基本流程

接收端:

1. 创建 DatagramSocket
   并绑定固定端口

2. 创建 byte[] 接收缓冲区

3. 创建 DatagramPacket

4. socket.receive(packet)

5. 获取实际长度

6. 解码数据

7. 获取发送方地址、端口

8. 关闭 Socket

核心模型:

DatagramSocket
      │
 receive(packet)
      │
      ▼
DatagramPacket
      │
      ├── 数据
      ├── 长度
      ├── 发送方 IP
      └── 发送方端口

3.3 DatagramPacket 常用 API

| API | 作用 | | -------------- | ------------------------------ | | getData() | 获取底层字节数组 | | getLength() | 获取数据报当前有效数据长度 | | getOffset() | 获取数据在缓冲区中的起始偏移量 | | getAddress() | 获取发送方或目标方 IP 地址 | | getPort() | 获取发送方或目标方端口 |

其中最常用:

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

四、原理与进阶

4.1 UDP 数据报具有边界

假设发送端执行:

send(A)

send(B)

send(C)

这是三个独立 UDP 数据报。

接收端每次:

receive(packet);

接收的是:

一个数据报。

UDP 不像 TCP 那样向 Java 程序提供一个连续的字节流。

这是后续区分 UDP 和 TCP 的核心之一:

UDP
面向数据报

TCP
面向字节流

4.2 UDP 不代表完全没有校验

“UDP 不可靠”不应该理解成:

UDP 对数据什么都不做。

UDP 协议头本身存在:

Checksum
校验和

“不可靠”真正强调的是:

UDP 不向应用层提供 TCP 那种完整的可靠、有序、重传式交付保证。


4.3 UDP 数据大小到底是不是 64KB

入门课程经常简化成:

UDP 一次最多发送 64KB

它表达了一个正确方向:

UDP 单个数据报的大小存在限制,不能无限大。

更严谨地看,UDP 头中的 Length 字段为 16 位,并且该长度包括:

UDP Header
+
UDP Data

UDP Header 本身占:

8 Byte

在普通 IPv4 数据报中,还要考虑 IPv4 头部,因此理论最大 UDP 应用数据通常写作:

65507 Byte

而不是应用层可以随意发送完整的:

65535 Byte

更重要的是:

实际开发中通常不应该追求接近 UDP 理论最大值。

因为较大的 IP 数据报可能产生:

IP 分片

而任意一个分片丢失都可能导致整个数据报无法正常重组。

因此本阶段重点不是背:

65507

而是理解:

UDP 是一包一包发送
+
单个数据报大小有限
+
实际应用通常应该控制数据报大小

4.4 DatagramSocket 为什么也有 connect()

UDP 本身是:

Connectionless
无连接

但 Java 的:

DatagramSocket

仍然提供:

connect(...)

这并不意味着 UDP 变成了 TCP。

UDP Socket 的 connect() 主要是:

把 DatagramSocket
逻辑关联到一个固定远端地址

从而限制:

向谁发送
从谁接收

它不会执行 TCP 的:

三次握手

因此:

UDP 协议无连接

与:

DatagramSocket 有 connect API

并不矛盾。

JavaSE 入门阶段仍然以:

未连接 DatagramSocket
+
每个 DatagramPacket 自己携带目标地址

为主。


4.5 接收缓冲区太小会怎样

例如:

byte[] buffer = new byte[10];

但发送方发送了:

100 字节

当:

receive(packet);

接收时,如果消息超过 DatagramPacket 当前允许接收的长度:

超出的部分会被截断。

因此:

buffer 太小

并不是一个无关紧要的问题。

接收端必须根据应用层协议合理设计缓冲区。


五、实践应用

UDP 可以用于某些更关注:

低延迟
实时性
简单请求/响应

并且应用能够接受、处理部分数据丢失的场景。

但是否选择 UDP 不能简单根据:

“UDP 快”

做决定。

真正应该判断:

业务能否容忍丢失?
是否需要严格顺序?
是否需要自动重传?
消息规模如何?
延迟是否敏感?

例如实时音视频系统即使底层采用 UDP,也通常会在应用层或其他协议层加入大量复杂机制。

所以:

UDP
≠
不做可靠性设计

UDP
=
可靠性不是由基础 UDP 协议自动替应用完成

六、常见问题

6.1 DatagramSocket 和 DatagramPacket 有什么区别?

记住:

DatagramSocket
负责通信

DatagramPacket
负责封装一个数据报

或者:

Socket = 通信端点

Packet = 数据包

6.2 发送端为什么不用指定自己的端口?

不是没有端口。

执行:

new DatagramSocket();

之后系统会自动:

选择一个可用的本地端口

发送端依然具有自己的:

IP + Port

6.3 接收端为什么通常指定端口?

因为发送方必须知道:

数据应该发给哪个 UDP 服务

所以服务端一般需要稳定的:

Port

6.4 receive() 没有数据时会返回 null 吗?

不会简单返回 null

普通情况下:

socket.receive(packet);

会:

阻塞等待数据

直到收到数据、发生异常或受到相应 Socket 配置影响。


6.5 为什么接收后一定要 getLength()?

因为:

buffer.length

表示:

缓冲区有多大

而:

packet.getLength()

表示:

本次实际收到多少有效字节

两个概念完全不同。


6.6 UDP 发送成功就代表对方收到吗?

不代表。

例如:

socket.send(packet);

正常返回,只意味着数据已经交给本地网络栈处理。

它不意味着:

对方应用一定收到了

UDP 本身没有 TCP 那种可靠确认机制。


6.7 UDP 会自动重传吗?

基础 UDP 协议不会提供 TCP 式可靠重传。

如果业务必须做到:

消息必须收到

则需要:

应用层 ACK
超时
重传
序号
去重

等机制,或者直接考虑 TCP。


七、练习与验收

7.1 知识问答

  1. UDP 的全称是什么?
  2. 什么叫 UDP 无连接?
  3. 什么叫 UDP 面向数据报?
  4. UDP 为什么被称为不可靠传输协议?
  5. DatagramSocketDatagramPacket 分别负责什么?
  6. new DatagramSocket() 是否意味着发送端没有端口?
  7. new DatagramSocket(8080) 表示什么?
  8. send()receive() 分别属于哪个类?
  9. 接收端为什么需要准备 byte[]
  10. buffer.lengthpacket.getLength() 有什么区别?
  11. receive() 没有数据时有什么执行特征?
  12. UDP 发送成功为什么不能证明对方一定收到?

7.2 代码阅读

阅读:

DatagramSocket socket =
        new DatagramSocket(8080);

byte[] buffer =
        new byte[1024];

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

socket.receive(packet);

回答:

  1. 8080 是谁的端口?
  2. buffer 有什么作用?
  3. 创建 packet 时为什么没有目标 IP?
  4. receive() 没有数据时程序会怎样?
  5. receive() 返回之后 packet 中新增了哪些有效信息?

7.3 手写代码

只写 API 骨架,不要求组成完整程序:

  1. 创建随机本地端口的 DatagramSocket
  2. 创建绑定 UDP 9999 端口的 DatagramSocket
  3. "Hello UDP" 使用 UTF-8 转换成 byte[]
  4. 创建向 127.0.0.1:9999 发送数据的 DatagramPacket
  5. 创建容量为 2048 字节的接收 DatagramPacket
  6. 写出发送数据的方法调用。
  7. 写出接收数据的方法调用。

7.4 Debug

下面代码有什么问题?

byte[] buffer = new byte[1024];

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

System.out.println(
        new String(buffer)
);

要求说明:

  1. 为什么此时 buffer 中还没有收到网络数据?
  2. 缺少哪个核心操作?
  3. 收到数据后为什么也不应该简单按照整个 buffer.length 解码?

7.5 综合训练

请画出:

UDP Client
      ↓
DatagramSocket
      ↓
DatagramPacket
      ↓
Network
      ↓
DatagramSocket
      ↓
DatagramPacket
      ↓
UDP Server

并在每一个节点旁边写出:

它负责什么?

要求能够解释整个数据从:

String

一直到:

对方 String

需要经历哪些转换。


7.6 本章验收

关闭资料后,能够完整口述:

UDP
↓
Datagram
↓
DatagramSocket
↓
DatagramPacket
↓
send / receive

并能够闭卷写出:

new DatagramSocket();

new DatagramSocket(8080);

new DatagramPacket(
        data,
        data.length,
        address,
        port
);

new DatagramPacket(
        buffer,
        buffer.length
);

socket.send(packet);

socket.receive(packet);

同时解释:

IP
端口
数据
实际长度
发送方地址
阻塞

之间的关系,即可认为本章核心知识已经掌握。