目录
前言
1.网络通信
1.1基础概念
1.2协议分层
1.3封装和分用
编辑1.4总结
2.UDP 和 TCP 网络编程
2.1 UDP和TCP的区别
2.1.1可靠传输和不可靠传输
2.1.2面向字节流和面向数据报
2.1.3 有链接和无连接
2.1.4 全双工和半双工
2.2UDP网络编程
2.2.1UDP的socket api
2.2.2 UDP协议编写回显服务器 + 客户端
2.3TCP网络编程
2.3.1TCP的serversocket api 和 socket api
2.3.2 TCP 实现回显服务器端 + 客户端
服务器端:
客户端:
3.UDP报头的内部结构
4.TCP报头的内部结构和部分特性
4.1确认应答机制
4.2超时重传
4.3 连接管理(三次握手,四次挥手)
4.4滑动窗口
4.5流量控制
4.6拥塞控制
4.7延迟应答
4.8捎带应答
4.9面向字节流
4.10TCP异常情况的处理
总结
在简介UDP协议和TCP协议之前考虑到有些人可能网络这一块基础不是很牢固的铁子们,所以第一部分是先讲解网络编程中一些基础知识,如果有大佬已经提前了解过的话,就请直接滑到第二部分,那废话就不多说开启本篇博客啦。
1)IP地址:使用IP地址来描述网络上一个设备所在的位置(在通信的时候肯定是有明确的地址的吧,总不能虚空索敌吧)。
2)端口号:区分一个主机上不同的应用程序(如果有按照Mysql的可能就回有点印象,MySql默认是安装到3306端口的),端口就相当于在一个大的宿舍楼里面通过宿舍门牌号去区分每个宿舍。
一个网络程序,在启动的时候,都需要一个或者多个端口号,后续的通信过程都需要依赖端口号来进行
3)协议:描述了网络通信传输的数据的含义(协议就只是表示一种约定,这样的约定可以是任意的)
在网络通信中最重要的就是协议,在网络上不可能所有人电脑的都是出自一个厂家的,不然做电脑的就发了,生产电脑和电脑相关设备的厂家有很多种,此时为了能够让不同厂家生产的电脑可以进行交互,就需要有一份协议去约束它们了,让大伙按照同样的标准来研发设备,这下不同厂商搞出来“遥遥领先”的设备都可以一起相互通信了。
网络通信是一个非常复杂的工作,如果是光靠一个协议去管理这么庞大的工作的话,首先制定协议要考虑的因数就变得十分多,其次是会涉及到很多非常繁琐,非常细节的工作,最后用一个协议去管理的话,这个协议会变得非常复杂,非常庞大(一边说的重要,一边又把协议写的这么复杂),不便于日常的使用和学习
这个时候聪明的程序猿就想出了协议分层的策略了,上层协议调用下层协议 下层协议为上层协议提供服务,这是为了避免跨层级调用引起的混乱。
目前协议分层主要有两种方式
1)OSI七层网络模型(只是在教科书中存在,这里不会详细说明)
2)UDP/TCP 五层网络模型(真实世界的情况)
那五层具体涵盖有什么内容呢?
1.应用层:负责应用程序间沟通,如简单电子邮件传输(SMTP)、文件传输协议(FTP)、网络远程访问协议(Telnet)等。我们的网络编程主要就是针对应用层。
2.传输层:负责两台主机之间的数据传输。如传输控制协议 (TCP),能够确保数据可靠的从源主机发送到目标主机。
3.网络层:负责地址管理和路由选择。例如在IP协议中,通过IP地址来标识一台主机,并通过路由表的方式规划出两台主机之间的数据传输的线路(路由)。路由器(Router)工作在网路层。
4.数据链路层:负责设备之间的数据帧的传送和识别。例如网卡设备的驱动、帧同步(就是说从网线上检测到什么信号算作新帧的开始)、冲突检测(如果检测到冲突就自动重发)、数据差错校验等工作。有以太网、令牌环网,无线LAN等标准。交换机(Switch)工作在数据链路层。
5.物理层:负责光/电信号的传递方式。比如现在以太网通用的网线(双绞 线)、早期以太网采用的的同轴电缆(现在主要用于有线电视)、光纤,现在的wifi无线网使用电磁波等都属于物理层的概念。物理层的能力决定了最大传输速率、传输距离、抗干扰性等。集线器(Hub)工作在物理层。
上图就是分层的具体顺序,由图可以看出OSI七层协议和UDP/TCP五层协议其实没啥区别,OSL七层就是把五层中的应用层给分成了三层,在五层协议中我们以后主要打交道的就是应用层和传输层(后端开发)。
以qq或者微信发送消息为例去介绍网络数据传输的基本流程
在实现数据传输这个一重要前提是双方使用的协议是一样的不然是对方是解析不了的,qq有自己的协议
发送方(封装):
1.应用层
QQ应用程序,从输入框中获取到你要输入的信息,构造成应用层数据报(根据应用层的协议去构造的),这里我也不知道qq应用层具体的协议是啥样的,我在这里就假设一下下应用层协议是这样的,“发送人qq号,接收人qq号,时间,消息内容”(所谓的构造应用层数据报过程,就是以一定的格式去将数据以字符串的形式拼接),我们为了下面的过程有一个具体的数据进行画图,在这里假设一个数据出来。
接下来应用就会调用传输层提供的接口,把上述的数据交给传输层进行处理。
2.传输层
传输层的协议有非常多,其中最主要的协议就是我们后文会进行详细简介的UDP/TCP协议(这是两个协议嗷,不是一个东西),此处假设使用的是UDP协议,上面把数据从应用层传到了传输层,交给了UDP,于是UDP就要按照自己的协议格式,去生成一个UDP数据报了。
UDP并不会关系你应用层传下来的具体数据内容是啥,只是把应用层传下来的数据当作一个字符串,然后去构造出一个UDP数据报。
这里可以那发快递来比喻一下,负责贴标签的快递小哥拿到一个快递,他并不会关心要发的快递里面内容物是啥(只要不是什么太夸张的违禁品就行),快递小哥只会关心要多大的盒子才能放的下这个东西,进行包装后再贴上标签就传到下一个流程了,这里的UDP报头(标签)主要是“五元组”(ip 源端口 目的 ip 目的端口 协议类型 )中的源端口 和 目的端口
接下来还需要把数据传输给网络层了
3.网络层
网络层这块最主要的协议就是IP协议(后面的博客会进行讲解),此时IP协议也会根据自己的格式去构造以一个ip数据报(套娃)
IP协议也不会去关心这里载荷部分的内容是啥,只是单纯的把载荷当做一个字符串,在这个基础上拼接上拼接多一个IP报头,IP报头住哟啊放的就是源ip和目的ip。
到这就不得不提一下“五元组”了,源ip 源端口 目的 ip 目的端口 协议类型 ----> 网络通信中的“五元组”
上述这样的数据还需要进一步交给数据链路层
4.数据链路层
以太网,又会针对IP 数据报 进行进一步的封装再添加上数据头和数据尾
以太网也不关心载荷里是啥,只是把载荷当做字符串,进一步的拼接上顿头顿尾.构造成 以太网数据
5.物理层
硬件设备(网卡)
本质上都是二进制的数据(一组 0101 构成的)
硬件设备就需要对上述数据进行转换了~ 光信号/电信号/电磁波
下面就用一张图去描述数据在封装中的过程
接收方(分用):
1.物理层
当接收方的硬件设备接收到发送方发过来的光信号/电信号/电磁波,需要把得到的一大串二进制序列进行调解
接下来就会把这一个数据传输到物理层的上一层(数据链路层)
2.数据链路层
数据链路层的以太网协议,就会针对这个数据进行解析
接下来就会把载荷部分的内容传输给网络层(IP协议)
3.网络层
IP 协议针对这个数据报进行解析.去掉IP 报头,取出载荷,进一步的交给传输层
4.传输层
根据 ip 报头中的字段,就知道当前这个载荷是一个 UDP 数据报,交给 UDP 处理UDP 也是要针对数据报进行解析,去掉报头,取出载荷,进一步的交给应用程序.
5.应用层
UDP 报头中,有一个字段,目的端口.根据目的端口找到关联的 应用程序,就交给这个程序即可
qq 程序收到这个数据了.就会按照 qg 的应用层协议,进行解析
把这里的数据显示到界面上,在qq 中,对应的头像就开始闪烁,点进去,就能显示出这个新的消息,以及消息的时间啥的....
这里还是使用一张图去描述一下接收方分用的过程
主机 A,从上到下依次添加报头的过程,称为 封装主机 B,从下到上,依次解析报头的过程,称为 分用,每次网络数据的传输都需要经历这个过程,封装就是在打包快递 分用就是在拆快递,消息转发到某个设备,每个设备处理流程都是和上面的封装分用是一样的~~如果是一个交换机,交换机封装分用到数据链路层即可.交换机解析出以太网数据,进一步的获取到顺头中的“mac 地址”,根据 mac 地址查询交换机内部的转发表,确定接下来数据从哪个网口发出去.在发送之前又会重新把以太网数据帧封装好~~路由器则是封装分用到网络层.解析出 ip 数据包,获取到 ip 报头~~ 根据 i 报头中的的 进一步规划接下来要走的路线,接下来又会把这个数据进行重新封装,进行转发。上述描述的交换机路由器只是一个经典的交换机路由器,实际上现代的交换机路由器,会做更多事情,很可能是封装分用到应用层
在上面描述传输层的时候,讲到了传输层中有两个需要重点注意的协议,一个就是UDP,另外一个就是TCP,这两个协议也是本篇文章的重点,在讲解这两个协议的代码编写和特征之前,先来看一下这两个协议的区别(不可能效果一样的协议会同时存在的)
在这里先解释一下什么是可靠传输和不可靠传输,可靠传输的话就像你在淘宝跟客服小姐姐聊天,你发出的信息下面会有一个状态就是<未读>或者<已读>这么两个状态,这就代表我发出的消息是已经传输到对方那的,不存在说我消息没有发送过去,在聊天框中会出现这条消息标识为<已读>状态,就是能够感知到消息是否传输过去了(传输失败的时候软件会提醒你的,提醒的话我在微信见到的比较多),这就是可靠传输。不可靠传输就是你发出去的信息,你无法确认信息是否被对方收到,还是在传输的过程中丢包了(已读不回是一种悲伤)。
TCP协议在这就是可靠传输,而UDP在这块就是不可靠传输。注意这里并没有说哪种可靠传输一定比不可靠传输号的嗷,你安全性上去的同时,你的传输效率肯定是会下降的(不管你通过啥优化,都是比不过之前安全性不高的时候,有舍才有得),这里也是如此UDP协议的传输效率是高于TCP协议的传输效率的。
TCP和文件操作类型,都是“流”式的(由于这里传输的都是字节,所以称为字节流),而UDP是面向数据报,读写的基本单位是一个数据报(包含那里一系列的数据/属性)。
面向字节流和面向数据报的最大区别就是影响代码的编写。
在通过Java去编写MySql代码的时候,JDBC是先创建一个DataSource,在通过DataSource创建Connecton(连接),打电话,拨号,按下拨号线,直到对方拨通,这才算完成连接建立,TCP进行编写的时候,也是存在类似建立连接的过程的(在代码编写和特征分析的时候会有体现)。
发微信/短信。就就不需要进行建立连接操作就能进行通信了,客户端和服务器之间有一个“小本本”(内存)保存着对端的信息(端口,IP),此时“连接”就出现了,这里最大区别就在于,,一个客户端可以连接多个服务器一个服务器也可以对应多个客户端的连接(多对多)。
UDP和TCP在这块都是全双工,全双工和半双工区别就是,一个通道,是否可以双向通信,如果可以的话就是全双工,反之则是半双工。
UDP和TCP的区别还有很多,这块先讲解几个这两个协议本质的区别,在后面讲解协议特征的时候会详细说明
在UDP编程的时候有两个核心类,
1.DatagramSocket
DatagramSocket是一个socket对象,这里突然有冒出了一个Socket对象,别蒙,我来解释一下,操作系统,使用文件这样的概念来管理一些软硬件资源,操作系统也是使用文件的方式来管理网卡的,表示网卡这类文件就被称为Socket文件,在Java中的socket对象,就应该通过着系统里面的socket文件(最终还是要落到网卡上的),所以要进行网络通信就必须先有socket对象。
那来看一下DatagramSocket提供的构造方法和一些比较尝试用的方法
构造方法
方法名称 | 方法的功能 |
DatagramSocket() | 创建一个UDP数据报套接字的Socket,绑定到本机任意一个随机端口 (一般用于客户端) |
DatagramSocket(int port) |
创建一个UDP数据报套接字的Socket,绑定到本机指定的端口(一般用 于服务端) |
由上面这张表可以看出DatagramSocket这分了两种构造方法,主要原因是服务器端的端口一定要是固定的,不然你客户端先要使用这个服务器总是找不到,这里举个栗子,就把服务器端比做学校食堂的一个窗口,客户端比做我们,当我们有一天去食堂随便找了一个窗口点菜(这个就假设为24号窗口),我在这个窗口点完餐的时候就去找了一个位置等着菜做好(这里假设为36号位置),服务员会记录你坐的位置,当你吃完过了几天又突然想吃上次那个窗口的菜的时候,就会自然的去24号窗口点菜,而你这次坐的位置就不一定是36号位置了,而去63号位置(服务员还是会记录的的位置),这样就是正常的在学校吃饭的日常的吧。总不能食堂的窗口每天都变一个位置,然后每次想来吃的时候都得去一个一个窗口找之前那个窗口吧(我吃饭是坐的位置的可以随意变得,但是点菜的窗口一定得是固定的),别把食堂完成外面的流动商贩了。
这里DatagramSocket()不带参数的版本就对应着是系统随机分配的位置(哪有位置就去哪)。
所以客户端端口号就不能指定固定值,而服务器的就是要设定一个固定值,一个客户端的主机,上面运行的程序有很多,天知道你手动指定的那个端口是不是已经被其他程序占用了,让系统自动分配一个空闲的端口是更明智的选择,而服务器是完全在程序猿手里控制的,程序猿可以把服务器上的多个程序给安排好,让它们使用不同的端口。
DatagramSocket的基本方法
方法名称 | 方法功能 |
void receive(DatagramPacket p) | 从此套接字接收数据报(如果没有接收到数据报,该方法会阻 塞等待) |
void send(DatagramPacket p) |
从此套接字发送数据报包(不会阻塞等待,直接发送) |
void close() | 关闭此数据报套接字 |
以上的基本方法在下文的UDP代码编写中回去讲解,这里可以先看一下图表的介绍
2.DatagramPacket
DatagramPacket表示一个UDP数据报,在DatagramSocket的基本方法中外面不难发现在发送和接收请求的时候的参数都是DatagramPacket 实例化的对象,DatagramPacket是UDP Socket发送和接收的数据报(作为一个信息载体)。
那来看一下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 作为数据报,必须要能够承载一些数据,通过手动的byte[] 作为数据的空间
方法名称 | 方法功能 |
InetAddress getAddress() |
从接收的数据报中,获取发送端主机IP地址;或从发送的数据报中,获取接收端主机IP地址 |
int getPort() | 从接收的数据报中,获取发送端主机的端口号;或从发送的数据报中,获取接收端主机端口号 |
byte[] getData() | 获取数据报中的数据 |
getData udp数据报的载荷部分(完成的应用层数据报),基本的方法已经介绍完了,接下来就尝试使用UDP协议去编写一个回显客户端和服务器了(回显就是我客户端发啥内容,服务器端处理完是要返回一个一模一样的数据)
在这编写是有一个顺序的,建议是先编写出客户端的代码在去编写服务器端的代码
服务器端:
一上来的第一步可能是先去创建出一个DatagramSocket对象的(可以先让它为null,不着急实例化),然后在自定义构造方法去实例化DatagramSocket对象,博主在这先把这些基础的代码先完成了
//UDP的回显服务器
public class UdpEchoServer {
private DatagramSocket socket = null;
//服务器要绑定的端口
public UdpEchoServer(int prot) throws SocketException {
socket = new DatagramSocket(prot);//启动服务器
//java.net.SocketException 这里很容易会报错编写网络程序的时候经常见到
//端口号被占用
//对于服务器程序来说DatagramSocket不关闭问题也不大-----整一个程序中只有这一个socket对象,
//不是频繁创建的,这个对象的生命周期非常长(跟随这整一个程序),进程结束就把pcb回收了,
//里面的文件描述表也就销毁了 这里面的资源就被释放了(仅限于只有一个socket对象,并且生命周期跟随整一个进程)
}
public static void main(String[] args) throws IOException {
//服务器启动要固定绑到一个端口
UdpEchoServer server = new UdpEchoServer(9090);//手动分配端口号
}
}
然后我们在而外在去写一个方法去处理一下服务端的业务处理逻辑,这里就要注意一下编写服务器端的一个固定思路了,主要分为3个步骤,首当其冲的就是从客户端中读取请求,并解析,由于客服端和服务器端之间的数据传输是先要将数据进行打包(放入DatagramPacket实例化对象中的),所以我们在获取到数据的时候也是会获取到DatagramPacket对象的;
//1.读取请求,并解析 --- 固定套路
DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
socket.receive(requestPacket);//receive里面的参数DatagramPacket是一个输出的参数传入的是一个空的对象,出来的就变成给receive填充过数据的
//要判断是否有客户端有没有发请求过来 ---- 如果此时客户端的请求还没来,receive就会进入阻塞等待(知道有客户端请求进来)
从上面的代码可以看出,我们实现实例化了一个出一个requestPacket对象放到receive中去读取从客户端中传输的数据的,这可以指定大小博主这指定的是4kb,最大只能为64kb(至于为什么,在UDP的特性中会进行说明)。
当我们读取完数据之后就是对数据进行业务处理了,这才是整个客户端中最重要的部分,由于我们这要实现的是回显功能,就直接在专门处理业务逻辑的方法中返回一样的值就行了
这里规定一下下后面专门处理业务逻辑的方法名为process。
//2.根据请求,计算出响应
//这样的转字符的前提是,后续客户端发过来的是一个文本数据
String request = new String(requestPacket.getData(),0,requestPacket.getLength());//这里面的getLength是收到数据的实际的长度
String response = process(request);//计算出响应!!!! //requestPacket.getData()获取数据报中的数据
在拿到客户端数据的时候还需要对整个数据进行解析的就是通过requestPacket().getData()去获取数据,后面的参数就是在说明这个数据的长度。
当从客户端获取的这一个请求完成以后就可以将处理完成的数据在进行打包发送回去了
//3.把响应写回给客户端 ---- 固定套路
//此时需要告诉网卡,发的内容是啥(内容和长度(字节长度)),发给谁(客服端(ip和端口))
DatagramPacket dresponsePacket = new DatagramPacket(response.getBytes(),response.getBytes().length,
requestPacket.getSocketAddress());//这里的长度一定得是字节长度的,不然等等打印中文的时候可能会出错
//requestPacket.getSocketAddress()就是获取客户端的ip和端口(port)
socket.send(dresponsePacket);//发送(send)给客户端
//日志
System.out.printf("[%s:%d] rep: %s, resp : %s\n",dresponsePacket.getAddress().toString(),
requestPacket.getPort(),request,response);
在把得到的响应返还给客户端的时候要注意的点就有点点多了,主要的就是在将得到的数据打包放回的时候这里先要把数据数据转换成字节的形式,而且在下一个参数长度的时候一定要得是按照字节的情况去计算长度,不然很容易会出现bug,因为中文字符在String和以字节新式表示的时候的长度是不一样的,英文字符就没啥区别,关键的就是在要返回的响应中如果有中文字符就会寄。最后就是你返回总要给别人一个地址把(不然快递怎么到你手上),这就要用到前面为了从客户端读取数据时创建的requestPacket遍历了,它在读取客户端数据的时候会记录客户端的端口号和ip地址,此时就可以使用getSocketAddress()方法去将客户端的信息给读取出来了,最后就可以使用send方法将响应发送给客户端了,后面的日志是为了可以观察到这一整个过程,(注:从客户端发送的请求有时候不可能只有一条请求的,此时就需要while循环去优化一下代码了)。
下面是整一个UDP回显服务器端的代码
//UDP的回显服务器
public class UdpEchoServer {
private DatagramSocket socket = null;
//服务器要绑定的端口
public UdpEchoServer(int prot) throws SocketException {
socket = new DatagramSocket(prot);//启动服务器
//java.net.SocketException 这里很容易会报错编写网络程序的时候经常见到
//端口号被占用
//对于服务器程序来说DatagramSocket不关闭问题也不大-----整一个程序中只有这一个socket对象,
//不是频繁创建的,这个对象的生命周期非常长(跟随这整一个程序),进程结束就把pcb回收了,
//里面的文件描述表也就销毁了 这里面的资源就被释放了(仅限于只有一个socket对象,并且生命周期跟随整一个进程)
}
//使用这个方法来启动服务器
public void start () throws IOException {
System.out.println("服务器启动 !!!");
while(true) {
//反复的,长期的执行针对客户端请求处理的逻辑
//一个服务器,运行过程中,要做的事情,主要就3个逻辑
//1.读取请求,并解析 --- 固定套路
DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
socket.receive(requestPacket);//receive里面的参数DatagramPacket是一个输出的参数传入的是一个空的对象,出来的就变成给receive填充过数据的
//要判断是否有客户端有没有发请求过来 ---- 如果此时客户端的请求还没来,receive就会进入阻塞等待(知道有客户端请求进来)
//2.根据请求,计算出响应
//这样的转字符的前提是,后续客户端发过来的是一个文本数据
String request = new String(requestPacket.getData(),0,requestPacket.getLength());//这里面的getLength是收到数据的实际的长度
String response = process(request);//计算出响应!!!! //requestPacket.getData()获取数据报中的数据
//3.把响应写回给客户端 ---- 固定套路
//此时需要告诉网卡,发的内容是啥(内容和长度(字节长度)),发给谁(客服端(ip和端口))
DatagramPacket dresponsePacket = new DatagramPacket(response.getBytes(),response.getBytes().length,
requestPacket.getSocketAddress());//这里的长度一定得是字节长度的,不然等等打印中文的时候可能会出错
//requestPacket.getSocketAddress()就是获取客户端的ip和端口(port)
socket.send(dresponsePacket);//发送(send)给客户端
//日志
System.out.printf("[%s:%d] rep: %s, resp : %s\n",dresponsePacket.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(9090);//手动分配端口号
server.start();
}
}
看完这一整段代码,你会发现,诶怎么没有使用close方法,不怕内存资源泄露吗(这个博主有问题,去私信搞一下),其实如果是UDP协议编写的话就不大需要去考虑内存资源泄露的情况,因为UDP协议是无连接的,也就是说一个服务器是可以对于多个客户端进行服务的,此时就没啥关闭的必要了,反而你繁琐的去创建和销毁,消耗的资源会比较多一些。
客户端:
在客户端这块主要的就是从输入框中得到用户输入的指令,然后将指令打包传输给服务器端,然后就是解析服务器端返回的响应。
在客户端这块,我们在上面有使用过我们去食堂吃饭的案例的,即使想要去吃指定窗口的饭菜,那这的前提就是需要去记录它是几号食堂几号窗口的(对应的就是服务器端的ip地址和端口号),所以在创建客户端的时候创建Socket之前是需要得知服务器端的信息的
//回显客户端
public class UdpEchoClient {
private DatagramSocket socket = null;
private String serverip;
private int serverport;
//服务器的ip 和 服务器的端口
public UdpEchoClient(String ip,int port) throws SocketException {
serverip = ip;//服务器端ip
serverport = port;//服务器端接口
//这个new就让系统自动分配
socket = new DatagramSocket();
}
public static void main(String[] args) throws IOException {
//客户端就要访问我服务器绑定的端口
UdpEchoClient client = new UdpEchoClient("127.0.0.1",9090);
}
}
这里要注意的一点就是由于我是在一台机子上进行编码的,所以客户端和服务器端的ip地址都是一样的,都是本台机子的ip地址,就可以使用"127.0.0.1"来表示本台机子的ip地址,9090就是我在编写服务器端的端口号时手动指定的端口号。
实现完这些基本的逻辑,就到实现客户端的业务逻辑的时候了,在这还是定义一个start方式去实现客户端内部的业务逻辑。
这块首当其冲的肯定还是从控制太中读取用户输入的数据。
//1.从控制台读取用户输入数据
System.out.printf("->");
String request = scanner.next();
接下来就是构造亲求对象并发给数据,把从控制台得到的数据进行打包一下发送给客户端。
//2.构造请求对象,并发给服务器 ------ DatagramPacket数据报里面装的就是一个字符串 这里要转换成字节流发送给服务器端
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),request.getBytes().length,
InetAddress.getByName(serverip),serverport);
//发给哪个ip 发给哪个端口 ip这里需要通过方法把字符串形式的ip给转成InetAddress形式(使用到InetAddress的静态方法,getByName来进行构造)
socket.send(requestPacket);
在构造请求对象的时候一定要注意就是需要将数据先转换成字节,然后给出的长度也是字节形式数据的长度(原因和在服务器端的原因是原因的),这里比较关键的一个就是获取服务器端的ip,它跟获取端口号不同(直接让构造时的数据做为参数),这里需要使用到InetAddress.getByName();这个方法,就可以通过socket.sand(); 这个方法将它送给服务器端。
最后一步就是读取服务器的响应,并且解析出响应的内容。这里由于跟在服务器端所做的操作是差不多的,就只是不同场景下使用的顺序不同,这里就将整一块客户端实现的代码展示在大伙面前啦。
//回显客户端
public class UdpEchoClient {
private DatagramSocket socket = null;
private String serverip;
private int serverport;
//服务器的ip 和 服务器的端口
public UdpEchoClient(String ip,int port) throws SocketException {
serverip = ip;//服务器端ip
serverport = port;//服务器端接口
//这个new就让系统自动分配
socket = new DatagramSocket();
}
//让这个客户简反复的从控制台读取用户输入的内容,把这个内容构达成 UOP 请求,发给服务器,再读取服务器返回的 UDP 响应
//最终再显示再客户端的屏幕上
public void start () throws IOException {
Scanner scanner = new Scanner(System.in);
System.out.println("客户端启动!");
while (true) {
//1.从控制台读取用户输入数据
System.out.printf("->");
String request = scanner.next();
//2.构造请求对象,并发给服务器 ------ DatagramPacket数据报里面装的就是一个字符串 这里要转换成字节流发送给服务器端
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),request.getBytes().length,
InetAddress.getByName(serverip),serverport);
//发给哪个ip 发给哪个端口 ip这里需要通过方法把字符串形式的ip给转成InetAddress形式(使用到InetAddress的静态方法,getByName来进行构造)
socket.send(requestPacket);
//3.读取服务器的响应,并且解析出响应内容
DatagramPacket responsePacket = new DatagramPacket(new byte[4096],4096);
socket.receive(responsePacket);//读取服务器端返回的结果
String respones = new String(responsePacket.getData(),0,responsePacket.getLength());//长度为从0开始到responsPacket.getLength()的长度
//4.显示到屏幕上
System.out.println(respones);
}
}
public static void main(String[] args) throws IOException {
//客户端就要访问我服务器绑定的端口
UdpEchoClient client = new UdpEchoClient("127.0.0.1",9090);
client.start();
}
}
接下来就是UDP实现的回显服务器和客户端的实现,(注意,在打开程序的时候服务器要在客户端之前打开),博主这里试了一下在之后打开也是一样的,客户端这块在构造完之后会进入阻塞等待的状态,等到服务器的代码开始运行的时候一样也可以接收到的。
当然服务器这也是也可以连接多个客户端的,之前得是要去设置一下同一个进程可以多开。
在你的idea中找到这块地方,然后点进编程配置
在修改选项这块把允许多个实例给打勾然后保持选项就欧克克了。接下来就实验一下多个客户端的场景。
由上述服务器端的日志中可以体现,这三个打印请求都是来源于3个不同的端口发出的,这就很好的说明了,一个服务器对应多个客户端这一模式。
在UDP中负责创建UDP服务端的Socket api 为 DatagramSocket类,而早在TCP中负责创建服务器端的Socket的api 为 ServerSocket。那让我们先来了解了解ServerSocket的构造方法和在后面会使用到的一些基本的方法吧
方法名 | 方法功能 |
ServerSocket(int port) | 创建一个服务端流套接字Socket,并绑定到指定端口 |
注意了在TCP这块跟UDP不同,UDP是一个类分别去承保了服务器端和客户端的创建,因此DatagramSocket是提供了两套构造方法的(服务器端的是手动分配端口号,客户端是自动分配端口号),而在TCP这块是把创建服务器和客户端的Socket的类分成了两个,此处的ServerSocket就是用来专门服务服务器端的(server的中文就有服务器的意思)。
介绍完构造方法接下来就来看看serversocket一些基本的方法
方法名 | 方法功能 |
Socket accept() | 开始监听指定端口(创建时绑定的端口),有客户端连接后,返回一个服务端Socket 对象,并基于该Socket建立与客户端的连接,否则阻塞等待 |
void close() | 关闭此套接字 |
在前面介绍UDP协议和TCP协议之间的区别的时候,我们说到了UDP是无连接的协议,而TCP是有连接的协议,在TCP这块客户端想要使用服务器的话,在使用之前是会经历一个连接的过程的(三次握手),当建立完成连接以后,服务器端去调用accept方法就可以把已经建立连接成功客户端使用服务器提供的功能,如果服务器在调用accept方法是没有一个客服端是建立连接成功的话,服务器端的代码就会在accept这进入阻塞等待的过程,直到有客服端建立连接成功才会被唤醒。
在TCP端的服务器是一个线程对应一个客户端的(在下面的代码编写会体现出来),不同于UDP的服务器端,一个线程就可以完成多个客户端的请求,所以在TCP的服务器端这块每当一个客服端的请求完成要退出的时候,一定要对该服务器端进行close操作,避免内存资源泄露。
在讲完服务器端专属的Socket api,接下来就来看一下客户端的Socket api。
客户端这块的Socket api的名字就为Socket。
Socket 是客户端Socket,或服务端中接收到客户端建立连接(accept方法)的请求后,返回的服务端Socket。不管是客户端还是服务端Socket,都是双方建立连接以后,保存的对端信息,及用来与对方收发数据的,那就来看看socket的构造方法吧。
方法名 | 方法功能 |
Socket(String host,int prot) | 创建一个客户端流套接字Socket,并与对应IP的主机上,对应端口的进程建立连接 |
在客户端这里在构造一个客户端的socket的时候就直接把服务器端对应的信息给到参数就欧克克了。跟上面的去食堂吃饭是一个道理的。
socket的方法:
方法名称 | 方法功能 |
InetAddress getInetAddress() | 返回套接字所连接的地址 |
InputStream getInputStream() | 返回此套接字的输入流 |
OutputStream getOutputStream() | 返回此套接字的输出流 |
在这里就要注意一下了嗷,在前面讲解UDP和TCP的区别的时候,有提到一点就是UDP是面向数据据报传输的,TCP是面向字节流传输的,在编写UDP的回显服务器和客户端的时候可以很明显的发现,UDP的客户端和服务器端的信息交互时,交互的数据都是要提前“打包”成DatagramPacket后面在进行交互的,而在TCP这块就不一样了,由于是面向字节流,就意味着之前的在文件操作学到的InPutStream(读)和OutPutStream(写)这两个类里面对文件操作的方法,在TCP客户端和服务器端代码编写的时候时完全可以用的(注:在发送数据的时候还是要使用PrintWriter类进行打包的),TCP比UDP好一点的就是读取对方的数据的时候没有必要对对方发过来的数据进行解析,可以直接使用Scanner去接收
在这块就先把基本的搭建服务器端的Socket的基础代码先编写出来
//TCP 回显服务器
public class TcpEchoServer {
private ServerSocket serverSocket = null;
//绑定端口号
public TcpEchoServer(int pot) throws IOException {
serverSocket = new ServerSocket(pot);
}
public static void main(String[] args) throws IOException {
TcpEchoServer server = new TcpEchoServer(9090);
}
}
接下来就到TCP服务器端比较重要的一个步骤了 ---- 启动服务器,处理连接成功的客户端
//启动服务器
public void start() throws IOException {
System.out.println("服务区启动!");
while (true) {
//先处理客户端的连接(TCP是有连接的)
//这里的accept相当于就是一个揽客的,clientSocket就相当于揽过来销售的
Socket clientSocket = serverSocket.accept();//把内核中的连接给获取到引用程序中(生产者消费者模型)
//当服务器执行到accept的时候,此时客户端可能还没来(此时就会进行阻塞,阻塞到有客户端成功连接)
//accept是把内核中已经建立好的连接,拿到应用程序中,这个Socket对象就像一个耳麦一样,通过Socket对象和对方进行网络通信
processConnection(clientSocket);
}
}
这里的Socket.accept()对建立连接成功的客户端进行等待,就相当于生产者消费者模型(客户端为生产者,服务器端为消费者),accept如果把所有的建立连接的客服端都处理完了就会进入阻塞状态 。processConnection方法就是接下来处理服务器端逻辑的方法,注意了这块的代码还有会很多问题的(正常使用大部分是没啥问题的),在下面的优化部分我会一步一步的进行优化。
实现完了基本的代码部分,接下来就是服务器端业务处理部分的代码了。
//通过这个方法来处理一个连接的逻辑
private void processConnection(Socket clientSocket) throws IOException {
//服务器一启动就会进入accept阻塞,阻塞完就会执行这段代码
System.out.printf("[%s:%d] 客户端上线! \n",clientSocket.getInetAddress().toString(),clientSocket.getPort());
//接下来就可以读取数据,根据请求数据计算响应
//Socket
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
//inputStream就相当于耳机 outputStream就相当于麦克风
//一次连接中可能会涉及到多次请求
while (true) {
//1.读取请求并解析
Scanner scanner = new Scanner(inputStream);
if(!scanner.hasNext()) {
//读取完了,客户端下线
//在客户端没有发请求的时候,也会阻塞,如果客户端退出了,就会返回false
System.out.printf("[%s:%d] 客户端下线!\n",clientSocket.getInetAddress().toString(),clientSocket.getPort());
break;
}
String request = scanner.next();//这里默认规定是传输了一个文本数据,同时还得带有空白符作为分割(换行)
//2.根据请求计算响应
String respones = process(request);
//3.把响应写回给客户端,把OutPutStream 使用PrintWriter包裹一下,方便进行发数据
PrintWriter writer = new PrintWriter(outputStream);
// 使用PrintWriter 的println方法,把响应返回给客户端 ---- 这个是方便客户端通过scanner.next()进行读取
writer.println(respones);
//这里刷新缓冲区是为了确保数据真的通过网卡发出去了,而不是残留在内容
writer.flush();//刷新一下缓冲区
//日志
System.out.printf("[%s:%d] rep: %s,resp: %s\n",clientSocket.getInetAddress().toString(),clientSocket.getPort()
,request,respones);
}
}catch (IOException e) {
e.printStackTrace();
} finally {
try {
//在finally中加上close操作,确保当前的socket的关闭操作()
clientSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
这里要注意了嗷,在上面已经进行说明TCP是面向字节流的,所有我们使用InputStream 和 OutputStream对数据进行操作,它们在这块的关系就是耳麦功能,InputStream就是那个耳机接收客户端的数据,OutputStream就相当于麦 向客户端发送数据。这里在创建的时候使用到了try去创建InputStream和OutputStream的对象(这里解释一下啊,有try有一个功能就是在内部实例化的对象在出try的时候会自动进行销毁,刚刚好InputStream和OutputStream又是那种不用了就要进行销毁的类,所以这块使用了try(还一些其他的原因等会说明)),接下来就对代码进行一一讲解了嗷。
首当其冲肯定就是读取客户端的请求(日志不算嗷),由于是字节流的传输,所以直接可以使用Scanner.next(),进行接收数据
//1.读取请求并解析
Scanner scanner = new Scanner(inputStream);
if(!scanner.hasNext()) {
//读取完了,客户端下线
//在客户端没有发请求的时候,也会阻塞,如果客户端退出了,就会返回false
System.out.printf("[%s:%d] 客户端下线!\n",clientSocket.getInetAddress().toString(),clientSocket.getPort());
break;
}
String request = scanner.next();//这里默认规定是传输了一个文本数据,同时还得带有空白符作为分割(换行)
接下来的两个步骤都是和UDP是一样的,这块就先把代码截取出来
//2.根据请求计算响应
String respones = process(request);
//3.把响应写回给客户端,把OutPutStream 使用PrintWriter包裹一下,方便进行发数据
PrintWriter writer = new PrintWriter(outputStream);
// 使用PrintWriter 的println方法,把响应返回给客户端 ---- 这个是方便客户端通过scanner.next()进行读取
writer.println(respones);
//这里刷新缓冲区是为了确保数据真的通过网卡发出去了,而不是残留在内容
writer.flush();//刷新一下缓冲区
这块代码重要的部分其实是下面的两行代码,一是返回的时候要用啥方法,这块要在客户端编写的时候注意一下了,由于我们上面是使用了scanner.next()去接收数据的,也就以为着客户端发送的数据是以"\n"为结尾,所以我们在返回的时候也可以使用"\n"为结尾的格式进行发送数据,这里具体是以啥结尾就要跟编写客户端代码的人提前商量一下了(这块由于只是联系,客户端和服务器都是自己写,怎么爽怎么来就行),二是刷新缓冲区,这一个步骤就是为了避免有数据停留在缓冲区没有发送出去(当缓存区堆积到一定程度系统会自动的刷新一下的),在客户端的时候也是一样使用PrintWriter发送数据的数据就手动(代码)刷新一下。
最下面的就是对服务器的关闭了,至于为啥要放到catch里面进行关闭,其实就是为了服务器端在任何情况下可以成功进行关闭,这里就是为了避免在上面的代码出现异常的时候代码直接退出导致我下面的关闭操作没有成功运行到,这里把关闭的代码放到启动服务器的最后面也是可以的。
服务器端的代码基本上就完成的差不多了(process就是一个回显功能的方法,这个业务逻辑没啥要进行讲解的),服务端整体的代码放出来了(启动服务器端的优化在后面客户端的代码实现完成后才能体现出优化的效果)。
public class TcpEchoServer {
private ServerSocket serverSocket = null;
//此处使用可以灵活变化线程数的会好很多
private ExecutorService service = Executors.newCachedThreadPool();
//绑定端口号
public TcpEchoServer(int pot) throws IOException {
serverSocket = new ServerSocket(pot);
}
//启动服务器
public void start() throws IOException {
System.out.println("服务区启动!");
while (true) {
//先处理客户端的连接(TCP是有连接的)
//这里的accept相当于就是一个揽客的,clientSocket就相当于揽过来销售的
Socket clientSocket = serverSocket.accept();//把内核中的连接给获取到引用程序中(生产者消费者模型)
//当服务器执行到accept的时候,此时客户端可能还没来(此时就会进行阻塞,阻塞到有客户端成功连接)
//accept是把内核中已经建立好的连接,拿到应用程序中,这个Socket对象就像一个耳麦一样,通过Socket对象和对方进行网络通信
processConnection(clientSocket);//一万个客户端连接就有一万个socket对象
}
}
//通过这个方法来处理一个连接的逻辑
private void processConnection(Socket clientSocket) throws IOException {
//服务器一启动就会进入accept阻塞,阻塞完就会执行这段代码
System.out.printf("[%s:%d] 客户端上线! \n",clientSocket.getInetAddress().toString(),clientSocket.getPort());
//接下来就可以读取数据,根据请求数据计算响应
//Socket
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
//inputStream就相当于耳机 outputStream就相当于麦克风
//一次连接中可能会涉及到多次请求
while (true) {
//1.读取请求并解析
Scanner scanner = new Scanner(inputStream);
if(!scanner.hasNext()) {
//读取完了,客户端下线
//在客户端没有发请求的时候,也会阻塞,如果客户端退出了,就会返回false
System.out.printf("[%s:%d] 客户端下线!\n",clientSocket.getInetAddress().toString(),clientSocket.getPort());
break;
}
String request = scanner.next();//这里默认规定是传输了一个文本数据,同时还得带有空白符作为分割(换行)
//2.根据请求计算响应
String respones = process(request);
//3.把响应写回给客户端,把OutPutStream 使用PrintWriter包裹一下,方便进行发数据
PrintWriter writer = new PrintWriter(outputStream);
// 使用PrintWriter 的println方法,把响应返回给客户端 ---- 这个是方便客户端通过scanner.next()进行读取
writer.println(respones);
//这里刷新缓冲区是为了确保数据真的通过网卡发出去了,而不是残留在内容
writer.flush();//刷新一下缓冲区
//日志
System.out.printf("[%s:%d] rep: %s,resp: %s\n",clientSocket.getInetAddress().toString(),clientSocket.getPort()
,request,respones);
}
}catch (IOException e) {
e.printStackTrace();
} finally {
try {
//在finally中加上close操作,确保当前的socket的关闭操作()
clientSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
//回显
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer server = new TcpEchoServer(9090);
server.start();
}
}
首当其冲的还是先把socket和构造方法给创建好
//TCP回显客户端
public class TcpEchoClient {
private Socket socket = null;
//要先知道服务器的位置
public TcpEchoClient(String serverIp,int serverPort) throws IOException {
//这个new操作完成之后,就完成了tcp连接的建立
socket = new Socket(serverIp,serverPort);//过程很重要
}
public static void main(String[] args) throws IOException {
TcpEchoClient client = new TcpEchoClient("127.0.0.1",9090);
}
}
这块其实跟UDP的回显客户端差不多的,都是创建好socket对象,这里的new Socket操作其实就是在和TCP建立连接,当new操作完成了,连接也就建立完成了(具体的建立连接--三次握手在后面会讲解),接下来就开始实现客户端的基本逻辑了,其实这里代码都是差不多的(这里只是很普通的设计,重要的是知识点,后面的客户端设计可以慢慢去完善),由于是面向字节流都是要通过InputStream 和 OutputStream进行操作的,下面就把客户端这块的代码整块放出来了。
//TCP回显客户端
public class TcpEchoClient {
private Socket socket = null;
//要先知道服务器的位置
public TcpEchoClient(String serverIp,int serverPort) throws IOException {
//这个new操作完成之后,就完成了tcp连接的建立
socket = new Socket(serverIp,serverPort);//过程很重要
}
public void start() {
System.out.println("客户端启动");
Scanner scannerConsole = new Scanner(System.in);
try(InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
while (true) {
//1.从控制台读取数据
System.out.printf("->");
String resquest = scannerConsole.next();
//2.把请求发送给服务器
PrintWriter writer = new PrintWriter(outputStream);
writer.println(resquest);
//加一个刷新缓冲去 确保数据是真的发出去了
writer.flush();
//3.从服务器读取响应
Scanner scannerNetwork = new Scanner(inputStream);
String response = scannerNetwork.next();
//4.打印响应
System.out.println(response);
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws IOException {
TcpEchoClient client = new TcpEchoClient("127.0.0.1",9090);
client.start();
}
}
接下来就是开始运行找找bug了
当我们连接多个客户端的时候就发现,怎么我第二个客户端没有响应的,其实这里就是代码结构的问题
我们在这块的代码结构是clientSocket通过socket.accept获取到已经建立连接的客户端后就直接进入processConnection方法中了,哪剩下已经建立好连接的客户端在干啥,在等这个客户端结束连接,到这块就很容易就发现问题了,这的客户端和服务器好像是一对一的关系, 导致我启动第一个客户端以后想要启动第二个的时候就拉去“排队”了,这种场景好像有点熟悉,这不就多线程时面对的问题吗,我们直接把调用processConnection放到多线程就解决问题了,但是但是这里还有需要优化的一个部分,就是当用户量很多的时候,频繁的创建和销毁线程的开销还是蛮大的,这里就需要一位“英雄”登场了 ----- 线程池,线程池可以很大程度的优化这块的代码,这里就把优化过程的代码放出来了 。
//启动服务器
public void start() throws IOException {
System.out.println("服务区启动!");
while (true) {
//先处理客户端的连接(TCP是有连接的)
//这里的accept相当于就是一个揽客的,clientSocket就相当于揽过来销售的
Socket clientSocket = serverSocket.accept();//把内核中的连接给获取到引用程序中(生产者消费者模型)
//当服务器执行到accept的时候,此时客户端可能还没来(此时就会进行阻塞,阻塞到有客户端成功连接)
//accept是把内核中已经建立好的连接,拿到应用程序中,这个Socket对象就像一个耳麦一样,通过Socket对象和对方进行网络通信
//processConnection(clientSocket);//一万个客户端连接就有一万个socket对象
//这里要注意来进行关闭(在哪加都可以)
//单个线程不方便一边拉客一边介绍,开多线程,主线程负责接客,
//以上代码结构有问题,要开多线程进行accept的操作(这样就可以及时的处理客户端请求)
// Thread thread = new Thread( () -> {
// try {
// processConnection(clientSocket);
// } catch (IOException e) {
// e.printStackTrace();
// }
// });
// thread.start();
//再进一步优化使用线程池
service.submit(new Runnable() {
@Override
public void run() {
try {
processConnection(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
}
});
//这里还有问题,就是如果我服务器对应的客户端很多的时候,线程数目进一步增加
//此时系统的负担就会变得很重(高并发)----C10M
//解决的重要手段IO多路复用/IO多路转接
}
}
这个时候在让我们看一下在多个客户端的情况下的运行结果
运行是完全正常的,关于UDP和TCP回显服务器和客户端的编写到这就要结束了,接下来就是非常关键的内容了 要进一步的去了解UDP和TCP协议,这里有很多蛮有意思问题和知识点的。
在上面我们讲述过UDP协议相比于TCP协议的基本特点是无连接,不可靠传输,面向数据报的,全双工的。而且当数据从应用层封装到传输层的时候数据的前面会有一个UDP报头(传输层采用的是UDP协议的前提下),就有一个疑惑,蛙趣这个报头里面有啥东西,有啥功能需要去专门的进行加入,下面先把UDP的报文格式先放出来,在进一步的进行讲解
这里一上来就可以看到第一行存放的数据名称为源端口号和目的端口号(这些数据本质上是在同一层的,这为了展示方便一点就画成了这种形式),这里可以发现源端口和目的端口的大小都是16字节,也就是2bit位,在进一步的计算源端口号的大小和目的端口号的大小的范围是0~65535这一个方位(0是不会使用的),其中1~1024这个范围的端口号在生产电脑的时候就被系统赋予了特定的含义,也不允许使用(知名端口号),还有的就是16位UDP长度,也就是用来表示UDP载荷内容的大小,还记得在UDP实现服务器的时候我们创建了一个大小固定的DatagramPacket去接收对端发过来的数据的时候有说过,这个的大小绝对不能超过64kb对吧,原因就是在这里,超过范围的数据根本没有办法读取到。
接下来就是UDP校验和,假设一种生活场景,就是在我们要发送信息个对方的时候,数据一路从应用层封装下来到物理层要进行传输的时候,此时突然磁场变得很活跃(太阳有的时候会变得很活跃,就比如辐射出大量的高能粒子,就可能对地球上的通信造成影响),此时由于数据在物理层是已经转化成光信号/电信号进行传输的,就有可能在传输的路上遭到磁场的印象有些数据部分发生改变,此时如果接收方直接接收这个收到影响的数据,数据内容是出错的,就比如对方发的是"我愿意",然后这边收到的是"不愿意",这不就寄了,所以为了避免这一种情况的发生,接收方在收到数据之后,就需要先确认一下,这个数据是否是一个错误的数据,此时校验和就是比较简单有效的方式了。
这里拿生活中卖菜的场景先举例
在进行传输数据之前,会把数据报中的每个字节,都依此进行累加,把累加的结果保持到2个字节的变量中,如果加着加着加溢出了(校验和的范围位 0 ~ 65535)也没啥关系,只是会直接从0在开始加罢了,当所有的字节都加了一遍以后,最终就得到了校验和,在传输数据的时候,就会把原始数据和校验和一起传输过去,接收方在收到数据的时候,也同时会收到发送端送过来的校验和(旧的校验和),此时接收方就会按照同样的方法去计算发送过来的数据的校验和,的到新的校验和,如果旧的校验和和新的校验和,相同,就可以视为数据传输过程中,是正确的。如果不同,则视为传输过程中数据出错了.
数据相同 =>校验和相同
校验和不同 =>数据不同 (数学,逆否命题)
校验和相同 =>数据相同?? 不一定的!!! 逆命题不是逆否命题~
这里也可能会出现一种情况就是我数据收到磁场的影响很小,在整一个数据中(二进制数据),就只有一处的0变成了1,而也就只有一处的1变成了0,此时的到的校验和也是跟原来的数据的校验和是一样的,这就是有bug的地方,但是出现磁场波动的情况本身就比较小,如果在加上上述描述的情况就是少上加少的情况了(比中彩票还难),所以这种情况是存在,但不用过度担心。
当校验和验证完毕如果说是校验和不同会干啥,如果这里是UDP协议的话,就直接将这个数据给丢弃掉了,但如果是TCP协议的话(TCP协议内部也是有校验和的),此时就会要求对方重新发送数据(可靠性的一种体现),UDP的报头到这就差不多结束了,下面就是TCP的报头内容了。
让我们先来对几个简单的结构进行分析一下
端口号(源端口,目的端口),端口号是其中一个重要的部分,知道了端口号,才能进一步确认这个数据报交给哪个应用。
除开数据部分,剩下的就是报头的长度了(首部长度实际上就是在描述报头的长度),但是这块与UDP的有一点不同,就是TCP的报头是“变长”的,4bit表示的范围是0 -- 15 但是此处的单位是“4字节”,要把上面得到的结果乘于4才是真正的长度,也就是说TCP报头的最大长度位60字节,着其中前20个字节是固定的(TCP报头最短长度是20)剩下的就是选项里面的。
reserved,保留位,现在虽然先不用占个位置,后面可能需要用,保留位,就是给未来留下了可以升级扩容的空间,这里就是TCP协议比较好的一点,不想UDP协议一切内容都是固定死的(不好进行变化)。
接下来的内容就需要配合TCP内部的特性去进行讲解了
TCP的特点:有链接,可靠传输,面向字节流,全双工,内部实现的可靠传输,在外面编写代码的时候是感知不到的,TCP可靠传输实现的机制就是确认应答(并不是三次握手)。
给对方发一个数据的时候不知道有没有发送成功,此时就需要对方给一个应答,确认应答就是保证“可靠性”最核心的机制,可是当发送多条数据的时候,就可能会出现“后发先至”,就是后发的数据可能会出现在先发的数据前面(对于接收方)。由于数据在网络上传输的数据有很多,多条数据到达收件方中间的路径不一定相同,加上每个节点(路由器,交换机)繁忙程度不一样,此时这样的转发过程就可能会出现后发先至/
这块TCP的解决方法为针对数据进行编号
就像上面绘制的图表,当我们的数据传输给“老板”的时候是正常的数据,而“老板”发来的数据虽然出现了后发先至的情况,但由于我针对数据进行了编号,所以在我接收到信息的时候是可以正确的读取到信息的。
TCP将每个字节的数据进行编号,也就是所谓的序列化,由于TCP是面向字节流的,不是按照“条”为单位来传输的,针对字节的编号的,而不是针对“条”,应答报文也是要和收到的数据的序号相关联的,而不是“相等”。
这里的确认应答的意思是,1001序号之前的数据,这边已经接收到了
其中在TCP报头中的32位序号就是存放第一个字节的序号的地方 ,确认序号的数值就是收到的最后一个字节的编号加一
这里的32位确认序号就是给应答报文使用的,这里就会出现了两种报文,一是我们发送数据的普通的报文,另外一个就是确认数据到达的应答报文,哪系统要这么去区分这两种报文呢。
此时就需要使用到TCP报文中这6个小框框里面的其中一个了,ACK(天降猛男)
ACK这里只会是两个值,如果ACK为0时表示的就是一个普通报文,此时只有32号序号是有效的(32确认序号此时就是一个摆设),如果ACK为1时,表示这是一个应答报文,这个报文的序号和确认序号是有效的(应答报文也是有自己的序号的,跟确认序号各玩各的不会干扰)。
所以确认应答机制是TCP保证可靠性的核心机制(注:开始序号 + 长度 - 1 => 最后一个序号)。
但是实际上的场景可能会更复杂一点,这里说的情况就是丢包,在网络上传输数据很有可能会出现,发一个数据,然后丢了。
如果设备太繁忙了,后面新来的数据就需要等待的时间就变的很长,如果等待的太久的话就容易被丢弃,网络负载越繁忙,越容易出现丢包,如果出现丢包了,就自然不会收到应答了(对方都没接收到数据),此时这条数据就寄了,TCP针对等待一定时间后还没有等到应答,就做出了一个操作重传(超时重传)。
超时重传着有两种情况,
一是发的消息丢包了
二是应答报文丢包了
虽然情况不同,而且以上2种情况发送端无法区分是哪种情况,但是都会触发超时重传的机制,但是在第二种情况(应答报文丢包了),接收端同一条消息就会收到两次,可能会造成一些麻烦,所以接收方在收到重复的数据之后,需要对数据进行去重操作,把重复的数据丢弃掉,保证应用程序调用inputStream.read的时候,读到的数据不会出现重复,此时就可以通过tcp的序号来作为判定是否去重的判定依据。
tcp 会在内核中,给每个 socket 对象都安排一个内存空间,相当于一个队列,也称为“接收缓冲区收到的数据,都会被放到接收缓冲区里, 并且按照序号排列好顺序了,此时就可以很容易的找到该新收到的数据是否重复,此时的接受缓冲区 和 socket.read 就是一个生产者消费者模型,这里的有序排队也罢后发先至也被解决了(会将接收到得到数据进行排列),应用程序读到的数据也是正确的了,应用程序读到的数据也是正确的了。
超时重传这里等待超时时间不是一个固定的值,会随着超时的轮次越等越久,直到等的轮次太久了,就放弃重传了,而是尝试“重置”TCP连接 -- TCP 复位报文。
如果网络已经出现严重的故障(网线给老鼠吃了), 复位操作/重置操作,也无法成功的,此时就会放弃连接。
确认应答和超时重传机制是为可靠性机制(并不是安全机制)
1.建立连接(三次握手)
握手是我们日常生活中一种打招呼的方式,而在数据建立连接这里就需要握手去触发一些特定的场景,三次握手就是以为着 A和B完成建立连接的过程,就需要“三次”这样的打招呼的数据交互。
观察上图就会发现,这不是进行了4次交互吗,实际上B返回ACK(应答报文)的时候会顺带的返回一个建立连接的请求,之所以不分开是因为,数据在进行传输的时候是要经过封装和分用的过程的,还是会带有一点点资源开销在里面的,所以这里就将ACK和请求合并成一份去进行发送了(考虑到了成本问题)。
这张图中的SYN就是表示传输过来的报文时申请建立连接的请求(同步报文),
三次握手的第一次SYN(申请连接)一定是由客户端发起的(客户端是主动的一方),在客户端new Socket()的时候其实就开始进行三次握手了,new操作完成了以后三次握手就完成了,同时服务器这边针对三次握手是不需要涉及到任何应用层代码的,只要你这个进程是绑定了对于TCP端口就可以在内核中自动的配合三次握手,无论你服务器代码是咋写的。
三次握手的意义(初心);三次握手其实也是一种保证可靠性的机制(投石问路),三次握手中涉及的确认应答就是为了去试探客户端与服务器之间的网络通信和路线是否畅通,以及验证每个主机的发送能力和就收能力是否正常(就比如宿舍开黑的时候,需要使用带语言交互的时候,为了避免打团的时候有人发指令发现自己的麦有问题,或者自己的耳机有问题,在进入游戏之前大伙都会聊聊天(喂喂喂),确认一下对方的设备状态是否良好),三次握手之所以是三次的原因是因为恰好3次就能够验证完双方的发送和接收能力均正常,并且把信息同步给对方(如果是两次的话,服务器这边还不能知道验证通过的信息(客户端返回的ACK)),四次的话就相当于把中间那次拆成两次(开销大了一点,莫得必要)。
三次握手还能起到“消息协商”的效果,通信的时候都会涉及到一些参数,需要确保双方保持一致,此时就需要通过协商来确定参数具体是多次了(这里就是SYN里面的内容了)。
网络上传输的消息可能后发先至(先发后至)极端情况下,某个消息,迟到了,迟到了很久,当消息到达对端的时候,服务器和客户端已经断开了上一个连接,这是重新建立的连接了,这个时候 就可以通过序号,明显的识别出这个是上一个连接的消息,就可以丢弃了。
所以三次握手的初心主要就是两方面:
1.投石问路,验证通信路径是否畅通,双方的发送/接收能力是否正常
2.协商必要的参数,是客户端和服务器使用相同的参数进行消息传输
断开连接(四次挥手):目的就是释放资源,三次握手一定是客户端主动发起的, 但是四次挥手也可能是服务器端发起(大多数情况下是客户端主动发起)。
这里的FIN为1时就是结束报文段
fin是通过应用程序代码来触发的(close,或者进程结束),而不是通过内核来触发的,而ACK则是通过内核去控制的,收到fin就会立即返回ACK(秒回),而fin这要根据你的代码结构来进行的,比如服务器在收到fin的时候可能需要去完成一些收尾工作,此时就不能立即进行close操作发送fin给客户端,有因为ACK去硬等close的话也不知道要等多久(这里也是有时间成本的),所以这里的ACK 和 FIN 是分开进行发送的,如果当你的代码结构是收到对端的fin就马上进行close操作的话,此时的fin和ACK是可以一起发送的(所以有的时候四次挥手可以是三次挥手),主要看代码的结构是啥样的。
这里就会衍生出一个问题,如果我服务器端由于某些原因导致FIN结束报文段迟迟不能发送(始终不进行close操作)会咋样?
此时,服务器端的TCP就会处于CLOSE_WAIT状态,如果针对当前服务器的socket进行些操作就会触发异常,无论如何,这个连接已经寄了,关闭是唯一的选择,更极端的情况,如果代码写出bug了,close忘记写了,此时站在客户端的角度,迟迟收不到对方的FIN,也会进行等待,如果一直等还等不到的话,此时就会单方面断开连接(客户端直接把直接保证的对端的信息给删了,释放了)。
目标,释放资源.能双方都顺利释放,固然是最好.如果条件不允许,那也不影响咱们单方面释放
那如果通信过程中出现丢包了会咋样呢?这里也是涉及到了超时重传(三次握手也是一样),系统会尽可能的进行重传,如果重传仍然失败,连续多次,这里也会去选择进行单方面的断开连接。
如果最后一次ACK丢包了呢(客户端个服务器发的)
这里最后一个ACK还可能丢包的,此时的对端(B)就会重传一个FIN,如果A在发送ACK的时候就已经把来连接给断开了,重传的FIN就无人进行返回ACK了,所以这里需要让A在发出去最后一个ACK的时候,在与B保持一会联系(等对端是否还会再发一个FIN过来(前面发的ACK丢包了)),等一段时间后对端还是没啥反应,就可以彻底的断开连接了,A这里的等待时间就是网络上任意两点之间的最大时间乘于2(MSL)。
还有极端情况,比如,A 在等 2MSL 时间的过程中, B 在反复重传 FIN 多次,这些 FIN 都丢了.(理论上存在)如果真出现这个情况,当前 网络一定是出现严重故障了(网线给鼠鼠吃了)。=> 这个时候,是不具备“可靠传输”前提条件的.因此,A 就单方面释放资源,也无所谓了。
那现在再来问一下TCP 是如何实现可靠传输的?
确认应答 超时重传 连接管理(三次握手,四次握手),这些机制都起到了作用,但是真的起到决定性作用的还得是确认应答,三次握手只是保证连接的时候网络是没有问题的,但是网络环境是多变的,刚开始投石问路的时候是正常的,后面进行通信的时候就可能会发生变化(只保证了一小段),相比之下确认应答是保证每次传输数据都是可靠的。
在前面对比UDP协议和TCP协议的特点的时候,我补充了一句就是TCP可靠传输的传输效率是比UDP不可靠传输的传输效率慢一些的,这个时候就需要一些手段去适当提高一下TCP传输的传输效率了(不要太拉跨)。
正常来说客户端和服务器的通信是这样的。
这样传输的可靠性确实高了,但是大量的时间都消耗在等待ACK上了,太浪费时间了,此时就需要使用到滑动窗口去缩短一下等待时间
批量的发送一组数据,发送这一组数据的过程中就不需要等待ACK就直接往前发,此时就相当于使用等待一份ACK的时间去等待4个ACK(图表中是一下子发4个数据),把一次发多少数据不用等 ack 这样的大小,称为窗口,窗口越大,批量发送的数据也就越多,效率越高,但是传控的大小是不能无限大的,如果无限大就直接把所有数据直接发过去了,就相当于不必等待ACK,此时就和不可靠传输有啥区别,而且无限大服务器端是可能处理不来的。
在一下子发送4条数据的时候对端还是会先后返回2001,3001,4001,5001,的答应报文的,此时A已收到2001的答应报文就会立即发送5001-6000的数据保证还是用等一个ACK的时间在等4个ACK,如果是要等到4个ACK都放回在去进行下一轮数据传输的还是会比较费时的,这样就可以缩短等待时间,比之前能提升一定的效率(但是还是要等的,所以怎么样都快不过UDP)。
那么这里有衍生出来一个问题,中间丢包了怎么办(提高了效率的同时,必要不应该影响到可靠性(TCP的招牌可不能就这么没了))。
这里就需要等两种情况了
1.ACK丢了
如果是ACK丢包了是没啥影响的,因为后面的数据的应答报文是包含前面的,就比如说数据1-1000的应答报文没了(1001),但是1001-2000的应答报文(2001)能成功返回到对端的话就相当于把前面丢失的报文一起返回了,因为应答报文的意思是2001前面的数据都已经接收成功了,数据在传输的时候又是已经排好序的,所以只要这一组数据有一条的应答报文传输成功就没啥问题,除非是这一组的应答报文全丢了(鼠鼠饿了),网络出现问题了。
2.数据报丢了
就根据上面那张图的情况进行分析,此时B端是向A端索要1001这个数据的内容,但是1001开头的数据在传输的过程中丢包了,只有2001开头的数据和后面的数据传输过来了,此时观察B端返回的ACK报文,
B端会一直返回1001数据的报文,等到这一组数据发送完成之后,A端发现都是1001的应答报文就会意识到1001的数据在传输过程中丢包了,就会给B端重新发送1001的数据,当B端得到1001的数据的时候,就会返回上面传输的最后一条数据的应答报文了(此处是7001).
如果接收缓冲区,这一块是少了的,返回的 ACK 就会始终索要 1001 这个数据报,一旦 1001 这个数据报被补上了此时,1001-2000 后面的数据都不必重新传输了(都在缓冲区里待着呢)接下来就看后面的数据哪来是否还有缺失的,如果还有缺失,就继续索要缺失的数据,如果没有缺失,就直接索要缓冲区最后一条数据的下一个 就 欧克克了。
只有通信双方大规模传输数据的话,此时就会使用到滑动窗口,如果是通信双方传输的数据规模比较小的话,此时滑动窗口也滑不动的。
这里把只有通信双方大规模传输数据的话,此时就会使用到滑动窗口称为快重传,一般的情况称为超时重传。(其实也就是超时重传的一种衍生)。
作为滑动窗口的补充,滑动窗口,窗口越大传输效率越高.但是窗口也不能无限大.如果窗口太大了,就可能使接收方处理不过来了.或者是使传输的中间链路出现处理不过来,这样就会出现丢包就得重传了窗口大并没有提高效率,反而还影响了效率,流量控制就是给滑动窗口踩踩刹车(避免窗口过大)。
因此,流量控制就是根据根据接收方的处理能力,来限制发送方的发送速度(窗口大小),如果剩余的空间越大就说明应用程序消费数据的速度就越快,此时,就会直接把接收缓冲区的剩余的空间大小,通过ACK报文反馈给对方,作为发送方下一次发送数据时窗口大小的依据。
这个字段只是对于 ack 报文才有意义这个数字,就表示了当前接收方缓冲区剩余空间大小.这个数字返回给发送方,就可以作为发送方下一轮发送的参考依据了,这里的16位就代表了窗口大小最大是64kb吗?
其实不是,在选项中有一个选项是窗口大小扩展因子实际的窗口大小 是 16 位窗口大小 << 扩展因子此时就可以使能够表示的窗口大小。
所以滑动窗口的大小是实时变化的 。
当我们的接收缓冲区慢了的时候,虽然A端是不会再发送数据给B端了,但是也不知道B的接收缓冲区啥时候能腾出空间出来,所以为了区试探B的接收缓冲区是否有空间,A端就会周期性的发送一个“窗口探测包”(不会携带具体数据),只是为了触发一下ACK(查询当前接收缓冲区的情况) ,一旦返回的窗口大小的数值不是0就可以接着发送数据了,接收方就可以根据窗口大小,来反向限制发送方的传输速度了但是还要考虑中间链路的处理能力(拥塞控制)。
总的传输效率是一个木桶效应,它取决于“最短板”, 中间如果有某个环节,转发能力特别差,此时A的发送数据就不应该超过这里的阈值。
A端和B端中间的链路传输就数据在物理层进行传输的设备,这些设备一般都是运营商提供好的路由器交换机等,一般来说这些中间设备的转发能力很强的,但是也遭不住用户量大的时候,就比如道路在宽也会有堵车的时候,中间如果有某个环节,转发能力特别差,此时A的发送数据就不应该超过这里的阈值,那么应该怎么去优化才能提高我们的传输效率呢?
这里肯定不是针对中间设备进行优化的(我们不是中间设备的运营商,无权对这些设备进行优化),那此处就只能针对数据的传输量进行优化了,这里采取“实验”的方式,动态调整窗口大小,使得产生出一个合适的窗口大小。
1.使用一个比较小的窗口进行传输,如果传输畅通,就调大窗口
2.使用一个比较大的窗口进行传输,如果出现丢包(中间设备出现拥堵),就调小窗口。
这样的话也可以非常好的适应网络环境的动态变化,TCP中拥塞控制的具体展开如下
1.慢启动:刚开始进行通信的时候,会先使用一个非常小的窗口进行传输数据(先试试水)
2.指数级增长:在传输畅通的情况下,拥塞窗口(在拥塞机制下,采用的窗口大小)就会指数级增长(*2,增长速度极快)。
3.线性增长:指数增长当拥塞窗口达到一个阈值之后,就会从指数增长 -> 线性增长。
线性增长,也是增长,就会使发送速度越来越快,快到一定程度的时候,接近网络传输的极限,就可能会出现丢包。
4.拥塞窗口回归小窗口:当窗口大小增长过程中,如果传输的数据出现丢包的情况,就会认为当前网络出现拥堵,此时就会把窗口大小调整成最初的大小(小窗口),继续回到之前 指数增长 + 线性增长 的过程另外此处也会根据当前出现丢包的窗口大小,调整闯值(指数增长 -> 线性增长)。
具体的过程就如本图所示,这样的调整就可以非常好的适应多变的网络环境,这里也是有不少的性能损失的(每次回到慢开始的时候,都会使传输速度大打折扣),因此这块还有别的优化算法,在这就不进行展开简述了。
那这里岂不是会跟上面的流量控制产生一些冲突,其实不会,实际的发送方窗口 = min(拥塞窗口,流量控制窗口),此处不光是要考虑接收方的处理能力也要考虑中间节点(设备)的处理能力,只有在拥塞窗口和流量控制的配合下,才能使滑动窗口机制在可靠性的前提下,提高效率。
也是提高传输效率的机制(还是围绕滑动窗口),延迟应答其实就是去优化流量控制的一个操作,在上面的流量控制的时候我讲述到,当A端(发送消息端)发送数据给B端(接收端)的时候,每一次当B端放回ACK的时候都会去多返回一条窗口大小去作为A端下一次发送数据的时候窗口大小的参考,此处的延迟应答就是去优化了返回的窗口大小,当A端的数据到达B端的时候,B端不会立即的去返回ACK报文,而是先等一会,因为可能就在等的时间内B端可以多消费一些数据,B端的接收缓存区剩余的空间可能会变多,所以这里让ACK(确认应答)稍微延迟一点发送,可以使放回的窗口大小在扩宽一点。
此处通过延时应答,能提高多少速度,还是要取决于接收方应用程序的实际的处理能力了,(给你机会要中用啊),实际上还是得看接收方处理数据的能力的,如果是在处理的很慢,给再多时间也是没啥用的。
在延迟应答的基础上,引入的一个进一步提高效率的方式,客户端和服务器端之间的交互主要使一问一答的方式,就像建立连接和断开连接的时候,但是我们可以对数据报进行优化。
也就是四次握手优化成三次握手的情况
数据报从两个合并成一个,效率会有明显的提升的(主要还是因为每次传输数据都需要封装分用的),这里可以进行合并的原因,一是时机上是可以同时的(不像四次挥手,无法把握对端调用close的时机),另一方面是ACK数据是不需要携带载荷的,和正常的数据不冲突,完全就可以让一个数据报,既能携带载荷(三次握手这种情况中载荷部分就是SYN),又能带有ACK信息。
在面向字节流传输的时候,会产生出一些问题。其中最重要的就是粘包问题。
就那这种情况进行举例,接收方的应用程序,读取这里的数据read 可能是一次读一个字节或者一次读若干个字节.没有办法 一次读一个应用层数据报,此时这件事本身在传输层已经无解了,就需要站在应用层来解决这个问题了(换个屁股想想问题)。
此处有两个解法。
1).在应用层协议中,引入分割符区分包之间的边界:
1.使用分隔符
此时接收方的应用程序就可以通过\n来区分应用数据报的边界了,在前面我们实现TCP客户端和服务器的时候就是采用\n来区分数据的。
2.使用包的长度
使用最开头固定的字节表示长度
2).这个其实就是需要在编写代码之前规定好应用层协议中包的大小,主要偏向去规定协议。
通过上述过程,就完成了整体的读取和解析的过程,只要是面向字节流的机制(文件)都会存在粘包问题,但是解决方案都一样,要么分隔符,要么使用长度。
网络本身就会存在一些变数,会导致TCP连续不能正常进行工作,此时就需要TCP在这种环境下去做出相应的判断和行动了。
1),进程崩溃
只要是面向字节流的机制(文件)都会存在粘包问题,但是解决方案都一样,要么分隔符,要么使用长度,崩溃的这一方就会发送FIN进一步触发四次挥手此时连接就正常释放,此时连接就正常释放
2).主机关机(正常步骤的关机)
正常关机,就会先尝试干掉所有的进程(强制终止进程)就和上面的进程崩溃的处理一样的,主机关机是要一定时间的,在这个时间之内,四次挥手可能是刚刚好挥完了,如果没挥完了(也不要紧),如果没有挥手挥完就关机了,另一方还是可以进行单方面释放的
3)主机掉电(拔电源,没有任何反应的空间)
此时如果是台式电脑就直接寄了,这里假设A端是掉电端,此时A不可能再给B端发送FIN报文了,这里还要分为两种情况
a).如果B正在给A发消息(接收方掉电)
此时B发给A的消息就不可能收到ACK报文了,B接收不到报文就会进行超时重传,重传仍然失败就会触发复位报文(RST),尝试重连操作仍然失败的话,此时B端就会单方面进行断开连接。
b).如果A正在给B发送消息(发送方断电)
此时的情况会更复杂一些,因为B在等待A的消息,A突然就不发消息了,B不知道A是等会就把继续发还是已读不回(悲剧),B就会进行阻塞等待,具体要等多久B也不知道,这种情况可以想想前面流量控制的探测包,流量控制的时候,当对端的接收缓冲区满了以后就会进入等待,认识人家也不是干等着,而是会周期性的发送一个不携带数据的报文去触发对端的ACK报文,此处其实也是采用这种方法去解决问题的。
此处就涉及到"心跳包”,B 这边虽然是接收方,也会周期性的给对方发起一个 不携带任何 业务数据(载荷) tcp 数据报.发起这个包的目的,就是为了触发 ack. 就是确认一下 A 是否正常工作/确认网络是否畅通,虽然TCP中已经有心跳包的支持,但是还不够,往往还需要在应用层,应用程序汇中重新实现心跳包,(TCP的心跳包的周期太长了,是分钟级的),而且在高并发的场景下,分钟级的是绝对不够用的。
4)网线断开 (相当于主机掉电的高级版本)
TCP协议和UDP协议相比优势在于可靠性,适用于绝大部分场景,UDP优势在于效率,适合于机房内部主机之间通信(机房内部带宽比较充裕,不太容易遇到拥堵丢包的情况,又希望主机之间通信速度能比较快),各有各的适用环境,没有必要去否定某个协议的优势区间,也有两个协议都不适用的区间的(竞技游戏),此时的需求未可靠性和效率都要高,此时是要TCP或UDP都不一定合适,就需要进一步的对协议优化或者采用其他协议了,那么本篇文章到这就差不多该结束了,如果对文章有啥问题就欢迎在评论区进行发言嗷,886。