🎬 第四章 RTMP流媒体协议
📄 一句话概括

连接只管「通不通」,流才管「搬什么」。 createStream 发一条带事务号的请求,服务器回 _result 并在末尾带 streamId,通常是 1。

📝 本节和前面几节的关系

4.2.2 建好了连接,这一节在连接上开出一条流。 4.2.4 就是在这条流上发 play 或 publish。

一、连接与流是两个层次

NetConnection 与 NetStream 是两个对象,职责分得很清: ① 连接:负责握手、鉴权、app 路由。 它的状态只有「连上」与「断开」。 ② 流:负责一路具体的音视频。 一条连接可以开多条流,每条流有自己的 streamId。

为什么要分两层? 因为一个主播可能同时推「摄像头 + 桌面 + 麦克风」三路,它们共用一条连接(省掉重复握手与鉴权),但各自是独立的流。 这也让「一条连接里同时拉两路流」成为可能。

实测一条完整的推流会话,命令顺序是: connect → releaseStream → FCPublish → createStream → publish。 五条里真正必须的只有 connect 与 createStream → publish。

二、createStream 的请求与响应

请求极简:createStream + 事务号 + 一个空值。 实测 25 字节。

响应是 _result + 同一个事务号 + 空值 + streamId。 实测 29 字节。 那个 streamId 就是「以后所有消息里填的流号」。

编号规则: ① streamId 0 留给「连接本身」:所有控制消息都填 0。 ② 第一条流从 1 开始,之后每条加一。 ③ 流关掉之后编号不复用,避免旧包被误认成新流。

所以抓包时看到「流 ID 0」的消息,一定是控制类(设置块大小、确认、带宽);看到「流 ID 1」才是真正的音视频。 这个判据比记消息类型号快得多。

三、推流前那两条「遗留命令」

releaseStream 与 FCPublish 不在标准里,是 Flash 时代的产物。 但几乎所有服务器都实现了,不发就可能出问题。

它们各自的作用: ① releaseStream:通知服务器「把这个流名上的旧会话踢掉」。 同名流重推时,不发它就报「流已被占用」。 ② FCPublish:推流预检,服务器据此决定「允不允许你推」。 有的平台把鉴权放在这一步。

这两条最容易造成的误解是:报错说「占用」,人就以为是别人在推,其实是自己上一次没释放干净。 长时间推流断开重连必踩,所以它们虽然是遗留,还是得发。

四、streamId 用错会怎样

streamId 是所有音视频消息必须带的字段,填错不会报错,只会「没反应」。

三种典型表现: ① 填 0:消息被当成控制类,服务器忽略,观众看不到画面。 ② 填一个没创建过的号:服务器找不到对应流,直接丢弃。 抓包能看到包发出去了,但服务端统计里没有。 ③ 两条流混用同一个号:数据会串到一起,表现是画面里突然插进另一路的内容。

这三个错误都不报错,所以排查只能靠抓包。 判断方法:看服务端有没有「收到流」的日志,再看消息里的流号是不是 1。

五、动手 Demo:createStream 与 _result

编一条 createStream 请求与它的响应,把 streamId 解出来。 顺手看清「流 0 是连接、流 1 是第一路」的分界。

import struct

def amf_str(s):
    b = s.encode("utf-8")
    return b"\x02" + struct.pack("!H", len(b)) + b

def amf_num(v):
    return b"\x00" + struct.pack("!d", float(v))

# ① 请求:createStream + 事务号 4 + 空值
req = amf_str("createStream") + amf_num(4) + b"\x05"
print("请求 %d 字节" % len(req))

# ② 响应:_result + 事务号 4 + 空值 + streamId 1
res = amf_str("_result") + amf_num(4) + b"\x05" + amf_num(1)
print("响应 %d 字节" % len(res))

# ③ 解出 streamId:跳过 _result 与两个 9 字节的数字
off = 1 + 2 + len("_result")        # 类型标记 + 长度 + 内容
off += 9                            # 事务号
off += 1                            # 空值
sid = struct.unpack("!d", res[off + 1:off + 9])[0]
print("streamId = %.0f" % sid)

# ④ 流号与用途的约定
print("流 ID 0 → 协议控制/用户控制消息(不承载音视频)")
print("流 ID 1 → 第一路音视频(推流或拉流)")

实跑输出:

--------------------------------------------------------------------
建立流——从连接里分出一条流
--------------------------------------------------------------------
**① 一条连接为什么要有多条流**

  **RTMP 的连接(NetConnection)只负责「通不通」;**
  **真正搬数据的是流(NetStream)。**
  **一条连接可以开多条流:一路摄像头、一路桌面、一路麦克风。**

**② 真编 createStream 与它的响应**

  请求 25 字节,事务号 4

  响应 29 字节

  解析:命令 _result,事务号 4,参数 None,streamId = 1

  **streamId 从 1 开始编号,0 留给「连接本身」。**
  **所以后面所有消息里的流 ID 字段,0 表示控制、1 以上表示具体流。**

**③ 推流前还要多做两步**

  步骤         命令              作用
  -------------------------------------------------------------
  释放旧流     releaseStream     同名流正在推时先踢掉
  发布预检     FCPublish         通知服务器「我要推这个流」
  正式发布     publish           带流名与推流类型

  **releaseStream 与 FCPublish 是 Flash 时代的遗留,标准里没有。**
  **但几乎所有服务器都实现了它们。**
  **不发的后果是「同一个流名重推时会失败」,报的是「流已被占用」。**

  **实测一条完整推流会话的命令顺序是:**
  **connect → releaseStream → FCPublish → createStream → publish。**
  **五条里有三条是为了兼容老客户端,真正必须的只有 connect 与 publish。**

请求 25 字节、响应 29 字节——差的那 4 字节就是 streamId 那个双精度的一部分。 解析时关键是跳过两个固定长度的段:事务号 9 字节、空值 1 字节,然后才是 streamId。 流号 0 与 1 的分界是后面所有分析的基础:看到 0 就当控制消息处理,看到 1 才去解音视频。

30 秒实验

  1. 把事务号改成 9,看响应里的事务号该不该跟着变——必须变,否则客户端配不上对,会把响应丢掉。
  2. 把响应里的 streamId 改成 2,再看「流 ID 0/1」的判据——判据要改成「0 是控制,非 0 是流」,不能写死 1。
  3. 连发两次 createStream,看两个 streamId 是不是 1 和 2——这就是「一条连接多条流」的编号方式。

常见误区

  1. 「connect 成功就能推流」——还要 createStream 拿到流号。
  2. 「streamId 可以从 0 开始」——0 是连接本身,流从 1 开始。
  3. 「releaseStream 是废弃命令,可以不发」——同名流重推时会报「占用」,实际还得发。
自测三问(点开对答案) 1. 连接与流的分工是什么? —— **连接负责通不通与鉴权,流负责搬一路音视频。**一条连接可开多条流。 2. streamId 0 代表什么? —— **连接本身。**控制类消息的流 ID 字段都填 0。 3. 为什么重推同名流会报占用? —— **上一次的会话没释放。**发一条 releaseStream 就能踢掉。

相关笔记