UDP 编程核心类与方法
- DatagramSocket(通信插座)
- DatagramPacket(数据报包)
- [实践:UDP 回显(Echo)服务器⭐](#实践:UDP 回显(Echo)服务器⭐)
Java 中编写 UDP 程序需要两个类:DatagramSocket(通信插座)和 DatagramPacket(数据包)。
DatagramSocket(通信插座)
| 方法签名 | 说明 |
|---|---|
DatagramSocket() |
创建 UDP 套接字,绑定到本机随机端口(一般用于客户端) |
DatagramSocket(int port) |
创建 UDP 套接字,绑定到本机指定端口(一般用于服务器) |
void receive(DatagramPacket p) |
从此套接字接收数据报,若无数据则阻塞等待 |
void send(DatagramPacket p) |
从此套接字发送数据报,不会阻塞,直接发送 |
void close() |
关闭此数据报套接字 |
DatagramPacket(数据报包)
构造方法
| 方法签名 | 说明 |
|---|---|
DatagramPacket(byte[] buf, int length) |
构造一个用于接收 数据报的包,数据存入 buf,接收指定长度 |
DatagramPacket(byte[] buf, int offset, int length, SocketAddress address) |
构造一个用于发送 数据报的包,address 指定目的 IP 和端口 |
获取对方信息的方法
| 方法 | 返回值 | 说明 |
|---|---|---|
getAddress() |
InetAddress |
只拿到 IP 地址 |
getPort() |
int |
只拿到 端口号 |
getSocketAddress() |
SocketAddress |
同时拿到 IP 和端口 (封装在 InetSocketAddress 对象中) |
继承
<<abstract>>
SocketAddress
+Serializable
InetSocketAddress
-String hostname
-InetAddress addr
-int port
+getAddress() : InetAddress
+getPort() : int
+getHostString() : String
+toString() : String
实际返回的对象类型
封装了 IP + 端口
获取数据的方法
| 方法 | 返回值 | 说明 |
|---|---|---|
getData() |
byte[] |
拿到数据报中的完整字节数组(包括空余部分) |
getLength() |
int |
拿到有效数据的长度 |
重要细节:
"hello".length()返回的是 字符个数(5)。"hello".getBytes().length返回的是 字节个数(英文环境下也是 5,但中文环境下一个字符可能占 3 字节)。- 构造数据报时,必须用字节个数,因为网络传输的最小单位是字节。
实践:UDP 回显(Echo)服务器⭐
需求:客户端发什么,服务器就原样返回什么。流程如下:
服务器 客户端 服务器 客户端 3. receive() 阻塞等待 7. receive() 阻塞等待响应 loop [循环处理] 1. 从控制台读取用户输入 2. 发送请求数据报到服务器 4. 解析请求字符串 5. process() 计算响应(这里是直接原样返回) 6. 发送响应数据报给客户端 8. 打印响应到控制台
服务器端代码
操作系统 UdpEchoServer main() 操作系统 UdpEchoServer main() 构造方法中创建 socket 绑定 9090 端口 进入主循环 while(true) receive() 阻塞等待 loop [服务器主循环] new UdpEchoServer(9090) socket = new DatagramSocket(9090) 端口绑定成功,文件描述符表新增一项 server.start() 准备接收请求
java
public class UdpEchoServer {
private DatagramSocket socket = null;
public UdpEchoServer(int port) throws SocketException {
// ① 服务器必须绑定一个固定端口,让客户端能找到
socket = new DatagramSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动");
while (true) {
// ② 准备好一个空的数据报,用于承载客户端发来的请求
DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096);
// ③ receive 会阻塞,直到收到客户端发来的数据报
socket.receive(requestPacket);
// ④ 解析有效部分并转成字符串(注意用 getLength() 取有效长度)
String request = new String(requestPacket.getData(), 0, requestPacket.getLength());
// ⑤ 根据请求计算响应(这里是 Echo 的核心逻辑)
String response = process(request);
// ⑥ 构造响应数据报:response.getBytes().length 是字节数,不是字符数
DatagramPacket responsePacket = new DatagramPacket(
response.getBytes(), // 响应内容的字节数组
response.getBytes().length, // 字节个数
requestPacket.getSocketAddress() // 从请求中获取客户端的 IP 和端口
);
// ⑦ 发送响应
socket.send(responsePacket);
// ⑧ 打印日志,方便观察
System.out.printf("[%s:%d] req: %s, resp: %s\n",
requestPacket.getAddress().toString(),
requestPacket.getPort(),
request, response);
}
}
public String process(String request) {
return request; // 回显服务器,原样返回
}
public static void main(String[] args) throws IOException {
UdpEchoServer server = new UdpEchoServer(9090);
server.start();
}
}
构造接收用的数据报
java
DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096);
这里调用的是两参数构造方法:
| 参数 | 传入的值 | 含义 |
|---|---|---|
byte[] buf |
new byte[4096] |
一个大小为 4096 字节的空数组,用于存放接收到的数据 |
int length |
4096 |
最大能接收 4096 字节的数据 |
为什么是 4096?
- UDP 协议单个数据报理论上最大约 64KB,实际常用 1500 字节以内(以太网 MTU 限制)。
- 4096 是常见的选择,足以容纳大多数 UDP 应用层数据,是经验值。
- 如果对方发的数据超过 4096 字节,多出来的部分会被截断丢弃。
requestPacket 内部结构
byte 数组
new byte[4096]
length = 4096
最大接收长度
实际数据长度
初始为 0
接收数据(阻塞等待)
java
socket.receive(requestPacket);
这是一个阻塞方法,执行流程如下:
调用 receive()
没有数据到达
线程挂起,不消耗 CPU
数据报到达
返回,数据写入 requestPacket
等待中
接收完成
阻塞期间,程序停在这里
直到有数据到来
接收完成后,requestPacket 内部发生了两件事:
getData()返回的字节数组里,前 N 个字节被填入了收到的数据。getLength()的返回值变为 N(实际收到的数据长度),而不再是 0。
解析数据为字符串
java
String request = new String(requestPacket.getData(), 0, requestPacket.getLength());
这里构造字符串的方式很关键:
java
new String(byte[] bytes, int offset, int length)
| 参数 | 传入的值 | 含义 |
|---|---|---|
bytes |
requestPacket.getData() |
整个 4096 字节的数组 |
offset |
0 |
从数组的第 0 个位置开始取 |
length |
requestPacket.getLength() |
只取实际收到的有效字节数 |
为什么不用
new String(requestPacket.getData())?
如果直接用这个单参数构造方法,会把整个 4096 字节都转成字符串。但实际有效数据可能只有 10 字节,剩下 4086 字节都是无效的 0 值,会构造出一堆乱码或空白字符。必须用 getLength() 精确限定有效范围。
📦 完整的 4096 字节数组
❌ 无效数据 剩余4091字节全为0
✅ 有效数据 getLength()=5
0
H
e
l
l
o
0
...
0
根据请求计算响应
java
String response = process(request);
java
public String process(String request) {
return request; // 回显服务器,原样返回
}
这一行是整个服务器最核心的业务逻辑所在。
- 对于 Echo 服务器,
process()就是原样返回,请求是什么,响应就是什么。 - 对于真实的服务器,这里会被替换成复杂的业务逻辑,比如数据库查询、计算处理等。
- 把业务逻辑抽成单独的
process()方法,体现关注点分离的原则。未来要写别的服务器,只改这一个方法即可。
构造响应
java
DatagramPacket responsePacket = new DatagramPacket(
response.getBytes(), // 参数1: 响应内容的字节数组
response.getBytes().length, // 参数2: 要发送的字节数
requestPacket.getSocketAddress() // 参数3: 目的地址 = 客户端的 IP+端口
);
这里调用的是 DatagramPacket 的四参数构造方法(发送专用):
| 参数 | 传入的值 | 含义 |
|---|---|---|
byte[] buf |
response.getBytes() |
响应字符串转成的字节数组 |
int offset |
省略(默认 0) | 从数组的哪个位置开始发送 |
int length |
response.getBytes().length |
发送的字节数 |
SocketAddress address |
requestPacket.getSocketAddress() |
数据要发给谁 |
为什么用 getBytes().length 而不用 length()? ⭐
| 方法 | 返回值 | 示例 "你好" |
示例 "hello" |
|---|---|---|---|
length() |
字符个数 | 2 | 5 |
getBytes().length |
字节个数 | 6(UTF-8下中文每字3字节) | 5 |
结论 :网络传输的是字节,必须用字节长度。如果用了
length(),遇到中文字符就会出错。
requestPacket.getSocketAddress() 拿到的什么? ⭐
返回的是 InetSocketAddress 对象,封装了客户端的 IP 和端口。
携带了源 IP 和源端口
requestPacket.getSocketAddress
InetSocketAddress 对象
IP: 127.0.0.1
端口: 54321
当初客户端发来请求时
请求报文
服务器通过 getSocketAddress
提取出这个地址
构造响应时原样填回去
响应就能准确回到客户端
这就是 UDP "无连接" 的体现:
- TCP 建立连接后,双方互相保存对端信息,发送时不用每次都指定目的地址。
- UDP 每次发送都必须显式指定目的地址。这个地址信息需要自己从请求中提取并保存(就像这里做的一样)。
发送响应
java
socket.send(responsePacket);
send() 是非阻塞方法,它把数据交给操作系统后立即返回,不会等待对端确认(UDP 本身就是不可靠的,发完就不管了)。
立即返回
不等待结果
socket.send()
操作系统内核
网卡驱动
物理网络
继续执行下一条语句
打印日志
java
System.out.printf("[%s:%d] req: %s, resp: %s\n",
requestPacket.getAddress().toString(),
requestPacket.getPort(),
request, response);
这行日志会打印如下格式:
[/127.0.0.1:54321] req: hello, resp: hello
| 内容 | 对应方法 | 示例 |
|---|---|---|
| 客户端 IP | requestPacket.getAddress().toString() |
/127.0.0.1 |
| 客户端端口 | requestPacket.getPort() |
54321 |
| 请求内容 | request |
hello |
| 响应内容 | response |
hello |
客户端代码
UdpEchoServer UdpEchoClient 用户键盘输入 UdpEchoServer UdpEchoClient 用户键盘输入 receive() 解除阻塞 receive() 解除阻塞 loop [每次通信] 输入字符串 "hello" 构造 requestPacket (载荷 + 服务器IP+端口) socket.send(requestPacket) 解析 + process() socket.send(responsePacket) 打印 "hello"
java
public class UdpEchoClient {
private DatagramSocket socket = null;
private String serverIp; // 在代码里自己保存服务器 IP(UDP 不会帮你存)
private int serverPort; // 在代码里自己保存服务器端口
public UdpEchoClient(String serverIp, int serverPort) throws SocketException {
this.serverIp = serverIp;
this.serverPort = serverPort;
// ① 客户端用随机端口即可
socket = new DatagramSocket();
}
public void start() throws IOException {
Scanner scanner = new Scanner(System.in);
while (true) {
// ② 从控制台读取用户输入
System.out.println("请输入要发送的内容:");
if (!scanner.hasNext()) break;
String request = scanner.next();
// ③ 构造请求数据报,必须指定服务器的 IP 和端口
DatagramPacket requestPacket = new DatagramPacket(
request.getBytes(),
request.getBytes().length,
InetAddress.getByName(serverIp), // 把字符串 IP 转了对象
serverPort
);
// ④ 发送请求(不阻塞)
socket.send(requestPacket);
// ⑤ 准备接收响应
DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
socket.receive(responsePacket); // 阻塞等待服务器返回
// ⑥ 解析并打印
String response = new String(responsePacket.getData(), 0, responsePacket.getLength());
System.out.println(response);
}
}
public static void main(String[] args) throws IOException {
UdpEchoClient client = new UdpEchoClient("127.0.0.1", 9090);
client.start();
}
}
成员变量与构造方法
java
public class UdpEchoClient {
private DatagramSocket socket = null;
private String serverIp; // 客户端要通信的服务器 IP
private int serverPort; // 客户端要通信的服务器端口
public UdpEchoClient(String serverIp, int serverPort) throws SocketException {
this.serverIp = serverIp;
this.serverPort = serverPort;
socket = new DatagramSocket(); // 无参构造,随机端口
}
}
java
private String serverIp; // 客户端要通信的服务器 IP
private int serverPort; // 客户端要通信的服务器端口
- UDP 协议本身不保存对端信息,所以需要在自己的代码中用成员变量记录服务器的 IP 和端口。
- 每次发送数据报时,都要明确告诉 UDP:"请发给这个 IP 的这个端口"。
- 这和 TCP 完全不用------TCP 建立连接后,直接
write()就行,协议栈会自动填好目的地址。
java
socket = new DatagramSocket(); // 无参构造,随机端口
- 客户端不需要固定的端口号,让操作系统随机分配一个可用的就行。
- 只要服务器返回的响应能回到这个随机端口,通信就能正常进行。
构造请求
java
DatagramPacket requestPacket = new DatagramPacket(
request.getBytes(),
request.getBytes().length,
InetAddress.getByName(serverIp), // 把 "127.0.0.1" 转成 InetAddress 对象
serverPort // 服务器的 9090 端口
);
socket.send(requestPacket);
这里使用了 DatagramPacket 的五参数构造方法:
| 参数 | 值 | 含义 |
|---|---|---|
byte[] buf |
request.getBytes() |
要发送的数据 |
int offset |
省略 | 从数组哪个位置开始 |
int length |
request.getBytes().length |
发送的字节数 |
InetAddress address |
InetAddress.getByName(serverIp) |
目的 IP |
int port |
serverPort |
目的端口 |
java
InetAddress.getByName(serverIp)
- 这是一个静态方法 ,把字符串形式的 IP 地址(如
"127.0.0.1")转成InetAddress对象。 - 也可以传域名,比如
"www.baidu.com",它会自动进行 DNS 解析。 - 如果 IP 格式不对或域名解析失败,会抛出
UnknownHostException。
'127.0.0.1'
InetAddress.getByName()
InetAddress 对象
封装了 IP 信息
'www.baidu.com'
先 DNS 查询
得到真实 IP
InetAddress 对象
发送请求
java
socket.send(requestPacket)
与服务器端一样,send() 是非阻塞的,数据交给操作系统就返回了。
接收响应
java
DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
socket.receive(responsePacket); // 阻塞等待服务器返回
String response = new String(responsePacket.getData(), 0, responsePacket.getLength());
System.out.println(response);
与服务器端的逻辑完全对称:
- 先准备一个空的
DatagramPacket,大小为 4096 字节。 - 调用
receive()阻塞等待,直到服务器返回数据。 - 用
getData()+getLength()解析出有效数据。
为什么客户端也需要 receive() 阻塞? ⭐
Server Client Server Client 调用 receive() 进入阻塞状态 处理中... receive() 解除阻塞 拿到响应数据 send(requestPacket) send(responsePacket) 打印响应
如果客户端不发请求就调用 receive(),或者服务器没返回响应,客户端会一直阻塞 在那里。这其实是一种天然的同步机制,保证了请求-响应的对应关系。
服务器与客户端配合运行全景图
服务器 0.0.0.0:9090
客户端 127.0.0.1:随机端口
网络
网络
new DatagramSocket
从控制台读输入
构造 requestPacket
目的: 127.0.0.1:9090
socket.send 发送请求
socket.receive 阻塞等待响应
解析并打印响应
new DatagramSocket 9090
socket.receive 阻塞等待请求
解析请求
process 计算响应
构造 responsePacket
目的: 客户端IP+端口
socket.send 发送响应
补充:Socket 需要显式关闭吗?
文件需要关闭,但这个问题要看 对象的生命周期。对于上面这个服务器
- 服务器的
socket是成员变量,伴随整个进程存在。 - 进程退出时,操作系统会自动回收 文件描述符表中所有资源,不需要手动
close()。 - 但如果是临时创建的 socket,用完就应该调用
close()释放。
UDP 词典服务器
UdpDictServer(词典服务器)
UdpEchoServer(回显服务器)
继承
super调用
完全复用
被覆盖
socket 成员变量
构造方法:绑定端口
start():主循环
process():原样返回
dict 成员变量(新增)
构造方法:先调 super(port),再初始化词典
start():完全继承
process():重写!查词典翻译
核心思想:
UdpEchoServer中已经把网络通信的框架写好了:收请求 → 调process()→ 发响应。- 子类只需要重写
process()方法,就能改变服务器的业务逻辑。
java
import java.io.IOException;
import java.net.SocketException;
import java.util.HashMap;
/**
* UDP 词典服务器
* 继承自 UdpEchoServer,复用了网络通信框架
* 只需要重写 process() 方法,把"原样返回"改成"查词典翻译"
*/
public class UdpDictServer extends UdpEchoServer {
// 词典:用 HashMap 存储,键是中文词条,值是英文翻译
// HashMap 查找速度快(O(1)),适合做词典
private HashMap<String, String> dict = new HashMap<>();
/**
* 构造方法
* @param port 服务器要绑定的端口号
* @throws SocketException 如果端口被占用或没有权限,抛出此异常
*/
public UdpDictServer(int port) throws SocketException {
// 第一步:调用父类构造方法
// 父类会执行 socket = new DatagramSocket(port),完成端口绑定
super(port);
// 第二步:初始化词典,添加示例词条
dict.put("小猫", "cat"); // 小猫 → cat
dict.put("小狗", "dog"); // 小狗 → dog
dict.put("小兔子", "rabbit"); // 小兔子 → rabbit
dict.put("小鸭子", "duck"); // 小鸭子 → duck
// 实际项目中,词条可以从文件、数据库或远程 API 加载
}
/**
* 重写父类的 process() 方法
* 父类中是 "原样返回",这里改成 "查词典翻译"
*
* @param request 客户端发来的请求,例如 "小猫"
* @return 查到的翻译结果,如果没找到则返回 "未找到该词条"
*/
@Override
public String process(String request) {
// getOrDefault(key, defaultValue):
// 如果 key 在 HashMap 中存在,返回对应的 value
// 如果 key 不存在,返回 defaultValue("未找到该词条")
// 避免了 get() 返回 null 需要额外判断的问题
return dict.getOrDefault(request, "未找到该词条");
// 等价于下面的代码:
// String result = dict.get(request);
// if (result != null) {
// return result; // 找到了,返回翻译
// } else {
// return "未找到该词条"; // 没找到,返回提示信息
// }
}
/**
* 程序入口
* 创建词典服务器并启动
*/
public static void main(String[] args) throws IOException {
// 创建服务器对象,绑定 9090 端口
// 构造方法中会自动调用 super(9090) 绑定端口,并初始化词典
UdpDictServer server = new UdpDictServer(9090);
// 调用父类 UdpEchoServer 的 start() 方法,进入主循环
// start() 中的逻辑:收请求 → 调用 process() → 发响应
// 这里的 process() 已经是重写后的"查词典版本"
server.start();
}
}
运行效果对比
如果是 UdpEchoServer:9090 UdpDictServer:9090 客户端 如果是 UdpEchoServer:9090 UdpDictServer:9090 客户端 process() → 查词典 → "cat" 如果是回显服务器 process() → 原样返回 词典里没有 → 返回默认值 发送 "小猫" 返回 "cat" 返回 "小猫" 发送 "大象" 返回 "未找到该词条"