WebRTC | 网络传输协议 RTP 和 RTCP
UestcXiye 2024-06-25 17:33:34 阅读 80
WebRTC | 网络传输协议 RTP 和 RTCP
WebRTC | 网络传输协议 RTP 和 RTCP如何选择 TCP 与 UDPRTP概述工作机制报文结构RTP 的使用RTP 拓展头RTP 中的填充数据翻译器和混合器同步控制报文大小wireshark 抓取 RTP 报文
RTCP概述工作机制分组类型报文结构WebRTC 的反馈报文RTPFBPSFB
wireshark 抓取 RTCP 报文
RTP 会话过程RTSP 和 RTP 的关系jrtplib总结参考
WebRTC | 网络传输协议 RTP 和 RTCP
流媒体协议栈如下图所示:
RTP 标准定义了两个子协议,RTP 和 RTCP。
数据传输协议 RTP,用于实时传输数据。该协议提供的信息包括:时间戳(用于同步)、序列号(用于丢包和重排序检测)、以及负载格式(用于说明数据的编码格式)。控制协议 RTCP,用于 QoS 反馈和同步媒体流。相对于 RTP 来说,RTCP 所占的带宽非常小,通常只有5%。
如何选择 TCP 与 UDP
TCP为了实现数据传输的可靠性,采用的是“发送→确认→丢包→重传”这样一套机制。而且为了增加网络的吞吐量,还采用了延迟确认和Nagle算法(Nagle算法,将多个小包组成一个大包发送,组合包的大小不超过网络最大传输单元)。这套机制就是TCP产生延迟的根本原因。
为了增加网络的吞吐量,接收端不必每收到一个包就确认一次,而是对一段时间内收到的所有数据集体确认一次即可。为了实现该功能,TCP通常会在接收端启动一个定时器。定时器的时间间隔一般设置为200ms,即每隔200ms确认一次接收到的数据。这就是延迟确认机制。除此之外,TCP在发送端也启动了一个定时器,不过该定时器的功能不是发送确认消息,而是用来判别是否有丢包的情况。发送端定时器的时长为一个RTO(Retransmission Timeout,重传超时时长。其值约等于RTT的平均值,每次超时后以指数级增长。RTT表示一个数据包从发送端到接收端,然后再回到发送端所用的时长)。如果在定时器超时后仍然没有收到包的确认消息,则认为包丢失了,需要发送端重发丢失的包。这就是TCP的丢包重传机制。
UDP属于不可靠传输协议。在传输数据时,它不保证数据能可靠到达,也不保证数据有序,但它最大的优点就是传输速度“快”。由于UDP没有TCP那一套保证数据可靠、有序的控制逻辑,所以它不会被“人为”地变慢,因此它的实时性是最高的。
针对UDP丢包和抖动的问题,WebRTC给出了一套比较完美的解决方案,通过NACK、FEC、Jitter Bufer以及NetEQ技术既可以解决丢包和抖动问题,又不会产生影响服务质量的时延。通过上面的分析可以知道,由于TCP在极端网络情况下无法控制传输的时延大小,所以在做实时通信传输时,应该首选UDP。
RTP
概述
实时传输协议(Real-time Transport Protocol,简称 RTP)是一个网络传输协议,它是由IETF的多媒体传输工作小组1996年在RFC 1889中公布的。
RTP 是应用层协议,基于 UDP,分组传输,接收端数据包往往有延迟和乱序。在发送端,存在丢包;为降低延迟,往往对传输数据进行预处理(降低质量和高效压缩)。在接收端为了恢复时序,采用了接收缓冲;而为了实现媒体的流畅播放,则采用了播放缓冲。使用接收缓冲,可以将接收到的数据包缓存起来,然后根据数据包的封装信息(如包序号和时戳等),将乱序的包重新排序,最后将重新排序了的数据包放入播放缓冲播放。
RTP 为数据提供了具有实时特征的端对端传送服务,如在组播或单播网络服务下的交互式视频音频或模拟数据。RTP 不像 HTTP 和 FTP 可完整的下载整个影视文件,它是以固定的数据率在网络上发送数据,客户端也是按照这种速度观看影视文件,当影视画面播放过后,就不可以再重复播放,除非重新向服务器端要求数据。
RTP 本身只保证实时数据的传输,并不能为按顺序传送数据包提供可靠的传送机制,也不提供流量控制或拥塞控制,它依靠 RTCP 提供这些服务。RTP 并不保证传送或防止无序传送,也不确定底层网络的可靠性。 RTP 实行有序传送, RTP 中的序列号允许接收方重组发送方的包序列,同时序列号也能用于决定适当的包位置。
工作机制
RTP 协议就是提供了时间标签,序列号以及其它的结构用于控制适时数据的流放。在流的概念中,时间标签是最重要的信息。发送端依照即时的采样在数据包里隐蔽的设置了时间标签。在接受端收到数据包后,就依照时间标签按照正确的速率恢复成原始的适时的数据。不同的媒体格式调时属性是不一样的。
但是 RTP 本身并不负责同步,不提供任何机制来保证实时地传输数据,不支持资源预留,也不保证服务质量。RTP 报文甚至不包括长度和报文边界的描述。同时 RTP 协议和 RTCP 协议使用相邻的不同端口,这样大大提高了协议的灵活性和处理的简单性。
RTP 协议基于 UDP 实现。UDP 协议只是传输数据包,不管数据包传输的时间顺序。RTP 的协议数据单元是用 UDP 分组来承载的。在承载 RTP 数据包的时候,有时候一帧数据被分割成几个包具有相同的时间标签,则可以知道时间标签并不是必须的。而 UDP 的多路复用让 RTP 协议利用支持显式的多点投递,可以满足多媒体会话的需求。
报文结构
每一个 RTP 数据报都由头部(Header)和负载(Payload)两个部分组成,其中头部前 12 个字节的含义是固定的,而负载则可以是音频或者视频数据。
RTP报头格式为:
版本号(V):2 比特,用来标志使用的 RTP 版本。现在使用的都是第 2 个版本,所以该字段固定为 2。填充位(P):1 比特,如果 P=1,则该 RTP 包的尾部就附加一个或多个额外的八位组,它们不是有效载荷的一部分。扩展位(X):1 比特,如果 X=1,拓展头部会放在 CSRC 之后,携带一些附加信息。CC:4 比特,CSRC 计数器,指示 CSRC 标识符的个数。标记位(M):1 比特,该位的解释由配置文档(Profile)决定,一般情况下用于标识边界(对于视频,标记一帧的结束;对于音频,标记会话的开始)。载荷类型(PT):7 比特,标识了 RTP 载荷的类型。具体值和对应参见:RFC3551。序列号(Sequence Number):16 比特,发送方在每发送完一个 RTP 包后就将该域的值增加 1,接收者通过序列号来检测报文丢失情况,重新排序报文,恢复数据。序列号的初始值是随机的。时间戳(Timestamp):32 比特,时间戳反映了该 RTP 报文数据的第一个八位组的采样时刻。在一次会话开始时,时间戳初始化成一个初始值。即使在没有信号发送时,时间戳的数值也要随时间而不断地增加。接收者使用时间戳来计算延迟和延迟抖动,并进行同步控制。同步源标识符(SSRC):32 比特,同步源就是指 RTP 包流的来源。RTP 要求不同源的数据流之间用 SSRC 字段区分,在同一个 RTP 会话中不能有两个相同的 SSRC 值。该标识符由 MD5 随机算法生成。贡献源列表(CSRC List):0~15 项,每项 32 比特,用来标志对一个 RTP 混合器产生的新包有贡献的所有 RTP 包的源。由混合器将这些有贡献的 SSRC 标识符插入表中。SSRC 标识符都被列出来,以便接收端能正确指出交谈双方的身份。
RTP 的使用
关于RTP的使用主要包括以下两个方面:一是创建/解析RTP包;二是根据RTP包进行逻辑处理。
WebRTC 提供了一个 RTP 处理类:RtpPacket,定义一个该类的对象,就可以设置/提取 RTP 协议头各个字段的内容。
接下来讲一下逻辑处理,比如创建一个接收队列来消除包抖动。某个包找不到有两种情况:包丢失或者包乱序。如果缓冲队列满了,说明包丢失;否则,该包还处于待定状态。
当接收到一个包的标记位(M)为 1,可以与前面的 RTP 包组成一个完整的帧。将这些包从接收队列弹出,交给组帧模块处理。
WebRTC中解决RTP包抖动的缓冲队列就是我们通常所说的JitterBuffer。上面的例子就是JitterBuffer的基本原理。
RTP 拓展头
RTP 头部的扩展位(X)占 1 比特,如果 X=1,拓展头部会放在 CSRC 之后,携带一些附加信息。
RTP扩展头由三部分组成,分别为profile、length以及header extension。
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| defined by profile | length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| header extension |
| .... |
其中:
profile字段占2字节,用于区分不同的配置。在RFC5285中定义了两种profile,分别是 {0xBE,0xDE} 和 {0x10,0x0X}。接收端解析RTP扩展头时,通过profile来区分header extension中的内容该如何解析。length字段占2字节,表示扩展头所携带的header extension的个数。如果length为4,表示有4个header extension;header extension字段是扩展头信息,以4字节为单位,其具体含义由profile决定。
扩展头中的两个profile值 {0xBE,0xDE} 和 {0x10,0x0X} 分别代表存放在header extension中的两种不同的数据格式,即one-byte-header和two-byte-header。
one-byte-header 的含义是单个header extension由1字节的Header和N字节的Body组成。而Header由前4位的ID和后4位的len组成。
ID:拓展头的值,见于 《WebRTC 中的 SDP》 中 [网络描述:RTP 拓展头] 一章。len:跟在Header后面的数据(以字节为单位)长度减 1。
one-byte-header 示例:
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 0xBE | 0xDE | length=3 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| ID | L=0 | data | ID | L=1 | data...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
...data | ID | L=3 | data |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| data | 0(pad) | 0(pad) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
RTP拓展头后的第一个16为固定为0XBEDE标志,意味着这是一个one-byte扩展,length = 3 说明后面有三个扩展头,每个扩展头首先以一个byte开始,前4位是这个扩展头的ID, 后四位是data的长度-1,譬如说L=0意味着后面有1个byte的data,同理第二个扩展头的L=1说明后面还有2个byte的data。body结束后,为了让整个 RTP 拓展头是 4 字节的整数倍,后面填充了 2 字节的 0。
two-byte-header 的含义是单个header extension由2字节的Header和N字节的Body组成。而Header的第一个字节是ID,第二个字节 len 表示存放的实际长度。
当扩展头中的profile值为 {0x10,0x0X} 时,解析 RTP 拓展头会按照two-byte-header 的格式进行。其中的 X 占 4 bit,代表任意值由应用层自己定义它的含义。
two-byte-header 示例:
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 0x10 | 0x00 | length=3 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| ID | L=0 | ID | L=1 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| data | ID | L=4 | data...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
...data | 0 (pad) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
可以看到开头为 0x100 + 0x0, 接下来的为length=3表示接下来有3个头,接下来的就是扩展头和数据,扩展头除了ID和L相对于one-byte header从4bits变成了8bits之后,其余都一样。
RTP 中的填充数据
与RTP扩展头类似,RTP头中的P位用于标识RTP包中是否有填充数据。如果P位为1,说明RTP包中含有填充数据。
当RTP包中包含有填充数据时,其数据包的最后一个字节记录着包中填充字节的个数,即图中的Padding Size部分。如果Padding Size为5,说明RTP包中共有5个填充字节,其中包括它自己。这些填充数据不属于RTP Payload的部分,因此在解析RTP Payload部分之前,应将填充部分去掉。
去除填充部分的算法:读取 RTP 包的最后一字节,即 Padding Size。从最后一个字节算起,将 RTP 包末尾 Padding Size 个字节丢弃。
翻译器和混合器
翻译器和混合器都是 RTP 协议的中继系统。翻译器用在通过IP多播不能直接到达的用户区,例如发送者和接收者之间存在防火墙。当与会者能接收的音频编码格式不一样,比如有一个与会者通过一条低速链路接入到高速会议,这时就要使用混合器。在进入音频数据格式需要变化的网络前,混合器将来自一个源或多个源的音频包进行重构,并把重构后的多个音频合并,采用另一种音频编码进行编码后,再转发这个新的RTP包。从一个混合器出来的所有数据包要用混合器作为它们的同步源(SSRC,见RTP的封装)来识别,可以通过贡献源列表(CSRC表,见RTP的封装)可以确认谈话者。
同步控制
由上图可知,如果只有序列号,并不能完整按照顺序的将 data 播放出来,因为如果 data 中间有一段是没有资料的,只有序列号的话会造成错误,需搭配上让它知道在哪个时间将 data 正确播放出来,如此我们才能播放出正确无误的信息。
报文大小
RTP 载荷封装设计本文的网络传输是基于 IP 协议,所以最大传输单元(MTU)最大为 1500 字节,在使用 IP/UDP/RTP 的协议层次结构的时候,这其中包括至少 20 字节的 IP 头,8 字节的 UDP 头,以及 12 字节的 RTP 头。这样,头信息至少要占用 40 个字节,那么 RTP 载荷的最大尺寸为 1460 字节。如果一帧数据大于 1460 字节,则需要分片打包,然后到接收端再拆包,组合成一帧数据,进行解码播放。
wireshark 抓取 RTP 报文
抓取方法参考:《RTSP协议抓包及讲解》。
我们可以参考上面的 RTP 协议的报文结构对下面一条 RTP 报文进行分析:
RTCP
概述
实时传输控制协议(Real-time Transport Control Protocol,RTCP)是 RTP 的一个姐妹协议,它是应用层协议,基于 UDP 实现。
RTCP 的主要功能是:服务质量的监视与反馈、媒体间的同步,以及多播组中成员的标识。
工作机制
当应用程序开始一个 RTP 会话时将使用两个端口:一个给 RTP,一个给 RTCP。RTP 本身并不能为按顺序传送数据包提供可靠的传送机制,也不提供流量控制或拥塞控制,它依靠 RTCP 提供这些服务。
RTP和RTCP使用UDP端口1024 - 65535。RTP 使用偶数端口号接收发送数据,相应的RTCP则使用相邻的下一位奇数端口号。
RTCP 负责管理传输质量在当前应用进程之间交换控制信息。
在 RTP 会话期间,各参与者周期性地传送 RTCP 包,其中含有已发送的数据包的数量、丢失的数据包的数量等统计资料。因此,各参与者可以利用这些信息动态地改变传输速率,甚至改变有效载荷类型。
RTP 和 RTCP 配合使用,能以有效的反馈和最小的开销使传输效率最佳化,故特别适合传送网上的实时数据。根据用户间的数据传输反馈信息,可以制定流量控制的策略,而会话用户信息的交互,可以制定会话控制的策略。
RTCP 封装的仅仅是一些控制信息,因而分组很短,所以可以将多个RTCP分组封装在一个UDP包中。
分组类型
在 RTCP 通信控制中,RTCP 协议的功能是通过不同的 RTCP 数据报来实现的,主要有如下几种类型:
SR:发送端报告,所谓发送端是指发出 RTP 数据报的应用程序或者终端,发送端同时也可以是接收端。RR:接收端报告,所谓接收端是指仅接收但不发送 RTP 数据报的应用程序或者终端。SDES:源描述,主要功能是作为会话成员有关标识信息的载体,如用户名、邮件地址、电话号码等,此外还具有向会话成员传达会话控制信息的功能。其中有价值的是 CNAME,其作用是将不同的源(SSEC)绑定到同一个 CNAME 上。当重传流时,SSRC 会冲突,可以通过 CNAME 将旧的 SSRC 更换成新的 SSRC,从而保证通信的每一个 SSRC 都是唯一的。BYE:通知离开,主要功能是指示某一个或者几个源不再有效,即通知会话中的其他成员自己将退出会话。当 WebRTC 收到该报文时,就会删除该 SSRC 对应的通道。APP:由应用程序自己定义,解决了 RTCP 的扩展性问题,并且为协议的实现者提供了很大的灵活性。RTPFB:RTP 反馈报文,是指 RTP 传输层面的报文。该报文可以装入不同类型的子报文。PSFB:RTP 中与负载相关的反馈报文。该报文可以装入不同类型的子报文。
报文结构
每一个 RTCP 数据报都由头部(Header)和负载(Payload)两个部分组成,其中头部前 1 个字节的含义是固定的。
RTCP 报头格式为:
版本号(V):2 比特,用来标志使用的 RTCP 版本。现在使用的都是第 2 个版本,所以该字段固定为 2。填充位(P):1 比特,填充位标识。如果 P=1,则该 RTCP 包的尾部就附加一个或多个额外的八位组,它们不是有效载荷的一部分。接收报告计数器(RC):5 比特,其含义取决于报文类型。对于 RR/SR,它表示所携带接收报告的个数;对于 SDES,它表示报文中 item 的个数;对于 BYE,它表示 SSRC/CSRC 的个数。包类型(PT):8 比特,标识 RTCP 的分组类型。
长度域(Length):16 比特,表示整个 RTCP 包的大小(包括 RTCP 头、负载、填充字节),以 4 字节为单位。Data:负载。不同类型的 RTCP 报文其负载的内容千差万别,相关内容请自行查阅 RFC3550。
WebRTC 的反馈报文
RTPFB
PT = 205 表示 RTPFB 报文,是 RTP 传输层面的反馈报文。该报文可以装入不同类型的子报文。该报文中可以包含多个子报文,其中WebRTC使用到的报文只有4项。
NACK:接收端用于通知发送方在上次包发送周期内有哪些包丢失了。在NACK报文中包含两个字段:PID和BLP。PID(Package ID)字段用于标识从哪个包开始统计丢包;而BLP(16位)字段表示从PID包开始,接下来的16个RTP包的丢失情况。TMMBR和TMMBN是一对报文,TMMBR表示临时最大码流请求报文,TMMBN是对临时最大码流请求的应答报文。这两个报文虽然在WebRTC中实现了,但已被WebRTC废弃,其功能由TFB和REMB报文所代替。TFB是WebRTC中TCC算法的反馈报文,该报文会记录包的延迟情况,然后交由发送端的TCC算法计算下行带宽。
PSFB
PT = 206 表示 PSFB 报文,是 RTP 中与负载相关的反馈报文。该报文可以装入不同类型的子报文。
PLI报文与FIR报文很类似,当发送端收到这两个报文时,都会触发生成关键帧(IDR帧),但两者还是有一些区别的。PLI报文是在接收端解码器无法解码时发送的报文。FIR报文主要应用于多方通信时后加入房间的参与者向已加入房间的共享者申请关键帧。通过这种方式,可以保障后加入房间的参与者不会因收到的第一帧不是关键帧而引起花屏或黑屏的问题。
REMB报文是WebRTC增加的反馈报文,用于将接收端评估出的带宽值发给发送端。不过,由于最新的WebRTC已全面启用基于发送端的带宽估算方法,即TCC,因此目前REMB仅用于向后兼容,不再做进一步更新。
wireshark 抓取 RTCP 报文
我们可以参考上面的 RTCP 数据报对下面一包 RTP 报文进行分析:
设置过滤 RTCP 报文,可以看到下面第 397、401 报文为接收端报告+源描述,第 473、475 包报文为发送端报告 + 源描述报文:
RTP 会话过程
当应用程序建立一个RTP会话时,应用程序将确定一对目的传输地址。目的传输地址由一个网络地址和一对端口组成,有两个端口:一个给RTP,一个给RTCP,使得RTP/RTCP数据能够正确发送。RTP和RTCP使用UDP端口(范围 1024 - 65535)。RTP 使用偶数端口号接收发送数据,相应的RTCP则使用相邻的下一位奇数端口号,这样就构成一个UDP端口对。
发送过程如下:
RTP 协议从接收流媒体信息码流(如H.264),封装成RTP数据包;RTCP接收控制信息,封装成 RTCP 控制包。RTP 将 RTP 数据包发往UDP端口对中偶数端口;RTCP 将 RTCP 控制包发往UDP端口对中的奇数端口。
接收者:
根据RTP包的序列号来排序。根据声音流和图像流的相对时间(即RTP包的时间戳),以及它们的绝对时间(即对应的RTCP包中的RTCP),可以实现声音和图像的同步。接收缓冲用来排序乱序了的数据包。播放缓冲用来消除播放的抖动,实现等时播放。
RTSP 和 RTP 的关系
RTSP 与 RTP 最大的区别在于:RTSP 是一种双向实时数据传输协议,它允许客户端向服务器端发送请求,如回放、快进、倒退等操作。当然,RTSP 可基于 RTP 来传送数据,还可以选择 TCP、UDP、组播 UDP 等通道来发送数据,具有很好的扩展性,它是一种类似于 HTTP 协议的网络应用层协议。作为一个应用层协议, RTSP 提供了一个可供扩展的框架,它的意义在于使得实时流媒体数据的受控和点播变得可能。
jrtplib
jrtplib 是实现 RTP 的 C++ 库。
初始化:
RTPSession sess;
sess.Create(5000);
// 3.11版jrtplib库的Create方法被修改为Create(sessparams,&transparams)
RTPUDPv4TransmissionParams transparams;
RTPSessionParams sessparams;
sessparams.SetOwnTimestampUnit(1.0 / 8000.0); /*设置时间戳,1/8000表示1秒钟采样8000次,即录音时的8KHz*/
sessparams.SetAcceptOwnPackets(true);
transparams.SetPortbase(portbase); /*本地通讯端口*/
数据发送:
// RTP 协议允许同一会话存在多个目标地址
unsigned long addr = ntohl(inet_addr("127.0.0.1"));
sess.AddDestination(addr, 6000);
sess.SetDefaultPayloadType(0);
sess.SetDefaultMark(false);
sess.SetDefaultTimeStampIncrement(10);
sess.SendPacket((void *)buffer, sizeof(buffer), 0, false, 8000);
数据接收:
sess_client.Poll(); // 接收发送过来的 RTP 或者RTCP 数据报
sess_client.BeginDataAccess();
if (sess.GotoFirstSourceWithData())
{ // 遍历那些携带有数据的源
do
{
sess.AddToAcceptList(remoteIP, allports, portbase);
sess.SetReceiveMode(RECEIVEMODE_ACCEPTSOME);
RTPPacket *pack;
pack = sess.GetNextPacket(); // 处理接收到的数据
delete pack;
} while (sess.GotoNextSourceWithData());
}
sess_client.EndDataAccess();
总结
RTP是一个非常轻量的传输协议,特别适合传输音视频数据,或者说它就是专门为传输音视频数据而开发的。RTP控制协议RTCP对于传输服务质量起着关键的作用,WebRTC的服务质量系统中的大量控制参数都是通过RTCP获取的。
参考
https://blog.csdn.net/weixin_39766005/article/details/132301075https://baike.baidu.com/item/%E5%AE%9E%E6%97%B6%E4%BC%A0%E8%BE%93%E5%8D%8F%E8%AE%AEhttps://baike.baidu.com/item/rtcphttps://www.rfc-editor.org/rfc/rfc3551.htmlhttps://blog.csdn.net/leixiaohua1020/article/details/50535230https://blog.csdn.net/wangbuji/article/details/130781275https://blog.csdn.net/wangbuji/article/details/121285741https://blog.csdn.net/qq_41839588/article/details/133465671https://www.cnblogs.com/ishen/p/12050077.html
声明
本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。