mediasoup服务启动及建连、发布流程

2 阅读7分钟

继上一节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

  1. 在启动mediasoup项目时,会根据os.cpus()长度,创建worker

os.cpus 返回包含有关每个逻辑 CPU 内核的信息的对象数组。

实际测试下来,返回的是线程数

  1. 创建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:

  1. 拼凑ws的地址,端口默认使用的是4443,如果上面后端服务的端口修改后,这里也要配合修改一下,然后url里包括了roomId和peerId
  2. 服务端的ws监听到新连接后会平均在Worker上初始化Room实例,每一个Room都对应一个Router(转发路由器,和Room一一对应),Router实例创建过程会把媒体服务器支持的媒体编码和扩展头config.mediasoup.routerOptions.mediaCodecs传到router中(如果以后要扩展媒体服务器能力,也要记的修改下config的内容)
  3. 建立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时,直接劝退,这里还是拆解一下,忽略无关流程:

  1. 初始化mediasoup-client的Device,参数handlerName其实就是浏览器版本,对应mediasoup-client里的handlers文件夹,不传入Device内部会根据UA自行判断,设置成传入的话也可以方便调试其他hanlder;
  2. 发送第一个信令getRouterRtpCapabilities,这个是获取一下媒体服务器的媒体能力与扩展头的支持,这个能力挂载到router,是静态内容,直接返回给前端就好;
  3. 前端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去触发。

  1. 发送createWebRtcTransport信令(竟然还没有发进房的信令,如果真用mediasoup建议把进房的流程往前提提),看返回值就是SFU的ice/dtls/sctp参数,前端拿着这些参数去创建Transport,一个Transport其实就是一个handler,一个RTCPeerConnection,mediasoup设计里,一个handler只能是用来发布,或者是只用来订阅,并不能双向操作;
  2. 插下服务端处理createWebRtcTransport的流程,还记的在创建worker里传入的udp/tcp,ip/port参数嘛,这个地址其实就是ice的参数。router.createWebRtcTransport,router里使用channel与worker通讯,在worker是组装完candidate后就返回router里创建transport,把transport的id及ice等参数返回给前端(前后端都使用mediasoup的好处就是前后端的类型都能一一对应起来),tansport.id就是后续前端操作这个transport时就传到后端;
  3. 继续回到前端的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建连流程,连接建立后面的发布流程就一致了,具体的建连使用流程图来描述一下:

image.png

简要说明下:

  1. 第一步获取SFU的媒体能力,和后面获取的ice candidate就已经可以在client组装remote sdp,因此在发布过程中不再进行全量的sdp交换协商;

  2. createWebRtcTransport获取ice/dtls后并没有发起ice连接,而是在client等待一个发布时机,因为只有发布(audio or video)才会addTransceiver,才会createOffer,createOffer才会生成本地dtls信息,这个过程发生在setupTransport中,在所有发布中,这里只有第一次发布的时候才会触发。

  3. mediasoup-client里是没有信令的,所以生成的dtls包括后面的ssrc都是通过事件抛到业务层,业务层进行与信令交互后再回调给client,client在和SFU发送完dtls后才开始 setLocal/setRemote,setRemote后就开始ice连接(图中绿色连接),ice连接成功后就开始发送音视频数据

到此,client就已经完成了和server进行连接和发布的流程。

三、总结

上面流程其实只涉及到了最核心的流程,但实际在发布过程中也会有设置编码等等参数。

client的Transport里面实现也有很多比较好玩的东西,虽然里面既有produce的逻辑,又有consumer的逻辑,但其实它同时只能担任一种角色。里面既有异步队列awaitQueue,又有订阅的_pendingConsumerTasks,这其实是为了保证sdp的状态机不混乱,也保证了合并去处理sdp任务,这些以后有机会会详细说明。