UDP 网络编程
Socket 套接字
概念:Socket 套接字,是由操作系统提供用于网络通信的技术,是基于TCP/IP协议的网络通信的基本操作单元。基于Socket 套借字的网络程序开发就是网络编程。
通俗点来说,咱们程序员在写网络程序,其实主要编写的是应用层代码!(因为底层的哪些你动不了,也改变不了)也就是说程序员主要跟应用层打交道比较多,真正要进行网络通信要发送这个数据,需要下层协议给上层协议提供服务,上层协议调用下层协议,所以应用层就要调用传输层,那么传输层就需要给应用层提供一组 API。而这组 API 就统称为 Socket API。
注:本身 操作系统 给应用程序提供的 API,就是 C 风格的。(原因很简单,系统本身就是 C/C++ 写的)。而 Java 呢,JDK 针对系统的这些 API 进行了封装,封装成立 Java 风格的 API 。因此我们在调用Java 的 API时,实质上也是在调系统原生的 API 。
分类
我们的 Socket 套接字主要是针对传输层协议划分为如下的三种:
流套接字:使用传输层 TCP 协议。TCP,即传输层控制协议,是传输层协议。
对于字节流来说,可以简单的理解为,传输数据是基于IO流,流式数据的特征就是在IO流没有关闭的情况下是无边界的数据,可以多次发送,也可以分开多次接收。
数据报套接字:使用传输层 UDP 协议。UDP,即用户数据报协议,是传输层协议。
对于数据报来说,可以简单的理解为,传输数据是一块一块的,发送一块数据假如100个字节,必须一次发送,接收方也是必须一次接收100个字节,而不能分100次发送,每次接受1个字节。
原始套接字:原始套接字用于自定义传输层协议,用于读写内核没有处理的IP协议数据。(不用了解这个)
系统给咱们提供了很多组的 Socket API ,主要给大家介绍俩组:
一组是基于 UDP 的 API ,另一组是基于 TCP 的 API 。TCP 和 UDP 这俩个协议的差别很大,各自提供的 API 差异也很大。那么这俩协议各自有啥特点呢?
UDP 协议的特点:
- 无连接
- 不可靠传输
- 面向数据报
- 全双工
- 有接收缓冲区,无发送缓冲区
- 大小受限:一次最多传输64k
无连接:这里的无连接意思是,使用 UDP 通信的双方不需要去刻意保存对端的相关信息,例如像,发短信就相当于是个无连接通信,直接给对方投递信息,不需要接收连接就能通信。
不可靠传输:是指消息发了就发了,不关注结果发没发成功。
面向数据报:是以一个 UDP 数据报为基本单位进行接收/发送的(读写方式不灵活)。
全双工:一条路径,双向通信。(这俩协议在使用 Socket 套接字的时候,就不必考虑单向通信的问题,因为都是全双工。)
TCP 协议的特点:
- 有连接
- 可靠传输
- 面向字节流
- 全双工
- 有接收缓冲区,也有发送缓冲区
- 大小不受限
有连接:这里的有连接意思是,使用 TCP 通信的双方则需要刻意去保存对端的相关信息,例如像,打电话 就相当于是个有连接通信,需要对方先把电话接通了,也就是先把连接接受了才能够进行通信。
可靠传输:不是说发了就100%能够到达对方(这个要求太高了),而是尽可能地传输过去,并且还能够知道自己有没有成功。
面向字节流:是以字节为传输的基本单位,读写方式非常的灵活。
全双工:一条路径,双向通信。(这俩协议在使用 Socket 套接字的时候,就不必考虑单向通信的问题,因为都是全双工。)
此处所说的 有连接/无连接 不是说拿一根绳子,把俩设备绑一块。而是要理解成为,通信双方是否要各自记录对方的信息~
UDP 的API(比TCP的要简单)
UDP 中的 API 主要也就归咎俩个类:一个是 DatagramSocket 类的 API,另一个是DatagramPacket 类的 API,其中Datagram 就是 “数据报”的意思。
Socket ,说明这个对象是一个Socket 对象。
DatagramPacket,说明这个对象就是一个 UDP 数据报。
Socket 对象其实是一个特殊的文件,相当于对应到系统中的一个特殊文件(Socket 文件),Socket 文件并非对应到硬盘上的某个数据存储区域。而是对应到网卡这个硬件设备!!网卡一般是集成在主板上的某个区域上的。
- 有线网卡
- 无线网卡
文件这个词儿,广义上可以指代很多计算机中的软硬件资源,因为操作系统有个基本的思想就是“一切皆文件” ,为了简化系统内核的逻辑设计。狭义的文件是指硬盘上的一块数据存储区域。
所以我们要想进行网络通信,就需要有 Socket 文件这样的对象。借助这个 Socket 文件对象 才能够间接的操作网卡。而这个 Socket 文件对象 就相当于是个遥控器一样的存在。 这就导致了:
1.往这个 Socket 文件对象中写数据,就相当于是通过网卡发送消息。
2.从这个 Socket 文件对象中读数据,就相当于是通过网卡接收消息。
DatagramSocket API
DatagramSocket 是UDP 的Socket,用于发送和接收 UDP 数据报。
DatagramSocket 构造方法:
DatagramSocket 方法:
在 DatagramSocket 的俩个构造方法,第一个方法是无参版本的构造方法,第二个方法是有参版本的构造方法。这俩个方法之间相差了一个参数就是这个 port 端口号 。要这个端口号有什么用?那就是因为此处的 Socket 对象可能被客户端/服务器都会使用。我们的服务器程序这边往往需要在启动的时候明确指定关联一个具体的端口号(因为这个端口号必须要保持固定不变),而客户端这边则不需要明确手动指定,只需要由系统自动分配一个随机空闲的端口号即可(不要求固定)。
在 DatagramSocket 的三个方法中,第一个 receive 是接收,第二个 send 是发送,第三个 close 是关闭文件,因为我们的 Socket 本质上也是一个文件,文件用完了就得记得关闭,如果不关闭,会出现文件资源泄露的问题!!
DatagramPacket API
DatagramPacket 是UDP Socket 发送和接收的“数据报”。
DatagramPacket 的构造方法:
DatagramPacket 类提供的 API 中的第一个构造方法是直接拿一个字节数组来构造了这样的一个方法,第二个构造方法是不光拿了一个字节数组,还拿了一个 address 地址来构造了这样的一个方法。
因此呢,根据设计格式的不同,第一个版本不需要设置地址进去,所以我们通常是用来接收消息;第二个版本需要显式的设置地址进去,通常是要用来发送消息。所谓的发送消息,就是你得要知道这个消息具体是要发送给谁?发给哪个 IP 的主机以及端口是什么,所以就需要具体的地址来指定描述了
DatagramPacket 的方法:
除此之外呢,我们还可以通过 DatagramPacket 提供的方法来获取到地址,端口号,数据列等。
回显服务器-客户端的实现(Echo Server)
基于 UDP Socket 写一个最最最简单的 客户端-服务器 程序。什么是回显服务器(Echo Server)?回显服务器就是客户端发了个请求,然后服务器返回了一个一模一样的响应。回显服务器并没有实际真正的业务处理逻辑,也就是没有针对请求进行加工就返回了与请求一样的结果。
正常一般情况下的服务器主要是做三个核心工作:
- 读取请求并且解析。
- 根据请求来计算响应。(回显服务器省略了这一步)
- 把响应返回到客户端。
输出型参数方法 receive 的介绍 ->:
通常在一般情况下,我们一般见到的都是使用 参数 来作为方法的 “输入”,然后用 方法的返回值 作为方法的“输出”。但是也有的时候,会有例外,像输出型参数这样的方法是使用方法的参数作为返回值。因此,我们只需要给这个 receive 的方法传一个空的 packet 对象,然后再由这个 receive 方法内部把参数的这个空的 packet 进行填充即可。
举个栗子:输出型参数方法虽然很抽象,但是这就好比你去食堂打饭一样,你带一个空的饭盒,然后递给大妈一个空的饭盒,然后大妈相当于是哪个方法给你咔咔一顿盛饭,交给你的饭盒就是盛满饭的饭盒了~所以输出型参数的方法是把输出结果填充到你传来的参数里+面去了。
当代码写到这里的时候,也就是 receive 这一行,这个时候一旦服务器一启动的话,调用一下 start 方法,然后代码回立即 “刷”的一下子执行到了 receive 这里。要是此时,客户端还没有发来数据请求的话,怎么办?此时 receive 就会阻塞!!这里的阻塞就会一直持续到客户端真的有数据发送过来!!
这个 requestPacket 里面就是存放客户端的请求数据,客户端要是发送来一个“来份蛋炒饭”,此时requestPacket 里面就是这个内容。
这一行代码,创建了个 requestPacket 对象,实际上是定义创建了一个空的对象(里面的字节数组是全 0),这个空的对象持有了一个空的 字节数组(只有内存空间,里面没有任何有意义的数据)。receive 参数类型是 DatagramPacket,是引用类型。因此receive 方法的内部,是针对参数进行了修改,外部也是生效的(因为DatagramPacket对象,是引用类型)!通过 receive 执行之后,可以从网卡上读取数据,就可以把读到的数据 放到/填充到 这个参数(空的字节数组壳子)中去了。
啥时候外部变?啥时候外部不变,看你是否进行了“解引用操作”(C语言中的内容)?指针 * 是解引用运算,在 Java 中,[] 和 . 是解引用操作,直接用等号 = 修改不是,所以不能够改变实参!内部要想影响到外部,就需要通过解引用操作来进行的~
这个操作其实并非是必须的。此处这里只是为了后续代码简单方便,就构造了一个 String,就拿着 requestPacket 中持有的字节数组来进行构造。就比如说,如果客户端发来的数据是“老板来一份蛋炒饭”,此时这个数据就会以二进制的形式躺在那个 requestPacket 中的字节数组中,把这个字节数组拿出来重新构造一个 String,这个 String 的内容就是 “老板来一份蛋炒饭”。byte 数组里面存的就是这些二进制数据(16进制是二进制的简化表示,一个16进制数字对应四个二进制位)。byte 数组里面存的是这些二进制数字,然后转成 String 自然就是 字符串了。另外 requestPacket 开辟的空的字节数组空间只能使用的 byte 类型,因为这是 API 要求的。
后面的俩个参数意思是,在前面的代码里也写道,我们开头是给 requestPacket 分配了 4096 这样大小的空间,而这些4096大小的空间并非实际全都用上了,实际有多长还得看 requestPacket.getLength() 的大小。也就是 requestPacket 最大空间是 4096,实际占的空间比较小。转成 String 的时候要从字节数组byte[] 的 0号下标开始,一直转到 getLenth() 实际用到的字节数组长度下标即可,把这一段用来构造成 String。这里的 String 就是通过解析二进制变成字符串的。
Socket 是文件,就相当于是你点餐用的这个话筒,我们可以通过这个话筒进行网络通信。
packet 是要传输的数据报,这就是端上来的这个大饼子。
SocketAddress 这个方法是同时包含了 IP 和 端口号的方法。
客户端 - 服务器交互模型的建立:
客户端 send 请求 ——> 服务器 receive 请求;
服务器 send 请求 ——> 客户端 receive 请求。
这个构造,也是把请求的 String 类型数据构造成 DatagramPacket,一方面需要把 String 转成 getBytes字节数组;另一方面,需要指定服务器的 ip 和 端口。此处不是通过 InetAddress 来直接构造了,而是分开进行设置的。一方面是设置了字符串的 ip,另一方面设置了端口号。
在服务器响应那边的 DatagramPacket,是属于直接从 packet 里取出的 InetAddress 对象,然后这个对象是本身就包含了 ip 和 端口号。
交互流程图:
第一步:服务器先启动运行,执行到 receive 这个方法就会自动阻塞,然后服务器在这里一直等待请求。
第二步:客户端在启动运行之后,会从控制台读取数据,如果一直没有输入那么 Scanner 这个方法就会一直阻塞等待,输入完成之后然后就打成一个 Packet 包,最后再 send 给服务器。
此时此刻,服务器在收到客户端发来的请求之后,服务器就会解除阻塞,然后服务器和客户端都会往下同时执行代码。
第三步(客户端-服务器并行执行):客户端这边在 send 完给服务器请求之后,继续往下走,走到 receive 读取响应时,又会阻塞等待。
服务器这边则会从 receive 返回,读到网卡里客户端发来的请求数据之后,往下走到 process 方法生成最终响应,再往下走到 send,并且再同时打印日志~
第四步(客户端-服务器并行执行):客户端这边真正收到来自服务器 send 回来的数据之后,接触阻塞,执行下面的打印操作。
服务器进入下一轮循环,再次阻塞到 receive 这里,等待客户端的下一个请求~
第五步:客户端然后也继续进入下一轮循环,阻塞在 Scanner.next 这里,等待用户输入的新的数据~
上述流程虽然初步看起来非常的复杂,但是所有的服务器客户端程序基本都是这样的实现模式!
main 方法里的代码
服务器:
// 服务器端的 main 方法
public static void main(String[] args) throws IOException {
UdpEchoServer udpEchoServer = new UdpEchoServer(9090);
udpEchoServer.start();
}
服务器在进行启动的时候,一定要指定一个具体固定的端口号,我们这里假设了 9090 固定端口号作为服务器端口。
客户端:
// 客户端的 main 方法
public static void main(String[] args) throws IOException {
UdpEchoClient udpEchoClient = new UdpEchoClient("127.0.0.1",9090);
udpEchoClient.start();
}
客户端这里在进行启动的时候,需要绑定目的服务器的端口号 9090,同时由于我们这个代码的 客户端 和 服务器 是在同一个主机上,所以我们可以使用 环回IP “127.0.0.1”。如果是不同主机的话,需要写服务器的实际 IP。
执行结果:
**
**
第一幅图是客户端,第二幅图是服务器。
由此可见,上述通信过程中,站在客户端发送请求的角度看:
源 IP 是 127.0.0.1
源 端口 是 50167(这个端口号我没有指定,这就是系统自动给客户端分配的空闲随机端口!!)
目的 IP 是 127.0.0.1
目的 端口 是 9090
回显程序代码:
服务器
package network;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
import java.nio.charset.StandardCharsets;
// 回显服务器代码
// 以下代码由于 process 方法中没有涉及到任何逻辑业务流程,只是单纯的 API 使用,是最简单的网络交互模型,
public class UdpEchoServer {
// 需要先定义一个 socket 对象。
// 通过网络通信,必须要使用 socket 对象,因为网络通信我们要去操控网卡,socket 相当于一个遥控器。
private DatagramSocket socket = null;
// 这里为什么会抛出一个异常?
// 因为在绑定一个端口的时候,不一定就能成功!!有些情况下会失败,失败了就抛出 SocketException 这个异常。
// 什么时候会失败,那就是如果某个端口已经被别的进程占用了,此时这里的绑定操作就会出错。
// 换句话来说,同一时刻,同一个主机,一个端口,只能被同一个进程绑定。
public UdpEchoServer(int port) throws SocketException {
// 构造 socket 的同时并指定要关联/绑定的端口。
socket = new DatagramSocket(port);
}
// 启动服务器的主逻辑。
public void start() throws IOException {
System.out.println("服务器已启动!");
// 之所以服务器这边不停循环是因为服务器要不停的向客户端拿请求数据。服务器要时刻准备好接收请求,然后提供服务。
while (true) {
// 每次循环,需要做三件事情:
// 1.读取请求并解析
// 构造一个空的饭盒
DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
// 食堂大妈向你的饭盒里面盛饭(饭是从网卡上面来的)
socket.receive(requestPacket);
// 为了方便处理这个请求,我们把数据包转成 String 类型,而且 String 我们也比较熟悉。
String request = new String(requestPacket.getData(),0,requestPacket.getLength());
// 2.根据请求计算响应(此处省略这个步骤)
String response = process(request);
// 3.把响应结果写回到客户端
// 根据 response 字符串,构造一个 DatagramPacket.
// 并且这里和请求的 Packet 不同,此处构造响应的时候,需要指定这个包要发给谁。
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),response.getBytes().length,
// requestPacket 是从客户端这里收来的,所以 getSocketAddress 就会得到客户端的 ip 和 端口。
requestPacket.getSocketAddress());
// 返回响应结果给客户端。
socket.send(responsePacket);
System.out.printf("[%s:%d] req: %s,resp: %s\n",requestPacket.getAddress().toString(),
requestPacket.getPort(),request,response);
}
}
// 这个方法希望是根据请求计算响应。
// 由于咱们写的是个 回显 程序,请求是啥,响应就是啥!!
// 如果后续写个别的服务器,不再回显了,而是有具体的业务了,就可以修改这个 process 方法,根据需要来重新构造响应。
// 之所以单独把这个方法拎出来,因为这是一个服务器中的关键环节!!以后只需更改这个方法里面的内容即可~
public String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
UdpEchoServer udpEchoServer = new UdpEchoServer(9090);
udpEchoServer.start();
}
}
客户端
package network;
import java.io.IOException;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;
public class UdpEchoClient {
private DatagramSocket socket = null;
private String serverIP;
private int serverPort;
// 客户端在启动的时候,需要知道服务器是在哪里!!
public UdpEchoClient(String serverIP,int serverPort) throws SocketException {
// 对于客户端程序来说,不需要手动指定显示关联端口了。
// 但不代表客户端没有端口,而是系统自动分配了个空闲的端口。
socket = new DatagramSocket();
this.serverIP = serverIP;
this.serverPort = serverPort;
}
public void start() throws IOException {
// 通过这个客户端可以多次和服务器程序进行交互。
Scanner scan = new Scanner(System.in);
while (true) {
// 1.先从控制台读取一个字符串过来
// 先打印一个提示符,提示用户要输入内容
System.out.print("-> ");
String request = scan.next();
// 2.把字符串构造成 UDP packet,并进行发送
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),request.getBytes().length,
InetAddress.getByName(serverIP),serverPort);
socket.send(requestPacket);
// 3.客户端尝试读取服务器返回的响应
DatagramPacket responsePacket = new DatagramPacket(new byte[4096],4096);
socket.receive(responsePacket);
// 4.把响应数据转换成为 String 类型显示出来。
String response = new String(responsePacket.getData(),0,responsePacket.getLength());
System.out.printf("req: %s,resp: %s\n",request,response);
}
}
public static void main(String[] args) throws IOException {
UdpEchoClient udpEchoClient = new UdpEchoClient("127.0.0.1",9090);
udpEchoClient.start();
}
}
虽然我们现在这个程序已经是能够跑起来了,并且也没有啥太大问题,但是这个程序客户端和服务器都是在我自己这个主机上,也即是同一个主机之间的通信。Socket 编程是完全可以跨主机进行通信的!提供大家一个思路:当你把我写的 UDP 服务器程序 达成一个 jar包之后,部署到你的云服务器上时,本地机器就可以和远程的云服务器主机之间进行通信,也就实现了跨主机通信。
词典翻译器
上面我们已经实现好的回显程序,其实本身而言是没有什么实在意义的!(因为响应和请求是一模一样的)
那么如何写一个能够提供实在价值的服务器程序呢?
那就是它的响应和请求不一样了,响应是根据不同的请求计算而得到的~
这个 process 方法,就是根据请求计算响应的步骤。
接下来我们来实现一个简单的单词服务器。请求是一个英文单词,响应是这个单词的中文翻译。翻译的本质就是“查表”。
如: dog => 小狗 cat => 小猫
代码详情:
package network;
import java.io.IOException;
import java.net.SocketException;
import java.util.HashMap;
import java.util.Map;
// 此处使用继承,目的是为了复用之前的服务器代码。
public class UdpDictServer extends UdpEchoServer {
private Map<String,String> dict = new HashMap<>();
public UdpDictServer(int port) throws SocketException {
super(port);
dict.put("dog","小狗");
dict.put("cat","小猫");
dict.put("duck","小鸭子");
dict.put("bird","小鸟");
dict.put("pig","小猪");
// .........可以无限的添加很多很多数据,有道词典和我们相比,就是人家的这个表更大一些!!
}
@Override
public String process(String request) {
return dict.getOrDefault(request,"抱歉,该单词没有查到!");
}
public static void main(String[] args) throws IOException {
UdpDictServer udpDictServer = new UdpDictServer(9090);
udpDictServer.start();
}
}
运行结果:文章来源:https://www.toymoban.com/news/detail-804363.html
客户端
服务器
现在目前咱们的 UDP 服务器只有一个 main 线程,所以还不会涉及到线程安全的问题,但是真实的服务器通常都是多线程的,大概率还是会可能用到 ConcurrentHashMap 的!当前咱们这个代码还好,或许以后会用得到的!文章来源地址https://www.toymoban.com/news/detail-804363.html
到了这里,关于网络原理(三)—— UDP网络编程的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!