如何实现RTMP协议

认识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协议的目的是流媒体传输,为了验证效果,需要用到ffmpegffplay,这两个程序都可以在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都只有一个字节,内容为协议版本号。

在这里插入图片描述

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 0scheme 1两种结构,它们的区别仅仅是keydigest的摆放顺序不同。不管是哪种结构,keydigest的结构都是相同的。另外以前的zero字段现在变成了version,注意和C0、S0的version区分开。在简单握手中,zero字段是全零,而在复杂握手中,zero不是零,我们就是以此来区分要进行简单握手还是复杂握手的。

keydigestoffset字段并不是直接编码的偏移量,计算时需要将各个字节相加来计算。

  • key offset:(offset[0] + offset[1] + offset[2] + offset[3]) % 632
  • digest offset:(offset[0] + offset[1] + offset[2] + offset[3]) % 728

对于key来说,keyoffset要占去132字节,因此偏移量最大是764-132=632字节;而对于digestdigestoffset要占36字节,最大偏移量是764-36=728字节。

服务端需要对C1进行校验,校验的方式是先找到C1中32字节的digest,然后去掉它,对剩余的部分做sha256哈希,最后将哈希结果与digest进行比较。

在这里插入图片描述

这里我们并不知道如何区分scheme 0scheme 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协议将消息分块后进行传输,分块的目的有两个:

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

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

chunk有4个部分组成,分为Chunk HeaderChunk 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 HeaderExterned 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命令。

所有命令的前两项都是CommandNameTransactionID,之后的结构因命令而异。对于这些命令的具体结构,我的建议是把他们保存到文件里,用二进制查看器亲自看一看,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文件解析(一)

交互流程

无论是推流还是拉流,都是从connectcreateStream命令开始的。注意,这里的connect命令并不是连接到服务器,而是连接到应用。这里需要说明一下rtmp的地址结构,如下图。

在这里插入图片描述

rtmp的地址由4个部分组成,connect命令会携带application信息,至于streamName则由publishplay命令携带,可以简单的字符串,也可以是带参数的路径,如stream_name?secret=xxx&key=xxx,取决于服务端的实现。

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

在这里插入图片描述

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

推流

推流使用publish命令,有些客户端还会发动FCPublish命令,一般我们会忽略掉后者。推流的大致流程如下。

在这里插入图片描述

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

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

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

在这里插入图片描述

播放

播放使用play命令。播放流程如下所示。

在这里插入图片描述

以上流程也不是严格的,比如直播就可以不用发送StreamIsRecorded消息,如果play命令没有带reset标志,服务端也不需要发送reset响应。注意在发送音视频消息之前要先发送Metadata,第一个视频消息必须时video tag 0,其中包含了解码视频需要的SPS和PPS,第二个视频帧要是一个关键帧,否则解码会失败。

示例程序

rtmp协议本身的内容不多,实现起来也不难,我希望向使用http服务那样使用rtmp服务,下面是我实现的一个直播示例。前两个HandleCommand分别处理FCUpublishplay命令,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)
}

猜你喜欢

转载自blog.csdn.net/puss0/article/details/128841327