WebRTC
WebRTC (Web Real-Time Communications) 是一项实时通讯技术,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流、音频流或者其他任意数据的传输。
WebRTC 必须在 HTTPS 环境下运行,你可以在appr.tc/、snapdrop.net/体验 WebRTC 应用,或者在nashaofu.github.io/webrtc-demo…,nashaofu.github.io/webrtc-demo…查看 WebRTC 示例。
WebRTC vs WebSocket
- 用途区别
- WebSocket 允许浏览器和 Web 服务器之间进行全双工通信.
- WebRTC 允许两个浏览器之间的全双工通信。
- 协议区别
- WebSocket 使用 TCP 协议
- WebRTC 使用 UDP 协议
- 流量路径
- WebSocket 浏览需要经过服务器
- WebRTC 是直接连接,浏览不会经过第三方服务器,是一个去中心化的架构模型,简单说就是省带宽。
- 实时性
- WebSocket 延迟高(不是直接连接)
- WebRTC 延迟低
通常 WebRTC 会与 WebSocket 配合使用,WebSocket 的作用主要是用来交换客户端的 SDP 与网络信息,Websocket 传输的内容与真正通信数据无关,只是协助 WebRTC 建立连接。
WebRTC offer 与 answer 交换流程
和 TCP 3 次握手类似,WebRTC 连接需进行 offer 与 answer 的交换,至少需进行 4 次通信。分别为:发送offer/answer
,接收answer/offer
,发送网络信息,接收对方网络信息,如下示例为nashaofu.github.io/webrtc-demo…(需 clone 下来在本地运行)的通信过程
WebRTC 交换 offer 与网络参数之后,就会尝试直接使用对方的 IP 地址与端口进行直接连接,这个过程中会根据双方网络情况,使用的不同的方式建立连接,后文NAT 打洞就是介绍这部分内容。
信令服务器
A 与 B 在建立连接的 WebRTC 连接过程中,需要互相知道对方的 IP 与通信端口。那么 A 与 B 要如何知道对方的 IP 与端口呢?答案就是通过信令服务器。
信令服务器的作用是作为一个中间人帮助双方在尽可能少的暴露隐私的情况下建立连接。WebRTC 并没有提供信令传递机制,你可以使用任何方式如 WebSocket 或者 XMLHttpRequest 等,来交换彼此的令牌信息。
WebRTC 支持传输内容
-
WebRTC 从名称上就有实时会话的定义,那必然支持直接传输音频流和视频流(appr.tc/)
const pc = new RTCPeerConnection()
navigator.getUserMedia({ video: true }, stream => { // 添加视频流到会话中 stream.getTracks().forEach(track => pc.addTrack(track, stream))
// 在网页中预览自己摄像头拍摄到的内容 $localVideo.srcObject = stream })
-
WebRTC 并不只是用来做视频通话,其实它还可以用来传输任意数据,包括文件,文本等。WebRTC 规定了 dataChannel 这个双工(可读可写)数据通道。snapdrop.net/这个网站就是通过 WebRTC 进行文件分享。
const pc = new RTCPeerConnection()
const dataChannel = pc.createDataChannel('chat')
pc.addEventListener('datachannel', event => { // 接收通信方发送过来的数据 event.channel.addEventListener('message', event => { console.log('message', event.message) }) })
dataChannel.addEventListener('open', () => { // 发送数据,可发送任意数据 dataChannel.send('Hi!') })
dataChannel.addEventListener('close', event => {})
会话描述协议(SDP)
前面讲到了offer
与answer
是由RTCPeerConnection
实例调用createOffer
与createAnswer
创建的offer
与answer
中的主要内容是 SDP 文本,offer
或answer
数据结构如下:
{
type: "offer" | "answer",
sdp: string
}
从技术上讲,SDP 并不是一个真正的协议,而是一种数据格式,用于描述在设备之间共享媒体的连接。SDP 由一行或多行 UTF-8 文本组成,每行以一个字符的类型开头,后跟等号(=),然后是包含值或描述的结构化文本,其格式取决于类型。如下为一个 SDP 内容示例:
v=0
o=alice 2890844526 2890844526 IN IP4 host.anywhere.com
s=
c=IN IP4 host.anywhere.com
t=0 0
m=audio 49170 RTP/AVP 0
a=rtpmap:0 PCMU/8000
m=video 51372 RTP/AVP 31
a=rtpmap:31 H261/90000
m=video 53000 RTP/AVP 32
a=rtpmap:32 MPV/90000
SDP 主要描述了彼此的音视频编解码能力、网络带宽和传输协议等信息。WebRTC 中的 SDP 常用字段:
- Version(v):协议版本
- Origion(o):会话发起者
- Session Name(s):会话名
- Connection Data(c):连接数据
- Media Announcements(m):媒体描述
- 其他属性字段...
更多关于 SPD 讲解可参考:en.wikipedia.org/wiki/Sessio…
candidate 事件
当RTCPeerConnection
实例执行setLocalDescription()
后,RTCPeerConnection
就会探测自己的网络环境,然后用 candidate 事件会返回候选网络环境数据,网络环境数据中最重要的是 IP 地址与端口组成的候选通信地址。
candidate 事件中的 event.candidate 主要包含以下几个部分:
-
本机 IP 地址
-
本机用于 WebRTC 通信的端口号
-
候选者类型,包括 host、srflx 和 relay
-
优先级
-
传输协议
{ "address": "192.168.31.67", "candidate": "candidate:2147606101 1 udp 2122260223 192.168.31.67 57959 typ host generation 0 ufrag EaWw network-id 1 network-cost 10", "component": "rtp", "foundation": "2147606101", "port": 57959, "priority": 2122260223, "protocol": "udp", "relatedAddress": null, "relatedPort": null, "sdpMLineIndex": 0, "sdpMid": "0", "tcpType": null, "type": "host", "usernameFragment": "EaWw" }
candidate 事件 type 字段取值分别为 host、srflx、relay:
- host(Host candidate): 从本地网卡上获取的地址
- srflx(Server reflexive candidate): STUN 返回的该客户端的地址
- relay(Relay reflexive candidate):: TURN 服务器为该客户端分配的中继地址
本地的 candidate 与远端 candidate 构成的每一对都有一定的优先级,按优先级排序进行连通性检查。最后从有效的 candidate 组合中选择优先级最高的作为传输地址,用于建立 P2P 连接。
网络地址转换(NAT)
网络地址转换(英语:Network Address Translation,缩写:NAT;又称网络掩蔽、IP 掩蔽)在计算机网络中是一种在 IP 数据包通过路由器或防火墙时重写来源 IP 地址或目的 IP 地址的技术。这种技术被普遍使用在有多台主机但只通过一个公有 IP 地址访问互联网的私有网络中。
要建立一个连接需要知道对方的 IP 地址和端口号,在局域网里面一台路由器(基站)可能会连接着很多台设备,例如家庭路由器接入宽带的时候,宽带服务商会分配一个公网的 IP 地址,所有连到这个路由器的设备都共用这个公网 IP 地址。如果两台设备都用了同一个公网 IP:prot 去发送请求,服务器返回数据在经过路由器时它就不知道应该转发给哪一个设备。因此路由器需要重写 IP 地址/端口号进行区分,如下图所示:
NAT 设备通常会自动设置各个设备的映射关系,我们也可以在路由器端去手动设置。如上图的 NAT 维护的映射关系还会和要访问的目标 IP 地址进行绑定,例如同一终端使用同一端口访问不同的目标 IP,就会建立不同的映射关系。
如上示例 NAT 上建立的映射关系如下:
内网 IP 端口
外网 IP 端口
NAT 对外 IP 与端口
192.168.1.2:8080
39.182.39.30:443
10.188.20.10:8000
192.168.1.2:8080
39.182.39.40:443
10.188.20.10:8001
所以实际存储的映射关系会包含上面 3 部分内容,这样做的目的是保证网络安全。想象如下例子,终端192.168.1.2:8080
通过路由器使用10.188.20.10:8000
访问服务器 A,建立 NAT 映射如果为192.168.1.2:8080-->10.188.20.10:8000
,那么如果有人向10.188.20.10:8000
发送数据就会转发到192.168.1.2:8080
,这样就会导致内网的服务被外部随意访问,所以 NAT 映射会记录目标地址。当然,由于 NAT 有多种类型,NAT 映射也会存不同,更多内容可参考维基百科或者WebRTC 网络基础 九、第二节 NAT 打洞原理,下表进行一个简单的归纳。
NAT 打洞
由于 NAT 有上面 4 种类型,所以两个设备要建立 P2P 链接就要使用不同的方式。
- 如果 NAT 是完全圆锥型的,那么双方中的任何一方都可以发起通信。
- 如果 NAT 是受限圆锥型或端口受限圆锥型,双方必须一起开始向对方发起请求,这样双方的 NAT 上就都有了 NAT 映射了,然后就能连通。若有一方位于对称 NAT 后,就无法打洞成功。
- 对于对称 NAT 来说,客户端向 STUN 服务器(下节介绍,用于协助打洞)发包映射的公网 IP:端口与向其它客户端发包映射的公网 IP:端口是不一样的,一个连接创建一个公网的映射,也就是说其它客户端无法使用之前通过 STUN 服务器打好的洞,所以客户端双方无法成功打洞,只能使用 TURN 中转方案。
WebRTC 打洞
WebRTC 本身就已经实现 NAT 打洞功能,只需要连接的双方交换了网络端口和 IP 之后,WebRTC 就会自动进行打洞。WebRTC 使用一个叫做交互式连接设施(ICE)协议框架。ICE 整合了 STUN 与 TURN。STUN 是用来探测终端 NAT 类型、IP 和端口的服务,WebRTC 获取到 NAT 类型、IP 和端口后就会触发 candidate 事件,然后连接双方交换 IP 与端口,开始打洞。如果打洞失败,那么就会使用 TURN 服务器转发流量。
由于 WebRTC 提供了 ICE,所以使用非常简单,只需在new RTCPeerConnection
时传入iceServers
参数即可。googel 提供了免费的 STUN 服务器去帮助打洞,也可以自己架设服务器。
const pc = new RTCPeerConnection({
// 可以传入多个stun服务器或者turn服务器
iceServers: [
{ url: 'stun:stun.l.google.com:19302' },
{ url: 'stun:stun1.l.google.com:19302' },
{ url: 'stun:stun2.l.google.com:19302' },
{ url: 'stun:stun3.l.google.com:19302' },
{ url: 'stun:stun4.l.google.com:19302' }
]
})
实现一个在线视频会议 demo
1. 服务端使用 socket.io 来作为信令服务器来转发客户端数据。其主要功能包括房间创建分配与客户端数据转发。
const socket = require('socket.io')
module.exports = server => {
// 在https服务器上添加ws通信路径`/socket.io/`
const io = socket.listen(server)
io.sockets.on('connection', function(socket) {
socket.on('disconnecting', () => {
// 通知房间中的其他客户端断开连接
Object.keys(socket.rooms).forEach(room => {
socket.broadcast.to(room).emit('leaveed', socket.id)
})
})
// 转发客户端消息
socket.on('message', function(target, message) {
if (target) {
// 发送消息到指定客户端
io.sockets.sockets[target]?.emit('message', message)
}
})
// 房间创建与加入
socket.on('create or join', function(room) {
const clientsInRoom = io.sockets.adapter.rooms[room]
const numClients = clientsInRoom ? Object.keys(clientsInRoom.sockets).length : 0
if (numClients === 0) {
// 创建房间
socket.join(room)
// 通知当前客户端创建房间成功
socket.emit('created', room, socket.id)
} else if (numClients < 10) {
// 一个房间最多只能有10个人
socket.join(room)
// 通知当前客户端加入房间成功
socket.emit('joined', room, socket.id)
// 通知房间中的其他客户端有人加入
socket.broadcast.to(room).emit('message', {
socketId: socket.id,
type: 'join'
})
} else {
// max two clients
socket.emit('full', room)
}
})
})
}
2. 客户端创建代码
// 视频列表区域
const videos = document.querySelector('#videos')
// 本地视频预览
const localVideo = document.querySelector('#localVideo')
// 房间号
const roomId = document.querySelector('#roomId')
const query = new URLSearchParams(location.search)
const room = query.get('room')
if (!room) {
location.replace(
`${location.pathname}?room=${Math.random()
.toString(36)
.substr(2, 9)}`
)
}
// 存储通信方信息
const remotes = {}
const socket = io.connect()
// socket发送消息
function sendMsg(target, msg) {
console.log('->:', msg.type)
msg.socketId = socket.id
socket.emit('message', target, msg)
}
// 创建RTC对象,一个RTC对象只能与一个远端连接
function createRTC(stream, id) {
const pc = new RTCPeerConnection({
iceServers: [
{
urls: 'stun:stun.l.google.com:19302'
}
]
})
// 获取本地网络信息,并发送给通信方
pc.addEventListener('icecandidate', event => {
if (event.candidate) {
// 发送自身的网络信息到通信方
sendMsg(id, {
type: 'candidate',
candidate: {
sdpMLineIndex: event.candidate.sdpMLineIndex,
sdpMid: event.candidate.sdpMid,
candidate: event.candidate.candidate
}
})
}
})
// 有远程视频流时,显示远程视频流
pc.addEventListener('track', event => {
remotes[id].video.srcObject = event.streams[0]
})
// 添加本地视频流到会话中
stream.getTracks().forEach(track => pc.addTrack(track, stream))
// 用于显示远程视频
const video = document.createElement('video')
video.setAttribute('autoplay', true)
video.setAttribute('playsinline', true)
videos.append(video)
remotes[id] = {
pc,
video
}
}
navigator.mediaDevices
.getUserMedia({
audio: false, // 本地测试防止回声
video: true
})
.then(stream => {
roomId.innerHTML = room
localVideo.srcObject = stream
// 创建或者加入房间,具体是加入还是创建需看房间号是否存在
socket.emit('create or join', room)
socket.on('leaveed', function(id) {
console.log('leaveed', id)
if (remotes[id]) {
remotes[id].pc.close()
videos.removeChild(remotes[id].video)
delete remotes[id]
}
})
socket.on('full', function(room) {
console.log('Room ' + room + ' is full')
socket.close()
alert('房间已满')
})
socket.on('message', async function(message) {
console.log('<-:', message.type)
switch (message.type) {
case 'join': {
// 有新的人加入就重新设置会话,重新与新加入的人建立新会话
createRTC(stream, message.socketId)
const pc = remotes[message.socketId].pc
const offer = await pc.createOffer()
pc.setLocalDescription(offer)
sendMsg(message.socketId, { type: 'offer', offer })
break
}
case 'offer': {
createRTC(stream, message.socketId)
const pc = remotes[message.socketId].pc
pc.setRemoteDescription(new RTCSessionDescription(message.offer))
const answer = await pc.createAnswer()
pc.setLocalDescription(answer)
sendMsg(message.socketId, { type: 'answer', answer })
break
}
case 'answer': {
const pc = remotes[message.socketId].pc
pc.setRemoteDescription(new RTCSessionDescription(message.answer))
break
}
case 'candidate': {
const pc = remotes[message.socketId].pc
pc.addIceCandidate(new RTCIceCandidate(message.candidate))
break
}
default:
console.log(message)
break
}
})
})
示例截图
上述例子详细代码可查看github.com/nashaofu/we…,仓库包含了使用 dataChanel 实现的简单聊天室,具体可 clone 仓库到本地预览,注意需信任 tls 证书。