WebRTC进阶九 / 如何通过RTCDataChannel进行文字和文件传输

819 阅读6分钟

前言

可能大家都听说过WebRTC中可以传输文字文件,但是是怎么实现的我们今天就来看一看。

原理解析

开始之前我们需要认识一个新的APIRTCDataChannel,RTCDataChannel 接口代表在两者之间建立了一个双向数据通道的连接。不仅可以实现1对1的实时聊天,还可以进行文件传输。但是在传输文字和文件过程中还是有不一样的地方的,我们在发送的时候是支持多种数据类型的,包括string,Blob,ArrayBuffer,ArrayBufferView。

image.png

由于发送文本数据量较小,一般不会出现失败的问题,但是传输文件的时候,由于网络的原因,就可能出现失败的问题,为了解决这个问题一般我们都是采用断点续传的方式进行文件传输,那么如何进行断点续传呢。 首先我们传输文件的时候一般是要将文件分成多个块,在接收端可以每接受一个文件就告知发送端这个块接受完成了,当传输中断的时候,还可以在上次发生中断的地方进行重传。

知道了传输使用的API和基本原理后,我们来看看是怎么实现的吧。

实现

创建数据通道

首先我们需要知道createDataChannel的参数是啥,

image.png

第一个参数是一个名称,该字符串不能长于 65,535 字节

第二个是可选选项,描述如下

interface RTCDataChannelInit {
    id?: number;
    maxPacketLifeTime?: number;
    maxRetransmits?: number;
    negotiated?: boolean;
    ordered?: boolean;
    protocol?: string;
}
ordered (可选):布尔值,指定数据传输是否需要保证顺序,默认为 true。
maxPacketLifeTime (可选):一个数值,指定数据包的最大生存时间,单位为毫秒。如果设置了该属性,则当数据包超过该时间时,它将被丢弃。
maxRetransmits (可选):一个数值,指定数据包的最大重传次数。如果设置了该属性,则当数据包超过该次数时,它将被丢弃。
protocol (可选):一个字符串,指定协商的子协议名称,默认为 ""。
negotiated (可选):布尔值,指定是否为协商的数据通道,即是否由 RTCDataChannel.createDataChannel() 方法创建,默认为 false。
id (可选):一个数值,指定协商的数据通道 ID,默认为随机生成的值。

首先我们要创建一个数据通道,

let localPeer: RTCPeerConnection;
let localDatachannel: RTCDataChannel;
localDatachannel = localPeer.createDataChannel('file', {  
    ordered: true,  // 保证顺序
    maxRetransmits: 50  
});

上面设置了ordered,这样可以保证数据的有序到达,maxRetransmits设置为50后,可以在丢包后进行重传,最多重传50次。

这个时候有人就要问了,不能等到建立连接后再创建吗?其实是可以的,但是会导致重新进行ICE协商。

RTCDataChannel事件监听

在创建好RTCDataChannel对象后,我们需要对这个对象进行监听

// 当通道成功打开时触发此事件。此时您可以开始发送数据。
localDatachannel.onopen = (ev) => {  
console.log('onopen', ev);  
}  
// 当通道关闭时触发此事件。这可能是由于连接中断或调用`close()`方法导致的。
localDatachannel.onclose = (ev) => {  
console.log('onclose', ev);  
}  
// 当通道收到新数据时触发此事件。数据可以是字符串或二进制数据。
localDatachannel.onmessage = (ev) => {  
console.log('onmessage', ev);  
}  
// 当通道发生错误时触发此事件。这可能是由于连接问题或其他原因导致的。
localDatachannel.onerror = (ev) => {  
console.log('onerror', ev);  
}  
// 当发送缓冲区的大小低于其缓冲区阈值时触发此事件。这是一个提示,告诉您可以安全地发送更多数据。
localDatachannel.onbufferedamountlow = (ev) => {  
console.log('onbufferedamountlow', ev);  
}

建立链接

接下来就是要建立连接了,这个过程在之前的文章中有讲过了,这里就不再赘述了,大致的过程就是交换SDP协商ICE。

详见 从0搭建一个WebRTC,实现多房间多对多通话,并实现屏幕录制

发送文字

建立完连接后,我们就可以进行发送文字的尝试了,第一步是发送文字

const sendMessage = async () => {  
    localDatachannel.send(JSON.stringify({  
        text: textInput.value,  // 输入的文本
        uid: uid.value,  
    }));
 }

对端的onmessage会触发,

remoteDatachannel.onmessage = (ev) => {  
    if (typeof ev.data === 'string') {  
        const data = JSON.parse(ev.data);  // 接收到的文字
    } 
}

这个时候就是完成了文字的发送和接收了。

sendText.gif

发送文件

发送文件就和发送文字不太一样了,首先我们需要让用户选择文件

<input type="file" accept @change="fileChange"/>

const fileChange = async (ev: ProgressEvent<HTMLInputElement>) => {  
    if (ev.target?.files) {  
        // 发送进度
        sendPercentage.value = 0;   
        fileSelect = ev.target.files[0]  
    }  
}

选择好文件后就要开始进行文件的传输了,

// 定义一个异步函数,用于传输文件
const startTran = async () => {
  let offset = 0; // 偏移量,用于跟踪文件读取进度
  localDatachannel.send(JSON.stringify({ // 发送文件信息,包括文件名和大小
    fileInfo: {
      size: fileSelect.size, // 文件大小
      name: fileSelect.name // 文件名
    }
  }));

  const fileReader = new FileReader(); // 创建一个FileReader对象,用于读取文件
  // fileReader.readAsArrayBuffer(fileSelect); // 异步读取整个文件,但不会阻塞代码执行

  fileReader.onload = (e) => { // 当文件加载完成时触发该函数
    if (e.target?.result) { // 如果有数据读取成功
      localDatachannel.send(e.target.result); // 将读取的数据通过RTCDataChannel发送
      offset += e.target.result?.byteLength; // 更新偏移量,增加已读取的数据长度
      if (offset < fileSelect.size) { // 如果还有未读取的数据
        sendPercentage.value = parseInt(Math.ceil(offset / fileSelect.size * 100).toString()); // 计算传输进度百分比并更新UI
        readSlice(offset); // 继续读取下一段数据
      }
    }
  };

  const readSlice = (len: number) => { // 读取指定范围的数据,并将其发送到RTCDataChannel
    const slice = fileSelect.slice(offset, len + chunkSize); // 读取一定大小的数据片段
    fileReader.readAsArrayBuffer(slice); // 异步读取数据片段,但不会阻塞代码执行
  };

  readSlice(0); // 从0开始读取文件数据
};

这里开始发送后,远端就要进行接收文件

remoteDatachannel.onmessage = (ev) => {
  if (typeof ev.data === 'string') { // 如果接收到的数据是字符串
    const data = JSON.parse(ev.data); // 解析JSON数据
    if (data.fileInfo) { // 如果数据包含文件信息
      remoteFileInfo.value = data.fileInfo; // 更新远程文件信息
      return;
    }
    const sendUser = userLists.filter(item => item[0] === data.uid)[0]; // 获取发送方用户信息
    messagePool.value.push({ // 将消息添加到消息池中
      nick: sendUser[1].nick, // 发送方昵称
      message: data.text, // 发送的消息内容
    });
  } else { // 如果接收到的数据是二进制数据
    receiveFileBuffer.push(ev.data); // 将接收到的数据存储到接收文件缓冲区
    receiveSize += ev.data.byteLength; // 更新接收文件的大小
    percentage.value = parseInt(Math.ceil((receiveSize / remoteFileInfo.value!.size) * 100).toString()); // 更新传输进度
    if (receiveSize === remoteFileInfo.value?.size) { // 如果当前接收的文件等于总大小,认为接收完成
      isReceived.value = true; // 更新文件接收状态为true
    }
  }
};

到这里就是接收完了发送的文件,接下来就是要对接收的文件进行下载。

const mergeFile = () => {
  // 如果文件没有完全接收,直接返回
  if (!isReceived.value) return;

  // 根据接收的文件数据创建 Blob 对象
  let received = new Blob(receiveFileBuffer, { type: 'application/octet-stream' });
  receiveFileBuffer = []; // 清空接收缓存
  receiveSize = 0; // 重置接收文件大小

  // 创建一个 <a> 标签
  let a = document.createElement('a');

  // 通过 URL.createObjectURL() 方法创建 Blob 对象的 URL
  a.href = URL.createObjectURL(received);

  // 设置下载文件的名称
  a.download = remoteFileInfo.value?.name || 'name';

  // 模拟用户点击下载链接,触发文件下载
  a.click();

  // 释放创建的 <a> 标签
  a.remove();

  // 重置接收状态和文件信息
  isReceived.value = false;
  remoteFileInfo.value = undefined;
}

接下来用一个gif来演示文件传输

filegif.gif

总结

在本文中介绍了如何通过WebRTC的dataChannel进行文件传输,在实际的生产过程中还需要处理很多的边界和异常情况,比如网络中断了,传输被中断了,实现断点续传等等。

本文及以前的文字的源码,未来会更新在GitHub上面。