webrtc之传输文件

上一小节介绍了如何实现简单的音视频通话,这只是最最基础的功能。一个完整的音视频通话系统是有相当多功能的,比如桌面共享、关闭/打开麦克风、发送文件、房间管理等。不过呢,一口气吃不成胖子。功能一点点迭代添加就好了。这节就来介绍如何实现传输文件,以及传输文件的两种方式。

webrtc不仅仅可以用来实现音视频通话,也可以实现数据传输。这里数据类型可以为stringblobArrayBufferArrayBufferView(官方地址)。据此,我们可以实现发送消息,也可以实现传输文件。

发送消息的话很简单,直接字符串就行。但传输文件则有点不同,需要一点点的思考。

分析

首先传输文件的必要前提也是双方已经建立连接了,没连接成功指定是发送不了的。

要发送文件也有两种方式,一种是先询问对方是否需要接收,如果需要再发送;另一种则是直接发送,然后再提示对方有新文件是否需要接收。这两种方式区别不大,主要看场景吧。这里就先实现第一种,礼貌询问再发送,不做无用功。

然后考虑怎么发送文件,也有两种方式,一种是直接把整个文件发送,不做任何处理。这种方式适合体积小、数量少的文件,一旦量大了就容易出现发送失败的问题。另一种方式则是切片发送,即把文件分成一小块一小块,源源不断的发送。这种方式比较适合发送大体积文件,而且如果考虑的够细节,也可以实现网络恢复后继续发送,直至完成。

实现思路的话,也很简单。首先双方肯定是建立好连接了。发起方询问接收方是否接收文件,接收方回复。如果接收,则接收方监听onmessage事件,在这个事件中可以拿到发起方发送的文件。而发起方则是通过DataChannelsend方法来发送文件。

这个onmessage事件和send方法则是新的API,如下:

API

PeerConnection对象中有一个createDataChannel方法,用来创建出DataChannel对象。这个对象是专门用来传输数据的,它在文档中的定义如下:

ini 复制代码
interface RTCDataChannel : EventTarget {
  readonly attribute USVString label;
  readonly attribute boolean ordered;
  readonly attribute unsigned short ? maxPacketLifeTime;
  readonly attribute unsigned short ? maxRetransmits;
  readonly attribute USVString protocol;
  readonly attribute boolean negotiated;
  readonly attribute unsigned short ? id;
  readonly attribute RTCDataChannelState readyState;
  readonly attribute unsigned long bufferedAmount;
  [EnforceRange] attribute unsigned long bufferedAmountLowThreshold;
  attribute EventHandler onopen;
  attribute EventHandler onbufferedamountlow;
  attribute EventHandler onerror;
  attribute EventHandler onclosing;
  attribute EventHandler onclose;
  undefined close();
  attribute EventHandler onmessage;
  attribute BinaryType binaryType;
  undefined send(USVString data);
  undefined send(Blob data);
  undefined send(ArrayBuffer data);
  undefined send(ArrayBufferView data);
};

这里知道onopenonerroroncloseonmessagesend这几个事件就好,下面会用到。

然后这个createDataChannel方法也是有参数的,它的定义如下:

ini 复制代码
 RTCDataChannel createDataChannel(USVString label,
    optional RTCDataChannelInit dataChannelDict = {});
  1. label参数相当于是DataChannel的名字,是个字符串
  2. 另一个参数是个对象,重点有以下几个属性(机翻,感觉大差不差):
ini 复制代码
dictionary RTCDataChannelInit {
  boolean ordered = true; // 如果设置为 false,则允许无序传送数据。默认值 true,保证数据按顺序传送。
  [EnforceRange] unsigned short maxPacketLifeTime; // 限制通道在未确认的情况下传输或重新传输数据的时间(以毫秒为单位)。如果该值超过用户代理支持的最大值,则可能会限制该值。
  [EnforceRange] unsigned short maxRetransmits; // 限制通道在未成功传送的情况下重新传输数据的次数。如果该值超过用户代理支持的最大值,则可能会限制该值。
  USVString protocol = ""; // 用于该通道的子协议名称。
  boolean negotiated = false; // 默认值 false 告诉用户代理在带内通告通道并指示其他对等点调度相应的 RTCDataChannel 对象。如果设置为 true,则由应用程序协商通道并在另一个对等方创建具有相同 id 的 RTCDataChannel 对象。
  [EnforceRange] unsigned short id; // 当协商为 true 时设置通道 ID。协商时忽略是错误的。
};

使用的话,只传一个label也可以用。

ini 复制代码
channel = pc.createDataChannel('chat');

PeerConnection对象上还有一个ondatachannel方法,用来监听DataChannel对象。在这个方法中可以拿到DataChannel对象。

几个api已经介绍完了,下面就来试试水,看看怎么用起来。

实现

这里双方建立连接的过程就不再赘述了,看上一节就行。还有询问对方是否接收文件,我感觉也不太需要,复用询问对方是否接收通话的事件,然后加个判断即可,这里也不再说了。

首先是创建出DataChannel对象,并且监听相应的事件,代码如下:

ini 复制代码
// 给 pc添加事件
const onPcEvent = (pc, userId, targetUserId) => {

  // ......
  
  // 创建消息通道,建立webRTC通信之后,就可以直接 p2p 的直接发送消息, 无需中转服务器
  channel.value = pc.createDataChannel("chat");

  // 用于处理当数据通道被添加到连接时触发
  pc.ondatachannel = (e) => {
    e.channel.onopen = () => {
      console.log("通道打开");
    };
    e.channel.onclose = () => {
      console.log("通道关闭");
    };
    e.channel.onmessage = (data) => {
       // data为传输数据
      // ...代码下面再添加
    };
  };
}

非常简单,之后发送数据的话,直接调用channel.value.send()方法即可,接收数据则是在onmessage事件中。

这里先看看html部分,有两种,一种是发送消息,一种是发送文件:

ini 复制代码
  <div class="top">
    <div>
      发送消息:
      <el-input v-model="msg" placeholder="发送消息" />
    </div>
    <div>
      远端消息:
      <el-input v-model="remoteMsg" placeholder="" disabled />
    </div>
    <div>
      <el-button @click="sendMessageUserRtcChannel">点击发送</el-button>
    </div>
    <div class="upload">
      <el-upload
        v-model:file-list="fileList"
        class="upload-demo"
        action=""
        multiple
        show-file-list
        :http-request="httpRequest"
      >
        <el-button type="primary">点击上传</el-button>
        <template #tip>
          <div class="el-upload__tip">可上传文件或图片</div>
        </template>
      </el-upload>
    </div>
  </div>

代码很简单,看看就行。

下面来看看发送文件的第一种方式:

ini 复制代码
// 修改一下onmessage事件
  // 用于处理当数据通道被添加到连接时触发
  pc.ondatachannel = (e) => {
    e.channel.onopen = () => {
      console.log("通道打开");
    };
    e.channel.onclose = () => {
      console.log("通道关闭");
    };
    e.channel.onmessage = (data) => {
      if (isFile.value) {
        const blob = dataURItoBlob(data.data);
        // 生成下载地址
        const url = URL.createObjectURL(blob);
        const elink = document.createElement("a");
        elink.download = "下载";
        elink.style.display = "none";
        elink.href = url;
        document.body.appendChild(elink);
        elink.click();
        document.body.removeChild(elink);
      } else {
        remoteMsg.value = data.data; // 接收到远端消息
      }
    };
  };


// 自定义请求  发送文件
const httpRequest = () => {
  const type = fileList.value[0].raw.type;

  linkSocket.value.emit("sendFile", {
    userId: props.userId,
    targetUserId: remoteUserId.value,
    content: "我将要发送文件了",
    fileType: type,
  });
};

const sendFile = () => {
  if (!channel.value) return;
  const file = fileList.value[0];
  const fileReader = new FileReader(); // 创建文件读取对象

  fileReader.readAsDataURL(file.raw);
  fileReader.onload = (e) => {
    channel.value.send(e.target.result); // 发送数据
  };
};

// 接收到远程要发送文件的信息
const onSendFile = (fromUserId, content, targetUserId, type) => {
  if (targetUserId !== props.userId) return; // 不是自己的文件

  ElMessageBox.confirm(`即将接收到来自${fromUserId}的文件`, "info", {
    confirmButtonText: "接收",
    cancelButtonText: "不要",
    type: "info",
    center: true,
  })
    .then(() => {
      isFile.value = true; // 将要接收的信息类型 设置为文件
      fileType.value = type;

      linkSocket.value.emit("receiveFile", {
        userId: targetUserId,
        targetUserId: fromUserId,
        content: "我准备好了",
        type: "OK",
      });
    })
    .catch(() => {
      linkSocket.value.emit("receiveFile", {
        userId: targetUserId,
        targetUserId: fromUserId,
        content: "哥们我不要你的东西",
        type: "NO",
      });
    });
};

// 发送文件了
const onReceiveFile = (fromUserId, content, targetUserId, type) => {
  if (targetUserId !== props.userId) return; // 不是自己

  if (type === "OK") {
    ElMessage.success("对方同意接收你的文件");
    sendFile();
  } else {
    ElMessage.error("对方拒绝了你的文件,并给你一个大嘴巴子");
  }
};

首先上传文件后询问对方是否接收文件,如果接收的话,则将消息类型设置为文件,并且设置文件的类型,以便在接收到数据后重新生成文件。然后回应发起方,发起方收到消息后再读取文件数据,读取完之后送出去。然后接收方在onmessage里面拿到数据并下载下来。

这个方式还是挺简单的,唯一麻烦的地方在于,需要先读取文件类型发送给对方。当然,也可以拼接在数据前面,用特殊字符来分割,就不需要再多一个事件了(突然想到的)。

再来看看第二种:

ini 复制代码
import { useFileDialog } from "@vueuse/core";
            <svg-icon name="上传图片" class="cursor-pointer text-20px" @click="handleAddImg" />
            <el-button type="primary" @click="handleSendPublicMsg">发送</el-button>
            
     const { files, open: handleAddImg } = useFileDialog();
 
 // 读取文件 获取原始字节流
export const getFileAsArrayBuffer = (file) => {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result);
    reader.onerror = reject;
    reader.readAsArrayBuffer(file);
  });
}
 
 // 发送文件
const handleSendMsg = async () => {
  const len = upLoadImgsList.value.length;

  if (len) {
    // 分片上传
    const filesObj = unref(files);
    for (let i = 0; i < len; i++) {
      const file = filesObj[i];
      const metadata = {
        name: file.name,
        type: file.type,
        size: file.size,
        content: 'file-metadata'
      };
      
      //  先发送文件类型数据
      channel.value.send(metadata);

      const fileData = await getFileAsArrayBuffer(file);
      const chunkSize = 64 * 1024; // 64KB
      for (let start = 0; start < fileData.byteLength; start += chunkSize) {
        const chunk = fileData.slice(start, start + chunkSize);
        // 后发送文件片段数据
        channel.value.send({ name: file.name, chunk, start });
      }
    }
  }

  // 重置
  publicMsg.value = "";
  upLoadImgsList.value = [];
};

// 接收方
// 用于处理当数据通道被添加到连接时触发
pc.ondatachannel = (e) => {
  e.channel.onopen = () => {
    console.log("通道打开");
  };
  e.channel.onclose = () => {
    console.log("通道关闭");
  };
  e.channel.onmessage = (data) => {
    if (isFile.value) {

      // 上传图片 图片类型
      if (data.content === 'file-metadata') {
        const { name, type, size, msgId } = data;
        fileBuffers[name] = { type, size, chunks: [], receivedSize: 0, msgId };
      } else {
        // 上传图片 图片数据 分片上传
        const { name, chunk, start } = data;
        if (fileBuffers[name]) {
          fileBuffers[name].chunks.push(chunk);
          fileBuffers[name].receivedSize += chunk.byteLength;

          // Check if all chunks are received
          if (fileBuffers[name].receivedSize === fileBuffers[name].size) {
            const blob = new Blob(fileBuffers[name].chunks, {
              type: fileBuffers[name].type,
            });
            const blobUrl = URL.createObjectURL(blob);

            console.log(blobUrl, "useObjectUrl");

            // 生成下载地址
            const url = URL.createObjectURL(blob);
            const elink = document.createElement("a");
            elink.download = name;
            elink.style.display = "none";
            elink.href = url;
            document.body.appendChild(elink);
            elink.click();
            document.body.removeChild(elink);
            // Clean up
            delete fileBuffers[name];
          }
        }
      }
    } else {
      remoteMsg.value = data.data; // 接收到远端消息
    }
  };
};

这里的话,使用到了vueuse的上传文件方法,然后获取文件内容分片处理,接收方将接收到的数据按照顺序来拼接。这里最重要一点就是发送的数据是按照顺序来的,不会打乱数据顺序,不然这种方式就实现不了了。

对比两种方式,感觉差别不是很大。不过,更推荐第二种方式。

到这里就介绍完了两种传输文件的方式了,并没有什么特别难的地方。重点在于创建DataChannel对象,并且知道如何发送数据(使用send)和在哪里接收数据(监听onmessage)。当然,一切的大前提是已经成功建立连接了。

小节

本小节主要介绍了如何实现传输文件。首先是创建DataChannel对象,然后监听该对象的onmessage来接收数据,调用该对象的send方法来发送数据。之后就是实现了两种传输文件的方式,分别是直接发送和分片发送。这里推荐优先使用分片发送,量大管饱。

下一小节再来介绍如何切换麦克风和摄像头,以及如何使用桌面共享等小功能,冲!

相关推荐
To_OC8 小时前
别再串行写 await 了,Promise.all 才是并行请求的正确打开方式
前端·javascript·promise
vipbic8 小时前
中后台越做越乱后,我用插件化把它救回来了
前端·vue.js
Hyyy8 小时前
Computer Use 适合做什么,不适合做什么——一次真实使用后的思考
前端
小和尚同志9 小时前
前端 AI 单元测试思考与落地
前端·人工智能·aigc
invicinble10 小时前
c端系统,其实更像一个信息展示平台
前端
李姆斯11 小时前
管理是否可以被完全量化
前端·产品经理·团队管理
名字还没想好☜11 小时前
Next.js 中间件实战:鉴权、重定向与 A/B 分流
开发语言·前端·javascript·中间件·react·next.js
广州灵眸科技有限公司12 小时前
瑞芯微RV1126B开发板(EASY-EAI-PI2) INI文件操作
java·前端·javascript·网络·人工智能
kaiking_g12 小时前
vue项目中,静态导入 vs 动态导入 详解
前端·javascript·vue.js
江华森12 小时前
Web 开发基础:HTTP 与 REST
前端·网络协议·http