WebRTC技术简介
文章出自:知乎 https://zhuanlan.zhihu.com/p/421503695
作者:字节跳动ADFE团队
一、关键词
NAT | Network Address Translation | 网络地址转换 |
STUN | Session Traversal Utilities for NAT | NAT会话穿越应用程序 |
TURN | Traversal Using Relay NAT | 通过Relay方式穿越NAT |
ICE | Interactive Connectivity Establishment | 交互式连接建立 |
SDP | Session Description Protocol | 会话描述协议 |
WebRTC | Web Real-Time Communications | web实时通讯技术 |
二、WebRTC
WebRTC
(Web Real-Time Communications) 是一项实时通讯技术,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流、音频流或者其他任意数据的传输。
WebRTC必须在HTTPS环境下运行,你可以在https://appr.tc/、https://snapdrop.net/体验WebRTC应用,或者在https://nashaofu.github.io/webrtc-demo/,https://webrtc.github.io/samples/查看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,发送网络信息,接收对方网络信息,如下示例为https://nashaofu.github.io/webrtc-demo/socket.html(需clone下来在本地运行)的通信过程

WebRTC交换offer与网络参数之后,就会尝试直接使用对方的IP地址与端口进行直接连接,这个过程中会根据双方网络情况,使用的不同的方式建立连接,后文NAT打洞就是介绍这部分内容。
信令服务器
A与B在建立连接的WebRTC连接过程中,需要互相知道对方的IP与通信端口。那么A与B要如何知道对方的IP与端口呢?答案就是通过信令服务器。
信令服务器的作用是作为一个中间人帮助双方在尽可能少的暴露隐私的情况下建立连接。WebRTC并没有提供信令传递机制,你可以使用任何方式如WebSocket 或者XMLHttpRequest 等,来交换彼此的令牌信息。
WebRTC支持传输内容
-
WebRTC从名称上就有实时会话的定义,那必然支持直接传输音频流和视频流(https://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这个双工(可读可写)数据通道。https://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讲解可参考:https://en.wikipedia.org/wiki/Session_Description_Protocol
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
-
服务端使用 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)
}
})
})
}
-
客户端创建代码
// 视频列表区域
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(`/socket.html?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
}
})
})
示例截图

上述例子详细代码可查看https://github.com/nashaofu/webrtc-demo,仓库包含了使用dataChanel实现的简单聊天室,具体可clone仓库到本地预览,注意需信任tls证书。
原文始发于微信公众号(前端24):WebRTC技术简介
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/216962.html