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服务器。
本文中如果本人对于一些概念的理解有所错误,希望各位及时点出。