RTMP 里的那根“连接线”:读一份 NetConnection 源码

0 阅读5分钟

      如果把直播服务器比作一座电视台,那 NetConnection 就是观众或主播刚拨进来的那根电话线。

      电话一通,后面才有“我要看频道”“我要开播”“ping 一下你还活着吗”。

      这份 net-connection.go,讲的就是:一根 TCP 连接,如何变成一条 RTMP 会话。


一、开场:NetConnection 是谁?

        在代码里,NetConnection 不是“网络连接”这么朴素。

type NetConnection struct {
	task.Job
	*util.BufReader
	net.Conn
	...
}

它同时是三样东西:

  1. 一个任务(Job):能被调度、能跑定时任务;
  2. 一个带缓冲的读器(BufReader):解决“TCP 是流,RTMP 是块”的矛盾;
  3. 一个 net.Conn:真正的 socket。

重点来了:

TCP 给你字节流,RTMP 给你“消息 + 分块(chunk)”。

NetConnection 的核心使命,就是在这两者之间当翻译。

二、初始化:给连接“备齐家当”

func (nc *NetConnection) Init(conn net.Conn) {
	nc.Conn = conn
	nc.BufReader = util.NewBufReader(conn)
	nc.BufReader.SetTimeout(time.Second * 30)
	nc.ReadChunkSize = RTMP_DEFAULT_CHUNK_SIZE
	nc.WriteChunkSize = RTMP_DEFAULT_CHUNK_SIZE
	nc.incommingChunks = make(map[uint32]*Chunk)
	nc.mediaDataPool = gomem.NewScalableMemoryAllocator(...)
}

这里有几个“伏笔”:

  • incommingChunks:
    RTMP 一个流会被切成很多 chunk,同一个 CSID(Chunk Stream ID)要拼起来。
  • mediaDataPool:
    音视频数据很大,不能随便 make([]byte)。
  • ReadChunkSize / WriteChunkSize:
    后面客户端可能会改,这是“协商出来的规矩”。

难点预警:

RTMP 不是“一个消息 = 一次 read”。

一个消息可能是:

  • 1 个 chunk
  • 100 个 chunk
  • 一个 chunk 里塞半条消息

三、Chunk:RTMP 的灵魂(也是噩梦)

1. 先读 1 个字节

head, err := nc.ReadByte()
ChunkStreamID := uint32(head & 0x3f)
ChunkType := head >> 6

        这一行信息密度极高:

| 2 bit  | 6 bit              |
| fmt    | chunk stream id   |
  • fmt = 0/1/2/3:四种 header 压缩方式
  • csid:逻辑通道(不是 TCP 端口)

2. CSID 还能“续命”

case 0:
	chunkStreamID = 64 + uint32(u8)
case 1:
	chunkStreamID = 64 + uint32(u16_0) + (uint32(u16_1) << 8)

这就是 RTMP 的“变长 CSID”:

  • 0 → 用 1 个字节扩展
  • 1 → 用 2 个字节扩展
  • 2 → 保留
  • 3~63 → 直接用

亮点:

用极小的头,支持大量流。这是 RTMP 能在 2003 年跑起来的关键。

四、Chunk Header:四种格式,一种耐心

func (nc *NetConnection) readChunkType(h *ChunkHeader, chunkType byte) (err error)

Chunk Type 速记表

Type含义
0完整头(绝对时间戳)
1有时间 + 长度 + 类型
2只有时间
3啥都没有(继续上一次)

        最阴险的是 type 3:

“我和上一个 chunk 一模一样,只是数据接着来。”

        如果你不懂“状态机”,这里一定会写崩。

        而这份代码的做法是:

chunk, ok := nc.incommingChunks[ChunkStreamID]
if !ok {
	chunk = &Chunk{}
	nc.incommingChunks[ChunkStreamID] = chunk
}

        把“流”变成对象​

        把“分片”变成状态

        这是整个文件最值钱的设计。

五、音视频:不是“收下来”,而是“长出来”

        这是最惊艳的一段:

case RTMP_MSG_AUDIO:
	if writer, ok := nc.Writers[chunk.MessageStreamID]; ok {
		if writer.PubAudio {
			chunk.buf = writer.AudioFrame.NextN(msgLen)
		}
	}

        一般 RTMP 实现:

读 buffer → copy → 解析 → 再 copy → 推给播放器

        这份代码:

直接从“帧对象”里拿内存,让 socket 数据直接长进帧里

        结果:

  • 少一次拷贝
  • GC 压力骤降
  • 音视频帧和 chunk 天然对齐

         mediaDataPool 在这里不是“优化”,是架构本身。

六、消息拼好了,然后呢?

if chunk.bufLen == msgLen {
	switch chunk.MessageTypeID {
	case RTMP_MSG_AUDIO:
		writer.AudioFrame.SetTS32(...)
		err = writer.NextAudio()
	}
}

        重点不是“收到了”,而是:

“这一帧现在可以对外宣布了。”

        RTMP 的难点从来不是协议,而是:

  • 时间戳怎么算
  • 扩展时间戳怎么补
  • 多流并发怎么不串

        这里用:

chunk.ExtendTimestamp += delta

        把“相对时间”悄悄累积成“绝对时间”。

         亮点:

        没有大段 if-else 算时间,而是让 Chunk 自己记住历史。

七、RecvMessage:一个大循环,一场 dispatcher

for err == nil {
	if msg, err = nc.readChunk(); msg != nil ...
}

        像不像:

邮局分拣中心:

包裹不断来,拆包 → 看类型 → 分发

        它处理了什么?

  • 改 chunk size
  • 删半截流(abort)
  • 对端带宽
  • ping / pong
  • AMF0 命令

        其中最像“人话”的,是这段:

case RTMP_USER_PING_REQUEST:
	nc.SendUserControl(RTMP_USER_PING_RESPONSE)

        翻译一下:

“你还活着吗?”

“在。”

八、AMF:用“脚本语言思维”写协议

        RTMP 命令不是 protobuf,也不是 JSON。

        是 AMF:

02 00 07 connect
00 3F F0 00 00 00 00 00 00

        代码里:

cmd := amf.ReadShortString()
cmdMsg := CommandMessage{
	cmd,
	uint64(amf.ReadNumber()),
}

        像什么?

像 JS 解释器:

  • 第一个参数是函数名
  • 第二个是事务 ID
  • 后面是对象 / 字符串 / 数字 / 布尔

        命令全景:

命令含义
connect连上来
createStream开频道
publish开播
play观看
pause / seek播放控制
deleteStream拜拜

        难点:

        AMF 是“弱类型协议 + 强约定语义”。

        你读错一个字段,OBS 就不会推流,但 TCP 一切正常。

九、发送:比“写 socket”难得多

func (nc *NetConnection) SendMessage(t byte, msg RtmpMessage) (err error)

        亮点有三:

1️:写锁不是 mutex,而是原子自旋

for !nc.writing.CompareAndSwap(false, true) {
	runtime.Gosched()
}
defer nc.writing.Store(false)

        为什么?

  • RTMP 消息不能“半个出去”
  • Go 调度器友好
  • 高并发下比锁更轻

AMF0 / AMF3 自动切换

if nc.ObjectEncoding == 0 {
	msg.Encode(&nc.tmpBuf)
} else {
	amf3 := AMF3{AMF: nc.tmpBuf}
}

        一份接口,两套编码。

Chunk 发送是“拼 Buffers”

nc.sendBuffers = append(nc.sendBuffers, nc.chunkHeaderBuf)
...
nc.sendBuffers = append(nc.sendBuffers, buf)

            用 net.Buffers:

  • 少系统调用
  • 零拷贝友好
  • 大视频帧也不怕

        这是“能扛直播流量”的关键。

十、PingTask:让连接“有心跳”

type PingTask struct {
	task.TickTask
	NetConnection *NetConnection
}

func (t *PingTask) GetTickInterval() time.Duration {
	return time.Second * 10
}

        每 10 秒问一次:

“你还活着吗?”

        如果 RTMP 没有 ping:

  • NAT 把你忘了
  • 防火墙把你丢了
  • 对端死了你还在等

        小代码,大稳定性。

十一、这份代码的“魂”是什么?

        如果只记三句话:

        重点:

RTMP = TCP 流 + Chunk 状态机 + AMF 命令

        难点:

时间戳、CSID、分片重组、弱类型命令解析

        亮点:

音视频内存从“帧里长出来”,不是“从 socket 拷出来”

十二、结尾:一根线,一座台

        当第一个 connect 进来:

  • 没人知道后面是主播还是观众
  • 没人知道是 OBS、FFmpeg 还是浏览器
  • 但 NetConnection 不在乎

        它只做一件事:

把混乱的字节,变成有序的流;把陌生的连接,变成可管理的会话

        这就是为什么--

直播能“看起来很简单”,

是因为有人把复杂,藏进了 readChunk。