webrtc(Rust)传输对象指南:SctpTransport / DtlsTransport / IceTransport 与浏览器 API 的十大差异
webrtc(Rust)传输对象指南:SctpTransport / DtlsTransport / IceTransport 与浏览器 API 的十大差异
SctpTransport、DtlsTransport 与 IceTransport 是本项目(webrtc,Rust 编写的 async-friendly WebRTC 实现)对 W3C 同名接口的 Rust 落地。这篇指南以 docs/transport-objects.md 为骨架,完整记录 Rust 侧 API 表面与 WebRTC 1.0(W3C Recommendation,REC-webrtc-20250313)之间的每一处差异及其原因——从 JavaScript 移植过来的读者不必踩坑,可以直接在这里找到"为什么不一样"。文中所有行为差异均结合 src/peer_connection/transport/mod.rs 的 trait 与句柄实现、tests/transport_objects.rs 的集成测试逐一印证。
本文面向:正在用 Rust 编写 WebRTC 应用、需要读取或比对传输对象状态的开发者;从浏览器 RTCPeerConnection 迁移到 Rust 的开发者;以及任何想理解"无浏览器环境下的传输对象图"如何建模的人。读完后你将掌握:如何从 PeerConnection、sender、receiver 三个入口抵达传输对象;十个与浏览器不同的语义点;以及哪些方法为何是 async。
核对基准说明:本文与仓库文档一致,所有差异均对照 WebRTC 1.0 的 Recommendation(REC-webrtc-20250313)版本核对,而非 MDN 或已经漂移的 editor's draft。
如何抵达一个传输对象
与浏览器一样,传输对象的访问方式是"沿着图走",而不是从 RTCPeerConnection 上直接抓取。规范只在 RTCPeerConnection 上暴露 sctp 这一个传输成员,其余部分都要靠遍历:
// 数据通道:规范放在 RTCPeerConnection 上的唯一传输成员。
let sctp = pc.sctp().await.expect("SCTP negotiated");
let dtls = sctp.transport();
let ice = dtls.ice_transport();
// 媒体:sender 或 receiver 是另一条入口——也是不含数据通道的连接上唯一的入口。
let dtls = sender.transport().await?.expect("sender is associated");
这里刻意没有 pc.dtls_transport() 或 pc.ice_transport()。规范里就没有这两个:RTCPeerConnection 只暴露 sctp,其余图结构全靠遍历。本 API 的早期草案曾提供按 id 查找的方法,因与规范不符而被移除。
这条"按规范形状行走"的设计在源码中得到了忠实贯彻:PeerConnection::sctp 是 trait 上唯一的传输访问器,其文档注释明确写着"这是这里唯一的传输访问器,与 W3C 接口一致——DTLS 与 ICE 传输通过它、或从 sender/receiver 遍历获得"(见 src/peer_connection/mod.rs)。实现同样只保留三个 id 后便释放锁,见 sctp() 的实现(src/peer_connection/mod.rs)。
1. 身份是 id(),不是引用相等
规范: 传输图是对象引用,JavaScript 这样问:pc.sctp.transport === sender.transport。
本实现: 句柄是共享核心之上的"值"。因此 Arc::ptr_eq 回答的是另一个问题——两个句柄是否为同一对象,而不是它们是否指向同一传输——所以身份被显式暴露:
assert_eq!(sctp.transport().id(), sender.transport().await?.unwrap().id());
RTCTransportId 是不透明类型,应用无法自行构造;它存在的唯一意义就是被比较。它的保证:
- 不同传输拥有不同 id,跨 PeerConnection 也成立——拿一条连接的传输与另一条连接的传输比较,会正确报告"不同"。
- 跨读取稳定:id 在传输创建时分配,而不是每次查询时生成。
它不承诺的事:该值不能跨运行复现(它由每条连接独立的随机 nonce 播种,因为"跨连接不同"与"可复现"互斥);它与 RTCStatsId 无关——RTCTransportStats 只有一条条目描述整个 bundled transport,而不是每个传输各一条。
测试 tests/transport_objects.rs 把 id 的这两个性质钉死了:dtls.id() == sender_dtls.id()(sender 走的就是数据通道用的那条 DTLS)、sctp.id() == sctp_again.id()(id 是存储的而非每次铸造)、以及跨连接比较 dtls.id() != other_dtls.id()。这个测试文件还特别注明:它正是当初 #841 缺陷(所有成员实现在 rtc 内部、外部无法调用)对应的回归测试,因此断言的是真实值而非"没有 panic"。
2. 状态是读取的,不是推送的
规范: onstatechange、onerror、onselectedcandidatepairchange。
本实现: 一个都没有。状态是轮询读取的:
match dtls.state().await? { RTCDtlsTransportState::Connected => …, _ => … }
ICE 状态变化仍可通过既有的 peer-connection 事件观察(on_ice_connection_state_change、on_ice_gathering_state_change)。DTLS 与 SCTP 则纯轮询。
这与本 crate 的 async 事件模型一致:连接级事件通过 PeerConnectionEventHandler 推送(测试中的 Handler 即实现该 trait,见 tests/transport_objects.rs),而传输对象只提供快照式读取。
3. maxMessageSize 永远是有限值
规范: 类型为 unrestricted double,且 §6.1.1.2 规定当两端都不设限制时取正无穷。
本实现: 永远是有限的 u32。协商出的值同时决定一个真实的接收缓冲区大小,因此本实现总是有上限;§6.1.1.2 只允许"能处理任意大小消息"的实现上报"无限制",而本实现做不到。配置里写明"不设限制"时,会解析为实现的硬性上限,所以上报的值就是被强制执行的值——一边上报 infinity 一边只分配 256 KiB 缓冲区,等于让该属性成为一个传输兑现不了的承诺。
在 trait 定义里(src/peer_connection/transport/mod.rs),注释直接写明:"W3C 类型是 unrestricted double,使无限制的实现可以上报正无穷;本实现总有上限——协商值同时也决定真实缓冲区——因此该值总是有限的。"对应的 SctpTransportImpl::max_message_size(同文件 L388-L397)在锁内从核心读取协商值,并注明"实践中不可达"的错误分支。测试断言协商后的连接 max_message_size() > 0。
4. gatheringState 复用 RTCIceGatheringState
规范: 两个值集完全相同的枚举——RTCPeerConnection.iceGatheringState 用 RTCIceGatheringState,RTCIceTransport.gatheringState 用 RTCIceGathererState。
本实现: 一个类型 RTCIceGatheringState 两处通用。再建一个结构完全相同的枚举毫无收益,反而在每个调用点都要付出一次转换成本。trait 注释原话:"规范将这一项类型化为 RTCIceGathererState——一个值与 RTCIceGatheringState 完全相同的第二枚举;本 crate 为两者共用一个类型。"(src/peer_connection/transport/mod.rs)
5. component 永远是 Rtp
这不是偏差,而是合规。RTCP 复用是强制的(RTCRtcpMuxPolicy 只有单一值 "require"),而对复用传输,规范本身就规定:"单个 RTCIceTransport 同时承载 RTP 与 RTCP,且 component 被置为 rtp"。实现中 IceTransport::component() 直接返回常量 RTCIceComponent::Rtp(src/peer_connection/transport/mod.rs),trait 注释也写明"常量,故不加锁"。
6. getRemoteCertificates() 返回 DER 字节
Vec<Vec<u8>>,对应浏览器 sequence<ArrayBuffer>。在 DTLS 握手完成之前为空。测试验证握手完成后 !dtls.get_remote_certificates().await?.is_empty(),即"对端证书在握手后被保留"(tests/transport_objects.rs)。
7. sender.transport() / receiver.transport() 的可空性
规范: 在"RTCDtlsTransport 对象构建之前"为空,数据来自每个对象各自的 [[SenderTransport]] / [[ReceiverTransport]] 槽位。
本实现: 在该 sender/receiver 的 transceiver 被关联(即拥有 mid)之前返回 Ok(None)——这些槽位正是在应用本地或远端描述时被填充的。
注意这里刻意不以 DTLS 握手是否开始为键:offerer 在 setLocalDescription 时就关联了它的 transceivers(那时还不存在任何 answer),而浏览器同样会在那里报告一个 transport。句柄只是报告状态 New,直到握手开始。
Err 与 Ok(None) 是两回事:Err 表示该 sender 或 receiver 本身已不存在。实现印证:RtpSenderImpl::transport() 中,核心找不到该 sender 时返回 Err(ErrRTPSenderNotExisted),而 sender.transport() 内部查不到关联时则映射为 Ok(None)(src/rtp_transceiver/rtp_sender.rs);receiver 侧同理(src/rtp_transceiver/rtp_receiver.rs)。测试在协商前断言 sender.transport().await?.is_none()(未关联 sender 的传输为空),协商后再 expect("an associated sender has a transport")。
8. sctp() 在丢弃数据的重协商后不会回到 None
规范: 当 answer 发起关闭 SCTP 关联时,[[SctpTransport]] 被置回 null。
本实现: 不会。关联协商成功后 sctp() 变为 Some,并在连接的整个生命周期内保持 Some。将数据通道重协商移除后,留下的句柄其 state() 报告 Closed,而不是 sctp() 返回 None。
已知偏差,尚未修复。 重置它需要动重协商路径,其风险高于这个差距本身的价值。如果你在意这一点,请检查 state()。
9. RTCIceRole 从不报告 "unknown"
规范: RTCIceRole 为 "unknown" | "controlling" | "controlled",角色确定之前为 "unknown"。
本实现: 底层 agent 将角色存为布尔值,因此 role() 只能返回 Controlling 或 Controlled。枚举中确实存在 RTCIceRole::Unspecified,但这个访问器永远不会产出它。测试断言 ice.role().await? != RTCIceRole::Unspecified(tests/transport_objects.rs)。
10. RTCIceParameters 携带一个非标准成员
规范字典恰好只有 usernameFragment 与 password;本实现的该结构还额外携带 ice_lite。在 ICE 双端各有不同 ufrag 这一点上,测试做了专门验证:本地与远端参数的 username_fragment 互不相等,"说明这不是同一次读取的两次拷贝"(tests/transport_objects.rs)。
为什么有的方法是 async,有的不是
这里遵循的是 IDL 的可空性,而不是某种内部风格:
非 async |
原因 |
|---|---|
id() |
直接存在句柄上 |
SctpTransport::transport()、DtlsTransport::ice_transport() |
规范将这两者类型化为非空,因此下一个句柄无需查询核心即可构建 |
IceTransport::component() |
常量 |
其余方法都要读取实时的传输状态——意味着要拿核心锁——因此都是 async。
源码完全对应:SctpTransportImpl::id 直接返回存储字段(src/peer_connection/transport/mod.rs);transport() 与 ice_transport() 用已存的 id 直接 Arc::new(...) 构造下一个句柄,不加锁;而 state()、max_message_size() 等每个都先 self.inner.core.lock().await。
实现注记:routes(句柄如何重新找到自己的传输)
核心的借用视图无法跨越 await 存活,因此每个 async 访问器都要从某个入口重新遍历图、复制出拥有的数据。又因为核心是"规范形状"的——入口不止一个(纯媒体连接没有 sctp())——每个 DTLS/ICE 句柄都记录它当初到达时的路径(Sctp、Sender(id) 或 Receiver(id)),再沿该路径重新遍历。
源码中这就是 DtlsRoute 枚举(src/peer_connection/transport/mod.rs):Sctp 走 peer_connection.sctp(),Sender(id) / Receiver(id) 走 rtp_sender(id) / rtp_receiver(id),统一在 with_dtls 中完成"上锁 → 遍历 → 复制结果 → 返回"的闭环;IceTransportImpl 则通过 read_ice! 宏以相同模式读取每个成员(同文件 L480-L493)。三个句柄实现(SctpTransportImpl / DtlsTransportImpl / IceTransportImpl)各自持有 id + 下一跳的 id + Arc<PeerConnectionRef> 与 route,结构一目了然。
这就是不添加 pc.dtls_transport() 的代价。它对调用者不可见,且有专门的测试覆盖纯媒体连接场景——在那里 SCTP 路径不存在,只有 sender 与 receiver 路径可解析(tests/transport_objects.rs:媒体专用连接上 pc_a.sctp().await.is_none(),但 sender.transport() 与 receiver.transport() 都能抵达状态 Connected 的 DTLS 与 ICE)。
把十处差异串起来:一次真实连接的完整验证
tests/transport_objects.rs 中 transport_objects_are_reachable 测试在一条同时含数据通道与媒体轨道的真实连接上,逐一验证了上述全部语义:SCTP 协商前 sctp() 为 None;关联前 sender 的 transport() 为 None;连接建立后 RTCSctpTransportState::Connected、RTCDtlsTransportState::Connected、ICE 状态为 Connected | Completed、gathering_state == Complete;get_local_candidates / get_remote_candidates 非空;get_selected_candidate_pair() 返回的配对可读出 local/remote 地址;本地与远端 ICE 参数 ufrag 不同。想亲自跑一遍:
cargo test --test transport_objects
(该项目默认特性为 runtime-tokio 与 crypto-ring,见 Cargo.toml;测试通过 tests/common 提供的 block_on / runtime / timeout 辅助在自定义 runtime 上驱动。)
小结
这十处差异并非随意偏离,每一处都对应一个明确的动机:要么是"句柄是值而非引用"的必然推论(id),要么是 async 轮询模型下的自然选择(状态读取),要么是对规范措辞的忠实执行(component、maxMessageSize),要么是有意为之的已知取舍(sctp() 不回到 None)。从 JavaScript 移植时,把这张清单当作对照表:身份用 id() 比、状态用轮询读、maxMessageSize 永远有限、sender.transport() 在关联前为 Ok(None)、sctp() 一经协商便终身 Some——其余行为均与浏览器一致。