UDP是Socket中重要组成部分,下面主要想带大家一起了解什么是UDP,以及UDP可以用来做什么。
1. UDP是什么?
UDP全称为User Datagram Protocol,缩写为UDP,称为用户数据报协议,也叫用户数据报文协议。它是一个简单的面向数据报的传输层协议,正式规范为RFC 768。在上一篇Socket网络编程理论知识中介绍了UDP是一种面向无连接的协议,因此,在通信时发送端和接收端不用建立连接。
UDP通信的过程就像是货运公司在两个码头间发送货物一样,在码头发送和接收货物时都需要使用集装箱来装载货物,UDP通信也是一样,发送和接收的数据也需要使用“集装箱”进行打包。
UDP为什么不可靠呢?
- 它一旦把应用程序发给网络层的数据发送出去,就不保留数据备份
- UDP在IP数据报的头部仅仅加入了复用和数据校验
- 发送端生产数据,接收端从网络中抓取数据
- 结构简单,无检验,速度快,容易丢包,可广播
2. 基于UDP协议的能做什么呢?
2.1 网页或者APP的访问
原来访问网页和手机APP都是基于HTTP协议的。HTTP协议是基于TCP的,建立连接都需要多次交互,对于时延比较大的目前主流的移动互联网来讲,建立一次连接需要的时间会比较长,然而既然是移动中,TCP可能还会断了重连,也是很耗时的。而且目前的HTTP协议,往往采取多个数据通道共享一个连接的情况,这样本来为了加快传输速度,但是TCP的严格顺序策略使得哪怕共享通道,前一个不来,后一个和前一个即便没关系,也要等着,时延也会加大。
而QUIC(全称Quick UDP Internet Connections,快速UDP互联网连接)是Google提出的一种基于UDP改进的通信协议,其目的是降低网络通信的延迟,提供更好的用户互动体验。
QUIC在应用层上,会自己实现快速连接建立、减少重传时延,自适应拥塞控制,是应用层“城会玩”的代表。这一节主要是讲UDP,QUIC我们放到应用层去讲。
2.2 流媒体的协议
现在直播比较火,直播协议多使用RTMP,这个协议我们后面的章节也会讲,而这个RTMP协议也是基于TCP的。TCP的严格顺序传输要保证前一个收到了,下一个才能确认,如果前一个收不到,下一个就算包已经收到了,在缓存里面,也需要等着。对于直播来讲,这显然是不合适的,因为老的视频帧丢了其实也就丢了,就算再传过来用户也不在意了,他们要看新的了,如果老是没来就等着,卡顿了,新的也看不了,那就会丢失客户,所以直播,实时性比较比较重要,宁可丢包,也不要卡顿的。
另外,对于丢包,其实对于视频播放来讲,有的包可以丢,有的包不能丢,因为视频的连续帧里面,有的帧重要,有的不重要,如果必须要丢包,隔几个帧丢一个,其实看视频的人不会感知,但是如果连续丢帧,就会感知了,因而在网络不好的情况下,应用希望选择性的丢帧。
还有就是当网络不好的时候,TCP协议会主动降低发送速度,这对本来当时就卡的看视频来讲是要命的,应该应用层马上重传,而不是主动让步。因而,很多直播应用,都基于UDP实现了自己的视频传输协议。
2.3 实时游戏
游戏有一个特点,就是实时性比较高。
实时游戏中客户端和服务端要建立长连接,来保证实时传输。但是游戏玩家很多,服务器却不多。由于维护TCP连接需要在内核维护一些数据结构,因而一台机器能够支撑的TCP连接数目是有限的,然后UDP由于是没有连接的,在异步IO机制引入之前,常常是应对海量客户端连接的策略。
另外还是TCP的强顺序问题,对战的游戏,对网络的要求很简单,玩家通过客户端发送给服务器鼠标和键盘行走的位置,服务器会处理每个用户发送过来的所有场景,处理完再返回给客户端,客户端解析响应,渲染最新的场景展示给玩家。
如果出现一个数据包丢失,所有事情都需要停下来等待这个数据包重发。客户端会出现等待接收数据,然而玩家并不关心过期的数据,激战中卡1秒,等能动了都已经死了。
游戏对实时要求较为严格的情况下,采用自定义的可靠UDP协议,自定义重传策略,能够把丢包产生的延迟降到最低,尽量减少网络问题对游戏性造成的影响。
2.4 IoT物联网
一方面,物联网领域终端资源少,很可能只是个内存非常小的嵌入式系统,而维护TCP协议代价太大;另一方面,物联网对实时性要求也很高,而TCP时延大。Google旗下的Nest建立Thread Group,推出了物联网通信协议Thread,就是基于UDP协议的。
2.5 移动通信领域
在4G网络里,移动流量上网的数据面对的协议GTP-U是基于UDP的。因为移动网络协议比较复杂,而GTP协议本身就包含复杂的手机上线下线的通信协议。如果基于TCP,TCP的机制就显得非常多余。
3. UDP核心API
在UDP通信中有2个常用的类:一个是数据包类DatagramPacket,一个是数据包发送接收器类DatagramSocket
根据API文档的内容,对UDP两个常用类进行分析:
3.1 DatagramPacket
在java中,提供了一个DatagramPacket类,该类的实例对象就相当于一个集装箱,用来封装UDP通信中发送或者接收的数据。
首先需要了解下DatagramPacket的构造方法。在创建发送端和接收端的DatagramPacket对象时,使用的构造方法有所不同,接收端的构造方法只需要接收一个字节数组来存放接收到的数据,而发送端的构造方法不但要存放发送数据的字节数组,还需要指定发送端的IP地址和端口号。
先来了解下DatagramPacket的构造方法:
- 使用该构造方法创建DatagramPacket对象时,指定了封装数据的字节数组和数据的大小,没有指定IP地址和端口号。说明只能用于接收端,不能用于发送端,因为发送端一定要明确指出数据的目的地(IP地址和端口号),而接收端不需要明确知道数据的来源,只需要接收即可
- 使用该构造方法创建DatagramPacket对象时,不仅指定了封装数据的字节数组和数据大小,还指定了数据包的目标IP地址(address)和端口号(port)。该对象通常用于发送端,因为在发送数据时必须指定接收端的IP地址和端口号,就好像发送货物的集装箱上面必须标明接收人的地址一样。
其中SocketAddress对象封装了IP地址+端口号,相当于InetAddress+端口号port。
// 从SocketAddress子类的构造方法可以看出
InetSocketAddress(InetAddress addr, int port)
了解了DatagramPacket构造方法,接下来对DatagramPacket类中的常用方法进行说明:
3.2 DatagramSocket
DatagramPacket数据包的作用就如同是“集装箱”,可以将发送端或者接受端的数据封装起来。然而运输货物只有“集装箱”是不够的,还需要有码头。在程序中需要实现通信只有DatagramPacket数据包也同样不行,为此JDK中提供一个DatagramSocket类。DatagramSocket类的作用就类似于码头,使用这个类的实例对象就可以发送和接收DatagramPacket数据包。
在创建发送端和接收端的DatagramSocket对象时,使用的构造方法有所不同。
先来了解下DatagramSocket构造方法:
- 该构造用于创建发送端的DatagramSocket对象,在创建DatagramSocket对象时,并没有指定端口号,此时,系统会分配一个没有被其它网络程序所使用的端口号。
- 该构造既可创建接收端的DatagramSocket对象,又可以创建发送端的DatagramSocket对象,在创建接收端DatagramSocket对象时,必须要指定一个端口号,就可以监听指定的端口。
了解了DatagramSocket构造方法,接下来对DatagramSocket类中的常用方法进行说明:
- receive(DatagramPacket p) 接收数据报包,接收到的数据封装到DatagramPacket,还包含发送者的IP地址和发件人机器上的端口号。该方法阻塞,直到接收到数据报。
- send(DatagramPacket p) 发送数据报包,DatagramPacket包括指示要发送的数据,其长度,远程主机的IP地址和远程主机上的端口号的信息。
- close() 关闭此数据报套接字。所有当前阻塞的线程在receive(java.net.DatagramPacket)在此套接字将抛出一个SocketException 。
4. 基于UDP协议的Socket程序函数调用过程,实现简单聊天案例
使用UDP完成一个简易的聊天程序案例:在发送端控制台中输入要发送的消息,接收端接收发送端发来的消息,并在接收端控制台中输出发送端的IP地址、端口号和消息,当发送端输入886,发送端和接收端都结束。
UDP完成接收端程序:
public class UdpReceive {
public static void main(String[] args) throws IOException {
// 1. 创建DatagramPacket对象,用于封装一个字节数组,用于接收数据
byte[] data = new byte[1024]; // 最大长度1024*64=64KB
DatagramPacket receiverPacket = new DatagramPacket(data, data.length);
// 2. 创建DatagramSocket对象,绑定到本地主机上的指定端口
DatagramSocket socket = new DatagramSocket(10002);
while (true) {
// 3. 使用DatagramSocket对象的receive方法,接收数据包
// 该方法阻塞,直到接收到数据报
socket.receive(receiverPacket);
// 4. 拆包
// 返回该数据报发送或接收数据报的计算机的IP地址。
String ip = receiverPacket.getAddress().getHostAddress();
// 返回发送数据报的远程主机上的端口号,或从中接收数据报的端口号。
int port = receiverPacket.getPort();
// 返回要发送的数据的长度或接收到的数据的长度。
int length = receiverPacket.getLength();
String message = new String(data, 0, length);
System.out.println("Receive-> receiver data: " + message + " from " + ip + ":" + port);
if ("886".equalsIgnoreCase(message)) {
// 关闭接受者,不在接收消息
break;
}
}
// 5. 关闭此数据报套接字。
socket.close();
}
}
UDP完成发送端程序:
public class UdpSend {
public static void main(String[] args) throws IOException {
Scanner scanner = new Scanner(System.in);
InetAddress address = InetAddress.getByName("127.0.0.1");
// 2. 创建DatagramSocket对象,系统会分配一个可用的端口号
DatagramSocket socket = new DatagramSocket();
while (true) {
String message = scanner.nextLine(); // 读取输入的数据
byte[] data = message.getBytes();
// 1.创建DatagramPacket对象,用于封装长度为length数据报包发送到指定主机上的指定端口号。
DatagramPacket sendPacket = new DatagramPacket(data, data.length, address, 10002);
// 3.使用DatagramSocket对象中的send方法,发送数据报包
socket.send(sendPacket);
if ("886".equalsIgnoreCase(message)) {
// 结束聊天
break;
}
}
// 4. 关闭此数据报套接字
socket.close();
}
}
5.IP地址和端口号
5.1 IP地址
IP地址是指互联网协议地址(Internet Protocol Address)。IP地址用来给一个网络中的计算机设备做一个唯一的编号。在TCP/IP协议中,这个标识号就是IP地址。目前广泛使用的IP地址是IPv4。
IP地址分类:
- IPv4
它由4个字节大小的二进制数表示,如:00001010000100000010100100000001。由于二进制形式表示的IP地址非常不便记忆和处理,因此通常会将IP地址写成十进制的形式,每个字节用一个十进制数字(0-255)表示,数字间用符号“.”分开,如 “192.168.1.100”。
- IPv6
随着计算机网络规模的不断扩大,对IP地址的需求也越来越多,IPV4这种用4个字节表示的IP地址面临枯竭,因此IPv6 便应运而生了,IPv6采用128位地址长度,每16个字节一组,分成8组十六进制数,表示成fd00:EF01:4023:6507:bb92:e153:ef13:6789。
InetAddress
JDK中提供了一个InetAddress类,该类用于封装一个IP地址,并提供了一系列与IP地址相关的方法:
public class InetAddressDemo {
public static void main(String[] args) throws UnknownHostException {
// 返回本地主机的地址
InetAddress local = InetAddress.getLocalHost();
System.out.println("本机的IP地址:" + local.getHostAddress()); // 172.20.43.73
System.out.println("本机IP地址的主机名:" + local.getHostName()); // YQBMAC-0050
//确定主机名称的IP地址。
InetAddress remote = InetAddress.getByName("218.98.31.235");
System.out.println("remote的IP地址:" + remote.getHostAddress()); // 218.98.31.235
System.out.println("remote的主机名:" + local.getHostName()); // YQBMAC-0050
}
}
IP地址类别:
从上图可知,不同的类别可以通过子网掩码来区分。我们常用的是B类和C类地址。
先来看下UDP的广播和多播相关知识:
- 255.255.255.255为受限的广播地址,即所有网段都能收到,但路由并不会去转发该广播,毕竟所有网段都会接受,所以只有本局域网能够接收到。
- X.X.X.255 为 C 类广播,只有该网段下的才能收到 ,比如 192.168.33.255,那么 192.168.33.X 下的所有网段都能接收到。
- D类IP地址为多播预留。
广播地址的计算方法:
- IP地址与子网掩码进行“与”运算,得到网络地址;
- 子网掩码“取反”运算,然后与网络地址进行“或”运算,得到广播地址;
如:172.17.24.18/20 ,计算其广播地址;
由于该IP的掩码为20个比特位,因此,其掩码地址为:255.255.240.0
IP地址的二进制表示为:10101100.00010001.00011000.00010010
(1)IP地址与子网掩码按位“与”运算 结果:10101100.00010001.00010000.00000000 即:172.17.16.0
(2)子网掩码按位取反结果:00000000.00000000.00001111.11111111
与网络地址或运算结果:10101100.00010001.00011111.11111111 即:172.17.31.255
IP地址构成,由4个字节二进制数据表示,通常转化成十进制形式:
上面看到了受限广播地址,即 255.255.255.255 ,当使用这个地址作为广播地址时,路由器的其他设备都能监听到,但如果A路由器和B路由器想要之间也能通信,比如:
A:ip为192.168.134.7 ,子网掩码为 255.255.255.192
B:ip为192.168.134.100 ,子网掩码也是 255.255.255.192
看到A与B的子网掩码是一样,但其实还是不能通信,因为A与B的广播地址不一样,A广播地址为 192.168.134.63 B广播地址为 192.168.134.127。
5.2 端口号
网络通信,本质上是两个进程(应用程序)的通信。每台计算机都有很多的进程,那么在网络通信时,如何区分这些进程呢?
如果说IP地址可以唯一标识网络中的设备,那么端口号就可以唯一唯一标识设备中的进程(应用程序)了。
端口号是用两个字节表示整数,它的取值范围是0~65535。其中,0~1024之间的端口号用于一些知名的网络服务和应用,普通的应用程序需要使用1024以上的端口号,如果端口号被另一个服务或者程序所占用,会导致当前程序启动失败。
利用协议+IP地址+端口号组合,就可以标识网络中的进程了,那么进程间的通信可以利用这个标识与其它进程进行交互。
6. 案例实操,局域网搜索案例
实现一个局域网搜索案例:
首先绑定到本地主机上的30000端口,当接收到数据包时,拆数据包解析出要发送的端口号,然后随机生产一个序列号,使用解析的端口号发送该序列号。
public class UdpProvider {
public static void main(String[] args) throws IOException {
// 创建DatagramSocket对象,并将其绑定到本地主机上的指定30000端口
DatagramSocket socket = new DatagramSocket(30000);
// 创建DatagramPacket对象,用于封装接收的数据包
byte[] receiveData = new byte[1024];
DatagramPacket receivePacket = new DatagramPacket(receiveData, receiveData.length);
// 接收数据包
// 该方法阻塞,直到接收到数据报
socket.receive(receivePacket);
// 拆数据包
int length = receivePacket.getLength();
String ip = receivePacket.getAddress().getHostAddress();
int port = receivePacket.getPort();
String receivePort = new String(receiveData, 0, length);
System.out.println("Provider-> receive: " + receivePort + " form " + ip + ":" + port);
// 随机生产一个序列号
String sn = UUID.randomUUID().toString();
// 根据接收到端口号,发送数据包
byte[] data = sn.getBytes();
DatagramPacket packet = new DatagramPacket(data, data.length);
packet.setAddress(receivePacket.getAddress()); // 传入接收过来的IP地址
packet.setPort(Integer.parseInt(receivePort));
// 使用DatagramSocket发送数据包
socket.send(packet);
// 释放资源
socket.close();
}
}
绑定到本地主机上的20000端口,启动后,给30000端口发送一个数据包,数据封装20000端口数据。
public class UdpSearch {
public static void main(String[] args) throws IOException, InterruptedException {
CountDownLatch latch = new CountDownLatch(1);
new Thread(new SearchListener(latch)).start();
latch.await();
sendBroadcast();
}
private static void sendBroadcast() throws IOException {
// 创建DatagramSocket对象
DatagramSocket socket = new DatagramSocket();
// 创建DatagramPacket对象,用于封装数据包:数据、IP地址、端口号
byte[] data = "20000".getBytes();
DatagramPacket packet = new DatagramPacket(data, data.length);
packet.setPort(30000); // 数据包发给30000端口
packet.setAddress(InetAddress.getByName("255.255.255.255"));
//使用DatagramSocket的send方法,发送数据包
socket.send(packet);
socket.close();
System.out.println("Search-> 发送广播结束.");
}
private static class SearchListener implements Runnable {
DatagramSocket socket;
CountDownLatch latch;
boolean isClosed = false;
SearchListener(CountDownLatch latch) {
this.latch = latch;
}
@Override
public void run() {
System.out.println("Search-> 已启动...");
latch.countDown();
try {
// 创建DatagramSocket对象,并将其绑定到本地主机上的指定端口。
socket = new DatagramSocket(20000);
while (!isClosed) {
byte[] receiverData = new byte[1024];
// 创建DatagramPacket对象,用于接收数据包
DatagramPacket receiverPacket = new DatagramPacket(receiverData, receiverData.length);
// 使用DatagramSocket对象,接收数据报包。
// 该方法阻塞,直到接收到数据报
socket.receive(receiverPacket);
// 拆接收的数据包
// 获取接收数据报的IP地址
String ip = receiverPacket.getAddress().getHostAddress();
// 获取数据报中的远程主机上的端口号
int port = receiverPacket.getPort();
// 获取接收到的数据的长度。
int length = receiverPacket.getLength();
// 数据缓冲区
byte[] buffer = receiverPacket.getData();
String data = new String(buffer, 0, length);
System.out.println("Search-> " + new Device(ip, port, data));
}
} catch (IOException ignore) {
} finally {
close();
}
}
private void close() {
if (socket != null) {
//关闭数据报套接字。
//所有当前阻塞的线程在receive(java.net.DatagramPacket)在此套接字将抛出一个SocketException 。
socket.close();
}
socket = null;
}
private void exit() {
isClosed = true;
close();
}
}
}
如果我的文章对您有帮助,不妨点个赞鼓励一下(^_^)
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。