乐趣区

WebRTC实现p2p视频通话

简介

目的 帮助自己了解 webrtc 实现端对端通信

  # 使用流程
  git clone https://gitee.com/wjj0720/webrtc.git
  cd ./webRTC
  npm i
  npm run dev

  # 访问 127.0.0.1:3003/test-1.html 演示 h5 媒体流捕获
  # 访问 127.0.0.1:3003/local.html 演示 rtc 本地传输
  # 访问 127.0.0.1:3003/p2p.html 演示局域网端对端视屏    

what is WebRTC

  WebRTC(Web Real-Time Communication) 网页即时通信,是一个支持网页浏览器进行实时语音、视频对话的 API。于 2011 年 6 月 1 日开源并在 Google、Mozilla、Opera 支持下被纳入万维网联盟的 W3C 推荐标准
  闲话:目前主流实时流媒体 实现方式
  RTP :(Real-time Transport Protocol) 建立在 UDP 协议上的一种协议加控制

  HLS(HTTP Live Streamin)苹果公司实现的基于 HTTP 的流媒体传输协议

  RTMP(Real Time Messaging Protocol)Adobe 公司基于 TCP

  WebRTC google 基于 RTP 协议

WebRTC 组成

  • getUserMedia 负责获取用户本地的多媒体数据
  • RTCPeerConnection 负责建立 P2P 连接以及传输多媒体数据。
  • RTCDataChannel 提供的一个信令通道实现双向通信

h5 获取媒体流

目标:打开摄像头将媒体流显示到页面

MediaDevices 文档

  navigator.mediaDevices.getUserMedia({
    video: true, // 摄像头
    audio: true // 麦克风
  }).then(steam => {
    // video 标签的 srcObject
    video.srcObject = stream
  }).catch(e => {console.log(e)
  })

RTCPeerConnection

RTCPeerConnection api 提供了 WebRTC 端创建、链接、保持、监控闭连接的方法的实现
RTCPeerConnection MDN

  1. webRTC 流程

  以 A<=>B 创建 p2p 连接为例
  
  A 端:1. 创建 RTCPeerConnection 实例:peerA
    2. 将自己本地媒体流 (音、视频) 加入实例,peerA.addStream
    3. 监听来自远端传输过来的媒体流 peerA.onaddstream
    4. 创建 [SDP offer] 目的是启动到远程 (此时的远端也叫候选人))) 对等点的新 WebRTC 连接 peerA.createOffer 
    5. 通过 [信令服务器] 将 offer 传递给呼叫方
    6. 收到 answer 后去 [stun] 服务拿到自己的 IP, 通过信令服务将其发送给呼叫放

  B 端:
    1. 收到信令服务的通知 创建 RTCPeerConnection peerB,2. 也需要将自己本地媒体流加入通信 peerB.addstream
    3. 监听来自远端传输过来的媒体流 peerA.onaddstream
    4. 同样创建[SDP offer] peerA.createAnswer
    5. 通过 [信令服务器] 将 Answer 传递给呼叫方
    6. 收到对方 IP 同样去 [stun] 服务拿到自己的 IP 传递给对方

    至此完成 p2p 连接 触发双发 onaddstream 事件
  1. 信令服务

      信令服务器:webRTC 中负责呼叫建立、监控 (Supervision)、拆除(Teardown) 的系统
      为什么需要:webRTC 是 p2p 连接,那么连接之前如何获得对方信息,有如何将自己的信息发送给对方,这就需要信令服务
  2. SDP

      什么是 SDP
        SDP 完全是一种会话描述格式 ― 它不属于传输协议
        它只使用不同的适当的传输协议,包括会话通知协议(SAP)、会话初始协议(SIP)、实时流协议(RTSP)、MIME 扩展协议的电子邮件以及超文本传输协议(HTTP)SDP 协议是基于文本的协议,可扩展性比较强,这样就使其具有广泛的应用范围。WebRTC 中 SDP
        SDP 不支持会话内容或媒体编码的协商。webrtc 中 sdp 用于媒体信息 (编码解码信息) 的描述,媒体协商这一块要用 RTP 来实现
  3. stun

      1. 什么是 STUN
        STUN(Session Traversal Utilities for NAT,NAT 会话穿越应用程序)是一种网络协议,它允许位于 NAT(或多重 NAT)后的客户端找出自己的公网地址,查出自己位于哪种类型的 NAT 之后以及 NAT 为某一个本地端口所绑定的 Internet 端端口。这些信息被用来在两个同时处于 NAT 路由器之后的主机之间创建 UDP 通信。这种通过穿过路由直接通信的方式叫穿墙
      
      2. 什么是 NAT
        NAT(Network Address Translation,网络地址转换),是 1994 年提出的。当在专用网内部的一些主机本来已经分配到了本地 IP 地址,但现在又想和因特网上的主机通信时,于是乎在路由器上安装 NAT 软件。装有 NAT 软件的路由器叫做 NAT 路由器,它可以通过一个全球 IP 地址。使所有使用本地地址的主机在和外界通信时,这种通过使用少量的公有 IP 地址代表较多的私有 IP 地址的方式,将有助于减缓可用的 IP 地址空间的枯竭
    
      3.WebRTC 的穿墙
        目前常用的针对 UDP 连接的 NAT 穿透方法主要有:STUN、TURN、ICE、uPnP 等。其中 ICE 方式由于其结合了 STUN 和 TURN 的特点 webrtc 是用的就是这个
        google 提供的免费地址:https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/

SHOW THE CODE

  1. 前端

    <!DOCTYPE html>
      <html lang="zh">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <meta http-equiv="X-UA-Compatible" content="ie=edge" />
        <title> 端对端 </title>
      </head>
      <body>
        <div class="page-container">
          <div class="message-box">
            <ul class="message-list"></ul>
            <div class="send-box">
              <textarea class="send-content"></textarea>
              <button class="sendbtn"> 发送 </button>
            </div>
          </div>
          <div class="user-box">
            <video id="local-video" autoplay class="local-video"></video>
            <video id="remote-video" autoplay class="remote-video"></video>
            <p class="title"> 在线用户 </p>
            <ul class="user-list"></ul>
          </div>
          <div class="mask">
            <div class="mask-content">
              <input class="myname" type="text" placeholder="输入用户名加入房间">
              <button class="add-room"> 加入 </button>
            </div>
          </div>
          <div class="video-box">
    
          </div>
        </div>
        <script src="/js/jquery.js"></script>
        <script src="/js/socket.io.js"></script>
        <script>
          // 简单封装一下
          class Chat {constructor({ calledHandle, host, socketPath, getCallReject} = {}) {
              this.host = host
              this.socketPath = socketPath
              this.socket = null
              this.calledHandle = calledHandle
              this.getCallReject = getCallReject
              this.peer = null
              this.localMedia = null
            }
            async init() {this.socket = await this.connentSocket()
              return this
            }
            async connentSocket() {if (this.socket) return this.socket
              return new Promise((resolve, reject) => {let socket = io(this.host, { path: this.socketPath})
                socket.on("connect", () => {console.log("连接成功!")
                  resolve(socket)
                })
                socket.on("connect_error", e => {console.log("连接失败!")
                  throw e
                  reject()})
                // 呼叫被接受
                socket.on('answer', ({ answer}) => {this.peer && this.peer.setRemoteDescription(answer)
                })
                // 被呼叫事件
                socket.on('called', callingInfo => {this.called && this.called(callingInfo)
                })
                // 呼叫被拒
                socket.on('callRejected', () => {this.getCallReject && this.getCallReject()
                })
    
                socket.on('iceCandidate', ({ iceCandidate}) => {console.log('远端添加 iceCandidate');
                  this.peer && this.peer.addIceCandidate(new RTCIceCandidate(iceCandidate))
                })
    
              })
            }
            addEvent(name, cb) {if (!this.socket) return
              this.socket.on(name, (data) => {cb.call(this, data)
              })
            }
            sendMessage(name, data) {if (!this.socket) return
              this.socket.emit(name, data)
            }
            // 获取本地媒体流
            async  getLocalMedia() {
              let localMedia = await navigator.mediaDevices
                .getUserMedia({video: { facingMode: "user"}, audio: true })
                .catch(e => {console.log(e)
                })
              this.localMedia = localMedia
              return this
            }
            // 设置媒体流到 video
            setMediaTo(eleId, media) {document.getElementById(eleId).srcObject = media
            }
            // 被叫响应
            called(callingInfo) {this.calledHandle && this.calledHandle(callingInfo)
            }
            // 创建 RTC
            createLoacalPeer() {this.peer = new RTCPeerConnection()
              return this
            }
            // 将媒体流加入通信
            addTrack() {if (!this.peer || !this.localMedia) return
              //this.localMedia.getTracks().forEach(track => this.peer.addTrack(track, this.localMedia));
              this.peer.addStream(this.localMedia)
              return this
            }
            // 创建 SDP offer
            async createOffer(cb) {if (!this.peer) return
              let offer = await this.peer.createOffer({OfferToReceiveAudio: true, OfferToReceiveVideo: true})
              this.peer.setLocalDescription(offer)
              cb && cb(offer)
              return this
            }
            async createAnswer(offer, cb) {if (!this.peer) return
              this.peer.setRemoteDescription(offer)
              let answer = await this.peer.createAnswer({OfferToReceiveAudio: true, OfferToReceiveVideo: true})
              this.peer.setLocalDescription(answer)
              cb && cb(answer)
              return this
    
            }
            listenerAddStream(cb) {
              this.peer.addEventListener('addstream', event => {console.log('addstream 事件触发', event.stream);
                cb && cb(event.stream);
              })
              return this
            }
            // 监听候选加入
            listenerCandidateAdd(cb) {
              this.peer.addEventListener('icecandidate', event => {
                let iceCandidate = event.candidate;
                if (iceCandidate) {console.log('发送 candidate 给远端');
                  cb && cb(iceCandidate);
                }
    
              })
              return this
            }
            // 检测 ice 协商过程
            listenerGatheringstatechange  () {
              this.peer.addEventListener('icegatheringstatechange', e => {console.log('ice 协商中:', e.target.iceGatheringState);
              })
              return this
            }
            // 关闭 RTC
            closeRTC() {// ....}
          }
        </script>
        <script>
          $(function () {
    
            let chat = new Chat({
              host: 'http://127.0.0.1:3003',
              socketPath: "/websocket",
              calledHandle: calledHandle,
              getCallReject: getCallReject
            })
    
            // 更新用户列表视图
            function updateUserList(list) {$(".user-list").html(list.reduce((temp, li) => {temp += `<li class="user-li">${li.name} <button data-calling=${li.calling} data-id=${li.id} class=${li.id === this.socket.id || li.calling ? 'cannot-call' : 'can-call'}> 通话 </button></li>`
                return temp
              }, ''))
            }
            // 更新消息 li 表视图
            function updateMessageList(msg) {$('.message-list').append(`<li class=${msg.userId === this.socket.id ? 'left' : 'right'}>${msg.user}: ${msg.content}</li>`)
            }
    
            // 加入房间
            $('.add-room').on('click', async () => {let name = $('.myname').val()
              if (!name) return
              $('.mask').fadeOut()
              await chat.init()
              // 用户加入事件
              chat.addEvent('updateUserList', updateUserList)
              // 消息更新事件
              chat.addEvent('updateMessageList', updateMessageList)
    
              chat.sendMessage('addUser', { name})
    
            })
            // 发送消息
            $('.sendbtn').on('click', () => {let sendContent = $('.send-content').val()
              if (!sendContent) return
              $('.send-content').val('')
              chat.sendMessage('sendMessage', { content: sendContent})
            })
    
            // 视屏
            $('.user-list').on('click', '.can-call', async function () {
              // 被叫方信息
              let calledParty = $(this).data()
              if (calledParty.calling) return console.log('对方正在通话');
    
              // 初始本地视频
              $('.local-video').fadeIn()
              await chat.getLocalMedia()
              chat.setMediaTo('local-video', chat.localMedia)
    
              chat.createLoacalPeer()
                .listenerGatheringstatechange()
                .addTrack()
                .listenerAddStream(function (stream) {$('.remote-video').fadeIn()
                  chat.setMediaTo('remote-video', stream)
    
                })
                .listenerCandidateAdd(function (iceCandidate) {chat.sendMessage('iceCandidate', { iceCandidate, id: calledParty.id})
    
                })
                .createOffer(function (offer) {chat.sendMessage('offer', { offer, ...calledParty})
    
                })
            })
    
            // 呼叫被拒绝
            function getCallReject() {chat.closeRTC()
              $('.local-video').fadeIn()
              console.log('呼叫被拒');
            }
    
            // 被叫
            async function calledHandle(callingInfo) {if (!confirm(` 是否接受 ${callingInfo.name}的视频通话 `)) {chat.sendMessage('rejectCall', callingInfo.id)
                return
              }
    
              $('.local-video').fadeIn()
              await chat.getLocalMedia()
              chat.setMediaTo('local-video', chat.localMedia)
    
              chat.createLoacalPeer()
                .listenerGatheringstatechange()
                .addTrack()
                .listenerCandidateAdd(function (iceCandidate) {chat.sendMessage('iceCandidate', { iceCandidate, id: callingInfo.id})
    
                })
                .listenerAddStream(function (stream) {$('.remote-video').fadeIn()
                  chat.setMediaTo('remote-video', stream)
    
                })
                .createAnswer(callingInfo.offer, function (answer) {chat.sendMessage('answer', { answer, id: callingInfo.id})
    
                })
    
    
            }
    
          })
        </script>
      </body>
      </html>
  2. 后端

      const SocketIO = require('socket.io')
      const socketIO = new SocketIO({path: '/websocket'})
    
      let userRoom = {list: [],
        add(user) {this.list.push(user)
          return this
        },
        del(id) {this.list = this.list.filter(u => u.id !== id)
          return this
        },
        sendAllUser(name, data) {this.list.forEach(({ id}) => {console.log('>>>>>', id)
            socketIO.to(id).emit(name, data)
          })
          return this
        },
        sendTo(id) {return (eventName, data) => {socketIO.to(id).emit(eventName, data)
          }
        },
        findName(id) {return this.list.find(u => u.id === id).name
        }
      }
    
      socketIO.on('connection', function(socket) {console.log('连接加入.', socket.id)
    
        socket.on('addUser', function(data) {console.log(data.name, '加入房间')
          let user = {
            id: socket.id,
            name: data.name,
            calling: false
          }
          userRoom.add(user).sendAllUser('updateUserList', userRoom.list)
        })
    
        socket.on('sendMessage', ({ content}) => {console.log('转发消息:', content)
          userRoom.sendAllUser('updateMessageList', { userId: socket.id, content, user: userRoom.findName(socket.id) })
        })
    
        socket.on('iceCandidate', ({ id, iceCandidate}) => {console.log('转发信道')
          userRoom.sendTo(id)('iceCandidate', { iceCandidate, id: socket.id})
        })
    
        socket.on('offer', ({id, offer}) => {console.log('转发 offer')
          userRoom.sendTo(id)('called', { offer, id: socket.id, name: userRoom.findName(socket.id)})
        })
    
        socket.on('answer', ({id, answer}) => {console.log('接受视频');
          userRoom.sendTo(id)('answer', {answer})
        })
    
        socket.on('rejectCall', id => {console.log('转发拒接视频')
          userRoom.sendTo(id)('callRejected')
        })
    
        socket.on('disconnect', () => {
          // 断开删除
          console.log('连接断开', socket.id)
          userRoom.del(socket.id).sendAllUser('updateUserList', userRoom.list)
        })
      })
    
      module.exports = socketIO
    
    
      // www.js 这就不关键了
      const http = require('http')
      const app = require('../app')
      const socketIO = require('../socket.js')
      const server = http.createServer(app.callback())
      socketIO.attach(server)
      server.listen(3003, () => {console.log('server start on 127.0.0.1:3003')
      })  

搭建 STUN/TURN

因为没有钱买服务器 没试过

coturn 据说使用它搭建 STUN/TURN 服务非常的方便

  # 编译
  cd coturn
  ./configure --prefix=/usr/local/coturn
  sudo make -j 4 && make install

  # 配置
  listening-port=3478        #指定侦听的端口
  external-ip=39.105.185.198 #指定云主机的公网 IP 地址
  user=aaaaaa:bbbbbb         #访问 stun/turn 服务的用户名和密码
  realm=stun.xxx.cn          #域名,这个一定要设置
 
  #启动
  cd /usr/local/coturn/bin
  turnserver -c ../etc/turnserver.conf

  trickle-ice https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice 按里面的要求输入 stun/turn 地址、用户和密码 
  输入的信息分别是:STUN or TURN URI 的值为:turn:stun.xxx.cn
    用户名为:aaaaaa
    密码为:bbbbbb

STUN 参数传递

  let ice = {"iceServers": [{"url": "stun:stun.l.google.com:19302"},  // 无需密码的
    // TURN 一般需要自己去定义
    {
      'url': 'turn:192.158.29.39:3478?transport=udp',
      'credential': 'JZEOEt2V3Qb0y27GRntt2u2PAYA=', // 密码
      'username': '28224511:1379330808' // 用户名
    },
    {
      'url': 'turn:192.158.29.39:3478?transport=tcp',
      'credential': 'JZEOEt2V3Qb0y27GRntt2u2PAYA=',
      'username': '28224511:1379330808'
    }
  ]}
  // 可以提供多 iceServers 地址,但 RTC 追选择一个进行协商


  // 实例化的是给上参数 RTC 会在合适的时候去获取本地墙后 IP
  let pc = new RTCPeerConnection(ice);

  /*
    // 据说这些免费的地址都可以用
    stun:stun1.l.google.com:19302
    stun:stun2.l.google.com:19302
    stun:stun3.l.google.com:19302
    stun:stun4.l.google.com:19302
    stun:23.21.150.121
    stun:stun01.sipphone.com
    stun:stun.ekiga.net
    stun:stun.fwdnet.net
    stun:stun.ideasip.com
    stun:stun.iptel.org
    stun:stun.rixtelecom.se
    stun:stun.schlund.de
    stun:stunserver.org
    stun:stun.softjoys.com
    stun:stun.voiparound.com
    stun:stun.voipbuster.com
    stun:stun.voipstunt.com
    stun:stun.voxgratia.org
    stun:stun.xten.com
  */
退出移动版