WebRTC 两个浏览器直连,但连之前要互相通气:
  "我支持哪些编码格式" -> SDP
  "我的IP和端口是啥"   -> ICE Candidate

  信令服务器就是个传话筒,把这两样东西转交给对方。
  连上之后音视频直接P2P,信令服务器就没事了。

  A ---offer(SDP)------> 信令服务器 ----> B
  A <--answer(SDP)------ 信令服务器 <---- B
  A ---ICE candidate---> 信令服务器 ----> B
  A <--ICE candidate---- 信令服务器 <---- B
           ↓ 握手完成
  A <============ 直接P2P音视频,不过服务器 ============> B

  ---
  服务器

  <?php
  $rooms = new Swoole\Table(1 << 16);
  $rooms->column('room', Swoole\Table::TYPE_STRING, 32);
  $rooms->create();

  $server = new Swoole\WebSocket\Server('0.0.0.0', 9502);

  $server->on('open', function ($server, $req) use ($rooms) {
      preg_match('#/room/(\w+)#', $req->server['request_uri'], $m);
      $room = $m[1] ?? 'default';
      $rooms->set($req->fd, ['room' => $room]);

      // 告诉房间里已有的人:有新人进来了,你去发 offer 给他
      foreach ($rooms as $fd => $row) {
          if ($row['room'] === $room && $fd !== $req->fd && $server->isEstablished($fd)) {
              $server->push($fd, json_encode(['type' => 'peer_joined', 'peer' => $req->fd]));
          }
      }
  });

  $server->on('message', function ($server, $frame) use ($rooms) {
      $data = json_decode($frame->data, true);
      $data['from'] = $frame->fd; // 告诉接收方是谁发的
      $payload = json_encode($data);

      // offer/answer/ice 都指定了 to,点对点转发
      $to = (int)($data['to'] ?? 0);
      if ($to && $server->isEstablished($to)) {
          $server->push($to, $payload);
          return;
      }

      // 没指定 to 就广播给同房间其他人
      $room = $rooms->get($frame->fd)['room'];
      foreach ($rooms as $fd => $row) {
          if ($row['room'] === $room && $fd !== $frame->fd && $server->isEstablished($fd)) {
              $server->push($fd, $payload);
          }
      }
  });

  $server->on('close', function ($server, $fd) use ($rooms) {
      $room = $rooms->get($fd)['room'] ?? null;
      $rooms->del($fd);
      if (!$room) return;

      // 通知同房间其他人,这个人断了
      foreach ($rooms as $otherFd => $row) {
          if ($row['room'] === $room && $server->isEstablished($otherFd)) {
              $server->push($otherFd, json_encode(['type' => 'peer_left', 'peer' => $fd]));
          }
      }
  });

  $server->on('message', fn() => null);
  $server->start();

  ---
  前端

  <video id="local"  autoplay muted  style="width:45%"></video>
  <video id="remote" autoplay        style="width:45%"></video>

  <script>
  const ws = new WebSocket('ws://localhost:9502/room/888');
  const pc = new RTCPeerConnection({
      iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] // 用谷歌 STUN 获取公网 IP
  });
  let peerFd;

  // 收到信令消息,按类型处理
  ws.onmessage = async ({ data }) => {
      const msg = JSON.parse(data);

      if (msg.type === 'peer_joined') {
          // 有人进房间,我是先来的,我发 offer
          peerFd = msg.peer;
          const offer = await pc.createOffer();
          await pc.setLocalDescription(offer);
          ws.send(JSON.stringify({ type: 'offer', sdp: offer, to: peerFd }));
      }

      if (msg.type === 'offer') {
          // 我是后来的,收到 offer,回 answer
          peerFd = msg.from;
          await pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
          const answer = await pc.createAnswer();
          await pc.setLocalDescription(answer);
          ws.send(JSON.stringify({ type: 'answer', sdp: answer, to: peerFd }));
      }

      if (msg.type === 'answer') {
          await pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
      }

      if (msg.type === 'ice') {
          await pc.addIceCandidate(new RTCIceCandidate(msg.candidate));
      }

      if (msg.type === 'peer_left') {
          document.getElementById('remote').srcObject = null;
      }
  };

  // 收集到自己的 ICE candidate,发给对方
  pc.onicecandidate = ({ candidate }) => {
      if (candidate && peerFd) {
          ws.send(JSON.stringify({ type: 'ice', candidate, to: peerFd }));
      }
  };

  // 对方的视频流来了,塞进 video 标签
  pc.ontrack = ({ streams }) => {
      document.getElementById('remote').srcObject = streams[0];
  };

  // 开摄像头麦克风,加进 PeerConnection
  navigator.mediaDevices.getUserMedia({ video: true, audio: true }).then(stream => {
      document.getElementById('local').srcObject = stream;
      stream.getTracks().forEach(track => pc.addTrack(track, stream));
  });
  </script>

  ---
  解释

  SDP 是什么:一段文本,写着"我支持H264/VP8编码,采样率48000,我有一路视频一路音频"。createOffer() 生成自己的
  SDP,setRemoteDescription() 接收对方的 SDP,两边对照确认用哪个编解码器

  ICE Candidate 是什么:你可能有多个 IP(内网、外网、IPv6),每个 IP+端口组合就是一个
  candidate,浏览器收集完发给对方,对方挨个试,找到能通的那条路

  STUN 服务器干啥:你在 NAT 后面,不知道自己公网 IP 是啥。访问一下 STUN 服务器,它告诉你"你的公网出口是 1.2.3.4:5678",这个地址加进
   ICE candidate 里

  信令服务器连通之后就没用了:offer/answer/ice 交换完,浏览器直接打洞 P2P 连上。后续音视频数据不经过你的服务器,服务器压力为零

  peer_joined 谁发 offer:两个人进房间,必须有一个先发 offer。这里约定:先进来的人收到 peer_joined 通知,主动发 offer
  给新来的。新来的人等着收 offer 再回 answer

  data['from'] = $frame->fd:服务器转发消息时带上发送者 fd,接收方回消息时把这个 fd 填到 to
  字段,实现点对点转发,不广播给不相关的人

  ---
  测试

  php server.php

  # 浏览器开两个标签访问同一个 html 文件
  # 两个标签自动完成握手,看到对方的摄像头画面
  # 关一个标签,另一个收到 peer_left
Logo

邀请您加入社区

更多推荐