webRTC 实现两端通信

1,471 阅读5分钟

webRTC 是一款基于浏览器的实时通信的解决方案,于 2011 年 6 月 1 日开源并纳入万维网联盟的 W3C 推荐标准,在 2021 年 1 月被 W3C 和 IETF 发布为正式标准。

介绍

整个 webRTC 在 web 上的结构如下图

image

MediaStream

在实现整个通信的过程中,至少需要两个角色,一个是本地角色,一个是远程的角色。

在 webRTC 的实现过程中,可以简单理解为两端通过 RTCPeerconnection 实现两端的 MediaStream 的交互。所以整个通信的主体除了两端 web 设备外,主要就是RTCPeerconnection 和获取 MediaStream 的 getUserMedia 这两个 webRTC 相关的 API 了。

同时保证通信的体现,即视频流的呈现,还需要通过 <video> 或者 <audio> 组件对 MediaStream 进行展示,目前可以通过 srcObject (兼容性) 中传入对应的 MediaStream 进行实现。

从每端的用户角度来看,其实都拥有以下部分内容,不论是 local 还是 remote 的用户都拥有用于展示本地 MediaStream 的 local video 以及展示对方 MediaStream 的 remote video。local 和 remote 的用户本地的 MediaStream 都可通过 getUserMedia 获取到。(当然 <video> 也并不是一定必须的,通信的时候也可以只需要看远程的媒体流展示)

image.png

简单的 case

<template>
  <video id="localVideo" :srcObject="localStream"></video>
  <video id="remoteVideo" :srcObject="remoteStream"></video>
</template>

<script>
export default {
  data() {
    return {
      localStream: null,
      remoteStream: null
    };
  },
  mounted() {
    navigator.getUserMedia({ video: true, audio: true }, (stream) => {
      // 将媒体流 id 传输到 <video> 或 <audio> 组件 中,将媒体流进行展示
      this.localStream = stream;
      this.localStream.getTracks().forEach(function (track) {
        console.log('local add track');
      });
    }, (error) => {
      console.error(error);
    })
  }
};
</script>

点对点连接(RTCPeerConnection)

如果要实现双方的通信,单纯的展示媒体流并不够,这个时候就需要 RTCPeerconnection 出场了,可以同时用于跟踪本地流和远程流,在本地与远程用户之间建立点对点连接,并对该连接进行保活。

建立点对点连接一般需要通过 stun 服务器或者 turn 服务器来允许TCP或UDP的连接能跨越NAT或者防火墙。但在我们进行开发测试时可以不需要这样的服务器就能建立连接,只要本地用户和远程用户在同一网络下。

简单 case

var iceconf = {
  // 这里使用谷歌提供的 demo 使用的 stun 服务器。一般为了尽量支持所有的网络拓扑结构,通常这里会同时配置 stun 和 turn 服务器。
  iceServers: [{urls: 'stun:stun.l.google.com:19302'}]
}
// 初始化点对点连接对象
pc = new RTCPeerConnection(iceconf)

但单纯使用 RTCPeerConnection 也并不是就能够实现通信了,毕竟像会话描述协议这样的数据并不能通过 RTCPeerConnection 创建的点对点连接进行交互,所以这时候还需要一个信令服务器来协商建立连接,交换消息数据(如用户信息、会话描述信息、ICE 候选信息等)。信令服务器可以是 websocket 服务、XMLHttpRequest、事件服务器,当然不同的信令服务器对不同的消息有着不同的兼容

image

在进行通信之前,需要得到在线的用户信息,这里可以通过 WebSocket 实现

// template
<button v-for="{{users}}" :key="item"  @click="invite(item)">
    {{item}}
</button>

// js
// 初始化信令通道
var socket = new Websocket(socketConf)
socket.onmessage = (msg) => {
  if (msg.userList) {
    // 获取当前登录的远程用户列表
    this.users = msg.userList.filter(item => !!item && item !== currentUser)
  }
}
export default {
  data() {
    return {
      .....
      users: [],
      targetUser: null,
      currentUser: 'localUser'
    }
  },
  ......
  methods: {
    invite(user) {
      // 邀请的目标用户
      this.targetUser = user
      // 创建本地媒体流
      // 创建点对点连接RTCPeerConnection
      ...
    }
  }
};

将前面两部分组合起来,通过 RTCPeerConnection 的 addTrack,本地用户可将本地创建的媒体流通过已建立的点对点连接传输到远程,远程用户可通过创建的点对点连接的 ontrack 事件接收到本地用户传输的媒体流 ,这样便能够在同一个用户端同时查看到本地和远程媒体流了。(若本地用户想要查看远程用户的本地媒体流,远程用户使用 addTrack ,本地用户通过 ontrack 事件监听即可) image

var iceconf = {
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' },
    { url: 'turn:turnserver.com', username: 'user', credential: 'pass' },
  ],
}
var pendingCandidates = []
// 初始化简单信令通道
var socket = new WebSocket(socketConf)

pc = new RTCPeerConnection(iceconf)

navigator.getUserMedia(
  { video: true, audio: true },
  (stream) => {
    this.localStream = stream
    this.localStream.getTracks().forEach(function (track) {
      // 将一个本地的媒体 track 注册到点对点连接中,这些 track 将被传输给另一个对等点(远程用户)
      sender = pc.addTrack(track, this.localStream)
      // 呼叫者创建一个类型为 “offer” 的会话描述用于连接(此处的 SDP 信息中没有ICE 候选信息,若需要可通过监听 icegatheringstatechange 事件,当候选信息收集完成(complete) 时,重新创建会话描述)
      pc.createOffer(offerConf).then(function (offer) {
        // 应用生成的 SDP 作为对等连接的本地描述,此时 ICE 服务器才会通知创建 ICE 候选信息。
        pc.setLocalDescription(offer)
        // 将创建的会话描述 SDP 信息传输给远程用户
        socket.send({
          from: this.currentUser,
          to: this.targetUser,
          offer: offer.sdp,
        })
      })
    })
  },
  (error) => {
    console.error(error)
  }
)

// 监听ICE 候选信息收集情况
pc.onicecandidate = function (evt) {
  if (evt.candidate) {
    // 将 ICE 候选信息传输给远程用户。这里需要明确远程用户信息,否则将无法进行 ICE 候选信息交换,最终无法建立连接。
    socket.send({
      from: this.currentUser,
      to: this.targetUser,
      candidate: evt.candidate,
    })
  }
}
// 注册简单信令通道数据监听事件
socket.onmessage = function (msg) {
  if (msg.candidate) {
    // 收到远程发送的候选信息,存储 ice 候选信息
    pendingCandidates.push(candidate)
  }
  // 收到发起者发送的类型为 “offer” 的会话描述信息
  if (msg.offer) {
    // 将对等连接的远程会话描述信息设置为 offer
    pc.setRemoteDescription(msg.offer)
    // 若本地未创建了媒体流,此处还需要先创建媒体流(getUserMedia)
    pc.createAnswer().then(function (answer) {
      // 将本地会话描述信息设置为 answer
      pc.setLocalDescription(answer)
      // 向发起者发送类型为“answer” 的会话描述信息
      socket.send({
        from: this.currentUser,
        to: this.targetUser,
        answer: answer.sdp,
      })
    })
  }
  // 收到接收者发送的类型为 “answer” 的会话描述信息
  if (msg.answer) {
    // 将对等连接的远程会话描述信息设置为 answer
    pc.setRemoteDescription(msg.answer)
  }
}
pc.signalingstatechange = function (evt) {
  // 当收到 answer 并且设置远程会话描述(setRemoteDescription(answer))之后,signalingState 的状态会转变成 stable
  if (pc.signalingState === 'stable') {
    for (const candidate of pendingCandidates) {
      // 收到候选信息,与远程的候选信息进行协商(addIceCandidate 只能在双方建立连接(即 setRemoteDescription(answer) )之后进行协商)
      pc.addIceCandidate(candidate)
    }
  }
}
pc.ontrack = function (evt) {
  // 将收到的远程媒体流输出到 <video> 中
  this.remoteStream = evt.stream[0]
}

附录

以下为在使用 webrtc 实现实时通信过程中查过的一些感觉比较有用的文档

WebRTC

WebRTC API(MDN)

WebRTC 1.0: Real-Time Communication Between Browsers

rfc5245

Building Blocks of UDP

stun-parameters