Java中的网络编程API一共有两套:一套是UDP协议使用的API;另一套是TCP协议使用的API。这篇文章我们先来介绍UDP版本的API,并尝试来写一个回显服务器(接收到的请求是什么,返回的响应就是什么)。
UDP数据报套接字编程
API介绍
DatagramSocket
DatagramSocket是UDPSocket,用于发送和接收UDP数据报,就相当于网卡的作用。
DatagramSocket的构造方法:
DaragramSocket方法:
DatagramPacket
DatagramPacket是UDPSocket发送和接收的数据报。
DatagramPacket构造方法:
DatagramPacket方法:
构造UDP发送数据报的时候,需要传入SocketAddress(要发送信息的地址)。
补充:
UDPSocket中API的使用,是Java把操作系统中的原生API进行了一层封装。
其中最核心的类就是上面两个,DatagramSocket和DatagramPacket。
DatagramSocket:操作系统中有一类文件,就叫做socket(文件普通文件和目录文件都是存放在硬盘上的,socket文件,就抽象地表示了“网卡”这样的硬件设备,进行网络通信最核心的硬件设备就是网卡(通过网卡发送数据,就相当于写socket文件;通过网卡接收数据,就相当于读socket文件)。
DatagramPacket:UDP面向的是数据报,每次发送、接收数据的基本单位,就是一个UDP数据报,而DatagramPacket就表示了一份UDP数据报。
下面我们通过写一个回显服务器,来认识这些API的使用,并且了解在实际开发中,服务器需要做哪些事情,客户端需要做哪些事情。
回显服务器
回显服务器:客户端发什么请求,服务器就返回什么响应。(并没有什么业务逻辑,实际开发中,我们要返回什么响应是要根据业务逻辑来的)。
服务器端
第一步,先创建DatagramSocket对象,并且通过构造方法为服务器指定一个端口号:
我们的服务器程序一启动,就需要绑定/关联上操作系统的一个端口号,端口号也是一个整数,用来区分一个主机上进行网络通信的程序(一个端口号只能绑定一个程序,反过来,一个程序能够绑定多个端口号,上面抛出的SocketException就是为了处理多个程序绑定了同一个端口号导致Socket创建不成功的情况),在创建服务器时需要我们手动指定一个端口号。
下面我们来写start方法:
对于服务器来说,需要不停地接收请求,返回响应,接收请求,返回响应。(一个服务器单位时间能处理地请求,能返回地响应越多,服务器水平越高)。
服务器往往都是7*24小时运行,因此这里地while(true)并没有退出的必要,如果我们确实向重启服务器,直接“杀”进程即可。
当然,我们这里的while(true)是非常简单粗暴的写法。实际开发中的服务器,很可能要实现“优雅退出”的效果,即确保当前正在进行的请求做完了之后再进行退出。
在start方法中,我们要完成四步工作;
1、读取请求并解析。
2、根据请求计算响应(但我们这里只是简单构造回显服务器,这一步啥也不用干)。
3、把响应返回给客户端。
4、打印日志。
1、读取请求并解析
读取请求并解析的过程中,需要使用socket的receive方法:
使用receive需要传入一个输出型参数DatagramPacket对象,这个对象里有一个内置的字节数组(我们可以设置数组的最大长度),会保存我们收到的消息正文(应用层数据包,UDP数据报的载荷部分)。此外UDP报头、数据的源IP和源端口号等都会被这个DatagramPacket对象所保存。 注意:这里的receive方法是自带阻塞功能的,如果客户端没有发来请求就会阻塞等待。
我们可以将得到的字节数组,转换成String,方便后续根据请求计算响应:
基于字节数组构造出String,字节数组里面保存的内容不一定是二进制数据,也可能是文本数据,如果是文本数据,将其交给String ,也是没有问题的;如果是二进制数据,String也是可以保存的。
第一个参数中,通过requestPacket对象调用getData方法,将上面字节数组中的信息获取到,然后第二个参数表示从字节数组的0号位置,开始构造String,第三个参数,是通过requestPacket对象获取到字节数组的有效长度(如果通过最大长度构造,那么字符串后面的很大部分都会是空白)。通过这三个参数来构造我们的String对象。
补充:
receive是传输层的UDP协议提供的一个API,传输层会给每个socket对象在内核中分配一个缓冲区,每次网卡读到一个数据读到一个数据,都会层层分用,解析好之后,最后放到这个缓冲区中,应用程序调用receive本质上就是从缓冲区中拿走一个数据。
2、根据请求计算响应
因为是回显服务器,这一步我们就只需要简单return以下即可
3、把响应返回到客户端
要想把响应返回给客户端,这里需要使用send方法,这里面同样需要传一个DatagramPacket对象:
此时我们构造DatagramPacket时,参数又不一样了:
第一个参数:response.getByte()是将我们刚才得到的响应(String类型)以字节数组的形式得到。
第二个参数:response.getBytes().length这里是获取字节数组的长度,以字节为单位进行计算,如果传入的参数是response.length(),此时的单位就是字符了。
第三个参数:requestPacket.getSocketAddress(),这里调用方法的的对象是requestPacket,这个对象是我们用来读取请求的,表示的是客户端来的数据报。调用getSocketAddress方法,调用这个方法,会获取到一个InetAddress对象,这个INetAddress对象,就包含了和服务器通信对应的客户端的ip和端口号(即响应的目的ip和目的端口号)。
通过上面的代码,我们也可以看出UDP是无连接的通信。UDP的DatagramSocket对象自身不保存对端的IP和端口。而是在每一份数据报中,都有一个对端的IP和端口。另外代码中也没有建立连接和接收连接的操作。
而UDP的不可靠传输,无法在代码中体现。但是UDP面向数据报的特点,可以看到,在send返回响应和receive读取请求时,都是以DatagramPacket为单位的。
4、打印日志
注意:这里的requestPacket.getAddress().toString会以点分十进制的方式输出客户端的IP地址
再写一个main方法:
注意:这里传入的端口号一般在1024~65535之间(但不可以和其他进程冲突),1~1024之前的端口号是系统保留自用的端口号——知名端口号(不可占用)。
完整代码:
package UDPEcho;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
import java.nio.charset.StandardCharsets;
public class UDPEchoSever {
//先创建一个DatagramSocket对象
private DatagramSocket socket = null;
//服务启动需要绑定端口号
//一个主机的一个端口只能绑定一个进程,反过来一个进程可以绑定多个端口
public UDPEchoSever(int port) throws SocketException {
socket = new DatagramSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动!");
//需要不停接收请求并返回响应
while(true){
//1、读取请求并解析
//通过这个字节数组保存收到的消息正文(应用层数据包),也就是UDP数据报的载荷部分
DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
//此处的receive就从网卡读取到了一个UDP数据报,放到requestPacket中,载荷部分就被放到requestPacket内置的字节数组中了
//此处的UDP报头、源ip和端口号也会被保存
socket.receive(requestPacket);
//把读到的字节数组转为字符串
String request = new String(requestPacket.getData(),0, requestPacket.getLength());
//2、根据请求构造响应
String response = process(request);
//3、把响应返回给客户端
//构造体格DatagramPacket作为响应对象,里面不是空白的字节数组了,而是把String里面包含的字节数组拿进来了
//注意:获取长度是字节数组,单位是字节。如果直接获取字符串长度,单位是字符不一样的
//requestPacket.getSocketAddress()这个对象就包含了你和服务器对应的ip和端口号
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),response.getBytes().length,requestPacket.getSocketAddress());
socket.send(responsePacket);
//打印日志
System.out.printf("[%s:%d],res:%s,response:%s\n",requestPacket.getAddress().toString(),requestPacket.getPort(),request,response);
}
}
public String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
UDPEchoSever udpEchoSever = new UDPEchoSever(9090);
udpEchoSever.start();
}
}
客户端
编写客户端的代码,也需要创建一个DatagramSocket对象。但是此处就不必指定端口号了。
服务器编写代码的时候需要手动指定端口号,但是在客户端这边,一般不需要手动指定,系统会自动分配一个空闲的端口号。
代码中手动指定端口号,可以保证端口号始终都是固定的,如果不手动指定,依赖系统自动分配,导致服务器每次重启之后,端口号可能就变了,一旦变了,客户端就有可能找不到这个服务器在哪里了,所以我们的服务器代码,一般要手动指定端口号。
但是客户端,端口号让系统随机分配一个空闲的即可。(主要是我们无法确保手动指定的端口号是可用的(可能被其他进程占用了))。
服务器的端口就不会被占用吗?为什么只担心客户端呢?
服务器的机器是在程序员手里的,是可控的。程序员事先编写代码之前,是能知道服务器上有哪些端口是空闲的。但是,客户端时在普通用户的机器上的,用户千千万万,上面的环境也是千差万别,天知道某个用户会装什么奇奇怪怪的程序把端口号占用,此时,我们的程序无法正常启动,用户只会怪到程序员的头上。
上面的客户端构造方法还需要,我们需要指定服务器短的ip地址和端口号。因为时客户端主动给服务器发起请求,发起请求的前提就是需要知道服务器在哪里~~~(比如说我要去餐厅吃饭,我就需要知道餐厅的位置在哪里,才能去吃饭)
此处,客户端发起请求的目的ip(severIp)和目的端口(severPort)就是客户端的ip和端口,而请求的源ip就是客户端本机的ip,源端口就是客户端本机分配的端口。
接下来,和服务器一样我们的客户端也需要实现一个start方法,在start方法中,我们也要做四件事:
1、从控制台读取要发送的请求数据
2、构造请求并发送
3、读取服务器的响应
4、把响应显示在控制台上
因为我们是要从控制台读取要发送的数据,所以需要先创建一个Scanner对象,还需要一个循环不断读取数据并发送给服务器。
1、从控制台读取要发送的请求数据
这里的if语句是可以让用户在结束输入的时候,程序可以正确地跳出循环,避免程序一直等待用户输入。
从控制台读取数据的时候,最好使用scanner读取字符串,最好使用next而不是nextLine(如果是文件的话就无所谓了)
如果使用nextLine读取,就需要手动输入换行符——Enter来控制。而由于Enter键不仅会产生\n这样的换行,还会产生其他字符,就可能会导致读到的内容出现问题。
而next是以"空白符"作为分隔符,包括但不限于,换行,空格,制表符……
2、构造请求并发送
同样的socket调用send方法,同样需要传入一个DatagramPacket类型的参数。
此处的Datagram参数又有所不同,第三个参数是我们当前要通信服务器的ip(注意:这里并不能传入String类型的severIP,而是需要调用InetAddress.getByName(severIP)),第四个参数则是我们当前要通信服务器上的端口号。
3、读取服务器的响应
这里仍然是使用socket的receive方法同样参数需要传入一个DatagramPacket类型的对象,此时构造方法仍然搭配receive使用,所以构造方法只需要指空白的字节数组即可
4、把响应显示在控制台上 
这里也需要基于DatagramPacket构造字符串,进行打印。
主函数:
其中127.0.0.1表示的是本机的ip,9090就是我们刚才在服务器中指定的端口号
完整代码:
package UDPEcho;
import java.io.IOException;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;
public class UDPEchoCline {
private DatagramSocket socket = null;
//客户端的IP
private String severIp;
//客户端端口号
private int severPort;
public UDPEchoCline(String severIp,int severPort) throws SocketException {
this.severIp = severIp;
this.severPort = severPort;
socket = new DatagramSocket();
}
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(severIp),severPort);
socket.send(requestPacket);
//3、读取服务器响应
DatagramPacket responesePacket = new DatagramPacket(new byte[4096],4096);
socket.receive(responesePacket);
//4、把响应打印到控制台上
String response = new String(responesePacket.getData(),0,responesePacket.getLength());
System.out.println(response);
}
}
public static void main(String[] args) throws IOException {
UDPEchoCline udpEchoCline = new UDPEchoCline("127.0.0.1",9090);
udpEchoCline.start();
}
}
基于DatagramPacket的补充
第一种:构造时指定空白的字节数组和字节数组的长度即可(搭配receive进行使用):
第二种:构造的时候指定有内容的字节数组,并且通过requestPacket.getSocketAddress方法获取请求方的IP和端口号。(用于服务器向客户端返回响应)。
第三种:构造的时候指定有内容的字节数组,并且分别将数据的目的IP和端口号作为两个参数。(用于客户端向服务器发起请求)
代码演示:
注意:这里一定要先启动服务器,再启动客户端
此时,我们再客户端控制台输入,就会返回同样的值
同时,我们的服务器也会打印日志
梳理代码
下面是一个大致的流程图:
图文并茂讲解:
1、服务器启动之后,进入receive阻塞,等待客户端的请求。
2、客户端在启动之后,在hasnext进入阻塞,等待用户输入。 3、用户在控制台输入之后,客户端会拿到用户输入的字符串,构造出请求,发送请求,并且在receive处等待响应返回。
4、服务器收到响应,就从receive解除阻塞,继续往下执行。
这里服务器执行完上述逻辑后,就会进入到下次循环中的receive的阻塞等待中,等待下一个客户端的请求过来。
5、客户端receive中返回,得到了服务器返回的响应数据,并且将数据打印在控制台上。
客户端在打印完毕之后也会进行下一次循环,等待用户再次从控制台输入信息。
补充 :
我们可以把写好的服务器代码,放到一个云服务器上,从而实现跨主机通信。