阅读 194

『前端日记』—— 基于WebRTC的浏览器跨端传输 | 项目复盘

写在前面

前阵子小伙伴微信给我发了一链接,说让我在电脑上打开看看。

“我电脑没装微信”
“内容很丰富”
“等下,我账号貌似登不上网页版微信”
“你去浏览器上输,我给你念”
“。。。”

不知道你们有没有过这种苦恼,于是老夫在干饭之余写了一个web应用,在不安装其它软件的情况下,使用浏览器实现跨端数据的传输。
👉 ox.vanoc.top

使用方法

首先在PC浏览器打开ox.vanoc.top

这是是老夫设计的主页,也是唯一的一个页面 = =。他主要有两个功能:

  • 点击左侧的电脑复制到手机会弹出一个二维码,用手机扫描二维码就会出现连接提示,当连接成功后会在二维码上出现跳动的提示,这个时候可以在电脑上复制一段文字或图片,再跑到页面中按Control+V就会将电脑剪切板中的内容发送到手机。
  • 点击又侧的手机复制到电脑会弹出一个二维码,扫码建立连接过程同上。连接成功后可以在手机的输入框内粘贴或输入任意文字,也可以选择图片发送到电脑,发送成功后会自动将发送的内容塞入电脑剪切板中,然后我们可以在电脑手动上进行粘贴使用这些内容。我还增加了图片识别功能,可以将手机中的图片发送到电脑,然后在电脑上按住左键拖拽选择需要识别的文字。

这俩功能其实从建立连接到数据传输都是一模一样的方式。至于为什么要分成两个功能呢?没错就是为了骗点击量。

第一次进入页面后还可以看到我精心准备的新手引导,或者你还可以看图文指南:

WebRTC在前端

WebRTC连接是文章应用的核心,它是由谷歌公司在2011年发布开源的用来建立P2P实时音视频传输的一套标准,包含了硬件、音视频驱动、各类数据传输协议、STUN/TURN 服务器以及SDP等等。

如今在现代Web浏览器中已经可以轻松通过RTCPeerConnectionMediaDevice等API建立WebRTC连接来进行音视频流或其它数据buffer的单点传输。如本文建立P2P传输的主要手段都是由RTCPeerConnection接口提供,下面我会简单介绍一下WebRTC建立连接进行数据传输的思路和原理。

WebRTC与WebSocket

提起Web间的数据通信就会想到WebSocket,稳定又安全。但基于TCP的WebSocket,为了保证传输的可靠性,加了一堆包头不说,发完消息还要对个暗号,对不上就要重发。当然最主要的问题是以老夫目前一个月1500块伙食费的财力,租的服务器带宽小就很慢,可能传几张大图片可能就欠费了。

相比之下用WebRTC建立起一的P2P单点通信,不仅传输速率快,而且p2p建立连接之后是不走服务器流量的,可谓是相当划算。当然,缺点就是在国内的网络环境下能实现NAT穿透成功的概率大概只有百分之50左右,一旦穿透失败webrtc会切换为中继模式,用指定的服务器做流量中转,保证数据传输。

Dsp与信令服务器

【图片来自网络】

首先我们了解一下webRtc建立p2p的过程,如上图所示:

  1. A设备创建Offer(sdp),并发送给B设备。
  2. B设备收到Offer,设置为远程remote,同时创建Answer(sdp),并发送给A设备。
  3. A设备收到Answer,并设置成远端remote。
  4. 询问stun服务器,获取icecandidate
  5. NAT穿透建立连接

所以建立WebRTC连接的第一步就是进行sdp的交换,那到底什么是sdp呢?

sdp其实就是一段对设备各种硬件和状态的描述文本,比如网络状况、音视频设备参数、ice候选等等

v=0
↵o=- 6005770948690505604 2 IN IP4 127.0.0.1
↵s=-
...
复制代码

webRTC需要对比连接双方的sdp,然后根据双方各自的能力协商出最合适的连接方式,所以我们需要用到一个web服务器来交换彼此的戒指哦不彼此的sdp,这个服务器一般叫做信令服务器。

stun/turn服务器与NAT穿透

通信双方确认好彼此的sdp信息后,就可以开始尝试NAT穿透了,WebRTC通过ICE框架来解决网络穿透的问题,这个框架包含了stun/turn技术。

stun服务器的主要用途是将收到的UDP数据包返回给客户端返回公共ip和端口,以及双方的连通性检测等等。它实现起来比较简单,所以网络上有很多公开的免费使用的stun地址可供选择。 b112ac2cd7e557d86dd5669e2858f6c3.png [图片来自网络]

如果stun服务器NAT失败,这时候我们就需要turn服务器来实现间接通信,这个服务器需要自己搭,它与stun相比主要增加了中继服务器,也就是通过turn服务器传输的数据是会跑自己服务器的流量计费的。

一般来说可以在webRTC提供的接口中设置多个stun/turn服务器地址,webRTC尝试所有服务器,并返回相应的icecandidate,再通过信令服务器交换双方的icecandidate,尝试建立连接。

const ICE_config= {
  'iceServers': [
    {
      'url': 'stun:stun.l.google.com:19302'
    },
    {
      'url': 'turn:192.158.29.39:3478?transport=udp',
    },
    {
      'url': 'turn:192.158.29.39:3478?transport=tcp',
    }
  ]
}
const pc = new RTCPeerConnection(ICE_config);
复制代码

心路历程

image.png 上图就是建立连接的流程图,用户在PC端创建二维码之后会在信令服务器生成一个唯一uid,并以uid为key值通过lru算法缓存一个可过期的连接状态对象(stateinfo),并将这个uid返回给PC端。PC端使用uid轮询stateinfo的状态同步到本地,同时当本地有offer或ice产生时更新至stateinfo。

当手机扫码后会得到PC端stateinfo的uid,然后同PC端一样,使用uid去轮询和同步stateinfo状态。而当尝试nat穿透超过一定时间后,我会认定其为连接失败,转换为中继服务器(websocket)进行数据传输。

当然,一台移动设备切换至websocket后会记录当前网络环境与uid,在之后用同一网络环境和同一台pc进行连接时会直接切换为websocket模式,省去不必要的等待时间。

提示:下文中代码片段并非完整代码,可能无法运行或有漏洞,仅供参考

搭建信令服务器

搭建一个信令服务器的方法有很多,可以用轮询的方式在服务器维护双方信令,也可以用webscoket的方式直接同步。

由于文中应用并发并不会很高,所以用node koa简单撸了一个服务器,以http轮询的方式来实现信令交换。

首先需要一个创建stateinfo的接口create

router.post('/create', async (ctx) => {
    const uid = createUid()
    cache.set(uid, { 
      offer: '',
      answer: '',
      status: 1,
      candidate1: [],
      candidate2: [],
    })
    
    ctx.body = {
        code: 0,
        data: {
            uid
        }
    }
})
复制代码

优化一下,每台PC只生成一次uid,节约内存,同时提供快速重连的可能。我们加个uid检查的中间件即可:

module.exports = () => async (ctx, next) => {
  let uid = ctx.cookies.get(names.CLIENT_UID_NAME)
  if (!uid) {
    uid = createUid(ctx)
    ctx.cookies.set(names.CLIENT_UID_NAME, uid, { httpOnly: true })
  }

  ctx.state.uid = uid
  return next()
}

// create接口改造为
router.post('/create', async (ctx) => {
    const uid = ctx.state.uid
    ...
复制代码

再提供一个用来轮询状态接口check

router.get('/check', async (ctx) => {
  const { uid } = ctx.state
  const data = cache.get(uid)
  if (!data) {
    ctx.body = { code: errorCode.NOT_FIND, message: errorCode.NOT_FIND_MESSAGE }
    return
  }

  ctx.body = { code: 0, data, message: '' }
})
复制代码

当客户端状态更新时需要同步到服务器stateinfo,所以需要一个更新接口update

router.post('/update', async (ctx) => {
  const { body } = ctx.request
  const { uid } = ctx.state
  
  const stateinfo = cache.get(uid)
  
  if (body.offer) {
      stateinfo.offer = body.offer
      stateinfo.status = 2
  }
  
  // 切换至websocket
  if (body.upgrade) {
      stateinfo.status = -1
  }
  ...
})
复制代码

搭建中继服务器

webrtc在国内nat穿透成功率不到百分之50,所以当穿透失败时需要有一个中继服务来保证数据成功送达。原本它是用turn服务器进行中继过度的,它找不到免费的,所以通常都是用coturn自己部署。但是这东西依赖数据库,要么mysql要么redis啥的,以我仅剩0.5G内存的阿里云ECS只能望而却步,反正都是跑自个儿的流量,直接用webscoket中继一下。

websocket是http的一种升级,Client发送一个upgraderequest,这是一个http请求,server返回101,升级成功,开始建立webscoket连接。所以当我们不想再启动另一个node服务器时,只需要定制http服务器的upgrade函数,就能实现http服务器与websocket服务器共用同一端口。

module.exports = function createSocket(server) {

  const socketServer = new webSocket.Server({ noServer: true })
  
  socketServer.on('connection', function connection(ws, uid, type) {
    setSocketRoom(uid, type, ws)
    ws._room_type = type
    ws._room_id = uid
    
    ws.on('message', function incoming(message) {
      sendMessage(this._room_id, this._room_type, message)
    })

    ws.on('close', function onclose() {
      closeRoom(this._room_id)
    })
  })

  server.on('upgrade', (request, socket, head) => {
    const urlParsed = request.url.match(/^\/ws\/\?uid=(.+)?&type=(.+)$/)
    const [_, uid, type] = urlParsed

    initSocketRoom(uid)
    if (hasConnectedRoom(uid)) {
      socket.write('HTTP2.0 401 room is connected\r\n\r\n')
      socket.destroy()
      return
    }

    socketServer.handleUpgrade(request, socket, head, function done(ws) {
      socketServer.emit('connection', ws, uid, type);
    });
  })
}
复制代码

图片分片传输

如何使用webrtc创建dataChannel进行数据传输的相关文章有很多,这里不再赘述。 他支持发送的数据类型有stringbolbarraybuffer等,并且单次传输数据的大小是有限制的,这个大小限制在不同浏览器中的表现也不太一样,比如chorme/firfox浏览器可以达到256kb,而在ios safari中只有16kb。所以一些比较大的数据比如图片,就需要进行分片传输。

首先我们先将图片转成arraybuffer对象,保险起见以8kb长度将其分割成若干段

 public sendFileSlice(file: File) {
    if (file.size > 1024 * 1024 * 10) {
      message.error('图片太大了,老夫扛不住啊')
      return
    }
    const fragmentSize = 1024 * 8; // 每次send最大只有8kb
    const fileReader = new FileReader();
    fileReader.onload = () => {
      const buffer = fileReader.result as ArrayBuffer;
      const size = buffer.byteLength;
      const partitionLength = Math.abs(size / fragmentSize);
      const fragments = Array<ArrayBuffer>();
      for (let i = 0; i < partitionLength; i++) {
        fragments.push(buffer.slice(i * fragmentSize, (i + 1) * fragmentSize));
      }
      this.sendFragmentArrayBuffer(fragments, file.type);
    };
    fileReader.readAsArrayBuffer(file);
  }
复制代码

我们可以给传输数据定义一些类型枚举,每次发送消息时将这些head带上,方便接收方根据不同的head进行不同的操作

export enum MessageActionHead {
  // 系统通信
  NATIVE = "[native]",
  // 连接成功的应答
  CONNECT = "[connect]",
  // 发送的文本数据
  TEXT = "[text]",
  // 图片buffer, 由于send存在大小限制,我采用分片传输
  BUFFER = "[buffer]",
  // buffer分片收到确认的回信
  INFO = "[info]",
  // 心跳包
  HEART = "[heart]",
  // 关闭连接
  CLOSE = "[close]",
  // 挂起,移动端选择图片时会挂起js,这时候需要停止心跳包的监听
  HOLDER = "[holder]",
}
复制代码

当收信方接收到BUFFER字符时,则表示需要进行分片数据收集了,并在每收到一个buffer后回信INFO字符,发信方再开始发送下一个buffer,保证包的时序性。

操作电脑剪切板

在浏览器中拷贝到剪切板有两种方式

第一种是使用input + execCommand将input中的内容选中然后调用execCommand('copy')复制,但是这种方法只能拷贝文本至剪切板,并且需要用户点击事件授权。

第二种方式就是操作navigator.clipboard对象直接修改剪切板内容,这种方式是无感知的,同时兼顾文本与图片

const tryCopyClipboard = async (
  type: MessageActionHead,
  content: string | Blob
) => {
  if (!hasClipboard) return 0;
  if (type === MessageActionHead.TEXT) {
    try {
      const copyText = (content as string).replace(/\\n/g, '\n')
      await navigator.clipboard.writeText(copyText);
      return true;
    } catch (e) {
      console.log(e);
      return false;
    }
  }

  if (type === MessageActionHead.BUFFER) {
    try {
      const targetType = (content as Blob).type;
      let copyTargetBlob = content as Blob;
      if (targetType.endsWith("jpg") || targetType.endsWith("jpeg")) {
        copyTargetBlob = await jpgBlobToPng(content as Blob);
      }
      await navigator.clipboard.write([
        new window.ClipboardItem({
          [copyTargetBlob.type]: copyTargetBlob,
        }),
      ]);
      return true;
    } catch (e) {
      console.log(e);
      return false;
    }
  }

  return false;
};
复制代码

识别图片中的文字

用开源的JavascriptOCR引擎——Tesseract.js可以很方便的实现提取图片中文字的功能,Tesseract.js由ocr引擎和语言包组成,ocr会根据初始化时指定的语言种类去github下载相应的语言包,中文语言包大概有30M左右。

tessearct.js的api很简单,但是问题来了,语言包是放在github上的,国内访问不了,怎么解决呢?

通过tessearct的语言包加载源码可以看到它会先去浏览器的indexDB中找缓存,找不到才会去重新下载,所以我们只需要先将所需的语音包从github下载上传到自己的cdn,然后在客户端初始化Tessearct之前手动将语言包从cdn下载到客户端并缓存到indexDB即可。

// 缓存检查
export async function catchDistrictFile(
  processCallback?: (event: ProgressEvent) => void,
  successCallback?: () => void
) {
  try {
    await initDBConnect();
    const hasChCache = await findDB(CH_DATA_NAME);
    const hasEnCache = await findDB(EN_DATA_NAME);

    // 如果
    if (hasChCache) {
      const buffer = await loadFile(
        `${import.meta.env.VITE_CDN_URL}gz/${CH_DATA_NAME}.gz`,
        processCallback
      );

      setBuffer('./' + CH_DATA_NAME, gzip.gunzipSync(buffer));
    }
    if (hasEnCache) {
      const buffer = await loadFile(
        `${import.meta.env.VITE_CDN_URL}gz/${EN_DATA_NAME}.gz`,
        processCallback
      );
      setBuffer('./' + EN_DATA_NAME, gzip.gunzipSync(buffer));
    }
    successCallback && successCallback();
  } catch (e) {
    console.log(e);
  }
}


async function readText () {
    await catchDistrictFile()
    await worker.load();
    await worker.loadLanguage("eng+chi_sim");
    await worker.initialize("eng+chi_sim", OEM.TESSERACT_ONLY);
    await worker.setParameters({
      tessedit_pageseg_mode: PSM.SINGLE_BLOCK,
    });
}
复制代码

最后

虽然webRTC成功建立P2P连接的概率不到百分之五十,但相比直接用服务器进行数据转发,起码能省掉一半带宽和流量费不是,穷人家的程序员越是要精打细算[狗头]。

本文正在参与「掘金 2021 春招闯关活动」, 点击查看 活动详情

文章分类
前端
文章标签