深入探索基于 WebSocket 的 WebRTC 全栈音视频会议室项目 - p2p.chat

项目概述

p2p.chat 是一个开源的基于 WebSocket 和 WebRTC 实现的点对点的视频会议 Web 项目,主要技术栈是 Socket.io 和 Nextjs。

阅读这个项目的代码之前,需要对 WebRTC 有基本的了解,例如明白怎么建立最基础的 WebRTC 链接,后续的核心逻辑代码中,大部分都围绕着 WebRTC 的建立,不了解的话可能会看得一头雾水。

下面就是项目技术栈的简单介绍

后端部分 @p2p.chat/signalling,基于 Socket.io 的 WebRTC 信令服务器。

前端部分 @p2p.chat/www,基于 Nextjs 的前端网页应用。

@p2p.chat/signalling

信令服务器的实现非常简单,在入口文件 index.js 只执行了两个函数:

TypeScript 复制代码
import { createConsoleLogger } from "./lib/logger";
import { createServer } from "./websockets";
​
const PORT = 8080;
​
const logger = createConsoleLogger();
const server = createServer(logger);
​
server.listen(PORT);
logger.info(`Started listening on ${PORT}`);

其中 createConsoleLogger 函数是基于 winston 库实现的日志输出函数,用于实现简单的日志格式化功能。

核心部分在于 createServer 函数,createServer 使用 socket.io 启动 WebSocket 服务器,开始监听事件,在监听事件这一步,作者对每一个事件触发回调函数都使用了柯里化,用于保存一些会复用的参数,以 joinRoom 事件为例:

TypeScript 复制代码
socket.on("joinRoom", onJoinRoom(logger, socket));
​
const onJoinRoom = (logger: Logger, socket: Socket) => (room: string) => {
  logger.info(`join room=${room} sid=${socket.id}`);
​
  socket.join(room);
  socket.broadcast.to(room).emit("peerConnect", socket.id);
};

onJoinRoom 闭包保存了 loggersocket,这是一个保存常用参数的好办法。

在信令服务器监听的这些事件中,几乎只有一个目的:交换客户端之间的信息。

TypeScript 复制代码
const onConnection = (logger: Logger, server: Server) => (socket: Socket) => {
  logger.info(`connection sid=${socket.id}`);
​
  socket.emit("connected");
  socket.on("joinRoom", onJoinRoom(logger, socket));
  socket.on("disconnecting", onDisconnect(logger, socket));
  socket.on("webRtcAnswer", onWebRtcAnswer(logger, server, socket));
  socket.on("webRtcIceCandidate", onWebRtcIceCandidate(logger, server, socket));
  socket.on("webRtcOffer", onWebRtcOffer(logger, server, socket));
};

监听到事件触发之后,将得到的信息(sid、candidate、offer、answer ...)传递给指定的客户端。

因为后端的部分实在是太过于简单,这里就不再贴代码了。

@p2p.chat/www 页面视图

前端页面的逻辑比信令服务器要复杂一些,涉及到 React 的状态传递和 WebRTC 的连接,项目使用 Recoil 作为状态管理库,不过不影响整体的代码理解,不过后边有大量的 Recoil 状态存取,有可能会看的比较晕。

项目 Nextjs 使用的还是 page router 的版本,入口从 _app.tsx 开始,项目中甚至绝大多数页面都是视图相关,没有核心逻辑处理,可以直接跳过。

如果你只想学习这个项目创建 WebRTC 连接进行音视频通话的部分,你可以直接跳转到核心逻辑部分,刨去状态的存取,可以照猫画虎的实现一个最基本的连接逻辑。

值得一提的是作者喜欢用 useCallback 包裹每一个函数,这似乎更像是一种负优化?

components/home/create-room.tsx

components/home/create-room.tsx 组件负责视频会议房间号和房间名的生成,代码逻辑非常简单,完全跳过也不影响,但是这里有一个房间号生成和校验逻辑很有意思,值得看看。完整的创建和校验逻辑放在文章结尾,这里只贴出组件代码。

tsx 复制代码
const [roomName, setRoomName] = React.useState("");

Input 监听 onInput 事件,事件回调是 handleChange

tsx 复制代码
const handleChange = React.useCallback((value: string) => {
    // slugify 用于将字符串中的空格和特殊字符替换为短横线,并将所有字母转换为小写字母。
    setRoomName(slugify(value));
}, []);
Typescript 复制代码
export const slugify = (text: string): string => {
  return text
    .replace(/[^-a-zA-Z0-9\s+]+/gi, "") // Remove all non-word chars
    .replace(/\s+/gi, "-") // Replace all spaces with dashes
    .replace(/--+/g, "-") // Replace multiple - with single -
    .toLowerCase();
};

回调将获取到的输入内容经过 slugify 函数处理,主要是将字符串中的空格和特殊字符替换为短横线,并将所有字母转换为小写字母。例如输入 "Hello World!",输出 "hello-world"。最后存储在组件的 roomName 状态中。

Button 监听 onClick 事件,事件回调是 submit

tsx 复制代码
const submit = React.useCallback(() => {
    // cleanSlug 用于清理字符串中的连字符(-),将字符串的开头和结尾的连字符去掉
  let cleanRoomName = cleanSlug(roomName);
    //  判断 cleanRoomName 是否为空,为空则调用 randomRoomName 生成随机字符
  cleanRoomName = cleanRoomName === "" ? randomRoomName() : cleanRoomName;
    // 最后将 cleanRoomName 作为参数传递调用 createRoomCode 函数
    // createRoomCode 的作用是根据传递的 cleanRoomName 和时间戳的后五位来生成一个唯一的 hash
  const roomCode = createRoomCode(cleanRoomName);
    // 触发页面跳转
  router.push(
    `/${roomCode}/${cleanRoomName}?created=true`,
    `/${roomCode}/${cleanRoomName}`
  );
}, [roomName, router]);
Typescript 复制代码
// cleanSlug 用于清理字符串中的连字符(-),将字符串的开头和结尾的连字符去掉
export const cleanSlug = (text: string): string => {
  return text
    .replace(/^-+/, "") // Trim - from start of text
    .replace(/-+$/, ""); // Trim - from end of text
};
Typescript 复制代码
export const randomRoomName = (): string => {
  return Math.random().toString(16).substr(2, 8);
};
Typescript 复制代码
export const createRoomCode = (roomName: string): string => {
    // 截取当前时间戳后五位
  const key = (+new Date()).toString(36).slice(-5);
    // 传递 roomName 和时间戳后五位生成 hash
  const hash = getRoomHash(key, roomName);
    // 拼接并返回
  return key + hash;
};
Typescript 复制代码
const getRoomHash = (key: string, roomName: string): string => {
    // 调用 shorthash 生成 hash
  return shorthash.unique(`${key}${roomName}`);
};

pages/[roomCode]/[roomCode].tsx

[roomCode].tsx 所负责的页面是视频会议的主要页面,也是前端视图部分的核心。

tsx 复制代码
const [local, setLocal] = useRecoilState(localState); // 初始状态为 "requestingName"
// 通过 useSetRecoilState 可以在页面不更新的情况下触发状态更新
const setPeers = useSetRecoilState(peersState);
const [room, setRoom] = useRecoilState(roomState); // 初始状态为 "loading"
const socketRef = React.useRef<Socket>(); // 用于存储 socket 实例

room 状态负责页面能否正常显示,它有三个状态分别是 loading、error 和 ready。只有当状态为 ready 时,页面才会进入后续对 local 状态判断的流程中去。

local 状态的不同让当前页面所显示的状态不同,他有着五个状态:requestingNamerequestingPermissionsrequestingDevicesconnectingconnected。分别对应输入用户名(RequestName 组件)、请求权限(RequestPermission 组件)、选择设备(RequestDevices 组件)、加载(Loading 组件)、接通(Call 组件)五个阶段。

除了以上状态,组件内还有三个副作用(useEffect):

第一个副作用用于 roomName 的校验:

tsx 复制代码
React.useEffect(() => {
    // useEffect 回调内是 IIFE 函数,便于写 async 代码
  (async () => {
        // 来自 Next router 的路由状态
    if (!router.isReady) {
      return;
    }
​
        // 从路由参数中取出 roomCode 和 roomName
    const roomCode = router.query.roomCode as string;
    const roomName = router.query.roomName as string;
​
    try {
            // validateRoom 用于验证 roomName 是否合法
      validateRoom(roomCode, roomName);
    } catch (err) {
            // 如果 validateRoom 验证失败会抛出异常
            // 异常将会通过使用 recoil 封装好的状态控制赋值给 roomState 状态
            // 然后触发 Room 组件的更新
​
      setRoom(roomActions.setError);
      return;
    }
​
        // 如果验证成功,roomState 状态会被设置为 ready
    setRoom(roomActions.setReady(roomName));
  })();
}, [router, setRoom]);

第二个函数负责清空副作用和边界情况:

tsx 复制代码
React.useEffect(() => {
  return () => {
    // Reset app state
    setPeers(defaultPeersState);
    setRoom(defaultRoomState);
    setLocal(defaultLocalState);
​
        // socketRef 在 createSocket 函数中会被赋值 socket 实例
        // 清空 socket 实例
    if (socketRef.current !== undefined) {
      socketRef.current.disconnect();
    }
​
        // 清空 streamMap 内的视频流
    streamMap.forEach((stream, key) => {
      stream?.getTracks().forEach((track) => {
        track.stop();
      });
      streamMap.delete(key);
    });
​
        // rtcDataChannelMap 保存的是 RTCDataChannel,在后面会讲到
        // 关闭所有的 RTCDataChannel
    rtcDataChannelMap.forEach((channel, sid) => {
      channel.close();
      rtcDataChannelMap.delete(sid);
    });
​
        // 关闭删除 rtcPeerConnectionMap 的所有 rtcPeerConnectionMap 连接 
    rtcPeerConnectionMap.forEach((rtcPeerConnection, sid) => {
      rtcPeerConnection.close();
      rtcPeerConnectionMap.delete(sid);
    });
  };
}, [setLocal, setPeers, setRoom]);

第三个副作用负责创建 WebSocket 连接

tsx 复制代码
React.useEffect(() => {
  (async () => {
    if (local.status === "connecting") {
      const roomCode = router.query.roomCode as string;
            // 调用 createSocket 创建 WebSocket 链接并监听事件
      createSocket(roomCode, local, socketRef, setLocal, setPeers);
    }
  })();
}, [local, router.query.roomCode, setLocal, setPeers]);

至此 [roomCode].tsx 已经结束了,它将可以触发自身更新的 dispatch 传递出去,后续将根据这些状态的变化更新页面显示或是执行不同的副作用。

components/room/request-permission.tsx

local.statusrequestingPermissions 时,Room 组件会渲染 RequestPermission 组件,这个组件负责创建和存储本地媒体流。

tsx 复制代码
const setLocal = useSetRecoilState(localState);

组件的核心是 requestPermissions 函数:

tsx 复制代码
const requestPermissions = React.useCallback(async () => {
    // createLocalStream 负责创建本地媒体流,然后返回创建的 MediaStream
  const stream = await createLocalStream();
    // 存储创建的 MediaStream 到 streamMap
  streamMap.set(LocalStreamKey, stream);
    // 更新状态让 Room 组件渲染下一个组件
  setLocal(localActions.setRequestingDevices);
}, [setLocal]);

components/room/request-devices.tsx

local.statusrequestingDevices 时,Room 组件会渲染 RequestDevices 组件,这个组件负责选择媒体设备,代码量较多。

tsx 复制代码
const setLocal = useSetRecoilState(localState);
const [devices, setDevices] = React.useState<Devices>();

先从 RequestDevices 组件的副作用开始:

tsx 复制代码
React.useEffect(() => {
  (async () => {
        // getDevices 会获取并返回当前设备上的可用音频和视频设备列表
    setDevices(await getDevices());
  })();
}, []);

获取到了音频和视频设备列表,视图中会根据列表渲染选择器,选择器监听 change 事件,并回调 handleAudioChange 函数和 handleVideoChange 函数:

tsx 复制代码
const handleAudioChange = React.useCallback(
  (deviceId: string | undefined) => {
        // handleDeviceChange 用于中断现有 MediaStream,并执行回调获取新 MediaStream 并存储
    handleDeviceChange(deviceId, async (devices: Devices) => {
            // 在可用设备列表中遍历查找选中的设备 ID
      const selectedAudio =
        devices.audio.find((device) => {
          return device.id === deviceId;
        }) ?? null;
            // 调用 createLocalStream 传入新的设备 ID 创建新的 MediaStream 
      const stream = await createLocalStream({
        audioDeviceId: selectedAudio?.id,
        videoDeviceId: devices.selectedVideo?.id,
      });
      setDevices({ ...devices, selectedAudio });
      return stream;
    });
  },
  [handleDeviceChange]
);

handleVideoChange 函数行为基本和 handleAudioChange 一致。

tsx 复制代码
const handleVideoChange = React.useCallback(
  (deviceId: string | undefined) => {
    handleDeviceChange(deviceId, async (devices: Devices) => {
      const selectedVideo =
        devices.video.find((device) => {
          return device.id === deviceId;
        }) ?? null;
      const stream = await createLocalStream({
        videoDeviceId: selectedVideo?.id,
        audioDeviceId: devices.selectedAudio?.id,
      });
      setDevices({ ...devices, selectedVideo });
      return stream;
    });
  },
  [handleDeviceChange]
);

然后是用于删除视频流和创建新视频流的 handleDeviceChange 函数:

tsx 复制代码
const handleDeviceChange = React.useCallback(
  async (
    deviceId: string | undefined,
    cb: (devices: Devices) => Promise<MediaStream | null>
  ) => {
    if (devices === undefined || deviceId === undefined) {
      return;
    }
​
    const stream = mapGet(streamMap, LocalStreamKey);
        // stopStream 用于删除中断现有 MediaStream
    stopStream(stream);
        // 调用回调获取新 MediaStream 并保存
    streamMap.set(LocalStreamKey, await cb(devices));
  },
  [devices]
);

最后是绑定在下一步操作按钮上的 joinRoom 函数:

tsx 复制代码
const joinRoom = React.useCallback(async () => {
  const stream = mapGet(streamMap, LocalStreamKey);
    // getVideoAudioEnabled 用于从 MediaStream 中获取音频和视频是否启用的状态。
  const { audioEnabled, videoEnabled } = getVideoAudioEnabled(stream);
    // 设置 local 状态
  setLocal(localActions.setConnecting(audioEnabled, videoEnabled));
}, [setLocal]);

components/room/call.tsx

local.statusconnecting 时,将触发 Room 组件的第三个 useEffect,这个副作用中将创建 WebSocket 连接,并设置监听事件,在监听的 connected 事件中的 onConnected 回调中,将会把状态设置为 connected,Room 渲染最后一个组件 Call。

Call 组件包含 components/room/grid.tsxcomponents/room/controls.tsx,分别负责媒体的显示和媒体的控制功能。

components/room/grid.tsx

Grid 组件的代码比较多,但是大部分代码实际上用于计算视频数量和容器大小,通过计算得出网格的行和列数,使用 react-resize-detector 库对元素大小进行监听。

这里项目作者使用了一些比较少见的做法:使用 useMemo 缓存组件

tsx 复制代码
const videos = React.useMemo<React.ReactElement[]>(() => {
  return [
        // videos 列表第一项是本地 MediaStream
    <LocalVideo key="local" />,
        // 通过遍历 peers 渲染远程 MediaStream
    ...peers.map((peer) => <PeerVideo key={peer.sid} peer={peer} />),
  ];
}, [peers]);

最后将 videos 渲染到页面上,完成了 MediaStream 的显示。

值得注意的是,在 LocalVideo 组件内部,使用了 assertlocal.status 进行状态检查,但是这种状态检查在出错时会直接抛出异常终端渲染,项目中也没有出现使用 ErrorBoundary 捕获错误,这似乎更像是一种错误?

tsx 复制代码
export default function LocalVideo() {
  const local = useRecoilValue(localState);
​
    // 错误后会直接中断渲染
  assert(local.status === "connecting" || local.status === "connected");
  const stream = mapGet(streamMap, LocalStreamKey);
  const { audioEnabled, videoEnabled } = getVideoAudioEnabled(stream);
​
  return (
    <GridVideo
      audioDisabled={!audioEnabled}
      local
      name={`${local.name} (You)`}
      stream={stream}
      videoDisabled={!videoEnabled}
    />
  );
}

components/room/controls.tsx

Controls 组件的代码也不少,但是重点的函数只有两个,它们是负责控制声音的 handleToggleAudio、负责控制视频的 handleToggleVideo

tsx 复制代码
const handleToggleAudio = React.useCallback(() => {
    // peers 存储的是当前存在的远端连接的信息,包含 sid、status、音视频状态
  peers.forEach((peer) => {
        // rtcDataChannelMap 存储由 RTCDataChannel.createDataChannel 创建的 RTCDataChannel
    const channel = rtcDataChannelMap.get(peer.sid);
​
    if (channel !== undefined) {
            // sendMessage 函数实际上调用 RTCDataChannel 实例的 send 方法发送第二个参数的 JSON
            // 这一步通过发送 JSON 格式的内容给远端实际上是为了更新试图中显示的远端控制状态
      sendMessage(channel, {
        type: "peer-state",
        name: local.name,
        audioEnabled: !audioEnabled,
        videoEnabled,
      });
    }
  });
​
    // 设置本地 MediaStream 的音频状态
  const audioTracks = stream?.getAudioTracks();
​
  if (audioTracks !== undefined && audioTracks.length > 0) {
    audioTracks[0].enabled = !audioEnabled;
  }
​
  setLocal(localActions.setAudioVideoEnabled(!audioEnabled, videoEnabled));
}, [audioEnabled, local.name, peers, setLocal, stream, videoEnabled]);

handleToggleAudiohandleToggleVideo 行为也是一致的。

tsx 复制代码
const handleToggleVideo = React.useCallback(() => {
  peers.forEach((peer) => {
    const channel = rtcDataChannelMap.get(peer.sid);
​
    if (channel !== undefined) {
      sendMessage(channel, {
        type: "peer-state",
        name: local.name,
        audioEnabled,
        videoEnabled: !videoEnabled,
      });
    }
  });
​
  const videoTracks = stream?.getVideoTracks();
​
  if (videoTracks !== undefined && videoTracks.length > 0) {
    videoTracks[0].enabled = !videoEnabled;
  }
​
  setLocal(localActions.setAudioVideoEnabled(audioEnabled, !videoEnabled));
}, [audioEnabled, local.name, peers, setLocal, stream, videoEnabled]);

函数执行时通过调用 getVideoAudioEnabled 传入 MediaStream 获取音频和视频的初始状态

tsx 复制代码
const { audioEnabled, videoEnabled } = getVideoAudioEnabled(stream);

至此视图层面部分比较核心的代码已经结束了,接下来是前端项目中比较核心逻辑处理部分。

@p2p.chat/www 核心逻辑

lib/mesh/websocket.ts

lib/mesh/websocket.ts 负责创建 socket 和监听事件并执行回调,可以说是这个项目中最核心的部分,因为这部分代码负责了整个项目的主要功能:WebRTC 连接的建立

createSocket 函数和后端部分的实现基本一样,也是作者喜欢的闭包保存参数的方式。

Typescript 复制代码
export const createSocket = async (
  roomCode: string, // roomCode 房间号,闭包保存
  local: Local, // 由 Recoil 管理的状态
  socketRef: React.MutableRefObject<Socket | undefined>, // React ref
  setLocal: SetLocal, // 用于更改 Recoil 中 local 状态
  setPeers: SetPeers // 用于更改 Recoil 中 peers 状态
): Promise<void> => {
  assert(process.env.NEXT_PUBLIC_SIGNALLING_URL !== undefined);
  const socket: Socket = io(process.env.NEXT_PUBLIC_SIGNALLING_URL);
​
  socketRef.current = socket;
​
  socket.on("connected", onConnected(socket, roomCode, setLocal));
  socket.on("peerConnect", onPeerConnect(socket, local, setPeers));
  socket.on("peerDisconnect", onPeerDisconnect(setPeers));
  socket.on("webRtcOffer", onWebRtcOffer(socket, local, setPeers));
  socket.on("webRtcAnswer", onWebRtcAnswer(socket));
  socket.on("webRtcIceCandidate", onWebRtcIceCandidate(setPeers));
};

在这里事件的触发需要区分已存在客户端和新加入客户端,后面会简称为发送方和加入方。因为有些事件只会在发送方被触发。

connected 在 WebSocket 连接成功后会触发,主要任务是加入房间和同步状态。

Typescript 复制代码
// onConnected 是发送方和加入方都会被触发的事件回调
const onConnected = (socket: Socket, roomCode: string, setLocal: SetLocal) => () => {
	console.debug(`connected`);
	socket.emit("joinRoom", roomCode);
	setLocal(localActions.setSocket);
};

peerConnect 在加入方进入房间时在发送方被触发,事件回调 onPeerConnect 也就是已经存在于房间内的客户端需要执行的任务。

Typescript 复制代码
// onPeerConnect 是仅会在发送方被触发的事件回调
const onPeerConnect =
  (socket: Socket, local: Local, setPeers: SetPeers) => async (sid: string) => {
        // sid 是经过 WebSocket 传递的加入方的 sid
    console.debug(`peerConnect sid=${sid}`);
​
        // 如果已经存有相同的 RTCPeerConnection 实例则不需要重复创建
    if (rtcPeerConnectionMap.get(sid)) {
      console.warn("Received connect from known peer");
      return;
    }
​
        // 调用 createRtcPeerConnection 创建 RTCPeerConnection 实例
    const rtcPeerConnection = createRtcPeerConnection(
      socket,
      local,
      sid,
      setPeers,
      true
    );
        // 存储创建的 RTCPeerConnection 实例
    rtcPeerConnectionMap.set(sid, rtcPeerConnection);
        // 调用 createOffer 创建 Offer
    const offerSdp = await rtcPeerConnection.createOffer();
        // 设置本地的会话描述(session description)
    rtcPeerConnection.setLocalDescription(offerSdp);
        // 更新状态
    setPeers(peersActions.addPeer(sid));
​
        // 向指定 sid 的加入方触发 webRtcOffer 事件
    socket.emit("webRtcOffer", { offerSdp, sid });
  };

webRtcOffer 会被加入发触发,事件回调 onWebRtcOffer 也就是新加入房间的客户端需要执行的任务。

由于这个项目的前端和后端中 socket 的事件命名是完全一样的,而且后端的事件回调基本上只做一件事情:向全部或者指定 sid 的客户端传递数据。所以直接从前端的事件触发顺序往下看也能够看明白。

Typescript 复制代码
// onWebRtcOffer 是仅会在加入方被触发的事件回调
const onWebRtcOffer =
  (socket: Socket, local: Local, setPeers: SetPeers) =>
  async ({ offerSdp, sid }: WebRtcOffer) => {
        // sid 是经过 WebSocket 传递的发送方的 sid
    console.debug(`webRtcOffer fromSid=${socket.id} toSid=${sid}`);
​
        // 调用 createRtcPeerConnection 创建 RTCPeerConnection 实例
    const rtcPeerConnection = createRtcPeerConnection(
      socket,
      local,
      sid,
      setPeers,
      false
    );
        // 存储创建的 RTCPeerConnection 实例
    rtcPeerConnectionMap.set(sid, rtcPeerConnection);
        // 更新状态
    setPeers(peersActions.addPeer(sid));
        // 设置远端的会话描述(session description)
    rtcPeerConnection.setRemoteDescription(new RTCSessionDescription(offerSdp));
        // 调用 createAnswer 创建 Answer
    const answerSdp = await rtcPeerConnection.createAnswer();
        // 设置本地的会话描述(session description)
    rtcPeerConnection.setLocalDescription(answerSdp);
​
        // 向指定 sid 的发送方触发 webRtcAnswer 事件
    socket.emit("webRtcAnswer", { answerSdp, sid });
  };

webRtcAnswer 会被发送方触发,事件回调 onWebRtcAnswer 发送方收到来自加入方发送的 Answer 之后需要执行的任务。

Typescript 复制代码
const onWebRtcAnswer = (socket: Socket) => (webRtcAnswer: WebRtcAnswer) => {
    // socket.id 是加入方也就是自己的 sid
    // webRtcAnswer.sid 是经过 WebSocket 传递的发送方的 sid
  console.debug(`webRtcAnswer fromSid=${socket.id} toSid=${webRtcAnswer.sid}`);
    // 取出在 onPeerConnect 中存入的 RTCPeerConnection 实例
  const rtcPeerConnection = mapGet(rtcPeerConnectionMap, webRtcAnswer.sid);
    // 设置远端的会话描述(session description)
  rtcPeerConnection.setRemoteDescription(
    new RTCSessionDescription(webRtcAnswer.answerSdp)
  );
};

webRtcAnswer 事件之后,WebRTC 连接就已经完成了基本的信息交换,接下来就是处理 ICE 候选者 (ICE candidate)的生成和收集,而这一步被封装在了 createRtcPeerConnection 函数中。

createRtcPeerConnection 函数内会用 RTCPeerConnection 监听 onicecandidate 事件,这里会触发 WebSocket 的 webRtcIceCandidate 事件。

webRtcAnswer 是发送方和接收方都会触发的事件,用于交换彼此的 candidate 信息。 Typescript

Typescript 复制代码
// onWebRtcIceCandidate 是发送方和加入方都会被触发的事件回调
const onWebRtcIceCandidate =
  (setPeers: SetPeers) => (webRtcIceCandidate: WebRtcIceCandidate) => {
        // WebRtcIceCandidate 是经由 WebSocket 传递的包含远端 sid 和远端 candidate 的对象
        // 取出 RTCPeerConnection 实例
    const rtcPeerConnection = mapGet(
      rtcPeerConnectionMap,
      webRtcIceCandidate.sid
    );
    console.debug(
      "received ice candidate",
      webRtcIceCandidate.candidate,
      rtcPeerConnection.iceConnectionState
    );
        // 调用 addIceCandidate 将 remote candidate 添加到 RTCPeerConnection 中
    rtcPeerConnection.addIceCandidate(
      new RTCIceCandidate({
        sdpMLineIndex: webRtcIceCandidate.label,
        candidate: webRtcIceCandidate.candidate,
      })
    );
​
        // 根据 rtcPeerConnection.iceConnectionState 状态更新 Recoil 状态
    if (
      rtcPeerConnection.iceConnectionState === "connected" ||
      rtcPeerConnection.iceConnectionState === "completed"
    ) {
      setPeers(peersActions.setPeerConnected(webRtcIceCandidate.sid));
    }
  };

到这里前端的 WebRTC 连接的建立就完成了。

lib/mesh/webrtc.ts

lib/mesh/webrtc.ts 中只有一个函数 createRtcPeerConnection,它主要负责创建 RTCPeerConnection 实例并返回。

Typescript 复制代码
const iceServers = {
  iceServers: [
    { urls: "stun:stun1.l.google.com:19302" },
    { urls: "stun:stun2.l.google.com:19302" },
  ],
};
​
export const createRtcPeerConnection = (
  socket: Socket<ServerEvents, ClientEvents>,
  local: Local,
  sid: string,
  setPeers: SetPeers,
  creator: boolean
): RTCPeerConnection => {
  assert(local.status === "connecting");
​
    // 创建 RTCPeerConnection 实例
  const rtcPeerConnection = new RTCPeerConnection(iceServers);
    // 取出本地 MediaStream
  const stream = mapGet(streamMap, LocalStreamKey);
​
    // 将本地的 MediaStream 轨道添加到 RTCPeerConnection 中
  stream?.getTracks().forEach((track) => {
    rtcPeerConnection.addTrack(track, stream);
  });
​
    // 获取远端发送来的 MediaStream 轨道
  rtcPeerConnection.ontrack = (e) => {
    if (e.streams.length > 0) {
      streamMap.set(sid, e.streams[0]);
    }
  };
​
    // 当RTCPeerConnection 收集到新的 ICE 候选者时,会触发 onicecandidate 回调
  rtcPeerConnection.onicecandidate = (e) => {
    console.debug(
      "ice candidate",
      e.candidate?.candidate,
      rtcPeerConnection.iceConnectionState
    );
    if (e.candidate !== null) {
            // 触发 webRtcIceCandidate 事件传递 candidate
      socket.emit("webRtcIceCandidate", {
        sid,
        label: e.candidate.sdpMLineIndex,
        candidate: e.candidate.candidate,
      });
    }
​
        // 根据 rtcPeerConnection.iceConnectionState 状态更新 Recoil 状态
    if (
      rtcPeerConnection.iceConnectionState === "connected" ||
      rtcPeerConnection.iceConnectionState === "completed"
    ) {
      setPeers(peersActions.setPeerConnected(sid));
    }
  };
​
    // creator 参数用于区别发送方和加入方
    // 在发送方会触发的 onPeerConnect 回调中 creator 会被传递为 true
    // 在加入方会触发的 onWebRtcOffer 回调中 creator 会被传递为 false
  if (creator) {
        // 如果是发送方调用函数,教会调用 createDataChannel 创建 RTCDataChannel 实例
    const channel = rtcPeerConnection.createDataChannel("data");
        // registerDataChannel 是封装好的用于向 RTCDataChannel 发送信息的函数
        // 在项目中主要负责向会议中的客户端广播音频和视频状态
    registerDataChannel(sid, channel, local, setPeers);
  } else {
        // 当远端创建 RTCDataChannel 后,会触发 ondatachannel 事件
    rtcPeerConnection.ondatachannel = (event) => {
            // 调用 registerDataChannel 方法
      registerDataChannel(sid, event.channel, local, setPeers);
    };
  }
​
  return rtcPeerConnection;
};

lib/mesh/data.ts

registerDataChannel 是封装好的用于向 RTCDataChannel 发送信息的函数。

Typescript 复制代码
export const registerDataChannel = (
  sid: string,
  channel: RTCDataChannel,
  local: Local,
  setPeers: SetPeers
): void => {
  assert(local.status !== "requestingName" && local.status !== "requestingPermissions");
​
    // 监听传入的 RTCDataChannel 实例的 onopen 事件
  channel.onopen = () => {
    const stream = mapGet(streamMap, LocalStreamKey);
        // getVideoAudioEnabled 负责获取传入 MediaStream 的音频和视频的状态
    const { audioEnabled, videoEnabled } = getVideoAudioEnabled(stream);
        // sendMessage 函数实际上是调用 RTCDataChannel 实例的 send 方法
        // 向 RTCDataChannel 广播音频和视频状态
    sendMessage(channel, {
      type: "peer-state",
      name: local.name,
      audioEnabled,
      videoEnabled,
    });
  };
​
    // 监听 RTCDataChannel 实例的 onmessage 事件
  channel.onmessage = function (event) {
    const message: Message = JSON.parse(event.data);
​
    switch (message.type) {
      case "peer-state": {
                // onPeerState 是封装好的更新 Recoil 状态的函数
        onPeerState(sid, message, setPeers);
      }
    }
  };
​
    // 存储 RTCDataChannel 实例
  rtcDataChannelMap.set(sid, channel);
};
Typescript 复制代码
export const sendMessage = (channel: RTCDataChannel, message: Message) => {
    // 调用 RTCDataChannel 实例的 send 方法
  channel.send(JSON.stringify(message));
};

lib/mesh/stream.ts

Typescript 复制代码
export const createLocalStream = async ({
  audioDeviceId,
  videoDeviceId,
}: {
  audioDeviceId?: string;
  videoDeviceId?: string;
} = {}): Promise<MediaStream | null> => {
    // 如果没有传递参数,则默认获取摄像头和麦克风权限
  const audio = audioDeviceId !== undefined ? { deviceId: audioDeviceId } : true;
  const video = videoDeviceId !== undefined ? { deviceId: videoDeviceId } : true;
​
    // 用 try-catch 穷举获取全部权限
    // getMediaStream 是 getUserMedia 函数的封装
  try {
    // Try and get video and audio
    return await getMediaStream({ video, audio });
  } catch (err) {
    console.error(err);
    try {
      // Try just audio
      return await getMediaStream({ audio });
    } catch (err) {
      console.error(err);
      try {
        // Try just video
        return await getMediaStream({ video });
      } catch (err) {
        console.error(err);
        // No stream
        return null;
      }
    }
  }
};
Typescript 复制代码
const getMediaStream = async (
  constraints: MediaStreamConstraints
): Promise<MediaStream> => {
  return navigator.mediaDevices.getUserMedia(constraints);
};

getVideoAudioEnabled 负责获取传入 MediaStream 的音频和视频的状态。

Typescript 复制代码
export const getVideoAudioEnabled = (
  stream: MediaStream | null
): { audioEnabled: boolean; videoEnabled: boolean } => {
  if (stream === null) {
    return { audioEnabled: false, videoEnabled: false };
  }
​
  const videoTracks = stream.getVideoTracks();
  const audioTracks = stream.getAudioTracks();
​
    // 检查状态
  const videoEnabled =
    videoTracks !== undefined &&
    videoTracks.length > 0 &&
    videoTracks[0].enabled;
  const audioEnabled =
    audioTracks !== undefined &&
    audioTracks.length > 0 &&
    audioTracks[0].enabled;
​
  return { audioEnabled, videoEnabled };
};

getDevices 会获取并返回当前设备上的可用音频和视频设备列表。

Typescript 复制代码
export const getDevices = async (): Promise<Devices> => {
  const devices: Devices = {
    audio: [],
    selectedAudio: null,
    video: [],
    selectedVideo: null,
  };
​
    // 遍历所有设备
  (await navigator.mediaDevices.enumerateDevices()).forEach((mediaDevice) => {
    // We can't see what the device is. This happens when you enumerate devices
    // without permission having being granted to access that type of device.
        // 忽略无法识别的设备
    if (mediaDevice.label === "") {
      return;
    }
​
    const device: Device = {
      id: mediaDevice.deviceId,
      name: mediaDevice.label,
    };
​
        // 处理音频
    if (mediaDevice.kind.toLowerCase().includes("audio")) {
      devices.audio.push(device);
​
            // 默认选择第一个音频设备
      if (devices.selectedAudio === null) {
        devices.selectedAudio = device;
      }
    }
​
        // 处理视频
    if (mediaDevice.kind.toLowerCase().includes("video")) {
      devices.video.push(device);
​
            // 默认选择第一个视频设备
      if (devices.selectedVideo === null) {
        devices.selectedVideo = device;
      }
    }
  });
​
  return devices;
};

值得记录

项目中有一个地方值得我学习一下,就是对于房间号的创建和验证,这两个实现函数在 [lib/rooms/room-encoding.ts](https://github.com/tom-james-watson/p2p.chat/blob/master/www/lib/rooms/room-encoding.ts) 文件下。

createRoomCode 函数会获取当前的事件戳后 5 位,然后根据这后 5 位生成一个 hash。

Typescript 复制代码
// createRoomCode 负责根据传入的 roomName 创建房间号
export const createRoomCode = (roomName: string): string => {
    // 截取时间戳后五位生成
  const key = (+new Date()).toString(36).slice(-5);
    // 传递 getRoomHash key 和 roomName 生成 hash
  const hash = getRoomHash(key, roomName);
    // 最后再拼接上时间戳后五位生成就是生成的 RoomCode
  return key + hash;
};

getRoomHash 函数是对 shorthash 库的封装

Typescript 复制代码
const getRoomHash = (key: string, roomName: string): string => {
  return shorthash.unique(`${key}${roomName}`);
};

validateRoom 用于校验房间号是否正确,其逻辑就是截取出 createRoomCode 函数中放置的时间戳后五位,再次重复 createRoomCode 逻辑,判断房间是否存在。

Typescript 复制代码
export const validateRoom = (roomCode: string, roomName: string): void => {
  try {
    const key = roomCode.substr(0, 5);
    const hash = roomCode.substr(5);
​
        // 重复生成
    const computedHash = getRoomHash(key, roomName);
​
        // 判断生成结果是否和 createRoomCode 的结果一致
    if (hash !== computedHash) {
      throw new Error("Bad room hash");
    }
  } catch (e) {
    console.error(e);
    throw new Error("Invalid room code");
  }
};

这种方式可以简单的验证一个值是否有效,在项目中用来确定一个房间号是否存在,很有意思。

相关推荐
沧澜sincerely1 小时前
WebSocket 实时聊天功能
网络·websocket·vue·springboot
刘孬孬沉迷学习2 小时前
WebRTC 协议
学习·5g·webrtc·信息与通信·信号处理
尼罗河女娲3 小时前
【获取WebSocket】使用 Playwright 监听 Selenium 自动化测试中的 WebSocket 消息(一)
websocket·网络协议·selenium
尼罗河女娲3 小时前
【获取WebSocket】使用 Playwright 监听 Selenium 自动化测试中的 WebSocket 消息(二)
websocket·网络协议·selenium
XHW___0014 小时前
鸿蒙webrtc编译
华为·webrtc·harmonyos
2501_9216494914 小时前
如何获取美股实时行情:Python 量化交易指南
开发语言·后端·python·websocket·金融
jinxinyuuuus18 小时前
局域网文件传输:WebRTC与“去中心化应用”的架构思想
架构·去中心化·webrtc
kkk_皮蛋1 天前
信令是什么?为什么 WebRTC 需要信令?
后端·asp.net·webrtc
破烂pan1 天前
Python 长连接实现方式全景解析
python·websocket·sse
真上帝的左手1 天前
15. 实时数据- SSE VS WebSocket
websocket