1. 项目概述:为什么一个 WebRTC 示例代码值得拆解到“第一篇”?
WebRTC 这个词,现在几乎已经渗透进所有需要实时音视频交互的场景里——在线教育的双师课堂、远程医疗的会诊系统、智能硬件的设备预览、甚至某些工业巡检终端的现场回传。但真正动手写过 WebRTC 的人会发现,它不像调用一个 REST API 那样“发个请求、收个 JSON”就完事。它是一套状态机驱动的、多线程协作的、网络环境强依赖的复杂协议栈。而pion/webrtc,是目前 Go 生态中唯一被大规模生产验证、完全由纯 Go 实现、且不依赖 C/C++ 底层绑定的 WebRTC 栈。它不是封装层,而是从 SDP 解析、ICE 候选者收集、DTLS 握手、SRTP 加密、RTP/RTCP 封包解包,全部自己写的。
所以,“pion webrtc 示例代码解析 一”这个标题,表面看是讲一段 demo,实际是在拆解一个可落地的实时通信系统最小可行内核。我带过的几个团队,在做边缘设备端推流、轻量级信令网关、或嵌入式音视频中继时,最终都落到了 pion 上——不是因为它最炫,而是因为它足够透明、足够可控、足够容易嵌入到现有 Go 工程体系里。你不需要懂 RFC 3264(SDP)或 RFC 5764(DTLS-SRTP),但你必须知道:当peerConnection.OnTrack()回调被触发时,底层到底发生了什么?webrtc.SettingEngine里的SetICETimeout()是在哪个 goroutine 里起效的?MediaEngine注册 H.264 编码器时,为什么必须显式指定sdp.SDPValue而不能只写"H264"?这些细节,全藏在官方示例代码的每一行缩进里。
这篇文章不教你怎么跑通一个 demo,而是带你把pion/webrtc/examples/sfu或pion/webrtc/examples/data-channels这类目录下的 main.go 文件,当成一份协议实现说明书来读。我会逐行标注它的设计意图、隐藏约束、常见误用点,以及——更重要的是——当你把这段代码从 example 目录拷进自己项目里时,哪些地方你必须改、哪些地方改了反而会崩。比如,很多人复制粘贴settingEngine.SetEphemeralUDPPortRange(30000, 30100)后发现本地测试一切正常,一上云服务器就 ICE 失败,却不知道这个端口范围在 Docker 容器里根本没意义;又比如,peerConnection.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo)返回的 transceiver 对象,如果不立刻调用.Sender().SetCodecPreferences(),在某些安卓老版本浏览器上就会静音。这些都不是文档里写的“注意事项”,而是我在三个不同客户现场连续踩坑后记下来的硬经验。
适合谁读?如果你正在评估是否选用 pion 构建自己的实时通信模块,或者你已经写了两天代码但OnICEConnectionStateChange一直卡在checking状态,又或者你只是想搞懂 WebRTC 在 Go 里到底是怎么“活”起来的——那这篇就是为你写的。它不假设你熟悉 WebRTC 协议栈,但要求你至少写过 Go 的 HTTP handler;它不提供一键部署脚本,但能让你在出问题时,精准定位到是ice.Transport的Start()没执行,还是dtls.Conn的Handshake()被阻塞。
2. 整体架构与设计思路:为什么示例代码不是“玩具”,而是生产级骨架?
pion 官方示例代码最常被误解的一点,就是把它当成教学玩具。其实恰恰相反:这些例子是 pion 团队在给大厂客户做技术咨询时,反复打磨出来的最小生产可用模板。它们刻意回避了信令层(Signaling)、房间管理、用户状态同步等上层业务逻辑,把全部精力聚焦在 WebRTC 协议栈本身的生命周期控制上。这种“去业务化”的设计,不是偷懒,而是为了暴露最本质的耦合点。
我们以最经典的pion/webrtc/examples/data-channels/main.go为例。整个文件只有 180 行左右,但它完整呈现了一个 WebRTC PeerConnection 从创建、配置、连接、数据通道建立到关闭的全过程。它的主干结构是典型的“三段式”:
初始化段(第 20–50 行):创建
webrtc.Configuration、webrtc.SettingEngine、webrtc.MediaEngine,然后用它们构造webrtc.PeerConnection。这里的关键不是参数本身,而是参数之间的隐含依赖关系。例如,SettingEngine.SetICETimeout(30 * time.Second)必须在PeerConnection创建前设置,因为超时控制是绑定在ice.Transport初始化时的;而MediaEngine.RegisterDefaultCodecs()如果放在PeerConnection创建之后调用,会导致后续AddTransceiver无法识别编码器——因为PeerConnection内部的mediaEngine是在构造时深拷贝的,不是引用。事件注册段(第 52–95 行):集中注册所有回调函数:
OnICECandidate、OnICEConnectionStateChange、OnDataChannel、OnTrack等。这里的设计哲学是“事件驱动,无状态处理”。每个回调函数体都非常短,通常只做两件事:一是把收到的数据(如 candidate 字符串)通过信令通道发给对端;二是触发一个外部 channel 或 mutex-guarded 变量来通知主线程。这种写法避免了在回调里做耗时操作(如数据库写入、HTTP 请求),防止阻塞 WebRTC 内部的 goroutine 调度器。我见过太多团队在这里直接调用http.Post(),结果导致 ICE 候选者发送延迟,最终连接超时。信令交互段(第 97–180 行):模拟 SDP Offer/Answer 交换和 candidate 交换。注意,这里的
offer := peerConnection.CreateOffer(nil)并不是立即生成 SDP 字符串,而是返回一个*webrtc.SessionDescription结构体,其String()方法才真正触发 SDP 序列化。而peerConnection.SetLocalDescription(offer)才是真正的关键动作——它会启动 ICE 候选者收集、DTLS 证书生成、以及内部状态机向have-local-offer迁移。很多初学者卡在OnICECandidate不触发,根本原因就是漏掉了这一步,或者在SetLocalDescription之前就尝试调用CreateAnswer。
这种结构背后,是 pion 对 WebRTC 协议栈的深刻理解:WebRTC 不是一个“库”,而是一个“运行时”。它有自己的 goroutine 池(用于 STUN/TURN 事务)、自己的定时器(用于 ICE 心跳、DTLS 重传)、自己的内存池(用于 RTP 包复用)。示例代码之所以“看起来简单”,是因为它把所有这些复杂性都封装在PeerConnection对象内部,只暴露了符合 W3C WebRTC API 规范的接口。但一旦你开始定制化,比如想替换默认的 ICE 传输为 QUIC,或者想在 RTP 包发出前插入自定义水印,你就必须理解这个运行时的内部调度逻辑。
另一个常被忽视的设计点是goroutine 安全边界。pion 明确声明:所有PeerConnection的方法(CreateOffer、SetRemoteDescription、AddTrack等)都是 goroutine-safe 的,但所有回调函数(OnTrack、OnDataChannel)的执行上下文,是由 pion 内部的taskQueue控制的,不是调用者的 goroutine。这意味着你在OnTrack里fmt.Println("track received")是安全的,但如果你在里面启动一个time.AfterFunc(5*time.Second, ...),那个闭包里的变量捕获必须小心——因为OnTrack的参数track是一个接口,其底层实现可能在回调返回后就被 GC 掉。我曾经在一个直播推流项目里,因为在这个回调里直接保存了track.RID()字符串并用于后续日志,结果在高并发下出现空指针 panic,最后发现是track对象生命周期比预期短。
所以,示例代码的“简洁”,是建立在对协议栈深度掌控之上的克制。它不教你如何写信令服务器,但教会你如何让信令服务器和 WebRTC 运行时之间,建立起一条低延迟、高可靠的控制通道。
3. 核心细节解析与实操要点:从配置到状态机的每一个关键开关
pion/webrtc 的配置项看似不多,但每个都像一个精密阀门,开大一点或关小一点,整个连接行为就会发生质变。我们以webrtc.Configuration和webrtc.SettingEngine为核心,逐个拆解那些在示例代码里一笔带过、但在真实环境中决定成败的参数。
3.1 MediaEngine:编码器注册不是“选填”,而是“必填契约”
示例代码里常看到:
m := &webrtc.MediaEngine{} if err := m.RegisterDefaultCodecs(); err != nil { panic(err) }这行代码看似无害,但它背后藏着一个硬性契约:pion 不会自动探测系统支持的编码器,它只信任你显式注册过的 codec。RegisterDefaultCodecs()是一个便捷函数,它内部调用了RegisterCodec(webrtc.RTPCodecParameters{...})来注册 VP8、VP9、H.264、Opus 等。但问题在于,它注册的是“通用配置”,比如 H.264 的sdp.SDPValue是"H264;level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f"。这个 profile-level-id 对应的是 baseline level 3.1,它在低端安卓手机上没问题,但在某些 iOS 设备上,如果摄像头输出的是 high profile,就会协商失败,最终没有视频流。
实操中,我们必须根据目标设备能力,定制化注册。比如,针对 iOS 14+,我们更倾向注册"H264;level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=64001f"(high profile level 3.1);而针对老旧的 Fire TV Stick,则要降级到"H264;level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f"(constrained baseline)。这个值不是随便写的,它由三部分组成:profile-id(2 字节十六进制)+level-id(1 字节十六进制),必须严格匹配设备实际支持的 capability。我们曾用ffmpeg -h encoder=h264_videotoolbox在真机上跑出支持列表,再反查 RFC 6184 的 Annex A 表格,才确定最终值。
提示:不要依赖
RegisterDefaultCodecs()。在生产环境,应该构建一个codecRegistry,根据 User-Agent 或设备指纹,动态选择最合适的 codec 列表,并在MediaEngine初始化时一次性注册完毕。注册顺序也很重要——pion 会按注册顺序尝试协商,所以把最兼容的 codec(如 VP8)放在前面,把最高清但兼容性差的(如 AV1)放在后面。
3.2 SettingEngine:ICE 和 DTLS 的超时不是“数字”,而是“网络拓扑的映射”
SettingEngine是 pion 的“调优中枢”,其中SetICETimeout()、SetDTLSTimeout()、SetEphemeralUDPPortRange()这三个方法,直接决定了你的服务在不同网络环境下的鲁棒性。
先说SetEphemeralUDPPortRange(30000, 30100)。这个配置在本地开发时很香:它把所有 ICE 候选者限制在 100 个端口内,方便你用netstat -an | grep :300快速排查。但一上生产,它就成了定时炸弹。原因有二:第一,云服务器(尤其是 Kubernetes Pod)的 UDP 端口是共享的,30000-30100这个范围可能被其他进程占用;第二,pion 的 ICE Agent 在收集候选者时,会为每个网络接口(eth0, docker0, lo)都尝试绑定这个范围内的端口,如果某个接口绑不上,它会静默跳过,导致该接口的 candidate 缺失。我们线上曾遇到过,Pod 的docker0接口因为内核参数net.ipv4.ip_local_port_range被修改,导致 pion 无法在30000-30100内成功 bind,最终只上报了lo的 candidate,而lo对于跨节点通信毫无意义。
正确的做法是:在容器化部署时,完全不设置SetEphemeralUDPPortRange(),让 pion 使用操作系统默认的 ephemeral 端口范围(通常是32768-60999)。同时,在 Kubernetes Service 中,通过spec.ports[].nodePort显式暴露一个固定端口(如30000),并确保这个端口在宿主机防火墙和云厂商安全组中是开放的。这样,pion 会自动将这个nodePort作为 host candidate 上报,而无需你手动干预端口分配。
再看SetICETimeout(30 * time.Second)。这个 30 秒,不是“等 30 秒没连上就放弃”,而是 ICE Agent 在checking状态下,等待所有已知 candidate 对(host-host, host-srflx, srflx-srflx)完成 connectivity check 的总时间预算。pion 的 ICE 实现遵循 RFC 5245,它会为每个 candidate pair 启动一个 STUN Binding Request,并设置一个基于 RTT 估算的重传间隔(初始 500ms,指数退避)。如果网络 RTT 是 200ms,那么一个 pair 的完整 check 流程大约需要 2~3 秒;但如果网络丢包率高,重传次数增多,单个 pair 就可能耗尽大部分 timeout。因此,30 秒在弱网下是远远不够的。我们的经验是:在国内移动网络下,至少设为60 * time.Second;在跨国链路(如中日、中美)下,必须设为120 * time.Second,否则大量用户会卡在checking状态。
注意:
SetICETimeout()只影响 ICE 连接建立阶段。一旦进入connected状态,后续的 keep-alive 心跳(STUN Binding Indication)是由ice.Transport内部的定时器控制的,其间隔是固定的 15 秒,无法通过SettingEngine修改。这是 pion 的硬编码,如果你需要更激进的心跳(比如应对某些企业防火墙 20 秒断连),只能 fork pion 并修改ice/transport.go中的keepAliveInterval常量。
3.3 PeerConnection 生命周期:SetLocalDescription是“点火开关”,不是“设置动作”
这是新手最容易栽跟头的地方。在示例代码里,peerConnection.SetLocalDescription(offer)总是紧跟在CreateOffer()后面,看起来就像一个配套操作。但它的实际作用,远不止“设置一个字符串”。
当你调用SetLocalDescription(offer)时,pion 会立即触发以下一系列不可逆的动作:
- 启动 ICE Candidate Gatherer:为每个网络接口(包括
loopback,udp,tcp)生成 candidate,并通过OnICECandidate回调逐个上报; - 初始化 DTLS 传输层:生成本地 DTLS 证书(
crypto/tls.Certificate),并开始监听 DTLS 握手请求; - 启动 STUN/TURN 事务:如果配置了 STUN/TURN 服务器,会立即向它们发送 Binding Request,以获取 server-reflexive candidate;
- 迁移内部状态机:从
new→have-local-offer→checking(如果已设置 remote description)。
关键点在于:SetLocalDescription是一个异步、非阻塞的操作,但它会立即启动所有后台 goroutine。这意味着,如果你在SetLocalDescription之后,立刻调用peerConnection.AddTrack(...),是完全合法的,因为AddTrack只是往内部 track map 里加一个条目;但如果你在SetLocalDescription之前,就调用AddTrack,pion 会报错invalid state: cannot add track before setting local description。这是因为AddTrack需要访问MediaEngine的 codec 注册信息,而这些信息只有在SetLocalDescription触发的 SDP 解析流程中才会被真正加载和校验。
我们曾在一个会议系统里,为了“提前准备”,把AddTrack放在了CreateOffer之前,结果所有参会者都看不到视频。调试时发现,peerConnection.GetSenders()返回空 slice,OnTrack也从未触发。根源就是这个状态机违例。pion 的错误提示非常明确,但很多开发者忽略了日志里那句failed to add track: invalid state,转而去查网络或 codec 配置,白白浪费数小时。
3.4 OnDataChannel 回调:不是“收到通道”,而是“收到通道的握手确认”
OnDataChannel回调的触发时机,是 WebRTC 数据通道中最微妙的一环。它不是在你调用peerConnection.CreateDataChannel("chat")时触发,也不是在对端调用createDataChannel("chat")时触发,而是在DTLS 握手完成后,双方通过 SCTP INIT/INIT ACK 交换,成功协商出一个 data channel ID,并完成 SCTP association 建立之后。
这意味着,OnDataChannel的触发,本身就代表 DTLS 和 SCTP 两层都已经稳定。所以,在这个回调里,你可以安全地调用dataChannel.OnOpen()、dataChannel.OnMessage()等方法,而不用担心底层 transport 还没 ready。但这也带来一个陷阱:OnDataChannel的参数*webrtc.DataChannel对象,其ReadyState属性在回调执行时,不一定是"open"。它可能是"connecting",因为 SCTP association 的 final handshake(COOKIE ECHO / COOKIE ACK)可能还在路上。
实操中,我们必须在OnDataChannel回调里,再次注册OnOpen:
peerConnection.OnDataChannel(func(d *webrtc.DataChannel) { d.OnOpen(func() { log.Printf("DataChannel %s opened", d.Label()) // 此时才真正可以 send() d.SendText("Hello from pion!") }) d.OnMessage(func(msg webrtc.DataChannelMessage) { log.Printf("Received: %s", string(msg.Data)) }) })如果省略OnOpen,直接在OnDataChannel里调用d.SendText(),大概率会失败,因为d的内部sctpAssociation还未进入ESTABLISHED状态。pion 的DataChannel.SendText()方法会检查这个状态,如果不对,就直接返回 error,不会排队或重试。
4. 实操过程与核心环节实现:从零搭建一个可调试的 pion 环境
光看代码解析是不够的,必须亲手搭建一个可观察、可调试、可复现的 pion 开发环境。下面是我经过 5 个以上项目验证的标准化流程,它绕开了所有常见的“环境陷阱”,让你在 15 分钟内就能看到第一个OnICEConnectionStateChange从new变成connected。
4.1 环境准备:Go 版本、依赖管理与网络拓扑
首先,明确最低要求:Go 1.19+。pion/webrtc 在 1.18 及以下版本中,对net/netip的支持不完善,会导致某些 IPv6 场景下 candidate 收集异常。我们统一使用 Go 1.21.6,这是目前最稳定的 LTS 版本。
依赖管理,必须使用 Go Modules。在项目根目录执行:
go mod init github.com/yourname/pion-demo go get github.com/pion/webrtc/v3@v3.2.32注意,我们锁定了v3.2.32,而不是@latest。pion 的 master 分支更新频繁,但v3.x的 patch 版本(如3.2.32)是经过 CI 全量测试的稳定发布版。@latest可能包含未合并的 PR,导致go build失败或运行时 panic。
最关键的,是网络拓扑设计。pion 示例代码默认使用localhost,这在单机测试时没问题,但一旦你想用 Chrome 浏览器(作为对端)连接,就必须解决“浏览器无法直连 localhost:port”的问题。Chrome 出于安全策略,对localhost的 WebRTC 连接有特殊处理,它会忽略localhost的 candidate,强制走 loopback。这不是 bug,是 spec。所以,我们必须让 pion 服务监听在一个真实的、可被浏览器访问的 IP 地址上。
我的标准方案是:
- 在开发机上,执行
ifconfig | grep "inet " | grep -v 127.0.0.1,找到你的局域网 IP(如192.168.1.100)。 - 修改示例代码中的
http.ListenAndServe(":8080", nil)为http.ListenAndServe("192.168.1.100:8080", nil)。 - 在 Chrome 中,访问
http://192.168.1.100:8080,而不是http://localhost:8080。
这样,Chrome 就会正确上报192.168.1.100的 host candidate,pion 也能收到这个 candidate 并发起 connectivity check。我们曾用ngrok或localtunnel做外网穿透,结果发现它们会破坏 STUN binding 的源 IP 一致性,导致 ICE 失败,所以坚决不用。
4.2 代码改造:添加可观察性埋点与状态日志
官方示例代码的日志太“干净”了,干净到你根本不知道哪一步卡住了。我们必须在关键路径上,注入详细的、带时间戳的状态日志。以下是main.go的核心改造点(以>import "log" import "time" func main() { // 创建一个带毫秒时间戳的日志器 log.SetFlags(log.LstdFlags | log.Lmicroseconds) log.SetPrefix("[pion] ") // ... rest of the code }
- 在
OnICEConnectionStateChange回调里,打印完整的状态变迁和耗时:
peerConnection.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) { log.Printf("ICE State changed to: %s", state.String()) // 记录从 new 到 connected 的耗时 if state == webrtc.ICEConnectionStateConnected { connectedAt = time.Now() log.Printf("ICE connected in %v", connectedAt.Sub(createdTime)) } })- 在
OnICECandidate回调里,不仅打印 candidate,还要打印其类型和 IP:
peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) { if candidate == nil { log.Printf("ICE gathering finished") return } // 解析 candidate 字符串,提取 type 和 ip cStr := candidate.ToJSON().Candidate var candType, candIP string if strings.Contains(cStr, "typ host") { candType = "host" candIP = strings.Split(strings.Split(cStr, " ")[4], ":")[0] } else if strings.Contains(cStr, "typ srflx") { candType = "srflx" candIP = strings.Split(strings.Split(cStr, " ")[4], ":")[0] } log.Printf("ICE Candidate: %s (%s, %s)", candType, candIP, cStr) })- 在
OnDataChannel回调里,打印 data channel 的 ID 和标签:
peerConnection.OnDataChannel(func(d *webrtc.DataChannel) { log.Printf("OnDataChannel: ID=%d, Label=%s, Protocol=%s", d.ID(), d.Label(), d.Protocol()) // ... rest })这些日志不是为了“好看”,而是为了在连接失败时,能快速回答三个问题:candidate 收集全了吗?对端收到了哪些 candidate?connectivity check 发出去了吗?没有这些日志,你就是在盲人摸象。
4.3 调试工具链:用 wireshark 抓包,用 chrome://webrtc-internals 看状态
pion 是纯 Go 实现,没有 C 依赖,所以传统的gdb或pprof对它帮助有限。真正的调试利器,是网络层的可视化。
第一步:用 Wireshark 抓取 STUN/DTLS 流量
在开发机上启动 Wireshark,过滤条件设为stun || dtls。然后启动 pion 服务,并用 Chrome 连接。你会看到:
STUN Binding Request从 Chrome 发出,目标是 pion 的 UDP 端口;STUN Binding Success Response从 pion 返回,其中包含 pion 的公网 IP(如果走 STUN)或内网 IP;DTLS Client Hello从 Chrome 发出,DTLS Server Hello从 pion 返回,随后是完整的证书交换。
如果看不到Binding Success Response,说明 pion 的 UDP 端口没监听,或者防火墙拦截了。如果能看到 STUN 但看不到 DTLS,说明SetLocalDescription没触发 DTLS 初始化,大概率是MediaEngine注册有问题。
第二步:用 Chrome 的chrome://webrtc-internals页面
这是一个被严重低估的神器。它会实时显示:
- 当前 PeerConnection 的所有 candidate,包括
ip,port,type,priority; - 每个 candidate pair 的
state(frozen, waiting, inprogress, succeeded, failed)和nominated状态; RTCIceTransport的详细统计,如bytesSent,bytesReceived,retransmissionsSent;RTCDtlsTransport的状态,如state,dtlsCipher,certificates。
当你的连接卡在checking时,打开这个页面,看candidate pair列表。如果全是frozen,说明 pion 没有收到任何来自 Chrome 的 candidate,问题出在信令或OnICECandidate回调没正确发送;如果有一个 pair 是inprogress但长时间不变成succeeded,说明 STUN binding request 发出去了,但 response 没回来,问题出在网络或 pion 的 STUN 处理逻辑。
我们曾用这个页面,5 分钟内定位到一个 bug:pion 在处理某些 malformed STUN binding request 时,会 panic 并关闭 UDP listener,但日志里没有任何提示。chrome://webrtc-internals显示RTCIceTransport的state从checking突然变成failed,结合 Wireshark 看到STUN Binding Request后没有Response,立刻就知道是 pion 端挂了。
4.4 完整可运行示例:一个精简但功能完备的>package main import ( "encoding/json" "fmt" "log" "net/http" "strings" "time" "github.com/pion/webrtc/v3" "github.com/pion/webrtc/v3/pkg/media" ) var createdTime time.Time var connectedAt time.Time func main() { log.SetFlags(log.LstdFlags | log.Lmicroseconds) log.SetPrefix("[pion] ") // 1. 创建 MediaEngine 和 SettingEngine m := &webrtc.MediaEngine{} if err := m.RegisterCodec(webrtc.RTPCodecParameters{ RTPCodecCapability: webrtc.RTPCodecCapability{MimeType: "audio/opus", ClockRate: 48000, Channels: 2}, PayloadType: 111, }, "audio"); err != nil { panic(err) } s := webrtc.SettingEngine{} s.SetICETimeout(60 * time.Second) // s.SetEphemeralUDPPortRange(30000, 30100) // 注释掉,用系统默认 // 2. 创建 PeerConnection config := webrtc.Configuration{ ICEServers: []webrtc.ICEServer{ {URLs: []string{"stun:stun.l.google.com:19302"}}, }, } api := webrtc.NewAPI(webrtc.WithMediaEngine(m), webrtc.WithSettingEngine(s)) peerConnection, err := api.NewPeerConnection(config) if err != nil { panic(err) } defer peerConnection.Close() createdTime = time.Now() // 3. 注册事件回调 peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) { if candidate == nil { log.Printf("ICE gathering finished") return } cStr := candidate.ToJSON().Candidate var candType, candIP string if strings.Contains(cStr, "typ host") { candType = "host" candIP = strings.Split(strings.Split(cStr, " ")[4], ":")[0] } else if strings.Contains(cStr, "typ srflx") { candType = "srflx" candIP = strings.Split(strings.Split(cStr, " ")[4], ":")[0] } log.Printf("ICE Candidate: %s (%s, %s)", candType, candIP, cStr) }) peerConnection.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) { log.Printf("ICE State changed to: %s", state.String()) if state == webrtc.ICEConnectionStateConnected { connectedAt = time.Now() log.Printf("ICE connected in %v", connectedAt.Sub(createdTime)) } }) peerConnection.OnDataChannel(func(d *webrtc.DataChannel) { log.Printf("OnDataChannel: ID=%d, Label=%s, Protocol=%s", d.ID(), d.Label(), d.Protocol()) d.OnOpen(func() { log.Printf("DataChannel %s opened", d.Label()) // 发送一个测试消息 if err := d.SendText("Hello from pion! Time: " + time.Now().Format(time.RFC3339)); err != nil { log.Printf("Failed to send: %v", err) } }) d.OnMessage(func(msg webrtc.DataChannelMessage) { log.Printf("Received: %s", string(msg.Data)) }) }) // 4. 创建 DataChannel 并触发 Offer dataChannel, err := peerConnection.CreateDataChannel("test", nil) if err != nil { panic(err) } offer, err := peerConnection.CreateOffer(nil) if err != nil { panic(err) } if err := peerConnection.SetLocalDescription(offer); err != nil { panic(err) } // 5. 启动 HTTP 服务,提供一个简单的 HTML 页面 http.HandleFunc("/offer", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "sdp": offer.SDP, "type": "offer", }) }) log.Printf("Server starting on :8080 (use your LAN IP, e.g., http://192.168.1.100:8080)") log.Fatal(http.ListenAndServe(":8080", nil)) }
把这个文件保存为main.go,执行go run main.go,然后在 Chrome 中访问http://<your-lan-ip>:8080/offer,你就能看到完整的 SDP offer。配合一个简单的 HTML 页面(用RTCPeerConnection加载这个 offer 并 setRemoteDescription),你就能看到完整的连接日志。这个示例,就是你所有 pion 项目的起点。
5. 常见问题与排查技巧实录:那些文档里不会写的“血泪教训”
pion/webrtc 的文档(https://github.com/pion/webrtc)写得非常规范,但它本质上是一份 API 参考手册,而不是一本排错指南。真正的排错知识,都散落在 GitHub Issues、Discord 频道和无数个深夜的fmt.Println里。我把过去三年里,帮团队解决的最典型、最高频的 5 个问题,整理成一张速查表,并附上独家排查技巧。
| 问题现象 | 根本原因 | 排查技巧 | 我的实操心得 |
|---|---|---|---|
OnICECandidate从不触发,或只触发一次nil | SetLocalDescription未被调用,或调用时传入了nil;MediaEngine未注册任何 codec;SettingEngine的SetEphemeralUDPPortRange在容器中失效 | 1. 在SetLocalDescription前后各加一行log.Printf("Before/After SetLocalDescription");2. 检查peerConnection.GetSenders()是否为空;3. 在main()开头log.Printf("MediaEngine codecs: %+v", m.Codecs()) | 这是最常见的“入门即劝退”问题。我建议新手在CreateOffer后,立刻log.Printf("Offer SDP length: %d", len(offer.SDP)),如果长度是 0,说明CreateOffer失败了,根本 |