(因为下文我们会提到一些在上一篇文章中解释过的概念,如果你之前没有读过,我建议你先移步上一篇文章。)

在深入探讨实现细节之前,我们先来了解一下webRTC多方架构的主要方法。

网状结构(Mesh)

Mesh是最简单的一种架构。所有端之间都是相互连接的,会直接把自己的媒体发送到其他所有端上。

MeshwebRTC架构

优点:

缺点:

混合和MCU(MultipointConferenceUnit)

每个端将其媒体发送到中心服务器,并从中心服务器接收媒体。MCU作为一个混合点,接收、解码和混合来自所有端的媒体,最后以单一流的形式发送给所有用户。

优点:

缺点:

路由和SFU(SelectiveForwardUnit)

每个端将自己的媒体发送到中心服务器,并从它那里接收所有其他的媒体流。SFU就像一个媒体的路由器,接收所有用户的媒体流,然后决定将哪些流发送给哪些用户。

优点:

缺点:

给予我们的需求,我们决定实现第一种方法——最多支持3个端口的网状拓扑(但还是能扩展到更多用户的)。接下来我们来讨论下实现的细节。

完善措施

在开始讨论构建app的细节前,我们先进一步改进之前的架构,以期获取更简洁、架构更合理的代码。

WebRTC通信机制

如前所述,在mesh架构中,所有的端与端之间都是直接连接的,之前私聊中两端间建立连接的机制和配置都没变。因此,我们在文件中把这些相关项当作一个mixin单拎出来了:

exportconstvideoConfiguration={data(){return{constraints:{},//Mediaconstraintsconfiguration:servers,//TURN/STUNiceservers//OfferconfigofferOptions:{offerToReceiveAudio:1,offerToReceiveVideo:1},//LocalvideomyVideo:undefined,localStream:undefined,username:""}},created(){=this.$},//Methodimplementationsmethods:{asyncgetUserMedia(){},getAudioVideo(){},asyncsetRemoteDescription(remoteDesc,pc){try{log(`${}setRemoteDescription:start`)(remoteDesc)log(`${}setRemoteDescription:finished`)}catch(error){log(`ErrorsettingtheRemoteDescriptionin${}.Error:${error}`)}},asynccreateOffer(pc,to,room,conference=false){},asynccreateAnswer(pc,to,room,conference){},asynchandleAnswer(desc,pc,from,room,conference=false){},sSignalingMessage(desc,offer,to,room,conference){},addLocalStream(pc){},addCandidate(pc,candidate){},onIceCandidates(pc,to,room,conference=false){},},}

现在,我们已经找到创建所有RTCPeerConnection之外步骤的通用方法了,接下来就需要适当的组件提供和处理PeerConnection对象和过程中所需信息(远程描述、候选人、请求、回复等等)了。

详见使用mixins是所有的合并策略

templatedivclass="video"divclass="video__spinner"md-progress-spinnerv-if="!videoStream"class="md-accent"md-mode="indeterminate"/md-progress-spinner/divAudioVideoControlsv-if="displayControls":pauseVideo="pauseVideo":pauseAudio="pauseAudio"/AudioVideoControlsvideo:id="videoId"autoplay="true"/video/div/templatescriptexportdefault{name:"Video",components:{AudioVideoControls},props:{videoId:String,displayControls:Boolean,videoStream:MediaStream,pauseVideo:Function,pauseAudio:Function,}}/script

不管是哪种情况,组件都会获取合适的媒体流以及输入值的剩余部分。

pauseVideo和pauseAudio是通用的mixin媒体方法。

//().forEach(t==!)//().forEach(t==!)

音频

constraints:{audio:{echoCancellation:true,noiseSuppression:true,autoGainControl:false}}

MediaTrackAPI中可查看所有可用的约束条件。

//Localvideovideoid="localVideo"autoplay="true"muted/video//=0

注:这个操作只静音了本地播放,并不意味着你把正在运行的音频流也静音了。

WebRTC适配器

为了处理可能会出现的浏览器问题,我们引入了WebRTC适配器,把代码和不同的WebRTC浏览器实现连接起来。

importadapterfrom‘webrtc-adapter’(`Browser${}—version${}`)

用户信息

如上所述,用户无法一边私聊一边参加会议。所以我们在用户进入房间时,就在Redis中存储的对象中添加了一个会议标志(joinRoomwebsocket服务器监听器),来监测用户状态。

try{//(room,userName,{username,status,privateChat:false,conference:false})constusers=(room)//(room).emit(‘newUser’,{users,username})}catch(error){(error)}

注:我们更改了redishash模式中的密钥(从socketId变成了userName),这使得从websocket连接中获取用户信息更方便了。

会议

某一用户发起会议,会议开始,该用户成为该房间管理员。当开启app时,管理员会自动加入由自己用户名定义的会议室。

注:每个用户名在系统中都是唯一不重复的,所以每个websocket会议室都是独一无二的。

为做到这一点,我们开发了一个新的joinConference服务器监听器。

constjoinConference=(socket,namespace)=({username,room,to,from})={constadmin=username===(admin?`Conference-User"${username}"wantstoopenaconferenceroom`:`Conference-User"${username}"wantstojointhe"${to}"conference`)//(to,async()={if(!room)returntry{constuser=(room,username)(room,username,{user,conference:to})(to).emit('joinConference',{username,to,room,from})}catch(error){(error)}})}

借助conference:to,同一会议内的所有端用户都拥有相同的会议标志值,即管理员用户名。

而在前端方面,新的组件也会持有相关功能。

templatedivclass="conference-container"divclass="conference-container__header"h3Privateconference(upto3)/h3md-menumd-buttonclass="md-icon-buttonpage-container-logout"md-menu-trigger:disabled="peersLength===2||===1"v-if=""md-icongroup_add/md-icon/md-buttonmd-menu-contentdivv-for="userinusers":key=""md-menu-itemv-if="!==$!peers[]"@click="invitate()"md-iconperson_add/md-iconspan{{}}/span/md-menu-item/div/md-menu-content/md-menu/divdivclass="conference-container__videos"divclass="video"VideovideoId="localVideo":displayControls="true":videoStream="localStream":pauseVideo="pauseVideo":pauseAudio="pauseAudio":muted="true"/Video/div!--Peersvideoelements--divclass="conference-container__videos--remote"divv-for="(item,key)inpeers":key="key"class="video"Video:videoId="key":displayControls="false":videoStream="peers[key].peerStream":muted="false"/Video/div/div/div/div/templatescriptexportdefault{props:{conference:Object,users:Array//Userswithinthesamepublicroom},mixins:[videoConfiguration],//WebRTCmixincomponents:{Video},data:()=({peers:{},//PeersconnectedtotheconferencepeersLength:0}),asyncmounted(){=("localVideo")//Adminjointheroomif(){()this.$(WS_,{this.$,to:})}},methods:{invitate(user){this.$(WS_,{room:this.$,to:user,from:})},}}/scriptpeers对象会监测会议中的所有用户。peers:{userA:{username://Peerusernamepc://RTCPeerConnectionpeerStream://PeermediastreampeerVideo://Peervideoelement},userB:{..},..}

当管理员开启会议、会议组件实例化后,我们(与会者)就能获取媒体流并自动加入会议室了。若有新的与会者想加入,管理员会通过conferenceInvitation事件向每个新加入的端发送邀请。

constconferenceInvitation=(namespace)=async({room,to,from})={(`Conference-Invitationfrom"${from}"to"${to}"inroom${room}`)try{const{privateChat,conference}=(room,to)//Useralreadytalkingif(privateChat||conference){(`Conference-User"${to}":${privateChat}-Conference:${conference}`)(from).emit('conferenceInvitation',{message:`User${to}isalreadytalking`,from})}(room).emit('conferenceInvitation',{room,to,from})}catch(error){(error)}}

(会议邀请的websocket服务器监听器)

简而言之,管理员和初次加入的与会者间的交互机制可以概括为以下几点:

1.管理员(A)开启会议并加入会议室(即joinConferencesocket事件);

2.A向用户B发送邀请(即conferenceInvitationsocket事件);

3.B收到邀请,加入会议(即joinConference事件);

4.A收到邀请确认后,添加B为会议成员(即添加到自己的端对象中),创建并发送给B请求;

5.B收到请求后,添加A为会议成员(即添加到自己的端对象中),创建并将请求再发回给A

就像我们之前所做的那样,我们已经用视图父组件中实现了用合适的FEsocket监听器来处理邀请和确认事件。

除对端对象进行相应管理之外,webRTC机制在第4、第5点中的作用与上一篇文章中所述完全相同。但如今会议室会成为会议中所有与会者的信令机制。

这样安排目的还是为会议所有端提供交流方式,以交换通信所需的元数据。为了简化操作,我们定义了另一个信令socket事件PCSignalingConference,并在视图中定义了相关的监听器方法:

PCSignalingConference:function({desc,from,to,candidate}){//Ruleoutmessagewhenneededif(from===this.$||(!!toto!==this.$))returnif(desc){//Offerif(===DESCRIPTION_)={…,offer:{from,desc},open:true}//Answerelseif(===DESCRIPTION_)={…,answer:{from,desc}}}elseif(candidate){//={…,candidate:{from,candidate}}}}

和之前一样,我们根据信令通道发送的信息区分出请求、答复或者加入连接的新与会者。

但即使按照同样的流程,管理员A和用户B到底要如何操作呢?

B确认到达后,管理员才会激活会议,所以我们要在组件中添加以下内容:

scriptexportdefault{methods:{initWebRTC(user,desc){//Adduserthis.$set(,user,{username:user,pc:newRTCPeerConnection(),peerStream:undefined,peerVideo:undefined})([user].pc)([user].pc,user,,true)([user],user)//Actaccordinglydesc?(desc,[user].pc,user,,true):([user].pc,user,,true)},},watch:{conference:function({user,answer,candidate,userLeft,offer},oldVal){//Newuserif(useruser!==){(user)++}}}}/script

(和与会的新用户交互)

每当新用户入会,管理员会通过initWebRTC方法启动webRTC机制,依照我们在私人会话中的方式(只不过现在是使用mixin)创建邀请。

另一方面,用户B在获取管理员允许前不能发起会议。所以我们也要在组件中进行相应修改。

scriptexportdefault{asyncmounted(){=("localVideo")//Newusergetstheofferif(){const{offer:{from,desc}}=(from,desc)}},methods:{asyncinit(offer,desc){()(offer,desc)},initWebRTC(user,desc){},}}/script

(新的与会者收到邀请)

在收到邀请后,新的段会通过上述同样逻辑的initWebRTC创建并发送回复。

如果会议中已经有两个用户,而管理员又邀请了第三个用户呢?

同上述流程相同,只是扩展到了更多用户。

访客B加入已存在两个用户的会议

如图所示,在管理员邀请(1)之后,会议内所有人在得到确认(2)后会立即向新的端发出邀请(3)。之后,新的端会对每个用户进行回复(4),从而建立两个连接(N-1上行和下行链路)。

相信现在你也看到了,如果我们想把该操作扩展到更多的用户,就会变得有点麻烦。

请记住,虽然这两个邀请不会同时发生,也就是说它们不能以任何特定的顺序与新端交互。所以用户B需要在我们的组件中处理缺乏同步性的问题。

watch:{conference:function({offer},oldVal){//Newofferif(offeroffer!==!!){const{from,desc}=(from,desc)}}}

如前所述,第一次请求确认后,会议会被激活,所以我们也需要定位之后出现的新请求。但我们的项目只需借propsconference对象,一次只处理一个请求。所以在处理几个连续请求时,我们要注意不误导对象引用。如此看来,对于上述这两种情况,检索请求信息时都要创建常量。

会议对象指的是包含正确请求、答复、icecandidates、远程用户等信息的对象。

另外,会议结束后我们会退出会议、停止所有媒体流。

beforeDestroy(){//().forEach(peer=())={}//Leaveconferencethis.$(WS_,{…this.$,from:,conferenceRoom:})},每个端都会重置所有对等端连接,通过leaveConference事件离开会议,重置会议标志。constleaveConference=(socket,namespace)=async({room,from,conferenceRoom})={(`Conference-User"${from}"wantstoleavetheconferenceroom${room}`)try{constuser=(room,from)(room,from,{user,conference:false})(conferenceRoom,()={(conferenceRoom).emit('leaveConference',{room,from})})}catch(error){(error)}}

(离开会议的网络socket服务器监听器)

媒体方面,webRTCmixin会在销毁前重置本地媒体流。

beforeDestroy(){().forEach(track=())}

整合上述所有操作,我们就可以试运行了!

为进行测试,我们为每个用户都创建了一个应用实例。所以我们会借文件在配置中添加第三个应用副本。

#Copy3chat3:build:context:.args:VUE_APP_SOCKET_HOST:localhostVUE_APP_SOCKET_PORT:3002ports:-3002:3002networks:-video-chatdeps_on:-redisenvironment:PORT:3002REDIS_HOST:redisREDIS_PORT:6379

这样我们就得到了如下所示的本地测试环境。

本地测试环境

现在,我们只需要通过docker-compose来构建和运行应用就可以了!

我们安排了三个用户连接到不同的实例,并通过端对端连接进行包含这三个用户的会议。

有时webRTC应用中的错误追寻和解决操作有点复杂。这时,你可以使用Firefox检查about:webrtc的页面。它将为你提供关于SDP会话、ICEcandidates等及时信息。

注:WebRTC使用ICE框架来克服网络的复杂性。

在本地环境下做测试应该能顺利进行。在本地测试中,对等端会通过host候选来交换网络信息,也就是说ip地址就是远程对等端(同一网络内的所有对等端)的真实地址。请看一个UDP请求的小例子。

a=candidate:01=candidate:61UDP2122252543fd8b:15c5:43b9:9m00:1c89:1vvc:2592:c9c654057typhosta=candidate:181

TCPcandidate仅在UDP不可用或受到限制而不适合媒体流时使用

a=candidate:11

WebRTC网络架构

但在其他一些情况下,比如远程对等端的网络受限,这就需要使用TURN服务器和relay候选者。relay候选者的IP地址是TURN服务器在直接连接失败时用来转发两个对等端之间媒体的地址。

a=candidate:31

由于其特性,你会发现实际操作中有很多公共STUN服务器(比如该列表中的服务器),因为媒体流通过服务器就意味着带宽消耗。

如果你对TURN服务器感兴趣,有一些类似coturn的开源工具可以帮你创建自己的服务器。

总结