第30章:WebRTC项目实战——从零搭建一个视频会议系统

终于到了最后一章。说实话,我写到这里还挺感慨的。

前面29章,我们把WebRTC的各个模块拆开揉碎了讲。信令、媒体协商、ICE、数据通道……每一个知识点都像一块积木。现在,是时候把这些积木拼成一个真正能用的东西了。

这一章,我们要从零搭建一个完整的视频会议系统。不是Demo,是能部署上线的产品级项目。

需求分析:先搞清楚我们要做什么

做项目最怕什么?最怕需求没想清楚就开干。我以前带团队时吃过这个亏,代码写了一半,产品说“我们要加个屏幕共享”……嗯,那感觉,你懂的。

一个基础的视频会议系统,核心需求其实就这几条:

  • 多人音视频通话:至少支持4-6人同时开麦开摄像头
  • 屏幕共享:主讲人能分享自己的桌面或某个窗口
  • 文字聊天:会议内发送文本消息
  • 房间管理:创建房间、加入房间、离开房间
  • 基础控制:静音、关闭摄像头、切换布局

你可能会问:“要不要做录制?要不要做虚拟背景?”

我的建议是:第一版别加。先把核心链路跑通,功能可以后续迭代。我见过太多项目因为一开始想得太全,结果半年了连个能用的版本都没出来。

架构设计:画好图纸再动工

架构设计说白了就是两件事:谁负责什么,数据怎么流。

我们这个系统采用经典的SFU架构。为什么不用Mesh?因为Mesh在4人以上时带宽爆炸,我实测过,3个人还行,到第5个人时,有些用户的笔记本风扇就开始咆哮了。

下面这张图是我画的核心架构,你看一眼就明白了:

视频会议系统核心架构图 客户端 A 发送音视频 接收其他流 客户端 B 发送音视频 接收其他流 客户端 C 发送音视频 接收其他流 SFU 媒体服务器 接收所有上行流 选择性转发下行流 支持 Simulcast 信令服务器 房间管理 SDP 交换 ICE Candidate 转发 上行流 上行流 上行流 下行流 下行流 下行流 信令交互 上行流(客户端→SFU) 下行流(SFU→客户端) 信令交互

架构分三层:

  • 客户端层:浏览器或移动端,负责采集、渲染、用户交互
  • SFU媒体层:核心转发服务器,接收所有上行流,按需转发下行流
  • 信令层:管理房间、协调连接建立

关键设计决策:SFU 和信令服务器分开部署。为什么?因为信令是轻量级的,可以用 Node.js 搞定;而 SFU 对性能要求高,我推荐用 mediasoup 或 Janus。混在一起部署,出了问题你都不知道是信令挂了还是媒体转发崩了。

前后端实现:动手写代码

1. 信令服务器(Node.js + Socket.IO)

信令服务器其实不复杂,核心就是房间管理和消息转发。我直接给你看关键代码:

// server.js - 信令服务器核心逻辑
const io = require('socket.io')(server, {
  cors: { origin: '*' }
});

const rooms = new Map();

io.on('connection', (socket) => {
  console.log(`用户连接: ${socket.id}`);

  // 创建房间
  socket.on('create-room', (roomId) => {
    if (rooms.has(roomId)) {
      socket.emit('error', '房间已存在');
      return;
    }
    rooms.set(roomId, new Set([socket.id]));
    socket.join(roomId);
    socket.emit('room-created', { roomId });
  });

  // 加入房间
  socket.on('join-room', (roomId) => {
    if (!rooms.has(roomId)) {
      socket.emit('error', '房间不存在');
      return;
    }
    const room = rooms.get(roomId);
    room.add(socket.id);
    socket.join(roomId);
    
    // 通知房间内其他用户
    socket.to(roomId).emit('user-joined', { userId: socket.id });
    
    // 返回当前用户列表
    socket.emit('room-joined', { 
      roomId, 
      users: Array.from(room) 
    });
  });

  // 转发 SDP Offer
  socket.on('offer', (data) => {
    socket.to(data.target).emit('offer', {
      sdp: data.sdp,
      from: socket.id
    });
  });

  // 转发 SDP Answer
  socket.on('answer', (data) => {
    socket.to(data.target).emit('answer', {
      sdp: data.sdp,
      from: socket.id
    });
  });

  // 转发 ICE Candidate
  socket.on('ice-candidate', (data) => {
    socket.to(data.target).emit('ice-candidate', {
      candidate: data.candidate,
      from: socket.id
    });
  });

  // 断开连接
  socket.on('disconnect', () => {
    rooms.forEach((users, roomId) => {
      if (users.has(socket.id)) {
        users.delete(socket.id);
        socket.to(roomId).emit('user-left', { userId: socket.id });
        if (users.size === 0) rooms.delete(roomId);
      }
    });
  });
});

避坑指南:我曾经在生产环境中遇到一个问题——用户刷新页面后,房间里的其他人收不到“用户离开”事件。原因是浏览器刷新时 Socket.IO 断连有延迟。解决方案是在客户端加一个心跳检测,5秒没收到心跳就主动清理用户。

2. SFU 媒体服务器(mediasoup)

mediasoup 是我个人比较偏爱的 SFU 方案。它性能好,而且 API 设计得很干净。核心逻辑是这样的:

// mediasoup 核心配置
const mediasoup = require('mediasoup');

let worker;
let router;

async function createMediasoupWorker() {
  worker = await mediasoup.createWorker({
    logLevel: 'warn',
    rtcMinPort: 40000,
    rtcMaxPort: 49999
  });
  
  router = await worker.createRouter({
    mediaCodecs: [
      {
        kind: 'audio',
        mimeType: 'audio/opus',
        clockRate: 48000,
        channels: 2
      },
      {
        kind: 'video',
        mimeType: 'video/VP8',
        clockRate: 90000,
        parameters: {
          'x-google-start-bitrate': 1000
        }
      },
      {
        kind: 'video',
        mimeType: 'video/H264',
        clockRate: 90000,
        parameters: {
          'packetization-mode': 1,
          'profile-level-id': '42e01f',
          'level-asymmetry-allowed': 1
        }
      }
    ]
  });
  
  return router;
}

// 创建 WebRTC 传输
async function createWebRtcTransport(router) {
  const transport = await router.createWebRtcTransport({
    listenIps: [{ ip: '0.0.0.0', announcedIp: '你的公网IP' }],
    enableUdp: true,
    enableTcp: true,
    preferUdp: true,
    initialAvailableOutgoingBitrate: 1000000
  });
  
  return transport;
}

注意:mediasoup 的端口范围要开放。我遇到过最坑的一次是部署到阿里云,忘了开 UDP 端口范围,结果 ICE 连接死活建立不起来。排查了整整一下午,最后发现是安全组规则的问题。

3. 前端实现(React + WebRTC API)

前端这边,我用 React 来组织 UI。核心是 PeerConnection 的管理:

// useWebRTC.js - 自定义 Hook
import { useEffect, useRef, useState } from 'react';
import { io } from 'socket.io-client';

export function useWebRTC(roomId) {
  const socketRef = useRef(null);
  const peersRef = useRef({});
  const localStreamRef = useRef(null);
  const [remoteStreams, setRemoteStreams] = useState([]);

  // 获取本地媒体流
  async function startLocalStream() {
    try {
      const stream = await navigator.mediaDevices.getUserMedia({
        video: true,
        audio: true
      });
      localStreamRef.current = stream;
      return stream;
    } catch (err) {
      console.error('获取媒体设备失败:', err);
      // 降级处理:只获取音频
      const audioStream = await navigator.mediaDevices.getUserMedia({
        video: false,
        audio: true
      });
      return audioStream;
    }
  }

  // 创建 PeerConnection
  function createPeerConnection(userId) {
    const pc = new RTCPeerConnection({
      iceServers: [
        { urls: 'stun:stun.l.google.com:19302' },
        {
          urls: 'turn:你的turn服务器地址',
          username: '用户名',
          credential: '密码'
        }
      ]
    });

    // 添加本地流
    if (localStreamRef.current) {
      localStreamRef.current.getTracks().forEach(track => {
        pc.addTrack(track, localStreamRef.current);
      });
    }

    // 处理远程流
    pc.ontrack = (event) => {
      const remoteStream = event.streams[0];
      setRemoteStreams(prev => {
        const exists = prev.find(s => s.id === remoteStream.id);
        if (exists) return prev;
        return [...prev, remoteStream];
      });
    };

    // ICE 候选转发
    pc.onicecandidate = (event) => {
      if (event.candidate) {
        socketRef.current.emit('ice-candidate', {
          target: userId,
          candidate: event.candidate
        });
      }
    };

    peersRef.current[userId] = pc;
    return pc;
  }

  // 发起连接
  async function callUser(userId) {
    const pc = createPeerConnection(userId);
    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);
    
    socketRef.current.emit('offer', {
      target: userId,
      sdp: pc.localDescription
    });
  }

  // 响应连接
  async function handleOffer(data) {
    const pc = createPeerConnection(data.from);
    await pc.setRemoteDescription(new RTCSessionDescription(data.sdp));
    const answer = await pc.createAnswer();
    await pc.setLocalDescription(answer);
    
    socketRef.current.emit('answer', {
      target: data.from,
      sdp: pc.localDescription
    });
  }

  // 初始化
  useEffect(() => {
    socketRef.current = io('你的信令服务器地址');
    
    socketRef.current.on('user-joined', async ({ userId }) => {
      await callUser(userId);
    });
    
    socketRef.current.on('offer', handleOffer);
    
    socketRef.current.on('answer', async (data) => {
      const pc = peersRef.current[data.from];
      if (pc) {
        await pc.setRemoteDescription(new RTCSessionDescription(data.sdp));
      }
    });
    
    socketRef.current.on('ice-candidate', async (data) => {
      const pc = peersRef.current[data.from];
      if (pc) {
        await pc.addIceCandidate(new RTCIceCandidate(data.candidate));
      }
    });

    return () => {
      socketRef.current.disconnect();
      Object.values(peersRef.current).forEach(pc => pc.close());
    };
  }, [roomId]);

  return { startLocalStream, remoteStreams };
}

个人经验:处理 getUserMedia 失败时,千万别直接弹错误框。我见过太多用户第一次打开会议网站时,浏览器弹出摄像头权限请求,用户点了“拒绝”,然后页面就卡死了。正确的做法是降级——摄像头不行就只用麦克风,麦克风不行就纯文字聊天。用户体验优先。

部署上线:最后一公里

部署这块,我推荐用 Docker 容器化。原因很简单:环境一致,迁移方便。

这是我的 Docker Compose 配置:

# docker-compose.yml
version: '3.8'

services:
  # 信令服务器
  signaling:
    build: ./signaling
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
    restart: always

  # mediasoup SFU
  mediasoup:
    build: ./mediasoup
    ports:
      - "40000-49999:40000-49999/udp"
      - "4443:4443"
    environment:
      - MEDIASOUP_LISTEN_IP=0.0.0.0
      - MEDIASOUP_ANNOUNCED_IP=你的公网IP
    restart: always

  # Nginx 反向代理
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
      - ./ssl:/etc/nginx/ssl
    depends_on:
      - signaling
    restart: always

部署时要注意几个关键点:

  • HTTPS 必须:WebRTC 的 getUserMedia 和屏幕共享都要求安全上下文。没有 HTTPS,这些 API 直接罢工。
  • TURN 服务器:如果用户在公司内网或对称 NAT 后面,没有 TURN 服务器,连接大概率失败。我推荐用 coturn,开源且稳定。
  • 端口开放:mediasoup 的 UDP 端口范围要全部开放,别只开几个端口,否则并发一高就报错。

曾经踩过的坑:有一次部署上线后,发现部分用户能连上,部分用户连不上。排查了半天,发现是 TURN 服务器的证书过期了。浏览器在建立连接时,如果 TURN 服务器证书无效,会直接拒绝连接。所以,记得给 TURN 配好有效的 SSL 证书。

性能优化与监控

系统上线后,监控必须跟上。我一般会关注这几个指标:

指标 正常范围 告警阈值 说明
CPU 使用率 < 60% > 80% SFU 是 CPU 密集型
内存使用率 < 70% > 85% 每个 PeerConnection 约 50MB
丢包率 < 1% > 3% 网络质量核心指标
ICE 连接成功率 > 95% < 90% 低于 90% 说明 TURN 配置有问题
平均建连时间 < 3s > 5s 超过 5s 用户会感觉卡顿

监控工具我推荐用 Prometheus + Grafana。mediasoup 本身会暴露一些 metrics,直接接入就行。

核心总结:从零搭建一个视频会议系统,其实就是在做三件事——信令把大家拉到一个房间,SFU 把每个人的音视频流转发给其他人,前端把这一切包装成用户能理解的操作。技术本身不复杂,复杂的是把每个环节的异常情况都处理好。

好了,这一章的内容就到这里。30章走下来,我们从 WebRTC 的基础概念讲到了完整的项目实战。如果你能跟着把这一章的代码跑起来,那你已经具备了独立开发 WebRTC 应用的能力。

公众号:蓝海资料掘金营,微信deep3321