答案是:能!
藉助WebRTC
不只能作到音視頻聊天,還能實現點對點文件傳輸。javascript
WebRTC
(Web Real-Time Communication
)是一項實時通信技術,它容許網絡應用或者站點,在不借助中間媒介的狀況下,創建瀏覽器之間點對點(Peer-to-Peer
)的鏈接,實現視頻流音頻流或者其餘任意數據的傳輸。
WebRTC
包含的這些標準使用戶在無需安裝任何插件或者第三方的軟件的狀況下,建立點對點(Peer-to-Peer
)的數據分享和視頻會議成爲可能。前端
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.264
的VPx
。 隨後,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
那麼優異)。STUN/TURN
服務器能夠大大節約成本開銷。附一張瀏覽器版本支持圖以下: 數據庫
![]()
VideoEngine
)
VoiceEngine
)
Session Management
)iSAC
音效壓縮VP8
Google自家WebM項目的影片編解碼器WebRTC的底層實現很是複雜,附一張架構圖以下: 數組
![]()
WebRTC
雖然底層實現極其複雜,可是面向開發者的API仍是很是簡潔的,主要分爲三方面:瀏覽器
MediaStream
媒體數據流MediaStreamTrack
媒體源RTCPeerConnection
容許用戶在兩個瀏覽器之間直接通信。RTCIceCandidate
ICE協議的候選者RTCIceServe
rRTCDataChannel
:數據通道接口,表示一個在兩個節點之間的雙向的數據通道。主要有兩個API:MediaStream
與MediaStremTrack
:
MediaStreamTrack
表明一種單類型數據流,一個MediaStreamTrack
表明一條媒體軌道,VideoTrack
或AudioTrack
。這給咱們提供了混合不一樣軌道實現多種特效的可能性。MediaStream
一個完整的音視頻流,它能夠包含多個MediaStreamTrack
對象,它的主要做用是協同多個媒體軌道同時播放,也就是咱們常說的音畫同步。咱們要經過瀏覽器實現音視頻通話,首先須要訪問音/視頻設備,這很簡單:
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: "默認 - 攝像頭"
}
複製代碼
以此能夠做爲判斷設備是否具有語音/視頻通話能力的依據。
上面咱們得到了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;
複製代碼
前面一節,咱們成功的拿到了MediaStream
流媒體對象,可是仍然僅限於本地查看。
如何將流媒體與對方互相交換(實現音視頻通話)?
答案是咱們必須創建點對點鏈接(peer-to-peer),這就是RTCPeerConnection
要作的事情。
WebRTC
區別於傳統音視頻通話的特色即是兩臺機器之間直接建立點對點鏈接,無須經過服務器直接交換音視頻流數據!
WebRTC
中最難的點(
RTCPeerConnection
內部作了不少工做來簡化咱們的使用),許多文章都用了很是大的篇幅去闡述它的原理。
但在這以前,我先得提一個概念:信令服務器
兩臺公網上的設備要互相知道對方是誰,須要有一箇中間方去協商交換它們的信息。這就比如媒人作媒同樣,互不相識的一男一女,須要一個認識他們倆的媒人去搭橋牽線,讓他們可以喜結連理。
信令服務器乾的就是這個事兒,下面幾張圖中的雲朵就是信令服務器。
好了,咱們開始解釋原理。
要在兩臺設備之間建立點對點鏈接,最理想的狀況是雙方都具備公網IP:
這是由於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的特色是:當host主機經過NAT訪問外網的B主機時,就會在NAT上打個「洞」,全部知道這個洞的主機均可以經過它與內網主機上的偵聽程序通訊。 這個所謂的「洞」就是一張內外網的映射表,簡單表示成一個4元組以下:
{
內網IP,
內網端口,
映射的外網IP,
映射的外網端口
}
複製代碼
有了這個「洞」的數據,A主機與C主機都能經過這個洞與host通訊了。
IP限制錐型要比徹底錐型NAT嚴格得多,它的主要特色是:host主機在NAT上打「洞」後,NAT會對穿越洞口的IP地址作限制。只有登記的IP地址才能夠經過,也就是說,只有host主機訪問過的外網主機才能穿越NAT。其它主機即便知道了這個「洞」也不能與host通訊,由於NAT會檢查IP地址。 簡單表示成一個5元組以下:
{
內網IP,
內網端口,
映射的外網IP,
映射的外網端口,
被訪問主機的IP
}
複製代碼
端口限制錐型比IP限制錐型NAT更加嚴格,它的主要特色是:不光在NAT上對打「洞」的IP地址作了限制,還對具體的端口作了限制。 簡單表示成一個6元組以下:
{
內網IP,
內網端口,
映射的外網IP,
映射的外網端口,
被訪問主機的IP,
被訪問主機的端口
}
複製代碼
如上圖所示,只有B主機的P1端口才能穿越NAT與host通訊,P2端口都沒法穿越。
對稱型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
建立鏈接了:
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
用時序圖表示可能更便於理解:
RTCPeerConnection
對象,並監聽onicecandidate
事件;offer
createOffer
並經過信令服務器發送給Peer B;offer
後setRemoteSDP
,建立answer
createAnswer
並經過信令服務器發送給Peer A;answer
後setRemoteSDP
;offer/answer
,icecandidate
被通知到,Peer A與Peer B交換candidate
信息;當鏈接創建完成以後,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
。若是你們看到addStream
或onaddstream
沒必要擔憂,使用TypeScript
的好處就是在這方面它總能給你最新的代碼提示。
綜上所述,RTCPeerConnection
主要負責穿透並創建鏈接,而且它還會自動推流。
但它的能力可不止於此,它還有一個能力:RTCDataChannel
。
標題裏除了實時語音/視頻聊天,還提到了文件傳輸,這即是RTCDataChannel
的功能。
利用它,能傳輸string
、ArrayBuffer
、Blob
(目前僅FireFox支持)類型的數據。
因此,傳輸文本和文件不在話下。它的使用和MediaStream
同樣,都須要依附RTCPeerConnection
,這也能理解,離開了鏈接,不管是流媒體仍是文件都傳輸不了吧?
RTCPeerConnection
提供了一個方法用來建立RTCDataChannel
:
const dataChannel = pc.createDataChannel(label: string);
複製代碼
查閱官方API文檔,
label
只是一個描述,不要求惟一,也沒有實際意義。
假設使用Peer A的RTCPeerConnection
建立了RTCDataChannel
,那麼Peer B也須要建立RTCDataChannel
嗎?
答案是:不用!
Peer B只須要監聽RTCPeerConnection
的ondatachannel
事件便可,當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,說白了仍是太懶致使的……