继上一节mediasoup js/nodejs/c++调试环境搭建 - 掘金说明mediasoup调试环境后,就可以看一下mediasoup的信令连接、ICE连接及发布的流程(发布在mediasoup里叫produce)。这里还是以mediasoup-demo为切入点,只聚焦核心流程,会省略一些细节。
本文是需要一些WebRTC基础的同学阅读。
一、信令服务启动
启动入口在server.js/run函数,主要分为4步
- 启动mediasoup库的worker,就是上一篇中自己编译的c++程序
- 启动express服务
- 启动http服务
- 启动ws服务
async function run()
{
// Run a mediasoup Worker.
await runMediasoupWorkers();
// Create Express app.
await createExpressApp();
// Run HTTPS server.
await runHttpsServer();
// Run a protoo WebSocketServer.
await runProtooWebSocketServer();
}
1.1 runMediasoupWorkers
- 在启动mediasoup项目时,会根据os.cpus()长度,创建worker
os.cpus 返回包含有关每个逻辑 CPU 内核的信息的对象数组。
实际测试下来,返回的是线程数
- 创建webRtcServer,每一个worker都有单独的server,使用worker.appData.webRtcServer = webRtcServer进行关联
webRtcServer是启动的流媒体服务,后面的ice连接,及流转发都使用这个server。它在配置在config.webRtcServerOptions中,主要包括udp/tcp协议,及端口(默认44444)
1.2 createExpressApp/runHttpsServer/runProtooWebSocketServer
express提供了一些open api,这在连接流程中没什么用,跳过。
https根据上一节生成的证书还有config中的https端口(默认4443)启动http服务。
最后启动websocket信令服务,这里使用了protoo-server这个库,下面web的ws也使用了protoo-client,protoo提供了一些房间/人员管理的功能,但本质上还是一个ws连接,可以不深究。
二、业务流程
这里会切到前端app视角,以demo上触发的信令来前后串起整个流程
2.1 RoomClient创建
RoomClient是在业务层做的和会议(RTC)相关的封装,包括进退房,发布,订阅等。
RoomClient会在入口文件index.jsx里实例化,并传入一些定制化参数,包括是否要发布、订阅,是否使用datachannel,编码选择,传输层协议选择,simulcast的配置等,这些配置都可以通过query参数的形式指定,为了只看发布流程,我们可以在url里指定 consume=false&datachannel=false
RoomClient的实例挂载到了window.CC,到时候可以在控制台里查看内部状态。
2.2 roomClient.join 建立信令连接
Room.jsx组件挂载后,就会自动调用roomClient.join:
- 拼凑ws的地址,端口默认使用的是4443,如果上面后端服务的端口修改后,这里也要配合修改一下,然后url里包括了roomId和peerId
- 服务端的ws监听到新连接后会平均在Worker上初始化Room实例,每一个Room都对应一个Router(转发路由器,和Room一一对应),Router实例创建过程会把媒体服务器支持的媒体编码和扩展头config.mediasoup.routerOptions.mediaCodecs传到router中(如果以后要扩展媒体服务器能力,也要记的修改下config的内容)
- 建立ws连接,在ws触发open事件后,调用roomClient._joinRoom
const protooTransport = new protooClient.WebSocketTransport(this._protooUrl);
this._protoo = new protooClient.Peer(protooTransport);
this._protoo.on('open', () => this._joinRoom());
2.3 _joinRoom - 1 协商媒体能力
这里的实现超级长,在几年之前初次接触mediasoup时,直接劝退,这里还是拆解一下,忽略无关流程:
- 初始化mediasoup-client的Device,参数handlerName其实就是浏览器版本,对应mediasoup-client里的handlers文件夹,不传入Device内部会根据UA自行判断,设置成传入的话也可以方便调试其他hanlder;
- 发送第一个信令getRouterRtpCapabilities,这个是获取一下媒体服务器的媒体能力与扩展头的支持,这个能力挂载到router,是静态内容,直接返回给前端就好;
- 前端this._mediasoupDevice.load({ routerRtpCapabilities }),在Device中会创建一个临时的handler先创建a/v的sdp,也从中提取出媒体编码和扩展头,最终再与服务端的能力进行匹配,匹配成功才会给canProduceByKind赋值,如果不支持,则在produce前置判断中就直接return;
this._mediasoupDevice = new mediasoupClient.Device(
{
handlerName : this._handlerName
});
const routerRtpCapabilities =
await this._protoo.request('getRouterRtpCapabilities');
await this._mediasoupDevice.load({ routerRtpCapabilities });
2.3 _joinRoom - 2 准备与媒体进行ice连接
这里说的准备只是具备了与SFU连接的条件,实际连接的过程还是需要后面的produce去触发。
- 发送createWebRtcTransport信令(竟然还没有发进房的信令,如果真用mediasoup建议把进房的流程往前提提),看返回值就是SFU的ice/dtls/sctp参数,前端拿着这些参数去创建Transport,一个Transport其实就是一个handler,一个RTCPeerConnection,mediasoup设计里,一个handler只能是用来发布,或者是只用来订阅,并不能双向操作;
- 插下服务端处理createWebRtcTransport的流程,还记的在创建worker里传入的udp/tcp,ip/port参数嘛,这个地址其实就是ice的参数。router.createWebRtcTransport,router里使用channel与worker通讯,在worker是组装完candidate后就返回router里创建transport,把transport的id及ice等参数返回给前端(前后端都使用mediasoup的好处就是前后端的类型都能一一对应起来),tansport.id就是后续前端操作这个transport时就传到后端;
- 继续回到前端的this._mediasoupDevice.createSendTransport,初始化Transport的实例,Transport里实例化handler,handler里实例化了RTCPeerConnection,把ice的参数传给handler构造了RemoteSdp(后面再涉及到sdp协商只会发送增量信息比如ssrc),等待后续操作;
// Demo/app/RoomClient.js
const transportInfo = await this._protoo.request(
'createWebRtcTransport',
{
producing : true,
});
const {
id,
iceParameters,
iceCandidates,
dtlsParameters,
sctpParameters
} = transportInfo;
this._sendTransport = this._mediasoupDevice.createSendTransport(
{
id,
iceParameters,
iceCandidates,
dtlsParameters,
sctpParameters,
iceServers : [],
proprietaryConstraints : PC_PROPRIETARY_CONSTRAINTS,
additionalSettings :
{ encodedInsertableStreams: this._e2eKey && e2e.isSupported() }
});
// mediasoup-client
this._handler.run({
direction,
iceParameters,
iceCandidates,
dtlsParameters,
sctpParameters,
iceServers,
iceTransportPolicy,
additionalSettings,
proprietaryConstraints,
extendedRtpCapabilities,
});
2.4 _joinRoom - 3 进房信令
除了sendTransport,如果允许订阅的话,也会创建recvTransport,这个过程是和send一致的,直接跳过看下面就真正发送了join的进房信令,返回值就是当前房间内已存在的用户。
const { peers } = await this._protoo.request(
'join',
{
displayName : this._displayName,
device : this._device,
});
2.5 发布/ICE连接 (重点)
也是在进房流程中,最后判断是否要发布,会调用enableMic,如果要发布视频的话就调用enableWebcam。
先看下发布音频,业务层使用getUserMedia采集音频,拿到MediaStreamTrack,通过sendTransport.proce方法把track和音频配置传进去
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
track = stream.getAudioTracks()[0];
this._micProducer = await this._sendTransport.produce(
{
track,
codecOptions :
{
opusStereo : true,
opusDtx : true,
opusFec : true,
opusNack : true
}
});
sendTransport的第一次调用produce方法就会触发ICE建连流程,连接建立后面的发布流程就一致了,具体的建连使用流程图来描述一下:
简要说明下:
-
第一步获取SFU的媒体能力,和后面获取的ice candidate就已经可以在client组装remote sdp,因此在发布过程中不再进行全量的sdp交换协商;
-
createWebRtcTransport获取ice/dtls后并没有发起ice连接,而是在client等待一个发布时机,因为只有发布(audio or video)才会addTransceiver,才会createOffer,createOffer才会生成本地dtls信息,这个过程发生在setupTransport中,在所有发布中,这里只有第一次发布的时候才会触发。
-
mediasoup-client里是没有信令的,所以生成的dtls包括后面的ssrc都是通过事件抛到业务层,业务层进行与信令交互后再回调给client,client在和SFU发送完dtls后才开始 setLocal/setRemote,setRemote后就开始ice连接(图中绿色连接),ice连接成功后就开始发送音视频数据
到此,client就已经完成了和server进行连接和发布的流程。
三、总结
上面流程其实只涉及到了最核心的流程,但实际在发布过程中也会有设置编码等等参数。
client的Transport里面实现也有很多比较好玩的东西,虽然里面既有produce的逻辑,又有consumer的逻辑,但其实它同时只能担任一种角色。里面既有异步队列awaitQueue,又有订阅的_pendingConsumerTasks,这其实是为了保证sdp的状态机不混乱,也保证了合并去处理sdp任务,这些以后有机会会详细说明。