WebRTC實現p2p視頻通話

簡介

目的 幫助本身瞭解webrtc 實現端對端通訊

qq.png

# 使用流程
  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組成

zucheng.webp.jpg

  • getUserMedia負責獲取用戶本地的多媒體數據
  • RTCPeerConnection負責創建P2P鏈接以及傳輸多媒體數據。
  • RTCDataChannel提供的一個信令通道實現雙向通訊

h5 獲取媒體流

目標:打開攝像頭將媒體流顯示到頁面

MediaDevices 文檔html

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流程

peer2peertimeline.png

以 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. SDPjquery

    什麼是SDP
        SDP 徹底是一種會話描述格式 ― 它不屬於傳輸協議
        它只使用不一樣的適當的傳輸協議,包括會話通知協議(SAP)、會話初始協議(SIP)、實時流協議(RTSP)、MIME 擴展協議的電子郵件以及超文本傳輸協議(HTTP)
        SDP協議是基於文本的協議,可擴展性比較強,這樣就使其具備普遍的應用範圍。
      
      WebRTC中SDP
        SDP不支持會話內容或媒體編碼的協商。webrtc中sdp用於媒體信息(編碼解碼信息)的描述,媒體協商這一塊要用RTP來實現
  3. stungit

    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. 前端github

    <!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. 後端web

    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 服務很是的方便npm

# 編譯
  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
  */

歡迎提問後端

相關文章
相關標籤/搜索