第30章:WebRTC项目实战——从零搭建一个视频会议系统
终于到了最后一章。说实话,我写到这里还挺感慨的。
前面29章,我们把WebRTC的各个模块拆开揉碎了讲。信令、媒体协商、ICE、数据通道……每一个知识点都像一块积木。现在,是时候把这些积木拼成一个真正能用的东西了。
这一章,我们要从零搭建一个完整的视频会议系统。不是Demo,是能部署上线的产品级项目。
需求分析:先搞清楚我们要做什么
做项目最怕什么?最怕需求没想清楚就开干。我以前带团队时吃过这个亏,代码写了一半,产品说“我们要加个屏幕共享”……嗯,那感觉,你懂的。
一个基础的视频会议系统,核心需求其实就这几条:
- 多人音视频通话:至少支持4-6人同时开麦开摄像头
- 屏幕共享:主讲人能分享自己的桌面或某个窗口
- 文字聊天:会议内发送文本消息
- 房间管理:创建房间、加入房间、离开房间
- 基础控制:静音、关闭摄像头、切换布局
你可能会问:“要不要做录制?要不要做虚拟背景?”
我的建议是:第一版别加。先把核心链路跑通,功能可以后续迭代。我见过太多项目因为一开始想得太全,结果半年了连个能用的版本都没出来。
架构设计:画好图纸再动工
架构设计说白了就是两件事:谁负责什么,数据怎么流。
我们这个系统采用经典的SFU架构。为什么不用Mesh?因为Mesh在4人以上时带宽爆炸,我实测过,3个人还行,到第5个人时,有些用户的笔记本风扇就开始咆哮了。
下面这张图是我画的核心架构,你看一眼就明白了:
架构分三层:
- 客户端层:浏览器或移动端,负责采集、渲染、用户交互
- 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