目录
1、网络编程基础
2、UDP数据报套接字编程
2.1.DatagramSocket API(方法)
2.2、DatagramPacket API(方法)
2.3、InetSocketAddress API
3、基于UDP socket写一个回显服务器
3.1、服务器端
3.2 、客户端
3.3、完整回显服务器和客户端代码
3.3.1、服务器和客户端代码的执行流程
4、 基于UDP Socket写一个简单的单词翻译服务器
4.1、服务器
我们知道网络通信的过程中,发送方将数据从应用层封装到物理层,然后进行发送。接收方在拿到数据之后,将数据从物理层分用到应用层。我们进行网络编程就是将网络上的主机,通过不同的进程,以编程的方式实现网络通信。进行网络编程,我们主要可以操作的就是应用层,其他层都是系统封装好的,我们没有能力去修改。我们要发送一个数据,需要上层协议,调用下层协议,也就是说我们在进行应用层代码编写的时候,需要调用传输层给我们提供的API。这组API我们将其统称为socket api.系统提供的socket api有很多,我们主要了解下面这两组。
由于TCP和UDP这两个协议的差别很大,所以提供的API也存在很大的差异。了解一下这两个协议最基本的特点。
UDP特点 | TCP特点 |
无连接 | 有连接 |
不可靠传输 | 可靠传输 |
面向数据报 | 面向字节流 |
全双工 | 全双工 |
- 有连接和无连接:有连接可以理解为,通信双方,各自记录了对方信息,表示两台主机在进行通信的时候是否需要记录一下对端的信息。 使用UDP通信的双方,不需要保存对端的相关信息。使用TCP通信的双方,则需要保存对方的相关信息。(使用UDP通信,不需要接受连接,直接投递,就能通信。可以想象一下发短信;使用TCP通信,需要先把链接接收,才能通信。可以想象一下打电话)。
- 可靠传输与不可靠传输:这里的可靠传输和不可靠传输并不是说安全性的问题,而是说发送端在将数据发送了之后,发送端能不能判断接收端收到信息,如果能够确定接收端收到了信息或者没有接收到消息,这就是可靠传输。不可靠传输就是,发送端将数据发送之后,就不会在管了,至于接收方接收到消息还是没有接收到消息,发送端不关心。
- 面向字节流和面向数据报:面向字节流就是以字节为传输的基本单位,以流的形式传输数据,可以一次读一个字节,也可以一次读很多字节,读写方式非常灵活。面向数据报就是以一个UDP数据报为基本单位。
- 全双工和半双工:全双工是一条通信链路,双向通信(就像车道一样)。半双工是一条通信链路,单向通信。
UDP数据报的套接字提供了最主要的两个核心类、一个是DatagramSocket一个是DatagramPacket。
Datagram的意思就是"数据报",Socket,说明这个对象是一个socket对象(相当于对应到系统中一个特殊的文件【socket文件】)。socket文件并非对应到硬盘上的某个数据存储区域,而是对应到网卡这个硬件设备。
我们想要进行网络通信,就需要有socket文件这样的对象,借助这个socket文件对象,才能够间接的操作网卡。这个socket对象就像是一个遥控器。往这个socket对象中写数据,相当于通过网卡发送消息。从这个socket对象中读数据,相当于通过网卡接收消息。
✨ DatagramSocket类的构造方法:
下面我们提供了两个构造方法一个带参数,一个不带参数,带参数的构造方法中的参数代表的意思就是端口号
方法 | 方法说明 |
DatagramSocket() | 创建一个UDP数据报套接字Socket,绑定到本机任意一个随机端口(一般用于客户端) |
DatagramSocket(int port) | 创建一个UDP数据报套接字Socket,绑定本机指定的端口(一般用于服务端 ) |
第二个带有参数的构造方法可能会被用户端或者服务器都使用,服务器端的socket对象往往都要关联一个具体的端口号。客户端的socket对象则不需要手动指定,系统会自己分配。
✨DatagramSocket方法:
方法 | 方法说明 |
void receive(DatagramPacket p) | 从次套接字接收数据报(如果没有接收到数据报,该方法会阻塞等待) |
void send(DatagramPacket p) | 从此套接字发送数据报(不会阻塞等待,直接发送) |
void close() | 关闭此数据报套接字 |
✨DatagramPacket构造方法:
第一个构造方法,不需要设置地址进去,通常用来接收消息;
第二个构造方法,需要显示的设置地址进去,通常要用来发送消息。
方法 | 方法说明 |
DatagramPacket(byte[ ] buf,int length) | 构造一个DatagramPacket以用来接收数据报,接收的数据保存在字节数则(第一个参数buf)中,接收指定长度(第二个参数length) |
DatagramPacket(byte[ ] buf,int offset,int length,SocketAddress address) | 构造一个DatagramPacket以用来发送数据报,发送的数据为字节数组(第一个参数buf)中,从0到指定长度(第二个参数length)。address指定目的主机的IP和端口号。 |
✨ DatagramPacket方法:
方法 | 方法说明 |
InetAddressgetAddress() | 从接收的数据报中,获取发送端主机IP地址;或从发送的数据报中,获取接收端主机IP地址 |
int getPort() | 从接收的数据报中,获取发送端主机的端口号;或从发送的数据报中,获取接收端主机端口号 |
byte[ ] getData() | 获取数据报中的数据 |
构造UDP发送的数据报时,需要传入socketAddress,该对象可以使用InetSocketAddress来创建。
✨InetSocketAddress(SocketAddress的子类)构造方法:
方法 | 方法说明 |
InetSocketAddress(InetAddress addr,int port) | 创建一个Socket地址,包含IP地址和端口号 |
基于UDP socket写一个最简单的客户端-服务器程序。
回显服务器:客户端发送一个请求,服务端返回一个一摸一样的响应。就比如我们去吃饭,给老板说来一份蛋炒饭,老板没有给你做,他也说了一句老板来一份蛋炒饭。
✨一个服务器,主要做三个核心工作:
- 读取请求并解析
- 根据请求计算响应(会先服务器就是把这里省略掉了)
- 把响应返回到客户端
1️⃣创建一个UDP的socket对象并且指定一个端口号
public class UdpEchoServer {
//需要先定义一个socket对象
//通过网络通信,必须要使用socket对象
private DatagramSocket socket = null;
public UdpEchoServer(int port) throws SocketException {
//构造socket的同时,指定要关联/绑定的端口。
socket = new DatagramSocket(port);
}
我们创建的服务器这个类中构造的socket对象,绑定端口号的时候时会提示要申明异样,这是因为,我们在主方法中创建服务器这个对象并手动指定端口号的时候,我们指定的端口号,可能被其他的进程占用着,这个时候这里的绑定端口号的操作就会出错。(同一个主机上,一个端口,同一时刻只能被一个进程绑定)。
2️⃣启动服务器
//启动服务器的主逻辑
public void start() throws IOException {
System.out.println("服务器启动!");
while(true){
//每次循环,要做三件事情
//1.读取请求并解析
//构造一个空饭盒
DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
//receive 方法会从网卡中读取数据填充到requestPacket对象中。
socket.receive(requestPacket);
//为了方便处理这个请求,把数据报转换成String
String request = new String(requestPacket.getData(),0, requestPacket.getLength());
//2.根据请求计算响应(此处省略这个步骤)
//3.把响应结果写回到客户端
}
}
}
启动服务器之后,服务器会做三件事情,1读取请求并解析,2.根据请求计算响应,3.把响应结果写回到客户端。服务器不可能只是对一个客户端进行响应,所以这里使用where循环,当服务器对一个客户端服务完成之后,他还需要在对其他客户端的请求做出响应。
❓看到这里有的老铁会想socket对象怎样存储客户端发来的请求数据呢?
- 我们在用户态中写一个应用程序,在其中创建了一个socket对象,此时的socket对象在内核中对应了一个PCB(进程控制块),PCB中存在一个文件描述表(记录这个程序打开了那些文件),这个文件描述符表中有一个项,就记录了这个打开的socket文件。socket文件和网卡建立了链接之后,网卡接收到数据之后,对数据报分用到传输层,看到端口号,根据端口号,就找到了对应的进程(也就找到了对应的socket文件)。
- 系统内核,会给每个socket文件都分配一定的内存空间分为:1.发送缓冲区,2.接收缓冲区。
- 系统会把收到的这个数据拷贝到该socket对应的接收缓冲区中,此时应用程序调用socket.receive方法,就是从该内核中的socket文件的接收缓冲区里把数据拷贝到了receive方法参数中的DatagramPacket对象中。(socket文件的接收缓冲区类似于阻塞队列,receive方法将该缓冲区中内容拷贝到DatagramPacket对象中,就相当于数据出队列)
补充:
❓❓网卡属于TCP/IP五层协议的那一层?
❗❗如果精确的说,网卡的位置当时应该算是物理层,单网卡时PC与Internet网云连接的通道,因而,IP层也存在并且最重要的也出现在IP层,因此,如果单一的说,网卡在TCP/IP的第二层比较正确。(这也就能解释为什么上述网卡得到数据之后,可以分用到传输层)。
✨理解socket对象调用的receive方法
- receive这个方法的参数是一个输出型参数。
- 之前我们在Java中看到的多数方法都是,使用参数作为方法的"输入",使用返回值作为方法的输出。比如我们要调用一个方法找到两数的最小值,这个时候我们就会调用Math类的min方法并输入两个参数,比较完成之后,方法通过return将结果输出。
- 但是这里的receive方法是传入一个空的packet对象,然后由receive方法内部把参数的这个packet进行填充,等到这个方法执行完毕,packet对象内部也就有了写回的数据。
举例理解:输出型参数,就像是我们去餐厅吃饭,自己拿了一个餐盒,将空餐盒交给打饭阿姨,阿姨把饭打好之后给我们。
画图理解:
❗❗❗注意:服务器启动之后,调用start方法,就会立即执行到receive这里,要是此时还没有客户端发来数据,此时receive就会阻塞,一直持续到客户端有请求数据发送过来。
✨理解将请求的数据报转换成为String
这个操作并非是必须的,只是此处为了后续的代码简单,当客户端发来的数据是"老板来一份蛋炒饭",此时这个数据就会以二进制的形式存在于requestPacket中的字节数组中,把字节数组中的元素拿出来构造成一个String,这个String内容就是"老板来一份蛋炒饭"。
offest:0和requestPacketgetLength()这两个参数的的意义:
requestPacket对象中的byte数组给定的数组长度为4096,但是一个请求数据并没有将这个数组占满,我们构造String时,通过requestPacket.getLength()方法求出请求数据的实际长度。
这两个参数表示的意思为:从byte[ ] 的0下标位置到getLength()这个下标,把这一段用来构造字符串(String)。
3️⃣根据请求计算响应
//2.根据请求计算响应(此处省略这个步骤)
String response = process(request);
//这个方法希望是分局请求来计算响应
//由于我们写的是回显程序,所以请求是啥,响应就是啥
//如果后续写一个别的服务器,不在回显,具有具体的业务,可以修改process方法,
//根据需要来重新构造响应
private String process(String request) {
return request;
}
4️⃣把响应写回到客户端
这里有两步操作,1、打包响应,2、发送响应
//3.把响应结果写回到客户端
//和请求的数据报不同,此处构造响应的时候,需要指定这个报要发给谁
//根据response字符串,构造一个DatagramPacket对象(响应的数据报)
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);
我们首先要构造一个响应的数据报,这个数据报中存有根据请求数据计算得出的响应。
- getBytes()方法的作用是使用idea默认的字符集将此String编码为字节序列,并将结果存储到新的字节数据中。
- 构造UDP发送的数据报时,需要传入socketAddress,该对象可以使用InetsocketAddress来创建。requestPacket.getSocketAddress()这个操作的作用是,从请求数据报中获取这个响应发送的主机地址。
1️⃣创建一个UDP的socket对象
这里我们创建客户端类的时候,如果需要进行网络通信,也需要创建一个socket对象(文件),写这个客户端类的构造方法的时候,我们并不需要显示的关联一个端口号,这并代表客户端没有端口,而是让系统自己分配一个空闲的端口。我们在创建客户端类的时候是需要创建客户端IP和客户端端口的成员变量。因为客户端启动的时候要知道服务器在哪里。
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;
}
2️⃣启动客户端
启动客户端这个方法中存在四个步骤:
- 先从控制台,读取一个字符串
- 把字符串构造成UDP数据报,并进行发送
- 客户端尝试读取服务器返回的响应
- 把响应数据转换成字符串(string)显示出来
public void start() throws IOException {
//通过这个客户端可以多次和服务器仅从交互
Scanner scanner = new Scanner(System.in);
while(true){
//1.先从控制台,读取一个字符串
System.out.println("-> ");//打印一个提示符,提示用户要输入内容
String request = scanner.next();
//2.把字符串构造成UDP数据报,并进行发送
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);
}
}
✨理解把字符串构造成UDP数据报。
这里将输入的字符串转换成UDP数据报,需要进行两方面的操作,一方面首先将字符串编码为字节序列,存入新的字节数组中;另一方面需要指定服务器的IP和端口号。
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),request.getBytes().length,InetAddress.getByName(serverIP),serverPort);
- getBytes()方法:使用idea默认的字符集将此String编码为字节序列,并将结果存储到新的字节数据中。
- InetAddress.getBytes(serverIP):这里的作用是给客户端要发送数据报中添加服务器的网络主机地址(目的主机的IP)。
- serverPort:表示设置一个端口号(这个端口号表示服务器在执行的时候绑定的那个端口号)
这里对用户输入的字符串进行转换封装成数据报对服务器发送的时候,我们需要设置服务器的主机IP和服务器程序执行所绑定的端口号。
服务器
public class UdpEchoServer {
//需要先定义一个socket对象
//通过网络通信,必须要使用socket对象
private DatagramSocket socket = null;
//绑定一个端口,不一定成功,会抛出异常,所以这里需要先申明这个异常
//如果某个端口已经被别的进程占用了,此时这里的绑定操作就会出错
//同一个主机上,一个端口,同一时刻只能被一个进程绑定
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);
//receive 方法会从网卡中读取数据填充到requestPacket对象中。
socket.receive(requestPacket);
//为了方便处理这个请求,把数据报转换成String
String request = new String(requestPacket.getData(),0, requestPacket.getLength());
//2.根据请求计算响应(此处省略这个步骤)
String response = process(request);
//3.把响应结果写回到客户端
//和请求的数据报不同,此处构造响应的时候,需要指定这个报要发给谁
//根据response字符串,构造一个DatagramPacket对象(响应的数据报)
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方法,
//根据需要来重新构造响应
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
UdpEchoServer udpEchoServer = new UdpEchoServer(9090);
udpEchoServer.start();
}
}
客户端
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 scanner = new Scanner(System.in);
while(true){
//1.先从控制台,读取一个字符串
System.out.println("-> ");//打印一个提示符,提示用户要输入内容
String request = scanner.next();
//2.把字符串构造成UDP数据报,并进行发送
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是一个系统的文件,是文件就要在使用完成之后释放资源,但是上述代码中我们并没有这步操作,这是为什么?
- 要关闭一个系统文件,就要等到这个文件在进程中不在使用了,此时才能调用close方法,对文件进行关闭。
- 但是上述的客户端和服务器的代码中的socket文件一直被使用,只要start方法中的循环不停止,那么socket文件一直在被使用,如果循环结束,那么start方法也就结束,进而main方法也会结束,那么进程也就结束了,进程都结束了,那么所有的文件资源也就自然释放了。
- 当socket文件的生命周期和进程一样的时候,调用close关闭socket文件这个操作也就可有可无了。但是频繁创建的socket文件,就必须保证这些文件可以及时的被关闭。
最终实现效果,请求是一个英文单词,响应是这个单词的中文翻译
我们上述写了一个回显服务器,这里我们实现单词翻译服务器只需要将回显服务器继承并将pocess方法重写,在这个类中使用HashMap实现查单词的效果,HashMap是K-V模型,让英文单词为key,中文翻译为value。这样就可以实现一个单词翻译服务器。
//使用继承,是为了复用之前的代码
public class UdpDictServer extends UdpEchoServer{
private Map dict = new HashMap<>();
public UdpDictServer(int port) throws SocketException {
super(port);
dict.put("dog","小狗");
dict.put("cat","小猫");
dict.put("apple","苹果");
dict.put("bear","熊");
//这里可以添加无限个数据
}
@Override
public String process(String request){
return dict.getOrDefault(request,"该单词没有查到!");
}
public static void main(String[] args) throws IOException {
UdpDictServer udpDictServer = new UdpDictServer(9090);
//这里调用start方法是父类中写的
udpDictServer.start();
}
}
✨对调用的getOrDefault()方法作用进行说明:返回指定键映射的值,如果此映射不包含该键的映射,则返回defaultValue,上述使用这个方法时,我们将键的默认映射设置为“该单词没有查到!”
客户端,我们可以使用回显程序的客户端。
运行结果为