基于WebRTC的局域网文件传输
基于WebRTC的局域網文件傳輸
WebRTC(Web Real-Time Communications)是一項實時通訊技術,允許網絡應用或者站點,在不借助中間媒介的情況下,建立瀏覽器之間點對點P2P(Peer-To-Peer)的連接,實現視頻流、音頻流、文件等等任意數據的傳輸,WebRTC包含的這些標準使用戶在無需安裝任何插件或者第三方的軟件的情況下,可以創建點對點Peer-To-Peer的數據分享和電話會議等。
描述
通常來說,在發起文件傳輸或者音視頻通話等場景的時候,我們需要借助第三方的服務器來中轉數據,例如我們通過IM即時通訊軟件向對方發送消息的時候,我們的消息會先發送到服務器,然后服務器再將消息發送到對方的客戶端,這種方式的好處是可以保證消息的可靠性,但是存在的問題也比較明顯,通過服務器進行轉發的速度會受限于服務器本身的帶寬,同時也會增加服務器的負載,特別是在傳輸文件或者進行音視頻聊天的情況下,會給予服務器比較大的壓力,對于服務提供商來說提供服務器的帶寬同樣也是很大的開銷,對于用戶來說文件與消息經由服務器轉發也存在安全與隱私方面的問題。
WebRTC的出現解決了這些問題,其允許瀏覽器之間建立點對點的連接,實現數據的傳輸,以及實時通信的復雜性、插件依賴和兼容性問題,提高了安全性和隱私保護。因此WebRTC廣泛應用于實時通信領域,包括視頻會議、音視頻聊天、遠程協作、在線教育和直播等場景。而具體到WebRTC與P2P的數據傳輸上,主要是解決了如下問題:
-
WebRTC提供了一套標準化的API和協議,使開發者能夠更輕松地構建實時通信應用,無需深入了解底層技術細節。 -
WebRTC支持加密傳輸,使用DTLS-SRTP對傳輸數據進行加密,確保通信內容的安全性,對于敏感信息的傳輸非常重要。 -
WebRTC使用P2P傳輸方式,使得數據可以直接在通信雙方之間傳輸,減輕了服務器的負擔,且通信質量不受服務器帶寬限制。 -
P2P傳輸方式可以直接在通信雙方之間傳輸數據,減少了數據傳輸的路徑和中間環節,從而降低了傳輸延遲,實現更實時的通信體驗。 -
P2P傳輸方式不需要經過中心服務器的中轉,減少了第三方對通信內容的訪問和監控,提高了通信的隱私保護。
在前一段時間,我想在手機上向電腦發送文件,因為要發送的文件比較多,所以我想直接通過USB連到電腦上傳輸,等我將手機連到電腦上之后,我發現手機竟然無法被電腦識別,能夠充電但是并不能傳文件,因為我的電腦是Mac而手機是Android,所以無法識別設備這件事就變得合理了起來。那么接著我想用WeChat去傳文件,但是一想到傳文件之后我還需要手動將文件刪掉否則會占用我兩份手機存儲并且傳輸還很慢,我就又開始在網上尋找軟件,這時候我突然想起來了AirDrop也就是隔空投送,就想著有沒有類似的軟件可以用,然后我就找到了Snapdrop這個項目,我覺得這個項目很神奇,不需要登錄就可以在局域網內發現設備并且傳輸文件,于是在好奇心的驅使下我也學習了一下,并且基于WebRTC/WebSocket實現了類似的文件傳輸方案https://github.com/WindrunnerMax/FileTransfer。通過這種方式,任何擁有瀏覽器的設備都有傳輸數據的可能,不需要借助數據線傳輸文件,也不會受限于Apple全家桶才能使用的隔空投送,以及天然的跨平臺優勢可以應用于常見的IOS/Android/Mac設備向PC臺式設備傳輸文件的場景等等。此外即使因為各種原因路由器開啟了AP隔離功能,我們的服務依舊可以正常交換數據,這樣避免了在路由器不受我們控制的情況下通過WIFI傳輸文件的掣肘。那么回歸到項目本身,具體來說在完成功能的過程中解決了如下問題:
- 局域網內可以互相發現,不需要手動輸入對方
IP地址等信息。 - 多個設備中的任意兩個設備之間可以相互傳輸文本消息與文件數據。
- 設備間的數據傳輸采用基于
WebRTC的P2P方案,無需服務器中轉數據。 - 跨局域網傳輸且
NAT穿越受限的情況下,基于WebSocket服務器中轉傳輸。
此外,如果需要調試WebRTC的鏈接,可以在Chrome中打開about://webrtc-internals/,FireFox中打開about:webrtc即可進行調試,在這里可以觀測到WebRTC的ICE交換、數據傳輸、事件觸發等等。
WebRTC
WebRTC是一套復雜的協議,同樣也是API,并且提供了音視頻傳輸的一整套解決方案,可以總結為跨平臺、低時延、端對端的音視頻實時通信技術。WebRTC提供的API大致可以分為三類,分別是Media Stream API設備音視頻流、RTCPeerConnection API本地計算機到遠端的WebRTC連接、Peer-To-Peer RTCDataChannel API瀏覽器之間P2P數據傳輸信道。在WebRTC的核心層中,同樣包含三大核心模塊,分別是Voice Engine音頻引擎、Video Engine視頻引擎、Transport傳輸模塊。音頻引擎Voice Engine中包含iSAC/iLBC Codec音頻編解碼器、NetEQ For Voice網絡抖動和丟包處理、 Echo Canceler/Noise Reduction回音消除與噪音抑制等。Video Engine視頻引擎中包括VP8 Codec視頻編解碼器、Video Jitter Buffer視頻抖動緩沖器、Image Enhancements圖像增強等。Transport傳輸模塊中包括SRTP安全實時傳輸協議、Multiplexing多路復用、?STUN+TURN+ICE網絡傳輸NAT穿越,DTLS數據報安全傳輸等。
由于在這里我們的主要目的是數據傳輸,所以我們只需要關心API層面上的RTCPeerConnection API和Peer-To-Peer RTCDataChannel API,以及核心層中的Transport傳輸模塊即可。實際上由于網絡以及場景的復雜性,基于WebRTC衍生出了大量的方案設計,而在網絡框架模型方面,便有著三種架構: Mesh架構即真正的P2P傳輸,每個客戶端與其他客戶端都建立了連接,形成了網狀的結構,這種架構可以同時連接的客戶端有限,但是優點是不需要中心服務器,實現簡單;MCU(MultiPoint Control Unit)網絡架構即傳統的中心化架構,每個瀏覽器僅與中心的MCU服務器連接,MCU服務器負責所有的視頻編碼、轉碼、解碼、混合等復雜邏輯,這種架構會對服務器造成較大的壓力,但是優點是可以支持更多的人同時音視頻通訊,比較適合多人視頻會議。SFU(Selective Forwarding Unit)網絡架構類似于MCU的中心化架構,仍然有中心節點服務器,但是中心節點只負責轉發,不做太重的處理,所以服務器的壓力會低很多,這種架構需要比較大的帶寬消耗,但是優點是服務器壓力較小,典型場景是1對N的視頻互動。對于我們而言,我們的目標是局域網之間的數據傳輸,所以并不會涉及此類復雜的網絡傳輸架構模型,我們實現的是非常典型的P2P架構,甚至不需要N-N的數據傳輸,但是同樣也會涉及到一些復雜的問題,例如NAT穿越、ICE交換、STUN服務器、TURN服務器等等。
信令
信令是涉及到通信系統時,用于建立、控制和終止通信會話的信息,包含了與通信相關的各種指令、協議和消息,用于使通信參與者之間能夠相互識別、協商和交換數據。主要目的是確保通信參與者能夠建立連接、協商通信參數,并在需要時進行狀態的改變或終止,這其中涉及到各種通信過程中的控制信息交換,而不是直接傳輸實際的用戶數據。
或許會產生一個疑問,既然WebRTC可以做到P2P的數據傳輸,那么為什么還需要信令服務器來調度連接。實際上這很簡單,因為我們的網絡環境是非常復雜的,我們并不能明確地得到對方的IP等信息來直接建立連接,所以我們需要借助信令服務器來協調連接。需要注意的是信令服務器的目標是協調而不是直接傳輸數據,數據本身的傳輸是P2P的,那么也就是說我們建立信令服務器并不需要大量的資源。
那如果說我們是不是必須要有信令服務器,那確實不是必要的,在WebRTC中雖然沒有建立信令的標準或者說客戶端來回傳遞消息來建立連接的方法,因為網絡環境的復雜特別是IPv4的時代在客戶端直接建立連接是不太現實的,也就是我們做不到直接在互聯網上廣播我要連接到我的朋友,但是我們通過信令需要傳遞的數據是很明確的,而這些信息都是文本信息,所以如果不建立信令服務器的話,我們可以通過一些即使通訊軟件IM來將需要傳遞的信息明確的發給對方,那么這樣就不需要信令服務器了。那么人工轉發消息的方式看起來非常麻煩可能并不是很好的選擇,由此實際上我們可以理解為信令服務器就是把協商這部分內容自動化了,并且附帶的能夠提高連接的效率以及附加協商鑒權能力等等。
SIGNLING
/ \
SDP/ICE / \ SDP/ICE
/ \
Client <-> Client
基本的數據傳輸過程如上圖所示,我們可以通過信令服務器將客戶端的SDP/ICE等信息傳遞,然后就可以在兩個Client之間建立起連接,之后的數據傳輸就完全在兩個客戶端也就是瀏覽器之間進行了,而信令服務器的作用就是協調這個過程,使得兩個客戶端能夠建立起連接,實際上整個過程非常類似于TCP的握手,只不過這里并沒有那么嚴格而且只握手兩次就可以認為是建立連接了。此外WebRTC是基于UDP的,所以WebRTC DataChannel也可以相當于在UDP的不可靠傳輸的基礎上實現了基本可靠的傳輸,類似于QUIC希望能取得可靠與速度之間的平衡。
那么我們現在已經了解了信令服務器的作用,接下來我們就來實現信令服務器用來調度協商WebRTC。前邊我們也提到了,因為WebRTC并沒有規定信令服務器的標準或者協議,并且傳輸的都是文本內容,那么我們是可以使用任何方式來搭建這個信令服務器的,例如我們可以使用HTTP協議的短輪詢+超時、長輪詢,甚至是EventSource、SIP等等都可以作為信令服務器的傳輸協議。在這里我們的目標不是僅僅建立起鏈接,而是希望能夠實現類似于房間的概念,由此來管理我們的設備鏈接,所以首選的方案是WebSocket,WebSocket可以把這個功能做的更自然一些,全雙工的客戶端與服務器通信,消息可以同時在兩個方向上流動,而socket.io是基于WebSocket封裝了服務端和客戶端,使用起來非常簡單方便,所以接下來我們使用socket.io來實現信令服務器。
首先我們需要實現房間的功能,在最開始的時候我們就明確我們需要在局域網自動發現設備,那么也就是相當于局域網的設備是屬于同一個房間的,那么我們就需要存儲一些信息,在這里我們使用Map分別存儲了id、房間、連接信息。那么在一個基本的房間中,我們除了將設備加入到房間外還需要實現幾個功能,對于新加入的設備A,我們需要將當前房間內已經存在的設備信息告知當前新加入的設備A,對于房間內的其他設備,則需要通知當前新加入的設備A的信息,同樣的在設備A退出房間的時候,我們也需要通知房間內的其他設備當前離開的設備A的信息,并且更新房間數據。
// 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);
// 房間內通知
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來實現的,因為此時需要注意一個問題,如果我們的信令服務器是部署在公網的服務器上,那么我們的房間就是全局的,也就是說所有的設備都可以連接到同一個房間,這樣的話顯然是不合適的,解決這個問題的方法很簡單,對于服務器而言我們獲取用戶的IP地址,如果用戶的IP地址是相同的就認為是同一個局域網的設備,所以我們需要獲取當前連接的Socket的IP信息,在這里我們特殊處理了127.0.0.1和192.168.0.0兩個網域的設備,以便我們在本地/路由器部署時能夠正常發現設備。
// 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 || "";
}
// 本地部署應用時,`ip`地址可能是`::1`或`::ffff:`
if (ip === "::1" || ip === "::ffff:127.0.0.1" || !ip) {
ip = "127.0.0.1";
}
// 局域網部署應用時,`ip`地址可能是`192.168.x.x`
if (ip.startsWith("::ffff:192.168") || ip.startsWith("192.168")) {
ip = "192.168.0.0";
}
return ip;
};
至此信令服務器的房間功能就完成了,看起來實現信令服務器并不是一件難事,將這段代碼以及靜態資源部署在服務器上也僅占用20MB左右的內存,幾乎不占用太多資源。而信令服務器的功能并不僅僅是房間的管理,我們還需要實現SDP和ICE的交換,只不過先前也提到了信令服務器的目標是協調連接,那么在這里我們還需要實現SDP和ICE的轉發用以協調鏈接,在這里我們先將這部分內容前置,接下來再開始聊RTCPeerConnection的協商過程。
// 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;
}
// 轉發`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 }) => {
// 轉發`ICE` -> `Target`
// ...
});
socket.on(CLINT_EVENT.SEND_ANSWER, ({ origin, answer, target }) => {
// 轉發`Answer` -> `Target`
// ...
});
socket.on(CLINT_EVENT.SEND_ERROR, ({ origin, code, message, target }) => {
// 轉發`Error` -> `Target`
// ...
});
連接
在建設好信令服務器之后,我們就可以開始聊一聊RTCPeerConnection的具體協商過程了,在這部分會涉及比較多的概念,例如Offer、Answer、SDP、ICE、STUN、TURN等等,不過我們先不急著了解這些概念我們先開看一下RTCPeerConnection的完整協商過程,整個過程是非常類似于TCP的握手,當然沒有那么嚴格,但是也是需要經過幾個步驟才能夠建立起連接的:
A SIGNLING B
------------------------------- ---------- --------------------------------
| Offer -> LocalDescription | -> | -> | -> | Offer -> RemoteDescription |
| | | | | |
| RemoteDescription <- Answer | <- | <- | <- | LocalDescription <- Answer |
| | | | | |
| RTCIceCandidateEvent | -> | -> | -> | AddRTCIceCandidate |
| | | | | |
| AddRTCIceCandidate | <- | <- | <- | RTCIceCandidateEvent |
------------------------------- ---------- --------------------------------
- 假設我們有
A、B客戶端,兩個客戶端都已經實例化RTCPeerConnection對象等待連接,當然按需實例化RTCPeerConnection對象也是可以的。 -
A客戶端準備發起鏈接請求,此時A客戶端需要創建Offer也就是RTCSessionDescription(SDP),并且將創建的Offer設置為本地的LocalDescription,緊接著借助信令服務器將Offer轉發到目標客戶端也就是B客戶端。 -
B客戶端收到A客戶端的Offer之后,此時B客戶端需要將收到Offer設置為遠端的RemoteDescription,然后創建Answer即同樣也是RTCSessionDescription(SDP),并且將創建的Answer設置為本地的LocalDescription,緊接著借助信令服務器將Answer轉發到目標客戶端也就是A客戶端。 -
A客戶端收到B客戶端的Answer之后,此時A客戶端需要將收到Answer設置為遠端的RemoteDescription,客戶端A、B之間的握手過程就結束了。 - 在
A客戶端與B客戶端握手的整個過程中,還需要穿插著ICE的交換,我們需要在ICECandidate候選人發生變化的時候,將ICE完整地轉發到目標的客戶端,之后目標客戶端將其設置為目標候選人。
經過我們上邊簡單RTCPeerConnection協商過程描述,此時如果網絡連通情況比較好的話,就可以順利建立連接,并且通過信道發送消息了,但是實際上在這里涉及的細節還是比較多的,我們可以一步步來拆解這個過程并且描述涉及到的相關概念,并且在最后我們會聊一下當前IPv6設備的P2P、局域網以及AP隔離時的信道傳輸。
首先我們來看RTCPeerConnection對象,因為WebRTC有比較多的歷史遺留問題,所以我們為了兼容性可能會需要做一些冗余的設置,當然隨著WebRTC越來約規范化這些兼容問題會逐漸減少,但是我們還是可以考慮一下這個問題的,比如在建立RTCPeerConnection時做一點小小的兼容。
// 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,其他的參數我們保持默認即可我們不需要太多關注,以及例如sdpSemantics: unified-plan等配置項也越來越統一化病作為默認值,在比較新的TS版本中甚至都不再提供這個配置項的定義了。那么我們目光回到iceServers這個配置項,iceServers主要是用來提供我們協商鏈接以及中轉的用途,我們可以簡單理解一下,試想我們的很多設備都是內網的設備,而信令服務器僅僅是做了數據的轉發,所以我們如果要是跨局域網想在公網上或者在路由器AP隔離的情況下傳輸數據的話,最起碼需要知道我們的設備出口IP地址,STNU服務器就是用來獲取我們的出口IP地址的,TURN服務器則是用來中轉數據的,而因為STNU服務器并不需要太大的資源占用,所以有有比較多的公網服務器提供免費的STNU服務,但是TURN實際上相當于中轉服務器,所以通常是需要購置云服務器自己搭建,并且設置Token過期時間等等防止盜用。上邊我們只是簡單理解一下,所以我們接下來需要聊一下NAT、STNU、TURN三個概念。
NAT(Network Address Translation)網絡地址轉換是一種在IP網絡中廣泛使用的技術,主要是將一個IP地址轉換為另一個IP地址,具體來說其工作原理是將一個私有IP地址(如在家庭網絡或企業內部網絡中使用的地址)映射到一個公共IP地址(如互聯網上的IP地址)。當一個設備從私有網絡向公共網絡發送數據包時,NAT設備會將源IP地址從私有地址轉換為公共地址,并且在返回數據包時將目標IP地址從公共地址轉換為私有地址。NAT可以通過多種方式實現,例如靜態NAT、動態NAT和端口地址轉換PAT等,靜態NAT將一個私有IP地址映射到一個公共IP地址,而動態NAT則動態地為每個私有地址分配一個公共地址,PAT是一種特殊的動態NAT,在將私有IP地址轉換為公共IP地址時,還會將源端口號或目標端口號轉換為不同的端口號,以支持多個設備使用同一個公共IP地址。NAT最初是為了解決IPv4地址空間的短缺而設計的,后來也為提高網絡安全性并簡化網絡管理提供了基礎。在互聯網上大多數設備都是通過路由器或防火墻連接到網絡的,這些設備通常使用網絡地址轉換NAT將內部IP地址映射到一個公共的IP地址上,這個公共IP地址可以被其他設備用來訪問,但是這些設備內部的IP地址是隱藏的,其他的設備不能直接通過它們的內部IP地址建立P2P連接。因此,直接進行P2P連接可能會受到網絡地址轉換NAT的限制,導致連接無法建立。
STUN(Session Traversal Utilities for NAT)會話穿透應用程序用于在NAT或防火墻后面的客戶端之間建立P2P連接,STUN服務器并不會中轉數據,而是主要用于獲取客戶端的公網IP地址,在客戶端請求服務器時服務器會返回客戶端的公網IP地址和端口號,這樣客戶端就可以通過這個公網IP地址和端口號來建立P2P連接,主要目標是探測和發現通訊對方客戶端是否躲在防火墻或者NAT路由器后面,并且確定內網客戶端所暴露在外的廣域網的IP和端口以及NAT類型等信息,STUN服務器利用這些信息協助不同內網的計算機之間建立點對點的UDP通訊。實際上STUN是一個Client/Server模式的協議,客戶端發送一個STUN請求到STUN服務器,請求包含了客戶端本身所見到的自己的IP地址和端口號,STUN服務器收到請求后,會從請求中獲取到設備所在的公網IP地址和端口號,并將這些信息返回給設備,設備收到STUN服務器的回復后,就可以將這些信息告訴其他設備,從而實現對等通信,本質上將地址交給客戶端設備,客戶端利用這些信息來嘗試建立通信。NAT主要分為四種,分別是完全圓錐型NAT、受限圓錐型NAT、端口受限圓錐型NAT、對稱型NAT,STUN對于前三種NAT是比較有效的,而大型公司網絡中經常采用的對稱型NAT則不能使用STUN獲取公網IP及需要的端口號,具體的NAT穿越過程我們后邊再聊,在我的理解上STUN比較適用于單層NAT,多級NAT的情況下復雜性會增加,如果都是圓錐型NAT可能也還好,而實際上因為國內網絡環境的復雜性,甚至運營商對于UDP報文存在較多限制,實際使用STUN進行NAT穿越的成功率還是比較低的。
TURN(Traversal Using Relay NAT)即通過Relay方式穿越NAT,由于網絡的復雜性,當兩個設備都位于對稱型NAT后面或存在防火墻限制時時,直接的P2P連接通常難以建立,而當設備無法直接連接時,設備可以通過與TURN服務器建立連接來進行通信,設備將數據發送到TURN服務器,然后TURN服務器將數據中繼給目標設備。實際上是一種中轉方案,并且因為是即將傳輸的設備地址,避免了STUN應用模型下出口NAT對RTP/RTCP地址端口號的任意分配,但無論如何就是相當于TURN服務器成為了中間人,使得設備能夠在無法直接通信的情況下進行數據傳輸,那么使用TURN服務器就會引入一定的延遲和帶寬消耗,因為數據需要經過額外的中間步驟,所以TURN服務器在WebRTC中通常被視為備用方案,當直接點對點連接無法建立時才使用,并且通常沒有公共的服務器資源可用,而且因為實際上是在前端配置的iceServers,所以通常是通過加密的方式生成限時連接用于傳輸,類似于常用的圖片防盜鏈機制。實際上在WebRTC中使用中繼服務器的場景是很常見的,例如多人視頻通話的場景下通常會選擇MCU或者SFU的中心化網絡架構用來傳輸音視頻流。
那么在我們了解了這些概念以及用法之后,我們就簡單再聊一聊STUN是如何做到NAT穿透的,此時我們假設我們的網絡結構只有一層NAT,并且對等傳輸的兩側都是同樣的NAT結構,當然不同的NAT也是可以穿越的,在這里我們只是簡化了整個模型,那么此時我們的網絡IP與相關端口號如下所示:
內網 路由器 公網
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,一旦一個內部地址IA:IP映射到外部地址EA:EP,所有發自IA:IP的包會都經由3EA:EP向外發送,并且任意外部主機都能通過給EA:EP發包到達IA:IP。那么此時我們假設我們需要建立連接,此時我們需要基于A和B向STUN服務器發起請求,即1.1.1.1:1111 -> 7.7.7.7:7777那么此時STUN服務器就會返回A的公網IP地址和端口號,即3.3.3.3:3333,同樣的B也是6.6.6.6:6666 -> 7.7.7.7:7777得到8.8.8.8:8888,那么此時需要注意,我們已經成功在路由器的路由表中建立了映射,那么我此時任意外部主機都能通過給EA:EP發包到達IA:IP,所以此時只需要通過信令服務器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,其的限制是內部主機只能向之前已經發送過數據包的外部主機發送數據包,也就是說數據包的源地址需要與NAT表相符,而端口受限圓錐型NAT是一種特殊的受限圓錐型NAT,其限制是內部主機只能向之前已經發送或者接收過數據包的外部主機的相同端口發送數據包,也就是說數據包的源IP和PORT都要與NAT表相符。舉個例子的話就是只有路由表中已經存在的IP/IP:PORT才能被路由器轉發數據,實際上很好理解,當我們正常發起一個請求的時候都是向某個固定的IP:PORT發送數據,而接受數據的時候,這個IP:PORT已經在路由表中了所以是可以正常接受數據的,而這兩種NAT雖然限制了IP/IP:PORT必需要在路由表中,但是并沒有限制IP:PORT只能與之前的IP:PORT通信,所以我們只需要在之前的圓錐型NAT基礎上,主動預發送數據包即可,相當于把IP/IP:PORT寫入了路由表,那么路由器在收到來自這個IP/IP:PORT的數據包時就可以正常轉發了。
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是限制最多的,每一個來自相同內部IP與PORT,到一個特定目的地IP和PORT的請求,都映射到一個獨特的外部IP地址和PORT,同一內部IP與端口發到不同的目的地和端口的信息包,都使用不同的映射,類似于在端口受限圓錐型NAT的基礎上,限制了IP:PORT只能與之前的IP:PORT通信,對于STUN來說具體的限制實際上是我們發起的IP:PORT探測請求與最終實際連接的IP:PORT是同一個地址與端口的映射,然而在對稱型NAT中,我們發起的IP:PORT探測請求與最終實際連接的IP:PORT會被記錄為不同的地址與端口映射,或者換句話說,我們通過STUN拿到的IP:PORT只能跟STUN通信,無法用來共享給別的設備傳輸數據。
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有關NAT穿透相關的概念之后,我們繼續完成WebRTC的鏈接過程,實際上因為我們已經深入分析了NAT的穿透,那么就相當于我們已經可以在互聯上建立起鏈接了,但是因為WebRTC并不僅僅是建立了一個傳輸信道,這其中還伴隨著音視頻媒體的描述,用于媒體信息的傳輸協議、傳輸類型、編解碼協商等等也就是SDP協議,SDP是<type>=<value>格式的純文本協議,一個典型的SDP如下所示,而我們將要使用的Offer/Answer/RTCSessionDescription就是帶著類型的SDP即{ type: "offer"/"answer"/"pranswer"/"rollback", sdp: "..." },對于我們來說可能并不需要過多關注,因為我們現在的目標是建立連接以及傳輸信道,所以我們更多的還是關注于鏈接建立的流程。
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
那么此時我們需要創建鏈接,看起來發起流程非常簡單,我們假設現在有兩個客戶端A、B,此時客戶端A通過createOffer創建了Offer,并且通過setLocalDescription將其設置為本地描述,緊接著將Offer通過信令服務器發送到了目標客戶端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);
};
當目標客戶端B收到Offer之后,可以通過判斷當前是否正在建立連接等狀態來決定是否接受這個Offer,接受的話就將收到的Offer通過setRemoteDescription設置為遠程描述,并且通過createAnswer創建answer,同樣將answer設置為本地描述后,緊接著將answer通過信令服務器發送到了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);
};
當發起方的客戶端A收到了目標客戶端B的應答之后,如果當前沒有設置遠程描述的話,就通過setRemoteDescription設置為遠程描述,此時我們的SDP協商過程就完成了。
// 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);
}
};
實際上我們可以關注到在創建Offer和Answer的時候還存在onicecandidate事件的回調,這里實際上就是ICE候選人變化的過程,我們可以通過event.candidate獲取到當前的候選人,然后我們需要盡快通過信令服務器將其轉發到目標客戶端,目標客戶端收到之后通過addIceCandidate添加候選人,這樣就完成了ICE候選人的交換。在這里我們需要注意的是我們需要盡快轉發ICE,那么對于我們而言就并不需要關注時機,但實際上時機已經在規范中明確了,在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);
};
那么到這里我們的鏈接協商過程就結束了,而我們實際建立P2P信道的過程就非常依賴ICE(Interactive Connectivity Establishment)的交換,ICE候選者描述了WebRTC能夠與遠程設備通信所需的協議和路由,當啟動WebRTC P2P連接時,通常連接的每一端都會提出許多候選連接,直到他們就描述他們認為最好的連接達成一致,然后WebRTC就會使用該候選人的詳細信息來啟動連接。ICE和STUN密切相關,前邊我們已經了解了NAT穿越的過程,那么接下來我們就來看一下ICE候選人交換的數據結構,ICE候選人實際上是一個RTCIceCandidate對象,而這個對象包含了很多信息,但是實際上這個對象中存在了toJSON方法,所以實際交換的數據只有candidate、sdpMid、sdpMLineIndex、usernameFragment,而這些交換的數據又會在candidate字段中體現,所以我們在這里就重點關注這四個字段代表的意義。
-
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,候選字符串指定候選的網絡連接信息,這些屬性均由單個空格字符分隔,并且按特定順序排列,如果候選者是空字符串,則表示已到達候選者列表的末尾,該候選者被稱為候選者結束標記。-
foundation: 候選者的標識符,用于唯一標識一個ICE候選者。,示例4234997325。 -
component: 候選者所屬的是RTP:1還是RTCP:2協議,示例1。 -
protocol: 候選者使用的傳輸協議udp/tcp,示例udp。 -
priority: 候選者的優先級,值越高越優先,示例1677729535。 -
ip: 候選者的IP地址,示例101.68.35.129。 -
port: 候選者的端口號,示例24692。 -
type: 候選者的類型,示例srflx。-
host:IP地址實際上是設備主機公網地址,或者本地設備地址。 -
srflx: 通過STUN或者TURN收集的NAT網關在公網側的IP地址。 -
prflx:NAT在發送STUN請求以匿名代表候選人對等點時分配的綁定,可以在ICE的后續階段中獲取到。 -
relay: 中繼候選者,通過TURN收集的TURN服務器的公網轉發地址。
-
-
raddr: 候選者的遠程地址,表示在此候選者之間建立連接時的對方地址,示例0.0.0.0。 -
rport: 候選者的遠程端口,表示在此候選者之間建立連接時的對方端口,示例0。 -
generation: 候選者的ICE生成代數,用于區分不同生成時的候選者,示例0。 -
ufrag: 候選者的ICE標識符,用于在ICE過程中進行身份驗證和匹配,示例WbBI。 -
network-cost: 候選者的網絡成本,較低的成本值表示較優的網絡路徑,示例999。
-
-
sdpMid: 用于標識媒體描述的SDP媒體的唯一標識符,示例sdpMid: "0",如果媒體描述不可用,則為空字符串。 -
sdpMLineIndex: 媒體描述的索引,示例sdpMLineIndex: 0,如果媒體描述不可用,則為null。 -
usernameFragment: 用于標識ICE會話的唯一標識符,示例usernameFragment: "WbBI"。
在鏈接建立完成之后,我們就可以通過控制臺觀察WebRTC是否成功建立了,在內網的情況下ICE的候選人信息大致如下所示,我們可以通過觀察IP來確定連接的實際地址,并且在IPv4和IPv6的情況下是有所區別的。
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網域直接訪問對方,這種情況下STUN服務器就起到作用了,相當于做了一次NAT穿透,此時我們可以觀察到IP地址是公網地址并且相同,但是端口號是不同的,我們也可以理解為我們的數據包通過公網跑了一圈又回到了局域網,很像是完成了一次網絡回環。
ICE Candidate pair: 101.68.35.129:25595 <=> 101.68.35.129:25596
在前幾天搬磚的時候,我突然想到一個問題,現在都是IPv6的時代了,而STUN服務器實際上又是支持IPv6的,那么如果我們的設備都有全球唯一的公網IPv6地址豈不是做到P2P互聯,從而真的成為互聯網,所以我找了朋友測試了一下IPv6的鏈接情況,因為手機設備通常都是真實分配IPv6的地址,所以就可以直接在手機上先進行一波測試,首先訪問下test-ipv6來獲取手機的公網IPv6地址,并且對比下手機詳細信息里邊的地址,而IPv6目前只要是看到以2/3開頭的都可以認為是公網地址,以fe80開頭的則是本地連接地址。在這里我們可以借助Netcat也就是常用的nc命令來測試,在手機設備上可以使用Termux并且使用包管理器安裝netcat-openbsd。
# 設備`A`監聽
$ nc -vk6 9999
# 設備`B`連接
$ nc -v6 ${ip} 9999
這里的測試就很有意思了,然后我屋里的路由器設備已經開啟了IPv6,而且關閉了標準中未定義而是社區提供的NAT6方案,并且使用獲取IPv6前綴的Native方案,然而無論我如何嘗試都不能通過我的電腦連接到我的手機,實際上即使我的電腦沒有公網地址而只要手機有公網地址,那么從電腦發起連接請求并且連接到手機,但是可惜還是無法建立鏈接,但是使用ping6是可以ping通的,所以實際上是能尋址到只是被攔截了連接的請求,后來我嘗試在設備啟動HTTP服務也無法直接建立鏈接,或許是有著一些限制策略例如UDP的報文必須先由本設備某端口發出后這個端口才能接收公網地址報文。再后來我找我朋友的手機進行連接測試,我是聯通卡朋友是電信卡,我能夠連接到我的朋友,但是我朋友無法直接連接到我,而我們的IPv6都是2開頭的是公網地址,然后我們懷疑是運營商限制了端口所以嘗試不斷切換端口來建立鏈接,還是不能直接連接。
于是我最后測試了一下,我換到了我的卡2電信卡,此時無論是我的朋友還是我的電腦都可以直接通過電信分配的IPv6地址連接到我的手機了。這就很難繃,而我另一個朋友的聯通又能夠直接連接,所以在國內的網絡環境下還是需要看地域性的。之后我找了好幾個朋友測試了P2P的鏈接,因為只要設備雙方只要有一方有公網的IP那么大概率就是能夠直接P2P的,所以通過WebRTC連接的成功率還是可以的,并沒有想象中那么低,但我們主要的場景還是局域網傳輸,只是我們會在項目中留一個輸入對方ID用以跨網段鏈接的方式。
通信
在我們成功建立鏈接之后,我們就可以開啟傳輸信道Peer-To-Peer RTCDataChannel API相關部分了,通過createDataChannel方法可以創建一個與遠程對等點鏈接的新通道,可以通過該通道傳輸任何類型的數據,例如圖像、文件傳輸、文本聊天、游戲更新數據包等。我們可以在RTCPeerConnection對象實例化的時候就創建信道,之后等待鏈接成功建立即可,同樣的createDataChannel也存在很多參數可以配置。
-
label: 可讀的信道名稱,不超過65,535 bytes。 -
ordered: 保證傳輸順序,默認為true。 -
maxPacketLifeTime: 信道嘗試傳輸消息可能需要的最大毫秒數,如果設置為null則表示沒有限制,默認為null。 -
maxRetransmits: 信道嘗試傳輸消息可能需要的最大重傳次數,如果設置為null則表示沒有限制,默認為null。 -
protocol: 信道使用的子協議,如果設置為null則表示沒有限制,默認為null。 -
negotiated: 是否為協商信道,如果設置為true則表示信道是協商的,如果設置為false則表示信道是非協商的,默認為false。 -
id: 信道的唯一標識符,如果設置為null則表示沒有限制,默認為null。
前邊我們也提到了WebRTC希望借助UDP實現相對可靠的數據傳輸,類似于QUIC希望能取得可靠與速度之間的平衡,所以在這里我們的order指定了true,并且設置了最大傳輸次數,在這里需要注意的是,我們最終的消息事件綁定是在ondatachannel事件之后的,當信道真正建立之后,這個事件將會被觸發,并且在此時將可以進行信息傳輸,此外當negotiated指定為true時則必須設置id,此時就是通過id協商信道相當于雙向通信,那么就不需要指定ondatachannel事件了,直接在channel上綁定事件回調即可。
// packages/webrtc/client/core/instance.ts
const channel = connection.createDataChannel("FileTransfer", {
ordered: true, // 保證傳輸順序
maxRetransmits: 50, // 最大重傳次數
});
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;
};
那么在信道創建完成之后,我們現在暫時只需要關注最基本的兩個方法,一個是channel.send方法可以用來發送數據,例如純文本數據、Blob、ArrayBuffer都是可以直接發送的,同樣的channel.onmessage事件也是可以接受相同的數據類型,那么我們接下來就借助這兩個方法來完成文本與文件的傳輸。那么我們就來最簡單地實現傳輸,首先我們要規定好基本的傳輸數據類型,因為我們是實際上只區分兩種類型的數據,也就是Text/Blob數據,所以需要對這兩種數據做基本的判斷,然后再根據不同的類型響應不同的行為,當然我們也可以自擬數據結構/協議,例如借助Uint8Array構造Blob前N個字節表示數據類型、id、序列號等等,后邊攜帶數據內容,這樣也可以組裝直接傳輸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 };
那么我們封裝發送文本和文件的方法,我們可以看到我們在發送文件的時候,我們會先發送一個文件信息的消息,然后再發送文件內容,這樣就可以在接收端進行文件的組裝。在這里需要注意的有兩點,對于大文件來說我們是需要將其分割發送的,在協商SCTP時會有maxMessageSize的值,表示每次調用send方法最大能發送的字節數,通常為256KB大小,在MDN的WebRTC_API/Using_data_channels對這個問題有過描述,另一個需要注意的地方時是緩沖區,由于發送大文件時緩沖區會很容易大量占用緩沖區,并且也不利于我們對發送進度的統計,所以我們還需要借助onbufferedamountlow事件來控制緩沖區的發送狀態。
// 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();
};
那么最后我們只需要在接收的時候將內容組裝到數組當中,并且在調用下載的時候將其組裝為Blob下載即可,當然因為目前我們是單文件發送的,也就是說發送文件塊的時候并沒有攜帶當前塊的任何描述信息,所以我們在接收塊的時候是不能再發送其他內容的。
// 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);
};
在后來補充了一下多文件傳輸的方案,具體的思路是構造ArrayBuffer,其中前12個字節表示當前塊所屬的文件ID,再使用4個字節也就是32位表示當前塊的序列號,其余的內容作為文件塊的實際內容,然后就可以實現文件傳輸的過程中不同文件發送塊,然后就可以在接收端通過存儲的ID和序列號進行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
當WebRTC無法成功進行NAT穿越時,如果想在公網發送數據還是需要經過TURN轉發,那么都是通過TURN轉發了還是需要走我們服務器的中繼,那么我們不如直接借助WebSocket傳輸了,WebSocket也是全雙工信道,在非AP隔離的情況下我們同樣也可以直接部署在路由器上,在局域網之間進行數據傳輸。
連接
使用WebSocket進行傳輸的時候,我們是直接借助服務器轉發所有數據的,不知道大家是不是注意到WebRTC的鏈接過程實際上是比較麻煩的,而且相對難以管理,這其中部分原因就是建立一個鏈接涉及到了多方的通信鏈接,需要客戶端A、信令服務器、STUN服務器、客戶端B之間的相互連接,那么如果我們使用WebSocket就沒有這么多方連接需要管理,每個客戶端都只需要管理自身與服務器之間的連接,就像是我們的HTTP模型一樣是Client/Server結構。
WebSocket
/ \
DATA / \ DATA
/ \
Client Client
那么此時我們在WebSocket的服務端依然要定義一些事件,與WebRTC不一樣的是,我們只需要定義一個房間即可,并且所有的狀態都可以在服務端直接進行管理,例如是否連接成功、是否正在傳輸等等,在WebRTC的實現中我們必須要將這個實現放在客戶端,因為連接狀態實際上是客戶端直接連接的對等客戶端,在服務端并不是很容易實時管理整個狀態,當然不考慮延遲或者實現心跳的話也是可以的。
那么同樣的在這里我們的服務端定義了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) {
// 狀態復位
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發起連接請求、SEND_RESPONSE回應連接請求、SEND_MESSAGE發送消息、SEND_UNPEER發送斷開連接請求,并且在這里因為狀態是由服務端管理的,我們可以立即響應對方是否正在忙線等狀態,那么便可以直接使用回調函數通知發起方。
// packages/websocket/server/index.ts
socket.on(CLINT_EVENT.SEND_REQUEST, ({ origin, target }, cb) => {
// 驗證
if (authenticate.get(socket) !== origin) return void 0;
// 轉發`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;
// 轉發`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;
// 轉發`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;
// 處理自身的狀態
peer.delete(origin);
updateMember(room, origin, "state", CONNECTION_STATE.READY);
// 驗證
if (peer.get(target) !== origin) return void 0;
// 轉發`Unpeer`
const targetSocket = room.get(target)?.socket;
if (targetSocket) {
// 處理`Peer`狀態
updateMember(room, target, "state", CONNECTION_STATE.READY);
peer.delete(target);
targetSocket.emit(SERVER_EVENT.FORWARD_UNPEER, { origin, target });
}
});
通信
在先前我們實現了WebRTC的單文件傳輸,那么在這里我們就來實現一下多文件的傳輸,由于涉及到對于Buffer的一些操作,我們就先來了解一下Unit8Array、ArrayBuffer、Blob的概念以及關系。
-
Uint8Array:Uint8Array是一種用于表示8位無符號整數的數組類型,類似于Array,但是其元素是固定在范圍0到255之間的整數,也就是說每個值都可以存儲一個字節的8位無符號整數,Uint8Array通常用于處理二進制數據。 -
ArrayBuffer:ArrayBuffer是一種用于表示通用的、固定長度的二進制數據緩沖區的對象,提供了一種在JS中存儲和操作二進制數據的方式,但是其本身不能直接訪問和操作數據,ArrayBuffer = Uint8Array.buffer。 -
Blob:Blob是一種用于表示二進制數據的對象,可以將任意數據轉換為二進制數據并存儲在Blob中,Blob可以看作是ArrayBuffer的擴展,Blob可以包含任意類型的數據,例如圖像、音頻或其他文件,通常用于在Web應用程序中處理和傳輸文件,Blob = new Blob([ArrayBuffer])。
實際上看起來思路還是比較清晰的,如果我們自擬一個協議,前12個字節表示當前塊所屬的文件ID,再使用4個字節也就是32位表示當前塊的序列號,其余的內容作為文件塊的實際內容,那么我們就可以直接同時發送多個文件了,而不必要像之前一樣等待一個文件傳輸完成之后再傳輸下一個文件。不過看起來還是比較麻煩,畢竟涉及到了很多字節的操作,所以我們可以偷懶,想一想我們的目標實際上就是在傳輸文件塊的時候攜帶一些信息,讓我們能夠知道當前塊是屬于哪個ID以及序列。
那么我們很容易想到二進制文件實際上是可以用Base64來表示的,由此我們就可以直接傳輸純文本了,當然使用Base64傳輸的缺點也很明顯,Base64將每3個字節的數據編碼為4個字符,編碼后的數據通常會比原始二進制數據增加約1/3的大小,所以我們實際傳輸的過程中還可以加入壓縮程序,比如pako,那么便可以相對抵消一些傳輸字節數量的額外傳輸成本,實際上也是因為WebSocket是基于TCP的,而TCP的最大段大小通常為1500字節,這是以太網上廣泛使用的標準MTU,所以傳輸大小沒有那么嚴格的限制,而如果使用WebRTC的話單次傳輸的分片是比較小的,我們一旦轉成Base64那么傳輸的大小便會增加,就可能會出現問題,所以我們自定義協議的多文件傳輸還是留到WebRTC中實現,這里我們就直接使用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));
};
接下來就是我們的常規操作了,首先是分片發送文件,這里因為我們是純文本的文件發送,所以并不需要特殊處理Text/Buffer的數據差異,只需要直接發送就好,大致流程與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();
};
在接收消息的地方,我們改變了策略,因為當前的數據是純文本攜帶了很多數據,所以對于文件分塊而言我們的可控性更高了,所以我們采用一種客戶端請求的多文件分片策略,具體就是說在A向B發送文件的時候,我們由B來請求我希望拿到的下一個文件分片,A在收到請求的時候將這個文件進行切片然后發送給B,當這個文件分片傳輸完成之后再繼續請求下一個,直到整個文件傳輸完成,而每個分片我們都攜帶了所屬文件的ID以及序列號、總分片數量等等,這樣就不會因為多文件傳遞的時候造成混亂,而且兩端的文件傳輸進度是完全一致的,不會因為緩沖區的差異造成兩端傳輸進度上的差別。
// 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
總結
以上是生活随笔為你收集整理的基于WebRTC的局域网文件传输的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Rust实现线段树和懒标记
- 下一篇: 文心一言 VS 讯飞星火 VS chat