WebRTC实战 | 快速搭建1对1音视频通话
正在加载今日诗词....
2023-07-16

WebRTC 系列教程分为三篇进行介绍,本篇为第二篇,上一篇WebRTC 这么火 🔥,前端靓仔,请收下这篇入门教程

通过上一篇文章,各位小伙伴们应该都了解了 WebRTC 相关概念以及通信过程后,趁热打铁,我们来搭建一个 WebRTC 一对一音视频通话项目。

hot

WebRTC 一对一通话分为服务端和客户端,服务端使用 node 实现信令服务,客户端使用 vue 实现逻辑。

服务端实现:使用 Socket.io 搭建信令服务器

信令服务器主要是处理通信双端进行媒体协商和 ICE 候选(NAT 穿越信息)

按照 Socket.IO官网教程很快便能搭建一个聊天应用程序。

  1. 安装 socket.io 和 express

    yarn add socket.io express
    
  2. 使用 express 初始化 socket 服务

    let app = express()
    let http_server = http.createServer(app)
    http_server.listen(3333)
    let io = new Server(http_server, {
      // 允许跨域访问
      cors: {
        origin: '*',
      },
    })
    http_server.on('listening', () => {
      let addr = http_server.address()
      if (addr) {
        let port = typeof addr === 'string' ? addr : addr.port
      }
    })
    

    通过new Server初始化后,返回的io就是一个 socket 实例

  3. 监听connection事件,获取客户端参数

    使用上一步的 socket 实例监听connection消息,Socket 会加入由其自己的 id 标识的房间,此 id 是一个随机的 20 个字符的标识符;

    handshake 对象是 Socket.IO 会话开始时发生的握手的一些详细信息,从这里可以拿到客户端传过来的query参数。

    io.on('connection', (socket) => {
      const { query } = socket.handshake
      // 获取socket连接参数 username和room
      const { username, room } = query
      ...
    })
    
  4. 加入房间

    当获取到客户端传递的room房间号后,我们就可以开始加入房间,通过joinleave可以加入和离开房间,即socket.join(room)

    // 房间人数满了 不再加入
    if (members.length === 2) {
      return
    }
    // 连接管理
    let user = { userId: socket.id, username }
    members.push(user)
    // 房间管理
    socket.join(room)
    // 每次连接向房间发送用户列表
    io.to(room).emit('userList', members)
    

    向当前房间发送用户信息,房间存在两个人时就可以发起音视频通信了

  5. 监听 WebRTC 信令服务所需要监听的事件

    // 接收到《接收者》发送candidate连接成功消息,转发给《接收者》
    socket.on('candidate', (room, candidate) => {
      socket.to(room).emit('candidate', candidate)
    })
    // 接收到《发起者》发送offer,转发给《接收者》
    socket.on('offer', (room, offer) => {
      socket.to(room).emit('offer', offer)
    })
    // 接收到《接收者》发送answer,转发给《发起者》
    socket.on('answer', (room, answer) => {
      socket.to(room).emit('answer', answer)
    })
    

    注册以上三种事件,用于处理 WebRTC 双端进行媒体协商和 ICE 候选的过程

  6. 离开房间/断开连接

    除了disconnect表示 socket 在断开连接时触发,还有disconnecting也有同样的作用,可以看下官网对这两个特殊事件的说明

    socket.on('disconnect', () => {
      members = members.filter((m) => m.username !== user.username)
      // 断开连接发送用户列表
      io.to(room).emit('userList', members)
    })
    

    当客户端调用socket.disconnect()断开连接时,服务端向房间内推送当前用户信息,客户端做相应处理。

到这里,信令服务器服务端的工作已经完成,接下来我们看下客户端。

客户端实现:搭建 1 对 1 音视频聊天

客户端使用 vite + vue + antd 搭建,初始化 vue 项目后开始下列操作

采集媒体流

因为需要同时显示本地和远端视频,所以需要两个<video>标签

<template>
  <div class="video">
    <section class="local-video">
      <video ref="localVideoRef"></video>
    </section>
    <section class="remote-video">
      <video ref="remoteVideoRef"></video>
    </section>
  </div>
  ...
</template>

<script setup>
import Video from './Video.vue'
import { ref, onMounted } from 'vue'

const emits = defineEmits(['streamSuccess', 'leave'])

const remoteVideoRef = ref(null)
const localVideoRef = ref(null)

onMounted(() => initVideo(localVideoRef.value.$el))
// 初始化本地视频
const initVideo = async (video) => {
  if (!video) return
  try {
    let config = {
      video: true,
      audio: true,
    }
    let stream = await navigator.mediaDevices.getUserMedia(config)
    video.srcObject = stream
    emits('streamSuccess', { stream, remoteVideoRef })
    video.play()
  } catch (e) {
    console.log(`error: `, e)
  }
}
...
</script>

在挂载时,开始初始化本地视频:

使用navigator.mediaDevices.getUserMedia(config)获取到视频流后,通过video.srcObject可以将获取到的视频流在 video 元素上进行播放。

初始化 Socket.io 客户端

在采集媒体流完成后,回调streamSuccess方法,开始初始化 socket 服务

// 等待本地视频初始化完成后发送信令服务
const streamSuccess = ({ stream, remoteVideoRef }) => {
  const info = { ...userInfo, localStream: stream, remoteVideoRef }
  socket = initSocket(info)
}
// initSocket
const initSocket = ({ username, room, remoteVideoRef, localStream }) => {
  let localPc
  // 连接server 携带username和room
  const socket = io('http://localhost:3333', {
    path: '/',
    query: { username, room },
  }).connect()

  // 当有人加入房间时
  socket.on('userList', async (res) => {
    // 房间少于两人时 对方掉线 则关闭对方视频
    if (res.length < 2) {
      let video = remoteVideoRef.value.$el
      video.srcObject = null
      return
    }
    if (username === res[0]?.username) sendOffer()
  })
}

我这里的逻辑是:当有人加入房间时,服务端 Socket 发送房间人数列表,当房间存在两个人时,第一个加入的人作为呼叫方,第二个人作为被叫方,双方开始媒体协商

媒体协商

上面我们在说到房间存在两人时,呼叫方开始发送 offer,即sendOffer方法,在此方法内部需要做以下事情:

创建 RTCPeerConnection 对象

我们在上篇文章提到 RTCPeerConnection 对象的作用,还不太清楚的小伙伴可以回头再看一下。

为了让 WebRTC 的相关 api 在各个浏览器中都能够正常的运行,强烈建议使用补充库,例如强大并且被广泛支持的 Adapter.js,引入它可以确保 WebRTC 在各个浏览器中的兼容性。

两种方法引入:

  1. 直接引入

    在 html 中添加https://webrtc.github.io/adapter/adapter-latest.js

  2. npm 方式

    yarn add webrtc-adapter
    

    在项目中引入即可

    // 仅仅只需要引入 adapter 即可
    import adapter from 'webrtc-adapter'
    

这里的rtcConfig用于设置新连接的选项:

export const rtcConfig = {
  iceServers: [
    {
      urls: ['stun:stun.l.google.com:19302'],
    },
    {
      urls: ['turn:wangxiang.website:3478'],
      username: 'admin',
      credential: 'admin',
    },
  ],
}

如上所示,我们配置了iceServers,数组内每个对象描述一个可能被 ICE 代理使用的服务器,也就是 STUN 或 TURN 服务器。如果未指定,则将在没有可用的 STUN 或 TURN 服务器的情况下进行连接尝试,就只能使用 host 类型的 candidate 尝试连接,即局域网内 P2P 通讯。

const sendOffer = async () => {
  // 初始化RTCPeerConnection对象
  localPc = new RTCPeerConnection(rtcConfig)
  // 添加RTC流
  localStream.getTracks().forEach((track) => {
    localPc.addTrack(track, localStream)
  })
  // 创建offer

  // 收集candidate
}

处理 offer

这里通过createOffer方法创建 offer,通过setLocalDescription存储为本地描述,接着通过信令服务将 offer 传递到被叫方。

const sendOffer = async () => {
  // 初始化RTCPeerConnection对象
  // 添加RTC流
  // 处理offer
  let offer = await localPc.createOffer()
  await localPc.setLocalDescription(offer)
  socket.emit('offer', room, offer)
}

处理 candidate

当通过setLocalDescription方法更改本地描述之后,RTCPeerConnection 对象会抛出icecandidate事件。通过监听此事件,可以获取到收集到的 candidate,接着通过信令服务器将 candidate 传递到被叫方。

const sendOffer = async () => {
  // 初始化RTCPeerConnection对象
  // 添加RTC流
  // 创建offer
  // 处理 candidate
  localPc.onicecandidate = (event) => {
    console.log('localPc:', event.candidate, event)
    if (event.candidate)
      socket.emit(SOCKET_ON_RTC.CANDIDATE, room, {
        pc: 'local',
        candidate: event.candidate,
      })
  }

呼叫方 offer 发送完毕,等待被叫方传回 answer。 wait.png

被叫方处理

被叫方首先通过信令服务器获取到 offer:

socket.on('offer', async (offer) => {
  console.log(`接收到offer`, offer)
  // 存储offer 发送answer
  sendAnswer(offer)
})

接着在sendAnswer方法中执行与呼叫方同样的操作,即创建 RTCPeerConnection 对象、处理 answer、处理 candidate 等

const sendAnswer = offer => {
  localPc = new RTCPeerConnection(rtcConfig)
  // 添加RTC流
  localStream.getTracks().forEach((track) => {
    localPc.addTrack(track, localStream)
  })
  // 处理answer
  await localPc.setRemoteDescription(offer)
  const answer = await localPc.createAnswer()
  await localPc.setLocalDescription(answer)
  socket.emit('answer', room, answer)
  // 处理candidate
  localPc.onicecandidate = (event) => {
    console.log('localPc:', event.candidate, event)
    if (event.candidate)
      socket.emit('candidate', room, {
        pc: 'remote',
        candidate: event.candidate
      })
  }
}

呼叫方接收 answer

// 接收answer
socket.on('answer', async (answer) => {
  console.log(`接收到answer`, answer)
  // 完善本地remote描述
  await localPc.setRemoteDescription(answer)
})

媒体协商过程结束。

建立连接

icecandidate 收集后,通过信令服务器发送到对端,双端交换 candidate 信息,建立连接。

localPc.addIceCandidate(candidate)

处理音视频数据

初始化 RTCPeerConnection 对象完成后,即localPc,我们调用了addTrack()方法可以将本地视频流localStream的每个轨道添加到localPc对象的一组轨道上。

当建立连接完成后,这些 tracks 会源源不断的传输给对端,也就是我们称的推流。

当对端收到 tracks 推送时,通过回调函数localPc.ontrack,就可以拿到远程推送的流媒体对象:

let video = remoteVideoRef.value.$el
localPc.ontrack = (e) => {
  video.srcObject = e.streams[0]
  video.oncanplay = () => video.play()
}

当互相拿到对方的流媒体对象时,将流媒体通过video.srcObject赋给<video />标签,此时音视频数据就展现在页面上了 🎉。

更多

通过 RTCDataChannel 传输文本、文件等

RTCDataChannel 接口是 WebRTC API 的一个功能,可以为通信双方打开一个通道,通过该通道发送和接收任意数据,如:文本、文件等

dataChannel = RTCPeerConnection.createDataChannel(label[, options]);

通过 RTCPeerConnection 提供的createDataChannel方法来创建 RTCDataChannel,当通道连接成功后,触发回调函数onopen,这时候就可以通过dataChannel.send(message)发送消息了。

let dataChannel
const sendMessage = (username) => {
  const button = document.querySelector('.data-channel__button')
  const input = document.querySelector('.data-channel__input')
  button.disabled = false
  button.onclick = () => {
    if (!input.value) return
    const message = `${username}: ${input.value}`
    dataChannel.send(message)
    input.value = ''
    receiveMessage(message)
  }
}
const receiveMessage = (message) => {
  const output = document.querySelector('.data-channel__output')
  output.scrollTop = output.scrollHeight //窗口总是显示最后的内容
  output.value = output.value + message + '\r'
}
const openDataChannel = (localPc, username) => {
  // 创建datachannel通道
  dataChannel = localPc.createDataChannel('test')
  // datachannel通道打开 开始发送消息
  dataChannel.onopen = () => sendMessage(username)
  localPc.ondatachannel = (event) => {
    // 成功拿到 RTCDataChannel
    const dataChannel = event.channel
    dataChannel.onmessage = (event) => receiveMessage(event.data)
  }
}

export default openDataChannel

通过监听ondatachannel事件,获取到对端的RTCDataChannel,接着监听onmessage事件,即可拿到对端传输的消息。

中继方式:搭建 TURN 服务器

我们上面搭建的音视频通信只能在同一网络中进行,如果不同网络连接就会失败,这时候就需要 TURN 服务器来解决这个问题。

TURN 服务有两个作用:一是提供 STUN 服务,客户端通过 STUN 服务获取自己的公网地址;二是提供数据中继服务(当数据通信双方无法通过 P2P 传输时,就需要通过中继的方式让通信双方的数据可以互通)。

  1. 下载源码

    目前最著名的 TURN 服务器是由 Google 发起的开源项目 coturn,coturn 服务器完整的实现了 STUN/TURN/ICE 协议,支持 P2P 穿透防火墙。

    git clone https://github.com/coturn/coturn
    
  2. 生成 Markfile

    cd coturn
    ./configure --prefix=/usr/local/coturn
    

    需要安装 libevent 库,不然会报错

    coturn-make-error.png

  3. 安装 libevent

    如果已经安装过,则忽略这一步

    首先安装运行 coturn 需要依赖的环境

    yum install openssl openssl-libs libevent2 libevent-devel
    

    如果 libevent2 安装失败,手动安装 下载地址:http://libevent.org/

    # 解压
    tar -xf libevent-2.0.22-stable.tar.gz
    cd ibevent-2.0.22-stable
    # 生成Markfile
     ./configure --prefix=/usr
    # 执行make编译libevent
    make
    # 安装
    make install
    

    完成后,测试是否安装成功

    ls -a /usr/lib |grep libevent
    

    libevent-success.png

  4. 继续安装 coturn

    ./configure
    make
    make install
    
  5. 验证 coturn 服务是否安装成功

    which turnserver
    # /usr/local/bin/turnserver
    
  6. 生成签名

    cd /usr/local/etc/
    
    openssl req -x509 -newkey rsa:2048 -keyout /usr/local/etc/turn_server_pkey.pem -out /usr/local/etc/turn_server_cert.pem -days 99999 -nodes
    

    一路回车即可,完成后在/use/local/etc 目录下就有了turn_server_pkey.pemturn_server_cert.pem两个文件。

  7. 修改配置文件

    cp turnserver.conf.default turnserver.conf
    vim turnserver.conf
    
    # 网卡
    listening-device=eth0
    # 监听的端口
    listening-port=3478
    # 绑定的公网地址
    external-ip=43.140.xxx.xx
    # 用户名和密码
    user=admin:123456
    # 名称
    realm=admin
    
  8. 启动 turnserver

    ./turnserver ../etc/turnserver.conf
    

    查看是否启动成功

    ps -ef|grep turnserver
    
  9. 检测

    在线检测 ICE 穿透的地址:https://webrtc.github.io...

    上面这个地址用于检测 turn 服务是否可用,注意查看服务器端口是否已开启3478`

    coturn-server-success.png

    如果你测试一个 STUN 服务器,你能收集到一个类型为“srflx”的候选者,它就可以工作。如果你测试一个 TURN 服务器,你能收集到一个类型为“relay”的候选人,它就会工作。

  10. 搭建完成后,将可以将此链接复制,作为 RTCPeerConnection 连接时的 iceServers 配置。完成后,在不同网络之间,也能实现音视频通话了。

one-to-one.png

总结

本篇文章是在之前原理的基础上,从 0 到 1 实现一对一的音视频通话,接下来还有 WebRTC 多人通信的架构,以及如何使用 LiveKit 快速搭建多人音视频通话。

👉🏻 在线体验地址👉🏻 完整代码地址

以上就是本文的全部内容,希望这篇文章对你有所帮助,欢迎点赞和收藏 🙏,如果发现有什么错误或者更好的解决方案及建议,欢迎随时联系。

京ICP备2022027737号
Copyright © 2022 - present @wangxiang

  • ☀️
  • 🌑