Web實時語音/視頻聊天/文件傳輸

前端應用可否實現音視頻聊天?

答案是:能!
藉助WebRTC不只能作到音視頻聊天,還能實現點對點文件傳輸。javascript

WebRTC是什麼?

WebRTCWeb Real-Time Communication)是一項實時通信技術,它容許網絡應用或者站點,在不借助中間媒介的狀況下,創建瀏覽器之間點對點(Peer-to-Peer)的鏈接,實現視頻流音頻流或者其餘任意數據的傳輸。
WebRTC包含的這些標準使用戶在無需安裝任何插件或者第三方的軟件的狀況下,建立點對點(Peer-to-Peer)的數據分享和視頻會議成爲可能。前端

WebRTC的前世此生

1990年,Gobal IP Solutions成立於瑞典斯德哥爾摩(如下簡稱GIPS),這是一家VoIP 軟件開發商,提供了能夠說是世界上最好的語音引擎。 Skype、騰訊 QQ、WebEx、Vidyo 等都使用了它的音頻處理引擎,包含了受專利保護的回聲消除算法,適應網絡抖動和丟包的低延遲算法,以及先進的音頻編解碼器。Google 在 Gtalk 中也使用了 GIPS 的受權。java

2010年5月,Google以6820萬美圓收購了GIPS,並將其源代碼開源。加上在 2010 年收購的 On2 獲取到的 VPx 系列視頻編解碼器,WebRTC 開源項目應運而生,即 GIPS 音視頻引擎 + 替換掉 H.264VPx。 隨後,Google 又將在 Gtalk 中用於 P2P 打洞的開源項目 libjingle 融合進了 WebRTC程序員

2012年1月,谷經把WebRTC集成到了Chrome瀏覽器中。web

因此目前 WebRTC 提供了在 Web、iOS、Android、Mac、Windows、Linux 在內的全部平臺的 API,保證了 API 在全平臺的一致性。算法

使用 WebRTC 的好處主要有如下幾個方面typescript

  • 無償使用 GIPS 先進的音視頻引擎,在此以前都須要付費受權(固然,WebRTC因爲開源,因此相較於GIPS閹割了一部份內容,致使性能沒有GIPS那麼優異)。
  • 因爲音視頻傳輸是基於點對點傳輸的,因此實現簡單的 1 對 1 通話場景,只須要較少的服務器資源,藉助免費的 STUN/TURN 服務器能夠大大節約成本開銷。
  • 開發 Web 版本的應用很是方便,使用簡單的 JS 接口,無需安裝任何插件,便可實現音視頻互通。

當前瀏覽器支持狀況

  • 桌面PC端
    • Microsoft Edge
    • Google Chrome 23
    • Mozilla Firefox 22
    • Opera 18
    • Safari 11
  • Android端
    • Google Chrome 28(從版本29開始默認開啓)
    • Mozilla Firefox 24
    • Opera Mobile 12
  • Google Chrome OS
  • Firefox OS
  • iOS 11
  • Blackberry 10內置瀏覽器

附一張瀏覽器版本支持圖以下: 數據庫

WebRTC技術組成

底層技術

  • 圖像引擎 (VideoEngine)
    • VP8編解碼
    • jitter buffer: 動態抖動緩衝
    • Image enhancements: 圖像增益
  • 聲音引擎 (VoiceEngine)
    • iSAC/iLBC/Opus等編解碼
    • NetEQ語音信號處理
    • 回聲消除和降噪
  • 會話管理 (Session Management)
  • iSAC 音效壓縮
  • VP8 Google自家WebM項目的影片編解碼器
  • APIs (Native C++ API, Web API)

WebRTC的底層實現很是複雜,附一張架構圖以下: 數組

幾個重要API

WebRTC雖然底層實現極其複雜,可是面向開發者的API仍是很是簡潔的,主要分爲三方面:瀏覽器

  • Network Stream API
    • MediaStream 媒體數據流
    • MediaStreamTrack 媒體源
  • RTCPeerConnection
    • RTCPeerConnection 容許用戶在兩個瀏覽器之間直接通信。
    • RTCIceCandidate ICE協議的候選者
    • RTCIceServer
  • Peer-to-peer Data API
    • RTCDataChannel:數據通道接口,表示一個在兩個節點之間的雙向的數據通道。

技術點介紹

Network Stream API 網絡流媒體接口

主要有兩個API:MediaStreamMediaStremTrack

  • MediaStreamTrack 表明一種單類型數據流,一個MediaStreamTrack表明一條媒體軌道,VideoTrackAudioTrack。這給咱們提供了混合不一樣軌道實現多種特效的可能性。
  • MediaStream 一個完整的音視頻流,它能夠包含多個MediaStreamTrack對象,它的主要做用是協同多個媒體軌道同時播放,也就是咱們常說的音畫同步。

MediaStream

咱們要經過瀏覽器實現音視頻通話,首先須要訪問音/視頻設備,這很簡單:

const constraints = {
  audio: {
    echoCancellation: true,
    noiseSuppression: false
  },
  video: true
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
document.getElementById('#video').srcObject = stream;
複製代碼

調用navigator.mediaDevices.getUserMedia方法便可獲得流媒體對象MediaStream
constraints是一個約束配置,它是用來規範當前採集的數據是否符合須要。
由於,在採集視頻時,不一樣的設備有不一樣的參數設置,更優的設備可使用更高的參數(分辨率、幀率等)。
經常使用的配置以下所示:

{
    "audio": {
        echoCancellation: boolean,
        noiseSuppression: boolean
    },  // 是否捕獲音頻
    "video": {  // 視頻相關設置
        "width": {
            "min": "381", // 當前視頻的最小寬度
            "max": "640" 
        },
        "height": {
            "min": "200", // 最小高度
            "max": "480"
        },
        "frameRate": {
            "min": "28", // 最小幀率
             "max": "10"
        }
    }
}
複製代碼

有些機器只具有麥克風,沒有攝像頭怎麼辦呢?這時若是設置video: true就會拋出異常:Requested device not found
所以,咱們須要檢測設備是否具有語音/視頻的可行性,經過navigator.mediaDevices.enumerateDevices能夠枚舉出全部的媒體設備,格式以下:

{
    deviceId: "2c6e3e1b727b1442f905459e4cd47902988ccac6dff4361ae657cf44c4f3f55c"
    groupId: "781470b0bba090c6eb2b26eaa2e643e65a08e37f252f406d31da4d90cc3952e9"
    kind: "audioinput"
    label: "默認 - 麥克風"
},
{
    deviceId: "fe4d04d603512966a729e1313067574f462dbdc579d6ddaad7ad4460089239e1"
    groupId: "48c2f2d3191adf2d1d371d23d2219e228268ffbcfa4ba7dfe67a5855c81e6f13"
    kind: "videoinput"
    label: "默認 - 攝像頭"
}
複製代碼

以此能夠做爲判斷設備是否具有語音/視頻通話能力的依據。

MediaStreamTrack

上面咱們得到了MediaStream對象,經過它咱們能夠拿到音頻/視頻的MediaStreamTrack

const videoTracks = stream.getVideoTracks();
const audioTracks = stream.getAudioTracks();
複製代碼

能夠看到,MediaStream中的視頻軌與音頻軌分爲了兩個數組。
固然,咱們也能夠單獨操做MediaStreamTrack對象:

videoTracks[0].stop();
複製代碼

它還提供了以下屬性和方法,便於咱們操做單個軌道數據:

enabled: boolean;
readonly id: string;
readonly isolated: boolean;
readonly kind: string;
readonly label: string;
readonly muted: boolean;
onended: ((this: MediaStreamTrack, ev: Event) => any) | null;
onisolationchange: ((this: MediaStreamTrack, ev: Event) => any) | null;
onmute: ((this: MediaStreamTrack, ev: Event) => any) | null;
onunmute: ((this: MediaStreamTrack, ev: Event) => any) | null;
readonly readyState: MediaStreamTrackState;
applyConstraints(constraints?: MediaTrackConstraints): Promise<void>;
clone(): MediaStreamTrack;
getCapabilities(): MediaTrackCapabilities;
getConstraints(): MediaTrackConstraints;
getSettings(): MediaTrackSettings;
stop(): void;
複製代碼

RTCPeerConnection WebRTC鏈接

前面一節,咱們成功的拿到了MediaStream流媒體對象,可是仍然僅限於本地查看。
如何將流媒體與對方互相交換(實現音視頻通話)?
答案是咱們必須創建點對點鏈接(peer-to-peer),這就是RTCPeerConnection要作的事情。

WebRTC區別於傳統音視頻通話的特色即是兩臺機器之間直接建立點對點鏈接,無須經過服務器直接交換音視頻流數據!
建立點對點鏈接是 WebRTC中最難的點( RTCPeerConnection內部作了不少工做來簡化咱們的使用),許多文章都用了很是大的篇幅去闡述它的原理。
我接下來會試圖用幾張圖來儘量的解釋下。

但在這以前,我先得提一個概念:信令服務器

兩臺公網上的設備要互相知道對方是誰,須要有一箇中間方去協商交換它們的信息。這就比如媒人作媒同樣,互不相識的一男一女,須要一個認識他們倆的媒人去搭橋牽線,讓他們可以喜結連理。
信令服務器乾的就是這個事兒,下面幾張圖中的雲朵就是信令服務器。

好了,咱們開始解釋原理。

點對點鏈接原理

1. 最理想的狀況

要在兩臺設備之間建立點對點鏈接,最理想的狀況是雙方都具備公網IP:

如上圖所示,兩臺設備互相知曉對方的公網IP,只要經過信令服務器交換一下信息即可以建立點對點鏈接了。
然而,這種狀況出現的機率小到幾乎能夠忽略不計,由於公網IP實在太稀少了。

2. 兩臺設備都在NAT/防火牆後面

先解釋一下上圖中的NAT是什麼

這是由於IPV4引發的,咱們上網極可能會處在一個NAT設備(無線路由器之類)以後。 NAT設備會在IP封包經過設備時修改源/目的IP地址。對於家用路由器來講, 使用的是網絡地址端口轉換(NAPT), 它不只改IP, 還修改TCP和UDP協議的端口號, 這樣就能讓內網中的設備共用同一個外網IP。咱們的設備常常是處在NAT設備的後面, 好比在大學裏的校園網, 查一下本身分配到的IP, 實際上是內網IP, 代表咱們在NAT設備後面, 若是咱們在寢室再接個路由器, 那麼咱們發出的數據包會多通過一次NAT。

NAT會形成一個很棘手的問題,那就是內網穿透

NAT有一個機制,全部外界對內網發送的請求,到達NAT的時候,都會被NAT屏蔽,這樣若是咱們處於一個NAT設備後面,咱們將沒法獲得任何外界的數據。

可是這種機制有一個解決方案:就是若是咱們A主動往B發送一條信息,這樣A就在本身的NAT上打了一個通往B的洞。這樣A的這條消息到達B的NAT的時候,雖然被丟掉了,可是若是B這個時候在給A發信息,到達A的NAT的時候,就能夠從A以前打的那個洞中,發送給到A手上了。

簡單來說,就是若是A和B要進行通訊,那麼得事先A發一條信息給B,B發一條信息給A。這樣提早在各自的NAT上打了對方的洞,這樣下一次A和B之間就能夠進行通訊了。

NAT網絡分爲四種類型

  • 徹底錐型NAT

徹底錐型NAT的特色是:當host主機經過NAT訪問外網的B主機時,就會在NAT上打個「洞」,全部知道這個洞的主機均可以經過它與內網主機上的偵聽程序通訊。 這個所謂的「洞」就是一張內外網的映射表,簡單表示成一個4元組以下:

{
    內網IP,
    內網端口,
    映射的外網IP,
    映射的外網端口
}
複製代碼

有了這個「洞」的數據,A主機與C主機都能經過這個洞與host通訊了

  • IP限制錐型NAT

IP限制錐型要比徹底錐型NAT嚴格得多,它的主要特色是:host主機在NAT上打「洞」後,NAT會對穿越洞口的IP地址作限制。只有登記的IP地址才能夠經過,也就是說,只有host主機訪問過的外網主機才能穿越NAT。其它主機即便知道了這個「洞」也不能與host通訊,由於NAT會檢查IP地址。 簡單表示成一個5元組以下:

{
    內網IP,
    內網端口,
    映射的外網IP,
    映射的外網端口,
    被訪問主機的IP
}
複製代碼
  • 端口限制錐型NAT

端口限制錐型比IP限制錐型NAT更加嚴格,它的主要特色是:不光在NAT上對打「洞」的IP地址作了限制,還對具體的端口作了限制。 簡單表示成一個6元組以下:

{
    內網IP,
    內網端口,
    映射的外網IP,
    映射的外網端口,
    被訪問主機的IP,
    被訪問主機的端口
}
複製代碼

如上圖所示,只有B主機的P1端口才能穿越NAT與host通訊,P2端口都沒法穿越。

  • 對稱型NAT

對稱型NAT是全部NAT類型中最嚴格的一種類型,它也是IP+端口限制,但它與端口限制錐型不一樣之處在於:host與A主機和B主機通訊時會打兩個不一樣的「洞」,每訪問一個新的主機時,它都會從新開一個「洞」(使用不一樣的端口,甚至更換IP地址),而端口限制錐型多個鏈接使用的是同一個端口。

因此,當對稱型NAT碰到對稱型NAT或對稱型NAT遇到端口限制型NAT時,基本上雙方是沒法穿透成功的。

NAT解釋到這裏就差很少了,有須要的童鞋能夠查找相關資料去詳細瞭解一下,這裏不做過多的闡述。

回到剛剛的話題,WebRTC會怎麼處理NAT呢?答案是STUN/TURN

STUN(Session Traversal Utilities for NAT,NAT會話穿越應用程序)是一種網絡協議,它容許位於NAT(或多重NAT)後的客戶端找出本身的公網地址,查出本身位於哪一種類型的NAT以後以及NAT爲某一個本地端口所綁定的Internet端端口。

WebRTC首會先使用STUN服務器去找出本身的NAT環境,而後試圖找出打「洞」的方式,最後試圖建立點對點鏈接。

STUN服務器能夠直接使用google提供的免費服務stun.l.google.com:19302

STUN/TURN服務均可以本身搭建。

當它嘗試過不一樣的穿透方式都失敗以後,爲保證通訊成功率會啓用TURN服務器進行中轉,此時全部的流量都會經過TURN服務器。這時若是TURN服務器配置很差或帶寬不夠時,通訊質量基本上不好。

RTCPeerConnection的使用

上面解釋了點對點鏈接的原理,那麼具有穿透成功的條件以後,咱們要正式使用RTCPeerConnection建立鏈接了:

const pc = new RTCPeerConnection({ iceServers: [
    {
      'url': 'stun:turn.mywebrtc.com'
    },
    {
      'url': 'turn:turn.mywebrtc.com',
      'credential': 'siEFid93lsd1nF129C4o',
      'username': 'webrtcuser'
    }
  ]
});
pc.onicecandidate = candidate => sendEvent(Events.Candidate, { candidate });
stream.getTracks().forEach(track => {
  pc.addTrack(track, stream);
});

const answer = await pc.createAnswer();
pc.setLocalDescription(answer);
sendEvent(Events.Answer, { answer });

const offer = await pc.createOffer(options);
pc.setLocalDescription(offer);
sendEvent(Events.Offer, { offer });
複製代碼
  • 實例化一個RTCPeerConnection對象,須要提供iceServers屬性,提供STUN/TURN服務地址;
  • 監聽onicecandidate事件,當本地與對方offer/answer握手以後,icecandidate時會被通知到,再經過信令服務器將candidate信息發送給對方;
  • pc.addTrack(track, stream);綁定媒體軌道;
  • 收到offer發送answer / 收到answer發送offer

用時序圖表示可能更便於理解:

  1. Peer A實例化一個RTCPeerConnection對象,並監聽onicecandidate事件;
  2. Peer A建立offer createOffer並經過信令服務器發送給Peer B;
  3. Peer B收到offersetRemoteSDP,建立answer createAnswer並經過信令服務器發送給Peer A;
  4. Peer A收到answersetRemoteSDP
  5. Peer A與Peer B互相處理完offer/answericecandidate被通知到,Peer A與Peer B交換candidate信息;
  6. 鏈接創建完成!

推送MediaStream

當鏈接創建完成以後,RTCPeerConnection會將本地的tracks軌道推送給對方,這就是爲何要pc.addTrack(track, stream);

注意 pc.addTrack(track, stream);的第二個參數stream很是重要,若是不給,對方拿到的結果streams會是空數組!

當對方接收到tracks推送時,會通知回調函數pc.ontrack,能夠從event對象中拿到遠程流媒體對象:

pc.ontrack = event => {
    this.remoteMediaStream = event.streams[0];
};
複製代碼

當互相拿到對方的流媒體對象時,語音/視頻通話就成功了,將流賦給<video />標籤便可。

WebRTC的規範這幾年變更比較大,網上有不少文章和demo都有點舊了,有些API已經廢棄,好比如今的ontrack之前是onaddstream,如今的addTrack取代了以前的addStream。若是你們看到addStreamonaddstream沒必要擔憂,使用TypeScript的好處就是在這方面它總能給你最新的代碼提示。

綜上所述,RTCPeerConnection主要負責穿透並創建鏈接,而且它還會自動推流。

但它的能力可不止於此,它還有一個能力:RTCDataChannel

RTCDataChannel

標題裏除了實時語音/視頻聊天,還提到了文件傳輸,這即是RTCDataChannel的功能。
利用它,能傳輸stringArrayBufferBlob(目前僅FireFox支持)類型的數據。

因此,傳輸文本和文件不在話下。它的使用和MediaStream同樣,都須要依附RTCPeerConnection,這也能理解,離開了鏈接,不管是流媒體仍是文件都傳輸不了吧?

RTCPeerConnection提供了一個方法用來建立RTCDataChannel

const dataChannel = pc.createDataChannel(label: string);
複製代碼

查閱官方API文檔label只是一個描述,不要求惟一,也沒有實際意義。

假設使用Peer A的RTCPeerConnection建立了RTCDataChannel,那麼Peer B也須要建立RTCDataChannel嗎?

答案是:不用!

Peer B只須要監聽RTCPeerConnectionondatachannel事件便可,當Peer A建立RTCDataChannel成功後,Peer B的RTCPeerConnection會收到通知,並觸發ondatachannel事件傳入Peer A的RTCDataChannel對象。

解釋有點繞,直接看代碼:

// Peer A
const dataChannel = pc_a.createDataChannel('message'); 

// Peer B
pc_b.ondatachannel = event => {
  // 成功拿到 RTCDataChannel
  const dataChannel = event.channel;
};
複製代碼

固然,這一切的一切取決於Peer A與Peer B的RTCPeerConnection成功握手,因此說它纔是最重要的,也是最難理解的。

那麼接下來,如何發送消息呢?

發送文本消息是很是簡單的:

// Peer A
dataChannel.send('hello , I am Peer A');

// Peer B
dataChannel.onmessage = event => {
    console.log(event.data); // hello , I am Peer A
};
複製代碼

真的很是簡單。

可是接下來咱們要傳輸文件,就略微麻煩一些了……FireFox倒還好,支持直接發送Blob類型數據。

據我測試,FireFox發送的Blob對象,Chrome也能接收到(雖然它發送不了,會直接拋出異常)。

對於Chrome而言,要發送文件,只能選擇ArrayBuffer類型,而發送的時候須要進行分塊傳輸,通常1024 byte爲1塊。 單個文件還好,若是是多個文件同時傳輸,接收方就須要判斷每次接收的塊是屬於哪一個文件的。

因此我選擇了將對象{ fileId, data: chunk }轉成字符串傳輸,接收方收到消息解碼後能經過fileId還原到相應的文件上。

// Peer A發送文件
const fileReader = new FileReader();
let currentChunk = 0;
const readNextChunk = () => {
  const start = chunkLength * currentChunk;
  const end = Math.min(transfer.totalSize, start + chunkLength);
  fileReader.readAsArrayBuffer(transfer.file.slice(start, end));
};
fileReader.onload = () => {
    const raw = fileReader.result as ArrayBuffer;
    transfer.transferedSize += raw.byteLength;
    transfer.progress = transfer.transferedSize / transfer.totalSize;
  
    this.channel.sendMessage({
      fileId,
      data: arrayBuffer2String(raw)
    });
    currentChunk++;
    if(chunkLength * currentChunk < transfer.totalSize) {
      readNextChunk();
    } else {
      transfer.status = TransferStatus.Complete;
  };
};
readNextChunk();
複製代碼
// Peer B接收文件
dataChannel.onmessage = (event: MessageEvent, raw: string) => {
    const result = JSON.parse(raw) as { fileId: string, data: string };
    const { fileId, data } = result;
    const buffer = string2ArrayBuffer(data);
    const transfer = this.receiveFileQueue.find(f => f.id === fileId);
    transfer.status = TransferStatus.Transfering;
    transfer.data.push(buffer);
    transfer.transferedSize += buffer.byteLength;
    transfer.progress = transfer.transferedSize / transfer.totalSize;
    if (transfer.transferedSize === transfer.totalSize) {
      transfer.status = TransferStatus.Complete;
    }
}
複製代碼

實際效果以下圖所示:

由於接收到文件後,我設計的交互是讓用戶本身選擇是否下載或刪除,因此我是將接收到的文件二進制數據直接存儲在內存變量上,這也致使了一個問題:文件越多或者大文件(1M以上)就會佔用內存致使瀏覽器卡頓。

關於這個問題有解決方案,就是使用IndexedDB將文件存儲到瀏覽器的本地數據庫中去,它使用的是本地文件系統。不過懶得折騰了,畢竟這只是個WebRTC的demo呢。

結束語

好吧,基本上講完了,內容比較多,能堅持看到這裏的童鞋已經很不容易了。
WebRTC本就是一個比較小衆的技術,多數程序員平日都用不到它,甚至發佈多年至今也不知道它的存在(我身邊90%的同事都不知道它的存在)。 我是2017年在一家在線客服軟件公司就任時瞭解到這項技術的,當時驚呆了,手機瀏覽器和PC瀏覽器居然能在不借助任何插件的狀況下實現語音視頻通話! 工做上沒用到這個技術,因此一直也沒去細細研究。直到最近心血來潮才用它寫了個demo,說白了仍是太懶致使的……

相關文章
相關標籤/搜索