前端一对一RTC实现入门

9 篇文章 0 订阅
9 篇文章 0 订阅

RTC这个概念最近越炒越火,而webRTC只是RTC实现的其中一环。webRTC名称源自网页即时通信(英语:Web Real-Time Communication)的缩写,是一个支持网页浏览器进行实时语音对话或视频对话的API。虽然webRTC标准在2011年就出现了,但是webRTC标准只定义了客户端的行为,服务端却并没有规范。这篇文章简单的聊一下webRTC的前端规范。

首先要即时通信,需要获取视频或者音频流,这个需要用到navigator.mediaDevices.getUserMedia这个方法,调用成功后会resolve一个MediaStream对象:

navigator.mediaDevices.getUserMedia({ audio: true, video: true}) 
    .then(stream => {
        let video = document.querySelector('#rtc')
        video.srcObject = stream
    })
    .catch(err => {
        console.log(err)
    })

navigator.mediaDevices.getUserMedia接收一个constraints对象参数,可以指定是否需要音频或者视频,通过返回的stream赋值给video标签,可以实现预览模式。

除了navigator.mediaDevices.getUserMedia还有一个navigator.mediaDevices.getDisplayMedia方法,此方法用于屏幕分享

navigator.mediaDevices.getDisplayMedia()
    .then(stream => {
        let video = document.querySelector('#rtc')
        video.srcObject = stream
    })
    .catch(err => {
        console.log(err)
    })

这个方法也会接收一个constraints对象参数,默认包含一个视频轨道,当然也可以开启音频轨道,

navigator.mediaDevices.getDisplayMedia({ audio: true, video: true })
    .then(stream => {
        let video = document.querySelector('#rtc')
        video.srcObject = stream
    })
    .catch(err => {
        console.log(err)
    })

开启后左下角会有多一个分享音频的checkbox,同样我们可以通过video标签开启预览模式。这里需要提一下目前火狐支持navigator.mediaDevices.getDisplayMedia方法开启屏幕分享模式:

navigator.mediaDevices.getUserMedia({ audio: false, video: {mediaSource: 'screen'} }) 
    .then(stream => {
        let video = document.querySelector('#rtc')
        video.srcObject = stream
    })
    .catch(err => {
        console.log(err)
    })

获得了stream后,接下来就是发送数据了。既然webRTC可以P2P点对点连接,那么是不是不需要服务器,两个客户端之间直接通讯了?显然是不行的。这里还需要一个信令服务器:

       信令的作用在于协调通讯。其实很好理解,两个客户端之间需要通讯但是并不知道双方的ip地址等信息,不过通过某台服务器转发网络信息,两者之间就可以互相通讯了。当然也有特殊情况,需要通过TURN服务器来转发,这个之后会说到。当然交换的信息中不仅仅需要网络信息,还需要双方媒体协商的媒体信息即编解码等媒体信息。当然,除了网络信息与媒体信息之外,服务器还需要进行一些业务操作,比如管理房间,踢人,挂断等。

但是在实际情况中协调通讯并没这么简单,大多数情况下两者处于不在一个内网的环境中,即设备前还有一个NAT设备的话,两个设备p2p是需要公网地址的,所以这里还需要STUN服务器,只有通过STUN服务器找到设备后对应的公网ip和端口,才能进行通讯。所以STUN的主要作用在于:接收来自于客户端的请求,将客户端的公网ip和端口封装到ICE Candidate中返回给客户端,然后客户端通过webRTC接口获取公网的ip和端口信息再通过信令服务器转发给另一个客户端。STUN除了返回网络信息之外,还会返回对应NAT设备的类型。什么是NAT呢,引用百度百科的描述:

NAT(Network Address Translation,网络地址转换)是1994年提出的。当在专用网内部的一些主机本来已经分配到了本地IP地址(即仅在本专用网内使用的专用地址),但现在又想和因特网上的主机通信(并不需要加密)时,可使用NAT方法。

这种方法需要在专用网(私网IP)连接到因特网(公网IP)的路由器上安装NAT软件。装有NAT软件的路由器叫做NAT路由器,它至少有一个有效的外部全球IP地址(公网IP地址)。这样,所有使用本地地址(私网IP地址)的主机在和外界通信时,都要在NAT路由器上将其本地地址转换成全球IP地址,才能和因特网连接。

为什么这里STUN会返回NAT类型呢,因为不同的NAT类型有不同的机制,有的NAT设备在外界对内网的请求到达NAT设备时,都会被NAT所丢弃,但是通过NAT穿越打洞可以解决这个问题,有的NAT设备穿越打洞也无法解决那么需要通过TURN服务器进行信息的转发。故需要通过NAT类型来判断接下来的连接方式,不同的NAT类型后续处理的示意图大致为以下三种:

从上图能看出,根据STUN Server返回的NAT类型,或直接通信,或通过TURN服务器协调打洞后通讯,或通过TURN服务器转发。(打洞简单来说就是A设备往B设备发送一条信息,发送时会在自己的NAT设备上打个洞,B设备往A设备发送一条信息,也会在自己的NAT设备上打个洞。双方可以通过这个洞来互相通讯)。


知道了这些概念之后,我们就开始连接通讯了,这里需要用到webRTC的RTCPeerConnection接口:

let peer = new PeerConnection({
      iceServers: [
        { url: 'stun:stun1.l.google.com:19302'},
        {
          url: 'turn:***',
          username: '***',
          credential:'***'
        }
      ]
    })

可以看到RTCPeerConnection接收一个iceServers对象,其中可以填写STUN服务地址或TURN服务地址,如果数组为空或者没有指定iceServers将不适用任何第三方服务器即无法获得公网ip,那么只能本地进行通信。

创建完实例后,这里还需实例监听icecandidate事件,这个就是信令协商的关键,该事件会返回一个sdp的ice候选者,将ice候选通过服务器转发给另一个客户端,因为socket有房间的概念,故这边采用socket.io来传递协商信息。当然webRTC对于信令并没有对应规范,其他方式也同样可以:

const peer = new RTCPeerConnection()
peer.addEventListener('icecandidate', event => {
    if (event.candidate) {
      this.$socket.emit('candidate', {candidate:event.candidate})
    }
})

这里简单的看下candidate的数据内容:

​​
candidate: "candidate:1 1 UDP 2122121471 192.168.99.1 49705 typ host"
​
sdpMLineIndex: 0
​
sdpMid: "0"
​
usernameFragment: "4e7470f4"

如上文所说其中包含ip和端口,最后typ host代表这次是本地连接,typ有三个类型:本地,非对称NAT,堆成NAT。

创建了WebRTC连接后,接下来就是媒体信息交换了,代码如下:

const peer = new RTCPeerConnection()
let offer = await peer.createOffer({
    offerToReceiveAudio: true,
    offerToReceiveVideo: true
})
peer.setLocalDescription(offer)
this.$socket.emit('giveOffer',{
    sdp: offer
})

createOffer接收一个对象参数,其中offerToReceiveAudio如果该值为false,即使本地端将发送音频数据,也不会提供远程端点发送音频数据。 如果此值为true,即使本地端不会发送音频数据,也将向远程端点发送音频数据。 默认行为是仅在本地发送音频时才提供接收音频,否则不提供;offerToReceiveVideo同理。创建完之后,设置本地offer然后将offer通过socket传给信令服务器即可。

至于接收offer,通过setRemoteDescription方法即可:

const peer = new RTCPeerConnection()
peer.setRemoteDescription(data.sdp)
let answer = await peer.createAnswer()
peer.setLocalDescription(answer)
this.$socket.emit('backOffer',{sdp:answer})

接收完offer设置远程描述后调用createAnswer方法返回交换offer。


信令服务器代码如下:

import {
    WebSocketGateway,
    WebSocketServer,
    SubscribeMessage,
    MessageBody,
    ConnectedSocket,
    OnGatewayInit,
    OnGatewayConnection,
    OnGatewayDisconnect,
  } from '@nestjs/websockets'

  interface JoinBodyDto {
    name: string,
    room: number
  }

  interface CommonDto {
    to: string,
    from: string
  }

  enum ReplyStatus {
    refuse,
    accept
  }

  interface ReplyBodyDto extends CommonDto {
    status: ReplyStatus
  }
  interface OfferDto extends CommonDto{
    sdp:any
  }
  interface CandidateDto extends CommonDto{
    candidate:any
  }
  @WebSocketGateway()
  export class EventsGateway
    implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
    private roomList = {}
    private socketList = {}
    @WebSocketServer() server
    /* 加入房间 */
    @SubscribeMessage('join')
    async handleJoinEvent(
      @MessageBody() data:JoinBodyDto,
      @ConnectedSocket() client,
    ): Promise<Boolean> {
      // 加入房间
      client.join(data.room)
      // 维护房间列表信息
      const joinMember = {
        id:client.id,
        name:data.name,
      }
      if(this.roomList[data.room] === undefined) {
        this.roomList[data.room] = [joinMember]
      }else {
        this.roomList[data.room].push(joinMember)
      }
      // 保存socket实例
      this.socketList[client.id] = client
      // 给自己发送socketId
      client.emit('ticket',joinMember)
      // 群发广播包括自己
      this.server.to(data.room).emit('joined', {
          ...joinMember,
          member:this.roomList[data.room]
      })
      return false
    }
    /* 申请rtc连接 */
    @SubscribeMessage('apply')
    async handleApply(
      @MessageBody() data:CommonDto,
      @ConnectedSocket() client,
    ): Promise<Boolean> {
      this.socketList[data.to].emit('apply', data.from)
      return false
    }
    /* 回复是否同意rtc连接 */
    @SubscribeMessage('reply')
    async handleReply(
      @MessageBody() data:ReplyBodyDto,
      @ConnectedSocket() client,
    ): Promise<Boolean> {
      this.socketList[data.to].emit('reply', {
        from: data.from,
        status: data.status
      })
      return false
    }
    /* 通知挂断rtc */
    @SubscribeMessage('hangUp')
    async handleHangUp(
      @MessageBody() data:CommonDto,
      @ConnectedSocket() client,
    ): Promise<Boolean> {
      this.socketList[data.to].emit('hangUp', data.from)
      return false
    }
    /* 发送rtc offer */
    @SubscribeMessage('giveOffer')
    async handleGiveOffer(
      @MessageBody() data:OfferDto,
      @ConnectedSocket() client,
    ): Promise<Boolean> {
      this.socketList[data.to].emit('giveOffer', {from:data.from,sdp:data.sdp})
      return false
    }
    /* 回复rtc offer */
    @SubscribeMessage('backOffer')
    async handleBackOffer(
      @MessageBody() data:OfferDto,
      @ConnectedSocket() client,
    ): Promise<Boolean> {
      this.socketList[data.to].emit('backOffer', {from:data.from,sdp:data.sdp})
      return false
    }
    /* 交换candidate信息 */
    @SubscribeMessage('candidate')
    async handleCandidate(
      @MessageBody() data:CandidateDto,
      @ConnectedSocket() client,
    ): Promise<Boolean> {
      this.socketList[data.to].emit('candidate', {from:data.from,candidate:data.candidate})
      return false
    }
    /* socket断开生命周期 */
    handleDisconnect(socket) {
      console.log(socket.id,'leave connect')
      for (let key in this.roomList) {
        if(this.roomList[key].find(item => item.id === socket.id) !== undefined) {
          this.roomList[key] = this.roomList[key].filter(item => item.id !== socket.id)
          this.server.to(key).emit('leaved', {
            member:this.roomList[key]
        })
        }
      }
    }
    /* socket初始化 */
    afterInit(server) {
      console.log('socket init success')
    }
    /* socket连接成功 */
    handleConnection(socket) {
      console.log(socket.id,'connect success')
    }
  }
  

前端大致代码如下:

<template>
    <div class="login">
        <div class="video-bg"></div>
        <div class="container d-flex justify-center align-center mb-6">
            <div class="col-lg-4 col-md-5" style="max-width:400px">
                <v-card class="mx-auto my-5" max-width="1144">
                    <video id="own" autoplay style="height:300px;width:300px;"></video>
                    <video id="other" autoplay style="height:300px;width:300px;"></video>
                    <v-card-text class="text-center" v-show="!isLogin">
                        <v-form ref="form">
                            <v-text-field
                                v-model="name"
                                :counter="6"
                                :error-messages="nameErrors"
                                label="名字"
                                required
                                @input="$v.name.$touch()"
                                @blur="$v.name.$touch()"
                            ></v-text-field>

                            <v-text-field
                                v-model="room"
                                :error-messages="roomErrors"
                                label="房间号"
                                required
                                @input="$v.room.$touch()"
                                @blur="$v.room.$touch()"
                            ></v-text-field>

                            <v-btn color="success" class="mr-4" @click="handleJoinRoom">加入房间</v-btn>
                        </v-form>
                    </v-card-text>
                    <v-card-text class="text-center" v-show="isLogin">
                        <v-list dense>
                            <v-subheader>聊天</v-subheader>
                            <v-list-item-group color="primary">
                                <v-list-item
                                    v-for="(item, i) in filterList"
                                    :key="i"
                                    @click="handleApply(item)"
                                >
                                    <v-list-item-icon>
                                        <v-icon>mdi-cellphone</v-icon>
                                    </v-list-item-icon>
                                    <v-list-item-content>
                                        <v-list-item-title v-text="item.name"></v-list-item-title>
                                    </v-list-item-content>
                                </v-list-item>
                            </v-list-item-group>
                        </v-list>
                    </v-card-text>
                </v-card>
            </div>
        </div>
        <v-dialog v-model="applyDialog" persistent max-width="290">
            <v-card v-show="!loading">
                <v-card-title class="headline">来点提示</v-card-title>
                <v-card-text>来电人:{{ applyData.name }}</v-card-text>
                <v-card-actions>
                    <v-spacer></v-spacer>
                    <v-btn color="green darken-1" text @click="handleDisagree">拒绝</v-btn>
                    <v-btn color="green darken-1" text @click="handleAgree">同意</v-btn>
                </v-card-actions>
            </v-card>
            <v-card v-show="loading">
                建立连接中
            </v-card>
        </v-dialog>
        <v-dialog v-model="waitDialog" persistent max-width="290">
            <v-card>
                <v-card-title class="headline">等待响应</v-card-title>
                <v-card-text>响应人:{{ waitData.name }}</v-card-text>
            </v-card>
        </v-dialog>
    </div>
</template>
<script>
import { validationMixin } from 'vuelidate'
import { required, maxLength } from 'vuelidate/lib/validators'
export default {
    sockets: {
        connect() {
            console.log('连接成功')
        },
        connected() {
            console.log('已连接')
        },
        reconnect() {
            console.log('已重连')
        },
        connect_error() {
            console.log('连接失败')
        },
        disconnected() {
            console.log('断开连接')
        },
        connect_timeout() {
            console.log('连接超时')
        },
        // 保存自己的socketId
        ticket(data) {
            this.socketId = data.id
        },
        // 有人加入房间
        joined(data) {
            this.list = data.member
        },
        // 有人离开房间
        leaved(data) {
            this.list = data.member
        },
        // 有人来电
        apply(data) {
            if (this.applyDialog) {
                this.$socket.emit('reply', { from: this.socketId, to: data, status: 2 })
                return false
            }
            this.applyDialog = true
            this.applyData = {
                id: data,
                name: this.list.find(item => item.id === data).name
            }
        },
        // 有人回复是否通讯
        async reply(data) {
            this.waitDialog = false
            if (data.status === 1) {
                await this.handleCreateP2P(data.from)
                this.handleCreateOffer(data.from)
            } else if (data.status === 0) {
                this.$message({
                    type: 'error',
                    text: `${this.waitData.name}挂断了电话`
                })
            } else if (data.status === 2) {
                this.$message({
                    type: 'error',
                    text: `${this.waitData.name}忙线中`
                })
            }
        },
        // 交换candidate信息
        async candidate(data) {
            try {
                await this.peer.addIceCandidate(data.candidate)
            }catch(e) {
                console.log(e)
            }
        },
        // 有人挂断
        hangUp(data) {
            this.peer.close()
            this.peer = null
        },
        async giveOffer(data) {
            try {
                await this.peer.setRemoteDescription(data.sdp);
                this.handleCreateAnswer(data)
            }catch(e) {
                console.log(e)
            }
        },
        async backOffer(data) {
            try {
                await this.peer.setRemoteDescription(data.sdp); // 呼叫端设置远程 answer 描述
            } catch (e) {
                console.log(e)
            }
        }
    },
    mixins: [validationMixin],
    validations: {
        name: { required, maxLength: maxLength(6) },
        room: { required }
    },
    data() {
        return {
            loading: false,
            isLogin: false,
            socketId: '',
            name: '',
            room: '',
            list: [],
            applyDialog: false,
            applyData: {},
            waitDialog: false,
            waitData: {},
            peer: null
        }
    },
    computed: {
        nameErrors() {
            const errors = []
            if (!this.$v.name.$dirty) return errors
            !this.$v.name.maxLength && errors.push('昵名不能超过六位')
            !this.$v.name.required && errors.push('请填写昵名')
            return errors
        },
        roomErrors() {
            const errors = []
            if (!this.$v.room.$dirty) return errors
            !this.$v.room.required && errors.push('请填写房间号')
            return errors
        },
        filterList() {
            return this.list.filter(item => item.id !== this.socketId)
        }
    },
    methods: {
        // 创建sdp
        async handleCreateOffer(from) {
            try {
                let offer = await this.peer.createOffer({
                    offerToReceiveAudio: 1,
                    offerToReceiveVideo: 1
                })
                await this.peer.setLocalDescription(offer)
                this.$socket.emit('giveOffer', { from: this.socketId, to: from, sdp: offer })
            } catch (e) {
                console.log(e)
            }
        },
        // 接收sdp并交换
        async handleCreateAnswer(data) {
            try {
                await this.peer.setRemoteDescription(data.sdp);
                let answer = await this.peer.createAnswer()
                await this.peer.setLocalDescription(answer)
                this.$socket.emit('backOffer', { from: this.socketId, to: data.from, sdp: answer })
            } catch (e) {
                console.log(e)
            }
        },
        // 请求通电
        handleApply(item) {
            this.$socket.emit('apply', { from: this.socketId, to: item.id })
            this.waitDialog = true
            this.waitData = {
                id: item.id,
                name: this.list.find(i => item.id === i.id).name
            }
        },
        // 加入房间
        handleJoinRoom() {
            this.$v.$touch()
            if (!this.$v.$invalid) {
                this.$socket.emit('join', { room: this.room, name: this.name })
                this.isLogin = true
            }
        },
        // 同意通电
        async handleAgree() {
            this.$socket.emit('reply', { from: this.socketId, to: this.applyData.id, status: 1 })
            this.loading = true
            await this.handleCreateP2P(this.applyData.id)
        },
        // 不同意通电
        handleDisagree() {
            this.$socket.emit('reply', { from: this.socketId, to: this.applyData.id, status: 0 })
            this.applyDialog = false
        },
        // 创建p2p连接
        async handleCreateP2P(to) {
            try {
                const stream = await navigator.mediaDevices.getDisplayMedia({ audio: false, video: true })
                let video = document.querySelector('#own')
                video.srcObject = stream
                this.peer = new RTCPeerConnection()
                this.peer.addStream(stream)
                this.peer.addEventListener('addstream', event => {
                    this.loading = false
                    this.applyDialog = false
                    let video = document.querySelector('#other')
                    video.srcObject = event.stream
                })
                this.peer.addEventListener('icecandidate', event => {
                    if (event.candidate) {
                        this.$socket.emit('candidate', {from: this.socketId, to: to, candidate: event.candidate})
                    }
                });
            } catch (e) {
                console.log(e)
            }
        }
    }

}
</script>
<style lang="scss">
.login {
    background: white;
    position: fixed;
    top: 0;
    left: 0;
    width: 100vw;
    height: 100vh;
    z-index: 100000000;
    .container {
        height: 100vh;
    }
    .video-bg {
        position: absolute;
        right: 0;
        bottom: 0;
        left: 0;
        top: 0;
        opacity: 0.3;
        background: radial-gradient(
            circle at 50% 50%,
            rgba(80, 110, 250, 0.1) 0%,
            rgba(80, 110, 250, 0.1) 33.333%,
            rgba(80, 110, 250, 0.3) 33.333%,
            rgba(80, 110, 250, 0.3) 66.666%,
            rgba(80, 110, 250, 0.5) 66.666%,
            rgba(80, 110, 250, 0.5) 99.999%
        );
        .my-video {
            min-width: 100%;
            min-height: 100%;
        }
    }
}
</style>

至此,一个简单的一对一RTC完成了。之后有时间再研究下搭建个STUN和TURN服务器。


本文中如果本人对于一些概念的理解有所错误,希望各位及时点出。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值