认识rtmp
rtmp是Adobe公司出品的流媒体传输协议,它的全称是Real Time Messaging Protocol,是一个实时消息传输协议,学习RTMP一定要抓住 一个关键点:消息。
rtmp协议的原文可以在Adobe官网下载,内容十分精简,建议读一读原文。
rtmp的核心是消息交换,是一个基于TCP的协议,消息被分成消息块(chunk)使用TCP传输。每个chunk都携带一个id,称为chunk id,接收端根据chunk id将分块重新组装成完整的消息。所有chunk id相同的分块构成一条虚拟的chunk stream(块流),是一条逻辑流。同时每个消息也有一个message stream id,所有message stream id相同的消息构成一条消息流,这是第二条逻辑流。
message stream和chunk stream之间并不存在一一对应的关系。一条message stream可以通过多条chunk stream传输,不同的message stream也可以复用同一条chunk stream。消息有很多种类型,消息类型和消息流也不是一一对应的关系,一条消息流可以传输不同类型的消息,但一般每一种消息都会独占一条chunk stream。
rtmp协议的流程也是从握手开始。握手之后就全部是消息交换。
工欲善其事 必先利其器
rtmp协议的目的是流媒体传输,为了验证效果,需要用到ffmpeg
和ffplay
,这两个程序都可以在ffmpeg官网上找到,直接下载压缩包解压就可以使用。当然,为了使用方便,可以添加到path。有其他的推拉流工具也可以,作为开发,这两个命令就足够了。
推流命令:
ffmpeg -re -stream_loop -1 -i trailer.mp4 -codec copy -f flv rtmp://localhost/live/test
播放命令:
ffplay -autoexit rtmp://localhost/live/test
这两个命令比较长,可以使用make帮助我们简化工作。
push: trailer.mp4
@ffmpeg -re -stream_loop -1 -i trailer.mp4 -codec copy -f flv rtmp://localhost/live/test
pull:
@ffplay -autoexit rtmp://localhost/live/test
协议使用golang开发,具体的代码实现详见Github.
握手
rtmp协议从握手开始,由客户端发起。客户端和服务端分别需要发送3个数据块,客户端发送的称为C0、C1和C2;服务端发送的称为S0、S1和S2。
C0和S0有相同的结构,C1和S1有相同的结构,C2和S2有相同的结构。
握手过程如下:
- 客户端发送C0和C1
- 服务端收到C0(或C1)后,发送S0和S1
- 客户端收到S1后发送C2
- 服务端收到C1后,发送S2
- 客户端收到S2且服务端收到C2,握手完成
数据格式
C0和S0都只有一个字节,内容为协议版本号。
C0中的是客户端要求的RTMP版本号,S0中是服务端选择的版本号,目前版本号为3。0-2是早期版本,已废弃,4-31是未来版本,32-255不允许使用,因为在ASSIC码中他们是可打印字符,其他协议常会用一个可打印字符作为版本号,RTMP协议为了做出区分,不使用可打印字符作为版本号。服务端无法识别客户端版本号时,响应3,客户端要么降到版本3,要么放弃握手。
C1和S1都是1536字节,格式如下:
time
字段用来协调消息的时间起点,因为rtmp的每个消息都是带有时间戳的。rtmp的主要目的是传输音视频数据,而音视频都是时间相关的信息。
zero
字段必须是全零。random bytes
是随机数,可以是任意字节内容。
C2和S2也是1536字节,格式如下:
C2的time
来自于S1的time
字段,S2的time
来自于C1的time
字段。
C2的time2
来自于C1的time
字段,S2的time2
来自于S1的time
字段。
C2的random echo
来自于S1的random bytes
字段,S2的random echo
来自于C1的random bytes
字段。
一个完整的握手流程如下:
- Unintialized(未初始化):在此阶段发送协议版本。
- Version Sent(版本已发送):发送C0和S0后分别进入此状态,客户端等待S1,服务端等待C1。
- Ack Sent(确认已发送):发送C2和S2后进入此状态。
- Handshake Done(握手结束):收到C2和S2进入此状态。
复杂握手
上面是rtmp协议中描述的握手过程,被称为简单握手。现在还有一种称为复杂握手的握手方式,没有公开的官方说明,只有网络上流传着它的传说。
如果要实现一个可用的rtmp服务就需要实现复杂握手,因为有些客户端已经采用了复杂握手,并且拒绝简单握手,这其中就有ffplay。
复杂握手和简单握手的区别在于复杂握手的random bytes
不只是单纯的随机字节,而是带有校验信息的。C1和S1结构对比如下图所示。
复杂握手的C1和S1有scheme 0
和scheme 1
两种结构,它们的区别仅仅是key
和digest
的摆放顺序不同。不管是哪种结构,key
和digest
的结构都是相同的。另外以前的zero
字段现在变成了version
,注意和C0、S0的version区分开。在简单握手中,zero
字段是全零,而在复杂握手中,zero
不是零,我们就是以此来区分要进行简单握手还是复杂握手的。
key
和digest
中offset
字段并不是直接编码的偏移量,计算时需要将各个字节相加来计算。
- key offset:
(offset[0] + offset[1] + offset[2] + offset[3]) % 632
- digest offset:
(offset[0] + offset[1] + offset[2] + offset[3]) % 728
对于key
来说,key
和offset
要占去132字节,因此偏移量最大是764-132=632字节;而对于digest
,digest
和offset
要占36字节,最大偏移量是764-36=728字节。
服务端需要对C1进行校验,校验的方式是先找到C1中32字节的digest
,然后去掉它,对剩余的部分做sha256哈希,最后将哈希结果与digest
进行比较。
这里我们并不知道如何区分scheme 0
和scheme 1
,网上几乎都是说先选一种scheme结构去做校验,如果失败就换另一种scheme去校验,如果成功,说明就是这种scheme。虽然能工作,但是怎么看都不是靠谱的样子,现在的version
字段不要求为0,4个字节肯定是会编码一些信息的,将scheme编码到version
字段中的确是可行的方案,不过由于没找到关于version
字段含义的说明,也只能作为一种猜测,还需要进一步验证。关于C1中128字节的key
也没有找到相关的用途说明。
对于S1,服务端需要以相同的方式生成digest供客户端校验。S2的生成要复杂一些,首先要将C1的digest
哈希得到一个key
,然后用这个key
哈希S2的前1504字节得到sign
,最后将sign
放到S2的最后组成完整的S2。
以上就是复杂握手的过程,关于哈希用到的key和具体代码实现可用参考handshake.go
。
chunk
rtmp协议将消息分块后进行传输,分块的目的有两个:
- 避免大而不重要的消息阻塞小而重要的消息。
- 减少重复发送相同的消息头部。
chunk是rtmp的基本单位,每个chunk必须完整发送,也就是说发送完一个chunk之前,不能发送另一个chunk。
chunk有4个部分组成,分为Chunk Header
和Chunk Data
,如下图所示:
基本头部
基本头部中包含两个信息:消息头部的格式(fmt
)和chunk stream id(csid)。fmt
指示了消息头部的格式,消息头部一共4种类型,需要两个比特来编码;csid标识了该chunk属于哪一路chunk流,接收端需要根据它来组装消息。
本着能省则省的原则,基本头部的长度有1字节,2字节和3字节三种,根据chunk stream id的大小而定。
第一种情况是csid在2到63之间,用一个字节编码。
第二种情况是csid在64到319之间,使用2字节编码。
第三种情况是csid在320到65599之间,使用3字节编码。
注意,此情况下csid的计算方式是第三个字节 × 256 + 第二个字节 + 64
,换句话说,csid是以小端序编码的。
csid的范围是2到65599,0和1保留,0表示基本头使用2字节编码,1表示基本头使用3字节编码。2也是一个特殊的csid,专用于协议控制消息和用户控制消息,普通消息的csid都是从3开始。
消息头部
消息头部中记录了消息的相关信息,包括消息的时间戳,长度,类型和所属的消息流id。消息头部有4种类型,由基本头部中的fmt
指定。
Type 0
0类型消息头部共11字节,包含完整的头部信息。
根据能省则省的原则,消息头部中的timestamp
只有3字节,如果时间戳超过了0xFFFFFF
,需要将它设置为0xFFFFFF
,然后将真正的时间戳写入Chunk Header
的Externed Timestamp
中。
message length
也只有3个字节,所以消息的最大长度不能超过0xFFFFFF
。
message type id
(mtid)表示消息的类型,不同类型的消息携带的负载也不同,这个后面再说。
message stream id
(msid)表示消息所属的消息流,是这些字段中唯一以小端序编码的字段。0是一个特殊的msid,专用于协议控制消息和用户控制消息。
Type 1
1类型的消息头部共7字节,相比于类型0,缺少了message stream id
。
如果消息属于同一个消息流,那么后面的消息就不用重复发送消息流id了。注意这里的头三个字节不是时间戳了,而是时间戳增量。有些rtmp实现始终将头三个字节解释为时间戳其实是不对的,之所以也能正常工作是因为大部分时候消息时间戳都是从零开始的。如果时间戳增量超过了0xFFFFFF
,也需要编码到Externed Timestamp
中。
Type 2
2类型的消息头只剩3个字节,用来设置时间戳增量。如果超过了0xFFFFFF
,也需要编码到Externed Timestamp
中。
Type 3
3类型消息头部是0字节,这下全都省了。对于固定长度,时间戳成等差数列的消息,第一个分块发送一个0类型消息头,第二个分块发送一个2类型的消息头,之后的消息就可以发送3类型的消息头了,比如音频数据。此外,如果一个大消息被分成多个chunk发送,除了第一个chunk,后面的chunk也可以发送3类型的消息头,比如视频数据。
在读取消息时,对时间戳的处理要格外小心。因为对于音视频消息,时间戳时非常重要的,会影响到播放,如果时间戳错误可能会导致音画不同步。在笔者的实现中,就曾犯过这样的错误。特别是对时间戳增量的处理,如果处理的不对,音画不同步的现象会随着播放的进行逐渐累积。
扩展时间戳
扩展时间戳是一个可选项,只有当消息头部中的时间戳(时间戳增量)大于0xFFFFFF
时,才存在扩展时间戳。
分块负载
chunk的负载长度(chunk size)也不是固定的,但是不能小于128字节。在Chunk Header
中并没有指定负载长度,它是客户端/服务端的一个状态,默认是128字节,可以通过协议控制消息来修改,并且读和写的chunk size可以单独设置。
消息
rtmp有很多类型的消息,不同类型的消息有不同的格式和作用。
协议控制消息
协议控制消息的message stream id必须是0,chunk stream id是2,主要用于设置chunk stream的相关状态。协议控制消息的时间戳都是0,必须立即生效。
Set Chunk Size
mtid=1,用来设置分块的负载大小。确切的说是对方的读chunk size,自己的写chunk size,因为读写可以设置不同的chunk size。该消息负载4字节,有效位只有31比特,也就是说chunk的最大负载是0x7FFFFFFF
字节。
Abort Message
mtid=2,大小4字节,内容是csid,用来告诉对方放弃读取所指定chunk stream中的消息。比如某个消息发送了一半不想发送了,就可以使用这个消息来取消。
Acknowledgement
mtid=3,rtmp也提供了窗口机制,当接收端接收到窗口大小的字节数后,需要发送一个确认消息。注意确认消息中的内容是截至目前为止已接收到的字节数。
Window Acknowledgement Size
mtid=5,用来设置窗口大小。
Set Peer Bandwidth
mtid=6,除了设置窗口大小,还会设置带宽模式,共3种:
- 0:严格,将窗口大小设置为该消息指定的大小。
- 1:宽松,可以使用该窗口大小,如果之前的窗口更小,也可以继续使用之前。
- 2:动态,如果之前设置了严格模式,把该消息当作严格模式,否则忽略该消息。
用户控制消息
用户控制消息的message stream id也必须是0,chunk stream id是2,主要用于设置message stream的相关状态。
用户控制消息的消息类型为4,内容包括Event Type和Event Data,共7种类型。
Stream Begin
一般在连接或创建流之后由服务端发给客户端。
命令消息
消息类型17和20都表示命令消息,区别是编码格式不同,17是采用AMF3编码,而20是采用AMF0编码。命令消息主要是控制流媒体的相关状态。
AMF格式与解码参见【Go】FLV文件解析(二)。
命令消息分成两大类:NetConnection Command和NetStream Command。
无论是推流还是拉流,客户端都会先发送connect
命令。接下来对于推流端会发送publish
命令,可能还有FCPublish
命令,取决于客户端;而对于拉流端,会发送play
命令。
所有命令的前两项都是CommandName
和TransactionID
,之后的结构因命令而异。对于这些命令的具体结构,我的建议是把他们保存到文件里,用二进制查看器亲自看一看,vscode就不错。
connect
connect
命令消息结构如下。
其中Command Name为connect
,Transaction ID为1,User Argument是可选项。
服务端收到connect
命令后需要发送一个响应,响应也是command消息,结构是一样的,其中Command Name为_result
或_error
,Transaction ID固定为1。
publish
publish
命令用来发布流,会携带流名称和流类型两个信息,结构如下。
Publishing Type有以下三种:
- live:不将数据写入文件,直播使用此类型。
- record:将数据写入文件,如果文件已存在,覆盖原文件。
- append:将数据追加到文件,如果文件不存在则创建。
play
play
命令用来播放流,结构如下。
对于直播来说,重要的是Stream Name,点播会用到Start。
音视频消息
传输音视频数据是我们使用rtmp协议的主要目的,音频消息的消息类型是8,视频消息的消息类型是9。如果你熟悉FLV文件的结构,会发现这些数字很眼熟,都是Adobe出品的,所以定义是一样的。此外还有类型为18的视频元数据Tag,在rtmp中对应的是消息类型为18和15的消息,18是AMF0编码,15是AMF3编码。
对于音视频消息,其负载是FLV文件的Tag Data部分的内容,如果是做直播应用,直接缓存它,然后发送给播放端就可以了,如果是做点播应用,需要提取出Tag Data发送给客户端。
FLV文件中有三个特殊的Tag:Script Tag,Video Tag 0和Audio Tag 0。播放时,必须先将这三个Tag按顺序发送给播放端。
关于FLV文件的格式与解析参见【Go】FLV文件解析(一)。
交互流程
无论是推流还是拉流,都是从connect
和createStream
命令开始的。注意,这里的connect
命令并不是连接到服务器,而是连接到应用。这里需要说明一下rtmp的地址结构,如下图。
rtmp的地址由4个部分组成,connect
命令会携带application
信息,至于streamName
则由publish
和play
命令携带,可以简单的字符串,也可以是带参数的路径,如stream_name?secret=xxx&key=xxx
,取决于服务端的实现。
createStream
命令创建的是message stream,之后的音视频消息都会在这条message stream上传输。与它对应的另一个命令是deleteStream
,用来删除一条message stream。
上面是connect
和createStream
的流程示意,上面的流程并不是强制的,有时候你会发现精简一下,去掉几个过程也能正常工作,但connect
和createStream
的response是必不可少的。
推流
推流使用publish
命令,有些客户端还会发动FCPublish
命令,一般我们会忽略掉后者。推流的大致流程如下。
这个过程也不是十分严格的,除了publish result
是必须的,实际的过程可能有出入,收到音视频数据后如何缓存它们已经超出了协议本身的内容,可以有不同的实现方案。
作为直播的服务端,从抽象的角度来说,流缓存器应该是一个无限长的队列,发布者向队尾写入数据。队列上有一些入口,播放端从入口开始读取数据,但是不删除。注意,入口不一定是队头,这些入口对应的应该是关键帧所在位置。当播放端读到队尾时,需要等待发布者写入数据。
然而实际中我们不可能实现一个无限长的队列,不过我们可以使用环形队列来替代。想象在三维空间中的一个螺旋上升的弹簧,在二维空间就是一个圆。还要注意读写的时候不能加锁,因为不能让读阻塞写,也就是拉流端不能影响推流端。当拉流端读的太慢时,启动丢帧机制。
播放
播放使用play
命令。播放流程如下所示。
以上流程也不是严格的,比如直播就可以不用发送StreamIsRecorded
消息,如果play
命令没有带reset
标志,服务端也不需要发送reset
响应。注意在发送音视频消息之前要先发送Metadata
,第一个视频消息必须时video tag 0,其中包含了解码视频需要的SPS和PPS,第二个视频帧要是一个关键帧,否则解码会失败。
示例程序
rtmp协议本身的内容不多,实现起来也不难,我希望向使用http服务那样使用rtmp服务,下面是我实现的一个直播示例。前两个HandleCommand
分别处理FCUpublish
和play
命令,HandleData
用来处理音视频数据。
本文只介绍了使用rtmp实现推拉流所涉及的内容,完整的rtmp协议可以阅读协议原文。
package main
import (
"fmt"
"log"
"github.com/chenyj/rtmp"
"github.com/chenyj/rtmp/encoding/av"
)
func main() {
// 流缓存器
streams := map[string]rtmp.Streamer{}
// 处理unpublished命令
rtmp.HandleCommand(rtmp.CMD_FCUNPUBLISH, func(w rtmp.MessageWriter, r *rtmp.Request) error {
s, ok := streams[r.StreamPath]
if !ok {
return nil
}
s.Write(nil)
return nil
})
// 处理play命令
rtmp.HandleCommand(rtmp.CMD_PLAY, func(w rtmp.MessageWriter, r *rtmp.Request) error {
s, ok := streams[r.StreamPath]
if !ok {
return rtmp.ResponsePlay(w, false, "stream not found")
}
err := rtmp.ResponsePlay(w, true, "")
if err != nil {
return err
}
go func(it rtmp.Iterator) {
for {
p, err := it.Next()
if err != nil {
break
}
err = w.WriteMessage(rtmp.NewMessage(p))
if err != nil {
break
}
}
fmt.Println("播放结束")
}(s.Iterator())
return nil
})
// 处理音视频数据
rtmp.HandleData(func(app, path string, p *av.Packet) error {
s, ok := streams[path]
if !ok {
s = rtmp.NewStream(3000)
streams[path] = s
}
s.Write(p)
return nil
})
// 使用默认端口启动rtmp服务
err := rtmp.ListenAndServe("", nil)
log.Fatal(err)
}