上一篇文章简单阐述了,在 H5 中,做直播需要哪些技术知识点,有哪些直播流协议和技术。通过对比,本篇主要聚焦于 RTMP 直播协议的相关内容,也就是说,本篇将会直接进行实际操作 Buffer 的练习和相关的学习。
RTMP 全称即是
。顾名思义就是用来作为实时通信的一种协议。该协议是 Adobe 搞出来的。主要是用来传递音视频流的。它通过一种自定义的协议,来完成对指定直播流的播放和相关的操作。和现行的直播流相比,RTMP 主要的特点就是高效,这里,我就不多费口舌了。我们先来了解一下 RTMP 是如何进行握手的。
- Real-Time Messaging Protocol
RTMP 是基于 TCP 三次握手之后的,所以,RTMP 不是和 TCP 一个 level 的。它本身是基于 TCP 的可靠性连接。RTMP 握手的方式如图:
(C 代表 Client,S 代表 Server)
它主要是通过两端的字段内容协商,来完成可信度认证的。基本过程如下:
整个过程如上图所述,但实际上有些细节需要注意。
握手开始:
【1】 客户端发送 C0,C1 包
此时,客户端处于等待状态。客户端有两个限制:
【2】 服务端在接受到 C0,发送 S0,S1 包。也可以等到接受到 C1 之后再一起发送,C1 包的等待不是必须的。
此时,服务端处于等待状态。服务端有两个限制:
【3】客户端接受到 S1/S0 包后,发送 C2 包。
【4】服务端接受到 C2 包后,返回 S2 包,并且此时握手已经完成。
不过,在实际应用中,并不是严格按照上面的来。因为 RTMP 并不是强安全性的协议,所以,S2/C2 包只需要 C1/S1 中的内容,就可以完成内容的拼接。
这么多限制,说白了,其实就是一种通用模式:
接下来,我们来具体看看 C/S 012 包分别代表什么。
C0 和 S0 其实区别不大,我这里主要讲解一下 C0,就差不多了。首先,C0 的长度为 1B。它的主要工作是确定 RTMP 的版本号。
C1/S1 长度为 1536B。主要目的是确保握手的唯一性。格式为:
C2/S2 的长度也是 1536B。相当于就是 S1/C1 的响应值。上图也简单说明了就是,对应 C1/S1 的 Copy 值,不过第二个字段有区别。基本格式为:
这里需要提及的是,RTMP 默认都是使用 Big-Endian 进行写入和读取,除非强调对某个字段使用 Little-Endian 字节序。
上面握手协议的顺序也是根据其中相关的字段来进行制定的。这样,看起来很容易啊哈,但是,我们并不仅仅停留在了解,而是要真正的了解,接下来,我们来实现一下,如果通过 Buffer 来进行 3 次握手。这里,我们作为 Client 端来进行请求的发起,假设 Server 端是按照标准进行发送即可。
我们使用 Buffer 实操主要涉及两块,一个块是 request server 的搭建,还有一块是 Buffer 的拼接。
这里的 Server 是直接使用底层的 TCP 连接。
如下,一个简易的模板:
- const client = new net.Socket();
- client.connect({
- port: 1935,
- host: "6721.myqcloud.com"
- },
- () = >{
- console.log("connected");
- });
- client.on('data', (data) = >{
- client.write('hello');
- });
不过,为了更好的进行实际演练,我们通过
的方式,来做一个筛选器。这里,我们使用 mitt 模块来做代理。
- EventEmitter
- const Emitter = require('mitt')();
然后,我们只要分析的就是将要接受到的 S0/1/2 包。根据上面的字节包图,可以清楚的知道包里面的详细内容。这里,为了简单起见,我们排除其他协议的包头,只是针对 RTMP 里面的包。而且,我们针对的只有 3 种包,S0/1/2。为了达到这种目的,我们需要在
时间中,加上相应的钩子才行。
- data
这里,我们借用 Now 直播的 RTMP 流来进行相关的 RTMP 直播讲解。
Server 的搭建其实上网搜一搜,应该都可以搜索出来。关键点在于,如何针对 RTMP 的实操握手进行 encode/decode。所以,这里,我们针对上述操作,来主要讲解一下。
我们主要的工作量在于如何构造出 C0/1/2。根据上面格式的描述,大家应该可以清楚的知道 C0/1/2 里面的格式分别有啥。
比如,C1 中的 time 和 random,其实并不是必须字段,所以,为了简单起见,我们可以默认设为 0。具体代码如下:
- class C {
- constructor() {
- this.time;
- this.random;
- }
- C0() {
- let buf = Buffer.alloc(1);
- buf[0] = 3;
- return buf;
- }
- C1() {
- let buf = Buffer.alloc(1536);
- return buf;
- }
- /**
- * write C2 package
- * @param {Number} time the 4B Number of time
- * @param {Buffer} random 1528 byte
- */
- produceC2() {
- let buf = Buffer.alloc(1536);
- // leave empty value as origin time
- buf.writeUInt32BE(this.time, 4);
- this.random.copy(buf, 8, 0, 1528);
- return buf;
- }
- get getC01() {
- return Buffer.concat([this.C0(), this.C1()]);
- }
- get C2() {
- return this.produceC2();
- }
- }
接下来,我们来看一下,结合 server 完成的 RTMP 客户端服务。
- const Client = new net.Socket();
- const RTMP_C = new C();
- Client.connect({
- port: 1935,
- host: "6721.liveplay.myqcloud.com"
- },
- () = >{
- console.log('connected') Client.write(RTMP_C.getC01);
- });
- Client.on('data', res = >{
- if (!res) {
- console.warn('received empty Buffer ' + res);
- return;
- }
- // start to decode res package
- if (!RTMP_C.S0 && res.length > 0) {
- RTMP_C.S0 = res.readUInt8(0);
- res = res.slice(1);
- }
- if (!RTMP_C.S1 && res.length >= 1536) {
- RTMP_C.time = res.readUInt32BE(0);
- RTMP_C.random = res.slice(8, 1536);
- RTMP_C.S1 = true;
- res = res.slice(1536);
- console.log('send C2');
- Client.write(RTMP_C.C2);
- }
- if (!RTMP_C.S2 && res.length >= 1536) {
- RTMP_C.S2 = true;
- res = res.slice(1536);
- }
- })
详细代码可以参考 gist 。
RTMP 整个内容,除了握手,其实剩下的就是一些列围绕 type id 的 message。为了让大家更清楚的看到整个架构,这里简单陈列了一份框架:
在 Message 下的 3 个一级子 Item 就是我们现在将要大致讲解的内容。
可以看到上面所有的 item 都有一个共同的父 Item–Message。它的基本结构为:
下面,我们先了解一下 Header 和不同 typeID 的内容:
RTMP 中的 Header 分为 Basic Header 和 Message Header。需要注意,他们两者并不是独立的,而是相互联系。Message Header 的结构由 Basic Header 的内容来决定。
接下来,先分开来讲解:
BH(基础头部)主要是定义了该 chunk stream ID 和 chunk type。需要注意的是,BH 是变长度的,即,它的长度范围是 1-3B。怎么讲呢?就是根据不同的 chunk stream ID 来决定具体的长度。CS ID(Chunk Stream ID)本身的支持的范围为 <= 65597 ,差不多为 22bit。当然,为了节省这 3B 的内容。 Adobe 搞了一个比较绕的理论,即,通过如下格式中的 CS ID 来确定:
- 0 1 2 3 4 5 6 7
- +-+-+-+-+-+-+-+-+
- |fmt| cs id |
- +-+-+-+-+-+-+-+-+
即,通过 2-7 bit 位来确定整个 BH 的长度。怎么确定呢?
RTMP 规定,CS ID 的 0,1,2 为保留字,你在设置 CS ID 的时候只能从 3 开始。
- 0 1
- 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5
- +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
- |fmt| 0 | cs id - 64 |
- +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
注意上面的 cs id - 64。这个代表的就是,你通过切割第二个 byte 时,是将得到的值加上 64。即:
- 2th byte + 64 = CS ID
- 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3
- +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
- |fmt| 1 | cs id - 64 |
- +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
当然,后面 CS ID 的计算方法也是最后的结果加上 64。
- 0 1 2 3 4 5 6 7
- +-+-+-+-+-+-+-+-+
- |fmt| cs id |
- +-+-+-+-+-+-+-+-+
最后强调一下,因为 RTMP 规定,CS ID 的 0,1,2 为保留字,所以,0,1,2 不作为 CS ID。综上所述,CS ID 的起始位为 3(并不代表它是 3 个 Stream)。
上面我并没有提到 fmt 字段,这其实是用来定义 Message Header 的。
根据前面 BH 中 fmt 字段的定义,可以分为 4 种 MH(Message Header)。或者说,就是一种 MH 格式会存在从繁到简 4 种:
当 fmt 为 0 时,MH 的长度为 11B。该类型的 MH 必须要流的开头部分,这包括当进行快退或者点播时重新获取的流。该结构的整体格式如下:
也就是说,当 fmt 为 0 时,其格式是一个完整的 MH。
当 fmt 为 1 时,MH 的长度为 7B。该类型的 MH 不带 msg stream id。msg stream id 由前面一个 package 决定。该数值主要由前一个 fmt 为 0 的 MH 决定。该类型的 MH 通常放在 fmt 为 0 之后。
当 fmt 为 2 时,MH 的长度为 3B。该类型的 MH 只包括一个 timestamp delta 字段。其它的信息都是依照前面一个其他类型 MH 决定的。
当 fmt 为 3 时,这其实 RTMP 里面就没有了 MH。官方定义,该类型主要全部都是 payload 的 chunk,其 Header 信息和第一个非
的头一致。因为这主要用于 chunk 中,这些 chunk 都是从一个包里面切割出来的,所以除了第一个 chunk 外,其它的 chunk 都可以采用这种格式。当 fmt 为 3 时,计算它的 timestamp 需要注意几点,如果前面一个 chunk 里面存在
- type:3
,那么计算 fmt 为 3 的 chunk 时,就直接相加,如果没有,则是使用前一个 chunk 的
- timestrameDelta
来进行相加,用代码表示为:
- timestamp
- prevChunk.timeStamp += prevChunk.timeStampDelta || prevChunk.timeStamp;
不过,当 fmt: 3 的情况一般很难遇到。因为,他要求前面几个包必须存在 fmt 为 0/1/2 的情况。
接下来的就是 Message Body 部分。
上面说的主要是 Message Header 的公用部分,但是,对于具体的 RTMP Message 来说,里面的 type 会针对不同的业务场景有不同的格式。Message 全部内容如上图所示:
这里,我们根据流程图的一级子 item 来展开讲解。
PCM 全称为:Protocol Control Messages(协议控制消息)。主要使用来沟通 RTMP 初始状态的相关连接信息,比如,windows size,chunk size 等。
PCM 中一共有 5 种不同的 Message 类型,是根据 Header 中的 type ID 决定的,范围是 1~6 (不包括 4)。另外,PCM 在构造的时候需要注意,它 Heaer 中的 message stream id 和 chunk stream id 需要设置为固定值:
如图所示:
OK,我们接下来一个一个来介绍一下:
看名字大家应该都能猜到这类信息是用来干啥的。该类型的 PCM 就是用来设置 server 和 client 之间正式传输信息的 chunk 的大小,type ID 为 1。那这有啥用呢?
SCS(Set Chunk Size) 是针对正式发送数据而进行数据大小的发送限制。一般默认为 128B。不过,如果 server 觉得太小了,想发送更大的包给你,比如 132B,那么 server 就需要给你发送一个 SCS,告知你,接下来 "我发送给你的数据大小是 132B"。
如下,提供过 wireshark 抓包的结果:
该类 PCM 是用来告诉 client,丢弃指定的 stream 中,已经加载到一半或者还未加载完成的 Chunk Message。它需要指定一个 chunk stream ID。
基本格式为:
该协议信息其实就是一个 ACK 包,在实际使用是并没有用到,它主要是用来作为一个 ACK 包,来表示两次 ACK 间,接收端所能接收的最大字节数。
它基本格式为:
不过,该包在实际应用中,没有多高的出现频率。
这是用来协商发送包的大小的。这个和上面的
不同,这里主要针对的是客户端可接受的最大数据包的值,而 chunk size 是指每次发送的包的大小。也可以叫做
- chunk size
。一般电脑设置的大小都是 500000B。
- window size
详细格式为:
通过,wireshark 抓包的结果为:
这是 PCM 中,最后一个包。他做的工作主要是根据网速来改变发送包的大小。它的格式和 WAS 类似,不过后面带上了一个
用来标明当前带宽限制算法。当一方接收到该信息后,如果设置的 window size 和前面的 WAS 不一致,需要返回一个 WAS 来进行显示改变。
- Type
基本格式为:
其中 Limit Type 有 3 个取值:
为基准,否则忽略该次协议信息。
- Hard
实际抓包情况可以参考:
全称为:
(用户控制信息)。它的 Type ID 只能为 4。它主要是发送一些对视频的控制信息。其发送的条件也有一定的限制:
- User Control Message
它的 Body 部分的基本格式为:
UCM 根据 Event Type 的不同,对流进行不同的设置。它的 Event Type 一共有 6 种格式
,
- Stream Begin(0)
,
- Stream EOF(1)
,
- StreamDry(2)
,
- SetBuffer Length(3)
,
- StreamIs Recorded(4)
,
- PingRequest(6)
。
- PingResponse(7)
这里,根据重要性划分,只介绍 Begin,EOF,SetBuffer Length 这 3 种。
后发送。Event Data 为 4B,内容是已经可以正式用来传输数据的 Stream ID(实际没啥用)。
- connect
OK 剩下就是 Command Msg 里面的内容了。
Command Msg 里面的内容,其 type id 涵盖了 8~22 之间的值。具体内容,可以参考下表:
需要注意,为什么有些选项里面有两个 id,这主要和 AMF 版本选择有关。第一个 ID 表示 AMF0 的编解码方式,第二个 ID 表示 AMF3 的编解码方式。 其中比较重要的是 command Msg,video,audio 这 3 个 Msg。为了让大家更好的理解 RTMP 流的解析,这里,先讲解一下 video 和 audio 两个 Msg。
因为 RTMP 是 Adobe 开发的。理所当然,内部的使用格式肯定是 FLV 格式。不过,这和没说一样。因为,FLV 格式内部有很多的 tag 和相关的描述信息。那么,RTMP 是怎么解决的呢?是直接传一整个 FLV 文件,还自定义协议来分段传输 FLV Tag 呢?
这个其实很好回答,因为 RTMP 协议是一个长连接,如果是传整个 FLV 文件,根本没必要用到这个,而且,RTMP 最常用在直播当中。直播中的视频都是分段播放的。综上所述,RTMP 是根据自己的自定义协议来分段传输 FLV Tag 的。那具体的协议是啥呢?
这个在 RTMP 官方文档中其实也没有给出。它只是告诉我们 Video Msg 的 type ID 是 9 而已。
因为,RTMP 只是一个传输工具,里面传什么还是由具体的流生成框架来决定的。所以,这里,我选择了一个非常具有代表性的 RTMP 直播流来进行讲解。
通过 wireshark 抓包,可以捕获到以下的 RTMP 包数据:
这里需要提及一点,因为 RTMP 是主动将 Video 和 Audio 分开传输,所以,它需要交叉发布 Video 和 Audio,以保证音视频的同步。那么具体每个 Video Data 里面的数据都是一样的吗?
如果看 Tag 的话,他们传输的都是 VideoData Tag。先看一下 FLV VideoData Tag 的内容:
这是 FLV Video 的协议格式。但,遇到第一个字段
的时候,我们就可能懵逼了,这 TM 有 5 种情况,难道 RTMP 会给你 5 种不同的包吗?
- FrameType
答案是,有可能,但是,很大情况下,我们只需要支持 1/2 即可。因为,视频中最重要的是 I 帧,它对应的 FrameType 就是 1。而 B/P 则是剩下的 2。我们只要针对 1/2 进行软解,即可实现视频所有信息的获取。
所以,在 RTMP 中,也主要(或者大部分)都是传输上面两种 FrameType。我们通过实际抓包来讲解一下。
这是 KeyFrame 的包,注意 Buffer 开头的
数字。大家可以找到上面的 FrameType 对应找一找,看结果是不是一致的:
- 17
这是 Inter-frame 的包。同上,大家也可以对比一下:
Aduio Tag 也是和 Video Tag 一样的蜜汁数据。通过观察 FLV Audio Tag 的内容:
上面这些字段全是相关的配置值,换句话说,你必须实现知道这些值才行。这里,RTMP 发送 Audio Tag 和 Video Tag 有点不同。因为 Audio Tag 已经不可能再细分为 Config Tag,所以,RTMP 会直接传递 上面的 audio Tag 内容。详细可以参考抓包内容:
这也是所有的 Audio Msg 的内容。
因为 Audio 和 Video 是分开发送的。所以,在后期进行拼接的时候,需要注意两者的同步。说道这里,顺便补充一下,音视频同步的相关知识点。
音视频同步简单来说有三种:
主要过程变量参考就是
和
- timeStamp
。因为,这里主要是做直播的,推荐大家采用第二种方法,以
- duration
为准。因为,在实际开发中,会遇到 MP4 文件生成时,必须要求第一帧为
- Video
,这就造成了,以 Audio 为参考的,会遇到两个变量的问题。一个是 timeStamp 一个是 keyframe。当然,解决办法也是有的,就是检查最后一个拼接的 Buffer 是不是 Keyframe,然后判断是否移到下一次同步处理。
- keyframe
这里,我简单的说一下,以 Video 为准的同步方法。以 Video 同步,不需要管第一帧是不是 keyframe,也不需要关心 Audio 里面的数据,因为,Audio 数据是非常简单的 AAC 数据。下面我们通过伪代码来说明一下:
- // known condition
- video.timeStamp && video.perDuration && video.wholeDuration
- audio.timeStamp && audio.perDuration
- // start
- refDuration = video.timeStamp + video.wholeDuration
- delta = refDuration - audio.timeStamp
- audioCount = Math.round(delta/audio.perDuration);
- audDemuxArr = this._tmpArr.splice(0,audioCount);
- // begin to demux
- this._remuxVideo(vidDemuxArr);
- this._remuxAudio(audDemuxArr);
上面算法可以避免判断 Aduio 和 Video timeStamp 的比较,保证 Video 一直在 Audio 前面并相差不远。下面,我们回到 RTMP 内容。来看看 Command Msg 里面的内容。
Command Msg 是 RTMP 里面的一个主要信息传递工具。常常用在 RTMP 前期和后期处理。Command Msg 是通过 AMF 的格式进行传输的(其实就是类似 JSON 的二进制编码规则)。Command Msg 主要分为
和
- net connect
两大块。它的交流方式是双向的,即,你发送一次
- net stream
或者
- net connect
之后,另外一端都必须返回一个
- stream
或者
- _result
以表示收到信息。详细结构可以参考下图:
- _error
后续,我们分为两块进行讲解:
里面的 _result 和 _error 会穿插在每个包中进行讲解。
netConnection 可以分为 4 种 Msg,
,
- connect
,
- call
,
- createStream
。
- close
connect 是客户端向 Server 端发送播放请求的。里面的字段内容有:
。表示信息名称
- connect
那,Command Object 里面又可以存放些什么内容呢?
。
- live
。
- LNX 9,0,124,2
。
- rtmp://6521.liveplay.myqcloud.com/live
- 4071
- 252
- 1
简单来说,Command Object 就是起到 RTMP Route 的作用。用来请求特定的资源路径。实际数据,可以参考抓包结果:
上面具体的取值主要是根据 rtmp 官方文档来决定。如果懒得查,可以直接使用上面的取值。上面的内容是兼容性比较高的值。当该包成功发送时,另外一端需要得到一个返回包来响应,具体格式为:
可以参考:
connect 包发送的位置,主要是在 RTMP 握手结束之后。如下:
call 包主要作用是用来远程执行接收端的程序(RPC, remote procedure calls)。不过,在我解 RTMP 的过程中,并没有实际用到过。这里简单介绍一下格式。它的内容和
类似:
- connect
Command Object 里面的内容主要是针对程序,设置相关的调用参数。因为内容不固定,这里就不介绍了。
call 一般是需要有 response 来表明,远端程序是否执行,以及是否执行成功等。返回的格式为:
createStream 包只是用来告诉服务端,我们现在要创建一个 channel 开始进行流的交流了。格式和内容都不复杂:
当成功后,服务端会返回一个
或者
- _result
包来说明接收成功,详细内容为:
- _error
它的返回值很随意,参考抓包内容:
下面,我们来看一下 RTMP 中第二个比较重要的 command msg – netStream msg。
NetStream 里面的 Msg 有很多,但在直播流中,比较重要的只有
包。所以,这里我们着重介绍一下 play 包。
- play
play 包主要是用来告诉 Server 正式播放音视频流。而且,由于 RTMP 天然是做多流分发的。如果遇到网络出现相应的波动,客户端可以根据网络条件多次调用 play 命令,来切换不同模式的流。
其基本格式为:
- Null
- StreamName: '6721_75994f92ce868a0cd3cc84600a97f75c'
。例如:
- mp3
。
- mp3:6721_75994f9
。
- mp4:6721_75994f9.mp4
整个
包内容就已经介绍完了。我们可以看看实际的 play 抓包结果:
- play
那 play 包是在那个环节发送,发送完之后需不需要对应的 _result 包呢?
play 包比较特殊,它是不需要 _result 回包的。因为,一旦
包成功接收后。server 端会直接开始进行
- play
的操作。
- streamBegin
整个流程为:
到这里,后续就可以开始正式接收 video 和 audio 的 stream。
来源: http://www.tuicool.com/articles/3i6ra2R