30、综合实战:构建一个完整的视频会议应用(含信令、SFU、录制、聊天)
终于到了这一步。说实话,前面二十九章我们拆解了WebRTC的各个零件,现在该把它们组装起来了。一个完整的视频会议系统,说白了就是信令、媒体传输、录制、聊天这四个模块的有机整合。我当年第一次做这类项目时,光是把信令和媒体流串起来就折腾了两天——嗯,踩过的坑今天都帮你标出来。
整体架构:四个模块如何协作
先看一张图,理清脉络。这个架构我画过很多版本,最终简化成下面这样:
你看,客户端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,否则大文件重传会阻塞后续消息。我踩过这个坑,当时群里发图片,文字消息卡了十几秒才出来。
第五步:整合与避坑指南
把上面四个模块串起来,流程是这样的:
- 用户加入房间 → 信令服务器通知所有人
- 新用户创建PeerConnection,发布音视频Producer
- SFU转发Producer给房间内其他用户
- DataChannel自动建立,聊天消息实时传递
- 主持人点击"录制" → SFU侧启动FFmpeg录制所有流
- 会议结束 → 停止录制,生成MP4文件
- 信令服务器必须处理断线重连,否则用户掉线后无法恢复会话
- SFU的
announcedIp一定要配置正确,否则媒体流不通 - 录制文件命名建议带上时间戳和房间ID,避免覆盖
- DataChannel消息大小有限制(一般16KB),大文件要分片发送
chrome://webrtc-internals 查看所有连接状态。我曾经靠这个工具发现ICE候选没收集全,原来是STUN服务器地址写错了。
效果预览与测试
启动服务后,打开两个浏览器标签页,加入同一个房间。你应该能看到:
- 双方视频画面正常显示
- 聊天消息实时送达
- 点击录制后,服务器开始生成MP4文件
- 会议结束后,录制文件可正常播放
如果视频黑屏,先检查控制台有没有ICE failed错误。如果有,八成是TURN服务器没配或者STUN地址不对。我刚开始做的时候,总以为代码有问题,后来发现是防火墙把UDP端口封了——换成TCP中继就好了。
好了,这个综合实战项目到这里就完成了。你想想看,从信令到SFU,从录制到聊天,其实每个模块都不复杂,关键是把它们串起来的时候,边界条件和异常处理要到位。代码写完了,多跑几遍,把各种极端情况都测一测——掉线、重连、多人同时进出房间,这些场景能帮你发现很多隐藏问题。
公众号:蓝海资料掘金营,微信deep3321