最近须要搭建一个在线课堂的直播平台,思考到清晰度和提早性,咱们统一认为应用 WebRTC 最合适。
起因有两点:首先是“点对点通信”十分吸引咱们,不须要两头服务器,客户端直连,通信十分不便;再者是 WebRTC 浏览器原生反对,其余客户端反对也很好,不像传统直播用 flv.js 做兼容,能够实现规范对立。
然而令我十分难堪的是,社区看了好几篇文章,实践架构写了一堆,但没一个能跑起来。WebRTC 外面概念很新也很多,了解它的 通信流程
才是最要害,这点恰好很少有形容。
于是我就本人捣鼓吧。捣鼓了几天,可算是整明确了。上面我联合本人的实践经验,依照我了解的关键步骤,带大家从利用场景的角度意识这个厉害的敌人 —— WebRTC
。
纲要预览
本文介绍的内容包含以下方面:
- 什么是 WebRTC?
- 获取媒体流
- 对等连贯流程
- 本地模仿通信源码
- 局域网两端通信
- 一对多通信
- 我想学更多
什么是 WebRTC?
WebRTC (Web Real-Time Communications) 是一项实时通信技术,它容许网络应用或者站点,在 不借助两头媒介 的状况下,建设浏览器之间点对点(Peer-to-Peer)的连贯,实现视频流和音频流或者其余任意数据的传输。
简略的说,就是 WebRTC 能够不借助媒体服务器,通过浏览器与浏览器间接连贯(点对点),即可实现音视频传输。
如果你接触过直播技术,你就会晓得“没有媒体服务器”如许令人诧异。以往的直播技术大多是基于推流 / 拉流的逻辑实现的。要想做音视频直播,则必须有一台 流媒体服务器 做为两头站做数据转发。然而这种推拉流的计划有两个问题:
- 较高的提早
- 清晰度难以保障
因为两端通信都要先过服务器,就好比原本是一条直路,你偏偏“绕了半个圈”,这样必定会花更多的工夫,因而直播必然会有提早,即便提早再低也要 1s 以上。
清晰度高下的实质是数据量的大小。你设想一下,每天乘地铁下班,早顶峰人越多,进站的那条道就越容易堵,堵你就会走走停停,再加上还绕了路,是不是到公司就更晚了。
把这个例子分割到高清晰度的直播:因为数据量大就容易产生网络拥挤,拥挤就会导致播放卡顿,同时提早性也会更高。
然而 WebRTC 就不一样了,它不须要媒体服务器,两点一线直连,首先提早性肯定大大缩短。再者因为传输路线更短,所以清晰度高的数据流也更容易达到,相对来说不易拥挤,因而播放端不容易卡顿,这样就兼顾了清晰度与提早性。
当然 WebRTC 也是反对两头媒体服务器的,有些场景下的确少不了服务器转发。咱们这篇只探讨点对点的模式,旨在帮忙大家更容易的理解并上手 WebRTC。
获取媒体流
点对点通信的第一步,肯定是发动端获取媒体流。
常见的媒体设施有三种:摄像机 , 麦克风 和 屏幕。其中摄像机和屏幕能够转化为视频流,而麦克风可转化为音频流。音视频流联合起来就组成了常见的媒体流。
以 Chrome 浏览器为例,摄像头和屏幕的视频流获取形式不一样。对于摄像头和麦克风,应用如下 API 获取:
var stream = await navigator.mediaDevices.getUserMedia()
对于屏幕录制,则会用另外一个 API。限度是这个 API 只能获取视频,不能获取音频:
var stream = await navigator.mediaDevices.getDisplayMedia()
留神:这里我遇到过一个问题,编辑器里提醒 navigator.mediaDevices == undefined,起因是我的 typescript 版本小于 4.4,降级版本即可。
这两个获取媒体流的 API 有应用条件,必须满足以下两种状况之一:
- 域名是 localhost
- 协定是 https
如果不满足,则 navigator.mediaDevices
的值就是 undefined。
以上办法都有一个参数 constraints
,这个参数是一个配置对象,称为 媒体束缚。这外面最有用的是能够配置只获取音频或视频,或者音视频同时获取。
比方我只有视频,不要音频,就能够这样:
let stream = await navigator.mediaDevices.getDisplayMedia({
audio: false,
video: true
})
除了简略的配置获取视频之外,还能够对视频的清晰度,码率等波及视频品质相干的参数做配置。比方我须要获取 1080p 的超清视频,我就能够这样配:
var stream = await navigator.mediaDevices.getDisplayMedia({
audio: false,
video: {
width: 1920,
height: 1080
}
})
当然了,这里配置视频的分辨率 1080p,并不代表理论获取的视频肯定是 1080p。比方我的摄像头是 720p 的,那即使我配置了 2k 的分辨率,理论获取的最多也是 720p,这个和硬件与网络有关系。
下面说了,媒体流是由音频流和视频流组成的。再说的谨严一点,一个媒体流(MediaStream)会蕴含多条媒体轨道(MediaStreamTrack),因而咱们能够从媒体流中独自获取音频和视频轨道:
// 视频轨道
let videoTracks = stream.getVideoTracks()
// 音频轨道
let audioTracks = stream.getAudioTracks()
// 全副轨道
stream.getTracks()
独自获取轨道有什么意义呢?比方下面的获取屏幕的 API getDisplayMedia
无奈获取音频,然而咱们直播的时候既须要屏幕也须要声音,此时就能够别离获取音频和视频,而后组成一个新的媒体流。实现如下:
const getNewStream = async () => {var stream = new MediaStream()
let audio_stm = await navigator.mediaDevices.getUserMedia({audio: true})
let video_stm = await navigator.mediaDevices.getDisplayMedia({video: true})
audio_stm.getAudioTracks().map(row => stream.addTrack(row))
video_stm.getVideoTracks().map(row => stream.addTrack(row))
return stream
}
对等连贯流程
要说 WebRTC 有什么不优雅的中央,首先要提的就是连贯步骤简单。很多同学就因为总是连贯不胜利,后果被胜利劝退。
对等连贯,也就是下面说的点对点连贯,外围是由 RTCPeerConnection
函数实现。两个浏览器之间点对点的连贯和通信,实质上是两个 RTCPeerConnection 实例的连贯和通信。
用 RTCPeerConnection
构造函数创立的两个实例,胜利建设连贯之后,能够传输视频、音频或任意二进制数据(须要反对 RTCDataChannel API)。同时也提供了连贯状态监控,敞开连贯的办法。不过两点之间数据单向传输,只能由发动端向接收端传递。
咱们当初依据外围 API,梳理一下具体连贯步骤。
第一步:创立连贯实例
首先创立两个连贯实例,这两个实例就是相互通信的单方。
var peerA = new RTCPeerConnection()
var peerB = new RTCPeerConnection()
下文对立将发动直播的一端称为
发动端
,接管观看直播的一端称为接收端
当初的这两个连贯实例都还没有数据。假如 peerA 是发动端,peerB 是接收端,那么 peerA 的那端就要像上一步一样获取到媒体流数据,而后增加到 peerA 实例,实现如下:
var stream = await navigator.mediaDevices.getUserMedia()
stream.getTracks().forEach(track => {peerA.addTrack(track, stream)
})
当 peerA 增加了媒体数据,那么 peerB 必然会在后续连贯的某个环节接管到媒体数据。因而还要为 peerB 设置监听函数,获取媒体数据:
peerB.ontrack = async event => {let [ remoteStream] = event.streams
console.log(remoteStream)
})
这里要留神:必须 peerA 增加媒体数据之后,能力进行下一步! 否则后续环节中 peerB 的 ontrack
事件就不会触发,也就不会拿到媒体流数据。
第二步:建设对等连贯
增加数据之后,两端就能够开始建设对等连贯。
建设连贯最重要的角色是 SDP
(RTCSessionDescription),翻译过去就是 会话形容
。连贯单方须要各自建设一个 SDP,然而他们的 SDP 是不同的。发动端的 SDP 被称为 offer
,接收端的 SDP 被称为 answer
。
其实两端建设对等连贯的实质就是调换 SDP,在调换的过程中互相验证,验证胜利后两端的连贯能力胜利。
当初咱们为两端创立 SDP。peerA 创立 offer,peerB 创立 answer:
var offer = await peerA.createOffer()
var answer = await peerB.createAnswer()
创立之后,首先接收端 peerB 要将 offset 设置为近程形容,而后将 answer 设置为本地形容:
await peerB.setRemoteDescription(offer)
await peerB.setLocalDescription(answer)
留神:当 peerB.setRemoteDescription 执行之后,peerB.ontrack 事件就会触发。当然前提是第一步为 peerA 增加了媒体数据。
这个很好了解。offer 是 peerA 创立的,相当于是连贯的另一端,因而要设为“近程形容”。answer 是本人创立的,天然要设置为“本地形容”。
同样的逻辑,peerB 设置实现后,peerA 也要将 answer 设为近程形容,offer 设置为本地形容。
await peerA.setRemoteDescription(answer)
await peerA.setLocalDescription(offer)
到这里,互相交换 SDP 已实现。然而通信还未完结,还差最初一步。
当 peerA 执行 setLocalDescription 函数时会触发 onicecandidate
事件,咱们须要定义这个事件,而后在外面为 peerB 增加 candidate:
peerA.onicecandidate = event => {if (event.candidate) {peerB.addIceCandidate(event.candidate)
}
}
至此,端对端通信才算是真正建设了!如果过程顺利的话,此时 peerB 的 ontrack 事件内应该曾经接管到媒体流数据了,你只须要将媒体数据渲染到一个 video 标签上即可实现播放。
还要再提一次:这几步看似简略,理论程序十分重要,一步都不能出错,否则就会连贯失败!如果你在实践中遇到问题,肯定再回头检查一下步骤有没有出错。
最初咱们再为 peerA 增加状态监听事件,检测连贯是否胜利:
peerA.onconnectionstatechange = event => {if (peerA.connectionState === 'connected') {console.log('对等连贯胜利!')
}
if (peerA.connectionState === 'disconnected') {console.log('连贯已断开!')
}
}
本地模仿通信源码
上一步咱们梳理了点对点通信的流程,其实次要代码也就这么多。这一步咱们再把这些知识点串起来,简略实现一个本地模仿通信的 Demo,运行起来让大家看成果。
首先是页面布局,非常简单。两个 video 标签,一个播放按钮:
<div class="local-stream-page">
<video autoplay controls muted id="elA"></video>
<video autoplay controls muted id="elB"></video>
<button onclick="onStart()"> 播放 </button>
</div>
而后设置全局变量:
var peerA = null
var peerB = null
var videoElA = document.getElementById('elA')
var videoElB = document.getElementById('elB')
按钮绑定了一个 onStart
办法,在这个办法内获取媒体数据:
const onStart = async () => {
try {
var stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true
})
if (videoElA.current) {videoElA.current.srcObject = stream // 在 video 标签上播放媒体流}
peerInit(stream) // 初始化连贯
} catch (error) {console.log('error:', error)
}
}
onStart 函数里调用了 peerInit
办法,在这个办法内初始化连贯:
const peerInit = stream => {
// 1. 创立连贯实例
var peerA = new RTCPeerConnection()
var peerB = new RTCPeerConnection()
// 2. 增加视频流轨道
stream.getTracks().forEach(track => {peerA.addTrack(track, stream)
})
// 增加 candidate
peerA.onicecandidate = event => {if (event.candidate) {peerB.addIceCandidate(event.candidate)
}
}
// 检测连贯状态
peerA.onconnectionstatechange = event => {if (peerA.connectionState === 'connected') {console.log('对等连贯胜利!')
}
}
// 监听数据传来
peerB.ontrack = async event => {const [remoteStream] = event.streams
videoElB.current.srcObject = remoteStream
}
// 调换 sdp 认证
transSDP()}
初始化连贯之后,在 transSDP
办法中调换 SDP 建设连贯:
const transSDP = async () => {
// 1. 创立 offer
let offer = await peerA.createOffer()
await peerB.setRemoteDescription(offer)
// 2. 创立 answer
let answer = await peerB.createAnswer()
await peerB.setLocalDescription(answer)
// 3. 发送端设置 SDP
await peerA.setLocalDescription(offer)
await peerA.setRemoteDescription(answer)
}
留神:这个办法里的代码程序十分重要,如果改了程序多半会连贯失败!
如果顺利的话,此时曾经连贯胜利。截图如下:
咱们用两个 video 标签和三个办法,实现了本地模仿通信的 demo。其实“本地模仿通信”就是模仿 peerA 和 peerB 通信,把两个客户端放在了一个页面上,当然理论状况不可能如此,这个 demo 只是帮忙咱们理清通信流程。
Demo 残缺代码我曾经上传 GitHub,须要查阅请看 这里,拉代码间接关上 index.html
即可看到成果。
接下来咱们摸索实在场景 —— 局域网如何通信。
局域网两端通信
上一节实现了本地模仿通信,在一个页面模仿了两个端连贯。当初思考一下:如果 peerA 和 peerB 是一个局域网下的两个客户端,那么本地模仿通信的代码须要怎么改呢?
本地模仿通信咱们用了 两个标签 和 三个办法 来实现。如果离开的话,首先 peerA 和 peerB 两个实例,以及各自绑定的事件,必定是离开定义的,两个 video 标签也同理。而后获取媒体流的 onStart 办法肯定在发动端 peerA,也没问题,然而调换 SDP 的 transSDP
办法此时就生效了。
为啥呢?比方在 peerA 端:
// peerA 端
let offer = await peerA.createOffer()
await peerA.setLocalDescription(offer)
await peerA.setRemoteDescription(answer)
这里设置近程形容用到了 answer,那么 answer
从何而来?
本地模仿通信咱们是在同一个文件里定义变量,能够相互拜访。然而当初 peerB 在另一个客户端,answer 也在 peerB 端,这样的话就须要在 peerB 端创好 answer 之后,传到 peerA 端。
雷同的情理,peerA 端创立好 offer 之后,也要传到 peerB 端。这样就须要两个客户端近程替换 SDP,这个过程被称作 信令
。
没错,信令是近程替换 SDP 的 过程,并不是某种凭证。
两个客户端须要相互被动替换数据,那么就须要一个服务器提供连贯与传输。而“被动替换”最适宜的实现计划就是 WebSocket
,因而咱们须要基于 WebSocket 搭建一个 信令服务器
来实现 SDP 调换。
不过本篇不会详解信令服务器,我会独自出一篇搭建信令服务器的文章。当初咱们用两个变量 socketA
和 socketB
来示意 peerA 和 peerB 两端的 WebSocket 连贯,而后革新对等连贯的逻辑。
首先批改 peerA 端 SDP 的传递与接管代码:
// peerA 端
const transSDP = async () => {let offer = await peerA.createOffer()
// 向 peerB 传输 offer
socketA.send({type: 'offer', data: offer})
// 接管 peerB 传来的 answer
socketA.onmessage = async evt => {let { type, data} = evt.data
if (type == 'answer') {await peerA.setLocalDescription(offer)
await peerA.setRemoteDescription(data)
}
}
}
这个逻辑是发动端 peerA 创立 offer 之后,立刻传给 peerB 端。当 peerB 端执行完本人的代码并创立 answer 之后,再回传给 peerA 端,此时 peerA 再设置本人的形容。
此外,还有 candidate 的局部也须要近程传递:
// peerA 端
peerA.onicecandidate = event => {if (event.candidate) {socketA.send({ type: 'candid', data: event.candidate})
}
}
peerB 端稍有不同,必须是接管到 offer 并设置为近程形容之后,才能够创立 answer,创立之后再发给 peerA 端,同时也要接管 candidate 数据:
// peerB 端,接管 peerA 传来的 offer
socketB.onmessage = async evt => {let { type, data} = evt.data
if (type == 'offer') {await peerB.setRemoteDescription(data)
let answer = await peerB.createAnswer()
await peerB.setLocalDescription(answer)
// 向 peerA 传输 answer
socketB.send({type: 'answer', data: answer})
}
if (type == 'candid') {peerB.addIceCandidate(data)
}
}
这样两端通过近程互传数据的形式,就实现了局域网内两个客户端的连贯通信。
总结一下,两个客户端监听对方的 WebSocket 发送音讯,而后接管对方的 SDP,相互设置为近程形容。接收端还要获取 candidate 数据,这样“信令”这个过程就跑通了。
一对多通信
后面咱们讲的,不论是本地模仿通信,还是局域网两端通信,都属于“一对一”通信。
然而在很多场景下,比方在线教育班级直播课,一个老师可能要面对 20 个学生,这是典型的一对多场景。然而 WebRTC 只反对点对点通信,也就是一个客户端只能与一个客户端建设连贯,那这种状况该怎么办呢?
记不记得后面说过:两个客户端之间点对点的连贯和通信,实质上是两个 RTCPeerConnection 实例的连贯和通信。
那咱们变通一下,比方当初接收端可能是 peerB,peerC,peerD 等等好几个客户端,建设连贯的逻辑与之前的一样不必变。那么发动端是否从“一个连贯实例 ”扩大到“ 多个连贯实例”呢?
也就是说,发动端尽管是一个客户端,然而不是能够同时创立多个 RTCPeerConnection 实例。这样的话,一对一连贯的实质没有变,只不过把多个连贯实例放到了一个客户端,每个实例再与其余接收端连贯,变相的实现了一对多通信。
具体思路是:发动端保护一个连贯实例的数组,当一个接收端申请建设连贯时,发动端新建一个连贯实例与这个接收端通信,连贯胜利后,再将这个实例 push 到数组外面。当连贯断开时,则会从数组里删掉这个实例。
这种形式我亲测无效,上面咱们对发动端的代码革新。其中类型为 join
的音讯,示意连接端申请连贯。
// 发动端
var offer = null
var Peers = [] // 连贯实例数组
// 接收端申请连贯,传来标识 id
const newPeer = async id => {
// 1. 创立连贯
let peer = new RTCPeerConnection()
// 2. 增加视频流轨道
stream.getTracks().forEach(track => {peer.addTrack(track, stream)
})
// 3. 创立并传递 SDP
offer = await peerA.createOffer()
socketA.send({type: 'offer', data: { id, offer} })
// 5. 保留连贯
Peers.push({id, peer})
}
// 监听接收端的信息
socketA.onmessage = async evt => {let { type, data} = evt.data
// 接收端申请连贯
if (type == 'join') {newPeer(data)
}
if (type == 'answer') {let index = Peers.findIndex(row => row.id == data.id)
if (index >= 0) {await Peers[index].peer.setLocalDescription(offer)
await Peers[index].peer.setRemoteDescription(data.answer)
}
}
}
这个就是外围逻辑了,其实不难,思路理顺了就很简略。
因为信令服务器咱们还没有具体介绍,理论的一对多通信须要信令服务器参加,所以这里我只介绍下实现思路和外围代码。更具体的实现,我会在下一篇介绍信令服务器的文章再次实战一对多通信,到时候残缺源码一并奉上。
我想学更多
为了更好的爱护原创,之后的文章我会首发微信公众号 前端砍柴人。这个公众号只做原创,每周至多一篇高质量文章,方向是前端工程与架构,Node.js 边界摸索,一体化开发与利用交付等实际与思考。
除此之外,我还建了一个微信群,专门提供对这个方向感兴趣的同学交换与学习。如果你也感兴趣,欢送加我微信 ruidoc
拉你入群,咱们一起提高~