30、项目实战:从零搭建多人视频会议系统

说实话,走到这一章,前面那些理论、协议、信令、NAT穿透,终于要拧成一股绳了。

我经常被问到:“WebRTC学了一大堆,到底怎么落地?”

嗯,今天我们就亲手搭一个多人视频会议系统。从需求分析开始,到架构设计,再到编码实现,最后部署上线。一条龙走完。

30.1 需求分析:我们要做什么?

先别急着写代码。我有个习惯——动手前,先想清楚“到底要解决什么问题”。

多人视频会议,核心需求其实就几条:

  • 多人实时音视频通话:至少支持4-6人同时开摄像头、麦克风。
  • 屏幕共享:演示PPT、代码、设计稿。
  • 文字聊天:边开会边发消息,不打断发言。
  • 房间管理:创建房间、加入房间、离开房间。
  • 基础控制:静音、关闭摄像头、切换布局。

你想想看,这些需求其实对应着WebRTC的几个核心模块:

需求 技术对应
多人音视频 WebRTC PeerConnection + SFU/Mesh
屏幕共享 getDisplayMedia API
文字聊天 WebSocket / DataChannel
房间管理 信令服务器 + 房间状态维护
控制功能 MediaStreamTrack.enabled 切换

我个人建议,第一版不要贪多。先把核心通话跑通,再慢慢加功能。

30.2 架构设计:选Mesh还是SFU?

这是架构设计里最关键的决策点。

我简单解释一下:

  • Mesh架构:每个客户端直接跟其他所有客户端建立P2P连接。4个人开会,每人要建3个连接。8个人?每人7个连接。上行带宽爆炸。
  • SFU架构:客户端只跟服务器建一个连接。服务器负责转发音视频流。客户端只发一路,收多路。

我在早期一个项目里试过Mesh方案,4个人还行,加到6个人就开始卡顿、掉帧。后来全改成SFU了。

所以,我们选SFU。

SFU服务器我们选用 mediasoup。为什么?

  • 纯Node.js实现,部署简单
  • 性能好,支持几百路转发
  • 文档清晰,社区活跃

整体架构图如下:

多人视频会议系统架构图(SFU模式) 客户端 A 摄像头/麦克风/屏幕 客户端 B 摄像头/麦克风/屏幕 客户端 C 摄像头/麦克风/屏幕 SFU 服务器 mediasoup 转发音视频流 信令服务器 WebSocket 房间管理/信令交换 音视频流 信令 每个客户端只与SFU建立一条连接,SFU负责转发

30.3 编码实现:核心模块拆解

好,架构定下来了。我们开始编码。

整个项目分成三块:

  1. 信令服务器(Node.js + WebSocket)
  2. SFU媒体服务器(Node.js + mediasoup)
  3. 客户端(React + WebRTC API)

30.3.1 信令服务器

信令服务器说白了就是个“中间人”。它不碰音视频数据,只负责传递控制信息。

// server/signaling.js
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 3001 });

const rooms = new Map(); // 房间管理

wss.on('connection', (ws) => {
  ws.on('message', (message) => {
    const data = JSON.parse(message);
    
    switch(data.type) {
      case 'join-room':
        handleJoinRoom(ws, data.roomId, data.userId);
        break;
      case 'offer':
        relayToRoom(data.roomId, data, ws);
        break;
      case 'answer':
        relayToRoom(data.roomId, data, ws);
        break;
      case 'ice-candidate':
        relayToRoom(data.roomId, data, ws);
        break;
      case 'leave-room':
        handleLeaveRoom(ws, data.roomId);
        break;
    }
  });
});

嗯,这里要注意:信令服务器必须维护好房间内所有用户的连接列表。我曾经踩过一个坑——用户断线重连后,房间状态没清理干净,导致别人收到一堆无效的ICE候选。

30.3.2 SFU媒体服务器

mediasoup的使用其实不复杂。核心就三步:

  1. 创建Worker(一个Worker就是一个CPU核心)
  2. 创建Router(一个Router对应一个房间)
  3. 创建Producer/Consumer(生产者和消费者)
// server/mediasoup.js
const mediasoup = require('mediasoup');

let worker;
let router;

async function createWorker() {
  worker = await mediasoup.createWorker({
    logLevel: 'warn',
    rtcMinPort: 40000,
    rtcMaxPort: 49999,
  });
  
  router = await worker.createRouter({
    mediaCodecs: [
      {
        kind: 'video',
        mimeType: 'video/VP8',
        clockRate: 90000,
        parameters: {},
      },
      {
        kind: 'audio',
        mimeType: 'audio/opus',
        clockRate: 48000,
        channels: 2,
      },
    ],
  });
  
  return router;
}

我个人的习惯是,每个房间创建一个独立的Router。这样隔离性好,一个房间出问题不会影响其他房间。

30.3.3 客户端实现

客户端这边,主要工作是:

  • 获取本地媒体流(getUserMedia)
  • 创建PeerConnection
  • 通过信令服务器交换SDP和ICE
  • 把远程流渲染到video标签
// client/webrtc.js
async function startCall(roomId) {
  // 1. 获取本地流
  const localStream = await navigator.mediaDevices.getUserMedia({
    video: true,
    audio: true,
  });
  
  // 2. 创建PeerConnection
  const pc = new RTCPeerConnection({
    iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
  });
  
  // 3. 添加本地流
  localStream.getTracks().forEach(track => {
    pc.addTrack(track, localStream);
  });
  
  // 4. 监听远程流
  pc.ontrack = (event) => {
    const remoteVideo = document.getElementById('remote-video');
    remoteVideo.srcObject = event.streams[0];
  };
  
  // 5. 创建Offer
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
  
  // 6. 通过信令发送Offer
  signaling.send({
    type: 'offer',
    roomId: roomId,
    sdp: offer,
  });
}
小技巧: 调试阶段可以把ICE候选日志打开,看看是不是卡在“连通性检查”这一步。很多时候连不上,就是STUN/TURN配置的问题。

30.4 部署上线:从本地到云端

代码写完了,怎么让全世界的人都能用?

我推荐用 Docker + Nginx + Let's Encrypt 这套组合拳。

  1. Docker化:把信令服务器、SFU服务器、前端静态文件分别打包成镜像。
  2. Nginx反向代理:统一入口,处理HTTPS和WebSocket升级。
  3. Let's Encrypt:免费SSL证书。WebRTC强制要求HTTPS,这个不能省。
# nginx.conf
server {
    listen 443 ssl;
    server_name meeting.example.com;
    
    ssl_certificate /etc/letsencrypt/live/meeting.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/meeting.example.com/privkey.pem;
    
    # 前端静态文件
    location / {
        root /usr/share/nginx/html;
        index index.html;
    }
    
    # WebSocket信令
    location /ws {
        proxy_pass http://signaling:3001;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
    
    # mediasoup的UDP端口
    location /mediasoup {
        proxy_pass http://mediasoup:3002;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}
注意: mediasoup使用UDP端口传输媒体数据。云服务器安全组一定要开放UDP端口范围(比如40000-49999)。我见过有人折腾半天连不上,最后发现是安全组只开了TCP。

30.5 避坑指南:我踩过的那些坑

  • ICE候选不完整:我曾经在部署时忘了配置TURN服务器,结果有些用户死活连不上。后来加了coturn,问题解决。
  • 音频回声:多人会议最烦的就是回声。记得在客户端启用回声消除:audio: { echoCancellation: true }
  • 视频布局:4个人以上,全铺开太乱。我建议用“演讲者模式”——谁说话谁放大。
  • 带宽控制:默认情况下WebRTC会尽量用满带宽。在SFU端做一下码率限制,避免某个用户把服务器带宽吃光。

好了,到这里,一个完整的多人视频会议系统就搭起来了。

从需求分析到架构设计,从编码实现到部署上线,每一步都有坑,但也每一步都有收获。

你想想看,当你在浏览器里打开页面,看到自己和几个朋友的脸同时出现在屏幕上,那种感觉——嗯,挺爽的。


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