30、综合实战:构建一个完整的视频会议应用(含信令、SFU、录制、聊天)

终于到了这一步。说实话,前面二十九章我们拆解了WebRTC的各个零件,现在该把它们组装起来了。一个完整的视频会议系统,说白了就是信令、媒体传输、录制、聊天这四个模块的有机整合。我当年第一次做这类项目时,光是把信令和媒体流串起来就折腾了两天——嗯,踩过的坑今天都帮你标出来。

整体架构:四个模块如何协作

先看一张图,理清脉络。这个架构我画过很多版本,最终简化成下面这样:

视频会议应用架构图 客户端 A 摄像头/麦克风 聊天输入 录制控制 客户端 B 摄像头/麦克风 聊天输入 录制控制 信令服务器 (WebSocket) 房间管理 / SDP 交换 SFU 媒体服务器 转发 / 混流 / 录制 录制模块 (MP4) 本地/云端存储 聊天模块 (DataChannel) 文本 / 文件传输 信令 媒体流 信令 媒体流 客户端通过信令服务器建立连接,媒体流经 SFU 转发 聊天走 DataChannel,录制由 SFU 或客户端触发

你看,客户端A和B通过信令服务器交换SDP和ICE候选,媒体流则走SFU转发。聊天走DataChannel,录制可以由SFU侧做混流录制,也可以由客户端单独录制自己的流。我个人习惯把录制放在SFU侧,这样录出来的文件是完整的会议画面,省去后期拼接的麻烦。

第一步:信令服务器搭建

信令服务器是整个系统的"接线员"。我用Node.js + WebSocket实现,代码量不大,但要注意房间管理。

// server.js - 信令服务器核心
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

const rooms = new Map(); // 房间名 -> 用户列表

wss.on('connection', (ws) => {
  ws.on('message', (msg) => {
    const data = JSON.parse(msg);
    switch(data.type) {
      case 'join':
        handleJoin(ws, data.room, data.userId);
        break;
      case 'offer':
        relayToPeer(ws, data.targetId, data);
        break;
      case 'answer':
        relayToPeer(ws, data.targetId, data);
        break;
      case 'ice-candidate':
        relayToPeer(ws, data.targetId, data);
        break;
      case 'leave':
        handleLeave(ws, data.room);
        break;
    }
  });
});

function handleJoin(ws, room, userId) {
  if (!rooms.has(room)) rooms.set(room, new Map());
  rooms.get(room).set(userId, ws);
  // 通知房间内其他用户
  broadcast(room, { type: 'user-joined', userId }, ws);
}

这里有个细节:relayToPeer函数要过滤掉发送者自己,否则会形成回环。我曾经遇到过客户端收到自己发出的offer,然后死循环重连——排查了半天才发现是广播没排除自身。

第二步:SFU 媒体服务器集成

SFU我选的是 mediasoup,它性能好、文档全。核心逻辑是:每个客户端发布一个Producer,SFU转发给房间内其他消费者。

// sfu.js - mediasoup 核心逻辑
const mediasoup = require('mediasoup');

let worker, router;

async function createWorker() {
  worker = await mediasoup.createWorker();
  router = await worker.createRouter({
    mediaCodecs: [
      { kind: 'video', mimeType: 'video/VP8', clockRate: 90000 },
      { kind: 'audio', mimeType: 'audio/opus', clockRate: 48000 }
    ]
  });
}

async function createTransport() {
  return await router.createWebRtcTransport({
    listenIps: [{ ip: '0.0.0.0', announcedIp: 'your-public-ip' }],
    enableUdp: true,
    enableTcp: true
  });
}

async function produce(transport, kind, rtpParameters) {
  return await transport.produce({ kind, rtpParameters });
}

嗯,这里要注意announcedIp必须填公网IP,否则内网测试没问题,一上生产环境就黑屏。我有个朋友就栽在这上面,排查了三天。

第三步:录制功能实现

录制有两种思路:客户端录制和SFU侧录制。我推荐后者,因为SFU可以拿到所有参与者的纯净流,混录成单文件。

在 mediasoup 中,可以用router.createPlainTransport拉取RTP流,然后交给 FFmpeg 转码成MP4。

// recorder.js - SFU侧录制
const { spawn } = require('child_process');

async function startRecording(producer, outputPath) {
  const plainTransport = await router.createPlainTransport({
    comedia: true,
    rtcpMux: false
  });

  const consumer = await plainTransport.consume({
    producerId: producer.id,
    rtpCapabilities: router.rtpCapabilities
  });

  const ffmpeg = spawn('ffmpeg', [
    '-i', `rtp://${plainTransport.tuple.localIp}:${plainTransport.tuple.localPort}`,
    '-c:v', 'libx264',
    '-preset', 'ultrafast',
    '-f', 'mp4',
    outputPath
  ]);

  return { plainTransport, consumer, ffmpeg };
}

你想想看,如果每个客户端都自己录,录出来的文件时间轴对不上,后期剪辑会疯掉。统一在SFU侧录制,时间戳天然对齐。

第四步:聊天模块(DataChannel)

聊天不走信令服务器,而是通过WebRTC的DataChannel。这样做的好处是:聊天和媒体流走同一条加密通道,延迟低、安全性高。

// client.js - 客户端 DataChannel 聊天
const peerConnection = new RTCPeerConnection(config);
const dataChannel = peerConnection.createDataChannel('chat', {
  ordered: true
});

dataChannel.onopen = () => {
  console.log('聊天通道已建立');
};

dataChannel.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  displayMessage(msg.userId, msg.text, msg.timestamp);
};

function sendMessage(text) {
  const msg = {
    type: 'text',
    userId: localUserId,
    text: text,
    timestamp: Date.now()
  };
  dataChannel.send(JSON.stringify(msg));
}

这里有个坑:DataChannel的ordered: true保证消息顺序,但如果你传文件,建议用ordered: false配合maxRetransmits,否则大文件重传会阻塞后续消息。我踩过这个坑,当时群里发图片,文字消息卡了十几秒才出来。

第五步:整合与避坑指南

把上面四个模块串起来,流程是这样的:

  1. 用户加入房间 → 信令服务器通知所有人
  2. 新用户创建PeerConnection,发布音视频Producer
  3. SFU转发Producer给房间内其他用户
  4. DataChannel自动建立,聊天消息实时传递
  5. 主持人点击"录制" → SFU侧启动FFmpeg录制所有流
  6. 会议结束 → 停止录制,生成MP4文件
⚠️ 重要警告:
  • 信令服务器必须处理断线重连,否则用户掉线后无法恢复会话
  • SFU的announcedIp一定要配置正确,否则媒体流不通
  • 录制文件命名建议带上时间戳和房间ID,避免覆盖
  • DataChannel消息大小有限制(一般16KB),大文件要分片发送
💡 我的个人经验: 调试阶段可以用 Chrome 的 chrome://webrtc-internals 查看所有连接状态。我曾经靠这个工具发现ICE候选没收集全,原来是STUN服务器地址写错了。

效果预览与测试

启动服务后,打开两个浏览器标签页,加入同一个房间。你应该能看到:

  • 双方视频画面正常显示
  • 聊天消息实时送达
  • 点击录制后,服务器开始生成MP4文件
  • 会议结束后,录制文件可正常播放

如果视频黑屏,先检查控制台有没有ICE failed错误。如果有,八成是TURN服务器没配或者STUN地址不对。我刚开始做的时候,总以为代码有问题,后来发现是防火墙把UDP端口封了——换成TCP中继就好了。

好了,这个综合实战项目到这里就完成了。你想想看,从信令到SFU,从录制到聊天,其实每个模块都不复杂,关键是把它们串起来的时候,边界条件和异常处理要到位。代码写完了,多跑几遍,把各种极端情况都测一测——掉线、重连、多人同时进出房间,这些场景能帮你发现很多隐藏问题。


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