一、Java数据报套接字通信模型
对于UDP协议来说,具有无连接,面向数据报的特征,即每次都是没有建立连接,并且一次发送全部数据报。
java中使用UDP协议通信,主要基于DatagramSocket类来创建数据报套接字,并使用DatagramSocket作为发送或接收的UDP数据报。对于一次发送及接收UDP数据报的流程如下:
以上只是一次发送端的UDP数据报发送,及接收端的数据报接收,并没有返回的数据。也就是只有请求,没有响应。对于一个服务端来说,重要的是提供多个客户端的请求处理响应,流程如下:
二、UDP数据报套接字编程
Java把系统原生的API进行了封装,供我们使用,以下是API介绍,主要核心的类有两个:DatagramSocket,DatagramPacket。
1、DatagramSocket
操作系统中,有一类文件就叫做 socket 文件,普通文件、目录文件是在硬盘上的,socket 文件抽象表示了 “网卡” 这样的硬件设备。
进行网络通信最核心的硬件设备就是 网卡:
通过网卡发送数据,就是写 socket 文件。
通过网卡接受数据,就是读 socket 文件。
DatagramSocket是 UDP 的 Socket,负责对 socket 文件的读写,也就是借助网卡发送和接收数据报。
(1)DatagramSocket构造方法
方法签名 | 方法说明 |
DatagramSocket() | 创建一个UDP数据报套接字的Socket,绑定到本机 任意一个随机端口(一般用于客户端) |
DatagramSocket(int port) | 创建一个UDP数据报套接字的Socket,绑定到本机 指定的端口(一般用于服务端) |
(2)DatagramSocket方法
方法签名 | 方法说明 |
void receive(DatagramPacket p) | 从此套接字接受数据报(如果没有收到数据报,该 方法会阻塞等待) |
void send(DatagramPacket p) | 从此套接字发送数据包(不会阻塞等待,直接发送) |
void close() | 关闭此数据报套接字 |
2、DatagramPacket
DatagramPacket是UDP Socket发送和接收的数据报。
UDP面向数据报,每次发送接受数据的基本单位,就是一个UDP数据报,表示了一个UDP数据报。
(1)DatagramPacket构造方法
方法签名 | 方法说明 |
DatagramPacket(byte[] buf, int length) | 构造一个DatagramPacket以用来接收数据报,接收的 数据保存在字节数组(第一个参数buf)中,接收指定 长度(第二个参数length) |
DatagramPacket(byte[] buf, int offset, int length, SocketAddress address) |
构造一个DatagramPacket以用来发送数据包,发送的 数据为字节数组(第一个参数buf)中,从0到指定长度 (第二个参数length)。address指定目的主机IP和端口号 |
(2)DatagramPacket方法
方法签名 | 方法说明 |
InetAddress getAddress() | 从接收的数据报中,获取发送端主机IP地址; 或从发送的数据报中,获取接收端主机IP地址 |
int getPort() | 从接收的数据报中,获取发送端主机的端口号; 或从发送的数据报中,获取接收端主机的端口号 |
byte[] getData() | 获取数据报中的数据 |
构造UDP发送的数据报时,需要传入SocketAddress,该对象可以使用InetSocketAddress来创建。
3、InetSocketAddress
InetSocketAddress (SocketAddress的子类) 构造方法:
方法签名 | 方法说明 |
InetSocketAddress(InetAddress addr, int port) | 创建一个Socket地址,包含IP地址和端口号 |
三、代码示例:回显服务器
在网络编程中,回显服务器是最简单的程序了,相当于网络编程的 hello world。
功能:服务器接受客户端的请求,返回响应。(根据客户端发出的请求,返回啥响应,要更具实际的业务场景来编写代码,这里的案例没啥业务逻辑)
1、服务器代码
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) { //1、读取请求并解析 DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096); socket.receive(requestPacket); //把读到的字节数组,转换成字符串,方便后面的逻辑处理 String request = new String(requestPacket.getData(), 0, requestPacket.getLength()); //2、根据请求计算响应(对于回显服务器来说,这一步啥也不用做) String response =process(request); //3、把响应返回给客户端 //构造一个 DatagramPacket 作为响应对象 DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length, requestPacket.getSocketAddress()); socket.send(responsePacket); //打印日志 System.out.printf("[%s : %d] req: %s resp: %s", requestPacket.getAddress().toString(), requestPacket.getPort(), request, response); System.out.println(); } } //计算响应计算方法(这里没逻辑) public String process(String request) { return request; } public static void main(String[] args) throws IOException { UdpEchoServer udpEchoServer = new UdpEchoServer(9090); udpEchoServer.start(); } }
代码解析
第一步需要创建 DatagramSocket 对象,接下来要操作网卡,都是通过 socket 对象来完成的。socket 对象是在内存中的,操作这个对象就能影响到网卡,类似遥控器。
这个程序需要绑定 / 关联上一个操作系统上的端口号,所以构造方法里传的就是端口号。
端口号:是一个整数,用来区分一个主机上进行网络通信的程序。一个主机上的一个端口号只能被一个进程绑定,如果一个端口被进程A绑定了,如果进程B绑定这个端口,就会失败,除非进程A释放了这个端口,进程B才能绑定上。
反过来,一个进程可以绑定多个端口,很常见。端口号和 socket 对象是一一对应的,如果一个进程中有多个socket对象,那这个进程就能绑定多个端口了。
如图的异常:
该异常是网络很常见的异常,通常表示 socket 创建失败,比如说端口号被别的进程占用了,就会抛出该异常。
如图的循环:
对于服务器来说,需要不停的收到请求,返回响应。一个服务器在单位时间能处理的请求越多,那这个服务器就越厉害。
服务器往往是 7 * 24 小时运行的,要持续不断的运行下去,所以,这里的 while(true) 没有退出的必要,如果要退出,简单粗暴的办法就是 “杀进程”,在 linux 中有个结束进程的命令:kill,带有 -9 是强制退出,不带的话就是告诉进程,你该挂了,而进程可以选择继续工作,也可以选择自挂东南枝。所以才有了 杀进程 这一说法。
对于内部的逻辑要写的就是如下图:
receive方法里的参数如图:
里面放的参数是 DatagramPacket 对象,而此处它是 “输出型参数”,前面的文件IO涉及到了,像InputStream里的read方法,里面参数也是输出型参数(字节数组)。
实际上,DatagramPacket 内部就会包含一个字节数组。
如图的 DatagramPacket:
通过这个字节数组来保存收到的消息正文 (应用层数据包) ,也就是 UDP 数据报的载荷部分。后面的数字大小,就是这个 载荷 有多长,可以灵活设置。
如图:
这里的 receive 就能从网卡读到一个 UDP 数据报,这个数据报是被放到 requestPacket 对象中的,其中的 载荷部分 就放到 requestPacket 内置的 字节数组 中了,另外,报头部分也被 request 的其他属性保存了。
除了 UDP 报头之外,还有其他信息,比如 收到的数据源IP,可以通过 requestPacket 知道源IP 和 源端口。
如果服务器没有收到请求,服务器就会在这里阻塞,等到有客户端的请求过来了,再receive,然后执行下面的逻辑。
如图,拿到数据报后的解析:
这里是基于字节数组构造出的String的,字节数组里的内容不一定是二进制数据,也可能是文本数据,把文本数据交给 String 来保存,也是恰到好处,就算是二进制数据,Java的String 也是可以保存的。
上面的 0 是指的是从 0 号位置开始构造String。
getLength 是获取字节数组中有效数据的长度,用这么长来构造String,这里的有效长度,不一定是4096,4096是最大长度,一定要使用有效长度来构造,如果使用4096,就会生成一个非常长的 String ,后半部分都是空白。
客户端发来的请求数据,说是存在socket文件中,但要注意,此处的数据是在 socket 的内存缓冲器的,并不是在硬盘上,如图:
如图:
这个代码,要根据请求构造 / 计算响应,通过 process 方法来完成这个工作。
由于此处是回显服务器,所以就单纯的 return 了,如果是一些具有特点业务的服务器,process 就可以写其他任何你想要的逻辑了。
当计算完响应,可以 send 时,如图:
这里的 send 也需要放 DatagramPacket 的对象,作为参数。
DatagramPacket 的对象参数,第一个:不是空白字节数组,直接把 String 包含的字节数组给拎过来了;
第二个参数:单位是字节,得到字节的长度;这里不能写成:response.length(),这指的是字符个数 ,单位是字符;字符和字节个数肯定是不一样的,除非是标准ASCII表的字符
第三个参数:requestPacket是客户端发生过来的数据报,得到INetAddress 对象,这个对象包含了 IP 和 端口号,也就是和服务器对端(对应的客户端)的 IP 和 端口。这里是把请求中的源 IP 和 源端口,作为响应的 目的IP 和 目的端口,此时就可以做到把响应返回给客户端了。
从上面代码中可以看到:
UDP 是无连接的通信:UDP socket 自身不保存对端的 IP 和 端口,而是在每个数据报中都有一个,另外代码中也没有 “建立连接” 、“接受连接” 的操作。
不可靠传输:上面代码中体现不到。
面向数据报:send 和 receive 都是以 DatagramPacket 为单位的。
全双工:一个 socket 既可以发送,也可以接收。
打印日志那里:打印客户端 IP 是以点分十进制 的形式打印的,如图:
后面陆续是打印客户端端口号,打印请求字符串,打印响应字符串。
我们设置服务器端口时的注意事项,如图:
上面的端口号你可以指定任何你想要的端口,但也是有范围的,通常来说,使用的端口号x范围是:1024 < x < 65535。
但是也有前提,确保你当前这个端口在当前机器上没有被其他端口占用,如果指定了端口,创建服务器失败的话,换个端口就好了。
2、客户端代码
public class UdpEchoClient { private DatagramSocket socket = null; String serverIp; int serverPort; //此处使用的IP是点分十进制的风格,例如:192.168.0.0 public UdpEchoClient(String serverIp, int serverPort) throws SocketException { socket = new DatagramSocket(); this.serverIp = serverIp; this.serverPort = serverPort; } public void start() throws IOException { System.out.println("客户端启动"); Scanner scanner = new Scanner(System.in); while (true) { System.out.print("->"); //要做四个事情 //1、从控制台读取要发送的请求数据 if(!scanner.hasNext()) { break; } String request = scanner.next(); //2、构造请求并发送 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 response = new String(responsePacket.getData(), 0, responsePacket.getLength()); System.out.println(response); } } public static void main(String[] args) throws IOException { UdpEchoClient udpEchoClient = new UdpEchoClient("127.0.0.1", 9090); udpEchoClient.start(); } }
代码解析
首先要创建 socket 对象,如图:
此处是不需要手动指定端口号,让系统随机分配一个端口号即可,而构造方法中传的是服务器IP 和 服务器端口,所以要保存一下,把它们设成客户端的属性。而源 IP 就是客户端的 本机IP,源端口就是 系统分配的空闲端口。
对于客户端来说,系统随机分配的端口,是空闲的,但如果手动指定端口,是可用,但有可能被别的进程占用。
同理,服务器端口不一定就100%不会被别的进程占用,但服务器这个机器,是在程序员手里的,程序员可以事先知道服务器上都有哪些端口被使用,就比较有谱;普通用户千千万,其中电脑上的环境也会千差万别,天知道用户按照了什么鬼程序,把你的服务器端口给占用了,你的程序也会因为端口的绑定失败而无法启用,用户就会怪在你头上,说你代码写的不行。
输入请求,如图:
注意:从控制台读取,使用 scanner 读取字符串,最好是使用 next,而不是nextLine.
如果使用 nextLine 读取,就需要手动输入换行符:enter键 来控制。由于 enter键 不仅仅会产生 \n 字符,还好产生其他字符,就会导致读取到的内容容易出问题。
使用 next 其实是以 “空白符” 作为分隔符,分隔符包括但不限于 换行,回车,空格,制表符,垂直制表符...
如果是从文件中读取的,那就无所谓了,因为文件中有各种字符。
构造请求并发送,如图:
此处创建的 DatagramPacket 对象,里面参数放的先后分别是:请求字符串的字节数组,字节数组的长度,服务器的IP地址,服务器端口。
读取服务器的响应,如图:
是用新的字节数组存放响应的载荷。
把响应打印到控制台上,如图:
先把响应构造成字符串,再打印。这里 responsePacket.getData() 是得到响应的字节数组。
3、创建DatagramPacket三种不同的参数场景
如图:
第一种:服务器接收客户端发来的请求,所以要存放在空白数组中。
第二种:服务器构造好响应,返回给客户端的。
第三种:客户端构造好请求,发送给服务器的。
4、执行代码
启动服务器和启动客户端,如图:
客户端分别输入 hello 和 world,如图:
服务器这边的显示:
我们结束客户端程序,在执行客户端程序,不动服务器,输入你好,如图:
客户端:
服务器:
可以看到,客户端的端口号变了,因为终止了上一次的客户端程序,又重新执行客户端程序,系统会随机分配一个空闲端口给客户端使用,所以是不一样的。
5、客户端和服务器交互的过程
过程如图:
下面是画图解释其中的过程:
6、字典服务器
基于回显服务器,我们可以改进成字典服务器,只需要创建一个字典服务器,然后继承于这个回显服务器,重写process方法,里面写我们想要的逻辑业务。
代码示例:
public class UdpDictServer2 extends UdpEchoServer2{ HashMap<String, String> hashMap = new HashMap<>(); public UdpDictServer2(int serverPort) throws SocketException { super(serverPort); hashMap.put("cat", "小猫"); hashMap.put("dog", "小狗"); hashMap.put("chicken", "坤坤"); //这里还可以无限添加英汉键值对 //像是有道云这种专业的词典程序,本质就是包含了一个这样的非常大的,几十万个键值队的hashMap } //process要重写,插入我们自己的业务(查询单词),进行翻译 public String process(String request) { return hashMap.getOrDefault(request, "没有你要查找的单词"); } public static void main(String[] args) throws IOException { UdpDictServer server = new UdpDictServer(9090); //start是从父类继承下来的 server.start(); } }
UdpDictServer类里
UdpEchoServer类里:
这里的process是this的,当前类里的,看起来是UdpEchoServer类的引用。实际上指向的是UdpDictServer的实例,执行的上述下面的逻辑:
这里就会触发多态,实际会执行子类的方法。