23、SFU实战:使用Mediasoup搭建SFU服务器、客户端连接SFU、发布/订阅流
说实话,WebRTC 的 SFU 方案里,Mediasoup 是我个人最偏爱的一个。为什么?因为它够轻、够快、够灵活。不像某些商业方案那样黑盒,也不像 Janus 那样笨重。今天我们就来真刀真枪地干一场——用 Mediasoup 搭一个 SFU 服务器,然后让客户端连上来,发布自己的音视频流,再订阅别人的流。
嗯,先别急着敲代码。我们得搞清楚一件事:Mediasoup 到底在做什么?
Mediasoup 的核心角色
说白了,Mediasoup 就是一个跑在 Node.js 上的 SFU 引擎。它不负责信令,不负责房间管理,不负责用户认证。它只做一件事:转发媒体流。
你想想看,这其实是个很聪明的设计。信令逻辑千变万化,每个业务场景都不一样。Mediasoup 把最核心的媒体转发做好,剩下的交给开发者自己实现。我在项目中遇到过好几次,团队想用某个全栈方案,结果被它的信令逻辑卡得死死的。换成 Mediasoup 之后,反而轻松多了。
- Router:一个独立的媒体路由空间,相当于一个“房间”
- Transport:客户端与 Router 之间的连接通道,分为 WebRTC Transport 和 Plain Transport
- Producer:客户端向 Router 发布的媒体流(音频或视频)
- Consumer:客户端从 Router 订阅的媒体流
我习惯把 Router 想象成一个会议室,Transport 是会议室的门,Producer 是你对着麦克风说话,Consumer 是你戴上耳机听别人说话。简单吧?
整体架构图
先画一张图,把整个流程串起来。这样你心里就有谱了。
这张图你看懂了吗?发布者把流推给 SFU,SFU 再分发给订阅者。中间的信令通道负责协调——谁发布了、谁想订阅、SDP 怎么交换,都走这里。
搭建 SFU 服务器
好,开始动手。先搭服务器端。我用的是 Mediasoup 3.x 版本,配合 Express 和 Socket.IO 做信令。
1. 安装依赖
npm init -y
npm install mediasoup express socket.io cors
这里有个坑,我提醒你一下:Mediasoup 的 native 模块需要编译,如果你的 Node.js 版本太新(比如 20+),可能会遇到编译失败。我建议用 Node.js 18 LTS,稳得很。
2. 创建 Mediasoup Worker 和 Router
const mediasoup = require('mediasoup');
let worker;
let router;
async function createWorker() {
worker = await mediasoup.createWorker({
logLevel: 'warn',
logTags: ['info', 'ice', 'dtls', 'rtp', 'srtp', 'rtcp'],
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/VP9',
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,
},
},
],
});
console.log('Worker and Router created');
}
createWorker();
注意看 rtcMinPort 和 rtcMaxPort,这是 Mediasoup 用来传输媒体数据的 UDP 端口范围。我在生产环境里一般开 20000 个端口,够用。另外,mediaCodecs 里我同时支持了 VP8、VP9 和 H264,这样客户端可以根据自己的硬件编码能力选择。
3. 创建 Transport 和 Producer
客户端连上来之后,第一步是创建 Transport。Transport 分两种:WebRtcTransport 用于浏览器端,PlainTransport 用于服务器直连。我们这里用 WebRtcTransport。
// 服务端:创建 WebRtcTransport
async function createWebRtcTransport(callback) {
const transport = await router.createWebRtcTransport({
listenIps: [
{
ip: '0.0.0.0',
announcedIp: '你的公网IP或域名',
},
],
enableUdp: true,
enableTcp: true,
preferUdp: true,
initialAvailableOutgoingBitrate: 1000000,
});
callback({
id: transport.id,
iceParameters: transport.iceParameters,
iceCandidates: transport.iceCandidates,
dtlsParameters: transport.dtlsParameters,
});
return transport;
}
这里有个关键点:announcedIp。如果你的服务器在内网,客户端在外网,必须填公网 IP 或域名,否则 ICE 协商会失败。我曾经在这上面栽过跟头,排查了半天才发现是 IP 没暴露出去。
客户端拿到 Transport 的 ICE 信息后,会创建本地的 PeerConnection,然后双方完成 DTLS 握手。握手成功后,客户端就可以发布流了:
// 服务端:接收 Producer
async function createProducer(transport, kind, rtpParameters) {
const producer = await transport.produce({
kind,
rtpParameters,
});
console.log(`Producer created: ${producer.id}, kind: ${kind}`);
return producer;
}
嗯,这里 rtpParameters 是客户端发过来的编码参数,Mediasoup 会自动验证是否与 Router 配置的 codec 匹配。不匹配会直接报错,省得你后面排查。
客户端连接与发布
客户端这边,我用的是浏览器原生 WebRTC API,配合 Socket.IO 做信令。
1. 连接信令服务器
const socket = io('https://你的服务器地址');
socket.on('connect', () => {
console.log('Connected to signaling server');
});
2. 请求创建 Transport
socket.emit('createTransport', async (transportData) => {
const pc = new RTCPeerConnection({
iceServers: [],
});
// 设置远端 ICE 信息
pc.setRemoteDescription(new RTCSessionDescription({
type: 'offer',
sdp: generateOfferSDP(transportData),
}));
// 创建本地 answer
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
// 发送 answer 给服务端
socket.emit('transport-connect', {
dtlsParameters: {
role: 'server',
fingerprints: pc.localDescription.sdp.match(/a=fingerprint:(\S+)/)[1],
},
});
});
说实话,这段代码我简化了。实际项目中,SDP 的生成和解析要复杂得多。不过 Mediasoup 官方提供了一个 mediasoup-client 库,帮你封装了这些细节。我强烈建议你用这个库,别自己手写 SDP 解析,太容易出错了。
3. 发布音视频流
// 获取本地媒体流
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true,
});
// 将 track 添加到 PeerConnection
stream.getTracks().forEach(track => {
const sender = pc.addTrack(track, stream);
});
// 通知服务端创建 Producer
socket.emit('produce', {
kind: 'video',
rtpParameters: getRtpParameters(pc), // 从 PC 中提取
}, (producerId) => {
console.log('Producer created:', producerId);
});
transport.on('dtlsstatechange') 事件,状态变为 'connected' 后再允许客户端发布。
订阅流
订阅的逻辑其实和发布是对称的。客户端告诉服务端“我想看谁的流”,服务端创建一个 Consumer,然后把 Consumer 的信息返回给客户端,客户端再把它加到 PeerConnection 里。
服务端创建 Consumer
async function createConsumer(transport, producerId, rtpCapabilities) {
// 检查客户端的 rtpCapabilities 是否与 Producer 兼容
if (!router.canConsume({ producerId, rtpCapabilities })) {
throw new Error('Cannot consume this producer');
}
const consumer = await transport.consume({
producerId,
rtpCapabilities,
paused: false,
});
return {
id: consumer.id,
producerId: consumer.producerId,
kind: consumer.kind,
rtpParameters: consumer.rtpParameters,
};
}
客户端接收 Consumer
socket.on('newConsumer', async (consumerData) => {
const { id, producerId, kind, rtpParameters } = consumerData;
// 将 Consumer 的 rtpParameters 应用到 PeerConnection
const receiver = pc.addTransceiver(kind, {
direction: 'recvonly',
});
// 设置远端描述
await pc.setRemoteDescription(new RTCSessionDescription({
type: 'offer',
sdp: generateConsumerSDP(rtpParameters),
}));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
socket.emit('consumer-resume', { id });
});
你可能会问:为什么 Consumer 创建后还要调用 consumer.resume()?嗯,这是因为 Mediasoup 默认创建的 Consumer 是 paused 状态,需要客户端显式调用 resume 才能开始接收数据。这么设计是为了让客户端准备好之后再拉流,避免丢包。
完整流程总结
| 步骤 | 客户端 | 服务端 (Mediasoup) |
|---|---|---|
| 1 | 连接信令服务器 | 创建 Worker 和 Router |
| 2 | 请求创建 Transport | 创建 WebRtcTransport,返回 ICE/DTLS 参数 |
| 3 | 建立 PeerConnection,完成 DTLS 握手 | 监听 DTLS 状态变化 |
| 4 | 采集音视频,添加 Track | 创建 Producer,开始接收媒体流 |
| 5 | 请求订阅某人的流 | 创建 Consumer,返回 rtpParameters |
| 6 | 将 Consumer 添加到 PeerConnection | 调用 consumer.resume(),开始推送 |
你看,整个流程其实就六步。说白了,Mediasoup 把最复杂的媒体转发逻辑都封装好了,你只需要关心信令的编排和 Transport 的生命周期管理。
好了,这一章的内容就到这里。Mediasoup 的 SFU 实战,核心就是理解 Transport、Producer、Consumer 这三个概念,然后把信令流程走通。代码量其实不大,但细节很多。你动手敲一遍,遇到问题再回来看,效果会更好。
公众号:蓝海资料掘金营,微信deep3321