基于WebRTC的局域网文件传输

WebRTC(Web Real-Time Communications)是一项实时通讯技术,允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点P2P(Peer-To-Peer)的连接,实现视频流、音频流、文件等等任意数据的传输,WebRTC包含的这些标准使用户在无需安装任何插件或者第三方的软件的情况下,可以创建点对点Peer-To-Peer的数据分享和电话会议等。

描述

通常来说,在发起文件传输或者音视频通话等场景的时候,我们需要借助第三方的服务器来中转数据,例如我们通过IM即时通讯软件向对方发送消息的时候,我们的消息会先发送到服务器,然后服务器再将消息发送到对方的客户端,这种方式的好处是可以保证消息的可靠性,但是存在的问题也比较明显,通过服务器进行转发的速度会受限于服务器本身的带宽,同时也会增加服务器的负载,特别是在传输文件或者进行音视频聊天的情况下,会给予服务器比较大的压力,对于服务提供商来说提供服务器的带宽同样也是很大的开销,对于用户来说文件与消息经由服务器转发也存在安全与隐私方面的问题。

WebRTC的出现解决了这些问题,其允许浏览器之间建立点对点的连接,实现数据的传输,以及实时通信的复杂性、插件依赖和兼容性问题,提高了安全性和隐私保护。因此WebRTC广泛应用于实时通信领域,包括视频会议、音视频聊天、远程协作、在线教育和直播等场景。而具体到WebRTCP2P的数据传输上,主要是解决了如下问题:

  1. WebRTC提供了一套标准化的API和协议,使开发者能够更轻松地构建实时通信应用,无需深入了解底层技术细节。
  2. WebRTC支持加密传输,使用DTLS-SRTP对传输数据进行加密,确保通信内容的安全性,对于敏感信息的传输非常重要。
  3. WebRTC使用P2P传输方式,使得数据可以直接在通信双方之间传输,减轻了服务器的负担,且通信质量不受服务器带宽限制。
  4. P2P传输方式可以直接在通信双方之间传输数据,减少了数据传输的路径和中间环节,从而降低了传输延迟,实现更实时的通信体验。
  5. P2P传输方式不需要经过中心服务器的中转,减少了第三方对通信内容的访问和监控,提高了通信的隐私保护。

在前一段时间,我想在手机上向电脑发送文件,因为要发送的文件比较多,所以我想直接通过USB连到电脑上传输,等我将手机连到电脑上之后,我发现手机竟然无法被电脑识别,能够充电但是并不能传文件,因为我的电脑是Mac而手机是Android,所以无法识别设备这件事就变得合理了起来。那么接着我想用WeChat去传文件,但是一想到传文件之后我还需要手动将文件删掉否则会占用我两份手机存储并且传输还很慢,我就又开始在网上寻找软件,这时候我突然想起来了AirDrop也就是隔空投送,就想着有没有类似的软件可以用,然后我就找到了Snapdrop这个项目,我觉得这个项目很神奇,不需要登录就可以在局域网内发现设备并且传输文件,于是在好奇心的驱使下我也学习了一下,并且基于WebRTC/WebSocket实现了类似的文件传输方案https://github.com/WindrunnerMax/FileTransfer。通过这种方式,任何拥有浏览器的设备都有传输数据的可能,不需要借助数据线传输文件,也不会受限于Apple全家桶才能使用的隔空投送,以及天然的跨平台优势可以应用于常见的IOS/Android/Mac设备向PC台式设备传输文件的场景等等。此外即使因为各种原因路由器开启了AP隔离功能,我们的服务依旧可以正常交换数据,这样避免了在路由器不受我们控制的情况下通过WIFI传输文件的掣肘。那么回归到项目本身,具体来说在完成功能的过程中解决了如下问题:

  1. 局域网内可以互相发现,不需要手动输入对方IP地址等信息。
  2. 多个设备中的任意两个设备之间可以相互传输文本消息与文件数据。
  3. 设备间的数据传输采用基于WebRTCP2P方案,无需服务器中转数据。
  4. 跨局域网传输且NAT穿越受限的情况下,基于WebSocket服务器中转传输。

此外,如果需要调试WebRTC的链接,可以在Chrome中打开about://webrtc-internals/FireFox中打开about:webrtc即可进行调试,在这里可以观测到WebRTCICE交换、数据传输、事件触发等等。

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 APIPeer-To-Peer RTCDataChannel API,以及核心层中的Transport传输模块即可。实际上由于网络以及场景的复杂性,基于WebRTC衍生出了大量的方案设计,而在网络框架模型方面,便有着三种架构: Mesh架构即真正的P2P传输,每个客户端与其他客户端都建立了连接,形成了网状的结构,这种架构可以同时连接的客户端有限,但是优点是不需要中心服务器,实现简单;MCU(MultiPoint Control Unit)网络架构即传统的中心化架构,每个浏览器仅与中心的MCU服务器连接,MCU服务器负责所有的视频编码、转码、解码、混合等复杂逻辑,这种架构会对服务器造成较大的压力,但是优点是可以支持更多的人同时音视频通讯,比较适合多人视频会议。SFU(Selective Forwarding Unit)网络架构类似于MCU的中心化架构,仍然有中心节点服务器,但是中心节点只负责转发,不做太重的处理,所以服务器的压力会低很多,这种架构需要比较大的带宽消耗,但是优点是服务器压力较小,典型场景是1N的视频互动。对于我们而言,我们的目标是局域网之间的数据传输,所以并不会涉及此类复杂的网络传输架构模型,我们实现的是非常典型的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协议的短轮询+超时、长轮询,甚至是EventSourceSIP等等都可以作为信令服务器的传输协议。在这里我们的目标不是仅仅建立起链接,而是希望能够实现类似于房间的概念,由此来管理我们的设备链接,所以首选的方案是WebSocketWebSocket可以把这个功能做的更自然一些,全双工的客户端与服务器通信,消息可以同时在两个方向上流动,而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地址是相同的就认为是同一个局域网的设备,所以我们需要获取当前连接的SocketIP信息,在这里我们特殊处理了127.0.0.1192.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左右的内存,几乎不占用太多资源。而信令服务器的功能并不仅仅是房间的管理,我们还需要实现SDPICE的交换,只不过先前也提到了信令服务器的目标是协调连接,那么在这里我们还需要实现SDPICE的转发用以协调链接,在这里我们先将这部分内容前置,接下来再开始聊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的具体协商过程了,在这部分会涉及比较多的概念,例如OfferAnswerSDPICESTUNTURN等等,不过我们先不急着了解这些概念我们先开看一下RTCPeerConnection的完整协商过程,整个过程是非常类似于TCP的握手,当然没有那么严格,但是也是需要经过几个步骤才能够建立起连接的:

A SIGNLING B ------------------------------- ---------- -------------------------------- | Offer -> LocalDescription | -> | -> | -> | Offer -> RemoteDescription | | | | | | | | RemoteDescription <- Answer | <- | <- | <- | LocalDescription <- Answer | | | | | | | | RTCIceCandidateEvent | -> | -> | -> | AddRTCIceCandidate | | | | | | | | AddRTCIceCandidate | <- | <- | <- | RTCIceCandidateEvent | ------------------------------- ---------- --------------------------------
  1. 假设我们有AB客户端,两个客户端都已经实例化RTCPeerConnection对象等待连接,当然按需实例化RTCPeerConnection对象也是可以的。
  2. A客户端准备发起链接请求,此时A客户端需要创建Offer也就是RTCSessionDescription(SDP),并且将创建的Offer设置为本地的LocalDescription,紧接着借助信令服务器将Offer转发到目标客户端也就是B客户端。
  3. B客户端收到A客户端的Offer之后,此时B客户端需要将收到Offer设置为远端的RemoteDescription,然后创建Answer即同样也是RTCSessionDescription(SDP),并且将创建的Answer设置为本地的LocalDescription,紧接着借助信令服务器将Answer转发到目标客户端也就是A客户端。
  4. A客户端收到B客户端的Answer之后,此时A客户端需要将收到Answer设置为远端的RemoteDescription,客户端AB之间的握手过程就结束了。
  5. 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"] }],
});

在这里我们可以看出实例化RTCPeerConnection对象时,传入的Ice Servers都是使用的公开服务,因为本身STUN服务器是不需要比较大的性能消耗,所以当前互联网上存在不少公开的不需要认证的免费STUN服务。然而在我们寻找公开服务时,我们比较难以确认哪些服务是当前还可用的,因此需要我们在本地浏览器进行测试,我们可以借助Trickle ICE测试连接状态,并且可以将互联网公开的服务预设在LocalStorage中来方便测试。

// https://gist.github.com/mondain/b0ec1cf5f60ae726202e
// https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/
const str = ``;
const group = [];
str.split("\n").forEach(line => {
  const url = line.trim();
  url && group.push({ credential: "", urls: ["stun:" + url], username: "" });
});
localStorage.setItem("servers", JSON.stringify(group));

在这里我们主要是配置了iceServers,其他的参数我们保持默认即可我们不需要太多关注,以及例如sdpSemantics: unified-plan等配置项也越来越统一化病作为默认值,在比较新的TS版本中甚至都不再提供这个配置项的定义了。那么我们目光回到iceServers这个配置项,iceServers主要是用来提供我们协商链接以及中转的用途,我们可以简单理解一下,试想我们的很多设备都是内网的设备,而信令服务器仅仅是做了数据的转发,所以我们如果要是跨局域网想在公网上或者在路由器AP隔离的情况下传输数据的话,最起码需要知道我们的设备出口IP地址,STNU服务器就是用来获取我们的出口IP地址的,TURN服务器则是用来中转数据的,而因为STNU服务器并不需要太大的资源占用,所以有有比较多的公网服务器提供免费的STNU服务,但是TURN实际上相当于中转服务器,所以通常是需要购置云服务器自己搭建,并且设置Token过期时间等等防止盗用。上边我们只是简单理解一下,所以我们接下来需要聊一下NATSTNUTURN三个概念。

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、对称型NATSTUN对于前三种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应用模型下出口NATRTP/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。那么此时我们假设我们需要建立连接,此时我们需要基于ABSTUN服务器发起请求,即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: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,其的限制是内部主机只能向之前已经发送过数据包的外部主机发送数据包,也就是说数据包的源地址需要与NAT表相符,而端口受限圆锥型NAT是一种特殊的受限圆锥型NAT,其限制是内部主机只能向之前已经发送或者接收过数据包的外部主机的相同端口发送数据包,也就是说数据包的源IPPORT都要与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是限制最多的,每一个来自相同内部IPPORT,到一个特定目的地IPPORT的请求,都映射到一个独特的外部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

那么此时我们需要创建链接,看起来发起流程非常简单,我们假设现在有两个客户端AB,此时客户端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);
  }
};

实际上我们可以关注到在创建OfferAnswer的时候还存在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就会使用该候选人的详细信息来启动连接。ICESTUN密切相关,前边我们已经了解了NAT穿越的过程,那么接下来我们就来看一下ICE候选人交换的数据结构,ICE候选人实际上是一个RTCIceCandidate对象,而这个对象包含了很多信息,但是实际上这个对象中存在了toJSON方法,所以实际交换的数据只有candidatesdpMidsdpMLineIndexusernameFragment,而这些交换的数据又会在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来确定连接的实际地址,并且在IPv4IPv6的情况下是有所区别的。

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方法可以用来发送数据,例如纯文本数据、BlobArrayBuffer都是可以直接发送的,同样的channel.onmessage事件也是可以接受相同的数据类型,那么我们接下来就借助这两个方法来完成文本与文件的传输。那么我们就来最简单地实现传输,首先我们要规定好基本的传输数据类型,因为我们是实际上只区分两种类型的数据,也就是Text/Blob数据,所以需要对这两种数据做基本的判断,然后再根据不同的类型响应不同的行为,当然我们也可以自拟数据结构/协议,例如借助Uint8Array构造BlobN个字节表示数据类型、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大小,在MDNWebRTC_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的一些操作,我们就先来了解一下Unit8ArrayArrayBufferBlob的概念以及关系。

  • Uint8Array: Uint8Array是一种用于表示8位无符号整数的数组类型,类似于Array,但是其元素是固定在范围0255之间的整数,也就是说每个值都可以存储一个字节的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();
};

在接收消息的地方,我们改变了策略,因为当前的数据是纯文本携带了很多数据,所以对于文件分块而言我们的可控性更高了,所以我们采用一种客户端请求的多文件分片策略。具体就是说在AB发送文件的时候,我们由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