基于TURN数据信道的ICE Trickle信令方案弊端探讨
受限信令下的WebRTC连接优化方案与疑问
我需要使用WebRTC连接两个对等端,但存在一个限制:每个对等端仅能通过初始信令通道向对方传递一条消息。
常规解决方案是等待ICE收集完成后再发送应答,但有时ICE收集耗时可达一分钟左右;而如果对等端能尽早交换正确的ICE候选,连接可更快建立(这正是ICE Trickle的作用)。我拥有一台TURN服务器,可基本保证WebRTC连接能成功建立。
因此我提出以下方案:
- 等待收集到一个TURN("relay")候选
- 通过初始(单次)信令通道向远端对等端发送本地描述
- 在
icecandidate事件触发时,通过WebRTC数据信道发送ICE候选
理论上,该方案可确保连接在ICE收集完成前建立,且对等端仍能交换收集到的全部ICE候选。
原型代码
export class CallsManager { private peerConnection: RTCPeerConnection; private iceTricklingDataChannel: RTCDataChannel; /** * Stores local ICE candidates to be sent to the remote peer * when the data channel opens. */ private iceTricklingBuffer: Array<RTCIceCandidate | null>; constructor() { this.peerConnection = new RTCPeerConnection({ iceServers: [ { urls: "turn:<your_turn_ip>", username: "username", credential: "password123", }, ], }); this.iceTricklingBuffer = []; this.iceTricklingDataChannel = this.peerConnection.createDataChannel( "iceTrickling", { negotiated: true, id: 1, }, ); this.iceTricklingDataChannel.onmessage = (e) => { console.log("received ICE candidate from remote peer", e.data); this.peerConnection.addIceCandidate(JSON.parse(e.data)); }; this.iceTricklingDataChannel.onopen = () => { console.log( "iceTricklingDataChannel open: sending buffered ICE candidates", this.iceTricklingBuffer, ); for (const candidate of this.iceTricklingBuffer) { sendIceCandidateToDataChannel(this.iceTricklingDataChannel, candidate); } this.iceTricklingBuffer = []; }; } async init( outStream: MediaStream, onIncomingStream: (stream: MediaStream) => void, ) { this.peerConnection.ontrack = (e: RTCTrackEvent) => { const stream = e.streams[0]; onIncomingStream(stream); }; outStream .getTracks() .forEach((track) => this.peerConnection.addTrack(track, outStream)); const onIncomingCall = async (payload: string) => { const gatheredEnoughIceP = gatheredEnoughIce(this.peerConnection); const offerObject = { type: "offer", sdp: payload, } as RTCSessionDescriptionInit; const offerDescription = new RTCSessionDescription(offerObject); this.peerConnection.setRemoteDescription(offerDescription); this.peerConnection.setLocalDescription( await this.peerConnection.createAnswer(), ); await gatheredEnoughIceP; const answer = this.peerConnection.localDescription!.sdp; this.peerConnection.onicecandidate = this.trickleIceOverDataChannel.bind(this); window.signaling.acceptCall(answer); }; const onAcceptedCall = (payload: string) => { const answerObject = { type: "answer", sdp: payload, } as RTCSessionDescriptionInit; const answerDescription = new RTCSessionDescription(answerObject); this.peerConnection.setRemoteDescription(answerDescription); }; } async startCall(): Promise<void> { const gatheredEnoughIceP = gatheredEnoughIce(this.peerConnection); this.peerConnection.setLocalDescription( await this.peerConnection.createOffer(), ); await gatheredEnoughIceP; const offer = this.peerConnection.localDescription!.sdp; this.peerConnection.onicecandidate = this.trickleIceOverDataChannel.bind(this); window.signaling.startCall(offer); } private trickleIceOverDataChannel(e: RTCPeerConnectionIceEvent) { if (this.iceTricklingDataChannel.readyState !== "open") { console.log( "Gathered new ICE candidate, but iceTricklingDataChannel is not yet open, will buffer it", e.candidate, ); this.iceTricklingBuffer.push(e.candidate); return; } sendIceCandidateToDataChannel(this.iceTricklingDataChannel, e.candidate); } } function gatheredEnoughIce(pc: RTCPeerConnection): Promise<void> { if (pc.localDescription != null || pc.remoteDescription != null) { console.warn( "gatheredEnoughIce called after setLocalDescription " + "or setRemoteDescription: " + "it might not have captured all ICE candidate events", ); } const gotTurnCandidate = new Promise<void>((r) => { const listener = (e: RTCPeerConnectionIceEvent) => { if (e.candidate != null && e.candidate.type === "relay") { // TODO is it enought to gather just one "relay" candidate // to guarantee that the data channel will establish? r(); pc.removeEventListener("icecandidate", listener); } }; pc.addEventListener("icecandidate", listener); }); const iceGatheringComplete = new Promise<void>((r) => { const listener = () => { if (pc.iceGatheringState === "complete") { r(); pc.removeEventListener("icegatheringstatechange", listener); } }; pc.addEventListener("icegatheringstatechange", listener); }); return Promise.race([gotTurnCandidate, iceGatheringComplete]); } function sendIceCandidateToDataChannel( dataChannel: RTCDataChannel, candidate: null | RTCIceCandidate, ) { console.log("sending ICE candidate to remote peer", candidate); dataChannel.send( JSON.stringify(candidate === null ? candidate : candidate.toJSON()), ); }
具体疑问
- 如何判断已收集足够候选以基本保证连接建立(无数据包过滤、TURN服务器正常等前提下)?即仅收集一个"relay"候选是否足够?实际测试中这似乎并不总是有效,对等端会收集3个"relay"候选,分别对应视频、音频、数据信道三个媒体流,且
sdpMid各不相同。 - 后续新增媒体流时,是否会触发
"negotiationneeded"事件?若用户设备从Wi-Fi切换到蜂窝网络导致IP变更、连接中断,能否继续通过WebRTC连接的数据信道(经TURN服务器)进行信令?或者是否可能因对等端切换至真正的P2P连接导致TURN服务器连接超时,进而无法通过RTCPeerConnection通信?
适用场景
我有两个相关使用场景:
- 通过邮件进行信令,希望将消息数量降至最低,同时避免将用户IP通过邮件发送(可能被邮件服务器永久存储)。
- Tor项目的Snowflake仅支持对等端间传递一条消息,相关议题为"Signaling through TURN"。
此外还有其他适用场景,例如初始信令通道不可信时,对等端不希望将IP地址或私有网络信息发送至初始信令通道(即先使用iceTransportPolicy: 'relay',在通过TURN建立加密P2P连接后,再调用restartIce()或创建新的RTCPeerConnection),比如通过Tor网络进行信令的场景。
内容的提问来源于stack exchange,提问作者WofWca
相关产品推荐
相关产品推荐

