文章目录
- TCP 协议
- 1. TCP 协议段格式
- 2. TCP 报头解析
- 3. TCP 的可靠性
- 4. 面向字节流
- 5. 粘包问题
- 6. 连接队列维护
- TCP 的 确认应答机制
- TCP 的 超时重传机制
- TCP 的 三次握手
- TCP 的 四次挥手
- setsockopt 函数:设置套接字选项,解决 TIME_WAIT 状态引起的 bind 失败
- TCP 的 流量控制
- TCP 的 滑动窗口
- TCP 的 拥塞控制:慢启动机制 和 阈值
- TCP 的 延迟应答
- TCP 的 捎带应答
- TCP 的 异常情况
- 小结
TCP 协议
TCP(Transmission Control Protocol 传输控制协议):
- 传输层协议。
- 有连接:处于通信之前,也就意味着三次握手是不携带有效信息的。
- 可靠传输:有确认机制如 收到应答、超时重传、三次握手、四次挥手…
- 面向字节流:有对应的以字节为单位的缓冲区收发数据以供解析。
对比 UDP:
UDP(User Datagram Protocol 用户数据报协议)传输的过程类似于寄信。
- 传输层协议。
- 无连接:知道对端的IP和端口号就直接进行传输,不需要建立连接。
- 不可靠:没有确认机制,没有重传机制;如果因为网络故障该段无法发到对方,UDP协议层也不会给应用层返回任何错误信息。
- 面向数据报: 不能够灵活的控制读写数据的次数和数量。
1. TCP 协议段格式
TCP 的首部有一块定长大小为 20 字节的空间字段,用于报头信息的填写。后面接着的是一块储存选项的空间,也可能没有选项信息。
既然不确定选项字段的情况,如何分离报头和有效载荷呢?
-
在 TCP 前 20 字节的定长字段之中有一个记录首部长度的空间,这部分空间大小是 4 个比特位,而其中记录的单位大小是 4 字节,即可以记录 60 字节的长度,前 20 是定长的,后 40 就是给头部选项的。
-
所以整个首部的大小 = 首部长度字段 * 4 - 20,如果 == 0,则报头读取完毕,剩下的就是有效载荷
2. TCP 报头解析
源 / 目的端口号:
- 表示数据是从哪个进程来,到哪个进程去
32 位序号 / 32 位确认号:
- 对报文的编号,和确认报文的编号(见 TCP 的可靠性)
4 位 TCP 报头长度:
- 表示该 TCP 头部有多少个 32 位 bit(有多少个 4 字节); 所以TCP头部最大长度是15 * 4 = 60
6 个重要标志位:
-
ACK
- - acknowledge:该报文是一个确认报文,确认报文可能会携带数据,携带数据的确认报文的应答叫做 捎带应答。 -
SYN
– sync:请求建立连接,我们把携带 SYN 标识的称为 同步报文段。 -
FIN
:请求断开连接,通知对方,本端要关闭了,我们称携带 FIN 标识的为 结束报文段。 -
RST
- - reset:对方要求重新建立连接,我们把携带 RST 标识的称为复位报文段。 -
PSH
- - push:提示接收端应用程序立刻从 TCP 缓冲区把数据读走。 -
URG
- - urgent:标识紧急指针字段是否有效。
16 位窗口大小:
- 发送自己端接收缓冲区的剩余空间大小,以作 流量控制,不会导致对方发太快太多导致丢包,也可以避免发的太慢效率低下的问题出现。
16 位校验和:
- 发送端填充,CRC 校验。接收端校验不通过,则认为数据有问题,直接丢弃。此处的检验和不光包含 TCP 首部,也包含 TCP 数据部分。
16 位紧急指针:
- 是一个偏移量,标识哪部分数据是紧急数据(一个字节的状态码,这样的数据也叫做外带数据)。
40 字节头部选项:见后文。
其中 紧急数据 的处理,可以在 recv 和 send 中设置:
ssize_t recv(int sockfd, void *buf, size_t len, int flags);ssize_t send(int sockfd, const void *buf, size_t len, int flags);
参数 flags:
- MSG_OOB:允许 接收 / 发送 非正常流中的 紧急数据
但在实际使用中 URG 使用的很少,因为他能携带的信息量实在有限,
更多的是多开一个端口号,拿到并处理特殊信息
3. TCP 的可靠性
不可靠的情况有很多,比如:丢包、乱序、重复、校验失败、发送太快/太慢、网络问题…
客户端发送请求,在收到服务器响应,才可以 100% 的确保对方是收到的,也就是说 可靠性,是通过收到应答机制保证的。如此也说明,我们无法保证任何报文都是可靠送达,但可以局部保持可靠性。
实际上客户端可以同时对服务器发送多个请求报文,经过网络的传输,到达服务器的情况却不一样,应对不同的报文送达情况,客户端有不同的应对机制,判断哪个报文是哪个报文就是很有必要的。所以报文里会携带两种编号:序号、确认序号。确认序号是收到序号 +1 来设定的。两个序号在一段报文中注定是更高效的,所以在报头中有各自不同的字段空间。
序号的设置保证了 TCP 的可靠性。
4. 面向字节流
创建一个 TCP 的 socket,相当于同时在内核中创建一个 发送缓冲区 和一个 接收缓冲区
-
调用 write 时,数据会先写入发送缓冲区中。
-
如果发送的字节数太长,会被拆分成多个 TCP 的数据包发出;如果发送的字节数太短,就会先在缓冲区里等待,等到缓冲区长度差不多了,或者其他合适的时机发送出去。
-
接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区。
-
然后应用程序可以调用 read 从接收缓冲区拿数据。
-
另一方面,TCP 的一个连接,既有发送缓冲区,也有接收缓冲区,那么对于这一个连接,既可以读数据,也可以写数据。这个概念叫做 全双工。
由于缓冲区的存在,TCP 程序的读和写不需要一一匹配,例如:
写 100 个字节数据时,可以调用一次 write 写 100 个字节,也可以调用 100 次 write,每次写一个字节。
读 100 个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次 read 100 个字节,也可以一次 read 一个字节,重复100次。
5. 粘包问题
首先要明确,粘包问题中的 “包”,是指的应用层的数据包。
在 TCP 的协议头中,没有如同 UDP 一样的 “报文长度” 这样的字段,但是有一个序号这样的字段。
- 站在传输层的角度,TCP 是一个一个报文过来的。按照序号排好序放在缓冲区中。
- 站在应用层的角度,看到的只是一串连续的字节数据。那么应用程序看到了这么一连串的字节数据,就不知道从哪个部分开始到哪个部分,是一个完整的应用层数据包。
避免粘包问题, 归根结底就是, 明确两个包之间的边界。
-
对于定长的包,保证每次都按固定大小读取即可。例如上面的 Request 结构,是固定大小的,那么就从缓冲区从头开始按 sizeof(Request) 依次读取即可。
-
对于变长的包,可以在包头的位置,约定一个包总长度的字段,从而就知道了包的结束位置。
-
对于变长的包,还可以在包和包之间使用明确的分隔符(应用层协议, 是程序员自己来定的, 只要保证分隔符不和正文冲突即可)
对于 UDP 协议来说,是否也存在 “粘包问题” 呢?
- 对于 UDP,如果还没有上层交付数据,UDP 的报文长度仍然在。同时,UDP 是一个一个把数据交付给应用层。就有很明确的数据边界。
- 站在应用层的站在应用层的角度,使用 UDP 的时候,要么收到完整的UDP报文,要么不收,不会出现 “半个” 的情况。
6. 连接队列维护
Linux 内核协议栈为一个 TCP 连接管理使用两个队列。
- 半链接队列(用来保存处于 SYN_SENT 和 SYN_RECV 状态的请求)
- 全连接队列(accepted 队列)(用来保存处于established状态,但是应用层没有调用accept取走的请求)
全连接队列:管理已连接但还没发起任务的客户端。为了保证服务器的使用率,全连接队列的维护是必须维护的。也要控制这个队列不能太长。
- 因为可能会过多消耗 OS 本来是给 server 使用的资源,且队列太长 client 会不愿意等。
- 实际上更有效率的应该是 提高 网络吞吐量。
- 至于队列究竟要维护多长,需要看各自应用场景。
半连接队列:管理半连接状态的队列。
TCP 协议,需要在底层维护 全连接队列,最大长度是:listen()
的第二个参数 +1 。
TCP 的 确认应答机制
在确认应答机制中,TCP 首先将每个发出的数据,在发送缓冲区中都以字节为单位进行了编号,即序列号。每一个 ACK 都带有对应的确认序列号(序列号 +1),意思是告诉发送者,我已经收到了哪些数据,下一次你从哪里开始发。
例如,主机 A 和主机 B 通信:
A to B:发送数据(1~1000)
B to A:确认应答(收到了,下一个从 1001 开始发)
A to B:发送数据(1001~2000)
B to A:确认应答(收到了,下一个从 2001 开始发)
...
在字节单位的缓冲区中不断的读取和放置,就是 TCP 的特征之一,面向字节流。
TCP 的 超时重传机制
主机 A 发送数据给 B 之后,可能因为网络拥堵等原因,数据无法到达主机B。如果主机 A 在一个特定时间间隔内没有收到 B 发来的确认应答,就会进行重发。
例如,主机 A 和主机 B 通信:
A to B:发送数据(1~1000)
// 一段时间没有收到 B 的确认应答,A 判定丢包,开始重传
A to B:发送数据(1~1000)
B to A:确认应答(收到了,下一个从 1001 开始发)
因此主机 B 会收到很多重复数据,那么 TCP 协议需要能够识别出那些包是重复的包,并且把重复的丢弃掉。这时候报头里的序列号,就可以很容易实现去重。
超时的时间如何确定呢?
- 最理想的情况下,找到一个最小的时间,保证“确认应答一定能在这个时间内返回”。但是这个时间的长短,随着网络环境的不同,是有差异的:如果超时时间设的太长,会影响整体的重传效率;如果超时时间设的太短,有可能会频繁发送重复的包。
TCP 为了保证无论在任何环境下都能比较高性能的通信,此会动态计算最大超时时间。
- Linux 中(BSD Unix 和 Windows 也是如此),超时以 500ms 为一个单位进行控制,每次判定超时重发的超时时间都是 500ms 的整数倍。如果重发一次之后,仍然得不到应答,等待 2500ms 后再进行重传。如果仍然得不到应答,等待 4500ms 进行重传。依次类推, 以指数形式递增。累计到一定的重传次数,TCP 认为网络或者对端主机出现异常,强制关闭连接。
TCP 的 三次握手
整个 TCP 三次握手建立连接,传递信息,四次挥手断开连接的过程,包括应用层的建立。我们需要关注的是传输和两端的状态变化,流程图如下:
三次握手的过程,由双方的 OS 系统中的 TCP 层自主完成。
客户端:connect,触发连接,等待完成
服务器:accept,等待建立完成,获取连接
为什么握手是三次?
-
没有明显的设计漏洞(如果是 2 次会造成客户端零成本连接,服务器可能收到连接攻击而崩溃),一旦建立连接出现异常,成本嫁接到 client 端,server 端成本较低(因为最后一次发送可能丢失,如果偶数次握手,server 是不确定丢包与否即建立成功与否的)。
-
可以验证双方通信信道的通畅情况,三次握手是验证全双工通信信道通畅的最小成本。
三次握手除了确认信道畅通建立连接,还有什么用?
- 通过 16 位窗口大小进行流量控制。首先我们要知道,TCP 是面向连接的,处于正常通信之前,三次握手是不携带有效数据的,也就是说,两端第一次传输数据不是第一次通信。
- 在三次握手阶段,首先,客户端在发起连接请求时,除了 SYN 标志位被置 1,客户端也一定会将自己的 16 位窗口大小通告给服务器,以便服务器知道该客户端的承载能力。
- 其次,服务器响应 SYN + ACK 标志位被置 1,服务器的 16 位窗口大小也会被填上。这样双方的承载能力就都被对方知晓了,不会在第一次报文传输的时候出现流量异常导致的一系列问题。
服务器端状态转化:
-
[CLOSED -> LISTEN] 服务器端调用 listen 后进入 LISTEN 状态,等待客户端连接
-
[LISTEN -> SYN_RCVD] 一旦监听到连接请求(同步报文段),就将该连接放入内核等待队列中,并向客户端发送 SYN 确认报文
-
[SYN_RCVD -> ESTABLISHED] 服务端一旦收到客户端的确认报文,就进入 ESTABLISHED 状态,可以进行读写数据了
-
[ESTABLISHED -> CLOSE_WAIT] 当客户端主动关闭连接(调用 close),服务器会收到结束报文段,服务器返回确认报文段并进入 CLOSE_WAIT
-
[CLOSE_WAIT -> LAST_ACK] 进入 CLOSE_WAIT 后说明服务器准备关闭连接(需要处理完之前的数据),当服务器真正调用 close 关闭连接时,会向客户端发送 FIN,此时服务器进入 LAST_ACK 状态,等待最后一个 ACK 到来(这个ACK是客户端确认收到了 FIN)
-
[LAST_ACK -> CLOSED] 服务器收到了对 FIN 的 ACK,彻底关闭连接
客户端状态转化:
-
[CLOSED -> SYN_SENT] 客户端调用 connect,发送同步报文段
-
[SYN_SENT -> ESTABLISHED] connect 调用成功,则进入 ESTABLISHED 状态,开始读写数据
-
[ESTABLISHED -> FIN_WAIT_1] 客户端主动调用 close 时,向服务器发送结束报文段,同时进入 FIN_WAIT_1
-
[FIN_WAIT_1 -> FIN_WAIT_2] 客户端收到服务器对结束报文段的确认,则进入FIN_WAIT_2,开始等待服务器的结束报文段
-
[FIN_WAIT_2 -> TIME_WAIT] 客户端收到服务器发来的结束报文段,进入 TIME_WAIT,并发出 LAST_ACK
-
[TIME_WAIT -> CLOSED] 客户端要等待一个 2 MSL(Max Segment Life,报文最大生存时间)的时间, 才会进入 CLOSED 状态。
TCP 的 四次挥手
综合上述的描述,对四次挥手进一步做些解释。
四次挥手对于主动断开的一方 进行最后一次确认后,要进入 TIME_WAIT 状态,这是一个临时性状态,持续一会就没了。
为什么挥手是四次?
- 建立连接后,两端就是同等地位了,一方断开连接都需要另一方确认。
TIME_WAIT 状态的细节:
-
当主动断开的一方进入 TIME_WAIT 状态时,连接确实就断开了,但底层这个连接还没有被彻底关掉,相应的端口号还在被 TIME_WAIT 状态占用,在被占用的这段时间里,这个端口号就是不能使用的。
-
如果是 server 端发起断开连接并处于 TIME_WAIT 状态,而 server 端经不起等待或者更换端口时,就可以调用下面的接口,对套接字进行相应的选项设置,无视其TIME_WAIT 状态,重新使原端口可以被有效使用。
setsockopt 函数:设置套接字选项,解决 TIME_WAIT 状态引起的 bind 失败
#include <sys/types.h> /* See NOTES */#include <sys/socket.h>
int getsockopt(int sockfd, int level, int optname,void *optval, socklen_t *optlen); int setsockopt(int sockfd, int level, int optname,const void *optval, socklen_t optlen);
参数 level:
- 要设的属性在哪一层
参数 optname:
要设的是哪一个属性
SO_REUSEADDR:无视网络中的 TIME_WAIT 状态。主动关闭连接的一方处于 TIME_WAIT 的时候,允许新的连接重新绑定与 TIME_WAIT 状态的连接有冲突的 IP + PORT(),并立即接收数据。
SO_REUSEPORT: SO_REUSEADDR 对完全相同的IP+PORT绑定(无论是具体的IP还是通配)仍然出现Address already in use的错误,使用SO_REUSEPORT选项可以避免此错误。
参数 optval:
- 属性的值设成多少
参数 optlenl:
- 属性长度是多少
·
TIME_WAIT 状态有什么用?
-
当退出端退出时,或有正在传输的信息并未到达对端,TIME_WAIT 的存在,就是为了让已退出端尚未完成传输的信息消散,目的是不影响对端 ACK 的序号不被先前信息影响,保证下次同个端口号发送的数据能够被正常响应。
-
另一方面,是为了保证退出端的最后一次 ACK 被对端收到。
TCP 的 流量控制
之前提到过,接收端处理数据的速度是有限的。如果发送端发的太快,导致接收端的缓冲区被打满,这个时候如果发送端继续发送就会造成丢包,继而引起丢包重传等等一系列连锁反应。因此TCP支持根据接收端的处理能力,来决定发送端的发送速度。这个机制就叫做 流量控制(Flow Control)。
-
接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 “窗口大小” 字段,通过ACK端通知发送端。窗口大小字段越大, 说明网络的吞吐量越高。
-
接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端。发送端接受到这个窗口之后,就会减慢自己的发送速度。
-
如果接收端缓冲区满了,就会将窗口置为0,这时发送方不再发送数据,但是需要定期发送一个 窗口探测 数据段,使接收端把窗口大小告诉发送端。
-
窗口探测:发送方定期发出一个 TCP 报头,接收方必须 对所有请求进行应答(TCP 的协议内容),于是返回应答中的 16位 窗口字段就可以让发送方知道,什么时候可以继续发送有效载荷。
在TCP首部中,有一个 16 位窗 口字段,用来存放窗口大小信息。
那么问题来了,16 位数字最大表示 65535, 那么 TCP 窗口最大就是 65535 字节么?
实际上, TCP 首部 40 字节选项中还包含了一个 窗口扩大因子 M,实际 窗口大小是 窗口字段的值左移 M 位。
TCP 的 滑动窗口
对应确认应答策略,对每一个发送的数据段,都要给一个ACK确认应答,收到ACK后再发送下一个数据段。如此串行工作,性能较差,尤其在数据往返的时间较长的时候更甚。
一发一收的方式性能较低,只要一次发送多条数据,就可以大大的提高性能(其实是将多个段的等待时间重叠在一起了)。
-
窗口大小指的是无需等待确认应答而可以继续发送数据的最大值,上图的窗口大小就是4000个字节(四个段)。
-
发送前四个段的时候,不需要等待任何 ACK,直接发送。
-
收到第一个 ACK 后,滑动窗口向后移动,继续发送第五个段的数据,依次类推。
-
操作系统内核为了维护这个滑动窗口,需要开辟 发送缓冲区 来记录当前还有哪些数据没有应答;只有确认应答过的数据,才能从缓冲区删掉。
-
窗口越大, 则网络的吞吐率就越高。
一般情况
-
滑动窗口往左边部分,是已经发送已经确认过的无效数据,可以被覆盖
-
滑动窗口部分,暂时不用等待收到应答,可以直接发送。其大小和对方的接受能力有关,即应答报文中的窗口大小。
-
滑动窗口往右边部分,是尚未发送的数据区域。
如何理解滑动窗口?
-
所谓面向字节流的 TCP,他的接收和发送缓冲区都可以理解成一个个 char 类型的 数组。
-
而滑动窗口在这个数组中的“滑动”,实际是依赖两个指针(这里做 winstart,winend)划定范围,通过 ++ 运算符完成的。
一些规则
-
“窗口” 只会向右 “滑动”,左边是已经确认过的报文
-
一直向右移的规则并不会造成越界,因为发送缓冲区的结构是环状的。
-
滑动窗口的大小是浮动的,不是固定大小,而其 变大、变小、变 0 是根据对方给本端响应报头中 窗口大小 来调节的。
窗口变大通过 winend+=xxx 来完成窗口变小 winend-=xxx 来完成
-
滑动窗口大小的更新,更具体来说,和对方发送的序列号 seq 有关,在应答也是按序到达的前提下:
根据应答的 seq,winstart = seq; 根据应答的 win,winend = winstart + win;
-
滑动窗口内部的报文可以直接发送,多个报文传输,肯定会发生丢失的问题。
如果第一个丢失了情况1:数据丢了,即使后面的收到了,winstart 也不会向后移动,只是等待发送方超时重传。情况2:应答丢失,可以通过后面传来的响应报文确定,之前的一定接收到了。 如果中间或最后的丢失了随着 winstart 的右移都会转化成第一个丢失问题,按上述步骤处理。
-
数据要支持重传,就必须被保存起来,保存的位置就是滑动窗口
TCP 的 拥塞控制:慢启动机制 和 阈值
少量的丢包,我们仅仅是触发超时重传;大量的丢包,我们就认为 网络拥塞。
当 TCP 通信开始后,网络吞吐量会逐渐上升;随着网络发生拥堵,吞吐量会立刻下降。
当网络拥塞出现大量丢包的情况:发送方不能一直超时重传也不能完全停发。总体策略是,保证网络拥塞不能加重,再网络拥塞有起色的情况下,尽快恢复网络通信。
而 慢启动 机制,先发少量的数据探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。
此处引入一个概念程为 拥塞窗口
-
发送开始的时候,定义拥塞窗口大小为 1
-
每次收到一个 ACK 应答,拥塞窗口加 1
只是这样的话,拥塞窗口的大小肯定是指数级上升的,可实际不止如此。
-
每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送的窗口
滑动窗口 = min(对端主机的接受能力 win,网络的拥塞窗口) winstart = seq; winend = min(seq_win, 拥塞窗口);
正常来说拥塞窗口增长速度,是指数级别的。“慢启动” 只是指初使时慢,但是增长速度非常快。为了不增长的那么快, 因此不能使拥塞窗口单纯的加倍,需要 慢启动的 阈值 进行控制。
- 当拥塞窗口超过这个阈值的时候,不再按照指数方式增长,而是按照线性方式增长。
- 当 TCP 开始启动的时候,慢启动阈值等于窗口最大值
- 在每次超时重发的时候,慢启动阈值会变成原来的一半,同时拥塞窗口置回 1
拥塞控制,归根结底是 TCP 协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案。
TCP 的 延迟应答
如果接收数据的主机立刻返回 ACK 应答,这时候返回的窗口可能比较小。
假设接收端缓冲区为 1M,一次收到了500K的数据,
如果立刻应答,返回的窗口就是500K。
但实际上可能处理端处理的速度很快,10ms 之内就把 500K 数据从缓冲区消费掉了在上述情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些,也能处理过来。
如果接收端稍微等一会再应答,比如等待 200ms 再应答,那么这个时候返回的窗口大小就是 1M。
窗口越大,网络吞吐量就越大,传输效率就越高。我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。如何选择延迟应答的时机呢?
选择延迟应答的时机,也有不同的方案:
- 数量限制:每隔 N 个包就应答一次
- 时间限制:超过最大延迟时间就应答一次
具体的数量和超时时间,依操作系统不同也有差异,一般 N 取 2,超时时间取 200ms。
TCP 的 捎带应答
在延迟应答的基础上,我们发现,很多情况下,客户端服务器在应用层也是 “一发一收” 的。
意味着客户端给服务器说了 “你好吗”,服务器也会给客户端回一个 “我很好”。那么这个时候 ACK 就可以和服务器回应的 “我很好” 一起回给客户端。
TCP 的 异常情况
机器重启 / 进程终止:机器重启 / 进程终止会释放文件描述符,仍然可以发送 FIN,和正常关闭没有什么区别。
机器掉电 / 网线断开:接收端认为连接还在,一旦接收端有写入操作,接收端发现连接已经不在了,就会进行 reset。即使没有写入操作,TCP自己也内置了一个保活定时器,会定期询问对方是否还在,如果对方不在,也会把连接释放。
另外,应用层的某些协议,也有一些这样的检测机制
例如 HTTP 长连接中,也会定期检测对方的状态,例如QQ,。
在 QQ 断线之后,也会定期尝试重新连接。
小结
TCP 这么复杂,是因为要保证 可靠性,同时又尽可能的提高性能。
- 可靠性
- 校验和
- 序列号(按序到达)
- 确认应答
- 超时重发
- 连接管理
- 流量控制
- 拥塞控制 提高性能:
- 滑动窗口
- 快速重传
- 延迟应答
- 捎带应答 其他:
- 定时器(超时重传定时器, 保活定时器, TIME_WAIT定时器等)
另,基于TCP应用层协议有如下这些:
- HTTP
- HTTPS
- SSH
- Telnet
- FTP
- SMTP
当然, 也包括我们自己写 TCP 程序时自定义的应用层协议
🥰如果本文对你有些帮助,请给个赞或收藏,你的支持是对作者大大莫大的鼓励!!(✿◡‿◡) 欢迎评论留言~~