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 3264SDP或 RFC 5764DTLS-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-allowed1;packetization-mode1;profile-level-id42e01f。这个 profile-level-id 对应的是 baseline level 3.1它在低端安卓手机上没问题但在某些 iOS 设备上如果摄像头输出的是 high profile就会协商失败最终没有视频流。实操中我们必须根据目标设备能力定制化注册。比如针对 iOS 14我们更倾向注册H264;level-asymmetry-allowed1;packetization-mode1;profile-level-id64001fhigh profile level 3.1而针对老旧的 Fire TV Stick则要降级到H264;level-asymmetry-allowed1;packetization-mode1;profile-level-id42001fconstrained baseline。这个值不是随便写的它由三部分组成profile-id2 字节十六进制level-id1 字节十六进制必须严格匹配设备实际支持的 capability。我们曾用ffmpeg -h encoderh264_videotoolbox在真机上跑出支持列表再反查 RFC 6184 的 Annex A 表格才确定最终值。提示不要依赖RegisterDefaultCodecs()。在生产环境应该构建一个codecRegistry根据 User-Agent 或设备指纹动态选择最合适的 codec 列表并在MediaEngine初始化时一次性注册完毕。注册顺序也很重要——pion 会按注册顺序尝试协商所以把最兼容的 codec如 VP8放在前面把最高清但兼容性差的如 AV1放在后面。3.2 SettingEngineICE 和 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之前就调用AddTrackpion 会报错invalid state: cannot add track before setting local description。这是因为AddTrack需要访问MediaEngine的 codec 注册信息而这些信息只有在SetLocalDescription触发的 SDP 解析流程中才会被真正加载和校验。我们曾在一个会议系统里为了“提前准备”把AddTrack放在了CreateOffer之前结果所有参会者都看不到视频。调试时发现peerConnection.GetSenders()返回空 sliceOnTrack也从未触发。根源就是这个状态机违例。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 handshakeCOOKIE ECHO / COOKIE ACK可能还在路上。实操中我们必须在OnDataChannel回调里再次注册OnOpenpeerConnection.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/v3v3.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 candidatepion 也能收到这个 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还要打印其类型和 IPpeerConnection.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 收集全了吗对端收到了哪些 candidateconnectivity 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或内网 IPDTLS 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 的statefrozen, waiting, inprogress, succeeded, failed和nominated状态RTCIceTransport的详细统计如bytesSent,bytesReceived,retransmissionsSentRTCDtlsTransport的状态如state,dtlsCipher,certificates。当你的连接卡在checking时打开这个页面看candidate pair列表。如果全是frozen说明 pion 没有收到任何来自 Chrome 的 candidate问题出在信令或OnICECandidate回调没正确发送如果有一个 pair 是inprogress但长时间不变成succeeded说明 STUN binding request 发出去了但 response 没回来问题出在网络或 pion 的 STUN 处理逻辑。我们曾用这个页面5 分钟内定位到一个 bugpion 在处理某些 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从不触发或只触发一次nilSetLocalDescription未被调用或调用时传入了nilMediaEngine未注册任何 codecSettingEngine的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失败了根本