TCP和UDP详解
转载用于收藏学习:原文链接
TCP和UDP详解
计算机网络知识扫盲:https://blog.csdn.net/hansionz/article/details/85224786
网络编程套接字:https://blog.csdn.net/hansionz/article/details/85226345
HTTP协议详解:https://blog.csdn.net/hansionz/article/details/86137260
前言:
本篇博客介绍TCP协议和UDP协议的各个知识点,这两个协议都是位于传输层的协议,我们首先从传输层谈起。
传输层:
传输层是TCP/IP协议五层模型中的第四层。它提供了应用程序间的通信,它负责数据能够从发送端传输到接收端。其功能包括:一、格式化信息流;二、提供可靠传输。为实现后者,传输层协议规定接收端必须发回确认,并且假如分组丢失,必须重新发送。
再谈端口号:
在网络知识扫盲博客中谈到端口号标识了一个主机上进行通信的不同应用程序。在TCP/IP协议中, 用"源IP", "源端口号", "目的IP", "目的端口号", "协议号" 这样一个五元组来标识一个通信(可以通过 netstat -n查看,协议号指的是那个使用协议)。
一个进程可以绑定多个端口号,但是一个端口号不能被多个进程绑定。
端口号范围划分:
0 - 1023: 知名端口号,HTTP、FTP、 SSH等这些广为使用的应用层协议他们的端口号都是固定的,自己写的程序中,不能随意绑定知名端口号。
1024 - 65535:操作系统动态分配的端口号。 客户端程序的端口号,就是由操作系统从这个范围分配的。
常见的知名端口号:
ssh服务器:22端口
ftp服务器:21端口
http服务器:80端口
telnet服务器:23端口
https服务器:443端口
MYSQL服务器:3306端口
在Linux操作系统中使用命令cat /etc/services可以看到所有的知名端口。
netstat工具: 用来查看网络状态
n 拒绝显示别名,能显示数字的全部转化成数字
l 仅列出有在Listen (监听)的服务状态
p 显示正在使用Socket的程序识别码和程序名称
t (tcp)仅显示tcp相关选项
u u (udp)仅显示udp相关选项
a (all)显示所有选项,默认不显示LISTEN相关
pidof [进程名]: 可以根据进程名直接查看服务器的进程id。例如:pidof sshd。
UDP协议,UDP协议报文格式:
16位UDP长度表示整个数据报(UDP首部+UDP数据)的长度
如果校验和出错,就会直接丢弃(UDP校验首部和数据部分)
UDP协议的特点:
无连接:只知道对端的IP和端口号就可以发送,不需要实现建立连接。
不可靠:没有确认机制, 没有重传机制。如果因为网络故障该段无法发到对方, UDP协议层也不会给应用层返回任何错误信息。
面向数据报: 应用层交给UDP多长的报文, UDP原样发送既不会拆分,也不会合并。如果发送端调用一次sendto, 发送100个字节, 那么接收端也必须调用对应的一次recvfrom, 接收100个 字节,而不能循环调用10次recvfrom, 每次接收10个字节。所以UDP不能够灵活的控制读写数据的次数和数量。
UDP的缓冲区:UDP存在接收缓冲区,但不存在发送缓冲区。
UDP没有发送缓冲区,在调用sendto时会直接将数据交给内核,由内核将数据传给网络层协议进行后续的传输动作。为什么UDP不需要发送缓冲区? 因为UDP不保证可靠性,它没有重传机制,当报文丢失时,UDP不需要重新发送,而TCP不同,他必须具备发送缓冲区,当报文丢失时,TCP必须保证重新发送,用户不会管,所以必须要具备发送缓冲区。
UDP具有接收缓冲区,但是这个接收缓冲区不能保证收到的UDP报文的顺序和发送UDP报的顺序一致,如果缓冲区满了再到达的UDP数据报就会被丢弃。
UDP接收缓冲区和丢包问题:https://blog.csdn.net/ljh0302/article/details/49738191
UDP是一种全双工通信协议。 UDP协议首部中有一个16位的大长度. 也就是说一个UDP能传输的报文长度是64K(包含UDP首部)。如果我们需要传输的数据超过64K, 就需要在应用层手动的分包, 多次发送, 并在接收端手动拼装。
常见的基于UDP的应用层协议:
NFS:网络文件系统
TFTP:简单文件传输协议
DHCP:动态主机配置协议
BOOTP:启动协议(用于无盘设备启动)
DNS:域名解析协议
程序员在写UDP程序时自己定义的协议
TCP协议
TCP全称传输控制协议,必须对数据的传输进行控制。
TCP协议报文格式:
源端口号/目的端口号:表示数据从哪个进程来,要到那个进程去
32位序号:序号是可靠传输的关键因素。TCP将要传输的每个字节都进行了编号,序号是本报文段发送的数据组的第一个字节的编号,序号可以保证传输信息的有效性。比如:一个报文段的序号为300,此报文段数据部分共有100字节,则下一个报文段的序号为401。
32位确认序号:每一个ACK对应这一个确认号,它指明下一个期待收到的字节序号,表明该序号之前的所有数据已经正确无误的收到。确认号只有当ACK标志为1时才有效。比如建立连接时,SYN报文的ACK标志位为0。
4位首部长度(数据偏移): 表示该TCP头部有多少个32位bit(有多少个4字节),所以TCP头部大长度是15 * 4 = 60。根据该部分可以将TCP报头和有效载荷分离。TCP报文默认大小为20个字节。
6位标志位:
URG:它为了标志紧急指针是否有效。
ACK:标识确认号是否有效。
PSH:提示接收端应用程序立即将接收缓冲区的数据拿走。
RST:它是为了处理异常连接的, 告诉连接不一致的一方,我们的连接还没有建立好, 要求对方重新建立连接。我们把携带RST标识的称为复位报文段。
SYN: 请求建立连接; 我们把携带SYN标识的称为同步报文段。
FIN:通知对方, 本端要关闭连接了, 我们称携带FIN标识的为结束报文段。
16位的紧急指针:按序到达是TCP协议保证可靠性的一种机制,但是也存在一些报文想优先被处理,这时就可以设置紧急指针,指向该报文即可,同时将紧急指针有效位置位1。
16位窗口大小:如果发送方发送大量数据,接收方接收不过来,会导致大量数据丢失。然后接收方可以发送给发送发消息让发送方发慢一点,这是流量控制。接收方将自己接收缓冲器剩余空间的大小告诉发送方叫做16位窗口大小。发送发可以根据窗口大小来适配发送的速度和大小,窗口大小最大是2的16次方,及64KB,但也可以根据选项中的某些位置扩展,最大扩展1G。
16位校验和:发送端填充,CRC校验。如果接收端校验不通过, 则认为数据有问题(此处的检验和不光包含TCP首部也包含TCP数据部分)。
确认应答机制:
接收端收到一条报文后,向发送端发送一条确认ACK,此ACK的作用就是告诉发送端:接收端已经成功的收到了消息,并且希望收到下一条报文的序列号是什么。这个确认号就是期望的下一个报文的序号。
每一个ACK都带有对应的确认序列号,意思是告诉发送者,我们已经收到了哪些数据,下一个发送数据应该从哪里开始。 如上图,主机A给主机B发送了1-1000的数据,ACK应答,携带了1001序列号。告诉主机A,我已经接受到了1-1000数据,下一次你从1001开始发送数据。
超时重传:
TCP在传输数据过程中,还加入了超时重传机制。假设主机A发送数据给主机B,主机B没有收到数据包,主机B自然就不会应答,如果主机A在一个特定时间间隔内没有收到主机B发来的确认应答,就会进行重发,这就是超时重传机制。
当然还存在另一种可能就是主机A未收到B发来的确认应答,也可能是因为ACK丢失了。
因此主机B会收到很多重复数据,那么TCP协议需要能够识别出那些包是重复的包, 并且把重复的包丢弃掉,这时候我们可以利用前面提到的16位序列号, 就可以很容易做到去重的效果。
超时重发的时间应该如何确定?
在理想的情况下,可以找到一个小的时间来保证 "确认应答"一定能在这个时间内返回。但是这个时间的长短,随着网络环境的不同是有差异的。如果超时时间设的太长,会影响整体的重传效率。如果超时时间设的太短,有可能会频繁发送重复的包。TCP为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间。
Linux中超时时间以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍。如果重发一次之后,仍然得不到应答,等待2*500ms后再进行重传。如果仍然得不到应答,等待4*500ms进行重传。依次类推,以指数形式递增,当累计到一定的重传次数,TCP认为网络或者对端主机出现异常,强制关闭连接。
连接管理机制
在正常情况下, TCP要经过三次握手建立连接,四次挥手断开连接。
三次握手及四次挥手:https://mp.csdn.net/mdeditor/86495932
TIME_WAIT状态: 当我们实现一个TCP服务器时,我们把这个服务器运行起来然后将服务器关闭掉,再次重新启动服务器会发现一个问题:就是不能马上再次绑定这个端口号和ip,需要等一会才可以重新绑定,其实等的这一会就是TIME_WAIT状态。
TCP协议规定主动关闭连接的一方要处于TIME_ WAIT状态,等待两个MSL的时间后才能回到CLOSED状态。
当我们使用Ctrl-C终止了server,server是主动关闭连接的一方在TIME_WAIT期间仍然不能再次监听同样的server端口。
MSL在RFC1122中规定为两分钟(120s),但是各操作系统的实现不同,在Centos7上默认配置的值是60s可以通过
cat /proc/sys/net/ipv4/tcp_fin_timeout查看MSL的值。
为什么TIME_WAIT时间一定是2MSL:
首先,TIME_WAIT是为了防止最后一个ACK丢失,如果没有TIME_WAIT,那么主动断开连接的一方就已经关闭连接,但是另一方还没有断开连接,它收不到确认ACK会认为自己上次发送的FIN报文丢失会重发该报文,但是另一方已经断开连接了,这就会造成连接不一致的问题,所以TIME_WAIT是必须的。
MSL是TCP报文在发送缓冲区的最大生存时间,如果TIME_WAIT持续存在2MSL的话就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失。(否则服务器立刻重启,可能会收到来自上一个进程的迟到的数据,但是这种数据很可能是错误的)。同时也是在理论上保证最后一个报文可靠到达。(假设最后一个ACK丢失, 那么服务器会再重发一个FIN,这时虽然客户端的进程不在了,但是TCP连接还在,仍然可以重发LAST_ACK,这就会导致问题)
解决TIME_WAIT状态引起的bind失败的方法:
在server的TCP连接没有完全断开之前不允许重新绑定,也就是TIME_WAIT时间没有过,但是这样不允许立即绑定在某些情况下是不合理的:
服务器需要处理非常大量的客户端的连接 (每个连接的生存时间可能很短,但是每秒都有很大数量的客户 端来请求)
这个时候如果由服务器端主动关闭连接(比如某些客户端不活跃,就需要被服务器端主动清理掉),这样服务器端就会产生大量TIME_WAIT状态
如果客户端的请求量很大,就可能导致TIME_WAIT的连接数很多,每个连接都会占用一个通信五元组(源ip, 源端口, 目的ip, 目的端口, 协议)。其中服务器的ip和端口和协议是固定的,如果新来的客户端连接的ip和端口号和TIME_WAIT占用的连接重复就造成等待。
解决方法:使用setsockopt()设置socket描述符的选项SO_REUSEADDR为1,表示允许创建端口号相同但IP地址不同的多个socket描述符。
关于setsockopt:https://www.cnblogs.com/clschao/articles/9588313.html
服务器端CLOSE_WAIT状态: 如果客户端是主动断开连接的一方,在服务器端假设没有关闭新连接,这时服务器端就会产生一个CLOSE_WAIT状态,因为服务器没有去关闭连接,所以这个CLOSE_WAIT状态很容易测试出来,这时四次挥手没有结束,只完成了两次。
1 #include "tcp_socket.hpp"
2
3 typedef void (*Handler)(string& req, string* res);
4
5 class TcpServer
6 {
7 public:
8 TcpServer(string ip, uint16_t port)
9 :_ip(ip)
10 ,_port(port)
11 {}
12
13 void Start(Handler handler)
14 {
15 //1.创建socket
16 listen_sock.Socket();
17 //2.绑定ip和端口号
18 listen_sock.Bind(_ip, _port);
19 //3.监听
20 listen_sock.Listen(5);
21
22 while(1)
23 {
24 TcpSocket new_sock;
25 string ip;
26 uint16_t port;
27 //4.接收连接
28 listen_sock.Accept(&new_sock, &ip, &port);
29 cout <<"client:" << ip.c_str() << " connect" << endl;
30 while(1)
31 {
32 //5.连接成功读取客户端请求
33 string req;
34 bool ret = new_sock.Recv(&req);
35 cout << ret << endl;
36 if(!ret)
37 {
38 //此处服务器端不关闭新连接,导致CLOSE_WAIT状态
39 //new_sock.Close();
40 break;
41 }
42 //6.处理请求
43 string res;
44 handler(req, &res);
45
46 //写回处理结果
47 new_sock.Send(res);
48 cout << "客户:" << ip.c_str() << " REQ:" << req << ". RES:" << res << endl;
49 }
50 }
51 }
52 private:
53 TcpSocket listen_sock;
54 string _ip;
55 uint16_t _port;
56 };
运行结果:
如果服务器上出现大量的CLOSE_WAIT状态,原因就是服务器没有正确的关闭 socket,导致四次挥手没有正确完成。这是可能是一个BUG,只需要加上对应的 close即可解决问题。
滑动窗口:
确认应答策略对每一个发送的数据段都要给一个ACK确认应答,接收方收到ACK后再发送下一个数据段,但是这样做有一个比较大的缺点,就是性能较差,尤其是数据往返的时间较长的时候。
既然一发一收的方式性能较低,那么我们考虑一次发送多条数据,就可以大大的提高性能,它是将多个段的等待时间重叠在一起。
窗口大小指的是无需等待确认应答而可以继续发送数据的最大值。上图的窗口大小就是4000个字节(四个段)。发送前四个段的时候,不需要等待任何ACK直接发送即可。当收到第一个ACK后滑动窗口向后移动,继续发送第五个段的数据,然后依次类推。操作系统内核为了维护这个滑动窗口,需要开辟发送缓冲区来记录当前还有哪些数据没有应答。只有确认应答过的数据,才能从缓冲区删掉,窗口越大,则网络的吞吐率就越高。滑动窗口左边代表已经发送过并且确认,可以从发送缓冲区中删除了,滑动窗口里边代表发送出去但是没有确认,滑动窗口右边代表还没有发送的数据。
如果在这种情况中出现了丢包现象,应该如何重发呢?
数据到达接收方,但是应答报文丢失:可以更具后边的ACK确认。假设发送方发送1-1000的数据,接收方收到返回确认ACK,但是返回的ACK丢失了,另一边发送1001-2000收到的确认ACK 2001,就可以认为1-1000数据接收成功 。
数据包之间丢失: 当某一段报文段丢失之后,发送端会一直收到 1001 这样的ACK,就像是在提醒发送端 "我想要的是 1001" 一样,如果发送端主机连续三次收到了同样一个"1001" 这样的应答,就会将对应的数据 1001 - 2000 重新发送,这个时候接收端收到了 1001 之后, 再次返回的ACK就是7001了。因为2001 - 7000接收端其实之前就已经收到了,被放到了接收端操作系统内核的接收缓冲区中。这种机制被称为 “高速重发控制”(也叫 "快重传")。
快重传要求接收方在收到一个失序的报文段后就立即发出重复确认(为的是使发送方及早知道有报文段没有到达对方)而不要等到自己发送数据时捎带确认。快重传算法规定,发送方只要一连收到三个重复确认就应当立即重传对方尚未收到的报文段,而不必继续等待设置的重传计时器时间到期。由于不需要等待设置的重传计时器到期,能尽早重传未被确认的报文段,能提高整个网络的吞吐量。
流量控制
接收端处理数据的速度是有限的,如果发送端发的太快,导致接收端的缓冲区被装满,这个时候如果发送端继续发送,就会造成丢包,然后引起丢包重传等等一系列连锁反应。因此TCP支持根据接收端的处理能力,来决定发送端的发送速度,这个机制就叫做流量控制(Flow Control)。
接收端将自己可以接收的缓冲区大小放入TCP首部中的"窗口大小"字段,通过ACK确认报文通知发送端
窗口大小字段越大,说明网络的吞吐量越高,接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端
发送端接受到这个窗口之后,就会减慢自己的发送速度,如果接收端缓冲区满了, 就会将窗口置为0。这时发送方不再发送数据,但是需要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端。
接收端如何把窗口大小告诉发送端呢? 在的TCP首部中,有一个16位窗口字段,就是存放了窗口大小信息,16位数字大表示65535,那么TCP窗口大就是65535字节吗? 实际上TCP首部40字节选项中还包含了一个窗口扩大因子M,实际窗口大小是 窗口字段的值左移M位。接收端窗口如果更新,会向发送端发送一个更新通知,如果这个更新通知在中途丢失了,会导致无法继续通信,所以发送端要定时发送窗口探测包。
拥塞控制:
虽然TCP有了滑动窗口这个大杀器能够高效可靠的发送大量的数据,但是如果在刚开始阶段就发送大量的数据,仍然可能引发问题,因为网络上有很多的计算机,可能当前的网络状态就已经比较拥堵,在不清楚当前网络状态下,贸然发送大量的数据是很有可能引起雪上加霜的,造成网络更加堵塞。
TCP引入慢启动机制,先发少量的数据探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。
图中的cwnd为拥塞窗口,在发送开始的时候定义拥塞窗口大小为1,每次收到一个ACK应答拥塞窗口加1。每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送的窗口。
像上面这样的拥塞窗口增长速度,是指数级别的。"慢启动"只是指初使时慢,但是增长速度非常快。为了不增长的那么快,因此不能使拥塞窗口单纯的加倍,此处引入一个叫做慢启动的阈值当拥塞窗口超过这个阈值的时候,不再按照指数方式增长, 而是按照线性方式增长。
当TCP开始启动的时候,慢启动阈值等于窗口最大值
在每次超时重发的时候,慢启动阈值会变成原来的一半同时拥塞窗口置回1
少量的丢包,我们仅仅是触发超时重传。大量的丢包,我们就认为网络拥塞。当TCP通信开始后,网络吞吐量会逐渐上升。随着网络发生拥堵,吞吐量会立刻下降。拥塞控制归根结底是TCP协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案。
拥塞控制与流量控制的区别:
拥塞控制是防止过多的数据注入到网络中,可以使网络中的路由器或链路不致过载,是一个全局性的过程。 流量控制是点对点通信量的控制,是一个端到端的问题,主要就是权衡发送端发送数据的速率,以便接收端来得及接收。
拥塞控制的标志:
重传计时器超时
接收到三个重复确认
拥塞避免:(按照线性规律增长)
拥塞避免并非完全能够避免拥塞,在拥塞避免阶段将拥塞窗口控制为按线性规律增长,使网络比较不容易出现拥塞。
拥塞避免的思路是让拥塞窗口cwnd缓慢地增大,即每经过一个往返时间RTT就把发送方的拥塞控制窗口加一。
无论是在慢开始阶段还是在拥塞避免阶段,只要发送方判断网络出现拥塞(其根据就是没有收到确认,虽然没有收到确认可能是其他原因的分组丢失,但是因为无法判定,所以都当做拥塞来处理),这时就把慢开始门限设置为出现拥塞时的门限的一半。然后把拥塞窗口设置为1,执行慢开始算法。
加法增大:执行拥塞避免算法后,拥塞窗口线性缓慢增大,防止网络过早出现拥塞
乘法减小:无论是慢开始阶段还是拥塞避免,只要出现了网络拥塞(超时),那就把慢开始门限值ssthresh减半
快恢复(与快重传配合使用)
采用快恢复算法时,慢开始只在TCP连接建立时和网络出现超时时才使用。
当发送方连续收到三个重复确认时,就执行“乘法减小”算法,把ssthresh门限减半。但是接下去并不执行慢开始算法。
考虑到如果网络出现拥塞的话就不会收到好几个重复的确认,所以发送方现在认为网络可能没有出现拥塞。所以此时不执行慢开始算法,而是将cwnd设置为ssthresh的大小,然后执行拥塞避免算法。
延迟应答
如果接收数据的主机立刻返回ACK应答,这时候返回的窗口可能比较小。假设接收端缓冲区为1M 一次收到了500K的数据。如果立刻应答,返回的窗口就是500K。 但实际上可能处理端处理的速度很快,10ms之内就把500K数据从缓冲区消费掉了,在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些也能处理过来。如果接收端稍微等一会再应答,比如等待200ms再应答,那么这个时候返回的窗口大小就是1M。
窗口越大,网络吞吐量就越大,传输效率就越高。我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。
数量限制: 每隔N个包就应答一次
时间限制: 超过大延迟时间就应答一次
注:具体的数量和超时时间, 依操作系统不同也有差异; 一般N取2, 超时时间取200ms
捎带应答:
在延迟应答的基础上,存在很多情况下,客户端服务器在应用层也是"一发一收" 的。 意味着客户端给服务器说了"How are you", 服务器也会给客户端回一个"Fine, thank you"。那么这个时候ACK就可以搭顺风车,和服务器回应的 "Fine, thank you" 一起回给客户端
面向字节流:
当我们创建一个TCP的socket,同时在内核中创建一个发送缓冲区和一个接收缓冲区。
调用write时,内核将数据会先写入发送缓冲区中,如果发送的字节数太长,会被拆分成多个TCP的数据包发出,如果发送的字节数太短,就会先在缓冲区里等待, 等到缓冲区长度达到设置长度,然后等到其他合适的时机发送出去。
调用read接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区。然后应用程序可以调用read从接收缓冲区拿数据。TCP的一个连接,既有发送缓冲区, 也有接收缓冲区,那么对于这一个连接,既可以读数据,也可以写数据。所以是全双工的。
由于缓冲区的存在,TCP程序的读和写不需要一一匹配。例如: 写100个字节数据时, 可以调用一次write写100个字节, 也可以调用100次write, 每次写一个字节; 读100个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次read 100个字节, 也可以一次 read一个字节, 重复100次
粘包问题:
粘包问题中的 "包"是指的应用层的数据包。在TCP的协议头中,没有如同UDP一样的 "报文长度"这样的字段,但是有一个序号这样的字段。站在传输层的角度, TCP是一个一个报文过来的,按照序号排好序放在缓冲区中,但是站在应用层的角度,它看到的只是一串连续的字节数据。应用程序看到了这么一连串的字节数据, 就不知道从哪个部分开始到哪个部分结束是一个完整的应用层数据包,这就是粘包问题。
如何避免粘包问题呢?明确两个包之间的边界
对于定长的包,保证每次都按固定大小读取即可。例如一个Request结构, 是固定大小的, 那么就从缓冲区从头开始按sizeof(Request)依次读取即可
对于变长的包,可以在包头的位置,约定一个包总长度的字段,从而就知道了包的结束位置。
对于变长的包,还可以在包和包之间使用明确的分隔符(应用层协议是程序员自己来定义的, 只要保证分隔符不和正文冲突即可)。
对于UDP协议,如果还没有上层交付数据, UDP的报文长度仍然在。 同时UDP是一个一个把数据交付给应用层,这样就有存在明确的数据边界,站在应用层的角度, 使用UDP的时候要么收到完整的UDP报文要么不收,不会出现"半个"的情况。
TCP连接异常情况:
进程终止:进程终止会释放文件描述符,仍然可以发送FIN,和正常关闭没有什么区别。机器重启和进程终止一样。
机器掉电/网线断开:接收端认为连接还在,一旦接收端有写入操作,接收端发现连接已经不在了,就会进行reset。即使没有写入操作,TCP自己也内置了一个保活定时器,会定期询问对方是否还在。如果对方不在,也会把连接释放。应用层的某些协议, 也有一些这样的检测机制.例如HTTP长连接中, 也会定期检测对方的状态.Q在QQ 断线之后, 也会定期尝试重新连接。
————————————————
版权声明:本文为CSDN博主「Hansionz」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/hansionz/article/details/86435127