WebRTC技術簡介

語言: CN / TW / HK

關鍵詞

NAT

Network Address Translation

網路地址轉換

STUN

Session Traversal Utilities for NAT

NAT會話穿越應用程式

TURN

Traversal Using Relay NAT

通過Relay方式穿越NAT

ICE

Interactive Connectivity Establishment

互動式連線建立

SDP

Session Description Protocol

會話描述協議

WebRTC

Web Real-Time Communications

web實時通訊技術 

WebRTC

WebRTC (Web Real-Time Communications) 是一項實時通訊技術,它允許網路應用或者站點,在不借助中間媒介的情況下,建立瀏覽器之間點對點(Peer-to-Peer)的連線,實現影片流、音訊流或者其他任意資料的傳輸。

WebRTC 必須在 HTTPS 環境下執行,你可以在https://appr.tc/https://snapdrop.net/體驗 WebRTC 應用,或者在https://nashaofu.github.io/webrtc-demo/https://nashaofu.github.io/webrtc-demo/檢視 WebRTC 示例。

WebRTC vs WebSocket

  1. 用途區別

  2. WebSocket 允許瀏覽器和 Web 伺服器之間進行全雙工通訊.

  3. WebRTC 允許兩個瀏覽器之間的全雙工通訊。

  4. 協議區別

  5. WebSocket 使用 TCP 協議

  6. WebRTC 使用 UDP 協議

  7. 流量路徑

  8. WebSocket 瀏覽需要經過伺服器

  9. WebRTC 是直接連線,瀏覽不會經過第三方伺服器,是一個去中心化的架構模型,簡單說就是省頻寬。

  10. 實時性

  11. WebSocket 延遲高(不是直接連線)

  12. WebRTC 延遲低

通常 WebRTC 會與 WebSocket 配合使用,WebSocket 的作用主要是用來交換客戶端的 SDP 與網路資訊,Websocket 傳輸的內容與真正通訊資料無關,只是協助 WebRTC 建立連線。

WebRTC offer 與 answer 交換流程

和 TCP 3 次握手類似,WebRTC 連線需進行 offer 與 answer 的交換,至少需進行 4 次通訊。分別為:傳送offer/answer,接收answer/offer,傳送網路資訊,接收對方網路資訊,如下示例為https://nashaofu.github.io/webrtc-demo/socket.html(需 clone 下來在本地執行)的通訊過程

image.png

WebRTC 交換 offer 與網路引數之後,就會嘗試直接使用對方的 IP 地址與埠進行直接連線,這個過程中會根據雙方網路情況,使用的不同的方式建立連線,後文NAT 打洞就是介紹這部分內容。

信令伺服器

A 與 B 在建立連線的 WebRTC 連線過程中,需要互相知道對方的 IP 與通訊埠。那麼 A 與 B 要如何知道對方的 IP 與埠呢?答案就是通過信令伺服器。

信令伺服器的作用是作為一箇中間人幫助雙方在儘可能少的暴露隱私的情況下建立連線。WebRTC 並沒有提供信令傳遞機制,你可以使用任何方式如 WebSocket 或者 XMLHttpRequest 等,來交換彼此的令牌資訊。

WebRTC 支援傳輸內容

  1. WebRTC 從名稱上就有實時會話的定義,那必然支援直接傳輸音訊流和影片流(https://appr.tc/)

    const pc = new RTCPeerConnection()

    navigator.getUserMedia({ video: true }, stream => { // 新增影片流到會話中 stream.getTracks().forEach(track => pc.addTrack(track, stream))

    // 在網頁中預覽自己攝像頭拍攝到的內容 $localVideo.srcObject = stream })

  2. WebRTC 並不只是用來做影片通話,其實它還可以用來傳輸任意資料,包括檔案,文字等。WebRTC 規定了 dataChannel 這個雙工(可讀可寫)資料通道。https://snapdrop.net/這個網站就是通過 WebRTC 進行檔案分享。

    const pc = new RTCPeerConnection()

    const dataChannel = pc.createDataChannel('chat')

    pc.addEventListener('datachannel', event => { // 接收通訊方傳送過來的資料 event.channel.addEventListener('message', event => { console.log('message', event.message) }) })

    dataChannel.addEventListener('open', () => { // 傳送資料,可傳送任意資料 dataChannel.send('Hi!') })

    dataChannel.addEventListener('close', event => {})

會話描述協議(SDP)

前面講到了offeranswer是由RTCPeerConnection例項呼叫createOffercreateAnswer建立的offeranswer中的主要內容是 SDP 文字,offeranswer資料結構如下:

{
    type: "offer" | "answer",
    sdp: string
}

從技術上講,SDP 並不是一個真正的協議,而是一種資料格式,用於描述在裝置之間共享媒體的連線。SDP 由一行或多行 UTF-8 文字組成,每行以一個字元的型別開頭,後跟等號(=),然後是包含值或描述的結構化文字,其格式取決於型別。如下為一個 SDP 內容示例:

   v=0
   o=alice 2890844526 2890844526 IN IP4 host.anywhere.com
   s=
   c=IN IP4 host.anywhere.com
   t=0 0
   m=audio 49170 RTP/AVP 0
   a=rtpmap:0 PCMU/8000
   m=video 51372 RTP/AVP 31
   a=rtpmap:31 H261/90000
   m=video 53000 RTP/AVP 32
   a=rtpmap:32 MPV/90000

SDP 主要描述了彼此的音影片編解碼能力、網路頻寬和傳輸協議等資訊。WebRTC 中的 SDP 常用欄位:

  • Version(v):協議版本
  • Origion(o):會話發起者
  • Session Name(s):會話名
  • Connection Data(c):連線資料
  • Media Announcements(m):媒體描述
  • 其他屬性欄位...

更多關於 SPD 講解可參考:https://en.wikipedia.org/wiki/Session_Description_Protocol

candidate 事件

RTCPeerConnection例項執行setLocalDescription()後,RTCPeerConnection就會探測自己的網路環境,然後用 candidate 事件會返回候選網路環境資料,網路環境資料中最重要的是 IP 地址與埠組成的候選通訊地址。

candidate 事件中的 event.candidate 主要包含以下幾個部分:

  • 本機 IP 地址
  • 本機用於 WebRTC 通訊的埠號
  • 候選者型別,包括 host、srflx 和 relay
  • 優先順序
  • 傳輸協議

    { "address": "192.168.31.67", "candidate": "candidate:2147606101 1 udp 2122260223 192.168.31.67 57959 typ host generation 0 ufrag EaWw network-id 1 network-cost 10", "component": "rtp", "foundation": "2147606101", "port": 57959, "priority": 2122260223, "protocol": "udp", "relatedAddress": null, "relatedPort": null, "sdpMLineIndex": 0, "sdpMid": "0", "tcpType": null, "type": "host", "usernameFragment": "EaWw" }

candidate 事件 type 欄位取值分別為 host、srflx、relay:

  • host(Host candidate): 從本地網絡卡上獲取的地址
  • srflx(Server reflexive candidate): STUN 返回的該客戶端的地址
  • relay(Relay reflexive candidate):: TURN 伺服器為該客戶端分配的中繼地址

本地的 candidate 與遠端 candidate 構成的每一對都有一定的優先順序,按優先順序排序進行連通性檢查。最後從有效的 candidate 組合中選擇優先順序最高的作為傳輸地址,用於建立 P2P 連線。

網路地址轉換(NAT)

網路地址轉換(英語:Network Address Translation,縮寫:NAT;又稱網路掩蔽、IP 掩蔽)在計算機網路中是一種在 IP 資料包通過路由器或防火牆時重寫來源 IP 地址或目的 IP 地址的技術。這種技術被普遍使用在有多臺主機但只通過一個公有 IP 地址訪問網際網路的私有網路中。

要建立一個連線需要知道對方的 IP 地址和埠號,在局域網裡面一臺路由器(基站)可能會連線著很多臺裝置,例如家庭路由器接入寬頻的時候,寬頻服務商會分配一個公網的 IP 地址,所有連到這個路由器的裝置都共用這個公網 IP 地址。如果兩臺裝置都用了同一個公網 IP:prot 去傳送請求,伺服器返回資料在經過路由器時它就不知道應該轉發給哪一個裝置。因此路由器需要重寫 IP 地址/埠號進行區分,如下圖所示:

image.png

NAT 裝置通常會自動設定各個裝置的對映關係,我們也可以在路由器端去手動設定。如上圖的 NAT 維護的對映關係還會和要訪問的目標 IP 地址進行繫結,例如同一終端使用同一埠訪問不同的目標 IP,就會建立不同的對映關係。

image.png

如上示例 NAT 上建立的對映關係如下:

內網 IP 埠

外網 IP 埠

NAT 對外 IP 與埠

192.168.1.2:8080

39.182.39.30:443

10.188.20.10:8000

192.168.1.2:8080

39.182.39.40:443

10.188.20.10:8001

所以實際儲存的對映關係會包含上面 3 部分內容,這樣做的目的是保證網路安全。想象如下例子,終端192.168.1.2:8080通過路由器使用10.188.20.10:8000訪問伺服器 A,建立 NAT 對映如果為192.168.1.2:8080-->10.188.20.10:8000,那麼如果有人向10.188.20.10:8000傳送資料就會轉發到192.168.1.2:8080,這樣就會導致內網的服務被外部隨意訪問,所以 NAT 對映會記錄目標地址。當然,由於 NAT 有多種型別,NAT 對映也會存不同,更多內容可參考維基百科或者WebRTC 網路基礎 九、第二節 NAT 打洞原理,下表進行一個簡單的歸納。

image.png

NAT 打洞

由於 NAT 有上面 4 種類型,所以兩個裝置要建立 P2P 連結就要使用不同的方式。

  1. 如果 NAT 是完全圓錐型的,那麼雙方中的任何一方都可以發起通訊。
  2. 如果 NAT 是受限圓錐型或埠受限圓錐型,雙方必須一起開始向對方發起請求,這樣雙方的 NAT 上就都有了 NAT 映射了,然後就能連通。若有一方位於對稱 NAT 後,就無法打洞成功。
  3. 對於對稱 NAT 來說,客戶端向 STUN 伺服器(下節介紹,用於協助打洞)發包對映的公網 IP:埠與向其它客戶端發包對映的公網 IP:埠是不一樣的,一個連線建立一個公網的對映,也就是說其它客戶端無法使用之前通過 STUN 伺服器打好的洞,所以客戶端雙方無法成功打洞,只能使用 TURN 中轉方案。

WebRTC 打洞

WebRTC 本身就已經實現 NAT 打洞功能,只需要連線的雙方交換了網路埠和 IP 之後,WebRTC 就會自動進行打洞。WebRTC 使用一個叫做互動式連線設施(ICE)協議框架。ICE 整合了 STUN 與 TURN。STUN 是用來探測終端 NAT 型別、IP 和埠的服務,WebRTC 獲取到 NAT 型別、IP 和埠後就會觸發 candidate 事件,然後連線雙方交換 IP 與埠,開始打洞。如果打洞失敗,那麼就會使用 TURN 伺服器轉發流量。

image.png

由於 WebRTC 提供了 ICE,所以使用非常簡單,只需在new RTCPeerConnection時傳入iceServers引數即可。googel 提供了免費的 STUN 伺服器去幫助打洞,也可以自己架設伺服器。

const pc = new RTCPeerConnection({
  // 可以傳入多個stun伺服器或者turn伺服器
  iceServers: [
    { url: 'stun:stun.l.google.com:19302' },
    { url: 'stun:stun1.l.google.com:19302' },
    { url: 'stun:stun2.l.google.com:19302' },
    { url: 'stun:stun3.l.google.com:19302' },
    { url: 'stun:stun4.l.google.com:19302' }
  ]
})

實現一個線上影片會議 demo

1.  服務端使用 socket.io 來作為信令伺服器來轉發客戶端資料。其主要功能包括房間建立分配與客戶端資料轉發。

const socket = require('socket.io')

module.exports = server => {
  // 在https伺服器上新增ws通訊路徑`/socket.io/`
  const io = socket.listen(server)

  io.sockets.on('connection', function(socket) {
    socket.on('disconnecting', () => {
      // 通知房間中的其他客戶端斷開連線
      Object.keys(socket.rooms).forEach(room => {
        socket.broadcast.to(room).emit('leaveed', socket.id)
      })
    })

    // 轉發客戶端訊息
    socket.on('message', function(target, message) {
      if (target) {
        // 傳送訊息到指定客戶端
        io.sockets.sockets[target]?.emit('message', message)
      }
    })

    // 房間建立與加入
    socket.on('create or join', function(room) {
      const clientsInRoom = io.sockets.adapter.rooms[room]
      const numClients = clientsInRoom ? Object.keys(clientsInRoom.sockets).length : 0

      if (numClients === 0) {
        // 建立房間
        socket.join(room)
        // 通知當前客戶端建立房間成功
        socket.emit('created', room, socket.id)
      } else if (numClients < 10) {
        // 一個房間最多隻能有10個人
        socket.join(room)
        // 通知當前客戶端加入房間成功
        socket.emit('joined', room, socket.id)
        // 通知房間中的其他客戶端有人加入
        socket.broadcast.to(room).emit('message', {
          socketId: socket.id,
          type: 'join'
        })
      } else {
        // max two clients
        socket.emit('full', room)
      }
    })
  })
}

2.  客戶端建立程式碼

// 影片列表區域
const videos = document.querySelector('#videos')
// 本地影片預覽
const localVideo = document.querySelector('#localVideo')
// 房間號
const roomId = document.querySelector('#roomId')

const query = new URLSearchParams(location.search)
const room = query.get('room')

if (!room) {
  location.replace(
    `${location.pathname}?room=${Math.random()
      .toString(36)
      .substr(2, 9)}`
  )
}
// 儲存通訊方資訊
const remotes = {}
const socket = io.connect()

// socket傳送訊息
function sendMsg(target, msg) {
  console.log('->:', msg.type)
  msg.socketId = socket.id
  socket.emit('message', target, msg)
}

// 建立RTC物件,一個RTC物件只能與一個遠端連線
function createRTC(stream, id) {
  const pc = new RTCPeerConnection({
    iceServers: [
      {
        urls: 'stun:stun.l.google.com:19302'
      }
    ]
  })

  // 獲取本地網路資訊,併發送給通訊方
  pc.addEventListener('icecandidate', event => {
    if (event.candidate) {
      // 傳送自身的網路資訊到通訊方
      sendMsg(id, {
        type: 'candidate',
        candidate: {
          sdpMLineIndex: event.candidate.sdpMLineIndex,
          sdpMid: event.candidate.sdpMid,
          candidate: event.candidate.candidate
        }
      })
    }
  })

  // 有遠端影片流時,顯示遠端影片流
  pc.addEventListener('track', event => {
    remotes[id].video.srcObject = event.streams[0]
  })

  // 新增本地影片流到會話中
  stream.getTracks().forEach(track => pc.addTrack(track, stream))

  // 用於顯示遠端影片
  const video = document.createElement('video')
  video.setAttribute('autoplay', true)
  video.setAttribute('playsinline', true)
  videos.append(video)
  remotes[id] = {
    pc,
    video
  }
}

navigator.mediaDevices
  .getUserMedia({
    audio: false, // 本地測試防止回聲
    video: true
  })
  .then(stream => {
    roomId.innerHTML = room
    localVideo.srcObject = stream

    // 建立或者加入房間,具體是加入還是建立需看房間號是否存在
    socket.emit('create or join', room)

    socket.on('leaveed', function(id) {
      console.log('leaveed', id)
      if (remotes[id]) {
        remotes[id].pc.close()
        videos.removeChild(remotes[id].video)
        delete remotes[id]
      }
    })

    socket.on('full', function(room) {
      console.log('Room ' + room + ' is full')
      socket.close()
      alert('房間已滿')
    })

    socket.on('message', async function(message) {
      console.log('<-:', message.type)
      switch (message.type) {
        case 'join': {
          // 有新的人加入就重新設定會話,重新與新加入的人建立新會話
          createRTC(stream, message.socketId)
          const pc = remotes[message.socketId].pc
          const offer = await pc.createOffer()
          pc.setLocalDescription(offer)
          sendMsg(message.socketId, { type: 'offer', offer })
          break
        }
        case 'offer': {
          createRTC(stream, message.socketId)
          const pc = remotes[message.socketId].pc
          pc.setRemoteDescription(new RTCSessionDescription(message.offer))
          const answer = await pc.createAnswer()
          pc.setLocalDescription(answer)
          sendMsg(message.socketId, { type: 'answer', answer })
          break
        }
        case 'answer': {
          const pc = remotes[message.socketId].pc
          pc.setRemoteDescription(new RTCSessionDescription(message.answer))
          break
        }
        case 'candidate': {
          const pc = remotes[message.socketId].pc
          pc.addIceCandidate(new RTCIceCandidate(message.candidate))
          break
        }
        default:
          console.log(message)
          break
      }
    })
  })

示例截圖

image.png

上述例子詳細程式碼可檢視https://github.com/nashaofu/webrtc-demo,倉庫包含了使用 dataChanel 實現的簡單聊天室,具體可 clone 倉庫到本地預覽,注意需信任 tls 證書。

原文可在飛書文件檢視:https://bytedance.feishu.cn/docs/doccnXlf9v9dKfutpBLWOd7HVXe