关于前端:音视频通信加餐-WebRTC一肝到底

11次阅读

共计 9661 个字符,预计需要花费 25 分钟才能阅读完成。

最近须要搭建一个在线课堂的直播平台,思考到清晰度和提早性,咱们统一认为应用 WebRTC 最合适。

起因有两点:首先是“点对点通信”十分吸引咱们,不须要两头服务器,客户端直连,通信十分不便;再者是 WebRTC 浏览器原生反对,其余客户端反对也很好,不像传统直播用 flv.js 做兼容,能够实现规范对立。

然而令我十分难堪的是,社区看了好几篇文章,实践架构写了一堆,但没一个能跑起来。WebRTC 外面概念很新也很多,了解它的 通信流程 才是最要害,这点恰好很少有形容。

于是我就本人捣鼓吧。捣鼓了几天,可算是整明确了。上面我联合本人的实践经验,依照我了解的关键步骤,带大家从利用场景的角度意识这个厉害的敌人 —— WebRTC

纲要预览

本文介绍的内容包含以下方面:

  • 什么是 WebRTC?
  • 获取媒体流
  • 对等连贯流程
  • 本地模仿通信源码
  • 局域网两端通信
  • 一对多通信
  • 我想学更多

什么是 WebRTC?

WebRTC (Web Real-Time Communications) 是一项实时通信技术,它容许网络应用或者站点,在 不借助两头媒介 的状况下,建设浏览器之间点对点(Peer-to-Peer)的连贯,实现视频流和音频流或者其余任意数据的传输。

简略的说,就是 WebRTC 能够不借助媒体服务器,通过浏览器与浏览器间接连贯(点对点),即可实现音视频传输。

如果你接触过直播技术,你就会晓得“没有媒体服务器”如许令人诧异。以往的直播技术大多是基于推流 / 拉流的逻辑实现的。要想做音视频直播,则必须有一台 流媒体服务器 做为两头站做数据转发。然而这种推拉流的计划有两个问题:

  1. 较高的提早
  2. 清晰度难以保障

因为两端通信都要先过服务器,就好比原本是一条直路,你偏偏“绕了半个圈”,这样必定会花更多的工夫,因而直播必然会有提早,即便提早再低也要 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 调换。

不过本篇不会详解信令服务器,我会独自出一篇搭建信令服务器的文章。当初咱们用两个变量 socketAsocketB 来示意 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  拉你入群,咱们一起提高~

正文完
 0