天天看点

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

认识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相同的消息构成一条消息流,这是第二条逻辑流。

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

message stream和chunk stream之间并不存在一一对应的关系。一条message stream可以通过多条chunk stream传输,不同的message stream也可以复用同一条chunk stream。消息有很多种类型,消息类型和消息流也不是一一对应的关系,一条消息流可以传输不同类型的消息,但一般每一种消息都会独占一条chunk stream。

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

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有相同的结构。

握手过程如下:

  1. 客户端发送C0和C1
  2. 服务端收到C0(或C1)后,发送S0和S1
  3. 客户端收到S1后发送C2
  4. 服务端收到C1后,发送S2
  5. 客户端收到S2且服务端收到C2,握手完成

数据格式

C0和S0都只有一个字节,内容为协议版本号。

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

C0中的是客户端要求的RTMP版本号,S0中是服务端选择的版本号,目前版本号为3。0-2是早期版本,已废弃,4-31是未来版本,32-255不允许使用,因为在ASSIC码中他们是可打印字符,其他协议常会用一个可打印字符作为版本号,RTMP协议为了做出区分,不使用可打印字符作为版本号。服务端无法识别客户端版本号时,响应3,客户端要么降到版本3,要么放弃握手。

C1和S1都是1536字节,格式如下:

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

time

字段用来协调消息的时间起点,因为rtmp的每个消息都是带有时间戳的。rtmp的主要目的是传输音视频数据,而音视频都是时间相关的信息。

zero

字段必须是全零。

random bytes

是随机数,可以是任意字节内容。

C2和S2也是1536字节,格式如下:

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

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

字段。

一个完整的握手流程如下:

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序
  • Unintialized(未初始化):在此阶段发送协议版本。
  • Version Sent(版本已发送):发送C0和S0后分别进入此状态,客户端等待S1,服务端等待C1。
  • Ack Sent(确认已发送):发送C2和S2后进入此状态。
  • Handshake Done(握手结束):收到C2和S2进入此状态。

复杂握手

上面是rtmp协议中描述的握手过程,被称为简单握手。现在还有一种称为复杂握手的握手方式,没有公开的官方说明,只有网络上流传着它的传说。

如果要实现一个可用的rtmp服务就需要实现复杂握手,因为有些客户端已经采用了复杂握手,并且拒绝简单握手,这其中就有ffplay。

复杂握手和简单握手的区别在于复杂握手的

random bytes

不只是单纯的随机字节,而是带有校验信息的。C1和S1结构对比如下图所示。

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

复杂握手的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

进行比较。

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

这里我们并不知道如何区分

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。

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

以上就是复杂握手的过程,关于哈希用到的key和具体代码实现可用参考

handshake.go

chunk

rtmp协议将消息分块后进行传输,分块的目的有两个:

  1. 避免大而不重要的消息阻塞小而重要的消息。
  2. 减少重复发送相同的消息头部。

chunk是rtmp的基本单位,每个chunk必须完整发送,也就是说发送完一个chunk之前,不能发送另一个chunk。

chunk有4个部分组成,分为

Chunk Header

Chunk Data

,如下图所示:

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

基本头部

基本头部中包含两个信息:消息头部的格式(

fmt

)和chunk stream id(csid)。

fmt

指示了消息头部的格式,消息头部一共4种类型,需要两个比特来编码;csid标识了该chunk属于哪一路chunk流,接收端需要根据它来组装消息。

本着能省则省的原则,基本头部的长度有1字节,2字节和3字节三种,根据chunk stream id的大小而定。

第一种情况是csid在2到63之间,用一个字节编码。

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

第二种情况是csid在64到319之间,使用2字节编码。

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

第三种情况是csid在320到65599之间,使用3字节编码。

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

注意,此情况下csid的计算方式是

第三个字节 × 256 + 第二个字节 + 64

,换句话说,csid是以小端序编码的。

csid的范围是2到65599,0和1保留,0表示基本头使用2字节编码,1表示基本头使用3字节编码。2也是一个特殊的csid,专用于协议控制消息和用户控制消息,普通消息的csid都是从3开始。

消息头部

消息头部中记录了消息的相关信息,包括消息的时间戳,长度,类型和所属的消息流id。消息头部有4种类型,由基本头部中的

fmt

指定。

Type 0

0类型消息头部共11字节,包含完整的头部信息。

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

根据能省则省的原则,消息头部中的

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

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

如果消息属于同一个消息流,那么后面的消息就不用重复发送消息流id了。注意这里的头三个字节不是时间戳了,而是时间戳增量。有些rtmp实现始终将头三个字节解释为时间戳其实是不对的,之所以也能正常工作是因为大部分时候消息时间戳都是从零开始的。如果时间戳增量超过了

0xFFFFFF

,也需要编码到

Externed Timestamp

中。

Type 2

2类型的消息头只剩3个字节,用来设置时间戳增量。如果超过了

0xFFFFFF

,也需要编码到

Externed Timestamp

中。

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

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

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

mtid=1,用来设置分块的负载大小。确切的说是对方的读chunk size,自己的写chunk size,因为读写可以设置不同的chunk size。该消息负载4字节,有效位只有31比特,也就是说chunk的最大负载是

0x7FFFFFFF

字节。

Abort Message

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

mtid=2,大小4字节,内容是csid,用来告诉对方放弃读取所指定chunk stream中的消息。比如某个消息发送了一半不想发送了,就可以使用这个消息来取消。

Acknowledgement

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

mtid=3,rtmp也提供了窗口机制,当接收端接收到窗口大小的字节数后,需要发送一个确认消息。注意确认消息中的内容是截至目前为止已接收到的字节数。

Window Acknowledgement Size

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

mtid=5,用来设置窗口大小。

Set Peer Bandwidth

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

mtid=6,除了设置窗口大小,还会设置带宽模式,共3种:

  • 0:严格,将窗口大小设置为该消息指定的大小。
  • 1:宽松,可以使用该窗口大小,如果之前的窗口更小,也可以继续使用之前。
  • 2:动态,如果之前设置了严格模式,把该消息当作严格模式,否则忽略该消息。

用户控制消息

用户控制消息的message stream id也必须是0,chunk stream id是2,主要用于设置message stream的相关状态。

用户控制消息的消息类型为4,内容包括Event Type和Event Data,共7种类型。

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

Stream Begin

一般在连接或创建流之后由服务端发给客户端。

命令消息

消息类型17和20都表示命令消息,区别是编码格式不同,17是采用AMF3编码,而20是采用AMF0编码。命令消息主要是控制流媒体的相关状态。

AMF格式与解码参见【Go】FLV文件解析(二)。

命令消息分成两大类:NetConnection Command和NetStream Command。

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

无论是推流还是拉流,客户端都会先发送

connect

命令。接下来对于推流端会发送

publish

命令,可能还有

FCPublish

命令,取决于客户端;而对于拉流端,会发送

play

命令。

所有命令的前两项都是

CommandName

TransactionID

,之后的结构因命令而异。对于这些命令的具体结构,我的建议是把他们保存到文件里,用二进制查看器亲自看一看,vscode就不错。

connect

connect

命令消息结构如下。

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

其中Command Name为

connect

,Transaction ID为1,User Argument是可选项。

服务端收到

connect

命令后需要发送一个响应,响应也是command消息,结构是一样的,其中Command Name为

_result

_error

,Transaction ID固定为1。

publish

publish

命令用来发布流,会携带流名称和流类型两个信息,结构如下。

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

Publishing Type有以下三种:

  • live:不将数据写入文件,直播使用此类型。
  • record:将数据写入文件,如果文件已存在,覆盖原文件。
  • append:将数据追加到文件,如果文件不存在则创建。

play

play

命令用来播放流,结构如下。

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

对于直播来说,重要的是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协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

rtmp的地址由4个部分组成,

connect

命令会携带

application

信息,至于

streamName

则由

publish

play

命令携带,可以简单的字符串,也可以是带参数的路径,如

stream_name?secret=xxx&key=xxx

,取决于服务端的实现。

createStream

命令创建的是message stream,之后的音视频消息都会在这条message stream上传输。与它对应的另一个命令是

deleteStream

,用来删除一条message stream。

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

上面是

connect

createStream

的流程示意,上面的流程并不是强制的,有时候你会发现精简一下,去掉几个过程也能正常工作,但

connect

createStream

的response是必不可少的。

推流

推流使用

publish

命令,有些客户端还会发动

FCPublish

命令,一般我们会忽略掉后者。推流的大致流程如下。

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

这个过程也不是十分严格的,除了

publish result

是必须的,实际的过程可能有出入,收到音视频数据后如何缓存它们已经超出了协议本身的内容,可以有不同的实现方案。

作为直播的服务端,从抽象的角度来说,流缓存器应该是一个无限长的队列,发布者向队尾写入数据。队列上有一些入口,播放端从入口开始读取数据,但是不删除。注意,入口不一定是队头,这些入口对应的应该是关键帧所在位置。当播放端读到队尾时,需要等待发布者写入数据。

然而实际中我们不可能实现一个无限长的队列,不过我们可以使用环形队列来替代。想象在三维空间中的一个螺旋上升的弹簧,在二维空间就是一个圆。还要注意读写的时候不能加锁,因为不能让读阻塞写,也就是拉流端不能影响推流端。当拉流端读的太慢时,启动丢帧机制。

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

播放

播放使用

play

命令。播放流程如下所示。

如何实现RTMP协议认识rtmp工欲善其事 必先利其器握手chunk消息交互流程示例程序

以上流程也不是严格的,比如直播就可以不用发送

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)
}