Skip to main content

WebRTC 全景实战 (3):信令服务器设计与会话状态机

Rainy
雨落无声,代码成诗 —— 致力于技术与艺术的极致平衡
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)控制通道
SDPSession Description Protocol描述会话媒体能力(编解码器、ICE 参数、DTLS 指纹)的文本格式,见 Ch4
Offer/AnswerJSEP 模型下的 SDP 协商:一方 Offer,另一方 Answer
ICE Candidate一个可用的网络地址(IP:Port + 类型),供 ICE 连通性检查使用
Room房间逻辑上的会话容器,同一 Room 内的参与者交换媒体
Peer对等端Room 内的一个参与者实例,通常对应一个浏览器 Tab 或一个设备
Trickle ICE边收集 ICE Candidate 边通过信令发送,而非等全部收集完
控制面Control Plane信令、鉴权、Room 管理——不承载媒体
数据面Data PlaneSRTP 媒体流、SCTP Data Channel——走 UDP,不经过信令服务器
WSSWebSocket SecureTLS 加密的 WebSocket,生产信令必须使用
JWTJSON Web Token常用于编码 Room 权限、Identity、过期时间的鉴权令牌

一、为什么 WebRTC 不内置信令?

WebRTC 的设计哲学是 「Bring Your Own Signaling」

  1. 灵活性:1v1 通话只需转发 Offer/Answer;大型会议需要 Room 状态、权限、录制控制——需求差异太大
  2. 避免重复:SIP、XMPP、MQTT 等已有成熟信令协议,WebRTC 不应重复造轮子
  3. 解耦:媒体走 UDP P2P 或 SFU,信令走 TCP WebSocket——路径、扩容策略完全不同
关键认知

信令服务器永远看不到 SRTP 媒体内容(除非你是 SFU)。它只传递 SDP 文本和 ICE Candidate JSON。媒体带宽不经过信令服务器。


二、信令消息协议设计

2.1 最小消息集(1v1 P2P)

消息类型方向载荷触发时机
joinC→S{ roomId, identity }用户进入 Room
joinedS→C{ peerId, peers[] }加入成功,返回已有 peer
offerC→S→C{ sdp, from, to? }createOffer + setLocalDescription
answerC→S→C{ sdp, from, to }收到 Offer 并 createAnswer
candidateC→S→C{ candidate, from, to? }onicecandidate 回调
peer-joinedS→C{ peerId }新 peer 加入 Room
peer-leftS→C{ peerId }peer 断开连接
leaveC→S用户主动离开

2.2 完整交互时序(1v1)

2.3 多人会议的消息扩展

当 Room 内 N > 2 时,P2P Mesh 需要 N×(N-1)/2 条 PeerConnection。信令消息需扩展:

额外消息用途
publish通知「我要发送媒体」,触发向所有其他 peer 发 Offer
subscribeSFU 模式下请求订阅某 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);
}
字段类型说明
roomIdstring房间唯一标识,如 UUID 或用户自定义 slug
peerIdstring服务端分配的连接 ID(每次连接不同)
identitystring业务层用户标识(同一用户重连可相同)
wsWebSocket活跃连接句柄
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 校验防止冒充他人 IdentityJWT sub 字段绑定

LiveKit Token 示例结构:

{
"sub": "user-123",
"video": {
"roomJoin": true,
"room": "my-room",
"canPublish": true,
"canSubscribe": true
},
"exp": 1718000000
}

七、常见问题与排查

现象可能原因排查
Offer 发出无 Answer对端未监听 onOffer检查信令消息日志
ICE 永远 checkingCandidate 未转发确认 onicecandidate → 信令 → addIceCandidate
重复连接重连未清理旧 peerId服务端 close 事件删除 peer
跨 Room 串流roomId 路由错误日志打印 roomId + peerId
信令通但无媒体媒体不走信令查 ICE/DTLS 状态,非信令问题

八、实战 Lab

  1. 启动 signaling/server.js,两个 Tab 加入同一 Room
  2. 在服务端打印所有消息,观察 Offer → Answer → Candidate 顺序
  3. 故意断开一个 Tab 的 WebSocket,观察 peer-left 事件
  4. 对比:手工信令(Ch2)vs WebSocket 信令的时序差异
  5. (进阶)为 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信令服务器设计与会话状态机✅ 已发布
4SDP 解剖与媒体协商✅ 已发布
5ICE、STUN、TURN 与 NAT 穿透✅ 已发布
6Data Channel 与 SCTP over DTLS✅ 已发布
7DTLS 握手与 SRTP 加密体系✅ 已发布
8RTP/RTCP 媒体传输与 QoS✅ 已发布
9音视频编解码与 Simulcast 入门✅ 已发布
10带宽估计与拥塞控制 GCC✅ 已发布
11Simulcast、SVC 与选择性订阅✅ 已发布
12SFU/MCU/Mesh 架构与 Pion 实战✅ 已发布
13调试工具链与可观测性✅ 已发布
14TURN 集群部署与多区域扩展✅ 已发布
15Capstone 生产级视频会议系统✅ 已发布

References

Logo
RainLib

Exploring the frontiers of technology, design, and distributed systems. Building tools for the future developers.

Suggestions & Feedback

© 2026 RainLib. Built for the Future.
All rights reserved.
System Normal