15、mediasoup实战入门:核心概念与第一个SFU Demo
好,咱们今天来聊点真家伙。前面讲了那么多WebRTC的理论,从信令到NAT穿透,从SDP协商到媒体流控制。但说实话,没有实际跑过一个SFU服务,你永远觉得自己在纸上谈兵。
我个人觉得,mediasoup是当前Node.js生态里最成熟的SFU方案。它不像Janus那么重,也不像Licode那么难配。说白了,它就是一个轻量级、高性能的SFU引擎,底层用C++跑,上层用Node.js控制。今天我们就把它拆开看看。
mediasoup的核心概念
在动手之前,咱们先把几个关键概念理清楚。我当年第一次看mediasoup文档时,就被Router、Transport、Producer、Consumer这几个词绕晕了。其实它们的关系很简单——
一句话总结:Router是会议室,Transport是门,Producer是说话的人,Consumer是听的人。
来,我们逐个拆解。
Router
Router是mediasoup中最顶层的概念。它代表一个独立的媒体路由空间。你可以把它理解成一个虚拟的会议室。每个Router内部有自己的RTP端口池、编解码器配置、带宽估计策略。
我在项目中遇到过一个问题:同一个进程里创建了多个Router,结果某个Router的配置错误导致所有Router都受影响。后来才发现,Router之间虽然是隔离的,但它们共享同一个Worker进程的资源。所以,每个Router的配置要独立检查。
// 创建Router的示例
const worker = await mediasoup.createWorker();
const router = await worker.createRouter({
mediaCodecs: [
{
kind: 'audio',
mimeType: 'audio/opus',
clockRate: 48000,
channels: 2
},
{
kind: 'video',
mimeType: 'video/VP8',
clockRate: 90000
}
]
});
Transport
Transport是客户端和Router之间的连接通道。它负责传输媒体数据。mediasoup支持两种Transport:
- WebRtcTransport:基于ICE/DTLS的标准WebRTC传输
- PlainTransport:纯RTP传输,适合与媒体服务器对接
嗯,这里要注意:每个客户端至少需要两个Transport——一个用于发送(Producer),一个用于接收(Consumer)。当然,你也可以复用同一个Transport,但我建议分开,方便后续做带宽控制和QoS。
我的习惯:每个客户端创建两个WebRtcTransport,一个叫sendTransport,一个叫recvTransport。命名清晰,排查问题也方便。
Producer
Producer就是媒体生产者。当客户端通过Transport向Router发送音视频流时,Router会创建一个Producer对象来管理这个流。
每个Producer对应一个媒体轨道。也就是说,如果你同时发送音频和视频,你会得到两个Producer——一个audio Producer,一个video Producer。
// 客户端创建Producer
const producer = await sendTransport.produce({
kind: 'video',
rtpParameters: {
codecs: [...],
encodings: [...]
}
});
Consumer
Consumer是媒体消费者。当其他客户端想要接收某个Producer的流时,Router会创建一个Consumer对象。
Consumer有一个很重要的特性:它可以做流的选择性转发。比如,你可以只接收视频流中的某一层(Simulcast场景),或者只接收音频流。这在带宽受限的场景下非常有用。
我曾经踩过的坑:Consumer创建后,如果不主动调用consumer.resume(),客户端是收不到数据的。因为mediasoup默认是暂停状态,需要你显式恢复。这个细节文档里有写,但很容易忽略。
核心关系图
下面这张图把上面四个概念的关系画清楚了。你看一眼就明白整个数据流是怎么走的。
搭建mediasoup开发环境
环境搭建这块,我踩过的坑比代码写的还多。mediasoup依赖一些原生模块,编译起来挺折腾的。下面是我总结的、最稳的步骤。
系统要求
| 组件 | 推荐版本 | 备注 |
|---|---|---|
| Node.js | 18.x 或 20.x | 不要用奇数版本,坑多 |
| Python | 3.8 - 3.11 | 编译node-gyp需要 |
| C++编译器 | GCC 10+ 或 MSVC 2022 | Windows上装Visual Studio Build Tools |
| 操作系统 | Ubuntu 22.04 / macOS 14+ | Windows也能跑,但生产环境建议Linux |
安装步骤
- 安装mediasoup
mkdir my-sfu-demo && cd my-sfu-demo
npm init -y
npm install mediasoup@3
安装过程会编译原生模块。如果卡在编译步骤,八成是Python或C++工具链的问题。我建议先跑一下 node-gyp rebuild 测试环境是否正常。
我曾经遇到的坑:在Windows上,mediasoup编译需要Python 3.x,但系统里装了多个Python版本。结果node-gyp死活找不到正确的Python路径。解决方案是设置环境变量:set PYTHON=C:\Python311\python.exe
- 安装信令服务器依赖
mediasoup本身不处理信令,我们需要自己搭一个简单的WebSocket信令服务。
npm install ws uuid
- 创建项目结构
my-sfu-demo/
├── server.js # mediasoup服务端
├── client.html # 浏览器客户端
├── package.json
└── node_modules/
创建第一个SFU Demo
好了,环境搭好了,概念也清楚了。咱们直接写代码。这个Demo实现的功能很简单:两个浏览器页面通过mediasoup进行音视频通话。
服务端代码(server.js)
const mediasoup = require('mediasoup');
const WebSocket = require('ws');
const { v4: uuidv4 } = require('uuid');
let worker, router;
const peers = new Map();
async function start() {
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 }
]
});
console.log('mediasoup Worker和Router已启动');
}
start();
// WebSocket信令服务
const wss = new WebSocket.Server({ port: 3000 });
wss.on('connection', (ws) => {
const peerId = uuidv4();
peers.set(peerId, { ws, transports: [], producers: [], consumers: [] });
ws.on('message', async (data) => {
const msg = JSON.parse(data);
switch (msg.type) {
case 'get-router-rtp-capabilities':
ws.send(JSON.stringify({
type: 'router-rtp-capabilities',
data: router.rtpCapabilities
}));
break;
case 'create-transport':
const transport = await router.createWebRtcTransport({
listenIps: [{ ip: '0.0.0.0', announcedIp: '127.0.0.1' }],
enableUdp: true,
enableTcp: true,
preferUdp: true
});
peers.get(peerId).transports.push(transport);
ws.send(JSON.stringify({
type: 'transport-created',
data: {
id: transport.id,
iceParameters: transport.iceParameters,
iceCandidates: transport.iceCandidates,
dtlsParameters: transport.dtlsParameters
}
}));
break;
case 'connect-transport':
const transportToConnect = peers.get(peerId).transports
.find(t => t.id === msg.data.transportId);
await transportToConnect.connect({ dtlsParameters: msg.data.dtlsParameters });
ws.send(JSON.stringify({ type: 'transport-connected' }));
break;
case 'produce':
const transportForProduce = peers.get(peerId).transports
.find(t => t.id === msg.data.transportId);
const producer = await transportForProduce.produce({
kind: msg.data.kind,
rtpParameters: msg.data.rtpParameters
});
peers.get(peerId).producers.push(producer);
// 通知其他peer有新的Producer
broadcastProducers(peerId, producer);
ws.send(JSON.stringify({
type: 'producer-created',
data: { id: producer.id }
}));
break;
case 'consume':
const transportForConsume = peers.get(peerId).transports
.find(t => t.id === msg.data.transportId);
const consumer = await transportForConsume.consume({
producerId: msg.data.producerId,
rtpCapabilities: msg.data.rtpCapabilities
});
peers.get(peerId).consumers.push(consumer);
ws.send(JSON.stringify({
type: 'consumer-created',
data: {
id: consumer.id,
producerId: consumer.producerId,
kind: consumer.kind,
rtpParameters: consumer.rtpParameters
}
}));
break;
}
});
});
function broadcastProducers(senderId, producer) {
for (const [peerId, peer] of peers) {
if (peerId !== senderId) {
peer.ws.send(JSON.stringify({
type: 'new-producer',
data: {
producerId: producer.id,
kind: producer.kind
}
}));
}
}
}
客户端代码(client.html)
<!DOCTYPE html>
<html>
<head>
<title>mediasoup Demo</title>
</head>
<body>
<h2>mediasoup SFU Demo</h2>
<button id="startBtn">开始通话</button>
<div>
<video id="localVideo" autoplay muted playsinline style="width:300px"></video>
<video id="remoteVideo" autoplay playsinline style="width:300px"></video>
</div>
<script src="https://unpkg.com/mediasoup-client@3/dist/mediasoup-client.min.js"></script>
<script>
let device, sendTransport, recvTransport;
let localStream, remoteStream;
const ws = new WebSocket('ws://localhost:3000');
document.getElementById('startBtn').onclick = async () => {
// 获取本地媒体
localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
document.getElementById('localVideo').srcObject = localStream;
// 创建mediasoup设备
device = new mediasoupClient.Device();
// 获取Router能力
ws.send(JSON.stringify({ type: 'get-router-rtp-capabilities' }));
ws.onmessage = async (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'router-rtp-capabilities') {
await device.load({ routerRtpCapabilities: msg.data });
await createTransports();
await produceMedia();
}
};
};
async function createTransports() {
// 创建发送Transport
ws.send(JSON.stringify({ type: 'create-transport' }));
// 创建接收Transport
ws.send(JSON.stringify({ type: 'create-transport' }));
}
async function produceMedia() {
// 发送音频
const audioTrack = localStream.getAudioTracks()[0];
const audioProducer = await sendTransport.produce({ track: audioTrack });
// 发送视频
const videoTrack = localStream.getVideoTracks()[0];
const videoProducer = await sendTransport.produce({ track: videoTrack });
}
</script>
</body>
</html>
我的建议:第一次跑Demo时,先用两个浏览器标签页测试。一个开client.html,另一个也开client.html。点击「开始通话」后,如果两个页面都能看到对方的画面,说明SFU跑通了。
运行与测试
node server.js
然后在浏览器打开 client.html。注意,因为用了 getUserMedia,需要用HTTPS或者localhost访问。
如果一切正常,你会看到本地视频画面,同时另一个标签页也会收到你的视频流。这就是最基础的SFU Demo了。
核心要点回顾:
- Router是媒体路由的核心,负责管理所有Transport
- Transport是客户端与Router之间的连接通道
- Producer负责发送媒体流,Consumer负责接收媒体流
- mediasoup的信令需要自己实现,通常用WebSocket
- 环境搭建时注意Python和C++编译器的版本兼容性
说实话,这个Demo虽然简单,但它包含了SFU最核心的流程。你把这个跑通了,后面加房间管理、加用户列表、加带宽自适应,都是在这个基础上做加法。
嗯,今天就先到这里。代码都在上面了,建议你亲手敲一遍。遇到问题别慌,mediasoup的日志很详细,打开 logLevel: 'debug' 看看,大部分问题都能找到线索。