19、SFU客户端集成:连接mediasoup服务器、发布本地流(Producer)、订阅远端流(Consumer)、房间管理逻辑

好,咱们今天聊点硬核的。前面几章我们把WebRTC的底层原理、信令交互、媒体协商都捋了一遍,现在终于到了真正动手集成SFU客户端的环节了。我选的是mediasoup,这个库在业界口碑不错,我自己在几个直播项目中都用过,稳定性和灵活性都经得起考验。

说白了,这一章就是教你:怎么让浏览器跟mediasoup服务器握手,怎么把自己的摄像头麦克风推上去,怎么拉别人的流下来,以及怎么管理一个房间里的所有参与者。嗯,咱们一步步来。

19.1 连接mediasoup服务器:从握手到就绪

客户端要跟mediasoup通信,得先过两关:第一关是WebSocket信令连接,第二关是获取服务器的能力信息。我刚开始做的时候,以为直接连上WebSocket就能推流了,结果发现还得先问服务器要一份「设备能力清单」——说白了就是服务器支持哪些编解码器、哪些传输参数。

连接流程大致是这样的:

  1. 客户端通过WebSocket连接到信令服务器
  2. 发送一个 getRouterRtpCapabilities 请求
  3. 服务器返回它支持的RTP能力(codecs、header extensions等)
  4. 客户端用这些能力来创建本地的 device 对象

代码示例:

// 创建mediasoup客户端Device
const device = new mediasoupClient.Device();

// 通过WebSocket获取服务器的RTP能力
const routerRtpCapabilities = await sendRequest('getRouterRtpCapabilities');

// 加载到device中
await device.load({ routerRtpCapabilities });

console.log('Device loaded successfully:', device.loaded);
// 输出: true
我的经验:这里有个坑——device.load() 必须在创建任何Transport之前调用。我曾经在项目里把顺序搞反了,结果折腾了半天才发现是加载顺序的问题。记住:先load,后create。

19.2 发布本地流:创建Producer

设备加载成功后,下一步就是把自己的音视频流推上去。mediasoup里管这个叫 Producer。要创建Producer,得先有一个 SendTransport——你可以把它理解成一条从客户端到服务器的上行通道。

流程如下:

  1. 客户端请求服务器创建一个 WebRtcTransport(用于发送)
  2. 服务器返回 iceParametersdtlsParametersiceCandidates
  3. 客户端用这些参数创建本地的 SendTransport
  4. 通过这个Transport,调用 transport.produce() 发布音视频轨

代码示例:

// 请求服务器创建发送Transport
const transportInfo = await sendRequest('createWebRtcTransport', {
  sctpCapabilities: device.sctpCapabilities,
  producing: true,
  consuming: false
});

// 创建本地SendTransport
const sendTransport = device.createSendTransport(transportInfo);

// 监听connect事件——用于建立DTLS连接
sendTransport.on('connect', async ({ dtlsParameters }, callback, errback) => {
  try {
    await sendRequest('connectTransport', {
      transportId: sendTransport.id,
      dtlsParameters
    });
    callback();
  } catch (error) {
    errback(error);
  }
});

// 监听produce事件——用于通知服务器创建Producer
sendTransport.on('produce', async ({ kind, rtpParameters }, callback, errback) => {
  try {
    const { id } = await sendRequest('produce', {
      transportId: sendTransport.id,
      kind,
      rtpParameters
    });
    callback({ id });
  } catch (error) {
    errback(error);
  }
});

// 获取本地媒体流
const stream = await navigator.mediaDevices.getUserMedia({
  video: true,
  audio: true
});

// 发布视频轨
const videoProducer = await sendTransport.produce({
  track: stream.getVideoTracks()[0]
});

// 发布音频轨
const audioProducer = await sendTransport.produce({
  track: stream.getAudioTracks()[0]
});

console.log('Producers created:', videoProducer.id, audioProducer.id);
注意: connectproduce 这两个事件必须处理,否则Transport无法正常工作。我曾经见过有人只处理了 connect 忘了 produce,结果视频轨一直发不上去,排查了半天才发现是回调没写。

19.3 订阅远端流:创建Consumer

别人推了流,你得能拉下来看。mediasoup里拉流对应的是 Consumer。要创建Consumer,需要先有一个 RecvTransport(下行通道)。

订阅流程:

  1. 客户端请求服务器创建另一个 WebRtcTransport(用于接收)
  2. 服务器返回参数,客户端创建 RecvTransport
  3. 服务器通知客户端「有新流来了」,客户端调用 transport.consume()
  4. 把拿到的远端媒体轨添加到 <video><audio> 元素上播放

代码示例:

// 请求服务器创建接收Transport
const recvTransportInfo = await sendRequest('createWebRtcTransport', {
  sctpCapabilities: device.sctpCapabilities,
  producing: false,
  consuming: true
});

// 创建本地RecvTransport
const recvTransport = device.createRecvTransport(recvTransportInfo);

// 同样要处理connect事件
recvTransport.on('connect', async ({ dtlsParameters }, callback, errback) => {
  try {
    await sendRequest('connectTransport', {
      transportId: recvTransport.id,
      dtlsParameters
    });
    callback();
  } catch (error) {
    errback(error);
  }
});

// 当服务器通知有新消费者时
async function consumeRemoteStream(producerId, rtpCapabilities) {
  const consumer = await recvTransport.consume({
    producerId,
    rtpCapabilities,
    paused: false
  });

  // 把远端流挂到video元素上
  const remoteVideo = document.getElementById('remote-video');
  remoteVideo.srcObject = new MediaStream([consumer.track]);

  // 监听track结束事件
  consumer.on('trackended', () => {
    console.log('Remote track ended');
  });

  return consumer;
}
避坑指南:我曾经在消费远端流时,忘了检查 consumer.kindvideo 还是 audio,结果把音频轨当视频轨往video元素上挂,页面直接报错。所以建议你拿到consumer后先判断一下类型。

19.4 房间管理逻辑:谁进谁出,一目了然

一个房间里通常有多个参与者。你需要管理:谁加入了、谁离开了、谁发布了新流、谁停止了推流。mediasoup本身不提供房间管理,这部分得我们自己实现。

我一般会在信令服务器上维护一个房间状态,客户端通过信令事件来同步。核心数据结构大概是这样:

// 房间状态(服务端维护)
const roomState = {
  roomId: 'room-001',
  peers: {
    'peer-1': {
      id: 'peer-1',
      displayName: '张三',
      producers: {
        'video-producer-id': { kind: 'video' },
        'audio-producer-id': { kind: 'audio' }
      }
    },
    'peer-2': {
      id: 'peer-2',
      displayName: '李四',
      producers: {
        'video-producer-id': { kind: 'video' }
      }
    }
  }
};

客户端需要监听以下几个关键信令事件:

信令事件 触发时机 客户端处理逻辑
newPeer 有新用户加入房间 更新参与者列表UI,记录peer信息
peerClosed 有用户离开房间 移除该用户的视频流,更新UI
newProducer 某用户发布了新流 调用 consumeRemoteStream() 订阅
producerClosed 某用户停止了推流 关闭对应的Consumer,移除视频元素

代码示例——房间管理类:

class RoomClient {
  constructor(roomId, displayName) {
    this.roomId = roomId;
    this.displayName = displayName;
    this.device = new mediasoupClient.Device();
    this.sendTransport = null;
    this.recvTransport = null;
    this.producers = new Map();
    this.consumers = new Map();
    this.peers = new Map();
  }

  async join() {
    // 1. 连接信令
    await this.connectSignaling();
    // 2. 加载device
    await this.loadDevice();
    // 3. 创建Transport
    await this.createTransports();
    // 4. 通知服务器我加入了
    await sendRequest('joinRoom', {
      roomId: this.roomId,
      displayName: this.displayName
    });
  }

  async publish(track) {
    const producer = await this.sendTransport.produce({ track });
    this.producers.set(producer.id, producer);
    return producer;
  }

  async subscribe(producerId) {
    const consumer = await this.recvTransport.consume({
      producerId,
      rtpCapabilities: this.device.rtpCapabilities
    });
    this.consumers.set(consumer.id, consumer);
    return consumer;
  }

  leave() {
    // 清理所有Producer和Consumer
    this.producers.forEach(p => p.close());
    this.consumers.forEach(c => c.close());
    this.sendTransport.close();
    this.recvTransport.close();
    sendRequest('leaveRoom', { roomId: this.roomId });
  }
}
核心要点:房间管理的本质就是「状态同步」。服务器维护真实状态,客户端通过信令事件来镜像这个状态。不要试图在客户端做太多逻辑判断,一切以服务器下发的通知为准。

19.5 整体架构图

下面这张图展示了客户端与mediasoup服务器之间的完整交互流程,从连接到发布、订阅,再到房间管理:

SFU客户端集成架构图 客户端(浏览器) mediasoup服务器 mediasoup-client Device SendTransport(上行通道) RecvTransport(下行通道) Producer / Consumer 管理 房间状态同步(Peer管理) Router(路由核心) WebRtcTransport(服务端) Producer(接收端) Consumer(发送端) 房间状态管理(Room) WebSocket信令 RTP媒体流(Producer→) RTP媒体流(←Consumer) 房间状态同步(双向)

这张图把整个流程串起来了。你看,左边是客户端,右边是服务器,中间通过WebSocket做信令控制,通过RTP传音视频数据。房间状态是双向同步的——谁进来了、谁走了、谁开了摄像头,两边都得知道。

19.6 常见问题与避坑

最后,我把自己在项目中踩过的几个坑列出来,希望能帮你省点时间:

  • Transport创建顺序不能乱:必须先创建SendTransport再创建RecvTransport?其实没有强制顺序,但建议先创建发送通道,因为发布流通常是用户的第一操作。
  • Consumer的paused状态:新创建的Consumer默认是resumed状态,但如果你不想立刻播放(比如用户还没点击播放按钮),可以传 paused: true,等需要时再 consumer.resume()
  • 不要重复创建Transport:每个客户端只需要一个SendTransport和一个RecvTransport,别每次发布流都新建一个。我见过有人把Transport当临时对象用,结果内存泄漏了。
  • 信令重连处理:WebSocket断连后,所有Transport和Producer都会失效。你需要监听断连事件,然后重新走一遍完整的连接流程。别想着只重连WebSocket——底层DTLS会话已经断了。
我的建议:如果你刚开始集成mediasoup,建议先用官方demo跑通一遍流程,理解每个事件的含义,再动手写自己的业务逻辑。直接上手写容易漏掉某些回调,调试起来很痛苦。

好了,这一章的内容就到这里。客户端集成的核心就是三件事:连上去、推上去、拉下来。房间管理则是把这些操作组织成有序的状态机。你只要把Transport的生命周期管好,把信令事件处理对,剩下的就是UI层面的展示了。


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