流媒体传输协议之 RTP(下篇)

网友投稿 244 2022-10-14


流媒体传输协议之 RTP(下篇)

本系列文章将整理各个流媒体传输协议,包括 RTP/RTCP,RTMP,希望通过深入梳理协议的设计细节,能够给流媒体领域的开发者带来一定的启发。

作者:逸殊审核:泰一

接上篇:《 流媒体传输协议之 RTP(上篇)》

RTP 控制协议

Sender & Receiver 报告

RTP 使用 Sender 报告(SR)和 Receiver 报告(RR)来反馈数据的接收质量,如果是媒体数据的发送者那就会发送 SR,否则发送 RR。这两类报文是通过头部的报文类型识别码来做区分的。SR 相对于 RR 来说多了 20byte 的 Sender 相关信息,除此之外其他内容都是一样的。

SR 报文

SR 报文包含三个部分,第一个部分是头部,有 8 BYTE,各个字段的含义如下:

version (V): 2 bits,RTP 协议版本。 padding (P): 1 bit,是否包含填充,最后一个填充字节标识了总共需要忽略多少个填充字节(包括自己)。Padding 可能会被一些加密算法使用,因为有些加密算法需要定长的数据块。在复合包中,只有最后一个 RTCP 包需要添加填充。 reception report count (RC): 5 bits,有多少个接收报告。可以为 0。 packet type (PT): 8 bits,200 表示 SR 报文。 length: 16 bits,报文长度(按 32-bit 字统计),包含头部和填充字节。 SSRC: 32 bits,身份定位符。

第二部分是发送者信息,包含 20 BYTE 的数据,总结了这个发送的的传输统计,各个字段的含义如下:

NTP timestamp: 64 bits,Wallclock time,用于计算 RTT。 RTP timestamp: 32 bits,RTP 时间戳,基于 NTP 的某一随机偏移量。用于媒体数据内同步。 sender's packet count: 32 bits,这个 SSRC 总共发送了多少包。 sender's octet count: 32 bits,这个 SSRC 总共发送了多少 BYTE 的数据。

第三部分可能什么都没有,也可能有多个接收报告,这取决的上次报告以后收到了多少个 Sender 的数据。每个报告块统计了一个 SSRC 的包数。具体内容如下:

SSRC_n (source identifier): 32 bits,这个信息块对应的 SSRC。 fraction lost: 8 bits,上次 SR 或 RR 发送后到目前为止的丢包率。 cumulative number of packets lost: 24 bits,整体过程的丢包总数。 extended highest sequence number received: 32 bits,低 16-bit 是收到的最新的 RTP 报文序列号,高 16-bit 是序列号循环的次数。 interarrival jitter: 32 bits,RTP 数据报文抵达时间的抖动。如果 Si 代表 i 包中包含的 RTP 时间戳,Ri 代表 i 包被接收时的 RTP 时间戳,那两个包 i 和 j 的到达时间抖动算法如下::D(i,j) = (Rj - Ri) - (Sj - Si) = (Rj - Sj) - (Ri - Si)。我们在计算这个抖动时,要结合每个包的抖动,来计算一个平均值,计算平均值的方案如下:J(i) = J(i-1) * (15 / 16) + (|D(i-1,i)|)/16。 last SR timestamp (LSR): 32 bits,该 SSRC 最后一个 RTCP 报文(SR)中带的 NTP 时间。 delay since last SR (DLSR): 32 bits,从该 SSSR 最后一个 RTCP 报文(SR)被收到以来经过的时间。

RR 报文

接收报告的格式和发送报文格式一样,只不过它在头部中用 201 表示这是一个 RR 报文。此外 RR 报文中不含有上述 SR 报文中的第二部分。如果 RR 报文是空的那么需要在头部标明 RC=0。

发送 / 接收报文的拓展一些预设可能根据自己的需求,要在接收报告和发送报告中附加一些信息。那么这些附加内容应该在 SR 或者 RR 的结尾之后。如果这些内容只有发送者相关,那么 RR 中就不包含这些信息。

分析发送报告和接收报告这些接收质量的报告信息可能不光只有发送者要使用,接收者或者第三方监控器也会使用。发送者可能根据接收质量调整自己的传输策略。接收者可以根据这个信息来确定自己遇到的问题是本地网络的问题还是整个 Session 的问题。网络的管理者可以根据这些信息来评估整个网络环境的情况。

SDES 报文

version (V),padding (P),length: 和上面一样。 packet type (PT): 8 bits,202 表示 SDES 类型。 source count (SC): 5 bits,SSRC/CSRC 块的数量。

每一个块中都包含多个描述内容,这些描述内容都是 32-bit 对齐的,其中前 8-bit 描述了类型,接着 8-bit 描述了信息长度(不包含前 16-bit),然后信息内容。注意信息部分不能超过 255 BYTE,这和前面的很多工作类似是为了约束 RTCP 的带宽。

描述的文本内容是 UTF-8 编码的。如果要使用多字节的编码,需要在醒目的地方表示用的什么的编码。

各个描述部分是没有中间分隔的,所以要用空字节来填充以达到对齐的效果。注意这里的填充和 RTCP 头部的 P 不是一个概念。

末端节点发送的 SDES 包含他自己的数据源标识。而 Mixer 发送的 SDES 包含多个 CSRC,如果 CSRC 的数量超过了 31 个,会拆分成多个 SDES 报文。

SDES 的所有类型会在后面一一介绍。其中只有 CNAME 是强制要有的。可能有一些类型的的描述只有部分预设才会使用。但是这些内容都是在一个共通的地方来记载,以防止不同的预设使用的描述类型发生冲突。如果要注册新的类型,需要通过 IANA 注册。

CNAME:权威的末端节点身份标识

因为 SSRC 在许多意外情况下会重新生成,所以 CNAME 被用来绑定旧的 SSRC 和新的 SSRC,来保持数据源的连续。 和 SSRC 一样,CNAME 也需要保证唯一性(同一个 Session 中)。 为了让同一个参与者的多个 SSRC 绑定在一起,我们需要 CNAME 是固定的。 为了让第三方监控用起来方便,CNAME 应该即方便程序使用,也要设计成可读的,可以根据它确认来源。

因此 CNAME 应该通过算法来生成而不是手动生成。为了满足如上需要,一般来说是按照如下的格式来描述 CNAME:

"user@host" eg: "doe@192.0.2.89" or "doe@2201:056D::112E:144A:1E24". "host", 如果是单用户系统,获取不到 user 时只使用 host。eg: "sleepy.example.com","192.0.2.89" or "2201:056D::112E:144A:1E24".

有些人可能会发现,如果上述的 host 使用的是子网地址的话,就没办法保证整个 Session 的唯一性了,通常这类没有直接 IP 的使用者是通过一个 RTP 级别的 Translator 来访问公共网络。这个 Translator 会处理从私有地址到公网地址的转换工作。

NAME:用户名

这个是描述数据源的真实名字,eg:"John Doe, Bit Recycler"。整个 Session 过程中希望这个值不变。全 Session 不需要唯一。

EMAIL:电子邮箱地址

电子邮箱地址,eg: "John.Doe@example.com"。整个 Session 过程中希望这个值不变。

PHONE:电话号码

电话号码需要以国际访问码开头,eg: "+1 908 555 1212"。

LOC:用户地理地址

视应用不同,详细程度会各不相同。

TOOL:应用名或工具名

带版本号的应用名,可以用来 DEBUG。

NOTE:提醒 / 状态

用来发送暂时性的消息描述当前状态。eg: "on the phone, can't talk"。

PRIV:自定义拓展

上层应用自定义的格式。一般都是用过一个前缀描述消息类型,然后后面跟着消息正文。

BYE 报文

BYE 报文表示一个或多个流媒体源不再活跃。

version (V),padding (P),length: 同上。 packet type (PT): 8 bits,203 表示 BYE 报文。 source count (SC): 5 bits,退出 Session 的 SSRC 的数量。

如果 BYE 报文被 Mixer 收到了,Mixer 应该啥都不改动,就发给下一节点。如果 Mixer 关闭了,它要发送一个包含它管理的所有 SSRC 的 BYE 报文。BYE 报文中可能也会跟着带一些离开原因的描述。这些描述和 SDES 中带的描述类似,需要 32-bit,用空字节填补空缺。

APP:应用定义的 RTCP 报文

APP 报文一般用于实验性的功能和开发。如果识别到了不认识 NAME 那么上层应用一般都会忽略它。如果开发或者测试功能稳定了,一般是要通过 IANA 注册一个新的 RTCP 报文类型。

version (V),padding (P),length: 同上。 subtype: 5 bits,APP 报文子类型,一般是上层应用定义。 packet type (PT): 8 bits,204 表示 APP 类型的 RTCP 报文。 name: 4 octets 一般是应用名,防止 subtype 冲突。 application-dependent data: variable length 和上层应用相关的内容,需要 32-bit 对齐。

RTP Translator & Mixer

作为末端节点的补充,RTP 引入了 Translator 和 Mixer 的概念,它们是 RTP 层的中间件。虽然这多少增加了协议的复杂度,但是对音视频通话应用来说它们还是很关键的,因为它们能解决防火墙问题和低带宽连接的问题。

描述

一个 RTP Translator/Mixer 连接至少两个传输层的用户组。通常来说,这里提到的用户组是公共网络的概念,传输层协议会为其生成一个组播地址(ip:port)。网络层协议,像是 IPv4 和 IPv6 对 RTP 协议来说是隐藏的。一个系统可能会有多个 Translator 和 Mixer(多个 Session),它们中的每一个都可以看作是一个用户组的逻辑分割。

为了避免创建在创建 Translator 和 Mixer 造成了网络包循环,必须遵循下列规则:

每个通过连接 Translator 和 Mixer 而加入 Session 的用户组,要么需要网络层隔离,要么最少互相知道这些参数(protocol,address,port)中的一个。 由上一个规则推广的话,各个用户组绝对不能同时连接多个 Translator 或者 Mixer,除非有某种机制能保证他们之间数据被阻断。

Translator:在不改变 RTP 报文 SSRC 的条件下,向后传播该报文,正因为如此,报文的接收者才能识别到 Translator 转发后的报文到底是来自哪个人。有些 Translator 可能直接转发报文,不做任何改动,也有可能改变数据编码,payload 类型和时间戳。

如果多个数据报文被重新编码并合并到一起的话,Translator 必须为这类报文指定一个组新的序列号。这样,输入报文的丢失就会导致输出报文的断层。数据的接收者一般是不知道 Translator 的存在的,除非通过 payload 类型的不同或者传输层报文的源地址来判断。

Mixer:从一个或多个数据源那里接收数据,随后可能会改变数据的格式,然后将这些数据合并,并传递给下家。因为多个数据源的时序并不一定是同步的,所以 Mixer 需要整合各个数据源的时序关系,并将其映射到自己的一套时序上,所以 Mixer 也是一个 SSRC,所有通过 Mixer 的报文必须打上该 Mixer 的 SSRC。

为了表示这些数据的原始数据源,一般会通过 CSRC 列表来记录。有些 Mixer 可能自己也是一个原始数据源,所以他自己的 SSRC 也会出现在 CSRC 列表中。有些应用可能不希望 Mixer 的 SSRC 出现在 CSRC 中,但是这样可能就无法发现循环网络包。

上图是一个 Mixers 和 Translators 连接的例子。[] 代表末端节点,() 代表 Mixer,<> 代表 Translator,"M1:48 (1, 17)" 表示 Mixer1 的报文,48 是 Mixer1 的 SSRC,括号里的 1,17 是 CSRC,它合并了 E1:17 和 E2:1 这两个节点的数据。

Translator 处理 RTCP

除了要转发数据包,进行数据包的更改,Translator 和 Mixer 也要发送 RTCP 报文。在很多情况下,它会将收到的末端节点的 RTCP 报文合并到复合包中。当再次收到这些包时或者自己的 RTCP 周期到时,它会将复合包发送出去。

有的 Translator 可能对收到的 RTCP 报文不做任何改动,只是简单的转发这个包。如果这个 Translator 改变了报文数据的 payload,它必须对 SR 或者 RR 做相关的改动。通常来说,Translator 不能将多个数据源的 SR 和 RR 合并,因为这样会导致 RTT 的计算出现问题(RTT 根据 LSR 和 DLSR 计算)。

SR 中的发送者信息: Translator 不会创建自己的发送者信息,它会将收到 SR 传给下家。其中 SSRC 不会发生任何改动,但是发送者信息有必要的话一定要做适当的改动。如果 Translator 改变了数据编码,那 "byte count" 字段就要更改。如果他将多个数据报文合并,那它需要修改 "sender's packet count" 字段。如果它改变了时间频率,那就需要修改 "RTP timestamp"。 SR/RR 中的接收者信息:SSRC 不会发生任何改动,如果 Translator 改变了序列号,那就需要修改 "extended last sequence number",在某些极端情况下,它可能完全没有接收反馈,或者根据接收到的 SR/RR 来构建自己的接收报告。一般情况下 Translator 是不需要自己的 SSRC 的,但是如果是为了表示自己的数据接收情况,它可能也会生成自己的 SSRC,并将这些 RTCP 报文发送过所有的连接者。 SDES:一般 Translator 收到 SDES 后会什么都不改就发给下家,但是也有可能为了节约带宽筛掉 CNAME 之外的信息的,如果 Translator 要发送自己的 RR 信息,那它一定要发送一个自己的 SDES 给所有连接者。 BYE:无改动转发,如果 Translator 有自己的 SSRC 也要发送自己的 BYE。 APP:无改动转发。

Mixer 处理 RTCP

因为 Mixer 会生成自己的数据流,所以他不会转发经过他的 SR 和 RR 而是为连接双方发送自己的 SR 和 RR 报文。

SR 的发送者信息:Mixer 不转发数据来源的发送信息。它会生成自己的发送者信息并把它发送给下家。 SR/RR 中的接收者信息:Mixer 会生成自己的接收信息,然后发送给所有数据来源,它绝对不能做接收报告的转发工作,或者把自己的接收信息发给错误的对象。 SDES:Mixers 通常会不做任何改动就转发 SDES 信息,但是也有可能为了节约带宽过滤除了 CNAME 之外的其他信息。Mixer 必须发送自己的 SDES 报文。通常,Mixer 会将多个收到的 SDES 打包一起发送。 BYE:Mixer 必须转发 BYE 报文。如果 Mixer 要退出时,它会将所有数据来源的 SSRC 放进 BYE 报文,也包括自己的 SSRC。 APP:视上层应用。

瀑布型 Mixer

一个 RTP Session 可能包含多个 Mixer 和 Translator,就像上图一样。如果 Mixer 是瀑布型的,就像 M2 和 M3,一个 Mixer 收到的数据可能是已经合并过的,它有自己的 CSRC 列表。那么第二个 Mixer 需要将之前的 CSRC 和自己接收的所有 SSRC 合并。就像图中 M3 的输出是 M3:89 (64,45)。

SSRC 的分配和使用

前面已经说过 SSRC 是一个随机的 32-bit 数,它需要在整个 Session 内保证唯一性。所以同一个网络下的参与者在刚加入 Session 时使用不同的 SSRC 至关重要。

我们不能简单的用本地的网络地址,因为可能不唯一。也不能不考虑初始状态而简单地调一个随机数函数。

碰撞的可能性

因为 SSRC 是随机选择的,这就可能多个数据源选用了相同的 SSRC。如果大家是同时加入 Session 的话,这个碰撞的几率就更高。如果 SSRC 的数量是 N,L 是 SSRC 的数据长度(这里是 32),那么碰撞的可能性是 1 - exp(-N2 / 2(L+1)),当 N=1000 时,碰撞率大概是 10**-4。

通常来说,实际的碰撞率会比上述的最坏情况要低。通常一个新节点加入时,其他节点已经有了自己的唯一 SSRC,这时候碰撞的概率只是生成的新 SSRC 在这些现有 SSRC 之中的可能性。这时候碰撞率是 N/2**L。当 N=1000 时,碰撞率大约是 2*10**-7。

因为新加入的节点会先接收一段时间的报文然后才发送自己的第一个报文,所以在它生成 SSRC 时可以避开已知的 SSRC,这也有效的降低了碰撞的几率。

碰撞的解决方案和循环的发现

通常来说 SSRC 碰撞的可能性很小,所有的 RTP 实现必须有发现冲突的机制,并在发现冲突时作出适当的处理。如果数据源发现了任何一个别的数据源和自己使用同一个 SSRC,它必须用原来的 SSRC 发送一个 BYE 报文,然后选用一个新的 SSRC。如果一个数据的接收者发现了多个数据源的 SSRC 碰撞了(通过传输地址或者 CNAME),那么它会只接收其中一个人的报文,丢弃另一个人的所有报文。

因为整个 Session 中的 SSRC 是唯一的,所以它也可以被用来发现环型报文。环形报文会导致数据的重复以及控制信息的重复。

Translator 可能会错误地将报文发送回该报文来的地方。 两个 Translator 错误地同时启动,它们两个都会转发同样的数据。 Mixer 可能会错误地将合并报文发送回这些报文来的地方。

一个数据源可能发现自己的或者别人的报文被循环发送了。无论是报文循环还是 SSRC 的碰撞都会导致同一个现象,即 SSRC 相同但是传输地址不同的报文。因此,如果数据源改变了自己的传输地址,那它就需要同时改变自己的 SSRC 来避免被检测成环形报文。有一个需要注意的内容是,如果一个 Translator 再重启的过程中改变了自己的传输地址,那么这个 Translator 转发的所有数据都会被检测成环。这类情况的解决方案一般有如下两个:

重启的时候不改变传输地址。 接收者的超时机制。

如果循环或者碰撞发生在离 Translator 和 Mixer 很远的地方,我们就不能通过传输地址来发现。但是我们仍然可以通过 CNAME 的不同来发现 SSRC 碰撞。

为了解决上述问题,RTP 的实现必须包含一个类似如下的算法。这个算法不包括多个数据源 SSRC 碰撞的情况,这类情况通常下都是先用原来的 SSRC 发送一个 BYE 然后重新选择一个新的 SSRC。

这个算法需要维护一个 SSRC 和传输地址的映射关系。因为 RTP 的数据和 RTCP 传输使用的是两个不同的端口,所以一个 SSRC 对应的是两个传输地址。

每次收到 RTP 报文和 RTCP 报文都会将其 SSRC 和 CSRC 在上述的表中进行比对。如果发现了传输地址对不上的情况,我们就可以说发现了一个循环或者碰撞。对于 RTCP 数据来说,可能每个数据块都有自己独立的 SSRC,比如 SDES 数据,对于这种情况就需要分别比对。如果没有在表中找到这个 SSRC 或者 CSRC,就需要新添加一项。当收到 BYE 报文时,需要先比对这个 BYE 的传输地址,如果传输地址匹配上了,就将这一项从表中删除。或者基于超时机制,将超时的数据从表中移除。

为了追踪自己的数据报文循环情况,必须维护另一个列表,这个表存储冲突报文的传输地址和收到该报文的时间。如果超过 10 个 RTCP 周期都没有收到这个传输地址的冲突报文,就将该项从表中删除。

下面的算法还假设参与者自己的 SSRC 和状态都包含在 SSRC 表中,它会先比对自己的 SSRC。

if (SSRC or CSRC identifier is not found in the source identifier table) { create a new entry storing the data or control source transport address, the SSRC or CSRC and other state; } /* Identifier is found in the table */ else if (table entry was created on receipt of a control packet and this is the first data packet or vice versa) { store the source transport address from this packet; } else if (source transport address from the packet does not match the one saved in the table entry for this identifier) { /* An identifier collision or a loop is indicated */ if (source identifier is not the participant's own) { /* OPTIONAL error counter step */ if (source identifier is from an RTCP SDES chunk containing a CNAME item that differs from the CNAME in the table entry) { count a third-party collision; } else { count a third-party loop; } abort processing of data packet or control element; /* MAY choose a different policy to keep new source */ } /* A collision or loop of the participant's own packets */ else if (source transport address is found in the list of conflicting data or control source transport addresses) { /* OPTIONAL error counter step */ if (source identifier is not from an RTCP SDES chunk containing a CNAME item or CNAME is the participant's own) { count occurrence of own traffic looped; } mark current time in conflicting address list entry; abort processing of data packet or control element; } /* New collision, change SSRC identifier */ else { log occurrence of a collision; create a new entry in the conflicting data or control source transport address list and mark current time; send an RTCP BYE packet with the old SSRC identifier; choose a new SSRC identifier; create a new entry in the source identifier table with the old SSRC plus the source transport address from the data or control packet being processed; } }

层级编码

对于不同 Session 的层级编码传输,一般都是所有层都使用同一个 SSRC,如果其中某一层发现了 SSRC 冲突,那么只改变这一层的 SSRC,而且他层的 SSRC 不做改变。

安全

下层协议可能会提供 RTP 应用所需要的所有安全服务,包括认证,数据完整性,数据保密性。这些服务在 IP 协议中都有解决方案。因为 Audio 和 Video 初始化过程中需要数据加密,而这时候 IP 协议这一层的安全服务还没有提供。所以,RTP 需要实现一个 RTP 专用的保密服务。这个保密服务是非常轻量级的,而且保密部分的服务向后兼容,以后可以随时进行更换。或者,某些预设会提供这部分加密服务,比如 SRTP(Secure Real-time Transport Protocol),SRTP 是基于 Advanced Encryption Standard (AES) 提供了一个比 RTP 默认加密服务更强大的实现。

保密性

保密性是指我们的报文只希望一些特定的接收者可以解码成明文,而其他人只能得到无用的信息,保密性是通过加密编码来提供的。

当需要为 RTP 和 RTCP 报文提供加密服务时,所有传输的内容都会在下层报文那里进行加密。对于 RTCP 来说,需要一个 32-bit 的随机数作为前缀。而 RTP 报文不需要前缀,取而代之的是随机序列号和时间戳偏移。因为随机部分很少,所以可以说这是一个非常弱的初始向量。此外,SSRC 也可被破解者修改,这是这个加密方案的另一个薄弱的环节。

对于 RTCP 来说,可能会将一个复合包分成两批,第一批加密,后一批明文发送。例如,SDES 部分的信息可能加密,而接收报告部分不加密就发送出去,因为只有这样那些第三方监控器才能在不知道密钥的情况下统计网络状况。如下图所示,SDES 信息必须跟在一个空的 RR 后,并且要有一个随机前缀。

RTP 协议使用的 Data Encryption Standard (DES) 算法,使用 cipher block chaining (CBC) 模式,这需要数据填充到 64-bit 对齐。密码算法使用零作为初始向量,因为 RTCP 报文中已经有一个随机前缀了。

RTP 之所以选择这个默认协议是因为它用起来很容易,但是因为 DES 太容易破解了。所以推荐预设中使用更健壮的加密算法来替换这个默认方案,例如 Triple-DES。这些算法普遍需要一个随机初始化块,RTCP 使用了 32-bit 的随机数作为前缀,RTP 使用了时间戳和序列号的随机偏移,可是相邻的 RTP 报文之间的随机性就很差。需要注意的是,无论是 RTCP 还是 RTP,它们的随机性都有限。加密型更好的应用,需要考虑更多的保密措施。例如 SRTP 配置文件,就基于 AES 来加密,它的加密方案就更完备,选择这个预设来使用 RTP 就挺不错的。

前面提到过也可以用 IP 级的加密方案或者 RTP 级的加密,一些预设可能会定义别的 payload 类型来加密。这种方案,可能只加密 payload 部分而头部分使用明文,因为只有 payload 部分才是应用真正需要的内容。这可能对硬件设备来说非常有用,它既处理解密过程,又处理解码过程。

身份认证和消息完整性

RTP 协议这一层没有身份认证和消息完整性服务,因为有些上层服务可能没有认证就能使用。而消息完整性服务依赖下层协议来实现。

RTP 下的网络层和传输层协议

RTP 需要下层协议提供多路复用机制。对于 UDP 这类应用,推荐 RTP 应该使用一个偶数端口传输数据,和它相关的 RTCP 流应该是用高一位的奇数端口。在单播模式下,每个参与者都需要一对端口来传输 RTP 和 RTCP 报文。两个参与者可能使用相同的端口。绝对不能以接收到的报文网络地址直接作为目标地址发送报文。

建议层编码模式是,使用相邻的端口,因此对于层 N 来说,数据端口是 P+2N,控制端口是 P+2N+1。对于 IP 组播来说,可能不会得到相邻的组播地址。

RTP 数据报文没有描述报文长度的信息。所以 RTP 报文依赖下层协议提供长度标识。所以一个 RTP 报文的最大长度由下层协议限制。

如果 RTP 报文使用的下层协议是流传输协议的话,必须定义一套数据帧分割机制。

参考

[1] rfc3550

作者的个人博客:https://beikejiedeliulangmao.top/


版权声明:本文内容由网络用户投稿,版权归原作者所有,本站不拥有其著作权,亦不承担相应法律责任。如果您发现本站中有涉嫌抄袭或描述失实的内容,请联系我们jiasou666@gmail.com 处理,核实后本网站将在24小时内删除侵权内容。

上一篇:总结Java对象被序列化的两种方法
下一篇:Spring mvc是如何实现与数据库的前后端的连接操作的?
相关文章

 发表评论

暂时没有评论,来抢沙发吧~