1. Socket套接字
1.1 概念
Socket 套接字,是由系统提供用于网络通信的技术,是基于TCP / IP协议的网络通信的基本操作单元。基于 Socket 套接字的网络程序开发就是网络编程。
1.2 分类
Socket套接字主要针对传输层协议划分为如下三类:
- 对于字节流来说,可以简单的理解为,传输数据是基于IO流,流式数据的特征就是在IO流没有关闭的情况下,是无边界的数据,可以多次发送,也可以分开多次接收。
- 对于数据报来说,可以简单的理解为,传输数据是一块一块的,发送一块数据假如100个字节,必须一次发送,接收也必须一次接收100个字节,而不能分100次,每次接收1个字节。
流套接字和数据报套接字的特点
流套接字 | 数据报套接字 |
---|---|
使⽤传输层TCP协议 | 使用传输层UDP协议 |
有连接 | 无连接 |
可靠传输 | 不可靠传输 |
面向字节流 | 面向数据报 |
有接收缓冲区,也有发送缓冲区 | 有接收缓冲区,无发送缓冲区 |
大小不限 | 大小受限:一次最多传输64k |
1.3 套接字通信模型
1.3.1 Java数据报套接字通信模型
- 对于 UDP 协议来说,具有无连接,面向数据报的特征,即每次都是没有建立连接,并且一次发送全部数据报,一次接收全部的数据报。
- Java中使用 UDP 协议通信,主要基于 DatagramSocket 类来创建数据报套接字,并使用DatagramPacket 作为发送或接收的 UDP 数据报。
对于一次发送及接收UDP数据报的流程如下
以上只是一次发送端的UDP数据报发送,及接收端的数据报接收,并没有返回的数据。也就是只有请求,没有响应。
对于一个服务端来说,重要的是提供多个客户端的请求处理及响应,流程如下
1.3.2 Java流套接字通信模型
1.4 Socket编程注意事项
- 客户端和服务端:开发时,经常是基于一个主机开启两个进程作为客户端和服务端,但真实的场景,一般都是不同主机;
- 注意目的IP和目的端口号,标识了一次数据传输时要发送数据的终点主机和进程;
- Socket编程我们是使用流套接字和数据报套接字,基于传输层的TCP或UDP协议,但应用层协议,也需要考虑,这块我们在后续来说明如何设计应用层协议。
- 关于端口被占用的问题
- 如果一个进程A已经绑定了一个端口,再启动一个进程B绑定该端口,就会报错,这种情况也叫端被占用。对于java进程来说,端口被占用的常见报错信息如下:
此时需要检查进程B绑定的是哪个端口,再查看该端口被哪个进程占用。以下为通过端口号查进程的方式:
- 在cmd输入netstat -ano | findstr 端口号,则可以显示对应进程的pid。如以下命令显示了8888进程的pid
- 在任务管理器中,通过pid查找进程
解决端口被占用的问题:
- 如果占用端口的进程A不需要运行,就可以关闭A后,再启动需要绑定该端口的进程B;
- 如果需要运行A进程,则可以修改进程B的绑定端口,换为其他没有使用的端口。
2. UDP数据报套接字编程
2.1 API 介绍
2.1.1 DatagramSocket
概念
计算机中的文件,通常是一个广义的概念,文件IO特指的是硬盘上的文件,是狭义的文件,除此之外,文件还可以代指一些硬件设备;
Socket 在计算机编程中,也可以认为是一种特殊的文件,打开 Socket 文件,也会在文件描述表中分配一个表项,来表示这个文件;
这样的文件特指网卡这样的硬件设备;
对于网卡这样的硬件设备,在操作系统中就被抽象成 Socket 文件;这样的设定,主要是为了方便操作网卡;
直接操作网卡,需要往网卡的寄存器上写一些特定的数据,不好操作;操作系统管理一些硬件设备,是抽象成文件统一管理的;把操作网卡,转化成操作Socket文件,此时 Socket 文件,就相当于网卡的 “遥控器" ;
所以 DategramSocket ,就是一个用来表示网卡的文件,通过 DategramSocket 来操作网卡,只是加了一个 Dategram 前缀,意思就是基于UDP协议进行网络通信
DatagramSocket 是UDP Socket,用于发送和接收UDP数据报。
构造方法
方法签名 | 方法说明 |
---|---|
DatagramSocket() | 创建一个UDP数据报套接字的Socket,绑定到本机任意一个随机端口(一般用于客户端) |
DatagramSocket(int port) | 创建一个UDP数据报套接字的Socket,绑定到本机指定的端口(一般用于服务端) |
方法
方法签名 | 方法说明 |
---|---|
void receive(DatagramPacket p) | 读操作,从此套接字接收数据报 (如果没有接收到数据报,该方法会阻塞等待) |
void send(DatagramPacket p) | 写操作,从此套接字发送数据报包 (不会阻塞等待,直接发送) |
void close() | 关闭此数据报套接字 |
DatagramSocket 类的 receive() & sand() 的参数类型,都是 DatagramPacket ;
一个UDP数据包,就通过 DatagramPacket 对象来进行体现的,在进行receive() 或者 send(),都是按照 DatagramPacket 这样的数据包为定位进行接收的;
2.1.2 DatagramPacket
定义
DatagramPacket 是UDP Socket发送和接收的数据报。
构造方法
UDP 数据包的载荷数据,可以通过构造方法来指定
方法签名 | 方法说明 |
---|---|
DatagramPacket(byte[] buf, int length) |
|
DatagramPacket (byte[] buf, int offset,int length, SocketAddress address) |
|
DatagramPacket (byte[] buf, int offset,int length, InetAddress address, int port) |
|
方法
方法签名 | 方法说明 |
---|---|
InetAddress getAddress() | 从接收的数据报中,获取发送端主机IP地址;或从发送的数据报中,获取接收端主机IP地址 |
int getPort() | 从接收的数据报中,获取发送端主机的端口号;或从发送的数据报中,获取接收端主机端口号 |
byte[] getData() | 获取数据报中的数据 |
构造UDP发送的数据报时,需要传入SocketAddress,该对象可以使用InetSocketAddress 来创建;
2.1.3 InetSocketAddress ( SocketAddress的子类 )
构造方法
方法签名 | 方法说明 |
---|---|
InetSocketAddress(lnetAddress addr,int port) | 创建一个Socket地址,包含IP地址和端口号 |
2.2 实现回显服务器
2.2.1 实现原理
- 客户端给服务器发一个数据的操作,称为请求;
- 服务器返回一个数据的操作,称为响应;
- 一个真实的服务器,请求和响应一般是不一样的,但是为了展示上述 API 的用法,就先不去管服务器中其他复杂的逻辑,写一个最简单的回显服务器(请求是什么,响应就是什么);
2.2.2 代码实现
UDP Echo Server
(1) 构造一个 socket 对象代表网卡文件
- 输出 socket 文件内容,等于从网卡中读取数据;
- 输入 socket 文件内容,等于向网卡内发送数据;
(2) 实现启动服务器的 start()
对于服务器来说,客户端啥时候发请求,发多少个请求,我们无法预测;
因此服务器中通常都需要有一个死循环,持续不断的尝试读取客户端的请求数据~~在主循环中,需要实现的逻辑:
- 读取请求并解析;
- 根据请求,计算响应。(服务器最关键的逻辑),但是此处写的是回显服务器。这个环节相当于省略了;
- 把响应返回给客户端;
循环每执行一次,就相当于处理了一次请求,处理请求的过程,典型的服务器也是上面这三个步骤
(3) 读取请求
- 服务器调用 receive() 对客户端发送的请求进行读取,等 receive() 方法执行完毕,参数里面对象的数据就是读取的结果:
创建一个 DatagramPacket 对象,用于接收请求
- 创建 DatagramSocket 对象的时候,需要指定一个字节数组,并且传入接收的指定长度:
- DatagramPacket 表示个UDP数据报。此处传入的字节数组,就保存 UDP的载荷部分;
读取网卡中的数据包
- 把 DatagramPacket 对象(对象为全0)传给 receive();
- receive() 就会在方法内部把从响应数据报中读到的数据,填充到 requestPacket 这个引用指向的对象中;
- receive() 执行完毕,参数里面的对象数据,就是需要从网卡中读取的数据(请求)
- 这个过程就相当于我们在食堂打饭,把空的盘子(requestPacket )交给打饭阿姨( receive() ),阿姨会把打好饭(读到的数据)的盘子还给我们;
(4) 解析请求
- 当前 UDP 载荷,是 DatagramSocket 对象的字节数组,存放着读取到的数据;
- 这些读取到的数据是二进制数据,为了方便后续处理,我们把读取到的二进制数据转换成字符串形式;
- 通过字节数组构造一个 String 对象,是构造 String 对象的一个典型做法;
- 上述操作,表示拿到了一个数据报中的字节数组,把整个字节数组传给String对象,并且指定字节数组有效部分的范围,调用相应的构造方法,构成一个字符串;
(5) 根据请求计算响应
- 这是服务器最关键的逻辑,但是此处写的是回显服务器,这个环节就相当于省略了;
- 根据解析数据报,得到的请求 request,计算出响应 response 的操作,封装成一个方法,可以起到解耦合的作用;
- 后续要写别的服务器,只需要修改 process() 的内容即可 ;
(6) 把计算好的响应返回给客户端
- 如何根据响应 response 构造 DatagramSocket 对象呢?
- 首先,需要拿出对响应数据报进行解析操作时,创建的字符串 request 里面的字节数组:
- 要传入字节数组的长度,而不是使用字符串的长度,因为字符串的单位是字符,而我们要使用字节的个数,来作为当前 responsePacket 数据包的参数
(6) 指定目的IP&目的端口
- 发送的响应数据报 responsePacket 是没有明确标注有发送的目的IP&目的端口的,要想正确地返回响应,就必须给响应数据报显式地标注目的IP & 目的端口;
- 对于服务器返回响应的目的IP&目的端口,就是接到客户端请求的源IP&源端口:
- 所以,通过调用 DatagramSocket 类中的 getSocketAddress(),该方法返回一个 InerSocketAddress 对象;
- 这个对象包含了目标IP&目标端口号(都在报头中,而不是不是在载荷中)
- 将这个对象作为参数,传给DatagramPacket对象,调用对应的构造方法;
- 所以,将 getScketAddress() 方法返回的对象,作为参数来调用对应的构造方法,实例出的 responsePacket,就会显式地标注响应数据报的目标IP&目标端口:
(7) 发送响应给客户端
- 服务器需要调用 send() 方法,把创建好的响应数据报作为响应,返回给客户端:
- send() 的构造方法
- 所以,我们把刚刚构建好的响应数据报返回给客户端:
(8) 打印日志来记录客户端/服务器交互的过程
(9) 判断当前 socket 对象(文件)是否需要关闭
- 文件是否需要关闭,考虑的是这个文件对象的生命周期是怎样的,此处的 socket 对象会自始至终伴随整个UDP服务器;
- 只要服务器运行,就随时可能会从客户端中读数据,如果提前关闭 socket对象,那么UDP服务器继续运行也没有意义,所以socket对象,不能在服务器运行的过程中关闭;
- 服务器关闭(进程结束),也不需要手动调用close(),因为进程结束时就会自动释放 PCB 的文件描述符表中的所有资源,
- 所以当前socket文件不手动调用 close(),也是完全没问题的,因为socket的生命周期本来就需要跟随整个进程的;
- 如果是有请求级别的文件对象,给一个请求,创建一个对象,就需要确保处理完毕之前,关闭对象。
- 所以需要结合实际情况来确认一个对象的生命周期,通过生命周期,来决定对象是否应该关闭;
(10) 补充
- DatagramPacket这个类说是一个UDP数据报,其实也包含了一些源信息;
- 这个类有接收IP&端口号的属性,在通过 receive() 填充好DatagramPacket对象后,可以直接从对象中取出 UDP 数据包的来源(源IP和源端口)
- 所以在将DatagramPacket这个UDP数据包,作为 receive() 方法的输入型参数时,不只是把UDP中的数据读进去了,还把IP&端口号等信息也读进去了;
- 拿到请求数据包的源IP和源端口,就可以作为参数传给DatagramPacket对象,调用相应的构造方法;
- UDP协议虽然没有保存发送请求数据包的地址信息,但是通过调用的构造方法,根据获取到的源IP和源端口,可以显式地指定响应数据包要发送的目的IP和目的端口 ;
UDP Echo Client
在客户端构造方法中传入的参数,就不能只有一个端口号了,要指定的是,当前客户端要连哪一个服务器,所以构造方法传要访问的服务器IP和端口号
UDP.本身不保存对端的信息,所以我们在代码中保存一下
对比服务器调用DatagramSocket的构造方法,服务器调用的是不传端口号的构造方法:
因为客户端访问服务器,访问的目的 IP&目的端口,都是都是服务器的源IP&源端口;
服务器返回响应给客户端,目的IP 是客户端所在的主机 IP;目的端口,则随机使用一个操作系统分配的空闲端口;
为什么客户端不推荐使用固定端口,而服务器推荐呢?
以餐饮店的老板和顾客交互的情景为例子:
对于老板(Server)
- 老板在选好一个地方后,就需要派发传单来宣传店铺,传单上有着餐饮店详细的地址(服务器源IP和源端口号)和菜谱(响应);
- 对于这个餐饮店的地址(服务器IP&端口号),是必须要指定一个地方的(调用指定端口号的 DatagramSocket 的构造方法);
- 如果餐厅地址不固定,就会影响顾客正常来店铺吃饭(客户端对服务器发出请求);
对于顾客(Client)
- 当顾客光顾餐饮店的时候(客户端对服务器发出请求),会对老板点菜(服务器对客户端发送的请求作出响应);
- 点菜后,需要找一个位置(服务器作出响应的目标IP&端口号)坐下,等待老板做菜(解析请求,计算响应);
- 顾客在等待的过程中,会找一个空闲的位置坐下,这个位置是随机的,而不是指定的;(调用不指定端口号的构造方法,服务器返回响应时,随机使用一个操作系统分配的空闲端口;)
- 如果顾客要指定一个位置等待,就可能需要等待这个位置的另一位顾客用餐(不同应用程序竞争同一个端口号)
总结:
- 为了能更好地接收客户端请求,需要指定服务器的IP和端口号;
- 对于服务器,端口号是区分同一个主机不同的应用程序的同一时刻,不能有两个程序使用同一个端口(操作系统中,一个程序尝试关联一个非空闲的端口,就会关联失败)
实现 start() 方法
从控制台读取用户输入的内容
把请求发送给服务器,需要构造DatagramPacket对象,构造过程中,不光要构造载荷,还要设置服务器的IP和端口号
需要把serverIp转换一下,通过InetAddress类提供的静态方法getByName,通过这样的方法,把字符串中的serverIp,转换成InetAddress对象
发送数据报请求给服务器
接收服务器响应
从服务器读取的数据( respose 中的二进制内容)进行读取解析,将读取到的数据转换成一个字符串,并且打印出来该字符串;
嵌套循环并设置循环结束条件
Server & client 的交互过程
补充
- 127.0.0.1 是一个特殊的IP,是一个环回IP,表示当前主机;无论主机的 IP真实是什么,都可以使用127.0.0.1代替(类似于this);
- 由于此时,客户端和服务器在同一个主机上,就可以使用127.0.0.1来访问,如果是不同主机就需要填写其他的IP了.
同时启动这两个程序的执行结果:
UDP Dict Server
编写⼀个英译汉的服务器,只需要继承 UdpEchoServer ,重写 process() 即可;
字典服务器和客户端交互过程
同时启动进程,查看程序执行结果
这个报错说明当前端口被占用了,换一个端口即可;
2.2.3 完整代码
UDP Echo Server
package network;import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;public class UdpEchoServer {private DatagramSocket socket ;public UdpEchoServer(int port) throws SocketException {socket = new DatagramSocket(port);}public void start() throws IOException {System.out.println("启动服务器");while (true){DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);socket.receive(requestPacket);String request = new String(requestPacket.getData(),0,requestPacket.getLength());String response = process(request);DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),response.getBytes().length, requestPacket.getSocketAddress() ) ;socket.send(responsePacket);System.out.printf("[%s:%d] rep: %s, resp: %s \n",requestPacket.getAddress().toString(), requestPacket.getPort(),request,response);}}public String process(String request) {return request;}public static void main(String[] args) throws IOException {UdpEchoServer server = new UdpEchoServer(9092);server.start();}
}
UDP Echo Client
package network;import java.io.IOException;
import java.net.*;
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 {this.serverIp = serverIp;this.serverPort = serverPort;socket = new DatagramSocket();}public void start() throws IOException {Scanner scanner = new Scanner(System.in);while (true){System.out.println("请输入要发送的内容");if(!scanner.hasNext()){break;}String request = scanner.nextLine();DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),request.getBytes().length,InetAddress.getByName(serverIp) , serverPort);socket.send(requestPacket);DatagramPacket responsePacket = new DatagramPacket(new byte[4096],4096);socket.receive(responsePacket);String response = new String(responsePacket.getData(),0,responsePacket.getLength());System.out.println(response);}}public static void main(String[] args) throws IOException {UdpEchoClient client = new UdpEchoClient("127.0.0.1", 9092);client.start();}
}
UDP Dict Server
package network;import java.io.IOException;
import java.net.SocketException;
import java.util.HashMap;public class UdpDictServer extends UdpEchoServer{private HashMap<String,String> dict = new HashMap<>();public UdpDictServer(int port) throws SocketException {super(port);//初始化词典dict.put("小狗","dog");dict.put("小猫","cat");dict.put("小鸭","rabbit");dict.put("小雷","handsome guy");}@Overridepublic String process(String request){//查字典return dict.getOrDefault(request,"未找到该词条");}public static void main(String[] args) throws IOException {UdpDictServer server = new UdpDictServer(9092);server.start();}
}