WebRTC|实时网络API的使用方法

429 阅读8分钟

WebRTC是指网络实时通信

它允许在浏览器之间创建一个直接的数据通信。

你可以用它来

  • 流媒体音频
  • 流媒体视频
  • 分享文件
  • 视频聊天
  • 创建一个点对点的数据共享服务
  • 创建多人游戏

以及更多。

这是一项努力,使实时通信应用易于创建,利用网络技术,因此,在你的网络浏览器旁边不需要第三方插件或外部技术。

未来应该不需要插件来执行RTC,而应该全部依靠一种标准技术--WebRTC。

所有的现代浏览器都支持它(Edge部分支持,它不支持RTCDataChannel ,见下文)。

WebRTC实现了以下的API。

  • **MediaStream**从用户端获取数据流,如摄像头和麦克风
  • **RTCPeerConnection**处理对等体之间的音频和视频流的通信
  • **RTCDataChannel**处理其他类型的数据(任意数据)的通信

对于视频和音频通信,你将使用MediaStreamRTCPeerConnection

其他类型的应用,如游戏、文件共享等,则依靠RTCDataChannel

在这篇文章中,我将创建一个使用WebRTC连接两个远程网络摄像头的例子,使用Node.jsWebsockets服务器。

提示:在你的项目中,你可能会使用一个抽象了许多细节的库。本教程旨在解释WebRTC技术,让你知道引擎盖下发生了什么。

这个API让你可以使用JavaScript访问摄像头和麦克风流。

这里有一个简单的例子,要求你访问摄像机并在页面中播放视频。

请看Flavio Copes(@flaviocopes)在CodePen上的PenWebRTC MediaStream简单例子

我们添加一个按钮来访问摄像机,然后我们添加一个video ,属性为autoplay

我们还添加了WebRTC适配器,这有助于实现跨浏览器的兼容性。

<button id="get-access">Get access to camera</button>
<video autoplay></video>
<script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>

JS监听按钮的点击,然后调用navigator.mediaDevices.getUserMedia() ,要求提供视频。

参见getUserMedia()教程。

然后我们通过调用stream.getVideoTracks() ,在调用getUserMedia() 的结果中访问所使用的摄像机的名称。

流被设置为video 标签的源对象,这样就可以进行播放。

document
  .querySelector('#get-access')
  .addEventListener('click', async function init(e) {
    try {
      const stream = await navigator.mediaDevices.getUserMedia({
        video: true
      })
      document.querySelector('video').srcObject = stream
      document.querySelector('#get-access').setAttribute('hidden', true)
      setTimeout(() => {
        track.stop()
      }, 3 * 1000)
    } catch (error) {
      alert(`${error.name}`)
      console.error(error)
    }
  })

getUserMedia()的参数可以指定对视频流的额外要求。

navigator.mediaDevices.getUserMedia(
  {
    video: {
      mandatory: { minAspectRatio: 1.333, maxAspectRatio: 1.334 },
      optional: [{ minFrameRate: 60 }, { maxWidth: 640 }, { maxHeigth: 480 }]
    }
  },
  successCallback,
  errorCallback
)

要获得音频流,你也会要求获得音频媒体对象,并调用stream.getAudioTracks() ,而不是stream.getVideoTracks()

在播放3秒后,我们通过调用track.stop() ,停止视频流。

信令

信令不是WebRTC协议的一部分,但它是实时通信的一个重要部分。

通过信令,设备之间进行通信,并就通信初始化达成一致,共享信息,如IP地址和端口、分辨率等等。

你可以自由选择任何一种通信机制,包括。

我们使用Websockets来实现它。

使用npm安装ws

我们从一个简单的Websockets服务器骨架开始。

const WebSocket = require('ws')

const wss = new WebSocket.Server({ port: 8080 })

wss.on('connection', (ws) => {
  console.log('User connected')

  ws.on('message', (message) => {
    console.log(`Received message => ${message}`)
  })

  ws.on('close', () => {
    //handle closing
  })
})

我们首先在前台添加一个 "用户名 "框,这样用户就可以在连接到服务器之前选择一个用户名。

<div id="login">
  <label for="username">Login</label>
  <input id="username" placeholder="Login" required="" autofocus="" />
  <button id="login">Login</button>
</div>

在客户端的JavaScript中,我们初始化Websocket到服务器。

const ws = new WebSocket('ws://localhost:8080')

ws.onopen = () => {
  console.log('Connected to the signaling server')
}

ws.onerror = (err) => {
  console.error(err)
}

当用户输入用户名并点击登录按钮时,我们得到用户名的值并对其进行检查,然后我们将这些信息发送给服务器。

document.querySelector('button#login').addEventListener('click', (event) => {
  username = document.querySelector('input#username').value

  if (username.length < 0) {
    alert('Please enter a username 🙂')
    return
  }

  sendMessage({
    type: 'login',
    username: username
  })
})

sendMessage 是一个封装函数,用于向Websocket服务器发送JSON编码的信息。我们使用一个 参数来区分我们要发送的不同类型的信息。type

const sendMessage = (message) => {
  ws.send(JSON.stringify(message))
}

在服务器端,我们对JSON消息进行解码,并检测消息的类型。

ws.on('message', (message) => {
  let data = null

  try {
    data = JSON.parse(message)
  } catch (error) {
    console.error('Invalid JSON', error)
    data = {}
  }

  switch (data.type) {
    case 'login':
      console.log('User logged', data.username)
      break
  }
})

我们必须将用户添加到连接的用户列表中,存储在一个关联数组users

如果已经有另一个用户拥有这个相同的用户名,我们就向客户端发送一个错误,否则我们就将用户添加到数组中,存储Websocket连接。

//...
case 'login':
  console.log('User logged', data.username)
  if (users[data.username]) {
    sendTo(ws, { type: 'login', success: false })
  } else {
    users[data.username] = ws
    ws.username = data.username
    sendTo(ws, { type: 'login', success: true })
  }
  break

在客户端,当这种情况发生时,我们处理消息,并调用getUserMedia() 函数。

ws.onmessage = (msg) => {
  console.log('Got message', msg.data)

  const data = JSON.parse(msg.data)

  switch (data.type) {
    case 'login':
      handleLogin(data.success)
      break
  }
}
//handleLogin...
navigator.mediaDevices.getUserMedia(
  { video: true, audio: true },
  (localStream) => {
    //...
  },
  (error) => {
    console.error(error)
  }
)

在获得本地流对象的成功回调中,我们首先隐藏#login div,我们可以显示一个新的div,承载video 的元素。

<div id="call">
  <video id="local" autoplay></video>
  <video id="remote" autoplay></video>
</div>
document.querySelector('div#login').style.display = 'none'
document.querySelector('div#call').style.display = 'block'

这样我们就可以在页面中的video#local 元素上开始流媒体。

document.querySelector('video#local').src = window.URL.createObjectURL(
  localStream
)

RTCPeerConnection

现在我们必须配置一个RTCPeerConnection。

现在你会发现有几个外来的术语。ICE代表Internet Connectivity EstablishmentSTUN代表Session Traversal of User Datagram Protocol [UDP] Through Network Address Translators [NATs])

在实践中,我们必须有一种方法让位于本地网络(如你的家)的2台计算机相互交谈。由于大多数用户都在NAT路由器后面,所以计算机不能接受开箱即来的连接。

在连接发生之前,有很多代码是需要的,所以我们可以让2个端点互相连接。

对方连接必须使用STUN服务器发起,该服务器将发回我们的ICE候选者,以便与另一个对方进行通信。

这基本上就是下面的代码所做的。

//using Google public stun server
const configuration = {
  iceServers: [{ url: 'stun:stun2.1.google.com:19302' }]
}

connection = new RTCPeerConnection(configuration)

connection.addStream(localStream)

connection.onaddstream = (event) => {
  document.querySelector('video#remote').srcObject = event.stream
}

connection.onicecandidate = (event) => {
  if (event.candidate) {
    sendMessage({
      type: 'candidate',
      candidate: event.candidate
    })
  }
}

我们使用谷歌的公共STUN服务器配置一个ICE服务器(这对测试来说很好用,但你很可能需要为生产使用配置你自己的)。

然后我们使用addStream() ,将本地流添加到该连接中,并为RTCPeerConnection.onaddstreamRTCPeerConnection.onicecandidate 事件传递两个回调处理程序。

RTCPeerConnection.onaddstream 当我们有一个远程音频/视频流进来的时候,就会被调用,我们把它分配给远程的 元素来进行传输。video

对于数据,该事件将被称为RTCPeerConnection.ondatachannel ,而不是使用addStream() 方法,你将使用createDataChannel()

RTCPeerConnection.onicecandidate 当我们收到一个候选的ICE时,就会调用这个事件,并将其发送到我们的服务器。

在这之前,我们必须尝试连接到一个对等体。

在这个简单的例子中,我们必须知道我们想要连接的另一个人的用户名,而且他们必须已经 "登录"。

两个用户中的一个必须在方框中输入用户名,然后点击 "呼叫 "按钮。

<div>
  <input id="username-to-call" placeholder="Username to call" />
  <button id="call">Call</button>
  <button id="close-call">Close call</button>
</div>

在客户端的JavaScript中,我们监听这个按钮的点击事件,并获得用户名的值。

如果用户名是有效的,我们就把它储存在我们稍后要使用的otherUsername 变量中,然后我们创建一个报价

let otherUsername

document.querySelector('button#call').addEventListener('click', () => {
  const callToUsername = document.querySelector('input#username-to-call').value

  if (callToUsername.length === 0) {
    alert('Enter a username 😉')
    return
  }

  otherUsername = callToUsername

  // create an offer
  connection.createOffer(
    (offer) => {
      sendMessage({
        type: 'offer',
        offer: offer
      })

      connection.setLocalDescription(offer)
    },
    (error) => {
      alert('Error when creating an offer')
      console.error(error)
    }
  )
})

一旦我们通过调用RTCPeerConnection.createOffer() ,得到了报价,我们就把它传递给我们的服务器,然后我们调用RTCPeerConnection.setLocalDescription() ,配置连接。

在服务器端,我们处理该提议,并将其发送给我们想要连接的用户,以data.otherUsername

case 'offer':
  console.log('Sending offer to: ', data.otherUsername)
  if (users[data.otherUsername] != null) {
    ws.otherUsername = data.otherUsername
    sendTo(users[data.otherUsername], {
      type: 'offer',
      offer: data.offer,
      username: ws.username
    })
  }
  break

客户端以Websocket消息的形式收到这个提议,我们调用handleOffer 方法。

ws.onmessage = (msg) => {
  //...
  switch (data.type) {
    //...
    case 'offer':
      handleOffer(data.offer, data.username)
      break
  }
}

这个方法接受要约和用户名,我们首先调用RTCPeerConnection.setRemoteDescription() ,指定连接的远程端属性,然后调用RTCPeerConnection.createAnswer() ,创建要约的答案。

一旦答案被创建,我们就用它来设置连接的本地端属性,并使用sendMessage 函数将其发布到我们的服务器。

RTCSessionDescription 对象描述了连接能力,在RTC发生之前必须被初始化。我们必须设置连接的本地端描述(setLocalDescription )和连接的另一端描述(setRemoteDescription )。

const handleOffer = (offer, username) => {
  otherUsername = username
  connection.setRemoteDescription(new RTCSessionDescription(offer))
  connection.createAnswer(
    (answer) => {
      connection.setLocalDescription(answer)
      sendMessage({
        type: 'answer',
        answer: answer
      })
    },
    (error) => {
      alert('Error when creating an answer')
      console.error(error)
    }
  )
}

在服务器端,我们处理answer 事件。

case 'answer':
  console.log('Sending answer to: ', data.otherUsername)
  if (users[data.otherUsername] != null) {
    ws.otherUsername = data.otherUsername
    sendTo(users[data.otherUsername], {
      type: 'answer',
      answer: data.answer
    })
  }
  break

我们检查我们想要交谈的用户名是否存在,然后我们将其设置为Websocket连接的otherUsername 。我们将答案发回给该用户。

在客户端,该用户将收到触发handleAnswer() 方法的answer 消息,该方法调用RTCPeerConnection.setRemoteDescription() ,以同步连接的远程端属性。

ws.onmessage = (msg) => {
  //...
  switch (data.type) {
    //...
    case 'answer':
      handleAnswer(data.answer)
      break
  }
}

const handleAnswer = (answer) => {
  connection.setRemoteDescription(new RTCSessionDescription(answer))
}

现在会话描述已经同步,两个对等体开始确定如何使用ICE协议在它们之间建立连接。这是绕过NAT路由器限制的关键部分。

RTCPeerConnection 在这个过程中,我们会产生一个ICE候选者并调用其 回调函数。在回调中,我们使用我们的 函数,将ICE候选者发送到连接的另一端。onicecandidate sendMessage()

connection.onicecandidate = (event) => {
  if (event.candidate) {
    sendMessage({
      type: 'candidate',
      candidate: event.candidate
    })
  }
}

在服务器端,我们处理candidate 事件,将其发送给其他对等体。

//...
case 'candidate':
  console.log('Sending candidate to:', data.otherUsername)

  if (users[data.otherUsername] != null) {
    sendTo(users[data.otherUsername], {
      type: 'candidate',
      candidate: data.candidate
    })
  }

  break

另一个对等体在客户端收到它。

ws.onmessage = (msg) => {
  //...
  switch (data.type) {
    //...
    case 'candidate':
      handleCandidate(data.candidate)
      break
  }
}

const handleCandidate = (candidate) => {
  connection.addIceCandidate(new RTCIceCandidate(candidate))
}

我们调用RTCPeerConnection.addIceCandidate() ,在本地添加候选人。

在这一点上,ICE交换步骤和会话描述已经完成,协商已经完成,WebRTC可以使用自动商定的连接机制,连接两个远程对等体。

我们现在有两台计算机直接互相通信,交换他们的网络摄像头流

关闭连接

连接可以通过编程方式关闭。我们有一个 "关闭呼叫 "的按钮,一旦连接完成,我们可以点击它。

<button id="close-call">Close call</button>
document.querySelector('button#close-call').addEventListener('click', () => {
  sendMessage({
    type: 'close'
  })

  handleClose()
})

const handleClose = () => {
  otherUsername = null
  document.querySelector('video#remote').src = null

  connection.close()
  connection.onicecandidate = null
  connection.onaddstream = null
}

在客户端,我们删除远程流,我们关闭RTCPeerConnection 连接,将其事件的回调设置为null。

我们将close 消息发送给服务器,服务器再将其发送给远程对等体。

case 'close':
  console.log('Disconnecting from', data.otherUsername)
  users[data.otherUsername].otherUsername = null
  if (users[data.otherUsername] != null) {
    sendTo(users[data.otherUsername], { type: 'close' })
  }
  break

所以在客户端我们可以调用handleClose() 函数。

ws.onmessage = (msg) => {
  //...
  switch (data.type) {
    //...
    case 'close':
      handleClose()
      break
  }
}

完整的例子可以在这个Gist上找到。