8. 实现 P2P 文本聊天室:结合信令服务器、ICE 交换、数据通道,完成一个双人聊天 Demo
好,咱们终于走到这一步了。前面几章我们把信令、ICE、数据通道拆开讲了一遍,现在该把它们串起来了。这一章的目标很明确——做一个能跑起来的双人聊天 Demo。
说白了,就是两个人打开浏览器,互相发消息。没有服务器中转,消息直接点对点传输。我当年第一次跑通这个 Demo 的时候,还挺激动的。你想想看,数据真的没经过任何中间服务器,直接从我的浏览器飞到对方的浏览器里,这种感觉很奇妙。
整体架构:三个模块怎么配合
先理一下思路。一个完整的 P2P 聊天室,需要三个核心模块协同工作:
- 信令服务器:负责交换 SDP 和 ICE 候选者。它不传聊天消息,只做“牵线搭桥”。
- ICE 交换:让双方找到最优的网络路径。说白了就是“你俩怎么连上最快”。
- 数据通道:连接建立后,真正的聊天数据走这里。
我画了一张图,帮你把这三者的关系理清楚:
嗯,这张图你看明白了吗?信令服务器在中间只做“介绍人”,一旦双方握手完成,数据通道就直连了。这时候哪怕信令服务器挂了,你们俩还能继续聊天。
信令服务器的实现
先写信令服务器。我用 Node.js + WebSocket 来实现,轻量又好用。我个人习惯用 ws 这个库,没那么多花里胡哨的东西。
// server.js
const WebSocket = require('ws');
const server = new WebSocket.Server({ port: 8080 });
const clients = new Map();
server.on('connection', (ws) => {
const id = generateId();
clients.set(id, ws);
// 告诉客户端自己的 ID
ws.send(JSON.stringify({ type: 'assign-id', id }));
ws.on('message', (data) => {
const msg = JSON.parse(data);
switch(msg.type) {
case 'offer':
case 'answer':
case 'ice-candidate':
// 转发给目标用户
const target = clients.get(msg.targetId);
if (target) {
target.send(JSON.stringify({
type: msg.type,
data: msg.data,
fromId: id
}));
}
break;
}
});
ws.on('close', () => {
clients.delete(id);
});
});
function generateId() {
return Math.random().toString(36).substr(2, 8);
}
这段代码很简单,就是维护一个客户端列表,收到消息就转发。注意我没有做房间管理,因为双人聊天不需要。如果你以后要做多人聊天室,可以加个房间概念。
客户端的核心逻辑
客户端这边,我分三步走:
- 连接信令服务器,拿到自己的 ID
- 创建 RTCPeerConnection,设置 ICE 候选者处理
- 创建数据通道,绑定消息事件
先看连接信令的部分:
// 客户端代码片段
const ws = new WebSocket('ws://localhost:8080');
let localId = null;
let remoteId = null;
let pc = null;
let dataChannel = null;
ws.onmessage = async (event) => {
const msg = JSON.parse(event.data);
switch(msg.type) {
case 'assign-id':
localId = msg.id;
console.log('我的 ID:', localId);
break;
case 'offer':
// 收到对方的 Offer,创建 Answer
remoteId = msg.fromId;
await handleOffer(msg.data);
break;
case 'answer':
await handleAnswer(msg.data);
break;
case 'ice-candidate':
await pc.addIceCandidate(new RTCIceCandidate(msg.data));
break;
}
};
这里有个细节我踩过坑——ICE 候选者的处理时机。我曾经在 Offer 还没设置的时候就添加 ICE 候选者,结果浏览器直接报错。正确的做法是:先设置远程 SDP,再添加 ICE 候选者。
创建 PeerConnection 和数据通道
接下来是核心的 RTCPeerConnection 创建:
function createPeerConnection() {
const config = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
};
pc = new RTCPeerConnection(config);
// ICE 候选者收集完成后,发送给信令服务器
pc.onicecandidate = (event) => {
if (event.candidate) {
ws.send(JSON.stringify({
type: 'ice-candidate',
targetId: remoteId,
data: event.candidate
}));
}
};
// 数据通道创建
dataChannel = pc.createDataChannel('chat', {
ordered: true
});
setupDataChannel();
return pc;
}
注意 createDataChannel 的第二个参数。我一般设置 ordered: true,保证消息顺序不乱。如果你做文件传输,可以考虑 ordered: false 配合 maxRetransmits 来提升性能。
数据通道的事件绑定
数据通道的事件处理,我习惯封装成一个函数:
function setupDataChannel() {
dataChannel.onopen = () => {
console.log('数据通道已打开');
document.getElementById('status').textContent = '已连接';
};
dataChannel.onclose = () => {
console.log('数据通道已关闭');
document.getElementById('status').textContent = '已断开';
};
dataChannel.onmessage = (event) => {
const msg = event.data;
appendMessage('对方', msg);
};
}
function sendMessage(text) {
if (dataChannel && dataChannel.readyState === 'open') {
dataChannel.send(text);
appendMessage('我', text);
} else {
alert('连接尚未建立,请稍后再试');
}
}
完整的连接流程
把上面这些拼起来,完整的连接流程是这样的:
| 步骤 | 用户 A | 信令服务器 | 用户 B |
|---|---|---|---|
| 1 | 连接 WebSocket | 分配 ID | 连接 WebSocket |
| 2 | 创建 Offer | 转发 Offer | 接收 Offer |
| 3 | 设置本地 SDP | — | 设置远程 SDP |
| 4 | — | 转发 Answer | 创建 Answer |
| 5 | 设置远程 SDP | — | 设置本地 SDP |
| 6 | 交换 ICE 候选者 | 双向转发 | 交换 ICE 候选者 |
| 7 | ✅ 数据通道建立,开始聊天 | ||
你看,其实就这几步。我刚开始学的时候总觉得 WebRTC 很复杂,拆开来看,每一步都很清晰。
启动聊天的入口
最后,用户怎么发起聊天呢?我设计了一个简单的流程:
- 用户 A 输入用户 B 的 ID,点击“发起连接”
- 用户 B 收到 Offer 后,自动创建 Answer
- 双方 ICE 候选者交换完成后,数据通道自动打开
代码实现:
// 用户 A 发起连接
async function startConnection(targetId) {
remoteId = targetId;
await createPeerConnection();
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
ws.send(JSON.stringify({
type: 'offer',
targetId: remoteId,
data: offer
}));
}
// 用户 B 处理 Offer
async function handleOffer(offer) {
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
ws.send(JSON.stringify({
type: 'answer',
targetId: remoteId,
data: answer
}));
}
好了,到这里一个完整的 P2P 聊天室就实现了。你可以在本地跑起来试试,打开两个浏览器窗口,互相发几条消息感受一下。我第一次跑通的时候,特意把 Wi-Fi 断掉,发现消息还能发——因为数据通道走的是局域网直连,根本不需要互联网。
这就是 P2P 的魅力。下一章我们会在这个基础上,加入更多实用功能。
公众号:蓝海资料掘金营,微信deep3321