Skip to content

WebRTC

1、WebRTC简介

  • WebRTC(Web Real-Time Communication)是一个支持网页浏览器进行实时语音对话或视频对话的API。它允许网络应用或者网站实现点对点的音视频通信, 而无需借助任何插件。WebRTC的目标是使实时通信变得简单易用,让开发者能够轻松地在网页上实现实时音视频通信功能。

2、应用场景

  • 1.点对点视频聊天:如微信视频等实时视频通话应用;
  • 2.多人视频会议:企业级多人视频会议系统,如:飞书、钉钉、腾讯会议等;
  • 3.在线教育:如在线课堂、在线辅导等;
  • 4.直播:游戏直播、课程直播等;

3、P2P通信原理

  • WebRTC使用P2P(Peer-to-Peer)通信原理,即点对点通信。在P2P通信中,两个设备可以直接进行通信,而不需要通过服务器中转。 这使得通信更加高效,延迟更低。

  • 要实现两个客户端的实时音视频通信,并且这两个客户端可能处于不同网络环境,使用不同的设备,都需要解决哪些问题?

  • 主要是下面3个问题:

  • 1.如何发现对方?

  • 2.不同的音视频编解码能力如何沟通?

  • 3.如何联系上对方?

  • 如何发现对方?

  • 在P2P通信的过程中,双方需要交换一些数据比如媒体信息,网络数据等信息,我们通常称这个过程叫做“信令(signaling)”。

  • 对应的服务器即“信令服务器(signaling server)”,通常也有人将它称为“房间服务器”,因为它不仅可以交换彼此的媒体 信息和网络信息,还可以交换一些其他信息,比如房间信息、用户信息等。

    • 比如:
      • (1)通知彼此xxx用户加入了房间;
      • (2)通知彼此xxx用户离开了房间;
      • (3)通知彼此xxx用户正在共享屏幕;
  • 为了避免出现冗余,并最大限度的提高与已有技术的兼容性,WebRTC标准并没有规定信令方法和协议。这里我们会用websocket来实现信令服务器。

  • 不同的音视频编解码能力如何沟通?

  • 不同浏览器对于音视频的编解码能力是不同的。

  • 在WebRTC中,有一个专门的协议,称为 Session Description Protocol(SDP),用于描述音视频的编解码能力。

  • 因此:参与音视频通讯的双方想要了解对方支持的媒体格式,必须要交换SDP信息。而交换SDP的过程,通常称为媒体协商

  • SDP信息包括:音视频编码格式、分辨率、帧率、传输协议等。

  • 如何联系上对方?

  • 其实就是网络协商的过程,即参与音视频实时通信的双方要了解彼此的网络情况,这样才有可能找到一条相互通讯的链路。

  • 理想的网络情况是每个客户端都有自己的私有公网IP,这样的话就可以直接进行点对点连接。实际上呢,出于网络安全和其他 原因的考虑,大多数客户端之间都是在某个局域网内,需要网络地址转换(NAT)。

  • 在WebRTC中我们使用ICE(Interactive Connectivity Establishment)机制建立网络连接。ICE协议通过一系列的技术(如: STUN、TURN、TURN+STUN等)帮助通信双方发现和协商可用的公共网络地址,从而实现NAT穿越。

  • ICE的工作原理如下:

    • 1.首先,通信双方收集本地网络地址(包括私有地址和公共地址)以及通过STUN和TURN服务器获取的候选地址。
    • 2.接下来,双方通过信令服务器交换这些候选地址。
    • 3.通信双方使用这些候选地址进行连接测试,确定最佳的可用地址。
    • 4.一旦找到可用的地址,双方就可以开始进行音视频通信。
  • 在WebRTC中网络信息通常用candidate来描述

  • 针对上面三个问题的总结:就是通过WebRTC提供的API获取各端的媒体信息SDP以及网络信息candidate,并通过信令服务器交换这些信息,从而实现音视频通信。

4、WebRTC API

1、音视频采集 getUserMedia

js
// 获取本地音视频流
const getLocalStream = async () => {
  const stream = await navigator.mediaDevices.getUserMedia({ // 获取音视频流
    video: true, // 是否获取视频
    audio: true // 是否获取音频
  })
  localVideo.value!.srcObject = stream // 将音视频流赋值给video标签
  localVideo.value!.play() // 播放音视频流
  return stream
}

2、核心对象 RTCPeerConnection

  • RTCPeerConnection是WebRTC中最核心的对象,它负责建立和管理点对点的连接,包括媒体协商、ICE协商、媒体传输等。
js
const peer = new RTCPeerConnection({
  iceServers:[ // 部署的时候需要配置
    {
      urls: 'stun:stun.l.google.com:19302' // 谷歌的公共服务
    },
    {
      urls: 'turn:turn.example.com:3478', // TURN服务器地址
      username: 'user', // TURN服务器用户名
      credential: 'pass' // TURN服务器密码
    }
  ]
})

主要会用到以下几个方法:

  • 媒体协商方法

  • createOffer:创建一个offer,用于开始媒体协商。

  • createAnswer:创建一个answer,用于响应一个offer。

  • setLocalDescription:设置本地SDP。

  • setRemoteDescription:设置远程SDP。

  • 重要事件:

  • onicecandidate:当ICE候选者被收集到时触发。

  • onaddstream:当远程媒体流到达时触发。

  • 整个媒体协商过程可以简化为三个步骤对应上述四个媒体协商方法:

  • 1.呼叫端创建Offer(createOffer)并将offer消息(内容是呼叫端的SDP)通过信令服务器传送给接收端, 同时调用setLocalDescription方法将offer消息保存到呼叫端的本地SDP中。

  • 2.接收端收到对端的Offer信息后调用setRemoteDescription方法将Offer信息保存到接收端的本地SDP中,然后创建Answer(createAnswer) 并将answer消息(内容是接收端的SDP)通过信令服务器传送给呼叫端,同时调用setLocalDescription方法将answer消息保存到接收端的本地SDP中。

  • 3.呼叫端收到对端的Answer信息后调用setRemoteDescription方法将Answer信息保存到呼叫端的本地SDP中,至此,媒体协商完成。

  • 经过上述三个步骤,则完成了P2P通信过程中的媒体协商部分,实际上在呼叫端以及接收端调用setLocalDescription同时也开始了收集各端自己的网络 信息(candidate),然后各端通过监听事件onicecandidate收集各自的candidate并通过信令服务器传递给对端,进而打通P2P通信的网络通道,并通过 监听 onaddstream 事件拿到对方的视频流进而完成了整个视频通话过程。

5、案例

  • 1、项目搭建

    • 前端项目
    • 1.项目使用vue3+ts运行如下命令:
    • bash
      npm create vite@latest webrtc-client -- --template vue-ts
    • 2.引入tailwindcss:
    • bash
      npm install -D tailwindcss postcss autoprefixer
      npx tailwindcss init -p
    • 3.配置tailwindcss,在tailwind.config.js中添加如下配置:
    • js
      module.exports = {
        content: [
          "./index.html",
          "./src/**/*.{vue,js,ts,jsx,tsx}",
        ],
        theme: {
          extend: {},
        },
        plugins: [],
      }
    • 4.在main.ts中引入tailwindcss:
    • ts
      import './style.css'
    • 5.在style.css中引入tailwindcss:
    • scss
      @tailwind base;
      @tailwind components;
      @tailwind utilities;
    • 6.在App.vue中添加如下代码:
    • Vue
      <template>
        <div class="flex items-center flex-col text-center p-12 h-full">
          <div class="relative h-full mb-4">
            <video 
               ref="localVideo"
               class="w-96 h-full bg-gray-200 mb-4"></video>
            <video
               ref="remoteVideo"
               class="w-32 h-48 absolute bottom-0 right-0 object-cover"></video>
            <div v-if="caller && calling" class="absolute top-2/3 left-36 flex flex-col items-center">
              <p class="mb-4 text-white">等待对方接听...</p>
              <img @click="hangUp" src="./hangup.svg" alt="hangup" class="w-16 cursor-pointer">
            </div>
            <div v-if="called && calling" class="absolute top-2/3 left-32 flex flex-col items-center">
              <p class="mb-4 text-white">收到视频邀请...</p>
              <div class="flex">
                <img @click="hangUp" src="./hangup.svg" alt="hangup" class="w-16 cursor-pointer mr-4">
                <img @click="acceptCall" src="./accept.svg" alt="answer" class="w-16 cursor-pointer" >
              </div>
            </div>
          </div>
          <div class="flex gap-2 mb-4">
            <button class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white" 
             @click="callRemote">发起视频</button>
            <button class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white" 
             @click="hangUp">挂断视频</button>
          </div>
        </div>
      </template>
      <script lang="ts" setup>
       import { ref } from 'vue'
       const called = ref<boolean>(false) // 是否是接受方
       const caller = ref<boolean>(false) // 是否是发起方
       const calling = ref<boolean>(false) // 呼叫中
       const communicating = ref<boolean>(false) // 视频通话中
       const localVideo = ref<HTMLVideoElement>() // video标签实例,播放人的视频
       const remoteVideo = ref<HTMLVideoElement>() // video标签实例,播放对方的视频
      
       // 发起方发起视频中请求
       const callRemote = () => {
        console.log('发起视频')
       }
       // 接受方接受视频请求
       const acceptCall = () => {
        console.log('同意视频邀请')
       }
       // 挂断视频
       const hangUp = () => {
        console.log('挂断视频') 
       }
      </script>
    • 执行完上面步骤可以运行npm run dev 来在本地启动项目了
    • 后端项目
    • 创建一个webrtc-server的文件夹,执行npm init ,一路回车即可,然后运行如下命令安装 socket.io 和 nodemon:
    • bash
      npm install socket.io nodemon --save
    • 在webrtc-server文件夹下创建一个index.js文件,内容如下:
    • javascript
      const socket = require('socket.io');
      const http = require('http');
      const server = http.createServer();
      const io = socket(server,{
        cors:{
          origin:'*', //允许所有来源
        }
      });
      io.on('connection',sock=>{
        console.log('连接成功')
      // 向客户端发送连接成功的消息
        sock.emit('connection success')
      })
      server.listen(3000,()=>{
        console.log('服务器启动成功')
      })
    • 在packag.json中添加如下命令:
    • json
       {
        "scripts": {
          "start": "nodemon index.js"
        }
      }
    • 运行npm start即可启动后端项目
    • 前端连接信令服务器
    • 前端需要安装 socket.io-client,并连接信令服务器
    • 先安装 socket.io-client:
    • bash
       npm install socket.io-client --save
    • Vue
      <script lang="ts" setup>
       import { onMounted,ref,onUnmounted } from 'vue'
       import { io , Socket} from 'socket.io-client'  
      
       const socket = ref<Socket | null>(null) // socket实例
       onMounted(()=>{
         socket.value = io('http://localhost:3000') // 连接信令服务器
         socket.value.on('connection success',()=>{
           console.log('连接信令服务器成功')
         })
       })
       onUnmounted(()=>{
         socket.value?.disconnect() // 断开连接
       })
      </script>
    • 发起视频请求
    • 角色:用户A -- 发起方,用户B -- 接收方
    • 房间:类比聊天窗口
    • 连接成功时加入房间:
    • javascript
       // 前端代码
      const roomId = '001'
        sock.on('connectionSuccess',()=>{
          console.log('连接信令服务器成功')
          sock.emit('joinRoom',roomId) // 前端发送加入房间事件
        })
    • 后端代码:
    • javascript
       // 后端代码
        sock.on('joinRoom',(room)=>{
          console.log('用户加入房间',room)
          sock.join(room) // 加入房间
        })
    • 用户A发送视频请求并通知用户B:
    • javascript
       // 发起方 发起视频聊天请求
      const callRemote = async ()=>{
      console.log('发起视频请求')
      caller.value = true
      calling.value = true
      await getLocalStream() // 获取本地视频流
      // 向信令服务器发起请求事件
      socket.value?.emit('callRemote',roomId)
      }
    • 用户B同意视频请求,并且通过信令服务器通知A用户
    • javascript
       // 接收方 接受视频聊天请求
      const acceptCall =  ()=>{
        console.log('接受视频请求')
        // 向信令服务器发起请求事件
        socket.value?.emit('acceptCall',roomId)
      }
    • 开始交换SDP信息和candidate信息
    • 1.用户A创建RTCPeerConnection对象,添加本地音视频流,生成offer,并且通过信令服务器将offer发送给用户B
    • javascript
       // 发起方 创建RTCPeerConnection对象
      peer.value = new RTCPeerConnection()
      // 添加本地视频流
      peer.value.addStream(localStream.value)
      // 创建offer
      const offer = await peer.value.createOffer({
        offerToReceiveAudio: 1,
        offerToReceiveVideo: 1
      })
      // 设置本地描述的offer
      await peer.value.setLocalDescription(offer)
      // 向信令服务器发送offer
      socket.value?.emit('sendOffer',{offer,roomId})
    • 2.用户B收到用户A的offer
    • javascript
       // 接收方 接收offer
      sock.on('sendOffer',(offer)=>{
        if(called.value){ // 判断接收方
           console.log('收到offer',offer)
        }
      })
    • 3.用户B创建RTCPeerConnection对象,添加本地音视频流,设置远程描述的offer,生成answer,并且通过信令服务器将answer发送给用户A
    • javascript
         // 获取本地音视频流
         const getLocalStream = async () => {
         //共享屏幕
         // return navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }).then(stream => {
         // localVideo.value!.srcObject = stream // 将音视频流赋值给video标签
         // localVideo.value!.play() // 播放音视频流
         // localStream.value = stream
         // return stream
         // });
         //开启音频摄像头 (注意:浏览器安全策略,部署到服务器如果是非https,则无法开启摄像头,getUserMedia不存在该方法)
         const stream = await navigator.mediaDevices.getUserMedia({ // 获取音视频流
           video: true, // 是否获取视频
           audio: true // 是否获取音频
         })
         localVideo.value!.srcObject = stream // 将音视频流赋值给video标签
         localVideo.value!.play() // 播放音视频流
         localStream.value = stream
         return stream
         }
       // 接收方 接收offer
      // 创建RTCPeerConnection对象
      peer.value = new RTCPeerConnection()
      // 添加本地视频流
      const stream = await getLocalStream()
      peer.value.addStream(stream)
      // 设置远程描述的offer
      await peer.value.setRemoteDescription(offer);
      // 创建answer
      const answer = await peer.value.createAnswer()
      // 设置本地描述的answer
      await peer.value.setLocalDescription(answer)
      // 向信令服务器发送answer
      socket.value?.emit('sendAnswer',{answer,roomId})
    • 4.用户A收到用户B的answer
    • javascript
       // 发送方 接收answer
      sock.on('sendAnswer',(answer)=>{
        if(caller.value){ // 判断发送方
           // 设置远程描述的answer
           peer.value.setRemoteDescription(answer)
        }
      })
    • 5.用户A获取candidate信息并且通过信令服务器发送candidate给用户B
    • javascript
      // 发送方 获取candidate信息并且通过信令服务器发送candidate给用户B
      peer.value.onicecandidate = (event) => {
        if (event.candidate) { // 判断是否有candidate信息
          // 通过信令服务器发送candidate信息给用户B
          socket.value?.emit('sendCandidate', { candidate: event.candidate, roomId })
        }
      }
    • 6.用户B收到并添加用户A的candidate信息
    • javascript
      // 接收方 接收candidate信息
      sock.on('sendCandidate', async (candidate) => {
        if (!caller.value) { // 判断接收方
          // 添加用户A的candidate信息
         await peer.value.addIceCandidate(candidate)
        }
      })
    • 7.用户B获取candidate信息并且通过信令服务器发送candidate给用户A
    • javascript
      // 接收方 获取candidate信息并且通过信令服务器发送candidate给用户A
      peer.value.onicecandidate = async (event) => {
        if (event.candidate) { // 判断是否有candidate信息
          // 通过信令服务器发送candidate信息给用户A
          await socket.value?.emit('sendCandidate', { candidate: event.candidate, roomId })
        }
      }
    • 8.用户A收到并添加用户B的candidate信息
    • javascript
      //  接收candidate信息
      sock.on('sendCandidate', async (candidate) => {
        // if (!caller.value) { 
          await peer.value.addIceCandidate(candidate)
        // }
      })
    • 9.用户A和用户B建立连接,可以进行P2P通信流
    • javascript
       // 监听onaddstream事件,获取对方视频流
       peer.value.onaddstream = (event) => {
        calling.value = false
        communicating.value = true;
        // 将对方视频流添加到video元素中
        remoteVideo.value!.srcObject = event.stream
        // 播放视频
        remoteVideo.value!.play()
      }
    • 挂断视频
    • javascript
      // 挂断视频
      const hangUp = async () => {
        socket.value?.emit('hangUp',roomId)
      } 
      
      // 状态恢复
      const reset = () => {
       // 关闭本地视频流
       localVideo.value!.srcObject?.getTracks().forEach(track => track.stop())
       // 关闭远程视频流
       remoteVideo.value!.srcObject?.getTracks().forEach(track => track.stop())
       // 关闭本地视频流
       localVideo.value!.srcObject = null
       // 关闭远程视频流
       remoteVideo.value!.srcObject = null
       // 关闭本地
       peer.value.close()
        calling.value = false
        caller.value = false
        called.value = false
        peer.value = null
        communicating.value = false
        localStream.value = null
      }
  • 拓展:peerjs

  • peerjs-github

  • 服务端实现

  • javascript
     const {PeerServer} =require('peer')
     const peerServer = PeerServer({port:9000,path:'/myPeerServer'})
  • 客户端实现

  • vue
    <script lang="ts" setup>
    import { ref,onMounted } from 'vue'  
    import {Peer} from 'peerjs'
    
    const url = ref<string>('') 
    const peer = ref<any>()
    const localVideo = ref<HTMLVideoElement>()
    const remoteVideo = ref<HTMLVideoElement>()
    const peerId = ref<string>()
    const remoteId = ref<string>()
    const caller = ref<boolean>(false)
    const called = ref<boolean>(false)
    const callObj = ref<any>()
    
     onMounted(() => {
       peer.value = new Peer({ // 连接信令服务器
         host: 'localhost',
         port: 9000,
         path: '/myPeerServer',
       })
       peer.value.on('open', (id) => {
         peerId.value = id
       })
       // 接受视频请求
       peer.value.on('call', async (call) => {
         called.value = true
         callObj.value = call
       })
     })
    
     // 获取本地视频流
    async function getLocalStream(constraints) {
    // 获取媒体流
      const stream = await navigator.mediaDevices.getUserMedia(constraints)
    // 将媒体流设置到video标签上播放
      localVideo.value!.srcObject = stream
      localVideo.value!.play()
      return stream
    }
    
    const acceptCalled = async () => {
     // 接受视频
      const stream = await getLocalStream({ video: true, audio: true })
      callObj.value.answer(stream);
      callObj.value.on('stream', (remoteStream) => { // 接收到的视频流
      called.value = false
       // 将远程媒体流添加到video标签中播放
      remoteVideo.value!.srcObject = remoteStream
      remoteVideo.value!.play()
    })
    }
    
    // 开启视频
    const callRemote = async () => {
       if(!remoteId.value){
         alert('请输入对方id')
         return
       }
       const stream = await getLocalStream({ video: true, audio: true })
       // 将本地媒体流发送到远程 Peer
       const call = peer.value.call(remoteId.value, stream)
       called.value = true  
       call.on('stream', (remoteStream) => { // 接收到的视频流
        caller.value = false
        // 将远程媒体流添加到video标签中播放
        remoteVideo.value!.srcObject = remoteStream
        remoteVideo.value!.play()
       })
     }
    
    </script>