网络编程(一):UDP socket api => DatagramSocket DatagramPacket
目录
1. TCP 和 UDP
1.1 TCP / UDP 的区别
1.1.1 有连接 vs 无连接
1.1.2 可靠传输 vs 不可靠传输
1.1.3 面向字节流 vs 面向数据报
1.1.4 全双工 vs 半双工
2. UDP socket api
2.1 DatagramSocket
2.1.1 构造方法
2.1.2 receive / send / close
2.2 DatagramPacket
2.2.1 构造方法
2.2.2 getAddress / getPort / getSocketAddress
3. UDP 回显 客服端-服务器
3.1 UDP 回显服务器
3.1.1 给服务器指定固定端口号
3.1.2 运行服务器
3.1.2.1 读取请求并解析
3.1.2.2 根据请求, 计算响应
3.1.2.3 返回响应
3.1.2.4 记录客户端/服务器的交互过程
3.2 UDP 回显客户端
3.2.1 给客户端随机分配空闲端口
3.2.2 运行客户端
3.2.2.1 用户输入请求
3.2.2.2 构造请求数据包
3.2.2.3 发送请求数据包
3.2.2.4 接收服务器响应
3.2.2.5 解析响应数据
1. TCP 和 UDP
上文说到, 作为程序员, 我们写代码时, 主要是和应用层来打交道(应用程序).
而将应用层的数据交给传输层, 就需要调用操作系统提供的一组 api(传输层向应用层提供的), 也就是 socket api.
而在传输层中有两个核心的协议:
- TCP 协议
- UDP 协议
这两个协议差别非常大, 编写代码时, 也是截然不同的风格, 于是 socket api 就提供了两套, 一套给 TCP 使用 ,一套给 UDP 使用.
1.1 TCP / UDP 的区别
- TCP => 有连接, 可靠传输, 面向字节流, 全双工
- UDP => 无连接, 不可靠传输, 面向数据报, 全双工
1.1.1 有连接 vs 无连接
这里的连接, 是一个抽象的概念, 指的是虚拟的/逻辑上的连接, 是指两个人之间有没有建立 "连接/联系" , 并非物理上网络的连接.
所以这里的 有/无连接, 就是指彼此之间, 有没有保存对方的信息(有没有保存对端的IP/端口).
(物理上的连接(网线, wifi ...), 肯定是都有的, 因为要进行网络通信, 物理连接是必不可少的!!)
比如, 两个人结婚, 去民政局领了结婚证后, 你们两个人才建立起这样的 虚拟的/逻辑的上的 连接, 你们两个人真正的 "连接" 了起来, 你是她的丈夫, 她是你的妻子.
- 对于 TCP 来说(有连接), TCP 协议中就保存了对端的信息. A 和 B 通信, A 中就保存了 B 的信息, B 中就保存了 A 的信息(彼此之间, 知道是谁和自己建立的连接).
- 而对于 UDP 来说(无连接), UDP 协议中就没有保存对端的信息. (但是我们可以在代码中手动使用变量来保存对端信息, 但这属于 UDP 的行为)
1.1.2 可靠传输 vs 不可靠传输
网络通信时, 数据是很容易出现丢失的(丢包, 原本要传输的数据被修改, 被识别出来后就会被丢弃):
- 光/电信号, 可能会收到外界磁场的干扰导致丢包
- 网络是通过 路由器/交换机 来构建的, 当需要转发的数据量超过了 路由器/交换机 的转发上限时, 就会出现"堵车", 造成丢包(下载很大的资源时, 网络就会很卡)
所以, 传输的数据, 是不一定 100% 到达目的地的, (中间可能出现丢包现象):
- 而 TCP 可靠传输 => 虽然不能保证 100% 发送成功, 但是会尽可能的提高传输成功的概率, 如果出现丢包, 就会尝试重新发送.(但是也会降低传输速率)
- 而 UDP 不可靠传输 => 把数据发送后, 就不再管了(不管发送成功与否)~~ (传输速率同时也更快)
1.1.3 面向字节流 vs 面向数据报
- 面向字节流: 读取数据的时候, 以字节为单位, 一次读写一个字节的数据
- 面向数据报: 读取数据的时候, 以一个数据报为单位(不是字符)
所以 TCP 可以支持任意长度的读取, 但是存在粘包问题.
而 UDP 一次只能读取一整个 UDP 数据报, 虽然不存在粘包问题, 但是却存在长度限制.
1.1.4 全双工 vs 半双工
- 全双工: 一个通信链路, 支持双向通信(既能读, 也能写)
- 半双工: 一个通信链路, 只支持单向通信(要么读, 要么写, 二选一)
2. UDP socket api
socket api 针对 TCP 和 UDP 提供了两套不同的 api , 我们先来看 UDP 的 socket api.
2.1 DatagramSocket
在之前的博客提到过, "文件" 是一个广义上的概念, 文件不止指硬盘上存储的文件, 还可以代指一些硬件设备(硬件设备被抽象成文件, 操作系统通过文件对硬件设备统一进行管理).
其中, 网卡就被抽象成 socket 文件, 我们可以通过操作 socket 文件, 间接来操作网卡.
而 DatagramSocket 就可以认为是 "网卡的遥控器", 可以通过 DatagramSocket 对象来向网卡中读写数据.
2.1.1 构造方法
操作网卡时, 由于网卡被抽象成 socket 文件, 所以操作网卡的流程和操作文件的流程是类似的:
- 打开文件
- 读写文件(读写网卡)
- 关闭文件
其中, DatagramSocket 通过构造方法 new 出对象后, 就相当于打开文件成功.
DatagramSocket 构造方法有两种:
- 无参版本 => 随机分配一个未被使用的端口号
- 带带参版本 => 指定一个固定的端口号
(使用 socket 时, 会关联上一个端口号, 以此区分主机上不同的应用程序)
我们可以把 DatagramSocket 理解为一个包裹,
- 里面的物品就是要传输的数据(UDP 数据包)
- 而端口号就是收货人(我)的电话, 快递小哥可以根据电话来确定唯一的收货人是谁.(端口号能定位到这个主机中的某个进程,从而实现交互)
2.1.2 receive / send / close
- receive --- 从 socket 文件(网卡)中读取 UDP 数据包.
当有人向这里发送数据包后, receive 可以从网卡中将数据包读出来, 填充到 DatagramPacket 对象中(输出型参数中). 也就是说, 接受数据前(调用前), 需要先构造一个空的 DatagramPacket(非null, 只是未初始化).
- send --- 发送 UDP 数据包. 前提是, 有目的 IP 和 目的端口
- close --- 关闭 socket 文件
2.2 DatagramPacket
DatagramPacket 用来表示一个完成的 UDP 数据包, 数据包的载荷使用 字节数组(byte[]) 来存储.
2.2.1 构造方法
UDP 数据包的载荷数据(字节数组), 需要通过构造方法来指定:
2.2.2 getAddress / getPort / getSocketAddress
- getAddress => 得到源 IP
- getPort => 得到源端口
- getSocketAddress => 得到源 IP 和 源端口, 封装在一个 InetAddress 对象中
上面说到 DatagramSocket 可以理解为一个快递包裹
而 DatagramPacket 就是包裹中的物品, 也就是要传输的数据
3. UDP 回显 客服端-服务器
这里通过模拟回显客户端服务器来加强对 DatagramSocket DatagramPacket 的理解和使用.
所谓回显, 就是指 客户端的请求是啥, 服务器的相应就是啥.(请求和相应是一样的).
3.1 UDP 回显服务器
对于服务器来说, 应该有一个固定的端口号(用户可以根据需求, 精准的找到某个服务器),
3.1.1 给服务器指定固定端口号
构造 DatagramSocket 对象, 读写网卡中的数据, 并使用带参版本的构造方法给服务器指定固定的端口号:
3.1.2 运行服务器
第二步就是把服务器运行起来, 但是对于服务器来说, 是不知道用户什么时候来访问的, 所以要 7*24 小时的运行 => while(true).
一个服务器程序, 通常都有以下三点大的流程:
- 读取请求并解析
- 根据请求, 计算响应
- 把响应返回给客户端
3.1.2.1 读取请求并解析
使用 socket 的 receive 方法来读取用户发来的请求(一个 DatagramPacket 数据包), 这里需要注意的是, receive 使用 "输出型参数", 将读取到的数据填充到 DatagramPacket 对象中.
具体步骤如下:
- 构造 DatagramPacket 对象(报头 + 载荷), new 字节数组作为 UDP 数据包的载荷(报头我们不需考虑, DatagramPacket 对象底层已经实现).
- 调用 receive 方法读取请求, 填充到 DatagramPacket 对象中
- 将读取到的 UDP 载荷(字节数组, 二进制信息)取出来, 构造出一个 String 信息 (1. getdata 拿到字节数组 2. requestPacket.getLength 拿到数组有效信息的长度 3. 根据以上信息, new String() 构造字符串)
3.1.2.2 根据请求, 计算响应
根据请求, 计算响应. 这一步骤是服务器中最关键的步骤, 但是由于我们这里构造是回显服务器, 所以 请求内容就是响应内容.
3.1.2.3 返回响应
由于 UDP 协议本身是没有保存对端的信息的(没有保存目的IP/目的端口), 而返回响应的前提是明确 数据包要给谁发, 发到哪, 即在构造数据包时, 要指定好目的IP和目的端口.
首先, 根据响应的字符串(response)构造出 UDP 响应数据包(responsePacket).
构造相应数据包 responsePacket 的过程如下:
- 拿到字符串底层的字节数组(字符串底层就是通过字节数组(byte[] value)存储数据) => response.getBytes()
- 拿到字节数组的长度 => response.getBytes().length(); 注意:这里拿的必须是字节数组的长度, 而不是字符串的长度(字符 != 字节)
- 通过 DatagramPacket 的构造方法, 构造 UDP响应数据包.
- 响应数据包的目的IP和目的端口, 就是请求的源IP和源端口. 可以从 receive 得到的 请求数据包中得到源IP/源端口(requestPacket.getgetSocketAddress()).
注意:
请求数据包(requestPacket)作为输出型参数, 经过 receive 的接收填满数据后, 其 UDP 报头中包含了 源端口/目的端口, IP报头中包含了 源IP/目的IP. (虽然说 requestPacket 是一个 UDP 数据包, 但是 IP信息也是包含的)
构造完成 响应数据包(responsePacket)后, 调用 send 方法给客户端返回 响应.
3.1.2.4 记录客户端/服务器的交互过程
打印日志信息:
注意事项:
- socket 文件需要调用 close 方法来关闭吗?
答: 文件是否要关闭, 需要考虑文件对象的生命周期是怎样的. 虽然 socket 是一个文件资源, 但是它是不需要 close 的, 因为此处的 socket 会自始至终伴随整个 UDP 服务器(如果关闭文件, 那么服务器就不能使用了), 所以只有当服务器关闭后(进程关闭), 文件资源才可以关闭, 而进程关闭, 就会自动释放 PCB 文件描述符表中的所有资源, 也就不需要手动 close.
- 当 receive 没有接收到客户端的请求时, 服务器会忙等吗??
答: 不会. 当 receive 没有从网卡中读到请求时, receive 是会触发阻塞等待的. 当有请求发来时, receive 才会继续往下执行.
- 客户端给服务器发的数据, 就是字符串. 本身收到的 DatagramPacket 的二进制数据就是从 String 转来的, 我们在服务器上只是再把 二进制数据 转回 String.
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;/*** Created with IntelliJ IDEA.* Description: 服务器端* User: dings* Date: 2024-11-03* Time: 14:33*//*** 回显服务器端*/
public class UdpEchoServer {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. 接收数据// Packet对象相当于一个完整的 UDP 数据包// 这里的字节数组, 就是 UDP 载荷DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096);// requestPacket 作为 "输出型参数"// 从 socket 中读取请求, socket 可以看做一个包裹, 其中的物品就是数据socket.receive(requestPacket);// 将字节数组(读到的二进制数据), 转化为字符串, 只是转化其中的有效信息(getLength)String request = new String(requestPacket.getData(), 0, requestPacket.getLength());// 2. 根据请求, 计算相应(服务器最关键的一步)// 这里是回显服务器, 直接返回请求即可String response = process(request);// 3. 返回相应// 把构造好的数据包发送出去, 前提是数据包中包含了目的IP和目的端口// 不能直接发送. UDP 协调没有保存对端信息(目的IP和目的端口), 需要根据请求来得到目的IP和目的端口// 而响应的目的IP和目的端口, 就是请求的源IP和源端口// (构造方法传入的是目的IP和目的端口) (getAddress/getPort/getSocketAddress => 得到的是源IP/源端口)DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length, requestPacket.getSocketAddress());socket.send(requestPacket);// 4. 打印日志信息System.out.printf("[%s %d], request : %s, response : %s\n", requestPacket.getAddress().toString(), requestPacket.getPort(), request, response);}}/*** 根据请求, 计算相应* @param request* @return*/private String process(String request) {return request;}public static void main(String[] args) throws IOException {UdpEchoServer server = new UdpEchoServer(9090);server.start();}
}
3.2 UDP 回显客户端
3.2.1 给客户端随机分配空闲端口
对于客户端来说, 需要明确以下两点:
- 客户端是不能指定固定端口的(由操作系统随机分配一个空闲的端口)!!
- 在客户端中, 要明确所访问的服务器的IP和端口(指定目的IP/目的端口)!!
如果客户端指定的是固定端口, 那这个端口很可能已经被电脑上的其他程序给占用了(造成冲突), 运行时就会使客户端运行失败!! 而默认分配空闲端口, 就可以避免这种情况.
而为什么服务器可以指定固定端口呢??
- 因为服务器就是一个服务网站, 它必须是固定的, 不能变来变去, 要不然用户就找不到了!!
- 而且服务器是在程序员手中的, 即使出现端口冲突, 程序员也可以很快处理.
- 而客户端是在用户手中的, 程序员是无法控制用户的电脑的~
举个例子:
汤老湿在陕科大食堂18号卖东北熏肉大饼(IP: 陕科大食堂, 端口: 18号)
此时, 老湿的档口就是服务器, 而来买饼的学生就是客户端.
显而易见, 老湿的档口(服务器端口号)是不能变来变去的, 他卖饼就是在18号档口卖的, 不能今天一个地明天一个地, 这样学生就找不着了~~
而来买饼的学生(客户端), 买完饼后, 肯定也不是固定在一个座位上吃饼, 因为这是食堂, 很可能昨天的坐的位置已经被其他人占了(客户端端口冲突), 所以只能坐在一个没有人的位置上吃饼(空闲端口).
3.2.2 运行客户端
3.2.2.1 用户输入请求
从控制台读取用户请求:
3.2.2.2 构造请求数据包
注意: 请求是要发送给服务器的, 所以要在数据包中指定服务器的IP和端口.
(请求数据包 = 载荷(字节数组) + 目的IP/目的端口)
因为 serverIP 是字符串, 所以要通过 InetAddress.getByName 构造一个 IP 地址(目的IP).
3.2.2.3 发送请求数据包
调用 socket 的 send 方法, 发送 UDP 请求数据包:
3.2.2.4 接收服务器响应
发送请求后, 客户端会收到服务器的响应, 仍然需要构造一个空的数据包作为输出型参数, 填充 receive 读到的响应数据.
3.2.2.5 解析响应数据
最终, 运行客户端程序:
注意: "127.0.0.1" 是一个特殊的 IP(环回 IP), 表示当前主机(不管主机实际IP是啥, 都表示当前主机), 类似于 this.
由于此时我们写的 回显客户端-服务器, 是在同一台主机上的, 就可以使用 127.0.0.1 来访问.
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.SocketException;
import java.util.Scanner;/*** Created with IntelliJ IDEA.* Description: 客户端* User: dings* Date: 2024-11-04* Time: 8:03*//*** 回显 客户端*/
public class UdpEchoClient {private String serverIP;// 服务器IPprivate int serverPort;// 服务器端口号private DatagramSocket socket = null;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("请输入你的请求: ");if(!scanner.hasNext()) {break;}String request = scanner.next();// 2. 构造请求数据包(载荷(字节数组) + 目的IP/目的端口)// 后续会将请求数据包发送给服务器端, 所以, 请求数据包要指定服务器地址(目的IP/目的端口)DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length, InetAddress.getByName(serverIP), serverPort);// 3. 发送 请求数据包socket.send(requestPacket);// 4. 接收服务器返回的响应// responsePacket 作为输出型参数, 填充服务器返回的响应DatagramPacket responsePacket = new DatagramPacket(new byte[4090], 4090);socket.receive(responsePacket);// 5. 解析响应信息// 将响应中的二进制数据(字节数组)转化为字符串, 只转化其中的有效数据(getLength)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", 9090);client.start();}
}
END