基于WebRTC的局域网文件传输
基于WebRTC的局域網(wǎng)文件傳輸
WebRTC(Web Real-Time Communications)是一項實時通訊技術(shù),允許網(wǎng)絡(luò)應(yīng)用或者站點(diǎn),在不借助中間媒介的情況下,建立瀏覽器之間點(diǎn)對點(diǎn)P2P(Peer-To-Peer)的連接,實現(xiàn)視頻流、音頻流、文件等等任意數(shù)據(jù)的傳輸,WebRTC包含的這些標(biāo)準(zhǔn)使用戶在無需安裝任何插件或者第三方的軟件的情況下,可以創(chuàng)建點(diǎn)對點(diǎn)Peer-To-Peer的數(shù)據(jù)分享和電話會議等。
描述
通常來說,在發(fā)起文件傳輸或者音視頻通話等場景的時候,我們需要借助第三方的服務(wù)器來中轉(zhuǎn)數(shù)據(jù),例如我們通過IM即時通訊軟件向?qū)Ψ桨l(fā)送消息的時候,我們的消息會先發(fā)送到服務(wù)器,然后服務(wù)器再將消息發(fā)送到對方的客戶端,這種方式的好處是可以保證消息的可靠性,但是存在的問題也比較明顯,通過服務(wù)器進(jìn)行轉(zhuǎn)發(fā)的速度會受限于服務(wù)器本身的帶寬,同時也會增加服務(wù)器的負(fù)載,特別是在傳輸文件或者進(jìn)行音視頻聊天的情況下,會給予服務(wù)器比較大的壓力,對于服務(wù)提供商來說提供服務(wù)器的帶寬同樣也是很大的開銷,對于用戶來說文件與消息經(jīng)由服務(wù)器轉(zhuǎn)發(fā)也存在安全與隱私方面的問題。
WebRTC的出現(xiàn)解決了這些問題,其允許瀏覽器之間建立點(diǎn)對點(diǎn)的連接,實現(xiàn)數(shù)據(jù)的傳輸,以及實時通信的復(fù)雜性、插件依賴和兼容性問題,提高了安全性和隱私保護(hù)。因此WebRTC廣泛應(yīng)用于實時通信領(lǐng)域,包括視頻會議、音視頻聊天、遠(yuǎn)程協(xié)作、在線教育和直播等場景。而具體到WebRTC與P2P的數(shù)據(jù)傳輸上,主要是解決了如下問題:
-
WebRTC提供了一套標(biāo)準(zhǔn)化的API和協(xié)議,使開發(fā)者能夠更輕松地構(gòu)建實時通信應(yīng)用,無需深入了解底層技術(shù)細(xì)節(jié)。 -
WebRTC支持加密傳輸,使用DTLS-SRTP對傳輸數(shù)據(jù)進(jìn)行加密,確保通信內(nèi)容的安全性,對于敏感信息的傳輸非常重要。 -
WebRTC使用P2P傳輸方式,使得數(shù)據(jù)可以直接在通信雙方之間傳輸,減輕了服務(wù)器的負(fù)擔(dān),且通信質(zhì)量不受服務(wù)器帶寬限制。 -
P2P傳輸方式可以直接在通信雙方之間傳輸數(shù)據(jù),減少了數(shù)據(jù)傳輸?shù)穆窂胶椭虚g環(huán)節(jié),從而降低了傳輸延遲,實現(xiàn)更實時的通信體驗。 -
P2P傳輸方式不需要經(jīng)過中心服務(wù)器的中轉(zhuǎn),減少了第三方對通信內(nèi)容的訪問和監(jiān)控,提高了通信的隱私保護(hù)。
在前一段時間,我想在手機(jī)上向電腦發(fā)送文件,因為要發(fā)送的文件比較多,所以我想直接通過USB連到電腦上傳輸,等我將手機(jī)連到電腦上之后,我發(fā)現(xiàn)手機(jī)竟然無法被電腦識別,能夠充電但是并不能傳文件,因為我的電腦是Mac而手機(jī)是Android,所以無法識別設(shè)備這件事就變得合理了起來。那么接著我想用WeChat去傳文件,但是一想到傳文件之后我還需要手動將文件刪掉否則會占用我兩份手機(jī)存儲并且傳輸還很慢,我就又開始在網(wǎng)上尋找軟件,這時候我突然想起來了AirDrop也就是隔空投送,就想著有沒有類似的軟件可以用,然后我就找到了Snapdrop這個項目,我覺得這個項目很神奇,不需要登錄就可以在局域網(wǎng)內(nèi)發(fā)現(xiàn)設(shè)備并且傳輸文件,于是在好奇心的驅(qū)使下我也學(xué)習(xí)了一下,并且基于WebRTC/WebSocket實現(xiàn)了類似的文件傳輸方案https://github.com/WindrunnerMax/FileTransfer。通過這種方式,任何擁有瀏覽器的設(shè)備都有傳輸數(shù)據(jù)的可能,不需要借助數(shù)據(jù)線傳輸文件,也不會受限于Apple全家桶才能使用的隔空投送,以及天然的跨平臺優(yōu)勢可以應(yīng)用于常見的IOS/Android/Mac設(shè)備向PC臺式設(shè)備傳輸文件的場景等等。此外即使因為各種原因路由器開啟了AP隔離功能,我們的服務(wù)依舊可以正常交換數(shù)據(jù),這樣避免了在路由器不受我們控制的情況下通過WIFI傳輸文件的掣肘。那么回歸到項目本身,具體來說在完成功能的過程中解決了如下問題:
- 局域網(wǎng)內(nèi)可以互相發(fā)現(xiàn),不需要手動輸入對方
IP地址等信息。 - 多個設(shè)備中的任意兩個設(shè)備之間可以相互傳輸文本消息與文件數(shù)據(jù)。
- 設(shè)備間的數(shù)據(jù)傳輸采用基于
WebRTC的P2P方案,無需服務(wù)器中轉(zhuǎn)數(shù)據(jù)。 - 跨局域網(wǎng)傳輸且
NAT穿越受限的情況下,基于WebSocket服務(wù)器中轉(zhuǎn)傳輸。
此外,如果需要調(diào)試WebRTC的鏈接,可以在Chrome中打開about://webrtc-internals/,FireFox中打開about:webrtc即可進(jìn)行調(diào)試,在這里可以觀測到WebRTC的ICE交換、數(shù)據(jù)傳輸、事件觸發(fā)等等。
WebRTC
WebRTC是一套復(fù)雜的協(xié)議,同樣也是API,并且提供了音視頻傳輸?shù)囊徽捉鉀Q方案,可以總結(jié)為跨平臺、低時延、端對端的音視頻實時通信技術(shù)。WebRTC提供的API大致可以分為三類,分別是Media Stream API設(shè)備音視頻流、RTCPeerConnection API本地計算機(jī)到遠(yuǎn)端的WebRTC連接、Peer-To-Peer RTCDataChannel API瀏覽器之間P2P數(shù)據(jù)傳輸信道。在WebRTC的核心層中,同樣包含三大核心模塊,分別是Voice Engine音頻引擎、Video Engine視頻引擎、Transport傳輸模塊。音頻引擎Voice Engine中包含iSAC/iLBC Codec音頻編解碼器、NetEQ For Voice網(wǎng)絡(luò)抖動和丟包處理、 Echo Canceler/Noise Reduction回音消除與噪音抑制等。Video Engine視頻引擎中包括VP8 Codec視頻編解碼器、Video Jitter Buffer視頻抖動緩沖器、Image Enhancements圖像增強(qiáng)等。Transport傳輸模塊中包括SRTP安全實時傳輸協(xié)議、Multiplexing多路復(fù)用、?STUN+TURN+ICE網(wǎng)絡(luò)傳輸NAT穿越,DTLS數(shù)據(jù)報安全傳輸?shù)取?/p>
由于在這里我們的主要目的是數(shù)據(jù)傳輸,所以我們只需要關(guān)心API層面上的RTCPeerConnection API和Peer-To-Peer RTCDataChannel API,以及核心層中的Transport傳輸模塊即可。實際上由于網(wǎng)絡(luò)以及場景的復(fù)雜性,基于WebRTC衍生出了大量的方案設(shè)計,而在網(wǎng)絡(luò)框架模型方面,便有著三種架構(gòu): Mesh架構(gòu)即真正的P2P傳輸,每個客戶端與其他客戶端都建立了連接,形成了網(wǎng)狀的結(jié)構(gòu),這種架構(gòu)可以同時連接的客戶端有限,但是優(yōu)點(diǎn)是不需要中心服務(wù)器,實現(xiàn)簡單;MCU(MultiPoint Control Unit)網(wǎng)絡(luò)架構(gòu)即傳統(tǒng)的中心化架構(gòu),每個瀏覽器僅與中心的MCU服務(wù)器連接,MCU服務(wù)器負(fù)責(zé)所有的視頻編碼、轉(zhuǎn)碼、解碼、混合等復(fù)雜邏輯,這種架構(gòu)會對服務(wù)器造成較大的壓力,但是優(yōu)點(diǎn)是可以支持更多的人同時音視頻通訊,比較適合多人視頻會議。SFU(Selective Forwarding Unit)網(wǎng)絡(luò)架構(gòu)類似于MCU的中心化架構(gòu),仍然有中心節(jié)點(diǎn)服務(wù)器,但是中心節(jié)點(diǎn)只負(fù)責(zé)轉(zhuǎn)發(fā),不做太重的處理,所以服務(wù)器的壓力會低很多,這種架構(gòu)需要比較大的帶寬消耗,但是優(yōu)點(diǎn)是服務(wù)器壓力較小,典型場景是1對N的視頻互動。對于我們而言,我們的目標(biāo)是局域網(wǎng)之間的數(shù)據(jù)傳輸,所以并不會涉及此類復(fù)雜的網(wǎng)絡(luò)傳輸架構(gòu)模型,我們實現(xiàn)的是非常典型的P2P架構(gòu),甚至不需要N-N的數(shù)據(jù)傳輸,但是同樣也會涉及到一些復(fù)雜的問題,例如NAT穿越、ICE交換、STUN服務(wù)器、TURN服務(wù)器等等。
信令
信令是涉及到通信系統(tǒng)時,用于建立、控制和終止通信會話的信息,包含了與通信相關(guān)的各種指令、協(xié)議和消息,用于使通信參與者之間能夠相互識別、協(xié)商和交換數(shù)據(jù)。主要目的是確保通信參與者能夠建立連接、協(xié)商通信參數(shù),并在需要時進(jìn)行狀態(tài)的改變或終止,這其中涉及到各種通信過程中的控制信息交換,而不是直接傳輸實際的用戶數(shù)據(jù)。
或許會產(chǎn)生一個疑問,既然WebRTC可以做到P2P的數(shù)據(jù)傳輸,那么為什么還需要信令服務(wù)器來調(diào)度連接。實際上這很簡單,因為我們的網(wǎng)絡(luò)環(huán)境是非常復(fù)雜的,我們并不能明確地得到對方的IP等信息來直接建立連接,所以我們需要借助信令服務(wù)器來協(xié)調(diào)連接。需要注意的是信令服務(wù)器的目標(biāo)是協(xié)調(diào)而不是直接傳輸數(shù)據(jù),數(shù)據(jù)本身的傳輸是P2P的,那么也就是說我們建立信令服務(wù)器并不需要大量的資源。
那如果說我們是不是必須要有信令服務(wù)器,那確實不是必要的,在WebRTC中雖然沒有建立信令的標(biāo)準(zhǔn)或者說客戶端來回傳遞消息來建立連接的方法,因為網(wǎng)絡(luò)環(huán)境的復(fù)雜特別是IPv4的時代在客戶端直接建立連接是不太現(xiàn)實的,也就是我們做不到直接在互聯(lián)網(wǎng)上廣播我要連接到我的朋友,但是我們通過信令需要傳遞的數(shù)據(jù)是很明確的,而這些信息都是文本信息,所以如果不建立信令服務(wù)器的話,我們可以通過一些即使通訊軟件IM來將需要傳遞的信息明確的發(fā)給對方,那么這樣就不需要信令服務(wù)器了。那么人工轉(zhuǎn)發(fā)消息的方式看起來非常麻煩可能并不是很好的選擇,由此實際上我們可以理解為信令服務(wù)器就是把協(xié)商這部分內(nèi)容自動化了,并且附帶的能夠提高連接的效率以及附加協(xié)商鑒權(quán)能力等等。
SIGNLING
/ \
SDP/ICE / \ SDP/ICE
/ \
Client <-> Client
基本的數(shù)據(jù)傳輸過程如上圖所示,我們可以通過信令服務(wù)器將客戶端的SDP/ICE等信息傳遞,然后就可以在兩個Client之間建立起連接,之后的數(shù)據(jù)傳輸就完全在兩個客戶端也就是瀏覽器之間進(jìn)行了,而信令服務(wù)器的作用就是協(xié)調(diào)這個過程,使得兩個客戶端能夠建立起連接,實際上整個過程非常類似于TCP的握手,只不過這里并沒有那么嚴(yán)格而且只握手兩次就可以認(rèn)為是建立連接了。此外WebRTC是基于UDP的,所以WebRTC DataChannel也可以相當(dāng)于在UDP的不可靠傳輸?shù)幕A(chǔ)上實現(xiàn)了基本可靠的傳輸,類似于QUIC希望能取得可靠與速度之間的平衡。
那么我們現(xiàn)在已經(jīng)了解了信令服務(wù)器的作用,接下來我們就來實現(xiàn)信令服務(wù)器用來調(diào)度協(xié)商WebRTC。前邊我們也提到了,因為WebRTC并沒有規(guī)定信令服務(wù)器的標(biāo)準(zhǔn)或者協(xié)議,并且傳輸?shù)亩际俏谋緝?nèi)容,那么我們是可以使用任何方式來搭建這個信令服務(wù)器的,例如我們可以使用HTTP協(xié)議的短輪詢+超時、長輪詢,甚至是EventSource、SIP等等都可以作為信令服務(wù)器的傳輸協(xié)議。在這里我們的目標(biāo)不是僅僅建立起鏈接,而是希望能夠?qū)崿F(xiàn)類似于房間的概念,由此來管理我們的設(shè)備鏈接,所以首選的方案是WebSocket,WebSocket可以把這個功能做的更自然一些,全雙工的客戶端與服務(wù)器通信,消息可以同時在兩個方向上流動,而socket.io是基于WebSocket封裝了服務(wù)端和客戶端,使用起來非常簡單方便,所以接下來我們使用socket.io來實現(xiàn)信令服務(wù)器。
首先我們需要實現(xiàn)房間的功能,在最開始的時候我們就明確我們需要在局域網(wǎng)自動發(fā)現(xiàn)設(shè)備,那么也就是相當(dāng)于局域網(wǎng)的設(shè)備是屬于同一個房間的,那么我們就需要存儲一些信息,在這里我們使用Map分別存儲了id、房間、連接信息。那么在一個基本的房間中,我們除了將設(shè)備加入到房間外還需要實現(xiàn)幾個功能,對于新加入的設(shè)備A,我們需要將當(dāng)前房間內(nèi)已經(jīng)存在的設(shè)備信息告知當(dāng)前新加入的設(shè)備A,對于房間內(nèi)的其他設(shè)備,則需要通知當(dāng)前新加入的設(shè)備A的信息,同樣的在設(shè)備A退出房間的時候,我們也需要通知房間內(nèi)的其他設(shè)備當(dāng)前離開的設(shè)備A的信息,并且更新房間數(shù)據(jù)。
// packages/webrtc/server/index.ts
const authenticate = new WeakMap<ServerSocket, string>();
const mapper = new Map<string, Member>();
const rooms = new Map<string, string[]>();
socket.on(CLINT_EVENT.JOIN_ROOM, ({ id, device }) => {
// 驗證
if (!id) return void 0;
authenticate.set(socket, id);
// 加入房間
const ip = getIpByRequest(socket.request);
const room = rooms.get(ip) || [];
rooms.set(ip, [...room, id]);
mapper.set(id, { socket, device, ip });
// 房間通知消息
const initialization: SocketEventParams["JOINED_MEMBER"]["initialization"] = [];
room.forEach(key => {
const instance = mapper.get(key);
if (!instance) return void 0;
initialization.push({ id: key, device: instance.device });
instance.socket.emit(SERVER_EVENT.JOINED_ROOM, { id, device });
});
socket.emit(SERVER_EVENT.JOINED_MEMBER, { initialization });
});
const onLeaveRoom = (id: string) => {
// 驗證
if (authenticate.get(socket) !== id) return void 0;
// 退出房間
const instance = mapper.get(id);
if (!instance) return void 0;
const room = (rooms.get(instance.ip) || []).filter(key => key !== id);
if (room.length === 0) {
rooms.delete(instance.ip);
} else {
rooms.set(instance.ip, room);
}
mapper.delete(id);
// 房間內(nèi)通知
room.forEach(key => {
const instance = mapper.get(key);
if (!instance) return void 0;
instance.socket.emit(SERVER_EVENT.LEFT_ROOM, { id });
});
};
socket.on(CLINT_EVENT.LEAVE_ROOM, ({ id }) => {
onLeaveRoom(id);
});
socket.on("disconnect", () => {
const id = authenticate.get(socket);
id && onLeaveRoom(id);
});
可以看出我們管理房間是通過IP來實現(xiàn)的,因為此時需要注意一個問題,如果我們的信令服務(wù)器是部署在公網(wǎng)的服務(wù)器上,那么我們的房間就是全局的,也就是說所有的設(shè)備都可以連接到同一個房間,這樣的話顯然是不合適的,解決這個問題的方法很簡單,對于服務(wù)器而言我們獲取用戶的IP地址,如果用戶的IP地址是相同的就認(rèn)為是同一個局域網(wǎng)的設(shè)備,所以我們需要獲取當(dāng)前連接的Socket的IP信息,在這里我們特殊處理了127.0.0.1和192.168.0.0兩個網(wǎng)域的設(shè)備,以便我們在本地/路由器部署時能夠正常發(fā)現(xiàn)設(shè)備。
// packages/webrtc/server/utils.ts
export const getIpByRequest = (request: http.IncomingMessage) => {
let ip = "";
if (request.headers["x-forwarded-for"]) {
ip = request.headers["x-forwarded-for"].toString().split(/\s*,\s*/)[0];
} else {
ip = request.socket.remoteAddress || "";
}
// 本地部署應(yīng)用時,`ip`地址可能是`::1`或`::ffff:`
if (ip === "::1" || ip === "::ffff:127.0.0.1" || !ip) {
ip = "127.0.0.1";
}
// 局域網(wǎng)部署應(yīng)用時,`ip`地址可能是`192.168.x.x`
if (ip.startsWith("::ffff:192.168") || ip.startsWith("192.168")) {
ip = "192.168.0.0";
}
return ip;
};
至此信令服務(wù)器的房間功能就完成了,看起來實現(xiàn)信令服務(wù)器并不是一件難事,將這段代碼以及靜態(tài)資源部署在服務(wù)器上也僅占用20MB左右的內(nèi)存,幾乎不占用太多資源。而信令服務(wù)器的功能并不僅僅是房間的管理,我們還需要實現(xiàn)SDP和ICE的交換,只不過先前也提到了信令服務(wù)器的目標(biāo)是協(xié)調(diào)連接,那么在這里我們還需要實現(xiàn)SDP和ICE的轉(zhuǎn)發(fā)用以協(xié)調(diào)鏈接,在這里我們先將這部分內(nèi)容前置,接下來再開始聊RTCPeerConnection的協(xié)商過程。
// packages/webrtc/server/index.ts
socket.on(CLINT_EVENT.SEND_OFFER, ({ origin, offer, target }) => {
if (authenticate.get(socket) !== origin) return void 0;
if (!mapper.has(target)) {
socket.emit(SERVER_EVENT.NOTIFY_ERROR, {
code: ERROR_TYPE.PEER_NOT_FOUND,
message: `Peer ${target} Not Found`,
});
return void 0;
}
// 轉(zhuǎn)發(fā)`Offer` -> `Target`
const targetSocket = mapper.get(target)?.socket;
if (targetSocket) {
targetSocket.emit(SERVER_EVENT.FORWARD_OFFER, { origin, offer, target });
}
});
socket.on(CLINT_EVENT.SEND_ICE, ({ origin, ice, target }) => {
// 轉(zhuǎn)發(fā)`ICE` -> `Target`
// ...
});
socket.on(CLINT_EVENT.SEND_ANSWER, ({ origin, answer, target }) => {
// 轉(zhuǎn)發(fā)`Answer` -> `Target`
// ...
});
socket.on(CLINT_EVENT.SEND_ERROR, ({ origin, code, message, target }) => {
// 轉(zhuǎn)發(fā)`Error` -> `Target`
// ...
});
連接
在建設(shè)好信令服務(wù)器之后,我們就可以開始聊一聊RTCPeerConnection的具體協(xié)商過程了,在這部分會涉及比較多的概念,例如Offer、Answer、SDP、ICE、STUN、TURN等等,不過我們先不急著了解這些概念我們先開看一下RTCPeerConnection的完整協(xié)商過程,整個過程是非常類似于TCP的握手,當(dāng)然沒有那么嚴(yán)格,但是也是需要經(jīng)過幾個步驟才能夠建立起連接的:
A SIGNLING B
------------------------------- ---------- --------------------------------
| Offer -> LocalDescription | -> | -> | -> | Offer -> RemoteDescription |
| | | | | |
| RemoteDescription <- Answer | <- | <- | <- | LocalDescription <- Answer |
| | | | | |
| RTCIceCandidateEvent | -> | -> | -> | AddRTCIceCandidate |
| | | | | |
| AddRTCIceCandidate | <- | <- | <- | RTCIceCandidateEvent |
------------------------------- ---------- --------------------------------
- 假設(shè)我們有
A、B客戶端,兩個客戶端都已經(jīng)實例化RTCPeerConnection對象等待連接,當(dāng)然按需實例化RTCPeerConnection對象也是可以的。 -
A客戶端準(zhǔn)備發(fā)起鏈接請求,此時A客戶端需要創(chuàng)建Offer也就是RTCSessionDescription(SDP),并且將創(chuàng)建的Offer設(shè)置為本地的LocalDescription,緊接著借助信令服務(wù)器將Offer轉(zhuǎn)發(fā)到目標(biāo)客戶端也就是B客戶端。 -
B客戶端收到A客戶端的Offer之后,此時B客戶端需要將收到Offer設(shè)置為遠(yuǎn)端的RemoteDescription,然后創(chuàng)建Answer即同樣也是RTCSessionDescription(SDP),并且將創(chuàng)建的Answer設(shè)置為本地的LocalDescription,緊接著借助信令服務(wù)器將Answer轉(zhuǎn)發(fā)到目標(biāo)客戶端也就是A客戶端。 -
A客戶端收到B客戶端的Answer之后,此時A客戶端需要將收到Answer設(shè)置為遠(yuǎn)端的RemoteDescription,客戶端A、B之間的握手過程就結(jié)束了。 - 在
A客戶端與B客戶端握手的整個過程中,還需要穿插著ICE的交換,我們需要在ICECandidate候選人發(fā)生變化的時候,將ICE完整地轉(zhuǎn)發(fā)到目標(biāo)的客戶端,之后目標(biāo)客戶端將其設(shè)置為目標(biāo)候選人。
經(jīng)過我們上邊簡單RTCPeerConnection協(xié)商過程描述,此時如果網(wǎng)絡(luò)連通情況比較好的話,就可以順利建立連接,并且通過信道發(fā)送消息了,但是實際上在這里涉及的細(xì)節(jié)還是比較多的,我們可以一步步來拆解這個過程并且描述涉及到的相關(guān)概念,并且在最后我們會聊一下當(dāng)前IPv6設(shè)備的P2P、局域網(wǎng)以及AP隔離時的信道傳輸。
首先我們來看RTCPeerConnection對象,因為WebRTC有比較多的歷史遺留問題,所以我們?yōu)榱思嫒菪钥赡軙枰鲆恍┤哂嗟脑O(shè)置,當(dāng)然隨著WebRTC越來約規(guī)范化這些兼容問題會逐漸減少,但是我們還是可以考慮一下這個問題的,比如在建立RTCPeerConnection時做一點(diǎn)小小的兼容。
// packages/webrtc/client/core/instance.ts
const RTCPeerConnection =
// @ts-expect-error RTCPeerConnection
window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection;
const connection = new RTCPeerConnection({
// https://icetest.info/
// https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/
iceServers: options.ice
? [{ urls: options.ice }]
: [{ urls: ["stun:stunserver.stunprotocol.org:3478", "stun:stun.l.google.com:19302"] }],
});
在這里我們主要是配置了iceServers,其他的參數(shù)我們保持默認(rèn)即可我們不需要太多關(guān)注,以及例如sdpSemantics: unified-plan等配置項也越來越統(tǒng)一化病作為默認(rèn)值,在比較新的TS版本中甚至都不再提供這個配置項的定義了。那么我們目光回到iceServers這個配置項,iceServers主要是用來提供我們協(xié)商鏈接以及中轉(zhuǎn)的用途,我們可以簡單理解一下,試想我們的很多設(shè)備都是內(nèi)網(wǎng)的設(shè)備,而信令服務(wù)器僅僅是做了數(shù)據(jù)的轉(zhuǎn)發(fā),所以我們?nèi)绻强缇钟蚓W(wǎng)想在公網(wǎng)上或者在路由器AP隔離的情況下傳輸數(shù)據(jù)的話,最起碼需要知道我們的設(shè)備出口IP地址,STNU服務(wù)器就是用來獲取我們的出口IP地址的,TURN服務(wù)器則是用來中轉(zhuǎn)數(shù)據(jù)的,而因為STNU服務(wù)器并不需要太大的資源占用,所以有有比較多的公網(wǎng)服務(wù)器提供免費(fèi)的STNU服務(wù),但是TURN實際上相當(dāng)于中轉(zhuǎn)服務(wù)器,所以通常是需要購置云服務(wù)器自己搭建,并且設(shè)置Token過期時間等等防止盜用。上邊我們只是簡單理解一下,所以我們接下來需要聊一下NAT、STNU、TURN三個概念。
NAT(Network Address Translation)網(wǎng)絡(luò)地址轉(zhuǎn)換是一種在IP網(wǎng)絡(luò)中廣泛使用的技術(shù),主要是將一個IP地址轉(zhuǎn)換為另一個IP地址,具體來說其工作原理是將一個私有IP地址(如在家庭網(wǎng)絡(luò)或企業(yè)內(nèi)部網(wǎng)絡(luò)中使用的地址)映射到一個公共IP地址(如互聯(lián)網(wǎng)上的IP地址)。當(dāng)一個設(shè)備從私有網(wǎng)絡(luò)向公共網(wǎng)絡(luò)發(fā)送數(shù)據(jù)包時,NAT設(shè)備會將源IP地址從私有地址轉(zhuǎn)換為公共地址,并且在返回數(shù)據(jù)包時將目標(biāo)IP地址從公共地址轉(zhuǎn)換為私有地址。NAT可以通過多種方式實現(xiàn),例如靜態(tài)NAT、動態(tài)NAT和端口地址轉(zhuǎn)換PAT等,靜態(tài)NAT將一個私有IP地址映射到一個公共IP地址,而動態(tài)NAT則動態(tài)地為每個私有地址分配一個公共地址,PAT是一種特殊的動態(tài)NAT,在將私有IP地址轉(zhuǎn)換為公共IP地址時,還會將源端口號或目標(biāo)端口號轉(zhuǎn)換為不同的端口號,以支持多個設(shè)備使用同一個公共IP地址。NAT最初是為了解決IPv4地址空間的短缺而設(shè)計的,后來也為提高網(wǎng)絡(luò)安全性并簡化網(wǎng)絡(luò)管理提供了基礎(chǔ)。在互聯(lián)網(wǎng)上大多數(shù)設(shè)備都是通過路由器或防火墻連接到網(wǎng)絡(luò)的,這些設(shè)備通常使用網(wǎng)絡(luò)地址轉(zhuǎn)換NAT將內(nèi)部IP地址映射到一個公共的IP地址上,這個公共IP地址可以被其他設(shè)備用來訪問,但是這些設(shè)備內(nèi)部的IP地址是隱藏的,其他的設(shè)備不能直接通過它們的內(nèi)部IP地址建立P2P連接。因此,直接進(jìn)行P2P連接可能會受到網(wǎng)絡(luò)地址轉(zhuǎn)換NAT的限制,導(dǎo)致連接無法建立。
STUN(Session Traversal Utilities for NAT)會話穿透應(yīng)用程序用于在NAT或防火墻后面的客戶端之間建立P2P連接,STUN服務(wù)器并不會中轉(zhuǎn)數(shù)據(jù),而是主要用于獲取客戶端的公網(wǎng)IP地址,在客戶端請求服務(wù)器時服務(wù)器會返回客戶端的公網(wǎng)IP地址和端口號,這樣客戶端就可以通過這個公網(wǎng)IP地址和端口號來建立P2P連接,主要目標(biāo)是探測和發(fā)現(xiàn)通訊對方客戶端是否躲在防火墻或者NAT路由器后面,并且確定內(nèi)網(wǎng)客戶端所暴露在外的廣域網(wǎng)的IP和端口以及NAT類型等信息,STUN服務(wù)器利用這些信息協(xié)助不同內(nèi)網(wǎng)的計算機(jī)之間建立點(diǎn)對點(diǎn)的UDP通訊。實際上STUN是一個Client/Server模式的協(xié)議,客戶端發(fā)送一個STUN請求到STUN服務(wù)器,請求包含了客戶端本身所見到的自己的IP地址和端口號,STUN服務(wù)器收到請求后,會從請求中獲取到設(shè)備所在的公網(wǎng)IP地址和端口號,并將這些信息返回給設(shè)備,設(shè)備收到STUN服務(wù)器的回復(fù)后,就可以將這些信息告訴其他設(shè)備,從而實現(xiàn)對等通信,本質(zhì)上將地址交給客戶端設(shè)備,客戶端利用這些信息來嘗試建立通信。NAT主要分為四種,分別是完全圓錐型NAT、受限圓錐型NAT、端口受限圓錐型NAT、對稱型NAT,STUN對于前三種NAT是比較有效的,而大型公司網(wǎng)絡(luò)中經(jīng)常采用的對稱型NAT則不能使用STUN獲取公網(wǎng)IP及需要的端口號,具體的NAT穿越過程我們后邊再聊,在我的理解上STUN比較適用于單層NAT,多級NAT的情況下復(fù)雜性會增加,如果都是圓錐型NAT可能也還好,而實際上因為國內(nèi)網(wǎng)絡(luò)環(huán)境的復(fù)雜性,甚至運(yùn)營商對于UDP報文存在較多限制,實際使用STUN進(jìn)行NAT穿越的成功率還是比較低的。
TURN(Traversal Using Relay NAT)即通過Relay方式穿越NAT,由于網(wǎng)絡(luò)的復(fù)雜性,當(dāng)兩個設(shè)備都位于對稱型NAT后面或存在防火墻限制時時,直接的P2P連接通常難以建立,而當(dāng)設(shè)備無法直接連接時,設(shè)備可以通過與TURN服務(wù)器建立連接來進(jìn)行通信,設(shè)備將數(shù)據(jù)發(fā)送到TURN服務(wù)器,然后TURN服務(wù)器將數(shù)據(jù)中繼給目標(biāo)設(shè)備。實際上是一種中轉(zhuǎn)方案,并且因為是即將傳輸?shù)脑O(shè)備地址,避免了STUN應(yīng)用模型下出口NAT對RTP/RTCP地址端口號的任意分配,但無論如何就是相當(dāng)于TURN服務(wù)器成為了中間人,使得設(shè)備能夠在無法直接通信的情況下進(jìn)行數(shù)據(jù)傳輸,那么使用TURN服務(wù)器就會引入一定的延遲和帶寬消耗,因為數(shù)據(jù)需要經(jīng)過額外的中間步驟,所以TURN服務(wù)器在WebRTC中通常被視為備用方案,當(dāng)直接點(diǎn)對點(diǎn)連接無法建立時才使用,并且通常沒有公共的服務(wù)器資源可用,而且因為實際上是在前端配置的iceServers,所以通常是通過加密的方式生成限時連接用于傳輸,類似于常用的圖片防盜鏈機(jī)制。實際上在WebRTC中使用中繼服務(wù)器的場景是很常見的,例如多人視頻通話的場景下通常會選擇MCU或者SFU的中心化網(wǎng)絡(luò)架構(gòu)用來傳輸音視頻流。
那么在我們了解了這些概念以及用法之后,我們就簡單再聊一聊STUN是如何做到NAT穿透的,此時我們假設(shè)我們的網(wǎng)絡(luò)結(jié)構(gòu)只有一層NAT,并且對等傳輸?shù)膬蓚?cè)都是同樣的NAT結(jié)構(gòu),當(dāng)然不同的NAT也是可以穿越的,在這里我們只是簡化了整個模型,那么此時我們的網(wǎng)絡(luò)IP與相關(guān)端口號如下所示:
內(nèi)網(wǎng) 路由器 公網(wǎng)
A: 1.1.1.1:1111 1.1.1.1:1111 <-> 3.3.3.3:3333 3.3.3.3:3333
B: 6.6.6.6:6666 6.6.6.6:6666 <-> 8.8.8.8:8888 8.8.8.8:8888
STUN: 7.7.7.7:7777
SIGNLING: 9.9.9.9:9999
接著我們來看完全圓錐型NAT,一旦一個內(nèi)部地址IA:IP映射到外部地址EA:EP,所有發(fā)自IA:IP的包會都經(jīng)由3EA:EP向外發(fā)送,并且任意外部主機(jī)都能通過給EA:EP發(fā)包到達(dá)IA:IP。那么此時我們假設(shè)我們需要建立連接,此時我們需要基于A和B向STUN服務(wù)器發(fā)起請求,即1.1.1.1:1111 -> 7.7.7.7:7777那么此時STUN服務(wù)器就會返回A的公網(wǎng)IP地址和端口號,即3.3.3.3:3333,同樣的B也是6.6.6.6:6666 -> 7.7.7.7:7777得到8.8.8.8:8888,那么此時需要注意,我們已經(jīng)成功在路由器的路由表中建立了映射,那么我此時任意外部主機(jī)都能通過給EA:EP發(fā)包到達(dá)IA:IP,所以此時只需要通過信令服務(wù)器9.9.9.9:9999將A的3.3.3.3:3333告知B,將B的8.8.8.8:8888告知A,雙方就可以*通信了。
From To Playload
[1.1.1.1:1111] -> [7.7.7.7:7777] [1.1.1.1:1111]
[7.7.7.7:7777] -> [1.1.1.1:1111] [3.3.3.3:3333]
[6.6.6.6:6666] -> [7.7.7.7:7777] [6.6.6.6:6666]
[7.7.7.7:7777] -> [6.6.6.6:6666] [8.8.8.8:8888]
[1.1.1.1:1111] -> [9.9.9.9:9999] [3.3.3.3:3333]
[9.9.9.9:9999] -> [6.6.6.6:6666] [3.3.3.3:3333]
[6.6.6.6:6666] -> [9.9.9.9:9999] [8.8.8.8:8888]
[9.9.9.9:9999] -> [1.1.1.1:1111] [8.8.8.8:8888]
[1.1.1.1:1111] -> [8.8.8.8:8888] [DATA]
[6.6.6.6:6666] -> [3.3.3.3:3333] [DATA]
受限圓錐型NAT和端口受限圓錐型NAT比較類似,我們就放在一起了,這兩種NAT是基于圓錐型NAT加入了限制,受限圓錐型NAT是一種特殊的完全圓錐型NAT,其的限制是內(nèi)部主機(jī)只能向之前已經(jīng)發(fā)送過數(shù)據(jù)包的外部主機(jī)發(fā)送數(shù)據(jù)包,也就是說數(shù)據(jù)包的源地址需要與NAT表相符,而端口受限圓錐型NAT是一種特殊的受限圓錐型NAT,其限制是內(nèi)部主機(jī)只能向之前已經(jīng)發(fā)送或者接收過數(shù)據(jù)包的外部主機(jī)的相同端口發(fā)送數(shù)據(jù)包,也就是說數(shù)據(jù)包的源IP和PORT都要與NAT表相符。舉個例子的話就是只有路由表中已經(jīng)存在的IP/IP:PORT才能被路由器轉(zhuǎn)發(fā)數(shù)據(jù),實際上很好理解,當(dāng)我們正常發(fā)起一個請求的時候都是向某個固定的IP:PORT發(fā)送數(shù)據(jù),而接受數(shù)據(jù)的時候,這個IP:PORT已經(jīng)在路由表中了所以是可以正常接受數(shù)據(jù)的,而這兩種NAT雖然限制了IP/IP:PORT必需要在路由表中,但是并沒有限制IP:PORT只能與之前的IP:PORT通信,所以我們只需要在之前的圓錐型NAT基礎(chǔ)上,主動預(yù)發(fā)送數(shù)據(jù)包即可,相當(dāng)于把IP/IP:PORT寫入了路由表,那么路由器在收到來自這個IP/IP:PORT的數(shù)據(jù)包時就可以正常轉(zhuǎn)發(fā)了。
From To Playload
[1.1.1.1:1111] -> [7.7.7.7:7777] [1.1.1.1:1111]
[7.7.7.7:7777] -> [1.1.1.1:1111] [3.3.3.3:3333]
[6.6.6.6:6666] -> [7.7.7.7:7777] [6.6.6.6:6666]
[7.7.7.7:7777] -> [6.6.6.6:6666] [8.8.8.8:8888]
[1.1.1.1:1111] -> [9.9.9.9:9999] [3.3.3.3:3333]
[9.9.9.9:9999] -> [6.6.6.6:6666] [3.3.3.3:3333]
[6.6.6.6:6666] -> [9.9.9.9:9999] [8.8.8.8:8888]
[9.9.9.9:9999] -> [1.1.1.1:1111] [8.8.8.8:8888]
[1.1.1.1:1111] -> [8.8.8.8:8888] [PRE-REQUEST]
[6.6.6.6:6666] -> [3.3.3.3:3333] [PRE-REQUEST]
[1.1.1.1:1111] -> [8.8.8.8:8888] [DATA]
[6.6.6.6:6666] -> [3.3.3.3:3333] [DATA]
對稱型NAT是限制最多的,每一個來自相同內(nèi)部IP與PORT,到一個特定目的地IP和PORT的請求,都映射到一個獨(dú)特的外部IP地址和PORT,同一內(nèi)部IP與端口發(fā)到不同的目的地和端口的信息包,都使用不同的映射,類似于在端口受限圓錐型NAT的基礎(chǔ)上,限制了IP:PORT只能與之前的IP:PORT通信,對于STUN來說具體的限制實際上是我們發(fā)起的IP:PORT探測請求與最終實際連接的IP:PORT是同一個地址與端口的映射,然而在對稱型NAT中,我們發(fā)起的IP:PORT探測請求與最終實際連接的IP:PORT會被記錄為不同的地址與端口映射,或者換句話說,我們通過STUN拿到的IP:PORT只能跟STUN通信,無法用來共享給別的設(shè)備傳輸數(shù)據(jù)。
From To Playload
[1.1.1.1:1111] -> [7.7.7.7:7777] [1.1.1.1:1111]
[7.7.7.7:7777] -> [1.1.1.1:1111] [3.3.3.3:3333]
[6.6.6.6:6666] -> [7.7.7.7:7777] [6.6.6.6:6666]
[7.7.7.7:7777] -> [6.6.6.6:6666] [8.8.8.8:8888]
[1.1.1.1:1111] -> [9.9.9.9:9999] [3.3.3.3:3333]
[9.9.9.9:9999] -> [6.6.6.6:6666] [3.3.3.3:3333]
[6.6.6.6:6666] -> [9.9.9.9:9999] [8.8.8.8:8888]
[9.9.9.9:9999] -> [1.1.1.1:1111] [8.8.8.8:8888]
[1.1.1.1:1111] -- [8.8.8.8:8888] []
[6.6.6.6:6666] -- [3.3.3.3:3333] []
在完整了解了WebRTC有關(guān)NAT穿透相關(guān)的概念之后,我們繼續(xù)完成WebRTC的鏈接過程,實際上因為我們已經(jīng)深入分析了NAT的穿透,那么就相當(dāng)于我們已經(jīng)可以在互聯(lián)上建立起鏈接了,但是因為WebRTC并不僅僅是建立了一個傳輸信道,這其中還伴隨著音視頻媒體的描述,用于媒體信息的傳輸協(xié)議、傳輸類型、編解碼協(xié)商等等也就是SDP協(xié)議,SDP是<type>=<value>格式的純文本協(xié)議,一個典型的SDP如下所示,而我們將要使用的Offer/Answer/RTCSessionDescription就是帶著類型的SDP即{ type: "offer"/"answer"/"pranswer"/"rollback", sdp: "..." },對于我們來說可能并不需要過多關(guān)注,因為我們現(xiàn)在的目標(biāo)是建立連接以及傳輸信道,所以我們更多的還是關(guān)注于鏈接建立的流程。
v=0
o=- 8599901572829563616 2 IN IP4 127.0.0.1
s=-
c=IN IP4 0.0.0.0
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
那么此時我們需要創(chuàng)建鏈接,看起來發(fā)起流程非常簡單,我們假設(shè)現(xiàn)在有兩個客戶端A、B,此時客戶端A通過createOffer創(chuàng)建了Offer,并且通過setLocalDescription將其設(shè)置為本地描述,緊接著將Offer通過信令服務(wù)器發(fā)送到了目標(biāo)客戶端B。
// packages/webrtc/client/core/instance.ts
public createRemoteConnection = async (target: string) => {
console.log("Send Offer To:", target);
this.connection.onicecandidate = async event => {
if (!event.candidate) return void 0;
console.log("Local ICE", event.candidate);
const payload = { origin: this.id, ice: event.candidate, target };
this.signaling.emit(CLINT_EVENT.SEND_ICE, payload);
};
const offer = await this.connection.createOffer();
await this.connection.setLocalDescription(offer);
console.log("Offer SDP", offer);
const payload = { origin: this.id, offer, target };
this.signaling.emit(CLINT_EVENT.SEND_OFFER, payload);
};
當(dāng)目標(biāo)客戶端B收到Offer之后,可以通過判斷當(dāng)前是否正在建立連接等狀態(tài)來決定是否接受這個Offer,接受的話就將收到的Offer通過setRemoteDescription設(shè)置為遠(yuǎn)程描述,并且通過createAnswer創(chuàng)建answer,同樣將answer設(shè)置為本地描述后,緊接著將answer通過信令服務(wù)器發(fā)送到了Offer來源的客戶端A。
// packages/webrtc/client/core/instance.ts
private onReceiveOffer = async (params: SocketEventParams["FORWARD_OFFER"]) => {
const { offer, origin } = params;
console.log("Receive Offer From:", origin, offer);
if (this.connection.currentLocalDescription || this.connection.currentRemoteDescription) {
this.signaling.emit(CLINT_EVENT.SEND_ERROR, {
origin: this.id,
target: origin,
code: ERROR_TYPE.PEER_BUSY,
message: `Peer ${this.id} is Busy`,
});
return void 0;
}
this.connection.onicecandidate = async event => {
if (!event.candidate) return void 0;
console.log("Local ICE", event.candidate);
const payload = { origin: this.id, ice: event.candidate, target: origin };
this.signaling.emit(CLINT_EVENT.SEND_ICE, payload);
};
await this.connection.setRemoteDescription(offer);
const answer = await this.connection.createAnswer();
await this.connection.setLocalDescription(answer);
console.log("Answer SDP", answer);
const payload = { origin: this.id, answer, target: origin };
this.signaling.emit(CLINT_EVENT.SEND_ANSWER, payload);
};
當(dāng)發(fā)起方的客戶端A收到了目標(biāo)客戶端B的應(yīng)答之后,如果當(dāng)前沒有設(shè)置遠(yuǎn)程描述的話,就通過setRemoteDescription設(shè)置為遠(yuǎn)程描述,此時我們的SDP協(xié)商過程就完成了。
// packages/webrtc/client/core/instance.ts
private onReceiveAnswer = async (params: SocketEventParams["FORWARD_ANSWER"]) => {
const { answer, origin } = params;
console.log("Receive Answer From:", origin, answer);
if (!this.connection.currentRemoteDescription) {
this.connection.setRemoteDescription(answer);
}
};
實際上我們可以關(guān)注到在創(chuàng)建Offer和Answer的時候還存在onicecandidate事件的回調(diào),這里實際上就是ICE候選人變化的過程,我們可以通過event.candidate獲取到當(dāng)前的候選人,然后我們需要盡快通過信令服務(wù)器將其轉(zhuǎn)發(fā)到目標(biāo)客戶端,目標(biāo)客戶端收到之后通過addIceCandidate添加候選人,這樣就完成了ICE候選人的交換。在這里我們需要注意的是我們需要盡快轉(zhuǎn)發(fā)ICE,那么對于我們而言就并不需要關(guān)注時機(jī),但實際上時機(jī)已經(jīng)在規(guī)范中明確了,在setLocalDescription不會開始收集候選者信息。
// packages/webrtc/client/core/instance.ts
private onReceiveIce = async (params: SocketEventParams["FORWARD_ICE"]) => {
const { ice, origin } = params;
console.log("Receive ICE From:", origin, ice);
await this.connection.addIceCandidate(ice);
};
那么到這里我們的鏈接協(xié)商過程就結(jié)束了,而我們實際建立P2P信道的過程就非常依賴ICE(Interactive Connectivity Establishment)的交換,ICE候選者描述了WebRTC能夠與遠(yuǎn)程設(shè)備通信所需的協(xié)議和路由,當(dāng)啟動WebRTC P2P連接時,通常連接的每一端都會提出許多候選連接,直到他們就描述他們認(rèn)為最好的連接達(dá)成一致,然后WebRTC就會使用該候選人的詳細(xì)信息來啟動連接。ICE和STUN密切相關(guān),前邊我們已經(jīng)了解了NAT穿越的過程,那么接下來我們就來看一下ICE候選人交換的數(shù)據(jù)結(jié)構(gòu),ICE候選人實際上是一個RTCIceCandidate對象,而這個對象包含了很多信息,但是實際上這個對象中存在了toJSON方法,所以實際交換的數(shù)據(jù)只有candidate、sdpMid、sdpMLineIndex、usernameFragment,而這些交換的數(shù)據(jù)又會在candidate字段中體現(xiàn),所以我們在這里就重點(diǎn)關(guān)注這四個字段代表的意義。
-
candidate: 描述候選者屬性的字符串,示例candidate:842163049 1 udp 1677729535 101.68.35.129 24692 typ srflx raddr 0.0.0.0 rport 0 generation 0 ufrag WbBI network-cost 999,候選字符串指定候選的網(wǎng)絡(luò)連接信息,這些屬性均由單個空格字符分隔,并且按特定順序排列,如果候選者是空字符串,則表示已到達(dá)候選者列表的末尾,該候選者被稱為候選者結(jié)束標(biāo)記。-
foundation: 候選者的標(biāo)識符,用于唯一標(biāo)識一個ICE候選者。,示例4234997325。 -
component: 候選者所屬的是RTP:1還是RTCP:2協(xié)議,示例1。 -
protocol: 候選者使用的傳輸協(xié)議udp/tcp,示例udp。 -
priority: 候選者的優(yōu)先級,值越高越優(yōu)先,示例1677729535。 -
ip: 候選者的IP地址,示例101.68.35.129。 -
port: 候選者的端口號,示例24692。 -
type: 候選者的類型,示例srflx。-
host:IP地址實際上是設(shè)備主機(jī)公網(wǎng)地址,或者本地設(shè)備地址。 -
srflx: 通過STUN或者TURN收集的NAT網(wǎng)關(guān)在公網(wǎng)側(cè)的IP地址。 -
prflx:NAT在發(fā)送STUN請求以匿名代表候選人對等點(diǎn)時分配的綁定,可以在ICE的后續(xù)階段中獲取到。 -
relay: 中繼候選者,通過TURN收集的TURN服務(wù)器的公網(wǎng)轉(zhuǎn)發(fā)地址。
-
-
raddr: 候選者的遠(yuǎn)程地址,表示在此候選者之間建立連接時的對方地址,示例0.0.0.0。 -
rport: 候選者的遠(yuǎn)程端口,表示在此候選者之間建立連接時的對方端口,示例0。 -
generation: 候選者的ICE生成代數(shù),用于區(qū)分不同生成時的候選者,示例0。 -
ufrag: 候選者的ICE標(biāo)識符,用于在ICE過程中進(jìn)行身份驗證和匹配,示例WbBI。 -
network-cost: 候選者的網(wǎng)絡(luò)成本,較低的成本值表示較優(yōu)的網(wǎng)絡(luò)路徑,示例999。
-
-
sdpMid: 用于標(biāo)識媒體描述的SDP媒體的唯一標(biāo)識符,示例sdpMid: "0",如果媒體描述不可用,則為空字符串。 -
sdpMLineIndex: 媒體描述的索引,示例sdpMLineIndex: 0,如果媒體描述不可用,則為null。 -
usernameFragment: 用于標(biāo)識ICE會話的唯一標(biāo)識符,示例usernameFragment: "WbBI"。
在鏈接建立完成之后,我們就可以通過控制臺觀察WebRTC是否成功建立了,在內(nèi)網(wǎng)的情況下ICE的候選人信息大致如下所示,我們可以通過觀察IP來確定連接的實際地址,并且在IPv4和IPv6的情況下是有所區(qū)別的。
ICE Candidate pair: :60622 <=> 192.168.0.100:44103
ICE Candidate pair: :55305 <=> 2408:8240:e12:3c45:f1ba:c574:6328:a70:45954
如果是在AP隔離的情況下,也就是說我們不能通過192.168.x.x網(wǎng)域直接訪問對方,這種情況下STUN服務(wù)器就起到作用了,相當(dāng)于做了一次NAT穿透,此時我們可以觀察到IP地址是公網(wǎng)地址并且相同,但是端口號是不同的,我們也可以理解為我們的數(shù)據(jù)包通過公網(wǎng)跑了一圈又回到了局域網(wǎng),很像是完成了一次網(wǎng)絡(luò)回環(huán)。
ICE Candidate pair: 101.68.35.129:25595 <=> 101.68.35.129:25596
在前幾天搬磚的時候,我突然想到一個問題,現(xiàn)在都是IPv6的時代了,而STUN服務(wù)器實際上又是支持IPv6的,那么如果我們的設(shè)備都有全球唯一的公網(wǎng)IPv6地址豈不是做到P2P互聯(lián),從而真的成為互聯(lián)網(wǎng),所以我找了朋友測試了一下IPv6的鏈接情況,因為手機(jī)設(shè)備通常都是真實分配IPv6的地址,所以就可以直接在手機(jī)上先進(jìn)行一波測試,首先訪問下test-ipv6來獲取手機(jī)的公網(wǎng)IPv6地址,并且對比下手機(jī)詳細(xì)信息里邊的地址,而IPv6目前只要是看到以2/3開頭的都可以認(rèn)為是公網(wǎng)地址,以fe80開頭的則是本地連接地址。在這里我們可以借助Netcat也就是常用的nc命令來測試,在手機(jī)設(shè)備上可以使用Termux并且使用包管理器安裝netcat-openbsd。
# 設(shè)備`A`監(jiān)聽
$ nc -vk6 9999
# 設(shè)備`B`連接
$ nc -v6 ${ip} 9999
這里的測試就很有意思了,然后我屋里的路由器設(shè)備已經(jīng)開啟了IPv6,而且關(guān)閉了標(biāo)準(zhǔn)中未定義而是社區(qū)提供的NAT6方案,并且使用獲取IPv6前綴的Native方案,然而無論我如何嘗試都不能通過我的電腦連接到我的手機(jī),實際上即使我的電腦沒有公網(wǎng)地址而只要手機(jī)有公網(wǎng)地址,那么從電腦發(fā)起連接請求并且連接到手機(jī),但是可惜還是無法建立鏈接,但是使用ping6是可以ping通的,所以實際上是能尋址到只是被攔截了連接的請求,后來我嘗試在設(shè)備啟動HTTP服務(wù)也無法直接建立鏈接,或許是有著一些限制策略例如UDP的報文必須先由本設(shè)備某端口發(fā)出后這個端口才能接收公網(wǎng)地址報文。再后來我找我朋友的手機(jī)進(jìn)行連接測試,我是聯(lián)通卡朋友是電信卡,我能夠連接到我的朋友,但是我朋友無法直接連接到我,而我們的IPv6都是2開頭的是公網(wǎng)地址,然后我們懷疑是運(yùn)營商限制了端口所以嘗試不斷切換端口來建立鏈接,還是不能直接連接。
于是我最后測試了一下,我換到了我的卡2電信卡,此時無論是我的朋友還是我的電腦都可以直接通過電信分配的IPv6地址連接到我的手機(jī)了。這就很難繃,而我另一個朋友的聯(lián)通又能夠直接連接,所以在國內(nèi)的網(wǎng)絡(luò)環(huán)境下還是需要看地域性的。之后我找了好幾個朋友測試了P2P的鏈接,因為只要設(shè)備雙方只要有一方有公網(wǎng)的IP那么大概率就是能夠直接P2P的,所以通過WebRTC連接的成功率還是可以的,并沒有想象中那么低,但我們主要的場景還是局域網(wǎng)傳輸,只是我們會在項目中留一個輸入對方ID用以跨網(wǎng)段鏈接的方式。
通信
在我們成功建立鏈接之后,我們就可以開啟傳輸信道Peer-To-Peer RTCDataChannel API相關(guān)部分了,通過createDataChannel方法可以創(chuàng)建一個與遠(yuǎn)程對等點(diǎn)鏈接的新通道,可以通過該通道傳輸任何類型的數(shù)據(jù),例如圖像、文件傳輸、文本聊天、游戲更新數(shù)據(jù)包等。我們可以在RTCPeerConnection對象實例化的時候就創(chuàng)建信道,之后等待鏈接成功建立即可,同樣的createDataChannel也存在很多參數(shù)可以配置。
-
label: 可讀的信道名稱,不超過65,535 bytes。 -
ordered: 保證傳輸順序,默認(rèn)為true。 -
maxPacketLifeTime: 信道嘗試傳輸消息可能需要的最大毫秒數(shù),如果設(shè)置為null則表示沒有限制,默認(rèn)為null。 -
maxRetransmits: 信道嘗試傳輸消息可能需要的最大重傳次數(shù),如果設(shè)置為null則表示沒有限制,默認(rèn)為null。 -
protocol: 信道使用的子協(xié)議,如果設(shè)置為null則表示沒有限制,默認(rèn)為null。 -
negotiated: 是否為協(xié)商信道,如果設(shè)置為true則表示信道是協(xié)商的,如果設(shè)置為false則表示信道是非協(xié)商的,默認(rèn)為false。 -
id: 信道的唯一標(biāo)識符,如果設(shè)置為null則表示沒有限制,默認(rèn)為null。
前邊我們也提到了WebRTC希望借助UDP實現(xiàn)相對可靠的數(shù)據(jù)傳輸,類似于QUIC希望能取得可靠與速度之間的平衡,所以在這里我們的order指定了true,并且設(shè)置了最大傳輸次數(shù),在這里需要注意的是,我們最終的消息事件綁定是在ondatachannel事件之后的,當(dāng)信道真正建立之后,這個事件將會被觸發(fā),并且在此時將可以進(jìn)行信息傳輸,此外當(dāng)negotiated指定為true時則必須設(shè)置id,此時就是通過id協(xié)商信道相當(dāng)于雙向通信,那么就不需要指定ondatachannel事件了,直接在channel上綁定事件回調(diào)即可。
// packages/webrtc/client/core/instance.ts
const channel = connection.createDataChannel("FileTransfer", {
ordered: true, // 保證傳輸順序
maxRetransmits: 50, // 最大重傳次數(shù)
});
this.connection.ondatachannel = event => {
const channel = event.channel;
channel.onopen = options.onOpen || null;
channel.onmessage = options.onMessage || null;
// @ts-expect-error RTCErrorEvent
channel.onerror = options.onError || null;
channel.onclose = options.onClose || null;
};
那么在信道創(chuàng)建完成之后,我們現(xiàn)在暫時只需要關(guān)注最基本的兩個方法,一個是channel.send方法可以用來發(fā)送數(shù)據(jù),例如純文本數(shù)據(jù)、Blob、ArrayBuffer都是可以直接發(fā)送的,同樣的channel.onmessage事件也是可以接受相同的數(shù)據(jù)類型,那么我們接下來就借助這兩個方法來完成文本與文件的傳輸。那么我們就來最簡單地實現(xiàn)傳輸,首先我們要規(guī)定好基本的傳輸數(shù)據(jù)類型,因為我們是實際上只區(qū)分兩種類型的數(shù)據(jù),也就是Text/Blob數(shù)據(jù),所以需要對這兩種數(shù)據(jù)做基本的判斷,然后再根據(jù)不同的類型響應(yīng)不同的行為,當(dāng)然我們也可以自擬數(shù)據(jù)結(jié)構(gòu)/協(xié)議,例如借助Uint8Array構(gòu)造Blob前N個字節(jié)表示數(shù)據(jù)類型、id、序列號等等,后邊攜帶數(shù)據(jù)內(nèi)容,這樣也可以組裝直接傳輸Blob,在這里我們還是簡單處理,主要處理單個文件的傳輸。
export type ChunkType = Blob | ArrayBuffer;
export type TextMessageType =
| { type: "text"; data: string }
| { type: "file"; size: number; name: string; id: string; total: number }
| { type: "file-finish"; id: string };
那么我們封裝發(fā)送文本和文件的方法,我們可以看到我們在發(fā)送文件的時候,我們會先發(fā)送一個文件信息的消息,然后再發(fā)送文件內(nèi)容,這樣就可以在接收端進(jìn)行文件的組裝。在這里需要注意的有兩點(diǎn),對于大文件來說我們是需要將其分割發(fā)送的,在協(xié)商SCTP時會有maxMessageSize的值,表示每次調(diào)用send方法最大能發(fā)送的字節(jié)數(shù),通常為256KB大小,在MDN的WebRTC_API/Using_data_channels對這個問題有過描述,另一個需要注意的地方時是緩沖區(qū),由于發(fā)送大文件時緩沖區(qū)會很容易大量占用緩沖區(qū),并且也不利于我們對發(fā)送進(jìn)度的統(tǒng)計,所以我們還需要借助onbufferedamountlow事件來控制緩沖區(qū)的發(fā)送狀態(tài)。
// packages/webrtc/client/components/modal.tsx
const onSendText = () => {
const str = TSON.encode({ type: "text", data: text });
if (str && rtc.current && text) {
rtc.current?.send(str);
setList([...list, { type: "text", from: "self", data: text }]);
}
};
const sendFilesBySlice = async (file: File) => {
const instance = rtc.current?.getInstance();
const channel = instance?.channel;
if (!channel) return void 0;
const chunkSize = instance.connection.sctp?.maxMessageSize || 64000;
const name = file.name;
const id = getUniqueId();
const size = file.size;
const total = Math.ceil(file.size / chunkSize);
channel.send(TSON.encode({ type: "file", name, id, size, total }));
const newList = [...list, { type: "file", from: "self", name, size, progress: 0, id } as const];
setList(newList);
let offset = 0;
while (offset < file.size) {
const slice = file.slice(offset, offset + chunkSize);
const buffer = await slice.arrayBuffer();
if (channel.bufferedAmount >= chunkSize) {
await new Promise(resolve => {
channel.onbufferedamountlow = () => resolve(0);
});
}
fileMapper.current[id] = [...(fileMapper.current[id] || []), buffer];
channel.send(buffer);
offset = offset + buffer.byteLength;
updateFileProgress(id, Math.floor((offset / size) * 100), newList);
}
};
const onSendFile = () => {
const KEY = "webrtc-file-input";
const exist = document.querySelector(`body > [data-type='${KEY}']`) as HTMLInputElement;
const input: HTMLInputElement = exist || document.createElement("input");
input.value = "";
input.setAttribute("data-type", KEY);
input.setAttribute("type", "file");
input.setAttribute("class", styles.fileInput);
input.setAttribute("accept", "*");
!exist && document.body.append(input);
input.onchange = e => {
const target = e.target as HTMLInputElement;
document.body.removeChild(input);
const files = target.files;
const file = files && files[0];
file && sendFilesBySlice(file);
};
input.click();
};
那么最后我們只需要在接收的時候?qū)?nèi)容組裝到數(shù)組當(dāng)中,并且在調(diào)用下載的時候?qū)⑵浣M裝為Blob下載即可,當(dāng)然因為目前我們是單文件發(fā)送的,也就是說發(fā)送文件塊的時候并沒有攜帶當(dāng)前塊的任何描述信息,所以我們在接收塊的時候是不能再發(fā)送其他內(nèi)容的。
// packages/webrtc/client/components/modal.tsx
const onMessage = useMemoizedFn((event: MessageEvent<string | ChunkType>) => {
if (isString(event.data)) {
const data = TSON.decode(event.data);
if (data && data.type === "text") {
setList([...list, { from: "peer", ...data }]);
} else if (data?.type === "file") {
fileState.current = { id: data.id, current: 0, total: data.total };
setList([...list, { from: "peer", progress: 0, ...data }]);
} else if (data?.type === "file-finish") {
updateFileProgress(data.id, 100);
}
} else {
const state = fileState.current;
if (state) {
const mapper = fileMapper.current;
if (!mapper[state.id]) mapper[state.id] = [];
mapper[state.id].push(event.data);
state.current++;
const progress = Math.floor((state.current / state.total) * 100);
updateFileProgress(state.id, progress);
if (progress === 100) {
fileState.current = void 0;
rtc.current?.send(TSON.encode({ type: "file-finish", id: state.id }));
}
}
}
});
const onDownloadFile = (id: string, fileName: string) => {
const data = fileMapper.current[id] || new Blob();
const blob = new Blob(data, { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
};
在后來補(bǔ)充了一下多文件傳輸?shù)姆桨福唧w的思路是構(gòu)造ArrayBuffer,其中前12個字節(jié)表示當(dāng)前塊所屬的文件ID,再使用4個字節(jié)也就是32位表示當(dāng)前塊的序列號,其余的內(nèi)容作為文件塊的實際內(nèi)容,然后就可以實現(xiàn)文件傳輸?shù)倪^程中不同文件發(fā)送塊,然后就可以在接收端通過存儲的ID和序列號進(jìn)行Blob組裝,思路與后邊的WebSocket通信部分保持一致,所以在這里只是描述一下ArrayBuffer的組裝方法。
// packages/webrtc/client/utils/binary.ts
export const getNextChunk = (
instance: React.MutableRefObject<WebRTCApi | null>,
id: string,
series: number
) => {
const file = FILE_SOURCE.get(id);
const chunkSize = getMaxMessageSize(instance);
if (!file) return new Blob([new ArrayBuffer(chunkSize)]);
const start = series * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const idBlob = new Uint8Array(id.split("").map(char => char.charCodeAt(0)));
const seriesBlob = new Uint8Array(4);
// `0xff = 1111 1111`
seriesBlob[0] = (series >> 24) & 0xff;
seriesBlob[1] = (series >> 16) & 0xff;
seriesBlob[2] = (series >> 8) & 0xff;
seriesBlob[3] = series & 0xff;
return new Blob([idBlob, seriesBlob, file.slice(start, end)]);
};
export const destructureChunk = async (chunk: ChunkType) => {
const buffer = chunk instanceof Blob ? await chunk.arrayBuffer() : chunk;
const id = new Uint8Array(buffer.slice(0, ID_SIZE));
const series = new Uint8Array(buffer.slice(ID_SIZE, ID_SIZE + CHUNK_SIZE));
const data = chunk.slice(ID_SIZE + CHUNK_SIZE);
const idString = String.fromCharCode(...id);
const seriesNumber = (series[0] << 24) | (series[1] << 16) | (series[2] << 8) | series[3];
return { id: idString, series: seriesNumber, data };
};
WebSocket
當(dāng)WebRTC無法成功進(jìn)行NAT穿越時,如果想在公網(wǎng)發(fā)送數(shù)據(jù)還是需要經(jīng)過TURN轉(zhuǎn)發(fā),那么都是通過TURN轉(zhuǎn)發(fā)了還是需要走我們服務(wù)器的中繼,那么我們不如直接借助WebSocket傳輸了,WebSocket也是全雙工信道,在非AP隔離的情況下我們同樣也可以直接部署在路由器上,在局域網(wǎng)之間進(jìn)行數(shù)據(jù)傳輸。
連接
使用WebSocket進(jìn)行傳輸?shù)臅r候,我們是直接借助服務(wù)器轉(zhuǎn)發(fā)所有數(shù)據(jù)的,不知道大家是不是注意到WebRTC的鏈接過程實際上是比較麻煩的,而且相對難以管理,這其中部分原因就是建立一個鏈接涉及到了多方的通信鏈接,需要客戶端A、信令服務(wù)器、STUN服務(wù)器、客戶端B之間的相互連接,那么如果我們使用WebSocket就沒有這么多方連接需要管理,每個客戶端都只需要管理自身與服務(wù)器之間的連接,就像是我們的HTTP模型一樣是Client/Server結(jié)構(gòu)。
WebSocket
/ \
DATA / \ DATA
/ \
Client Client
那么此時我們在WebSocket的服務(wù)端依然要定義一些事件,與WebRTC不一樣的是,我們只需要定義一個房間即可,并且所有的狀態(tài)都可以在服務(wù)端直接進(jìn)行管理,例如是否連接成功、是否正在傳輸?shù)鹊龋?code>WebRTC的實現(xiàn)中我們必須要將這個實現(xiàn)放在客戶端,因為連接狀態(tài)實際上是客戶端直接連接的對等客戶端,在服務(wù)端并不是很容易實時管理整個狀態(tài),當(dāng)然不考慮延遲或者實現(xiàn)心跳的話也是可以的。
那么同樣的在這里我們的服務(wù)端定義了JOIN_ROOM加入到房間、LEAVE_ROOM離開房間,這里的管理流程與WebRTC基本一致。
// packages/websocket/server/index.ts
const authenticate = new WeakMap<ServerSocket, string>();
const room = new Map<string, Member>();
const peer = new Map<string, string>();
socket.on(CLINT_EVENT.JOIN_ROOM, ({ id, device }) => {
// 驗證
if (!id) return void 0;
authenticate.set(socket, id);
// 房間通知消息
const initialization: SocketEventParams["JOINED_MEMBER"]["initialization"] = [];
room.forEach((instance, key) => {
initialization.push({ id: key, device: instance.device });
instance.socket.emit(SERVER_EVENT.JOINED_ROOM, { id, device });
});
// 加入房間
room.set(id, { socket, device, state: CONNECTION_STATE.READY });
socket.emit(SERVER_EVENT.JOINED_MEMBER, { initialization });
});
const onLeaveRoom = () => {
// 驗證
const id = authenticate.get(socket);
if (id) {
const peerId = peer.get(id);
peer.delete(id);
if (peerId) {
// 狀態(tài)復(fù)位
peer.delete(peerId);
updateMember(room, peerId, "state", CONNECTION_STATE.READY);
}
// 退出房間
room.delete(id);
room.forEach(instance => {
instance.socket.emit(SERVER_EVENT.LEFT_ROOM, { id });
});
}
};
socket.on(CLINT_EVENT.LEAVE_ROOM, onLeaveRoom);
socket.on("disconnect", onLeaveRoom);
之后便是我們建立連接時要處理的SEND_REQUEST發(fā)起連接請求、SEND_RESPONSE回應(yīng)連接請求、SEND_MESSAGE發(fā)送消息、SEND_UNPEER發(fā)送斷開連接請求,并且在這里因為狀態(tài)是由服務(wù)端管理的,我們可以立即響應(yīng)對方是否正在忙線等狀態(tài),那么便可以直接使用回調(diào)函數(shù)通知發(fā)起方。
// packages/websocket/server/index.ts
socket.on(CLINT_EVENT.SEND_REQUEST, ({ origin, target }, cb) => {
// 驗證
if (authenticate.get(socket) !== origin) return void 0;
// 轉(zhuǎn)發(fā)`Request`
const member = room.get(target);
if (member) {
if (member.state !== CONNECTION_STATE.READY) {
cb?.({ code: ERROR_TYPE.PEER_BUSY, message: `Peer ${target} is Busy` });
return void 0;
}
updateMember(room, origin, "state", CONNECTION_STATE.CONNECTING);
member.socket.emit(SERVER_EVENT.FORWARD_REQUEST, { origin, target });
} else {
cb?.({ code: ERROR_TYPE.PEER_NOT_FOUND, message: `Peer ${target} Not Found` });
}
});
socket.on(CLINT_EVENT.SEND_RESPONSE, ({ origin, code, reason, target }) => {
// 驗證
if (authenticate.get(socket) !== origin) return void 0;
// 轉(zhuǎn)發(fā)`Response`
const targetSocket = room.get(target)?.socket;
if (targetSocket) {
updateMember(room, origin, "state", CONNECTION_STATE.CONNECTED);
updateMember(room, target, "state", CONNECTION_STATE.CONNECTED);
peer.set(origin, target);
peer.set(target, origin);
targetSocket.emit(SERVER_EVENT.FORWARD_RESPONSE, { origin, code, reason, target });
}
});
socket.on(CLINT_EVENT.SEND_MESSAGE, ({ origin, message, target }) => {
// 驗證
if (authenticate.get(socket) !== origin) return void 0;
// 轉(zhuǎn)發(fā)`Message`
const targetSocket = room.get(target)?.socket;
if (targetSocket) {
targetSocket.emit(SERVER_EVENT.FORWARD_MESSAGE, { origin, message, target });
}
});
socket.on(CLINT_EVENT.SEND_UNPEER, ({ origin, target }) => {
// 驗證
if (authenticate.get(socket) !== origin) return void 0;
// 處理自身的狀態(tài)
peer.delete(origin);
updateMember(room, origin, "state", CONNECTION_STATE.READY);
// 驗證
if (peer.get(target) !== origin) return void 0;
// 轉(zhuǎn)發(fā)`Unpeer`
const targetSocket = room.get(target)?.socket;
if (targetSocket) {
// 處理`Peer`狀態(tài)
updateMember(room, target, "state", CONNECTION_STATE.READY);
peer.delete(target);
targetSocket.emit(SERVER_EVENT.FORWARD_UNPEER, { origin, target });
}
});
通信
在先前我們實現(xiàn)了WebRTC的單文件傳輸,那么在這里我們就來實現(xiàn)一下多文件的傳輸,由于涉及到對于Buffer的一些操作,我們就先來了解一下Unit8Array、ArrayBuffer、Blob的概念以及關(guān)系。
-
Uint8Array:Uint8Array是一種用于表示8位無符號整數(shù)的數(shù)組類型,類似于Array,但是其元素是固定在范圍0到255之間的整數(shù),也就是說每個值都可以存儲一個字節(jié)的8位無符號整數(shù),Uint8Array通常用于處理二進(jìn)制數(shù)據(jù)。 -
ArrayBuffer:ArrayBuffer是一種用于表示通用的、固定長度的二進(jìn)制數(shù)據(jù)緩沖區(qū)的對象,提供了一種在JS中存儲和操作二進(jìn)制數(shù)據(jù)的方式,但是其本身不能直接訪問和操作數(shù)據(jù),ArrayBuffer = Uint8Array.buffer。 -
Blob:Blob是一種用于表示二進(jìn)制數(shù)據(jù)的對象,可以將任意數(shù)據(jù)轉(zhuǎn)換為二進(jìn)制數(shù)據(jù)并存儲在Blob中,Blob可以看作是ArrayBuffer的擴(kuò)展,Blob可以包含任意類型的數(shù)據(jù),例如圖像、音頻或其他文件,通常用于在Web應(yīng)用程序中處理和傳輸文件,Blob = new Blob([ArrayBuffer])。
實際上看起來思路還是比較清晰的,如果我們自擬一個協(xié)議,前12個字節(jié)表示當(dāng)前塊所屬的文件ID,再使用4個字節(jié)也就是32位表示當(dāng)前塊的序列號,其余的內(nèi)容作為文件塊的實際內(nèi)容,那么我們就可以直接同時發(fā)送多個文件了,而不必要像之前一樣等待一個文件傳輸完成之后再傳輸下一個文件。不過看起來還是比較麻煩,畢竟涉及到了很多字節(jié)的操作,所以我們可以偷懶,想一想我們的目標(biāo)實際上就是在傳輸文件塊的時候攜帶一些信息,讓我們能夠知道當(dāng)前塊是屬于哪個ID以及序列。
那么我們很容易想到二進(jìn)制文件實際上是可以用Base64來表示的,由此我們就可以直接傳輸純文本了,當(dāng)然使用Base64傳輸?shù)娜秉c(diǎn)也很明顯,Base64將每3個字節(jié)的數(shù)據(jù)編碼為4個字符,編碼后的數(shù)據(jù)通常會比原始二進(jìn)制數(shù)據(jù)增加約1/3的大小,所以我們實際傳輸?shù)倪^程中還可以加入壓縮程序,比如pako,那么便可以相對抵消一些傳輸字節(jié)數(shù)量的額外傳輸成本,實際上也是因為WebSocket是基于TCP的,而TCP的最大段大小通常為1500字節(jié),這是以太網(wǎng)上廣泛使用的標(biāo)準(zhǔn)MTU,所以傳輸大小沒有那么嚴(yán)格的限制,而如果使用WebRTC的話單次傳輸?shù)姆制潜容^小的,我們一旦轉(zhuǎn)成Base64那么傳輸?shù)拇笮”銜黾樱涂赡軙霈F(xiàn)問題,所以我們自定義協(xié)議的多文件傳輸還是留到WebRTC中實現(xiàn),這里我們就直接使用Base64傳輸。
// packages/websocket/client/utils/format.ts
export const blobToBase64 = async (blob: Blob) => {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const data = new Uint8Array(reader.result as ArrayBuffer);
const compress = pako.deflate(data);
resolve(Base64.fromUint8Array(compress));
};
reader.onerror = reject;
reader.readAsArrayBuffer(blob);
});
};
export const base64ToBlob = (base64: string) => {
const bytes = Base64.toUint8Array(base64);
const decompress = pako.inflate(bytes);
const blob = new Blob([decompress]);
return blob;
};
export const getChunkByIndex = (file: Blob, current: number): Promise<string> => {
const start = current * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
return blobToBase64(file.slice(start, end));
};
接下來就是我們的常規(guī)操作了,首先是分片發(fā)送文件,這里因為我們是純文本的文件發(fā)送,所以并不需要特殊處理Text/Buffer的數(shù)據(jù)差異,只需要直接發(fā)送就好,大致流程與WebRTC是一致的。
// packages/websocket/client/components/modal.tsx
const onSendText = () => {
sendMessage({ type: "text", data: text });
setList([...list, { type: "text", from: "self", data: text }]);
setText("");
};
const sendFilesBySlice = async (files: FileList) => {
const newList = [...list];
for (const file of files) {
const name = file.name;
const id = getUniqueId();
const size = file.size;
const total = Math.ceil(file.size / CHUNK_SIZE);
sendMessage({ type: "file-start", id, name, size, total });
fileSource.current[id] = file;
newList.push({ type: "file", from: "self", name, size, progress: 0, id } as const);
}
setList(newList);
};
const onSendFile = () => {
const KEY = "websocket-file-input";
const exist = document.querySelector(`body > [data-type='${KEY}']`) as HTMLInputElement;
const input: HTMLInputElement = exist || document.createElement("input");
input.value = "";
input.setAttribute("data-type", KEY);
input.setAttribute("type", "file");
input.setAttribute("class", styles.fileInput);
input.setAttribute("accept", "*");
input.setAttribute("multiple", "true");
!exist && document.body.append(input);
input.onchange = e => {
const target = e.target as HTMLInputElement;
document.body.removeChild(input);
const files = target.files;
files && sendFilesBySlice(files);
};
input.click();
};
在接收消息的地方,我們改變了策略,因為當(dāng)前的數(shù)據(jù)是純文本攜帶了很多數(shù)據(jù),所以對于文件分塊而言我們的可控性更高了,所以我們采用一種客戶端請求的多文件分片策略,具體就是說在A向B發(fā)送文件的時候,我們由B來請求我希望拿到的下一個文件分片,A在收到請求的時候?qū)⑦@個文件進(jìn)行切片然后發(fā)送給B,當(dāng)這個文件分片傳輸完成之后再繼續(xù)請求下一個,直到整個文件傳輸完成,而每個分片我們都攜帶了所屬文件的ID以及序列號、總分片數(shù)量等等,這樣就不會因為多文件傳遞的時候造成混亂,而且兩端的文件傳輸進(jìn)度是完全一致的,不會因為緩沖區(qū)的差異造成兩端傳輸進(jìn)度上的差別。
// packages/websocket/client/components/modal.tsx
const onMessage: ServerFn<typeof SERVER_EVENT.FORWARD_MESSAGE> = useMemoizedFn(event => {
if (event.origin !== peerId) return void 0;
const data = event.message;
if (data.type === "text") {
setList([...list, { from: "peer", ...data }]);
} else if (data.type === "file-start") {
const { id, name, size, total } = data;
fileMapper.current[id] = [];
setList([...list, { type: "file", from: "peer", name, size, progress: 0, id }]);
sendMessage({ type: "file-next", id, current: 0, size, total });
} else if (data.type === "file-chunk") {
const { id, current, total, size, chunk } = data;
const progress = Math.floor((current / total) * 100);
updateFileProgress(id, progress);
if (current >= total) {
sendMessage({ type: "file-finish", id });
} else {
const mapper = fileMapper.current;
if (!mapper[id]) mapper[id] = [];
mapper[id][current] = base64ToBlob(chunk);
sendMessage({ type: "file-next", id, current: current + 1, size, total });
}
} else if (data.type === "file-next") {
const { id, current, total, size } = data;
const progress = Math.floor((current / total) * 100);
updateFileProgress(id, progress);
const file = fileSource.current[id];
if (file) {
getChunkByIndex(file, current).then(chunk => {
sendMessage({ type: "file-chunk", id, current, total, size, chunk });
});
}
} else if (data.type === "file-finish") {
const { id } = data;
updateFileProgress(id, 100);
}
});
const onDownloadFile = (id: string, fileName: string) => {
const blob = fileMapper.current[id]
? new Blob(fileMapper.current[id], { type: "application/octet-stream" })
: fileSource.current[id] || new Blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
};
每日一題
https://github.com/WindrunnerMax/EveryDay
參考
http://v6t.ipip.net/
https://icetest.info/
https://www.stunprotocol.org/
https://global.v2ex.co/t/843359
https://webrtc.github.io/samples/
https://bford.info/pub/net/p2pnat/
https://blog.p2hp.com/archives/11075
https://zhuanlan.zhihu.com/p/86759357
https://zhuanlan.zhihu.com/p/621743627
https://github.com/RobinLinus/snapdrop
https://web.dev/articles/webrtc-basics
https://juejin.cn/post/6950234563683713037
https://juejin.cn/post/7171836076246433799
https://chidokun.github.io/p2p-file-transfer
https://bloggeek.me/webrtc-vs-websockets/amp
https://github.com/wangrongding/frontend-park
https://web.dev/articles/webrtc-infrastructure
https://socket.io/zh-CN/docs/v4/server-socket-instance/
https://socket.io/zh-CN/docs/v4/client-socket-instance/
https://developer.mozilla.org/zh-CN/docs/Web/API/RTCPeerConnection/createDataChannel
https://developer.mozilla.org/zh-CN/docs/Web/API/WebRTC_API/Simple_RTCDataChannel_sample
總結(jié)
以上是生活随笔為你收集整理的基于WebRTC的局域网文件传输的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Rust实现线段树和懒标记
- 下一篇: 全球各类卫星遥感图像的下载方法汇总