webrtc实现多房间多用户的简单架构会议系统

从点对点视频通话到小型一对多直播,逐步加深了webrtc的了解和使用。现在再进一步,实现一个简单的多房间多用户的简单架构会议系统。

无论是点对点音视频还是直播,都是通过 P2P 的方式形成关联关系,而接下来的多对多会议也是一样,无非就是每个客户端都去和会议室中的每个用户建立关联,从而拿到对方的媒体信息。

过程分析

假设有A、B、C、D四个人需要参会,但是WebRTC仅支持 P2P ,那么 A 如果要和剩下的三个人视频通话,就必须和他们三个人都建立关联,也就是形成A-BA-CA-D的关联关系

看上图所示,只有建立关联关系之后,A 才能将自己的视频流发给 B、C、D 三个人,同时也才能收到其余三个人的视频画面。但是注意看,A 和其余三个人形成视频通话,但是其余三人之间并没有相互关联。

也就是说,现在这个方案只是达成了 1 对多 的场景(前面所说的直播场景)。那么如何让其余三个人之间也形成互通呢?

同理,看上图的 B 和 A 一样重新建立关联关系,只不过变成 B 为主体,剩下的A、C、D为被关联对象进而形成B-AB-CB-D的关联关系。但是这时你会发现,A-B 和 B-A 不是重复了吗? 这里就需要额外处理了,后面会提出解决方案。

重复A关联其他三个用户的过程,B,C,D都分别和其他用户建立起关联关系了。可以理解为就是多个一对多直播模式的关联过程,只是其中代码有些变化而已。

WebRTC的核心载体PeerConnection上来讲,上述的关联关系代表的就是这个核心载体,一对关联关系代表一个核心对象,A-B这一关系从代码层面上来表示,就是创建一个PeerConnection对象,这个对象中维护的是 A 和 B 之间的sdp信令 和媒体信息

用图来表示就如下所示:

首先 A 客户端需要和其余三个客户端通信,按照上述关系和WebRTC的会话流程,我们就需要创建三个PeerConnection对象来作为会话信令的载体,然后客户端还需要储存下这三个变量。这里为了操作方便,我们将其放置在Map数据结构中,key按照用户的 ID 组合成变量名,那么 A 和 B、C、D 关联关系储存方式如下:

js 复制代码
// A:10001 B:10002 C:10003 D:10004 分别代表四个客户端的用户ID
const RtcPcMaps = new Map()
const ABKeys = 10001-10002
const ACKeys = 10001-10003
const ADKeys = 10001-10004
RtcPcMaps.set(ABKeys , new PeerConnection()) //维护A-B关系
RtcPcMaps.set(ACKeys , new PeerConnection()) //维护A-C关系
RtcPcMaps.set(ADKeys , new PeerConnection()) //维护A-D关系

同理,B,C,D也应该是按照上述流程进行的。按照这个流程,就会出现上面所说的关联关系重复的问题。现在来思考下如何解决。

还是先再看一遍AB的关联过程,注意其中的一些小细节点:

  1. 首先 A 呼叫 B,呼叫之前我们一般通过实时通信协议WebSocket即可,让对方能收到信息。
  2. B 接受应答,A 和 B 均开始初始化PeerConnection 实例,用来关联 A 和 B 的SDP会话信息。
  3. A 调用createOffer创建信令,同时通过setLocalDescription方法在本地实例PeerConnection中储存一份(图中流程①)。
  4. 然后调用信令服务器将 A 的SDP转发给 B(图中流程②)。
  5. B 接收到 A 的SDP后调用setRemoteDescription,将其储存在初始化好的PeerConnection实例中(图中流程③)。
  6. B 同时调用createAnswer创建应答SDP,并调用setLocalDescription储存在自己本地PeerConnection实例中(图中流程④)。
  7. B 继续将自己创建的应答SDP通过服务器转发给 A(图中流程⑤)。
  8. A 调用setRemoteDescription将 B 的SDP储存在本地PeerConnection实例(图中流程⑤)。
  9. 在会话的同时,从图中我们可以发现有个ice candidate,这个信息就是 ice 候选信息,A 发给 B 的 B 储存,B 发给 A 的 A 储存,直至候选完成。

注意看,首先A设置本地offer,然后再将offer发送给B。B接收到offer之后将其设置为远程描述后,再创建answer发送给A这里B并没有先设置本地offer,而是直接将接收到的offer设置为远程描述 。也就是说,在AB关联过程中,有一方其实是不需要设置本地描述的(花了我一天才想明白!!!)。

可以思考如何利用这点,将A关联B和B关联A这两个PeerConnection对象去掉一个。比如A已经发送offer了,B就不需要再主动关联A了。如何才能判断A是否发送offer了呢?

  1. 假设房间内只有A时,是不会进行任何操作的。
  2. 当B进来后,将B标记为新人。即A和B拿到房间内成员信息时,B对象的isNewtrue,而A对象的isNewfalse
  3. 对于A来说,B是新人,可以直接进行关联。此时A首先创建offer,设置为本地描述,然后发送offer给B。对于B来说,他是新人,不需要进行任何操作,等着被关联就行。所以B不需要初始化,直接接收A的offer,然后设置为远程描述,再创建answer发送给A即可。到此,AB关联结束,只有A一方创建了PeerConnection对象,就是页面中只有一个PeerConnection对象。
  4. 此时再进来C,将C标记为新人。即ABC拿到房间内成员信息时,AB对象的isNewfalse,C对象的isNewtrue
  5. 对于AB来说,C是新人,可以直接进行关联。而对于C来说,他是新人,不需要进行任何操作,等着被关联就行。所以,AB分别创建一个PeerConnection对象。此时页面中有两个PeerConnection对象,而房间内有ABC三个人。
  6. 再进来一个新人,过程同上。

将过程简化就是,房间内的成员依次关联新进来的成员,新进来的成员不需要任何操作,乖乖等着被关联就行。这样就很好的解决了重复关联的问题。如下图所示:

代码实现

根据上述过程描述,用以下代码进行表示:

  1. A进入会议:获取本地媒体流并将其展示在已初始化好的 DOM 节点;初始化信令服务器连接,然后初始化房间,找出房间内的新人,发现新人是自己,不进行任何操作
linkSocket.value.on("roomUserList", 复制代码
    roomUserList.value = data.filter((e) => e.userId !== String(userId));
    console.log("房间成员列表", data);

    // 拿到房间成员列表后 初始化pc
    newUser.value = data.find((e) => e.isNew) ?? {}; // 找到新用户

    // 如果新用户是自己 则啥也不用干 等着被连接就行
    // 不是自己 则要去连接新用户的pc
    if (newUser.value?.userId !== userId) {
      initMeetingRoomPc();
    }
  });
  1. B进入会议:获取本地媒体流并将其展示在已初始化好的 DOM 节点;初始化信令服务器连接,然后初始化房间, 找出房间内的新人,发现新人是自己,不进行任何操作。但A发现了B为新人,则进行关联:
js 复制代码
const initMeetingRoomPc = async () => {
  if (!newUser.value) return;
  const localUserId = props.userId;
  const NUser = newUser.value;

  const pcKey = localUserId + "-" + NUser?.userId;
  let pc = RtcPcMaps.get(pcKey);
  if (!pc) {
    pc = new PeerConnection();
    RtcPcMaps.set(pcKey, pc);
  }

  console.log(RtcPcMaps, "RtcPcMaps-----------------");

  for (const track of localStream.value.getTracks()) {
    const sender = pc.getSenders().find((s) => s.track === track);
    if (sender) {
      // 如果发送器已经存在,则更新发送器的轨道
      sender.replaceTrack(track);
    } else {
      // 否则,添加新的轨道
      pc.addTrack(track);
    }
  }

  onPcEvent(pc, localUserId, NUser?.userId);
  // 创建offer
  const offer = await pc.createOffer();
  // 设置本地描述
  await pc.setLocalDescription(offer);
  // 发送offer给被呼叫端
  linkSocket.value.emit("offer", {
    offer,
    targetUserId: NUser?.userId,
    userId: localUserId,
  });
};
  1. B接收到offer,进行被动关联:
js 复制代码
// 处理远端offer
const onRemoteOffer = async (fromUserId, offer) => {
  const localUserId = props.userId;
  const pcKey = fromUserId + "-" + localUserId;
  let pc = new PeerConnection();
  RtcPcMaps.set(pcKey, pc);

  onPcEvent(pc, localUserId, fromUserId);

  for (const track of localStream.value.getTracks()) {
    const sender = pc.getSenders().find((s) => s.track === track);
    if (sender) {
      // 如果发送器已经存在,则更新发送器的轨道
      sender.replaceTrack(track);
    } else {
      // 否则,添加新的轨道
      pc.addTrack(track);
    }
  }

  await pc.setRemoteDescription(offer);

  const answer = await pc.createAnswer();
  await pc.setLocalDescription(answer);
  linkSocket.value.emit("answer", {
    userId: localUserId,
    targetUserId: fromUserId,
    answer,
  });
};
  1. C进入会议:获取本地媒体流并将其展示在已初始化好的 DOM 节点;初始化信令服务器连接,然后初始化房间, 找出房间内的新人,发现新人是自己,不进行任何操作。但AB发现了C为新人,则进行关联,过程同上。
  2. 至此,房间内每个人都和其他人建立了关联关系,可以互相视频了。

项目操作

项目地址:

打开下面这个,

下一节写有关通话过程的相关功能

相关推荐
燃先生._.11 分钟前
Day-03 Vue(生命周期、生命周期钩子八个函数、工程化开发和脚手架、组件化开发、根组件、局部注册和全局注册的步骤)
前端·javascript·vue.js
高山我梦口香糖1 小时前
[react]searchParams转普通对象
开发语言·前端·javascript
m0_748235241 小时前
前端实现获取后端返回的文件流并下载
前端·状态模式
m0_748240252 小时前
前端如何检测用户登录状态是否过期
前端
black^sugar2 小时前
纯前端实现更新检测
开发语言·前端·javascript
寻找沙漠的人3 小时前
前端知识补充—CSS
前端·css
GISer_Jing3 小时前
2025前端面试热门题目——计算机网络篇
前端·计算机网络·面试
m0_748245523 小时前
吉利前端、AI面试
前端·面试·职场和发展
理想不理想v3 小时前
webpack最基础的配置
前端·webpack·node.js