WebRTC 全景实战 (3):信令服务器设计与会话状态机
Rainy
雨落无声,代码成诗 —— 致力于技术与艺术的极致平衡
11 MIN READ•... VIEWS
"WebRTC 只标准化媒体通道,信令 deliberately 留给应用层。" — WebRTC for the Curious
Ch2 我们用手工复制 JSON 完成了 Offer/Answer 和 ICE Candidate 交换——这足以理解原理,但无法支撑生产。Serge Lachapelle 在 Curious 历史访谈中明确提到:IETF 刻意不对信令重新标准化,因为 SIP 等方案已存在,重新标准化只会引发政治斗争且「不会创造有价值贡献」。
因此,每一个 WebRTC 应用都必须实现自己的信令层。本章从设计原则出发,构建一套可扩展的 WebSocket 信令服务器,并讨论生产环境的安全、重连与 Room 状态管理。
配套代码:examples/webrtc-lab/signaling/
本篇术语表
| 术语 | 英文 | 解释 |
|---|---|---|
| 信令 | Signaling | 在 PeerConnection 建立之前/之中,交换 SDP、ICE Candidate、Room 元数据的带外(Out-of-band)控制通道 |
| SDP | Session Description Protocol | 描述会话媒体能力(编解码器、ICE 参数、DTLS 指纹)的文本格式,见 Ch4 |
| Offer/Answer | — | JSEP 模型下的 SDP 协商:一方 Offer,另一方 Answer |
| ICE Candidate | — | 一个可用的网络地址(IP:Port + 类型),供 ICE 连通性检查使用 |
| Room | 房间 | 逻辑上的会话容器,同一 Room 内的参与者交换媒体 |
| Peer | 对等端 | Room 内的一个参与者实例,通常对应一个浏览器 Tab 或一个设备 |
| Trickle ICE | — | 边收集 ICE Candidate 边通过信令发送,而非等全部收集完 |
| 控制面 | Control Plane | 信令、鉴权、Room 管理——不承载媒体 |
| 数据面 | Data Plane | SRTP 媒体流、SCTP Data Channel——走 UDP,不经过信令服务器 |
| WSS | WebSocket Secure | TLS 加密的 WebSocket,生产信令必须使用 |
| JWT | JSON Web Token | 常用于编码 Room 权限、Identity、过期时间的鉴权令牌 |
一、为什么 WebRTC 不内置信令?
WebRTC 的设计哲学是 「Bring Your Own Signaling」:
- 灵活性:1v1 通话只需转发 Offer/Answer;大型会议需要 Room 状态、权限、录制控制——需求差异太大
- 避免重复:SIP、XMPP、MQTT 等已有成熟信令协议,WebRTC 不应重复造轮子
- 解耦:媒体走 UDP P2P 或 SFU,信令走 TCP WebSocket——路径、扩容策略完全不同
关键认知
信令服务器永远看不到 SRTP 媒体内容(除非你是 SFU)。它只传递 SDP 文本和 ICE Candidate JSON。媒体带宽不经过信令服务器。
二、信令消息协议设计
2.1 最小消息集(1v1 P2P)
| 消息类型 | 方向 | 载荷 | 触发时机 |
|---|---|---|---|
join | C→S | { roomId, identity } | 用户进入 Room |
joined | S→C | { peerId, peers[] } | 加入成功,返回已有 peer |
offer | C→S→C | { sdp, from, to? } | createOffer + setLocalDescription 后 |
answer | C→S→C | { sdp, from, to } | 收到 Offer 并 createAnswer 后 |
candidate | C→S→C | { candidate, from, to? } | onicecandidate 回调 |
peer-joined | S→C | { peerId } | 新 peer 加入 Room |
peer-left | S→C | { peerId } | peer 断开连接 |
leave | C→S | — | 用户主动离开 |
2.2 完整交互时序(1v1)
2.3 多人会议的消息扩展
当 Room 内 N > 2 时,P2P Mesh 需要 N×(N-1)/2 条 PeerConnection。信令消息需扩展:
| 额外消息 | 用途 |
|---|---|
publish | 通知「我要发送媒体」,触发向所有其他 peer 发 Offer |
subscribe | SFU 模式下请求订阅某 Track(Ch12) |
mute / unmute | 通知静音状态(可选,也可仅本地处理) |
kick | 管理员踢人(服务端下发 disconnect) |
生产环境更推荐使用 SFU(如 LiveKit),此时每个客户端只需 1 条 PeerConnection 到 SFU,信令大幅简化——LiveKit 内置 Room 管理,你的信令层主要负责 Token 下发。
三、Room 与会话状态机
3.1 单个 Peer 的状态机
3.2 服务端 Room 数据结构
/**
* Room 内存模型(生产环境应持久化到 Redis)
* roomId -> Map<peerId, { ws, identity, joinedAt }>
*/
const rooms = new Map();
function getRoom(roomId) {
if (!rooms.has(roomId)) rooms.set(roomId, new Map());
return rooms.get(roomId);
}
| 字段 | 类型 | 说明 |
|---|---|---|
roomId | string | 房间唯一标识,如 UUID 或用户自定义 slug |
peerId | string | 服务端分配的连接 ID(每次连接不同) |
identity | string | 业务层用户标识(同一用户重连可相同) |
ws | WebSocket | 活跃连接句柄 |
Identity vs PeerId
- Identity:业务语义,如
"alice@company.com",可重复(多端登录) - PeerId:连接语义,如 UUID,每次 WebSocket 连接唯一
混淆两者会导致重连时无法正确清理旧连接。
四、WebSocket 信令服务器完整实现
4.1 服务端核心代码
// examples/webrtc-lab/signaling/server.js
import { WebSocketServer } from "ws";
import { randomUUID } from "crypto";
const PORT = process.env.PORT || 8080;
const rooms = new Map();
function getRoom(roomId) {
if (!rooms.has(roomId)) rooms.set(roomId, new Map());
return rooms.get(roomId);
}
function broadcast(roomId, senderId, payload) {
const room = getRoom(roomId);
for (const [peerId, peer] of room) {
if (peerId !== senderId && peer.ws.readyState === peer.ws.OPEN) {
peer.ws.send(JSON.stringify({ ...payload, from: senderId }));
}
}
}
function sendTo(roomId, targetPeerId, payload) {
const peer = getRoom(roomId).get(targetPeerId);
if (peer?.ws.readyState === peer.ws.OPEN) {
peer.ws.send(JSON.stringify(payload));
}
}
const wss = new WebSocketServer({ port: PORT });
wss.on("connection", (ws) => {
const peerId = randomUUID();
let roomId = null;
let identity = null;
ws.on("message", (raw) => {
let msg;
try {
msg = JSON.parse(raw.toString());
} catch {
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
return;
}
switch (msg.type) {
case "join": {
roomId = msg.roomId || "default";
identity = msg.identity || peerId;
const room = getRoom(roomId);
room.set(peerId, { ws, identity, joinedAt: Date.now() });
const peers = [...room.entries()]
.filter(([id]) => id !== peerId)
.map(([id, p]) => ({ peerId: id, identity: p.identity }));
ws.send(JSON.stringify({ type: "joined", peerId, roomId, identity, peers }));
broadcast(roomId, peerId, { type: "peer-joined", peerId, identity });
break;
}
case "offer":
case "answer":
case "candidate": {
if (!roomId) return;
if (msg.to) {
sendTo(roomId, msg.to, { ...msg, from: peerId });
} else {
broadcast(roomId, peerId, msg);
}
break;
}
case "leave": {
if (roomId) {
getRoom(roomId).delete(peerId);
broadcast(roomId, peerId, { type: "peer-left", peerId, identity });
}
break;
}
default:
ws.send(JSON.stringify({ type: "error", message: `Unknown type: ${msg.type}` }));
}
});
ws.on("close", () => {
if (roomId) {
getRoom(roomId).delete(peerId);
broadcast(roomId, peerId, { type: "peer-left", peerId, identity });
}
});
});
console.log(`Signaling server: ws://localhost:${PORT}`);
4.2 启动与验证
cd examples/webrtc-lab/signaling
npm install && npm start
# 输出: Signaling server: ws://localhost:8080
五、客户端信令集成
5.1 信令管理类
class SignalingClient {
constructor(url, roomId, identity) {
this.url = url;
this.roomId = roomId;
this.identity = identity;
this.peerId = null;
this.peers = new Map();
this.ws = null;
this.onOffer = null;
this.onAnswer = null;
this.onCandidate = null;
this.onPeerJoined = null;
this.onPeerLeft = null;
}
connect() {
return new Promise((resolve, reject) => {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.send({ type: "join", roomId: this.roomId, identity: this.identity });
};
this.ws.onmessage = (e) => this.handleMessage(JSON.parse(e.data));
this.ws.onerror = reject;
this.ws.onclose = () => this.reconnect();
this._resolveConnect = resolve;
});
}
handleMessage(msg) {
switch (msg.type) {
case "joined":
this.peerId = msg.peerId;
msg.peers.forEach((p) => this.peers.set(p.peerId, p));
this._resolveConnect?.();
break;
case "peer-joined":
this.peers.set(msg.peerId, msg);
this.onPeerJoined?.(msg);
break;
case "peer-left":
this.peers.delete(msg.peerId);
this.onPeerLeft?.(msg);
break;
case "offer":
this.onOffer?.(msg);
break;
case "answer":
this.onAnswer?.(msg);
break;
case "candidate":
this.onCandidate?.(msg);
break;
}
}
send(msg) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
}
}
sendOffer(targetPeerId, sdp) {
this.send({ type: "offer", sdp, to: targetPeerId });
}
sendAnswer(targetPeerId, sdp) {
this.send({ type: "answer", sdp, to: targetPeerId });
}
sendCandidate(targetPeerId, candidate) {
this.send({ type: "candidate", candidate, to: targetPeerId });
}
reconnect() {
setTimeout(() => this.connect(), 2000);
}
}
5.2 与 RTCPeerConnection 绑定
const sig = new SignalingClient("ws://localhost:8080", "demo-room", "alice");
await sig.connect();
const pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] });
// 媒体
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
stream.getTracks().forEach((t) => pc.addTrack(t, stream));
pc.ontrack = (e) => { remoteVideo.srcObject = e.streams[0]; };
// ICE → 信令
pc.onicecandidate = (e) => {
if (e.candidate) sig.sendCandidate(remotePeerId, e.candidate);
};
// 信令 → PeerConnection
sig.onOffer = async (msg) => {
await pc.setRemoteDescription(msg.sdp);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
sig.sendAnswer(msg.from, pc.localDescription);
};
sig.onAnswer = async (msg) => {
await pc.setRemoteDescription(msg.sdp);
};
sig.onCandidate = async (msg) => {
await pc.addIceCandidate(msg.candidate);
};
// 新 peer 加入 → 发起 Offer
sig.onPeerJoined = async (msg) => {
remotePeerId = msg.peerId;
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
sig.sendOffer(msg.peerId, pc.localDescription);
};
六、生产级安全设计
| 机制 | 说明 | 参考 |
|---|---|---|
| WSS | 信令必须 TLS,防 SDP/ICE 被窃听 | Let's Encrypt |
| JWT 鉴权 | 连接时 ?token=xxx,服务端验证 Room 权限 | LiveKit Token |
| TURN 短期凭证 | 通过 API 下发,防 TURN 被滥用 | RFC 8656 REST API |
| Room 隔离 | 不同 Room 消息不可互串 | 服务端路由校验 |
| Rate Limiting | 防 Candidate 洪水攻击 | 每 peer 每秒 ≤50 条 |
| Identity 校验 | 防止冒充他人 Identity | JWT sub 字段绑定 |
LiveKit Token 示例结构:
{
"sub": "user-123",
"video": {
"roomJoin": true,
"room": "my-room",
"canPublish": true,
"canSubscribe": true
},
"exp": 1718000000
}
七、常见问题与排查
| 现象 | 可能原因 | 排查 |
|---|---|---|
| Offer 发出无 Answer | 对端未监听 onOffer | 检查信令消息日志 |
| ICE 永远 checking | Candidate 未转发 | 确认 onicecandidate → 信令 → addIceCandidate |
| 重复连接 | 重连未清理旧 peerId | 服务端 close 事件删除 peer |
| 跨 Room 串流 | roomId 路由错误 | 日志打印 roomId + peerId |
| 信令通但无媒体 | 媒体不走信令 | 查 ICE/DTLS 状态,非信令问题 |
八、实战 Lab
- 启动
signaling/server.js,两个 Tab 加入同一 Room - 在服务端打印所有消息,观察 Offer → Answer → Candidate 顺序
- 故意断开一个 Tab 的 WebSocket,观察
peer-left事件 - 对比:手工信令(Ch2)vs WebSocket 信令的时序差异
- (进阶)为
join增加 JWT 验证中间件
九、本章小结与下一篇预告
| 要点 | 内容 |
|---|---|
| 信令定位 | WebRTC 标准外,应用自定义控制面 |
| 核心消息 | join / offer / answer / candidate |
| 传输 | WebSocket(生产用 WSS) |
| 安全 | JWT + TURN 短期凭证 + Room 隔离 |
| 生产选型 | 小规模自建;大规模用 LiveKit 内置 Room |
下一篇(Ch4) 深入 Offer/Answer 里的 SDP 文本结构:SDP 解剖与媒体协商。
系列导航
章节 主题 状态 0 架构全景与协议栈地图 ✅ 已发布 1 浏览器媒体 API 与设备管理 ✅ 已发布 2 第一个 P2P 视频通话 ✅ 已发布 3 信令服务器设计与会话状态机 ✅ 已发布 4 SDP 解剖与媒体协商 ✅ 已发布 5 ICE、STUN、TURN 与 NAT 穿透 ✅ 已发布 6 Data Channel 与 SCTP over DTLS ✅ 已发布 7 DTLS 握手与 SRTP 加密体系 ✅ 已发布 8 RTP/RTCP 媒体传输与 QoS ✅ 已发布 9 音视频编解码与 Simulcast 入门 ✅ 已发布 10 带宽估计与拥塞控制 GCC ✅ 已发布 11 Simulcast、SVC 与选择性订阅 ✅ 已发布 12 SFU/MCU/Mesh 架构与 Pion 实战 ✅ 已发布 13 调试工具链与可观测性 ✅ 已发布 14 TURN 集群部署与多区域扩展 ✅ 已发布 15 Capstone 生产级视频会议系统 ✅ 已发布