信令服务器
没有信令服务器,各个WebRTC之间是没办法通信的。
传递媒体数据有两个信息,必须经过信令服务器进行交换
- 媒体信息
通过SDP来表示,如编解码器是什么?是否支持音频视频?编码方式是什么?等
这些信息是通过SDP协议描述出来,通过信令服务器中转的
- 网络信息
两个WebRTC客户端会尽可能选择P2P进行连接,那么进行连接前是如何发现对方的?就是通过信令服务器。
首先将你所有网络相关信息传到信令服务器,服务器帮你交换到对端,对端拿到你的信息后,
若在同一局域网内,直接通过P2P传输;若不在,首先进行P2P穿越,看是否能打通,打通则传输,打不通则中转等。
- 具体业务
还有一点也需要信令服务器进行传输,比如加入房间,离开房间,禁言等功能
使用socket.io的原因
- socke.io是 WebRTC超集,它本身就有WebSocket功能
在传输时,一般有两种协议 TCP和 UDP
底层协议使用 UDP主要用于流媒体传输(音频视频)还有文本,文字聊天等,但 UDP是不可靠传输,是可以丢包的,当然音频视频是可以丢包的,丢失一帧只会卡顿下,还可以继续工作。
但信令服务器不能丢失数据,所有的包必须保证到达,否则断开连接,所以信令服务器一般使用TCP可靠性传输。
websocket底层使用的就是 TCP协议, socket.io 使用的也是TCP
- socket.io本身就有房间的概念,不用自己再写一个ROOM服务器
在websocket官方中,是有三个服务器的,ROOM服务器(提供用户进出房间服务)、信令服务器、流媒体(中转)服务器
选用socket.io 即不用单独写ROOM服务器,这里ROOM和信令是同一个服务器
- socket.io 跨平台、跨终端、跨语言
socket.io是一个基于Nodejs的库,在现有的Node Server上增加个socket.io即可
在任何终端都可以引入socket.io客户端的库,通过客户端的库就可以连接到 Nodejs中 socket.io服务器上
这样就可以建立连接,然后就可以创建,加入房间,这样房间内的人就可以通信了
多个 socke.io可以串行通信。
Socket.IO API
发送消息
- 给本次连接发送消息
socket.emit()
- 给某个房间内所有人发送消息
io.in(room).emit() /io.sockets.in(room).emit()
- 除了本连接外,给某个房间内所有人发消息
socket.to(room).emit()
- 除了本连接外,给所有人发消息
socket.broadcast.emit()
处理消息
- 发送action命令
S:socket.emit('action');
C: socket.on('action',function(){})
- 发送一个action命令,还有data数据
S:socket.emit('action',data);
C: socket.on('action',function(data){})
- 发送一个action命令,还有两个数据
S:socket.emit('action',arg1,arg2);
C: socket.on('action',function(arg1,arg2){})
- 发送一个action命令,再emit方法中包含回调函数
S:socket.emit('action',data,function(arg1,arg2){});
C: socket.on('action',function(data,fn){fn(a,b)})
基于socket.io实现聊天室
<html>
<head>
<title>Chat Room</title>
<link rel="stylesheet" href="./css/main.css"></link>
</head>
<body>
<table align="center">
<tr>
<td>
<label>UserName: </label>
<input type=text id="username"></input>
</td>
</tr>
<tr>
<td>
<label>room: </label>
<input type=text id="room"></input>
<button id="connect">Conect</button>
<button id="leave" disabled>Leave</button>
</td>
</tr>
<tr>
<td>
<label>Content: </label><br>
<textarea disabled style="line-height: 1.5;" id="output" rows="10" cols="100"></textarea>
</td>
</tr>
<tr>
<td>
<label>Input: </label><br>
<textarea disabled id="input" rows="3" cols="100"></textarea>
</td>
</tr>
<tr>
<td>
<button id="send">Send</button>
</td>
</tr>
</table>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.0.3/socket.io.js"></script>
<script src="./js/client.js"></script>
</body>
</html>
//client.js
'use strict'
//
var userName = document.querySelector('input#username');
var inputRoom = document.querySelector('input#room');
var btnConnect = document.querySelector('button#connect');
var btnLeave = document.querySelector('button#leave');
var outputArea = document.querySelector('textarea#output');
var inputArea = document.querySelector('textarea#input');
var btnSend = document.querySelector('button#send');
var socket;
var room;
btnConnect.onclick = () => {
//connect
socket = io.connect('https://zengqiang.mynatapp.cc/');
//recieve message
socket.on('joined', (room, id) => {
btnConnect.disabled = true;
btnLeave.disabled = false;
inputArea.disabled = false;
btnSend.disabled = false;
});
socket.on('leaved', (room, id) => {
btnConnect.disabled = false;
btnLeave.disabled = true;
inputArea.disabled = true;
btnSend.disabled = true;
socket.disconnect();
});
socket.on('message', (room, data) => {
outputArea.scrollTop = outputArea.scrollHeight;//窗口总是显示最后的内容
outputArea.value = outputArea.value + data + '\r';
});
socket.on('disconnect', (socket) => {
btnConnect.disabled = false;
btnLeave.disabled = true;
inputArea.disabled = true;
btnSend.disabled = true;
});
//send message
room = inputRoom.value;
socket.emit('join', room);
}
btnSend.onclick = () => {
var data = inputArea.value;
data = userName.value + ':' + data;
socket.emit('message', room, data);
inputArea.value = '';
}
btnLeave.onclick = () => {
room = inputRoom.value;
socket.emit('leave', room);
}
inputArea.onkeypress = (event) => {
//event = event || window.event;
if (event.keyCode == 13) { //回车发送消息
var data = inputArea.value;
data = userName.value + ':' + data;
socket.emit('message', room, data);
inputArea.value = '';
event.preventDefault();//阻止默认行为
}
}
//server.js
const http = require('http');
const https = require('https');
const fs = require('fs');
const express = require('express');
const socketIO = require('socket.io');
const serveIndex = require('serve-index')
const app = express();
app.use(serveIndex('./public'));
app.use(express.static('./public'));
const http_server = http.createServer(app);
const io = socketIO.listen(http_server);
//每个socket就是一个连接,一个客户端
io.sockets.on('connection', socket => {
socket.on('message', (room, data) => {
socket.to(room).emit('message', room, data)//房间内所有人,除自己外
});
//'join'时自定义事件
socket.on('join', room => {
socket.join(room);
//如果第一次的时候,因为有上一句代码,其实也已经添加进了rooms的列表里面
const myRoom = io.sockets.adapter.rooms[room];
const users = Object.keys(myRoom.sockets).length;//房间里面的人数
io.in(room).emit('joined', room, socket.id);//房间里面所有人
// socket.to(room).emit('joined',room,socket.id);
//socket.emit('join',room,socket.id);
// socket.broadcast.emit('joined',room,socket.id)
});
socket.on('leave', room => {
const myRoom = io.sockets.adapter.rooms[room];
const users = Object.keys(myRoom.sockets).length;
socket.leave(room);
io.in(room).emit('leaved', room, socket.id);
});
})
http_server.listen(8080);
WebRTC传输基本知识
- NAT(Network Address Translator)
当在专用网内部的一些主机本来已经分配到了本地IP地址(即仅在本专用网内使用的专用地址),但现在又想和因特网上的主机通信(并不需要加密)时,可使用NAT方法。
这种方法需要在专用网连接到因特网的路由器上安装NAT软件。装有NAT软件的路由器叫做NAT路由器,它至少有一个有效的外部全球IP地址。
这样,所有使用本地地址的主机在和外界通信时,都要在NAT路由器上将其本地地址转换成全球IP地址,才能和因特网连接。
其实就是内网服务器共用一个IP但是映射成不同的端口(这是其中一个方式)
- STUN(Simple Traversal of UDP Through NAT)
直接P2P通信
它允许位于NAT(或多重NAT)后的客户端找出自己的公网地址
,查出自己位于哪种类型的NAT之后以及NAT为某一个本地端口所绑定的Internet端端口。这些信息被用来在两个同时处于NAT路由器之后的主机之间创建UDP通信
- TURN(Traversal Using Relays around NAT)
转发
TURN协议允许NAT或者防火墙后面的对象可以通过TCP或者UDP接收到数据。这在使用了对称式的NAT(或者防火墙)的网络中尤其具有实用价值。
TURN方式解决NAT问题的思路与STUN相似,是基于私网接入用户通过某种机制预先得到其私有地址对应在公网的地址(STUN方式得到的地址为出口NAT上的地址,TURN方式得到地址为TURNServer上的地址),然后在报文负载中所描述的地址信息直接填写该公网地址的方式,实际应用原理也是一样的
TURN的局限性在于所有报文都必须经过TURNServer转发,增大了包的延迟和丢包的可能性
- ICE(Interactive Connectivity Establishment)
把上面三者打包在一起,然后选择最优解(到底选择p2p还是TURN),首先p2p,然后turn服务进行中转
NAT种类
- 完全锥形NAT(Full Cone NAT)
- 地址限制锥形NAT(Address Restricted Cone NAT)
- 端口限制锥形NAT(Port Restricted Cone NAT)
- 对称型NAT(Symmetric NAT)
NAT穿越原理
- 完全锥形NAT
需要内网主机先发起请求,经过防火墙等转换然后生成公网地址(在NAT服务上打个洞,形成外网地址),只要知道外网地址,其他服务可以随意请求,安全性低
- 地址限制锥形NAT
内网主机先发射请求,也是NAT服务打洞,但是是在防火墙上形成映射表(内网主机地址,外网地址,所请求服务的地址),这样可以避免其他服务能随意请求内网主机,安全性更高
- 端口限制锥形NAT
内网主机先发射请求,也是NAT服务打洞,但是是在防火墙上形成映射表(内网主机ip地址和端口,外网ip地址和端口,所请求服务的ip地址和端口),相比上一个安全性更高
- 对称型NAT
上面三种是在防火墙上面形成固定的ip地址和端口,虽然可能不通但是是都能找到的;对称型NAT针对请求的不同外部服务产生对应的不同的公网ip地址和端口,例如:同一个内网服务主机请求百度和请求腾讯,就会在防火墙上面形成两个不同的IP和端口都是公网的。而且只能专用IP端口和特定外网服务打通,其他通不了。一般情况下,这种方式在国内几乎无法成功穿透
穿越步骤
C指的是Client
- C1,C2向STUN发消息
- 交换公网IP和端口
- 然后根据不同NAT种类进行不同通信试探
- C1-C2,C2-C1,甚至是端口猜测
NAT穿越组合
全锥型 全锥型 √
全锥型 受限锥型 √
全锥型 端口受限锥型 √
全锥型 对称型 √
受限锥型 受限锥型 √
受限锥型 端口受限锥型 √
受限锥型 对称型 √
端口受限锥型 端口受限锥型 √
端口受限锥型 对称型 × 无法打通
对称型 对称型 × 无法打通
STUN介绍
- STUN存在的目的就是进行NAT穿越
- STUN是典型的客户端/服务器模式。客户端发送请求,服务端进行响应
RFC STUN规范
- RFC3489/STUN
基于UDP穿透,但是国内路由器厂商对路由器限制大甚至不能使用,所以成功率很低
Simple Traversal of UDP Through NAT
- RFC5389/STUN
是上面一种完善方案
Session Traversal Utilities for NAT
STUN协议
-
包含20字节的STUN header(RFC3489)
- 其中2个子节(16bit)类型
- 2个子节(16bit)消息长度,不包括消息头
- 16个字节(128bit)事务ID,请求与响应事务ID相同
Body中可以有0个或多个Attribute
RFC5389
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|0 0| STUN Message Type | Message Length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Magic Cookie |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
| Transaction ID (96 bits) |
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
将原来的128bit的TransactionID分割为2部分,一部分是magic cookie,一部分是id。
The magic cookie field MUST contain the fixed value 0x2112A442 in network byte order
magic cookie是一个常量
STUN Message Type
- 前两位必须是00,以区分复用同一端口时STUN协议
- 2位用于分类即C0和C1
- 12位用于定义请求/指示
C0C1
- 0boo:表示是一个请求
- 0bo1:表示是一个指示
- 0b1o:表示是请求成功的响应
- 0b11:表示是请求失败的响应
STUN消息类型
类型 含义
0x0001 绑定消息
0x0101 绑定响应
0x0111 绑定错误
0x0002 私密请求
0x0102 私密响应
0x0112 私密错误
大小端模式
- 大端模式:数据的高字节保存在内存的低地址中
- 小端模式:数据的高字节保存在内存的高地址中
- 网络字节顺序:采用大端排序方式
Transaction ID
- 4字节,32位,固定值0x2112A442。通过它可以判断客户端是否可以识别某些属性
- 12字节,96位,标识同一个事务的请求和响应
STUN Message Body
- 消息头后有0或多个属性
- 每个属性进行TLV编码:Type,Length,Value
RFC3489定义的属性
0x0001 MAPPED-ADDRESS 获取客户端映射地址
0x0002 RESPONSE-ADDRESS 获取对于MAPPED-ADDRESS的响应应该由哪发送
0x0003 CHANGE-REQUEST 请求服务端使用不同的IP和端口发送请响
0x0004 SOURCE-ADDRESS 指定服务端的IP地址和端口
0x0005 CHANGED-ADDRESS 它是CHANGE-REQUEST请求的响应
0x0006 USERNAME 用于安全验证
0x0007 PASSWORD 用于安全验证
0x0008 MESSAGE-INTEGRITY 消息完整性验证
0x0009 ERROR-CODE 错误码
0x000a UNKNOWN-ATTRIBUTES 未知属性
0x000b REFLECTED-FROM 拒约
TURN介绍(中继)
- 其目的是解决对称NAT无法穿越的问题
- 其建立再STUN之上,消息格式使用STUN格式消息
- TURN Client要求服务端分配一个公共IP和Port用于接收或发送数据
TURN使用的传输协议
TURN client to TURN server TURN server to peer
UDP UDP
TCP UDP
TLS over TCP UDP
TURN 发送数据的机制
- Send(上行) And Data(下行,会添加消息头)
因为流媒体数据量很大,每个都带消息头则对带宽压力很大,所以还有第二种方式
- Channel(管道,通过管道id)
- 这两种方式可以并存
ICE
ICE是一种标准穿透协议,利用STUN和TURN服务器来帮助端点建立连接。WebRTC当通过信令server交换完sdp, candidate后,之后依靠ICE框架在2端之间建立一个通道。
ICE的过程主要分为5步:
- 收集候选传输地址
- 在信令通道中交换候选选项(排序)
- 执行连接检查(连通性测试)
- 选择选定的对并启动媒体
-
心跳检测
https://www.imweb.io/topic/5a4a6cb2a192c3b460fce37f
https://segmentfault.com/a/1190000011403597
ICE Candidate(候选者)
- 每个candidate是一个地址(IP和端口)
- 通过SDP协议交换信息
- 例如:a=candidate:...UDP...192.169.1.2 1816 type host
- 每个候选者包括:协议,IP,端口和类型
Candidate类型
- 主机候选者(主机自己的IP和端口)
- 反射候选者(经过NAT处理之后的IP和端口)
- 中继候选者(通过TURN服务开通的IP和端口)
什么是SDP
SDP 完全是一种会话描述格式 ― 它不属于传输协议 ― 它只使用不同的适当的传输协议,包括会话通知协议(SAP)、会话初始协议(SIP)、实时流协议(RTSP)、MIME 扩展协议的电子邮件以及超文本传输协议(HTTP)。SDP协议是也是基于文本的协议,这样就能保证协议的可扩展性比较强,这样就使其具有广泛的应用范围。SDP 不支持会话内容或媒体编码的协商,所以在流媒体中只用来描述媒体信息。
媒体协商
- RTCPeerConnection
pc=new RTCPeerConnection([configuration]);
方法分类
- 媒体协商(编解码器,音视频格式等等告知)
- Stream/Track (流与轨道)
- 传输相关的方法
- 统计相关方法
协商状态变化
媒体协商方法
- createOffer
aPromise=myPeerConnection.createOffer([options]);
- createAnswer
aPromise=myPeerConnection.createAnswer([options]);
- setLOcalDescription
aPromise=myPeerConnection.setLOcalDescription(sessionDescription);
- setRemoteDescription
aPromise=myPeerConnection.setRemoteDescription(sessionDescription);
Track方法
- addTrack
rtpSender=myPC.addTrack(track,stream...);
//Parameters
参数 说明
track 添加到RTCPeerConnection中的媒体轨
stream 指定track所在的stream
- removeTrack
myPC.remoteTrack(rtpSender);
重要事件
- onnegotiationneeded 媒体协商时候触发
- onicecandidate 收到候选者触发
Demo(本机点对点通信)
RTCPeerConnection的作用是在浏览器之间建立数据的“点对点”(peer to peer)通信.
使用WebRTC的编解码器和协议做了大量的工作,方便了开发者,使实时通信成为可能,甚至在不可靠的网络,
比如这些如果在voip体系下开发工作量将非常大,而用webRTC的js开发者则不用考虑这些,举几个例子:
- 丢包隐藏
- 回声抵消
- 带宽自适应
- 动态抖动缓冲
- 自动增益控制
- 噪声抑制与抑制
- 图像清洗
不同客户端之间的音频/视频传递,是不用通过服务器的。但是,两个客户端之间建立信令联系,需要通过服务器。这个和XMPP的Jingle会话很类似。
- 服务器主要转递两种数据:
通信内容的元数据:打开/关闭对话(session)的命令、媒体文件的元数据(编码格式、媒体类型和带宽)等。
网络通信的元数据:IP地址、NAT网络地址翻译和防火墙等
WebRTC协议没有规定与服务器的信令通信方式,因此可以采用各种方式,比如WebSocket。通过服务器,两个客户端按照Session Description Protocol(SDP协议)交换双方的元数据。
本地和远端通讯的过程有些像电话,比如张三正在试着打电话给李四,详细机制:
- 张三创造了一个RTCPeerConnection 对象。
- 张三通过RTCPeerConnection createOffer()方法创造了一个offer(SDP会话描述) 。
- 张三通过他创建的offer调用setLocalDescription(),保存本地会话描述。
- 张三发送信令给李四。
- 李四接通带有张三offer的电话,调用setRemoteDescription() ,李四的RTCPeerConnection知道张三的设置(张三的本地描述到了李四这里,就成了李四的远程话描述)。
- 李四调用createAnswer(),将李四的本地会话描述(local session description)成功- 回调。
- 李四调用setLocalDescription()设置他自己的本地局部描述。
- 李四发送应答信令answer给张三。
- 张三将李四的应答answer用setRemoteDescription()保存为远程会话描述(李四的remote session description)。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
<body>
<video id="localvideo" autoplay playsinline></video>
<video id="remotevideo" autoplay playsinline></video>
<button id="start">start</button>
<button id="call">call</button>
<button id="hangup">Hang Up</button>
</body>
</html>
<script>
let localStream;
let pc1,pc2;
const localvideo = document.querySelector('video#localvideo');
const remotevideo = document.querySelector('video#remotevideo');
const btnStart = document.querySelector('button#start');
const btnCall = document.querySelector('button#call');
const btnhangup = document.querySelector('button#hangup');
btnStart.onclick = () => {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
console.log('不支持');
return
} else {
const constraints = {
video: true,
audio: false
}
navigator.mediaDevices.getUserMedia(constraints)
.then(getMediaStream)
.catch(handleError);
}
}
btnCall.onclick = () => {
pc1=new RTCPeerConnection();
pc2=new RTCPeerConnection();
pc1.onicecandidate=e=>{
pc2.addIceCandidate(e.candidate);
}
pc2.onicecandidate=e=>{
pc1.addIceCandidate(e.candidate);
}
pc2.ontrack=getRemoteStream;
localStream.getTracks().forEach(track => {
//本地采集的视频流添加到pc1的轨道中
pc1.addTrack(track,localStream);
});
let offerOptions={
//此时不采集音频
offerToRecieveAudio:0,
offerToRecieveVideo:1
};
pc1.createOffer(offerOptions).
then(getOffer)
.catch(handleError);
}
btnhangup.onclick = () => {
pc1.close();
pc2.close();
pc1=null;
pc2=null;
}
function getOffer(desc) {
pc1.setLocalDescription(desc);
pc2.setRemoteDescription(desc);
pc2.createAnswer().then(getAnswer)
.catch(handleError);
}
function getAnswer(desc) {
pc2.setLocalDescription(desc);
pc1.setRemoteDescription(desc);
}
function getMediaStream(stream) {
localvideo.srcObject = stream;
localStream = stream;
}
function handleError(err) {
console.log(err);
}
function getRemoteStream(e) {
remotevideo.srcObject=e.streams[0];
}
</script>
说明:上面例子只是本地显示,所以很多回调里面出现pc1和pc2同时存在,实际项目中,应该是例如进入pc1的回调之后发送网络通信,然后在另外一边接收的回调里面写逻辑