19、SFU客户端集成:连接mediasoup服务器、发布本地流(Producer)、订阅远端流(Consumer)、房间管理逻辑
好,咱们今天聊点硬核的。前面几章我们把WebRTC的底层原理、信令交互、媒体协商都捋了一遍,现在终于到了真正动手集成SFU客户端的环节了。我选的是mediasoup,这个库在业界口碑不错,我自己在几个直播项目中都用过,稳定性和灵活性都经得起考验。
说白了,这一章就是教你:怎么让浏览器跟mediasoup服务器握手,怎么把自己的摄像头麦克风推上去,怎么拉别人的流下来,以及怎么管理一个房间里的所有参与者。嗯,咱们一步步来。
19.1 连接mediasoup服务器:从握手到就绪
客户端要跟mediasoup通信,得先过两关:第一关是WebSocket信令连接,第二关是获取服务器的能力信息。我刚开始做的时候,以为直接连上WebSocket就能推流了,结果发现还得先问服务器要一份「设备能力清单」——说白了就是服务器支持哪些编解码器、哪些传输参数。
连接流程大致是这样的:
- 客户端通过WebSocket连接到信令服务器
- 发送一个
getRouterRtpCapabilities请求 - 服务器返回它支持的RTP能力(codecs、header extensions等)
- 客户端用这些能力来创建本地的
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——你可以把它理解成一条从客户端到服务器的上行通道。
流程如下:
- 客户端请求服务器创建一个
WebRtcTransport(用于发送) - 服务器返回
iceParameters、dtlsParameters、iceCandidates - 客户端用这些参数创建本地的
SendTransport - 通过这个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);
connect 和 produce 这两个事件必须处理,否则Transport无法正常工作。我曾经见过有人只处理了 connect 忘了 produce,结果视频轨一直发不上去,排查了半天才发现是回调没写。
19.3 订阅远端流:创建Consumer
别人推了流,你得能拉下来看。mediasoup里拉流对应的是 Consumer。要创建Consumer,需要先有一个 RecvTransport(下行通道)。
订阅流程:
- 客户端请求服务器创建另一个
WebRtcTransport(用于接收) - 服务器返回参数,客户端创建
RecvTransport - 服务器通知客户端「有新流来了」,客户端调用
transport.consume() - 把拿到的远端媒体轨添加到
<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.kind 是 video 还是 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服务器之间的完整交互流程,从连接到发布、订阅,再到房间管理:
这张图把整个流程串起来了。你看,左边是客户端,右边是服务器,中间通过WebSocket做信令控制,通过RTP传音视频数据。房间状态是双向同步的——谁进来了、谁走了、谁开了摄像头,两边都得知道。
19.6 常见问题与避坑
最后,我把自己在项目中踩过的几个坑列出来,希望能帮你省点时间:
- Transport创建顺序不能乱:必须先创建SendTransport再创建RecvTransport?其实没有强制顺序,但建议先创建发送通道,因为发布流通常是用户的第一操作。
- Consumer的paused状态:新创建的Consumer默认是resumed状态,但如果你不想立刻播放(比如用户还没点击播放按钮),可以传
paused: true,等需要时再consumer.resume()。 - 不要重复创建Transport:每个客户端只需要一个SendTransport和一个RecvTransport,别每次发布流都新建一个。我见过有人把Transport当临时对象用,结果内存泄漏了。
- 信令重连处理:WebSocket断连后,所有Transport和Producer都会失效。你需要监听断连事件,然后重新走一遍完整的连接流程。别想着只重连WebSocket——底层DTLS会话已经断了。
好了,这一章的内容就到这里。客户端集成的核心就是三件事:连上去、推上去、拉下来。房间管理则是把这些操作组织成有序的状态机。你只要把Transport的生命周期管好,把信令事件处理对,剩下的就是UI层面的展示了。