信令是什么?为什么 WebRTC 需要信令?
本文是 WebRTC 系列专栏的第六篇,也是第二部分"信令与会话管理"的开篇。我们将深入探讨信令的概念、作用以及如何设计自己的信令服务器。
目录
- 什么是信令
- 信令不是 WebRTC 标准的一部分
- 信令传输的内容
- 信令传输方式
- 信令服务器设计
- 信令服务器实现示例
- 总结
1. 什么是信令
1.1 信令的定义
信令(Signaling)是指在建立实时通信会话之前,通信双方交换控制信息的过程。这些控制信息包括:
- 会话的发起和终止
- 媒体能力的协商(编解码器、分辨率等)
- 网络连接信息的交换(IP地址、端口等)
- 会话状态的同步
在 WebRTC 中,信令是建立点对点连接的前置步骤。没有信令,两个浏览器无法知道对方的存在,更无法建立连接。
1.2 信令在通信中的位置
┌─────────────────────────────────────────────────────────────────────────┐ │ WebRTC 通信建立过程 │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ 阶段一: 信令阶段 │ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ │ │ │ │ │ 用户 A 信令服务器 用户 B │ │ │ │ │ │ │ │ │ │ │ │ 1. 发送 Offer ────> │ │ │ │ │ │ │ │ 2. 转发 Offer ────> │ │ │ │ │ │ │ │ │ │ │ │ │ │ <──── 3. 发送 Answer │ │ │ │ │ │ <──── 4. 转发 Answer │ │ │ │ │ │ │ │ │ │ │ │ │ │ <════ 5. ICE 候选交换 ════> │ │ │ │ │ │ │ │ │ │ │ └─────────────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ 阶段二: 媒体传输阶段 │ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ │ │ │ │ │ 用户 A <═══════════ P2P 连接 ═══════════> 用户 B │ │ │ │ (音视频数据直接传输,不经过服务器) │ │ │ │ │ │ │ └─────────────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────┘1.3 信令与媒体传输的区别
| 特性 | 信令 | 媒体传输 |
|---|---|---|
| 传输内容 | 控制信息(SDP、ICE候选) | 音视频数据 |
| 传输方式 | 通过服务器中转 | 点对点直连(P2P) |
| 协议 | 自定义(WebSocket/HTTP等) | RTP/SRTP |
| 数据量 | 小(几KB) | 大(持续流) |
| 时效性 | 建立连接时使用 | 连接建立后持续使用 |
2. 信令不是 WebRTC 标准的一部分
2.1 为什么 WebRTC 不定义信令协议
WebRTC 标准(W3C 和 IETF)故意不定义信令协议,原因如下:
灵活性考虑
不同的应用场景有不同的需求:
- 视频会议可能需要房间管理、用户列表等功能
- 一对一通话可能只需要简单的呼叫/应答机制
- 直播场景可能需要与现有的直播系统集成
兼容性考虑
开发者可以:
- 复用现有的信令基础设施(如 SIP、XMPP)
- 与现有的业务系统集成
- 使用最适合自己技术栈的传输协议
安全性考虑
信令涉及用户身份验证、房间权限等业务逻辑,这些应该由应用层自行设计。
2.2 WebRTC 标准定义了什么
WebRTC 标准定义的是:
┌─────────────────────────────────────────────────────────────────────────┐ │ WebRTC 标准范围 │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ W3C 定义 (JavaScript API): │ │ ├── RTCPeerConnection │ │ ├── MediaStream / MediaStreamTrack │ │ ├── RTCDataChannel │ │ └── getUserMedia / getDisplayMedia │ │ │ │ IETF 定义 (底层协议): │ │ ├── ICE (RFC 8445) - 连接建立 │ │ ├── STUN (RFC 5389) - 地址发现 │ │ ├── TURN (RFC 5766) - 中继传输 │ │ ├── DTLS (RFC 6347) - 密钥交换 │ │ ├── SRTP (RFC 3711) - 媒体加密 │ │ └── SDP (RFC 4566) - 会话描述 │ │ │ │ 不在标准范围内: │ │ └── 信令协议 (由开发者自行设计) │ │ │ └─────────────────────────────────────────────────────────────────────────┘2.3 开发者需要自行实现的部分
作为开发者,你需要自行实现:
- 信令服务器: 负责转发信令消息
- 信令协议: 定义消息格式和交互流程
- 用户管理: 用户注册、登录、在线状态
- 房间管理: 房间创建、加入、离开
- 权限控制: 谁可以呼叫谁、谁可以加入房间
3. 信令传输的内容
3.1 SDP (Session Description Protocol)
SDP 是信令传输的核心内容,用于描述会话的媒体能力。
SDP 的作用
用户 A 的 SDP (Offer): 用户 B 的 SDP (Answer): ┌─────────────────────────┐ ┌─────────────────────────┐ │ 我支持的视频编码: │ │ 我支持的视频编码: │ │ - VP8 │ │ - VP8 │ │ - VP9 │ 协商结果 │ - H.264 │ │ - H.264 │ ──────────> │ │ │ │ │ 共同支持: VP8, H.264 │ │ 我支持的音频编码: │ │ │ │ - Opus │ │ 我支持的音频编码: │ │ - G.711 │ │ - Opus │ │ │ │ │ │ 我的网络信息: │ │ 我的网络信息: │ │ - IP: x.x.x.x │ │ - IP: y.y.y.y │ │ - Port: 12345 │ │ - Port: 54321 │ └─────────────────────────┘ └─────────────────────────┘SDP 示例
v=0 o=- 4611731400430051336 2 IN IP4 127.0.0.1 s=- t=0 0 a=group:BUNDLE 0 1 a=extmap-allow-mixed a=msid-semantic: WMS stream_id m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 110 112 113 126 c=IN IP4 0.0.0.0 a=rtcp:9 IN IP4 0.0.0.0 a=ice-ufrag:abcd a=ice-pwd:efghijklmnopqrstuvwxyz a=ice-options:trickle a=fingerprint:sha-256 AA:BB:CC:DD:EE:FF:... a=setup:actpass a=mid:0 a=sendrecv a=rtcp-mux a=rtpmap:111 opus/48000/2 a=fmtp:111 minptime=10;useinbandfec=1 m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 102 c=IN IP4 0.0.0.0 a=rtcp:9 IN IP4 0.0.0.0 a=ice-ufrag:abcd a=ice-pwd:efghijklmnopqrstuvwxyz a=fingerprint:sha-256 AA:BB:CC:DD:EE:FF:... a=setup:actpass a=mid:1 a=sendrecv a=rtcp-mux a=rtpmap:96 VP8/90000 a=rtpmap:98 VP9/90000 a=rtpmap:100 H264/900003.2 ICE Candidate
ICE Candidate 描述了可用于建立连接的网络路径。
ICE Candidate 的类型
| 类型 | 说明 | 示例 |
|---|---|---|
| host | 本地网络接口地址 | 192.168.1.100:54321 |
| srflx | 通过 STUN 发现的公网地址 | 203.0.113.1:12345 |
| prflx | 连通性检查中发现的地址 | 动态发现 |
| relay | TURN 服务器分配的中继地址 | 198.51.100.1:3478 |
ICE Candidate 示例
// ICE Candidate 对象{candidate:"candidate:842163049 1 udp 1677729535 192.168.1.100 54321 typ srflx raddr 10.0.0.1 rport 12345 generation 0 ufrag abcd network-cost 999",sdpMid:"0",sdpMLineIndex:0,usernameFragment:"abcd"}Candidate 字符串解析
candidate:842163049 1 udp 1677729535 192.168.1.100 54321 typ srflx raddr 10.0.0.1 rport 12345 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 相关端口 │ │ │ │ │ │ │ │ │ └─ 相关地址 │ │ │ │ │ │ │ │ └─ 候选类型 │ │ │ │ │ │ │ └─ 类型标识 │ │ │ │ │ │ └─ 端口 │ │ │ │ │ └─ IP 地址 │ │ │ │ └─ 优先级 │ │ │ └─ 协议 │ │ └─ 组件 ID (1=RTP, 2=RTCP) │ └─ 基础标识 └─ 候选标识3.3 信令消息类型
一个完整的信令系统通常需要处理以下消息类型:
┌─────────────────────────────────────────────────────────────────────────┐ │ 信令消息类型 │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ 会话控制消息: │ │ ├── offer - 发起方的会话描述 │ │ ├── answer - 接收方的会话描述 │ │ ├── candidate - ICE 候选 │ │ ├── hangup - 挂断通话 │ │ └── reject - 拒绝通话 │ │ │ │ 房间管理消息: │ │ ├── join - 加入房间 │ │ ├── leave - 离开房间 │ │ ├── user-list - 房间用户列表 │ │ └── user-state - 用户状态变化 │ │ │ │ 系统消息: │ │ ├── ping/pong - 心跳检测 │ │ ├── error - 错误通知 │ │ └── notify - 系统通知 │ │ │ └─────────────────────────────────────────────────────────────────────────┘4. 信令传输方式
4.1 WebSocket
WebSocket 是最常用的信令传输方式,提供全双工通信能力。
优点
- 实时性好,延迟低
- 双向通信,服务器可主动推送
- 连接持久,无需重复握手
- 协议开销小
缺点
- 需要维护长连接
- 连接断开需要重连机制
- 部分网络环境可能不支持
实现示例
// 客户端constsocket=newWebSocket('wss://signaling.example.com');socket.onopen=()=>{console.log('信令连接已建立');};socket.onmessage=(event)=>{constmessage=JSON.parse(event.data);handleSignalingMessage(message);};socket.send(JSON.stringify({type:'offer',sdp:offer.sdp,target:'user-123'}));4.2 Socket.IO
Socket.IO 是基于 WebSocket 的封装库,提供了更多高级功能。
优点
- 自动重连机制
- 房间和命名空间支持
- 降级支持(WebSocket 不可用时使用轮询)
- 事件驱动的 API
缺点
- 额外的协议开销
- 需要客户端和服务端都使用 Socket.IO
实现示例
// 客户端constsocket=io('https://signaling.example.com');socket.on('connect',()=>{socket.emit('join-room',{roomId:'room-123'});});socket.on('offer',(data)=>{handleOffer(data);});socket.emit('answer',{sdp:answer.sdp,target:'user-123'});4.3 HTTP 轮询
使用 HTTP 请求定期查询服务器获取新消息。
优点
- 实现简单
- 兼容性最好
- 无需长连接
缺点
- 延迟高
- 服务器压力大
- 效率低
实现示例
// 客户端asyncfunctionpollMessages(){while(true){constresponse=awaitfetch('/api/messages');constmessages=awaitresponse.json();for(constmessageofmessages){handleSignalingMessage(message);}awaitnewPromise(resolve=>setTimeout(resolve,1000));}}asyncfunctionsendMessage(message){awaitfetch('/api/messages',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(message)});}4.4 MQTT
MQTT 是一种轻量级的发布/订阅消息协议,适合 IoT 场景。
优点
- 协议轻量,适合低带宽环境
- 发布/订阅模式,易于扩展
- QoS 支持,消息可靠性保证
- 适合 IoT 设备
缺点
- 需要 MQTT Broker
- 学习成本较高
实现示例
// 客户端constclient=mqtt.connect('wss://mqtt.example.com');client.on('connect',()=>{client.subscribe('room/123/signaling');});client.on('message',(topic,message)=>{constdata=JSON.parse(message.toString());handleSignalingMessage(data);});client.publish('room/123/signaling',JSON.stringify({type:'offer',sdp:offer.sdp,from:'user-456'}));4.5 传输方式对比
| 特性 | WebSocket | Socket.IO | HTTP 轮询 | MQTT |
|---|---|---|---|---|
| 实时性 | 高 | 高 | 低 | 高 |
| 实现复杂度 | 中 | 低 | 低 | 中 |
| 兼容性 | 好 | 很好 | 最好 | 好 |
| 服务器压力 | 低 | 低 | 高 | 低 |
| 适用场景 | 通用 | 快速开发 | 简单场景 | IoT |
5. 信令服务器设计
5.1 架构设计
单服务器架构
适用于小规模应用:
┌─────────────────────────────────────────────────────────────────────────┐ │ 单服务器架构 │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────────┐ │ │ │ 信令服务器 │ │ │ │ │ │ │ │ ┌───────────┐ │ │ │ │ │ 连接管理 │ │ │ │ │ ├───────────┤ │ │ │ │ │ 房间管理 │ │ │ │ │ ├───────────┤ │ │ │ │ │ 消息路由 │ │ │ │ │ └───────────┘ │ │ │ │ │ │ │ └────────┬────────┘ │ │ │ │ │ ┌───────────────────┼───────────────────┐ │ │ │ │ │ │ │ ┌────┴────┐ ┌────┴────┐ ┌────┴────┐ │ │ │ 客户端 A │ │ 客户端 B │ │ 客户端 C │ │ │ └─────────┘ └─────────┘ └─────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────┘分布式架构
适用于大规模应用:
┌─────────────────────────────────────────────────────────────────────────┐ │ 分布式架构 │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────────┐ │ │ │ 负载均衡器 │ │ │ └────────┬────────┘ │ │ │ │ │ ┌───────────────────┼───────────────────┐ │ │ │ │ │ │ │ ┌────┴────┐ ┌────┴────┐ ┌────┴────┐ │ │ │ 信令节点1│ │ 信令节点2│ │ 信令节点3│ │ │ └────┬────┘ └────┬────┘ └────┬────┘ │ │ │ │ │ │ │ └───────────────────┼───────────────────┘ │ │ │ │ │ ┌────────┴────────┐ │ │ │ 消息队列 │ │ │ │ (Redis Pub/Sub) │ │ │ └─────────────────┘ │ │ │ │ 说明: 不同节点上的客户端通过消息队列实现跨节点通信 │ │ │ └─────────────────────────────────────────────────────────────────────────┘5.2 核心功能模块
┌─────────────────────────────────────────────────────────────────────────┐ │ 信令服务器核心模块 │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ 1. 连接管理模块 │ │ ├── 连接建立与断开处理 │ │ ├── 心跳检测与超时处理 │ │ └── 连接状态维护 │ │ │ │ 2. 用户管理模块 │ │ ├── 用户身份验证 │ │ ├── 用户在线状态 │ │ └── 用户信息存储 │ │ │ │ 3. 房间管理模块 │ │ ├── 房间创建与销毁 │ │ ├── 用户加入与离开 │ │ ├── 房间成员列表 │ │ └── 房间权限控制 │ │ │ │ 4. 消息路由模块 │ │ ├── 点对点消息转发 │ │ ├── 房间广播 │ │ └── 消息过滤与验证 │ │ │ │ 5. 安全模块 │ │ ├── 身份认证 (JWT/Token) │ │ ├── 消息加密 │ │ └── 防攻击保护 │ │ │ └─────────────────────────────────────────────────────────────────────────┘5.3 消息协议设计
消息格式
// 基础消息结构{"type":"offer",// 消息类型"from":"user-123",// 发送者 ID"to":"user-456",// 接收者 ID (可选)"room":"room-789",// 房间 ID (可选)"data":{// 消息数据"sdp":"v=0\r\n..."},"timestamp":1699123456789// 时间戳}消息类型定义
// 会话控制消息constMessageTypes={// Offer/AnswerOFFER:'offer',ANSWER:'answer',// ICECANDIDATE:'candidate',// 通话控制CALL:'call',ACCEPT:'accept',REJECT:'reject',HANGUP:'hangup',// 房间管理JOIN:'join',LEAVE:'leave',USER_JOINED:'user-joined',USER_LEFT:'user-left',// 系统消息ERROR:'error',PING:'ping',PONG:'pong'};6. 信令服务器实现示例
6.1 基于 WebSocket 的信令服务器
服务端代码 (Node.js)
constWebSocket=require('ws');consthttp=require('http');constserver=http.createServer();constwss=newWebSocket.Server({server});// 存储连接和房间信息constclients=newMap();// clientId -> WebSocketconstrooms=newMap();// roomId -> Set<clientId>// 生成唯一 IDfunctiongenerateId(){returnMath.random().toString(36).substring(2,15);}// 发送消息给指定客户端functionsendTo(clientId,message){constclient=clients.get(clientId);if(client&&client.readyState===WebSocket.OPEN){client.send(JSON.stringify(message));}}// 广播消息给房间内所有成员(排除发送者)functionbroadcastToRoom(roomId,message,excludeId){constroom=rooms.get(roomId);if(room){room.forEach(clientId=>{if(clientId!==excludeId){sendTo(clientId,message);}});}}// 处理连接wss.on('connection',(ws)=>{constclientId=generateId();clients.set(clientId,ws);console.log(`客户端连接:${clientId}`);// 发送欢迎消息sendTo(clientId,{type:'welcome',clientId:clientId});// 处理消息ws.on('message',(data)=>{try{constmessage=JSON.parse(data);handleMessage(clientId,message);}catch(error){console.error('消息解析错误:',error);}});// 处理断开ws.on('close',()=>{handleDisconnect(clientId);});// 处理错误ws.on('error',(error)=>{console.error(`客户端${clientId}错误:`,error);});});// 处理消息functionhandleMessage(clientId,message){console.log(`收到消息 [${clientId}]:`,message.type);switch(message.type){case'join':handleJoin(clientId,message);break;case'leave':handleLeave(clientId,message);break;case'offer':case'answer':case'candidate':handleSignaling(clientId,message);break;case'hangup':handleHangup(clientId,message);break;case'ping':sendTo(clientId,{type:'pong'});break;default:console.log('未知消息类型:',message.type);}}// 处理加入房间functionhandleJoin(clientId,message){const{roomId}=message;if(!rooms.has(roomId)){rooms.set(roomId,newSet());}constroom=rooms.get(roomId);// 获取房间内现有成员constexistingMembers=Array.from(room);// 加入房间room.add(clientId);// 通知新成员房间内的其他用户sendTo(clientId,{type:'room-joined',roomId:roomId,members:existingMembers});// 通知房间内其他成员有新用户加入broadcastToRoom(roomId,{type:'user-joined',clientId:clientId},clientId);console.log(`客户端${clientId}加入房间${roomId}`);}// 处理离开房间functionhandleLeave(clientId,message){const{roomId}=message;constroom=rooms.get(roomId);if(room){room.delete(clientId);// 通知房间内其他成员broadcastToRoom(roomId,{type:'user-left',clientId:clientId},clientId);// 如果房间为空,删除房间if(room.size===0){rooms.delete(roomId);}}console.log(`客户端${clientId}离开房间${roomId}`);}// 处理信令消息(offer/answer/candidate)functionhandleSignaling(clientId,message){const{to,roomId}=message;// 添加发送者信息message.from=clientId;if(to){// 点对点消息sendTo(to,message);}elseif(roomId){// 房间广播broadcastToRoom(roomId,message,clientId);}}// 处理挂断functionhandleHangup(clientId,message){const{to,roomId}=message;message.from=clientId;if(to){sendTo(to,message);}elseif(roomId){broadcastToRoom(roomId,message,clientId);}}// 处理断开连接functionhandleDisconnect(clientId){console.log(`客户端断开:${clientId}`);// 从所有房间中移除rooms.forEach((members,roomId)=>{if(members.has(clientId)){members.delete(clientId);// 通知房间内其他成员broadcastToRoom(roomId,{type:'user-left',clientId:clientId},clientId);// 如果房间为空,删除房间if(members.size===0){rooms.delete(roomId);}}});// 删除客户端clients.delete(clientId);}// 启动服务器constPORT=process.env.PORT||8080;server.listen(PORT,()=>{console.log(`信令服务器运行在端口${PORT}`);});客户端代码
classSignalingClient{constructor(serverUrl){this.serverUrl=serverUrl;this.socket=null;this.clientId=null;this.handlers=newMap();}// 连接服务器connect(){returnnewPromise((resolve,reject)=>{this.socket=newWebSocket(this.serverUrl);this.socket.onopen=()=>{console.log('信令连接已建立');};this.socket.onmessage=(event)=>{constmessage=JSON.parse(event.data);this.handleMessage(message);if(message.type==='welcome'){this.clientId=message.clientId;resolve(this.clientId);}};this.socket.onerror=(error)=>{reject(error);};this.socket.onclose=()=>{console.log('信令连接已断开');this.emit('disconnected');};});}// 发送消息send(message){if(this.socket&&this.socket.readyState===WebSocket.OPEN){this.socket.send(JSON.stringify(message));}}// 加入房间joinRoom(roomId){this.send({type:'join',roomId});}// 离开房间leaveRoom(roomId){this.send({type:'leave',roomId});}// 发送 OffersendOffer(sdp,to){this.send({type:'offer',sdp,to});}// 发送 AnswersendAnswer(sdp,to){this.send({type:'answer',sdp,to});}// 发送 ICE CandidatesendCandidate(candidate,to){this.send({type:'candidate',candidate,to});}// 挂断hangup(to){this.send({type:'hangup',to});}// 注册事件处理器on(type,handler){if(!this.handlers.has(type)){this.handlers.set(type,[]);}this.handlers.get(type).push(handler);}// 触发事件emit(type,data){consthandlers=this.handlers.get(type);if(handlers){handlers.forEach(handler=>handler(data));}}// 处理消息handleMessage(message){this.emit(message.type,message);}// 断开连接disconnect(){if(this.socket){this.socket.close();}}}// 使用示例constsignaling=newSignalingClient('ws://localhost:8080');signaling.on('offer',async(message)=>{console.log('收到 Offer:',message);// 处理 Offer});signaling.on('answer',async(message)=>{console.log('收到 Answer:',message);// 处理 Answer});signaling.on('candidate',async(message)=>{console.log('收到 Candidate:',message);// 处理 ICE Candidate});signaling.on('user-joined',(message)=>{console.log('用户加入:',message.clientId);});signaling.on('user-left',(message)=>{console.log('用户离开:',message.clientId);});// 连接并加入房间signaling.connect().then((clientId)=>{console.log('我的 ID:',clientId);signaling.joinRoom('room-123');});7. 总结
7.1 核心要点
| 要点 | 说明 |
|---|---|
| 信令定义 | 建立通信前交换控制信息的过程 |
| 不在标准内 | WebRTC 故意不定义信令协议,给开发者灵活性 |
| 传输内容 | SDP(会话描述)和 ICE Candidate(网络候选) |
| 传输方式 | WebSocket、Socket.IO、HTTP、MQTT 等 |
| 服务器职责 | 消息转发、房间管理、用户管理 |
7.2 信令流程回顾
1. 用户 A 创建 Offer (createOffer) 2. 用户 A 设置本地描述 (setLocalDescription) 3. 用户 A 通过信令服务器发送 Offer 给用户 B 4. 用户 B 收到 Offer,设置远端描述 (setRemoteDescription) 5. 用户 B 创建 Answer (createAnswer) 6. 用户 B 设置本地描述 (setLocalDescription) 7. 用户 B 通过信令服务器发送 Answer 给用户 A 8. 用户 A 收到 Answer,设置远端描述 (setRemoteDescription) 9. 双方交换 ICE Candidate 10. P2P 连接建立7.3 下一篇预告
在下一篇文章中,我们将深入探讨 SDP(Session Description Protocol),包括:
- SDP 的完整结构
- Offer/Answer 模型
- 关键参数解读
- SDP 的修改与优化
参考资料
- RFC 4566 - SDP: Session Description Protocol
- WebRTC Signaling - MDN
- WebRTC for the Curious - Signaling
- Socket.IO Documentation