本篇以《诛仙》青云门人物为引,对照 Monibuca v5 plugin/rtmp/pkg/net-stream.go 的源码设计,把"RTMP 流级会话"讲成一门可运行的修行功法。
摘要
本文以《诛仙》青云门人物为线索,逐层拆解 Monibuca v5 中 plugin/rtmp/pkg/net-stream.go 的设计:NetStream 通过嵌入 *NetConnection 复用连接能力,用 StreamID 标识连接内的子会话;Begin / BeginPlay 负责开流授权,PublishWriter 与 RTMPSubscriber 分别承担推流写入与拉流零拷贝转发,closeStream 则完成资源回收而不关闭 TCP 连接。全文配有可运行示例与单元测试,帮助读者理解 RTMP 流级会话的职责边界。
一、世界观设定:RTMP 是一座青云剑阵
- 道玄真人 → NetConnection:掌管"连接级命令"(connect / createStream),定乾坤、开山门。
- 张小凡 → NetStream:流级会话,拿着 StreamID 这把"烧火棍",在连接里开辟一条专属灵脉。
- 陆雪琪 → Subscriber:拉流者,天琊出鞘,只取池中倒影(RingBuffer 读取)。
- 田不易 → Publisher:推流者,赤焰炼体,把音视频真气写入灵泉(RingBuffer 写入)。
- 苏茹 → RingBuffer:温柔包容,万流归宗,不丢帧、不复写。
- 曾书书 → Chunk:碎碎念,把大帧切成小块传。
net-stream.go 不负责握手(那是 handshake.go),不负责拆包细节(那是 chunk.go),它只做一件事:
把"一条流"的生命周期,安放在一条 TCP 连接里。
二、源码主角:NetStream 结构
go
type NetStream struct {
*NetConnection // 继承连接:能发 chunk、能回 AMF 命令
StreamID uint32 // 这条流在连接里的唯一编号
}
逐层拆解设计意图
- 组合而非继承
- NetStream 嵌入 *NetConnection,复用底层写 chunk、发消息的能力。
- 设计意图:连接是"通道",流是"通道里的子频道",避免重复持有 bufio、chunkSize。
- StreamID 是灵脉编号
- RTMP 里一个 TCP 连接可复用多个流(虽然常见场景只有 1 条)。
- StreamID 决定 chunk header 里的 message stream id。
小凡拜入大竹峰:nc.createStream() 分配 ID → 小凡持棍入场。
三、开流仪式:Begin 与 BeginPlay
- 流开始信号(User Control Message)
go
func (ns *NetStream) Begin() {
ns.SendStreamID(RTMP_USER_STREAM_BEGIN, ns.StreamID)
}
- 发一个 StreamBegin 控制消息。
- 语义:"弟子已入门,可以传功了"。
- 对应 RTMP 规范:Event type = 0x0000。
- 允许播放(拉流侧)
go
func (ns *NetStream) BeginPlay() {
ns.Begin()
ns.SendMessage(RTMP_MSG_AMF0_COMMAND, newPlayResponseMessageData(
ns.StreamID, NetStream_Play_Start, Level_Status,
))
}
逐行看设计:
- Begin():先通知控制面。
- Play_Start:AMF 状态消息,告诉客户端"可以收帧了"。
- 之后 Subscriber 才会把 RingBuffer 里的音视频,经 Mux() 切成 RTMP chunk 发出去。
雪琪拔剑:不是立刻出招,先亮剑鸣------"我要看了"。
四、推流侧:田不易的赤焰炼体
当客户端发 publish 命令:
go
// 伪代码逻辑(对应 index.go + net-stream.go 协作)
pub, _ := plugin.Publish(streamPath)
ns.Writers[ns.StreamID] = &PublishWriter{
Publisher: pub,
ns: ns,
}
PublishWriter 实现:
- 收到 RTMP 视频消息 → Parse 出 H264/AVCC
- 收到音频消息 → 解 AAC 头
- 调用 publisher.WriteVideo / WriteAudio
关键点(源码里容易忽略):
go
// 视频帧要算 delta timestamp
t := packet.Timestamp - lastVideoTime
lastVideoTime = packet.Timestamp
packet.Timestamp = t
设计意图:
- RTMP 传的是"相对时间戳",Monibuca 内部用绝对时间。
- net-stream.go 是"协议时钟"和"内部时钟"的翻译官。
田不易每一式都记"距上一式过了多久",苏茹才能把招式排进灵泉。
五、拉流侧:雪琪的天琊映流
go
func (ns *NetStream) Subscribe(sub *Subscriber) {
sender := &RTMPSubscriber{...}
ns.senders[ns.StreamID] = sender
sub.OnEvent = func(frame AVFrame) {
chunk := frame.Mux(videoCtx) // 转成 RTMP 能发的样子
ns.SendMessage(SEND_VIDEO_MESSAGE, chunk)
}
sub.PlayBlock()
}
设计拆解:
- Subscriber 从 RingBuffer 读帧(不拷内存,零拷贝引用)。
- Mux() 把内部帧转 RTMP 格式(AVCC + tag)。
- SendMessage 交给 NetConnection 切 chunk 发出去。
雪琪不炼功,只"映":池水(RingBuffer)里有啥,天琊就映啥。
六、关闭流:剑收人退
go
case "closeStream":
if s, ok := ns.senders[cmd.StreamId]; ok {
s.Stop()
delete(ns.senders, cmd.StreamId)
}
- 客户端断流 → 回收 Subscriber / Publisher。
- 底层 Task 取消,RingBuffer 引用释放。
- TCP 不断,还能再开新流。
小凡收棍,雪琪还剑,山门不关,只熄一盏灯。
七、可运行示例:本地起一个 RTMP 流,用代码"观想"
下面代码不依赖私有库,用标准 net 模拟"客户端发 publish",重点看 NetStream 职责边界。
- 最小可运行:模拟 NetStream 生命周期
go
package main
import (
"fmt"
"sync"
)
// ---- 极简复刻 net-stream.go 核心 ----
type NetConnection struct {
mu sync.Mutex
streamID uint32
}
func (nc *NetConnection) SendStreamID(event uint16, id uint32) {
fmt.Printf("[NetConnection] UserControl event=%d streamID=%d\n", event, id)
}
func (nc *NetConnection) SendAMFStatus(code string) {
fmt.Printf("[NetConnection] AMF status: %s\n", code)
}
type NetStream struct {
*NetConnection
StreamID uint32
}
func (ns *NetStream) Begin() {
ns.SendStreamID(0x0000, ns.StreamID) // StreamBegin
}
func (ns *NetStream) BeginPlay() {
ns.Begin()
ns.SendAMFStatus("NetStream.Play.Start")
}
func (ns *NetStream) Publish() {
ns.Begin()
ns.SendAMFStatus("NetStream.Publish.Start")
}
func main() {
nc := &NetConnection{streamID: 1}
ns := &NetStream{NetConnection: nc, StreamID: 100}
fmt.Println("== 拉流弟子陆雪琪入场 ==")
ns.BeginPlay()
fmt.Println("== 推流师长田不易入场 ==")
ns.Publish()
}
运行输出:
text
== 拉流弟子陆雪琪入场 ==
[NetConnection] UserControl event=0 streamID=100
[NetConnection] AMF status: NetStream.Play.Start
== 推流师长田不易入场 ==
[NetConnection] UserControl event=0 streamID=100
[NetConnection] AMF status: NetStream.Publish.Start
这就是 net-stream.go 的"骨架观想":不碰 socket,只管流级语义。
八、单元测试:验证 StreamID 与生命周期
go
// netstream_test.go
package rtmp
import "testing"
func TestNetStream_BeginPlay(t *testing.T) {
nc := &NetConnection{}
ns := &NetStream{NetConnection: nc, StreamID: 42}
// 用 channel 拦截发送,验证调用顺序
var events []string
nc.hook = func(s string) { events = append(events, s) }
ns.BeginPlay()
if events[0] != "StreamBegin:42" {
t.Fatalf("want StreamBegin:42, got %v", events)
}
if events[1] != "Play.Start" {
t.Fatalf("want Play.Start, got %v", events)
}
}
实战建议:源码里 SendMessage 是 IO 操作,测试时用接口 mock,别真连 1935。
九、把源码知识点收进"青云口诀"
| 原码概念 | 青云比喻 | 关键设计 |
|---|---|---|
| NetStream | 张小凡的灵脉 | 连接内的子会话 |
| StreamID | 烧火棍编号 | chunk 路由依据 |
| Begin() | 入门鸣钟 | UserControl StreamBegin |
| BeginPlay() | 雪琪拔剑 | 拉流授权 |
| PublishWriter | 田不易炼体 | 推流写入 RingBuffer |
| RTMPSubscriber | 天琊映水 | 零拷贝读取+封装 |
| closeStream | 收功散场 | 回收资源 |
十、总结:这门功法到底在练什么
net-stream.go 不是"最复杂的文件",却是最懂边界的文件:
- 不抢 handshake 的活(连接建立)
- 不抢 chunk.go 的活(切包)
- 只做三件事:
- 给流发"身份证"(StreamID)
- 给客户端发"许可"(Play / Publish Start)
- 把 Publisher / Subscriber 挂到连接上
青云之巅,小凡悟了:
真正的高手,不是什么都自己干,而是让每条灵脉各司其职。