日韩av黄I国产麻豆传媒I国产91av视频在线观看I日韩一区二区三区在线看I美女国产在线I麻豆视频国产在线观看I成人黄色短片

歡迎訪問 生活随笔!

生活随笔

當(dāng)前位置: 首頁 > 运维知识 > windows >内容正文

windows

基于WebRTC的局域网文件传输

發(fā)布時間:2024/1/2 windows 37 coder
生活随笔 收集整理的這篇文章主要介紹了 基于WebRTC的局域网文件传输 小編覺得挺不錯的,現(xiàn)在分享給大家,幫大家做個參考.

基于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é)作、在線教育和直播等場景。而具體到WebRTCP2P的數(shù)據(jù)傳輸上,主要是解決了如下問題:

  1. WebRTC提供了一套標(biāo)準(zhǔn)化的API和協(xié)議,使開發(fā)者能夠更輕松地構(gòu)建實時通信應(yīng)用,無需深入了解底層技術(shù)細(xì)節(jié)。
  2. WebRTC支持加密傳輸,使用DTLS-SRTP對傳輸數(shù)據(jù)進(jìn)行加密,確保通信內(nèi)容的安全性,對于敏感信息的傳輸非常重要。
  3. WebRTC使用P2P傳輸方式,使得數(shù)據(jù)可以直接在通信雙方之間傳輸,減輕了服務(wù)器的負(fù)擔(dān),且通信質(zhì)量不受服務(wù)器帶寬限制。
  4. P2P傳輸方式可以直接在通信雙方之間傳輸數(shù)據(jù),減少了數(shù)據(jù)傳輸?shù)穆窂胶椭虚g環(huán)節(jié),從而降低了傳輸延遲,實現(xiàn)更實時的通信體驗。
  5. 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傳輸文件的掣肘。那么回歸到項目本身,具體來說在完成功能的過程中解決了如下問題:

  1. 局域網(wǎng)內(nèi)可以互相發(fā)現(xiàn),不需要手動輸入對方IP地址等信息。
  2. 多個設(shè)備中的任意兩個設(shè)備之間可以相互傳輸文本消息與文件數(shù)據(jù)。
  3. 設(shè)備間的數(shù)據(jù)傳輸采用基于WebRTCP2P方案,無需服務(wù)器中轉(zhuǎn)數(shù)據(jù)。
  4. 跨局域網(wǎng)傳輸且NAT穿越受限的情況下,基于WebSocket服務(wù)器中轉(zhuǎn)傳輸。

此外,如果需要調(diào)試WebRTC的鏈接,可以在Chrome中打開about://webrtc-internals/FireFox中打開about:webrtc即可進(jìn)行調(diào)試,在這里可以觀測到WebRTCICE交換、數(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 APIPeer-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ù)器壓力較小,典型場景是1N的視頻互動。對于我們而言,我們的目標(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é)議的短輪詢+超時、長輪詢,甚至是EventSourceSIP等等都可以作為信令服務(wù)器的傳輸協(xié)議。在這里我們的目標(biāo)不是僅僅建立起鏈接,而是希望能夠?qū)崿F(xiàn)類似于房間的概念,由此來管理我們的設(shè)備鏈接,所以首選的方案是WebSocketWebSocket可以把這個功能做的更自然一些,全雙工的客戶端與服務(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)前連接的SocketIP信息,在這里我們特殊處理了127.0.0.1192.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)SDPICE的交換,只不過先前也提到了信令服務(wù)器的目標(biāo)是協(xié)調(diào)連接,那么在這里我們還需要實現(xiàn)SDPICE的轉(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é)商過程了,在這部分會涉及比較多的概念,例如OfferAnswerSDPICESTUNTURN等等,不過我們先不急著了解這些概念我們先開看一下RTCPeerConnection的完整協(xié)商過程,整個過程是非常類似于TCP的握手,當(dāng)然沒有那么嚴(yán)格,但是也是需要經(jīng)過幾個步驟才能夠建立起連接的:

              A                         SIGNLING                        B
-------------------------------        ----------        --------------------------------
|  Offer -> LocalDescription  |   ->   |   ->   |   ->   |  Offer -> RemoteDescription  |
|                             |        |        |        |                              |
| RemoteDescription <- Answer |   <-   |   <-   |   <-   |  LocalDescription <- Answer  |
|                             |        |        |        |                              |
|    RTCIceCandidateEvent     |   ->   |   ->   |   ->   |       AddRTCIceCandidate     |
|                             |        |        |        |                              |
|     AddRTCIceCandidate      |   <-   |   <-   |   <-   |     RTCIceCandidateEvent     |
-------------------------------        ----------        --------------------------------
  1. 假設(shè)我們有AB客戶端,兩個客戶端都已經(jīng)實例化RTCPeerConnection對象等待連接,當(dāng)然按需實例化RTCPeerConnection對象也是可以的。
  2. A客戶端準(zhǔn)備發(fā)起鏈接請求,此時A客戶端需要創(chuàng)建Offer也就是RTCSessionDescription(SDP),并且將創(chuàng)建的Offer設(shè)置為本地的LocalDescription,緊接著借助信令服務(wù)器將Offer轉(zhuǎn)發(fā)到目標(biāo)客戶端也就是B客戶端。
  3. 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客戶端。
  4. A客戶端收到B客戶端的Answer之后,此時A客戶端需要將收到Answer設(shè)置為遠(yuǎn)端的RemoteDescription,客戶端AB之間的握手過程就結(jié)束了。
  5. 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過期時間等等防止盜用。上邊我們只是簡單理解一下,所以我們接下來需要聊一下NATSTNUTURN三個概念。

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、對稱型NATSTUN對于前三種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)用模型下出口NATRTP/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è)我們需要建立連接,此時我們需要基于ABSTUN服務(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:9999A3.3.3.3:3333告知B,將B8.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ù)包的源IPPORT都要與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)部IPPORT,到一個特定目的地IPPORT的請求,都映射到一個獨(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)在有兩個客戶端AB,此時客戶端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)建OfferAnswer的時候還存在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ì)信息來啟動連接。ICESTUN密切相關(guān),前邊我們已經(jīng)了解了NAT穿越的過程,那么接下來我們就來看一下ICE候選人交換的數(shù)據(jù)結(jié)構(gòu),ICE候選人實際上是一個RTCIceCandidate對象,而這個對象包含了很多信息,但是實際上這個對象中存在了toJSON方法,所以實際交換的數(shù)據(jù)只有candidatesdpMidsdpMLineIndexusernameFragment,而這些交換的數(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來確定連接的實際地址,并且在IPv4IPv6的情況下是有所區(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ù)、BlobArrayBuffer都是可以直接發(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)造BlobN個字節(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大小,在MDNWebRTC_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的一些操作,我們就先來了解一下Unit8ArrayArrayBufferBlob的概念以及關(guān)系。

  • Uint8Array: Uint8Array是一種用于表示8位無符號整數(shù)的數(shù)組類型,類似于Array,但是其元素是固定在范圍0255之間的整數(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ù),所以對于文件分塊而言我們的可控性更高了,所以我們采用一種客戶端請求的多文件分片策略,具體就是說在AB發(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)容,希望文章能夠幫你解決所遇到的問題。

如果覺得生活随笔網(wǎng)站內(nèi)容還不錯,歡迎將生活随笔推薦給好友。

精品久久久免费 | 欧美精彩视频在线观看 | 婷婷av色综合 | 色婷婷综合久久久久 | 天天射成人 | 超碰在线天天 | 日韩1页 | 日韩精品一区二区三区中文字幕 | 精品日韩视频 | 日韩av一区二区三区 | 久久五月天综合 | 日本高清中文字幕有码在线 | 美女福利视频在线 | 久久九九视频 | 国内亚洲精品 | 婷婷 综合 色 | 97看片 | 日日摸日日添夜夜爽97 | 玖玖精品在线 | 日韩免费在线看 | 亚洲美女视频在线 | 国产精品91一区 | 日日成人网| 激情综合五月天 | 午夜精品福利一区二区三区蜜桃 | 天天干天天拍天天操 | 久草在线资源免费 | 2019精品手机国产品在线 | 麻豆免费视频网站 | 狠狠狠色丁香婷婷综合久久88 | 国产日韩欧美在线免费观看 | 成人免费网站视频 | 色婷婷综合久色 | 亚洲一级理论片 | 激情视频综合网 | 亚洲精品ww| 国产午夜三级一区二区三 | 在线视频 国产 日韩 | 精品一二三四视频 | 免费一级特黄录像 | 亚洲精品女人 | 国产成人精品在线观看 | 午夜影院一区 | 国产精品麻豆99久久久久久 | 99精品视频在线免费观看 | 亚洲国产精品女人久久久 | 中文亚洲欧美日韩 | 麻豆 videos| 成人一区在线观看 | 欧美日本国产在线观看 | 国产成人精品综合久久久久99 | 在线观看免费国产小视频 | 最近中文字幕大全中文字幕免费 | 国产手机在线 | 伊人网av | 97精品国产91久久久久久久 | 亚洲aaa毛片 | 成人av电影在线播放 | 欧美大jb| 国产999精品久久久影片官网 | 国产精品免费久久久久 | 91色蜜桃 | 99久久99久久综合 | a在线免费观看视频 | 在线看片中文字幕 | 亚洲精品日韩在线观看 | 成人免费精品 | 婷婷国产在线 | 久久66热这里只有精品 | 久久久激情网 | 亚洲欧洲一区二区在线观看 | 欧美做受高潮电影o | 99国内精品久久久久久久 | 色全色在线资源网 | 久久亚洲综合国产精品99麻豆的功能介绍 | 精品国产乱码久久久久久天美 | 欧美综合色| 亚洲精品乱码久久久久久蜜桃欧美 | 国产一区二区久久久久 | 色国产视频 | 国产免费人成xvideos视频 | 中文乱码视频在线观看 | 色小说av | 久草电影免费在线观看 | 欧美日韩视频免费看 | 日日爱网站 | 97超碰国产精品 | 香蕉久草在线 | 国产精品专区一 | 在线观看视频一区二区三区 | 久久久国产精品一区二区三区 | 日韩a级黄色片 | 最新国产精品视频 | 99999精品| www国产亚洲| 欧美成人69av | 国产1区在线 | 国产在线播放一区二区三区 | 色视频在线免费观看 | 99久久精品国产欧美主题曲 | 又黄又爽又无遮挡的视频 | 日韩a在线 | 精品福利网 | www国产精品com| 91夜夜夜 | 国产精品一区欧美 | 中文字幕在线看视频 | 国产精品九色 | 精品视频在线播放 | 久久国产精品精品国产色婷婷 | 国产精品永久久久久久久久久 | 精品久久一区二区三区 | 日韩视频免费观看高清 | 亚洲aⅴ在线 | 成人在线观看影院 | 亚洲精品自拍视频在线观看 | 人人草在线观看 | 日韩三级精品 | 丁香五月亚洲综合在线 | 亚洲视频在线观看免费 | 久久这里只有精品23 | 免费在线观看av的网站 | 国产精品午夜久久 | 久久久高清一区二区三区 | 久久99精品久久久久婷婷 | av一级网站 | 国产成人精品999在线观看 | 在线观看免费一级片 | 国产五月| 啪啪免费试看 | 日韩美视频| 午夜骚影 | 中文字幕刺激在线 | 99视频一区 | 超碰公开在线观看 | 久久精品亚洲国产 | 99r在线| 99精品视频在线 | 精产嫩模国品一二三区 | 91成人网页版 | 少妇视频在线播放 | 国产精品一区二区视频 | 久久呀 | 色婷婷福利视频 | 欧美日韩在线观看一区二区三区 | 夜色成人网 | 成年人毛片在线观看 | 91香蕉视频在线下载 | 精品福利视频在线观看 | 日日夜夜天天干 | 久久久污 | www欧美日韩| 国产91在线免费视频 | 91精品一区二区三区蜜臀 | 精品黄色视 | 悠悠av资源片 | 国内久久久久久 | 国产免费av一区二区三区 | 欧美精品一区二区三区一线天视频 | 国产一区二区免费 | 免费一级片观看 | 国产高清在线免费 | www色综合 | 99热国产精品 | 碰超在线观看 | av黄色影院| 午夜精品电影 | 99亚洲视频 | 精品亚洲成a人在线观看 | 日韩av影视在线观看 | 伊人色综合网 | 久久国产网 | 在线最新av| 欧美精品日韩 | 国产精品ssss在线亚洲 | 免费日韩 精品中文字幕视频在线 | 国产精品二区在线观看 | 成人免费视频视频在线观看 免费 | av看片网址| 中文字幕黄色 | 久久综合狠狠综合 | 国产日韩在线播放 | 91麻豆精品久久久久久 | 丁香六月久久综合狠狠色 | 成人动漫一区二区 | 日韩在线观看中文 | 99久久夜色精品国产亚洲96 | 天天激情天天干 | 91精品国产乱码 | 久久综合色天天久久综合图片 | 亚洲欧美日韩精品一区二区 | 91中文视频 | 国产精品色婷婷视频 | 狠狠干狠狠操 | 高清精品视频 | 久久在线看 | 久久99久久精品 | 黄色动态图xx | 日韩美一区二区三区 | 三级大片网站 | 97国产大学生情侣白嫩酒店 | 中文国产在线观看 | 国产日韩精品在线观看 | 色999视频| 成人免费观看大片 | 成片人卡1卡2卡3手机免费看 | 96久久欧美麻豆网站 | 久久精品亚洲精品国产欧美 | 久久女同性恋中文字幕 | 美女国产免费 | 国产高清视频网 | 91在线视频免费91 | 在线观看91久久久久久 | 亚洲视屏一区 | www国产精品com | 一区二区三区四区不卡 | 国产午夜视频在线观看 | 91资源在线播放 | 婷婷在线色 | 深夜激情影院 | 国产亚洲va综合人人澡精品 | 天天色天天骑天天射 | 国产一级片在线播放 | 一区二区在线电影 | 欧美日韩一区二区免费在线观看 | 91成人在线免费观看 | 99久久激情视频 | av黄色免费看 | 99热亚洲精品 | 九九有精品 | 国产精品美女久久 | 亚洲日本国产精品 | 久久成人福利 | 夜色资源网 | 久久久久久久看片 | av中文字幕在线观看网站 | 国产九九热视频 | 99免费看片 | 色偷偷中文字幕 | 久久成人综合 | 成人av午夜 | 一区二区三区电影在线播 | 中文免费在线观看 | 日韩欧美在线第一页 | 懂色av一区二区三区蜜臀 | 人人精品久久 | 伊人激情网 | 美女久久久久久久 | 成人h电影在线观看 | 国产精品免费视频一区二区 | 日韩av一区二区在线影视 | 色婷婷视频 | www久久| 国产明星视频三级a三级点| 日韩av免费在线看 | 九九九电影免费看 | 欧美在线观看禁18 | 精品国产一区二区三区久久久蜜臀 | 视频在线观看国产 | 黄色美女免费网站 | 中文字幕在线观看你懂的 | 天天草天天爽 | 国产精品国产三级国产aⅴ无密码 | 中文在线字幕免费观看 | 最新日韩在线观看视频 | 亚洲丝袜一区二区 | 日韩av不卡在线观看 | 国产成人精品一区二区三区免费 | 狠狠的干狠狠的操 | 国产一区在线免费观看 | 久久1电影院| 中文字幕在线免费播放 | 99精品国产福利在线观看免费 | 久久综合久久综合这里只有精品 | 欧美日韩一区二区三区在线观看视频 | 在线免费观看国产 | 粉嫩av一区二区三区免费 | 久久综合中文字幕 | av片在线观看 | 黄色在线观看免费网站 | 欧美一区二区三区不卡 | 91精彩在线视频 | 久久精品九色 | 狠狠躁日日躁狂躁夜夜躁 | 国产精品毛片久久久 | 中文字幕在线专区 | 亚洲精品1234区 | 久久久99精品免费观看乱色 | 欧美孕妇与黑人孕交 | 久久精品综合网 | 日韩精品久久一区二区 | 美女精品在线观看 | 亚洲 欧洲av | 日韩精品一区二区三区视频播放 | 成人在线超碰 | 一级欧美黄 | 成人a免费 | 91欧美日韩国产 | 久草在线最新视频 | 国产又粗又猛又爽又黄的视频先 | 久热av在线| 国产片免费在线观看视频 | 国产手机在线播放 | 亚洲成a人片在线www | 色婷婷综合久久久 | 三级黄色免费片 | 香蕉在线视频观看 | 国产精品久久网站 | www.狠狠色 | 夜夜高潮夜夜爽国产伦精品 | 日日干狠狠操 | 成年人在线视频观看 | 成人黄色片在线播放 | 久久人人97超碰国产公开结果 | 成人毛片一区 | 日韩女同一区二区三区在线观看 | 久久久久久久久毛片 | 国产精品中文字幕av | 色吧久久 | 国产精品久久精品 | 婷婷开心久久网 | 日本精品视频在线 | 日韩a欧美 | 激情在线免费视频 | 在线观看一区视频 | www.狠狠操.com| 精品在线免费观看 | 精品中文字幕视频 | 欧美大香线蕉线伊人久久 | 麻豆成人精品 | 久久视频国产精品免费视频在线 | 国产精品美女久久久久久久 | www激情久久 | 一级免费观看 | 中文字幕黄色av | 欧美黑人xxxx猛性大交 | 中文字幕一区三区 | 天天综合操 | 99情趣网视频 | 中文字幕欧美日韩va免费视频 | 日韩av女优视频 | 天天舔夜夜操 | 久久国产精品一国产精品 | 懂色av一区二区三区蜜臀 | 国产精品久久久一区二区 | 色资源中文字幕 | 中文字幕精品三级久久久 | 亚洲国产中文字幕在线视频综合 | 亚洲闷骚少妇在线观看网站 | 最近中文字幕在线 | 国产精品一区专区欧美日韩 | 国产一区二区三区免费在线 | 亚洲一级片在线观看 | 97色国产| 国产一二三区在线观看 | 国产黄色精品在线 | 久久综合国产伦精品免费 | 亚a在线 | av网址在线播放 | 中文字幕 国产 一区 | 又黄又爽的免费高潮视频 | 精品国产一区二区三区蜜臀 | www日韩精品 | 精品国产一区二区久久 | 在线国产能看的 | 久久国产电影 | 久久久久成人精品 | 日韩在线网址 | 久久免费视频2 | 爱色av.com | 在线观看日本高清mv视频 | 久久a久久| 探花视频免费观看高清视频 | 中文字幕日本电影 | 欧洲亚洲女同hd | 久草网视频在线观看 | 国产精品18久久久久久不卡孕妇 | 青草视频在线免费 | 亚洲免费在线播放视频 | 欧美日韩成人一区 | 在线播放视频一区 | 午夜国产成人 | 久久久久一区二区三区 | 干狠狠| 亚洲黄色免费在线看 | 免费韩国av | 久久精品视频2 | 色狠狠综合 | 四虎影视www | 麻花豆传媒mv在线观看网站 | 欧美成人一二区 | 免费能看的黄色片 | 亚洲精品视频二区 | 亚洲影视九九影院在线观看 | 免费观看91视频 | 国产高清av免费在线观看 | 婷婷综合国产 | 日韩免费福利 | 国产黄色精品网站 | 久久er99热精品一区二区三区 | 黄污视频网站大全 | 精品亚洲欧美无人区乱码 | 日本黄色免费在线观看 | 日韩www在线 | 在线中文字幕av观看 | 精品国产亚洲一区二区麻豆 | 伊人色综合久久天天 | 808电影 | 色噜噜日韩精品一区二区三区视频 | 成人理论电影 | 在线看片日韩 | 久九视频 | 欧美一二三区在线观看 | 91看片淫黄大片一级在线观看 | 日夜夜精品视频 | 狠狠地日 | 精品久久免费看 | a天堂最新版中文在线地址 久久99久久精品国产 | 中文字幕麻豆 | 国产精品av免费 | 97操操| 日韩高清在线一区二区三区 | 五月精品 | 亚洲三级av | 久久视了 | 久久精品视频中文字幕 | 久草在线看片 | 97在线精品视频 | 国产区第一页 | 成人小视频在线 | 网站在线观看你们懂的 | 最近中文字幕免费av | 国内精品久久久久久久久 | 欧美日韩有码 | 日韩成人在线一区二区 | 国产精品99久久久久久有的能看 | 毛片永久免费 | 国产成人精品综合 | 国产亚洲亚洲 | 亚洲成人精品国产 | 午夜999 | 国产精品久久久区三区天天噜 | 在线视频日韩欧美 | 国产色一区 | 日韩在线免费高清视频 | 免费久久99精品国产婷婷六月 | 韩日三级在线 | 在线视频欧美日韩 | 中文字幕在线看视频国产 | 色综合久久久久综合 | 欧美整片sss | 人人爽久久久噜噜噜电影 | 在线只有精品 | 色综合久久久久久中文网 | 东方av免费在线观看 | 国产专区免费 | 在线观看韩日电影免费 | av电影免费| 中文字幕乱码在线播放 | 人人爱人人射 | www.xxx.性狂虐| 久久精品视频3 | 久久久精品电影 | 久久久99久久 | 成人精品国产免费网站 | 一区二区不卡高清 | 亚州av网站| 国产一级a毛片视频爆浆 | 天天操天操 | 91大神在线观看视频 | 97色免费视频 | 蜜臀aⅴ精品一区二区三区 久久视屏网 | 国产国产人免费人成免费视频 | 久久男人免费视频 | 黄污在线看 | 成人免费共享视频 | 一级片免费在线 | av在线播放国产 | 色婷婷亚洲精品 | 国产女v资源在线观看 | 婷婷在线色 | 中文字幕在线电影 | 丁香六月婷婷开心 | 六月婷婷色 | 亚洲精品国偷拍自产在线观看蜜桃 | 色射爱| 亚洲人成在线观看 | 亚洲综合小说电影qvod | 日韩精品久久久久久中文字幕8 | 国产精品国产三级国产aⅴ入口 | 97色在线观看 | 又黄又爽又湿又无遮挡的在线视频 | 少妇bbw撒尿| 欧美小视频在线 | 欧美国产三区 | 欧美一级大片在线观看 | 欧美激情视频三区 | 91精品国产自产在线观看永久 | 国产黄色电影 | 久久伊人爱 | 精品在线观看视频 | 在线视频99| 国产亚洲欧美在线视频 | 国产精久久| 久久久99精品免费观看app | 国内免费久久久久久久久久久 | 亚洲激情视频在线观看 | 91精品一 | 国产精品免费视频久久久 | 亚洲精品乱码久久久久久久久久 | 91激情视频在线观看 | 久久新 | 最近中文字幕在线播放 | 精品视频www| 久久精品国亚洲 | 国产一级91 | 激情网综合 | 亚洲欧美视频一区二区三区 | 免费在线观看av网址 | 91精品蜜桃 | 亚洲片在线观看 | 超碰日韩 | 亚洲精品乱码久久久久久 | 午夜黄色影院 | 日韩黄色一级电影 | 免费看毛片网站 | 国产精品免费视频观看 | 国产一级视频在线免费观看 | 成人av影视| 91福利视频一区 | 成人观看视频 | 欧美激情第八页 | 中文字幕一区二区三区视频 | 久插视频 | 亚洲性视频| 激情欧美在线观看 | av动图| 欧美韩日视频 | 999久久a精品合区久久久 | 91av免费在线观看 | 色爱成人网| 东方av在线免费观看 | 天天射色综合 | 日韩成人在线一区二区 | 国产99中文字幕 | 成人国产精品久久久久久亚洲 | 国产精品久久久久9999吃药 | 不卡精品| 久草在线精品观看 | 国产在线观看你懂的 | 伊人色综合久久天天网 | 99视频精品视频高清免费 | 久久免费在线观看 | 97在线观看视频国产 | 日韩 在线 | 中文字幕在线视频一区二区 | 国产区av在线 | 99久国产 | 中文国产字幕 | 免费观看一级特黄欧美大片 | 欧美日韩视频网站 | 久久久久激情电影 | 国产精品成人一区二区三区吃奶 | 久久91久久久久麻豆精品 | 欧美日韩高清一区二区 | 日韩精品免费一区二区 | a视频免费 | 97视频入口免费观看 | 亚洲视频网站在线观看 | 成人在线黄色 | 欧洲一区二区三区精品 | 在线视频黄 | 国产亚洲婷婷免费 | 国产一级久久 | 97在线视频免费看 | 日韩欧美综合在线视频 | 中文不卡视频在线 | 天天操天天干天天爱 | 亚洲国产精彩中文乱码av | 超碰国产97 | 日日夜夜天天干 | 色视频网站免费观看 | 日韩啪视频| 91视频免费看网站 | 夜夜躁狠狠躁日日躁视频黑人 | 视色网站| 久久理论电影网 | wwwwww黄| 久久乱码卡一卡2卡三卡四 五月婷婷久 | 亚洲国产久 | 国产在线资源 | 狠狠操操 | 黄在线免费观看 | 色欧美日韩 | 中文字幕久久精品亚洲乱码 | 九九免费在线观看 | 国产精品色婷婷 | 国产色在线视频 | 久久刺激视频 | 狠狠操在线 | 国产人成精品一区二区三 | 中文字幕中文字幕在线中文字幕三区 | 免费色av | 九九热只有这里有精品 | 日韩一区二区免费在线观看 | 高潮久久久久久 | 五月激情站 | 国产精品一区二区视频 | 天天草综合 | 久久9视频| 99免在线观看免费视频高清 | 中文字幕中文 | 精品国产一区二区三区四 | 欧美一级小视频 | 草免费视频 | 91大神精品视频在线观看 | 亚洲高清91| 亚洲一级国产 | 久久艹艹| 狠狠色免费 | 免费一级片在线 | 中文字幕亚洲不卡 | 黄av在线| 色综合天天综合 | 91成人在线观看高潮 | www.五月婷 | 亚洲精品黄色在线观看 | 国产成人精品一区二区在线 | 91精品蜜桃 | 在线观看国产区 | 国产精品久久久久婷婷 | 久草精品在线 | 日本丶国产丶欧美色综合 | 精品欧美一区二区精品久久 | 色a在线观看 | www.综合网.com | 91av资源在线 | 四虎免费av| 成人福利在线播放 | 97福利在线 | 成人黄色小说在线观看 | 国产精品免费不卡 | 久久香蕉影视 | 国产伦理一区 | 亚洲第一香蕉视频 | 天堂av在线中文在线 | 日韩理论 | 九九热在线免费观看 | 九九精品视频在线 | 亚洲精品国产精品国自产观看 | 91福利视频网站 | 99久久精品无码一区二区毛片 | 日日日干 | 亚洲电影久久 | 婷婷色狠狠 | 国产在线观看你懂的 | 91视频高清 | 国产精品aⅴ | www色片| 精品在线免费视频 | 精品国产一区二区三区男人吃奶 | 久久久久国产精品一区 | 中文字幕在线观看你懂的 | 欧美一区二区在线免费看 | 亚洲成年片 | 日韩中文字幕在线观看 | 天天色棕合合合合合合 | 韩日精品在线观看 | 97成人免费 | 欧美国产日韩一区二区三区 | 天天爱综合 | 国内精品久久久久影院优 | 国产中文字幕免费 | 久久精品一区二区三区中文字幕 | 97超碰在线资源 | 免费观看一级 | 91在线精品一区二区 | 日本九九视频 | 国产午夜精品久久久久久久久久 | 日韩国产在线观看 | 天天爽综合网 | 美女网站在线免费观看 | 中文字幕免费一区二区 | 国产精品人人做人人爽人人添 | 久久免费播放 | 日韩在线观看中文 | 99亚洲天堂 | 天天干,天天插 | 久久久久福利视频 | 亚洲年轻女教师毛茸茸 | 精品国产一区二区三区不卡 | www.天天干.com| 中文字幕免费久久 | 日韩精品一区二区在线 | 夜夜躁日日躁狠狠躁 | 最近中文字幕大全中文字幕免费 | 免费久久久 | 91人网站 | 一区二区电影网 | 96久久久 | 久草视频中文 | 婷婷网站天天婷婷网站 | 亚洲精区二区三区四区麻豆 | 狠狠色丁香久久婷婷综 | 国产护士hd高朝护士1 | 日本久久中文字幕 | 久久成人视屏 | 久久激情视频 久久 | 中文字幕日韩高清 | 视频在线91 | 69av网| 色偷偷88888欧美精品久久 | 亚洲无吗视频在线 | 久久激情婷婷 | 亚洲精品视频二区 | av不卡免费在线观看 | av中文字幕在线播放 | av中文字幕网 | 福利视频网站 | 久久精品国产99 | 亚洲欧美成人网 | 久久亚洲影视 | 亚洲成人资源 | 99在线视频免费观看 | 国产精品对白一区二区三区 | 国产91av视频在线观看 | 91亚洲在线观看 | 伊人五月婷 | 五月天色中色 | 亚洲精品一区中文字幕乱码 | 久久久久久久久综合 | 久久综合网色—综合色88 | 综合色在线 | 99久久夜色精品国产亚洲 | 久久公开视频 | 在线观看亚洲免费视频 | 99久久精品日本一区二区免费 | 天天操网址 | 日韩在线视频一区二区三区 | 国产精品欧美日韩 | 91精品啪啪 | 在线观看av的网站 | 在线观看免费观看在线91 | 中文字幕之中文字幕 | 人人盈棋牌 | 狠日日| 亚洲精品久久久久久国 | 久久久久高清毛片一级 | 国产免费一区二区三区最新 | 日韩精品视频免费看 | 国产免费高清 | 五月天久久婷 | 久久热首页 | 国产精品亚洲a | 成人免费网站在线观看 | 久久99国产精品视频 | 久久99热国产 | 国产精品久久久久久久久久不蜜月 | 激情久久影院 | 久久人人97超碰com | 欧美在线一二 | 成年人网站免费在线观看 | 午夜电影一区 | 日韩精品免费一区二区 | 国产免费嫩草影院 | 四虎在线视频免费观看 | 国内一区二区视频 | 日本高清久久久 | 综合色久 | 日日干网 | 中文字幕在线观看的网站 | 色婷婷色| 久久99热这里只有精品 | 91麻豆高清视频 | 日韩在线视频线视频免费网站 | 亚洲精品在线一区二区三区 | 少妇高潮流白浆在线观看 | 天天拍天天色 | av中文天堂在线 | 中文字幕制服丝袜av久久 | 国产一区二区在线免费播放 | 成人中文字幕在线观看 | 精品久久久久久久久久久久久久久久久久 | 国产免费亚洲高清 | 81精品国产乱码久久久久久 | 免费久久99精品国产婷婷六月 | av在线看网站| 国产综合香蕉五月婷在线 | 成年人在线观看免费视频 | 热re99久久精品国产66热 | 久久久影视 | 欧美精品你懂的 | 亚洲全部视频 | 99这里都是精品 | 久久综合九色综合欧美就去吻 | 日本亚洲国产 | 中文日韩在线视频 | 91中文字幕在线播放 | 亚洲美女精品区人人人人 | 亚洲国产高清在线 | 亚洲精品大片www | 在线免费观看黄色 | 国产精品久久一区二区三区, | 人人干人人超 | www免费黄色 | 一级黄色电影网站 | 中文字幕电影高清在线观看 | 欧美aaaxxxx做受视频 | 96在线| 天天操天天射天天爽 | www视频免费在线观看 | 91av官网 | 日韩高清网站 | 91精品视频免费看 | 激情影院在线观看 | 日本3级在线观看 | 久久一区国产 | 韩国中文三级 | 国产精品手机视频 | 日韩手机视频 | 91人人网| 久久国产视频网 | 亚洲国产精久久久久久久 | 蜜臀av夜夜澡人人爽人人 | 99精品久久久久久久 | 免费福利在线观看 | 久久国产精品久久w女人spa | avav99| 亚洲一区二区视频在线播放 | 日韩专区中文字幕 | 天天草天天干天天 | 激情综合一区 | 国产精品久久久久一区二区 | 中文伊人 | 97视频在线免费 | 97碰在线| 久久久久久免费毛片精品 | 亚洲视频在线免费观看 | 午夜美女av| 女人18片毛片90分钟 | 国产精品自在线拍国产 | 色伊人网 | h网站免费在线观看 | 在线观看亚洲国产精品 | 日日操日日插 | 久久久久久高清 | 日本精品午夜 | 中文字幕 国产精品 | 国产精品免费久久久 | av理论电影 | 伊人亚洲综合 | 欧美精品v国产精品v日韩精品 | 深爱五月激情网 | 狠狠狠色丁香综合久久天下网 | 日韩资源在线 | 91在线免费公开视频 | 久久人人爽人人爽人人片 | 500部大龄熟乱视频使用方法 | 国产精品久久久久久久久大全 | 婷婷六月综合网 | 免费色网站 | 国产成人一级电影 | 免费国产亚洲视频 | 一级片免费观看 | а中文在线天堂 | 狠狠色丁香婷婷综合久久片 | 91精品秘密在线观看 | 99久久婷婷国产一区二区三区 | 天天综合网入口 | 久久超级碰 | 精品亚洲视频在线观看 | 亚洲一一在线 | 在线免费观看视频一区 | 婷婷www| 欧美另类视频 | 国产欧美精品一区二区三区四区 | 免费观看成人 | 免费高清在线一区 | 久久久精品久久日韩一区综合 | 粉嫩aⅴ一区二区三区 | 欧美ⅹxxxxxx| 国产精品网站一区二区三区 | 久久精品一 | 中文字幕免费不卡视频 | 日韩欧美高清在线 | 91精品国产91热久久久做人人 | 欧美一区在线观看视频 | 亚洲欧洲av | 99久久精品国产一区二区成人 | 成人黄色大片在线免费观看 | 亚洲成a人片77777kkkk1在线观看 | 日韩精品一区二区三区水蜜桃 | 国产高清免费在线观看 | 性色xxxxhd| 久久久99精品免费观看app | 亚洲精品中文在线 | 97福利视频 | 亚洲成人网在线 | 91福利视频网站 | 狠狠做六月爱婷婷综合aⅴ 日本高清免费中文字幕 | 国产精品一区二区在线播放 | 狠狠操狠狠干2017 | 久久久久欠精品国产毛片国产毛生 | 久久久久久国产精品亚洲78 | 韩国三级一区 | 亚洲国产日韩一区 | 777xxx欧美 | 懂色av一区二区三区蜜臀 | 人人插人人爱 | 免费av黄色| 一区二区观看 | 黄色三级免费 | 国产五月婷 | 草久在线观看 | 亚洲人人射 | 色婷婷五 | 久草在线久草在线2 | 欧美一级性生活视频 | 18岁免费看片 | 精品女同一区二区三区在线观看 | 又色又爽又黄 | 免费在线观看黄 | 国产在线观看你懂得 | x99av成人免费 | 成人三级av| 波多野结衣视频一区二区 | 91久草视频 | 天天曰视频 | 国产小视频你懂的 | 免费在线观看日韩欧美 | 天天色官网 | 国产精品久久久久久久久久了 | 美女福利视频在线 | 在线观看v片 | 日韩精品一区二区在线观看 | 日本性动态图 | 五月天综合激情 | av性在线| 狠狠色丁香久久婷婷综 | 国产玖玖精品视频 | 久久天天躁狠狠躁亚洲综合公司 | 中文字幕亚洲欧美 | 在线黄色免费 | 欧美美女视频在线观看 | 国产精品久久久久久a | 一区二区三区四区影院 | 免费看特级毛片 | 亚洲精品久 | 久久九九久久精品 | 国产精品一区二区三区四 | 激情黄色一级片 | 天天综合网 天天 | 人人揉人人揉人人揉人人揉97 | 少妇啪啪av入口 | 国产91精品在线观看 | 国产在线不卡一区 | 超碰大片 | 国产精品成人免费一区久久羞羞 | 亚州天堂 | 天天综合网在线 | 久久一区精品 | 91免费的视频在线播放 | 国产精品美女久久久网av | 中文av一区二区 | 在线免费黄 | 国产福利精品一区二区 | 成在人线av | 国产视频在线一区二区 | 免费在线观看中文字幕 | 精品亚洲二区 | 91桃色在线观看视频 | 色射色 | 九七视频在线 | 亚洲精品美女久久17c | 在线a视频免费观看 | 高清免费在线视频 | 久久综合成人网 | 五月婷婷影院 | 99久久99精品| 天天色天天干天天色 | 黄色精品国产 | 亚洲一区天堂 | 久久久久福利视频 | 草久在线播放 | 亚洲区另类春色综合小说校园片 | 亚洲第一久久久 | 天堂av在线7| 国产亚洲精品久 | 国产精品久久久久久久久蜜臀 | 一区二区三区在线观看 | 亚洲亚洲精品在线观看 | 高清精品久久 | 亚洲精品色 | 免费一级黄色 | 一区二区激情 | 成人aaa毛片 | 国产精品12345| 久久9精品 |