浏览器背着服务器偷偷“牵手”?WebRTC 凭什么让你们端到端直连!
在互联网的远古时代(大概十几年前),浏览器和服务器的关系那是相当传统的“主仆关系”。
浏览器:“我要看图片!”
服务器:“好的,给你。”
浏览器:“我要发消息给隔壁小王!”
服务器:“收到,我先存下来,等小王下次来刷新的时候我再给他。”
所有的流量,都必须经过服务器这个“中间商”赚差价(带宽费)。
但后来,大家需求变了。我们要视频通话,我们要在线联机打游戏。如果我和坐在我对面的同事视频聊天,数据还要绕地球一圈去美国的服务器再绕回来,这延迟能让你把“你好”听成“再见”。
于是,WebRTC (Web Real-Time Communication) 横空出世。它的口号是:踢开服务器,让浏览器之间直接“私奔”! 也就是传说中的 P2P (Peer-to-Peer) 端到端直连。
但问题来了:这事儿其实非常难。
为什么?因为浏览器都住在“高墙大院”里。今天我们就来扒一扒,WebRTC 是凭什么本事,让两个素未谋面的浏览器成功“牵手”的。
第一关:原本的“异地恋”困局(NAT 之墙)
想象一下,你(浏览器A)想给远方的女神(浏览器B)寄快递(发数据)。
你住在自家小区的 192.168.1.5 房间。女神住在她家小区的 10.0.0.3 房间。
如果你直接对着公网喊:“把信送给 10.0.0.3!” 快递员会疯掉,因为全世界可能有几亿个 10.0.0.3(局域网私有IP)。
这就是 NAT(网络地址转换) 干的好事。
你的路由器(小区大门)有一个公网 IP(比如 203.0.113.1),它负责把你发出去的数据包把“寄件人”改成公网 IP,等你收到回信时,再查表转交给你。
尴尬的局面出现了:
你不知道女神小区的公网 IP,女神也不知道你的。你们俩就像两个住在不同地下室的人,互相想喊话,但谁也听不见谁。
第二关:找个媒婆互换名片(Signaling)
在 WebRTC 发威之前,你们必须先通过一个大家都认识的“中间人”——信令服务器(Signaling Server)。
这个过程 WebRTC 不管(它只管传数据),通常用 WebSocket 或者 HTTP 来实现。
过程大概是这样:
- 你对服务器说:“我要找女神,这是我的SDP(包含我的音视频编码能力、我想用什么加密方式等等)。”
- 服务器把这封情书转给女神。
- 女神收到后,回传她的 SDP:“好的,我也支持这些格式。”
但这只是交换了“兴趣爱好”,最重要的“家庭住址”(IP 和端口)还没搞定呢!
信令服务器的简易逻辑(服务器端):
// 假设这是一个简单的 WebSocket 信令服务器
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
let clients = {}; // 存储所有连接的客户端
wss.on('connection', ws => {
let id = Math.random().toString(36).substring(7); // 给每个连接一个唯一ID
clients[id] = ws;
console.log(`新客户端连接: ${id}`);
// 通知新连接的客户端它的ID
ws.send(JSON.stringify({ type: 'yourId', id: id }));
ws.on('message', message => {
const data = JSON.parse(message);
// 如果是发送给特定用户的消息
if (data.targetId && clients[data.targetId]) {
console.log(`从 ${id} 转发消息给 ${data.targetId}`);
clients[data.targetId].send(JSON.stringify({
type: data.type,
senderId: id,
payload: data.payload
}));
} else {
// 否则可能是广播或其他信令
console.log(`收到 ${id} 的广播/信令: ${data.type}`);
}
});
ws.on('close', () => {
delete clients[id];
console.log(`客户端断开: ${id}`);
});
});
console.log('信令服务器在 8080 端口启动...');
浏览器端如何通过信令服务器交换 SDP:
// 客户端A (发起者)
const ws = new WebSocket('ws://localhost:8080');
let localPeerConnection;
let remotePeerId; // 假设通过信令知道对方ID
ws.onopen = async () => {
console.log("WebSocket 连接成功!");
// 获取本地音视频流
const localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
document.getElementById('localVideo').srcObject = localStream;
// 创建 PeerConnection
localPeerConnection = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }, // 我们的 STUN 服务器!
// { urls: 'turn:YOUR_TURN_SERVER_IP:3478', username: 'user', credential: 'password' } // 如果需要 TURN
]
});
// 将本地媒体流添加到 PeerConnection
localStream.getTracks().forEach(track => localPeerConnection.addTrack(track, localStream));
// 监听 ICE 候选者事件,用于发送给对方
localPeerConnection.onicecandidate = event => {
if (event.candidate) {
ws.send(JSON.stringify({
type: 'iceCandidate',
targetId: remotePeerId, // 发送给对方
payload: event.candidate
}));
}
};
// 监听远程流事件 (对方发来的视频/音频)
localPeerConnection.ontrack = event => {
document.getElementById('remoteVideo').srcObject = event.streams[0];
};
// 创建 Offer
const offer = await localPeerConnection.createOffer();
await localPeerConnection.setLocalDescription(offer);
// 通过信令服务器发送 Offer 给对方
ws.send(JSON.stringify({
type: 'offer',
targetId: remotePeerId,
payload: localPeerConnection.localDescription
}));
};
ws.onmessage = async event => {
const data = JSON.parse(event.data);
if (data.type === 'yourId') {
console.log(`我的ID是: ${data.id}`);
// 假设这里会有一个UI让用户输入对方的ID
remotePeerId = prompt("请输入对方的ID进行通话:");
} else if (data.type === 'offer' && !localPeerConnection) {
// 如果我不是发起者,收到 Offer
// 我也要创建 PeerConnection
localPeerConnection = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
]
});
// ... 添加本地媒体流和事件监听器 (onicecandidate, ontrack) 略 ...
await localPeerConnection.setRemoteDescription(new RTCSessionDescription(data.payload));
const answer = await localPeerConnection.createAnswer();
await localPeerConnection.setLocalDescription(answer);
// 发送 Answer 给发起者
ws.send(JSON.stringify({
type: 'answer',
targetId: data.senderId,
payload: localPeerConnection.localDescription
}));
} else if (data.type === 'answer' && localPeerConnection) {
// 发起者收到 Answer
await localPeerConnection.setRemoteDescription(new RTCSessionDescription(data.payload));
} else if (data.type === 'iceCandidate' && localPeerConnection) {
// 收到对方的 ICE 候选者
await localPeerConnection.addIceCandidate(new RTCIceCandidate(data.payload));
}
};
代码解析:
RTCPeerConnection是 WebRTC 的核心!它管理着整个 P2P 连接。createOffer()和createAnswer()用来生成 SDP。setLocalDescription()和setRemoteDescription()用来设置本地和远程的 SDP。onicecandidate事件会在浏览器发现它可能的“地址”(ICE Candidate)时触发,我们需要把这些地址通过信令服务器发给对方。addIceCandidate()用来把对方的地址加进来。
第三关:我是谁?我在哪?(STUN 服务器)
为了知道自己的“真实公网地址”,你需要一面镜子。这个镜子就是 STUN 服务器。
STUN 的工作原理极简:
- 你(从局域网内部)往 STUN 服务器发个包:“哥们,麻烦看一眼,我现在的脸(公网IP:端口)长啥样?”
- STUN 回复:“你现在的公网地址是 203.0.113.1:50000。”
好!现在你知道你在互联网上的“真实身份”了。你赶紧把这个地址通过刚才的信令服务器发给女神。女神也做同样的操作,把她的公网地址发给你。
在 WebRTC 中,STUN 服务器的地址配置在 RTCPeerConnection 的 iceServers 参数里:
// 在创建 PeerConnection 时指定 STUN 服务器
const localPeerConnection = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }, // 这就是谷歌提供的公共 STUN 服务器
// { urls: 'stun:stun1.l.google.com:19302' } // 可以配置多个
]
});
代码解析:
iceServers 是一个数组,里面可以包含 STUN 和 TURN 服务器的地址。当 RTCPeerConnection 启动后,它会自动与这些服务器通信,收集本地网络环境的各种可能连接信息(包括公网 IP 和端口)。这些信息就是 ICE Candidate 的一部分。
第四关:打洞(Hole Punching)与 ICE 协议
即便互相知道了公网 IP,路由器(防火墙)通常是个暴脾气:“凡是内部没人主动请求过的外部 IP,谁敢没头没脑地给我发数据,一律拦截!”
如果女神直接给你发数据,你的路由器会说:“这是谁?没见过,滚!”
这时候,WebRTC 的大管家 ICE (Interactive Connectivity Establishment) 登场了。ICE 是个极其执着的“老好人”,它的任务就是穷举所有可能的连接路径。
ICE 会指挥浏览器进行 “打洞” (Hole Punching) 操作:
- 你硬着头皮给女神的公网 IP 发个包(哪怕知道会被丢弃)。这会在你的路由器上留下一条记录:“我刚给这个地址发过信,它是熟人。”
- 女神也同时给你发个包。
- 当你的路由器看到女神发来的包时,查了下记录:“哎哟,这小子刚才联系过这个地址,放行!”
- Bingo!通道建立!
从此以后,你们俩的数据就直接点对点传输,不再需要服务器转发。这就是P2P 直连!画质清晰,延迟极低,不仅省了服务器带宽,还能偷偷说悄悄话。
在代码中,ICE 的整个过程是自动化进行的:
当你通过信令服务器交换了所有的 ICE Candidate 后,RTCPeerConnection 内部的 ICE 引擎就会开始工作。
// 当 PeerConnection 收到对方的 ICE 候选者时,调用 addIceCandidate
// ICE 引擎会自动尝试所有可能的连接组合,并进行打洞
localPeerConnection.addIceCandidate(new RTCIceCandidate(data.payload));
代码解析:
ICE 引擎会把所有收集到的本地 ICE Candidate 和对方的 ICE Candidate 进行配对。它会尝试各种组合,比如:
- 本地局域网 IP 对 对方局域网 IP
- 本地公网 IP 对 对方公网 IP
- 本地局域网 IP 对 对方公网 IP
- …等等
直到找到一个能够成功建立连接的路径。这个过程叫做 Candidate Gathering 和 Connectivity Checks。一旦找到可行的路径,RTCPeerConnection 就会进入 connected 状态。
隐藏关卡:实在连不上咋办?(TURN 服务器)
现实总是残酷的。有些路由器(也就是对称型 NAT,或者企业级防火墙)管得特别严:“想打洞?门都没有!IP 和端口变来变去的,我根本不认。”
这时候,ICE 尝试直连失败,就会祭出最后的大招:TURN 服务器。
TURN 是个中继站。
- 你把数据发给 TURN。
- TURN 把数据转给女神。
- 女神把数据发给 TURN。
- TURN 转给你。
这不又变回服务器转发了吗?
没错!这是 WebRTC 的保底方案。虽然延迟高了点,带宽贵了点,但总比连不上强。据统计,大概有 10%-20% 的连接需要走到这一步。
在代码中,TURN 服务器同样配置在 iceServers 里:
const localPeerConnection = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
// 这是一个 TURN 服务器的配置示例,通常需要认证
{
urls: 'turn:YOUR_TURN_SERVER_IP:3478',
username: 'your_turn_username',
credential: 'your_turn_password'
}
]
});
代码解析:
当 ICE 尝试所有 STUN 和直连方案都失败后,它会尝试使用 TURN 服务器。数据流会先发送到 TURN 服务器,然后由 TURN 服务器转发给对端。对浏览器来说,这个过程是透明的,它只知道通过 RTCPeerConnection 发送和接收数据。
总结一下
WebRTC 之所以能让浏览器“端到端”直连,全靠这套组合拳:
- Signaling(信令): 媒婆牵线,交换意向(SDP)。(你需要自己实现一个服务器来做这个)
RTCPeerConnection的初始化: 实例化它,准备开始“牵手”。- STUN 服务器: 照镜子,查出自己的公网 IP。 (配置在
iceServers里) - ICE & Hole Punching:
RTCPeerConnection内部自动执行,连哄带骗搞定路由器,打通 P2P 隧道。 (通过onicecandidate交换ICE Candidate,addIceCandidate接收) - TURN 服务器: 实在不行就找个替身(中继)传话。(配置在
iceServers里作为备选)
这一套操作下来,浏览器终于翻越了 NAT 的高墙,实现了自由的灵魂伴侣级(Peer-to-Peer)连接。
下次你在这个网页上流畅地开视频会时,别忘了背后有一群 STUN 和 ICE 协议正在疯狂地为你打洞哦!✌️







