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 是你戴上耳机听别人说话。简单吧?

整体架构图

先画一张图,把整个流程串起来。这样你心里就有谱了。

Mediasoup SFU 架构与发布/订阅流程 SFU 服务器 (Node.js) Mediasoup Worker Router (房间) Transport / Producer / Consumer 发布者客户端 (Producer) 采集 → 编码 → 发送 订阅者客户端 (Consumer) 接收 → 解码 → 渲染 发布流 (Producer) 订阅流 (Consumer) 信令通道 (WebSocket / HTTP)

这张图你看懂了吗?发布者把流推给 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();

注意看 rtcMinPortrtcMaxPort,这是 Mediasoup 用来传输媒体数据的 UDP 端口范围。我在生产环境里一般开 20000 个端口,够用。另外,mediaCodecs 里我同时支持了 VP8、VP9 和 H264,这样客户端可以根据自己的硬件编码能力选择。

小技巧: 如果你只做 Web 端,VP8 就够了,兼容性最好。如果涉及移动端或硬件编码,H264 是必须的。VP9 嘛,画质好但编码慢,看场景取舍。

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 的 DTLS 握手已经完成。否则 Producer 创建会失败。我建议在服务端监听 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 的生命周期管理。

避坑指南: 我曾经在生产环境中遇到一个问题:客户端频繁断连重连,导致 Transport 没有被正确关闭,端口被占满。后来我加了一个心跳机制,客户端 30 秒不发心跳就自动关闭 Transport,问题就解决了。建议你也加上。

好了,这一章的内容就到这里。Mediasoup 的 SFU 实战,核心就是理解 Transport、Producer、Consumer 这三个概念,然后把信令流程走通。代码量其实不大,但细节很多。你动手敲一遍,遇到问题再回来看,效果会更好。


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