如何实现两个人的实时视频通话呢?你可能会首先想到直播手段:采流 -> 推流 -> 拉流。但是,如果把这个过程放在前端去实现,那只能天台见了。然而,WebRTC的出现,扭转了这个现状。
借助WebRTC,前端可以不用去关注“采流 -> 推流 -> 拉流”这种过程,倾轻松就可以实现直播甚至实时音视频通话。
WebRTC是什么?底层原理是什么?怎么用?让烧烤哥给你娓娓道来!
什么是WebRTC?
WebRTC全称Web Real-time Communication,网页即时通讯技术。它是由Google发起的一个的实时通讯解决方案。之所以称为一个方案,而不是协议,是因为它涵盖了音视频采集、通讯的建立、信息传输、音视频显示等整套的实现方案。该方案的发起让加快速地实现一个音视频通讯应用成为可能。
如果你是web开发者,通过浏览器提供的WebRTC API可以轻松的实现音视频的采集和播放,实现端对端的通讯通道的建立,并可通过建立的通道实现音视频的数据的共享。
虽然WebRTC看起来是浏览器的玩意,但是由于谷歌的开源精神,它可以通过编译C++的代码实现全平台互通。所以,如果你想通过web远程控制windows电脑,可以让你的C++的小朋友接一波WebRTC,WebRTC还支持实时采集桌面哦!
WebRTC是如何实现端对端的音视频共享的?
传统的资源共享更多的是通过一个中转服务器进行交换的。把对方想要的资源提前上传到固定的公网服务器中,然后再通过地址进行访问。这种形式好的好处是可靠性非常强,因为资源服务器是固定的,不会受传输方和使用方的网络影响,非常灵活可靠!
但是,实时性很差。需要等到对方把文件上传完成之后才可以下载文件。当然,这个流程也可以达到实时的,可以在服务器搭建一个文件流中转,让到达服务器的文件流立即传输给拉取方。如此麻烦,为什么不省略掉这个服务器呢?
P2P连接
P2P全称peer to perr,学术名称为对等网络,它是一种网络技术和网络拓扑结构。建立起P2P连接的设备可以不经过第三方服务的转发,就可以达到1对1的信息传输交换。
那么如何创建一个P2P的连接呢?这里我们先看看现实的互联网世界是这样进行通讯的!
现实的网络世界
现实世界中的网络是这样的:
因为目前互联网大部分部署的网际协议(IP协议)版本是IPV4,然而IPV4使用的是32位2进制的地址,能够产生43亿个IP地址,如果每一个用户终端都以独立的IP地址接入互联网,那么这43亿的地址将不够分。
所以目前的网络结构基本是多设备终端通过一层或多层的NAT代理接入到互联网中,也就是局域网。
NAT是什么?
NAT:网络地址转换,它是一种解决专用网络内设备连接公网的技术。
那么NAT的工作原理是什么呢?
当设备A想发送一个请求到服务器172.20.98.44:7777 => 8.8.8.8:23456,首先请求会先到达NAT,由NAT修改报文的源地址和源端口以及相应的校验码,然后才会发送给服务器,此时就形成流一个映射关系:
172.20.98.44:7777 => 6.6.6.6:12345 => 8.8.8.8:23456
当服务器处理完成请求后响应数据返回到NAT时,NAT根据映射关系,修改目的地址和目的端口以及相应的校验码,然后再发送给设备A:
8.8.8.8:23456 => 6.6.6.6:12345 => 172.20.98.44:7777
这就是NAT使得内网设备能够正常访问公网服务器到原理特性。
处于内网中的设备可以借助NAT实现公网服务器的访问,但是在P2P连接中可能两个设备都处于不同的内网中,这两个设备如何进行P2P连接呢?
【免费分享】音视频学习资料包、大厂面试题、技术视频和学习路线图,资料包括(C/C++,Linux,FFmpeg webRTC rtmp hls rtsp ffplay srs 等等)有需要的可以点击788280672加群免费领取~
NAT穿透技术
要使得处于两个内网中的两个设备能够建立P2P连接的技术方案被统称为NAT穿透技术。一般来说,P2P可以建立UDP连接也可以建立TCP连接,所以这个机制又被称作UDP打洞或TCP打洞。因为webRTC使用的传输层协议是UDP协议,所以我这里主要讲解UDP打洞原理。
- 第一步,添加一个信使服务器。信使服务器的作用是发现、记录内网设备在NAT中映射的端口以及其NAT的公网IP。这种服务器也被称为STUN服务器。根据NAT的特性,当设备A向STUN服务器发送请求时,会形成映射关系:172.20.98.44:7777 => 6.6.6.6:12345 => 3.3.3.3:34567,这时STUN服务器将6.6.6.6:12345响应给设备A。同样的,设备B也进行响应的请求以获取对应的映射关系。
交换映射关系。设备A和设备B需要将自己在NAT的映射关系交换,为下一步建立连接做准备。这里需要另外协议进行交换。
交换完成之后,设备A向设备B的NAT-B地址8.8.8.8:23456发送请求包,因为这个请求不是设备B主动发起的,为了安全考虑,NAT-B接收到这个请求包之后不会转发给设备B,而是抛弃掉,但是NAT-A根据特性记录这个映射关系,后面从地址8.8.8.8:23456发来的包都会转发给设备A。同样的,设备B也向6.6.6.6:12345发起同样的请求,让NAT-B知道后面从6.6.6.6:12345发来的请求都转发给设备B。
完成上面的动作之后,设备A和设备B就可以建立P2P连接愉快的发送消息了。当然这个连接需要心跳包维持,防止被close掉。
上面就是一个完整的UDP打动过程,完成打洞之后设备就可以跨过NAT实现P2P连接了。
WebRTC是基于UDP实现的端对端连接
基于前面认知,WebRTC实现端对端的音视频传输也需要处理UDP打洞这个过程。在我们调用WebRTC创建端对端的连接时并不会去实现这个UDP打洞的过程(如果要,那就和提出WebRTC的初衷相背离了,实现方便快捷地建立音视频即时通讯是WebRTC所追求的)。更多的,我们需要按照流程调用API去完成WebRTC的创建。
从创建WebRTC连接中学习WebRTC的API使用
一起看一下创建连接的整个过程:
信令服务器:一种用于信息交换的服务,它在WebRTC过程中的作用是作为端交换建立连接所需要的信息。这个信令服务器实现不在WebRTC的方案范围,因为这个实现更贴近业务本身,而且有更多的实现方式搭配不同的业务场景。
虽然WebRTC的兼容性很不错(除了那个不可一世的IE),但是每个浏览器在提供顶层API的时候还是存在差异的,所以我们需要一个垫片解决这个差异:webrtcHacks/adapter
分解一下:
- 创建RTC实例。WebRTC提供了用于实例化连接的API RTCPeerConnection。下示代码,其中configuration可选,configuration用于配置STUN/TURN服务器信息的。这里STUN/TURN服务器也是需要自己搭建,有需求的看部署stun和turn服务器。
let connection = new RTCPeerConnection([configuration]);
如果不配制configuration,那意味着这个连接只能在内网进行。
- 访问摄像头麦克风设备。通过下示代码可以不依赖任何插件的情况下打开摄像头麦克风(此时浏览器会请求授权)并获取媒体流。其中constraints指请求的媒体类型和相对应的参数;mediaStream指的是媒体流,可以将mediaStream赋值给video元素的srcObject属性实现实时媒体流播放。更多关于mediaDevices信息可以看这里
navigator.mediaDevices.getUserMedia(constraints)
.then(function(mediaStream) { ... })
.catch(function(error) { ... })
getUserMedia在本地环境localhost以及可信域名(https)情况下才能够正常获取媒体流,否则会报错。
- 将媒体流加入到RTCPeerConnection实例中。
connection.addTrack(mediaStream.getVideoTracks()[0], stream);
- 交换SDP。WebRTC使用的是Offer-Answer应答模式交换Offer。首先由发起方通过createOffer创建Offer SDP,通过信令服务传递给接收方;接收方通过createAnswer创建Answer SDP,通过信令服务器传递给发起方。双方需要通过setLocalDescription、setRemoteDescription将自己生成和对等方传来的SDP设置到连接中。
SDP(Session Description Protocol) 是一种会话描述协议,基于文本,其本身并不属于传输协议,需要依赖其它的传输协议(比如 SIP 和 HTTP)来交换必要的媒体信息,用于两个会话实体之间的媒体协商。SDP里面包含了建立会话需要的媒体信息、网络信息、安全特性、传输策略。
注意:如果传输的是音视频流,在生成Offer SDP之前需要通过addTrack将媒体流添加到信道中,原因是生成SDP时需要采集流信息。如果不添加,则不会生存SDP。
//添加流媒体信息
connection.addTrack(stream.getVideoTracks()[0], stream);
//发起方创建Offer SDP
connection
.createOffer()
.then((sessionDescription) => {
console.log("发送offer");
if (connection) {
console.log("设置本地description");
connection.setLocalDescription(sessionDescription);
}
sendMessage(sessionDescription, targetId);
})
.catch(() => {
console.log("offer create error");
});
//接收方设置远端描述
connection.setRemoteDescription(
new RTCSessionDescription(sessionDescription)
);
//接收方生成Answer SDP
connection
.createAnswer()
.then((sessionDescription) => {
console.log("发送answer");
if (connection) {
console.log("设置本地description");
connection.setLocalDescription(sessionDescription);
}
sendMessage(sessionDescription, targetId);
})
.catch(() => {
console.log("创建answer失败");
});
//发起方设置远端描述
connection.setRemoteDescription(
new RTCSessionDescription(sessionDescription)
);
5、交换候选人信息candidate。连接双方通过监听RTCPeerConnection的icecandidate事件得到本身的候选人信息,然后通过信令服务传输给对等方。候选人信息candidate交换是建立webRTC连接的重要步骤。得到对等方的candidate之后,需要实例化RTCIceCandidate对象,然后通过RTCPeerConnection的addIceCandidate方法添加到Rtc实例中。
候选人信息candidate包含了当前设备所对应的NAT映射信息,包括:ip、端口、协议
icecandidate事件触发的时机是在setLocalDescription执行之后。
//监听icecandidate的触发
connection.addEventListener("icecandidate", (event) => {
if (event.candidate) {
console.log("发送candidate", event.candidate.candidate);
sendMessage(
{
type: "candidate",
label: event.candidate.sdpMLineIndex,
id: event.candidate.sdpMid,
candidate: event.candidate.candidate,
},
targetId
);
} else {
console.log("End of candidates.");
}
});
//添加候选人信息
const candidate = new RTCIceCandidate({
sdpMLineIndex: message.label,
candidate: message.candidate,
});
connection.addIceCandidate(candidate).catch((error) => {
console.log(error);
});
以上大概是完成整个WebRTC建立连接所需要的API,除此之外,还有很多关于连接的实践监听、关于创建连接过程中音视频编码的协商、以建立data channel的方式创建WebRTC连接等等API,有兴趣的可以查阅WebRTC API。
实战
了解玩WebRTC的一些主要API,让我们一起实战完成一个例子——开篇所说的实现两个人的实时通讯,这里我们来实现多人的同时视频通讯。这里主要是基于内网实现的例子,通过内网ip访问服务。
这里的实现主要分两部分,遵循前后端分离的思想,一部分是信令服务的实现,一部分是前端交互页面的实现。
第一步,创建信令服务:express + http://socke.io
前面说过,信令服务最主要的作用是创建连接时的信息中继。这里为了简单实现功能,使用的是node的express框架以及可支持双向通讯的socket.io;
创建Https服务
因为我们的网页如果需要多人在线视频通话,那我们的网站时需要分享出去的。前面说过,只有localhost或者可行地址才能访问浏览器媒体设备。所以这里为了避免跨协议所带来的问题,需要创建一个https服务。
const app = require("express")();
const fs = require("fs");
//读取证书
const key = fs.readFileSync(
"/Users/XXX/Documents/study/https/172.20.210.160.key",
"utf8"
);
const cert = fs.readFileSync(
"/Users/XXX/Documents/study/https/172.20.210.160.crt",
"utf8"
);
const http = require("https").Server(
{
key,
cert,
},
app
);
//监听3005端口
http.listen(3005, function () {
console.log("listening on *:3005");
});
其实创建https还可以通过配置nginx的形式,这里我比较不想搞nginx,所以就代码实现了。
跨域设置
因为页面和信令服务是两个不同的服务,会存在跨域的问题,入口文件需要加入以下代码:
const allowCors = function (req, res, next) {
res.header("Access-Control-Allow-Origin", req.headers.origin);
res.header("Access-Control-Allow-Methods", "GET,PUT,POST,DELETE,OPTIONS");
res.header("Access-Control-Allow-Headers", "Content-Type");
res.header("Access-Control-Allow-Credentials", "true");
next();
};
app.use(allowCors);
http://socket.io信息中继机制的实现。
实现很简单,代码完成一个机制,不得不赞叹开源世界的伟大。
const socketIo = require("socket.io");
function createSocketIo(httpInstance) {
//初始化实例,支持跨域
const io = socketIo(httpInstance, {
cors: {
origin: "*",
allowedHeaders: ["Content-Type"],
methods: ["GET,PUT,POST,DELETE,OPTIONS"],
},
});
//监听每一个连接
io.on("connection", function (socket) {
//监听连接上的单个端,同时像其他端发送新人加入消息
socket.on("connect", () => {
console.log("连上了");
socket.joinRoom("demo", () => {
socket.broadcast.to("demo").emit("new", socket.id);
});
});
//消息中转
socket.on("message", (message) => {
if (message.target) {
socket.to(message.target).emit("message", {
originId: socket.id,
data: message.data,
});
}
else {
socket.broadcast.to('demo').emit("message", {
originId: socket.id,
data: message.data,
});
}
});
});
}
module.exports = createSocketIo;
第二步,前端交互界面的实现
前端交互界面主要的作用是通过信令服务与加入房间的端建立WebRTC连接。
这里用户交互界面使用creat-react-app快速创建项目。
其中用户交互界面两部分,一部分是WebRTC的创建连接的逻辑,另一部分是http://socket.io的交互逻辑。
封装WebRTC连接,支持多对等方连接
//无敌垫片
import "webrtc-adapter";
class ConnectWebrtc {
protected connection: RTCPeerConnection | null;
constructor() {
this.connection = null;
}
//创建RTCPeerConnection实例,同时监听icecandidate,track事件
create(
onAddStream: EventListenerOrEventListenerObject,
onReomveStream: EventListenerOrEventListenerObject,
onCandidate: (candidate: RTCIceCandidate) => void
) {
this.connection = new RTCPeerConnection(undefined);
this.connection.addEventListener("icecandidate", (event) => {
if (event.candidate) {
onCandidate(event.candidate);
} else {
console.log("End of candidates.");
}
});
this.connection.addEventListener("track", onAddStream);
this.connection.addEventListener("removeTrack", onReomveStream);
}
//创建offer sdp
createOffer(
onSessionDescription: (
sessionDescription: RTCSessionDescriptionInit
) => void
) {
if (this.connection) {
this.connection
.createOffer()
.then((sessionDescription) => {
if (this.connection) {
this.connection.setLocalDescription(sessionDescription);
onSessionDescription(sessionDescription);
}
})
.catch(() => {
console.log("offer create error");
});
}
}
//创建answer sdp
createAnswer(
onSessionDescription: (
sessionDescription: RTCSessionDescriptionInit
) => void
) {
if (this.connection) {
this.connection
.createAnswer()
.then((sessionDescription) => {
if (this.connection) {
this.connection.setLocalDescription(sessionDescription);
}
onSessionDescription(sessionDescription);
})
.catch(() => {
console.log("创建answer失败");
});
}
}
//设置远端描述
setRemoteDescription(
sessionDescription: RTCSessionDescriptionInit | undefined
) {
this.connection?.setRemoteDescription(
new RTCSessionDescription(sessionDescription)
);
}
//设置候选人
setCandidate(message: any) {
if (this.connection) {
const candidate = new RTCIceCandidate({
sdpMLineIndex: message.label,
candidate: message.candidate,
});
this.connection.addIceCandidate(candidate).catch((error) => {
console.log(error);
});
}
}
//讲媒体流添加到连接中
addTrack(stream: MediaStream) {
if (this.connection) {
this.connection.addTrack(stream.getVideoTracks()[0], stream);
this.connection.addTrack(stream.getAudioTracks()[0], stream);
}
}
//从连接中删除媒体流
removeTrack() {
if (this.connection) {
this.connection.removeTrack(this.connection.getSenders()[0]);
}
}
}
export default ConnectWebrtc;
通过封装创建连接到关键步骤,可以实现创建多个WebRTC的连接。
页面交互组件与http://socket.io连接
import { useEffect, useRef, useState } from "react";
import { io, Socket } from 'socket.io-client';
import { server } from "./config";
import ConnectWebrtc from "./webrtc";
//媒体设备采集配置
const mediaStreamConstraints = {
video: {
width: 400,
height: 400
},
audio: true
};
const Room = () => {
//本地流
const localStream = useRef<MediaStream>();
//播放本地流的video标签
const localVideoRef = useRef<HTMLVideoElement>(null);
//保存多个连接实例对象
const connectList = useRef<{ [target: string]: any }>({});
//连接用户的列表
const [userlist, setUserList] = useState<string[]>([]);
//socket.io实例
let socket = useRef<Socket>();
//发送消息到指定对等方
const sendMessage = (data: any, targetId?: string | null) => {
socket.current?.emit('message', {
target: targetId,
data
})
}
//将对等方传来的媒体流添加到指定video标签中
const handeStreamAdd = (originId: string) => (event: any) => {
let video = document.getElementById(originId) as HTMLVideoElement;
if (video) {
video.srcObject = event.streams[0];
}
}
//获取与指定对等方WebRTC连接实例,如果不存在,则创建
const getConnection = (originId: string) => {
let connection = connectList.current?.[originId];
if (!connection) {
connection = new ConnectWebrtc();
connection.create(handeStreamAdd(originId), () => { }, (candidate: RTCIceCandidate) => {
sendMessage(
{
type: "candidate",
label: candidate.sdpMLineIndex,
id: candidate.sdpMid,
candidate: candidate.candidate,
},
originId
);
});
//优先将媒体流添加到连接中
connection.addTrack(localStream.current);
connectList.current[originId] = connection;
}
return connection;
}
//创建与信令服务的socket.io连接
const handleConnectIo = () => {
socket.current = io(server);
socket.current.on('connect', () => {
console.log('连上了');
});
//监听消息
socket.current.on('message', function (message) {
//添加对等方
if (!userlist.includes(message.originId)) {
userlist.push(message.originId);
setUserList([...userlist])
}
let connection = getConnection(message.originId);
//当作为接收方时,设置远端描述,并创建answer sdp
if (message.data.type === 'offer') {
connection.setRemoteDescription(message.data);
connection.createAnswer((sdp) => {
sendMessage(sdp, originId);
});
}
//当作为发起方时,收到answer sdp则设置为远端描述
else if (message.data.type === 'answer') {
connection.setRemoteDescription(message.data);
}
//当收到候选人信息时,将候选人信息加入到连接中
else if (message.data.type === 'candidate') {
connection.setCandidate(message.data);
}
});
//当收到新用户加入房间时,主动发起WebRTC连接
socket.current.on('new', (newId) => {
const connection = getConnection(newId);
connection.createOffer((sdp) => {
sendMessage(sdp, originId);
});
if (!userlist.includes(newId)) {
userlist.push(newId);
setUserList([...userlist])
}
})
}
//打开本地媒体设备并设置到video标签中进行播放
const handleGetLocalStream = (callback: () => void) => {
navigator.mediaDevices.getUserMedia(mediaStreamConstraints)
.then((mediaStream) => {
localStream.current = mediaStream;
if (localVideoRef.current) {
localVideoRef.current.srcObject = mediaStream;
}
callback();
}).catch((error) => {
console.log(error)
});
}
//组件挂载是优先打开媒体设备然后再建立socket.io连接
useEffect(() => {
handleGetLocalStream(() => {
handleConnectIo();
});
}, []);
return (
<div>
<div style={{ marginTop: 20 }}>
<p>我的画面</p>
<video ref={localVideoRef} autoPlay playsInline ></video>
</div>
<div style={{ marginTop: 20 }}>
<p>其他人的画面</p>
{
userlist.map(user => {
return <video id={user} key={user} autoPlay playsInline></video>
})
}
</div>
</div>
)
}
export default Room;
上面的这段组件代码清晰的展示了信令服务在整个WebRTC连接过程中的重要作用,可以说是主导整个流程的进行。整个过程还有很多细节还没有实现,但这不是我这里所要追求的。更重要的展示多方WebRTC实时通讯连接建立的大致实现过程。
实现的效果与头部的样子差不多,这里就不展示了。
延伸
用一张图表示上面实战中用户之间的关系:
错综复杂的关系,如果再加几百几万个连进来,那岂不是爆炸了。
所以,P2P连接是一种去中心化的连接方式,它适用于小量的连接,对于大量的连接需要的,最后还是的得回归中心化思想,由中心服务去实现去中心的能力(如CDN)。
乍一看,这不会又回到了传统的音视频直播的路子了。那我为什么要选择WebRTC呢?
从技术选型等角度上说,WebRTC对比其他的音视频协议具有更好的兼容性和可扩展性(因为它是开源的);再者,WebRTC连接的延时更低,这对于现要求实时性互动性越来越高的直播行业是一个非常不错的方案。
目前已经有一些对实时互动要求高的行业在使用WebRTC方案了。可以说是前景很光明。
总结
本文从原理到实战角度简单剖析了WebRTC,WebRTC是一个很复杂的方案,肯定不是我这一篇文章能够讲得通的。如果有不对地方,欢迎指正。