前言:
webrtc的作用是让两个客户端可以进行点对点的连接,使得双方在传递数据时不需要服务端做转发,提高效率。当然,实际的生产工作中,我们并不能完全脱离服务端,两个客户端想要建立链接,必须交换双方的信息,保证能访问到对方,且发送的内容能被对方正确解析,这个交换信息的工作往往需要服务器来完成。
另外,webrtc有自己的数据通道(RTCDataChannel),这是一个独立的通道,和媒体流通道不同,它可以传输任何数据,包括媒体流数据。所以也有人会用这个通道去传输h265的视频流,来避开webrtc不支持h265的这个问题。
webrtc建立连接的过程通常是由一方先发起的,即发起者,另一方作为应答者,类似于打电话,肯定是一方先打电话给另一方的,至于两者哪个是提供视频的,哪个是展示视频的,跟是否为发起者无关。
准备工作:
1.一个推流客户端,用于创建视频流,并将视频流推送给拉流客户端。
2.一个拉流客户端,用户接受推流客户端的视频流,并进行展示。
3.一个信令服务器,用于交换两个客户端的配置信息,包括音视频编解码器和网络情况等,信令服务器往往会开启两个websocket服务,分别用于接受客户端和服务端。
储备知识:
1.SDP:全名Session Description Protocal,中文名叫会话描述协议,在webrtc中主要用于描述设备的音视频编解码能力和网络情况。在后面的内容中,我会称呼其为offer和answer,分别对应发起者和应答者的sdp。
2.ICE:全名Interactive Connectivity Establishment,中文名叫交互式连接建立,它会同时使用STUN和TURN两种网络穿透技术,来帮助两个客户端建立端到端的网络连接。这里因为我们是纯内网的教学,所以不介绍STUN和TURN了,因为用不到,想了解的小伙伴自行百度。
3.WebSocket:一种保持长连接的双向通信的协议。可以做到客户端和服务端的实时数据传输。
4.webrtc相关api:
a.RTCPeerConnection,可以通过let pc = new RTCPeerConnection()的方式来创建一个webrtc实例,这是建立webrtc连接的起点,必须先创建webrtc实例,才能调用后续的方法。
b.RTCSessionDescription,可通过let offer = new RTCSessionDescription(offer)的方式来创建一个sdp,因为经过websocket转发后,sdp会变成json字符串,所以我们需要该方法来复原sdp对象。
c.RTCIceCandidate,类似于RTCSessionDescription,只不过它是用于复原ice对象的,我们不需要真的创建一个新的sdp或ice对象,我们调用这两个方法只是为了复原。
d.addTrack,调用方法: pc.addTrack(track, stream),一个属于应答者的监听事件,调用该方法可以将应答者的媒体流存入webrtc实例中,当两个客户端建立连接后,发起者的webrtc实例可以通过监听ontrack事件来获取到对应的媒体流。其中的track和stream可以通过<video>元素获取,或者某些特殊的api,比如getDisplayMedia,相关知识可见MDN。
e.createOffer,调用方法:let offer = await pc.createOffer()。这里最好用async...await...包裹一下,因为createOffer会返回一个promise。该方法一般由webrtc的发起者调用,返回的offer需要借助websocket服务发给另一个客户端。
f.createAnswer,调用方法:let answer = await pc.createAnswer()。和createOffer类似(其实本质是一样的,不过是作为应答者才需要调用的方法而已)。
g.setLocalDescription,调用方法:pc.setLocalDescription(offer)。调用即可,无需返回,一般不会失败的,用于设置本地描述。该方法在两个客户端都需要执行,发起者的参数是offer,应答者的参数是answer。
h.setRemoteDescription,调用方法:pc.setRemoteDescription(offer)。和setLocalDescription类似,不过是用于设置远程描述的,所以在发起者那边的参数是answer,在应答者这边的参数是offer,恰恰相反。
i.onicecandidate,调用方法:pc.onicecandidate=function(event){......},一个监听事件,当客户端查询到各自的网络信息后,会触发该事件,并给回调函数传入一个参数event,我们可以通过event.candidate来获取其中的信息,然后通过websocket服务发送给另一方,该事件在发起者和应答者都需要实现,并且不像sdp那样还要区分为offer和answer。
j.addIceCandidate,调用方法:pc.addIceCandidate(candidate),也是一个异步方法,不过没有返回值,所以调用即可,用于将另一方的candidate信息存到自己的webrtc实例中。
k.ontrack,调用方法:pc.ontrack=function (event){......},一个属于发起者的监听事件,该事件在获取到应答者的媒体流后触发,我们可以通过event.streams来拿到所有的媒体流,注意这是个数组,数组元素的多少取决于应答者执行了多少的addTrack。
工作流程:
这是一张从网上找的图,我觉得是能比较清晰的说明整个webrtc的工作流程的:
下面给大家讲解一下这张图,同时附上一些代码实现:
1.首先介绍四个角色:
a.Client A,该客户端用于提供视频流。
b.Stun Server,网络穿透服务器,用于查询Client A和Client B的网络信息,如果Client A和Client B处于同一内网环境下,一般来讲,是不用这个的。
c.Signal Server,信令服务器,用于交换两个客户端之间的信令(sdp和ice)
d.Client B,该客户端用于获取Client A推送过来的视频流,并进行展示。
2.第一步必定是启动信令服务器,因为两个客户端之间建立连接依赖于该服务器,信令服务器一般只需创建两个websocket,等待两个客户端连接自己。而信令服务器的功能也很简单,用两个变量streamer和player分别记录两个客户端的websocket连接,当Client A的服务(即streamer)触发message事件时,streamer会把answer(另一种格式的sdp,其本质也是sdp)和ice通过player的send方法,发送给Client B;当Client B的服务(即player)触发message事件时,player会把offer(也是sdp)和ice通过streamer的send方法,发送给推流端。当然服务器还需监听一些error,close事件,做容错处理,下面的代码有一半是在做这件事的。
Signal Server:
var clientConfig = { type: 'config', peerConnectionOptions: {} }; //webrtc对象的配置信息
let player; //拉流端websocket对象
let streamer; // 推流端websocket对象
//创建推流端websocket服务
let WebSocket = require('ws');
let streamerServer = new WebSocket.Server({ port: 5501, backlog: 1 });
streamerServer.on('connection', function (ws, req) {
ws.on('message', function onStreamerMessage(msg) {
try {
msg = JSON.parse(msg);
} catch(err) {
streamer.close(1008, 'Cannot parse');
return;
}
try {
//streamer通过player的send方法进行信息交换
if (msg.type == 'answer') {
player.send(JSON.stringify(msg));
} else if (msg.type == 'iceCandidate') {
player.send(JSON.stringify(msg));
} else if (msg.type == 'disconnectPlayer') {
player.close(1011 /* internal error */, msg.reason);
} else {
console.error(`unsupported Streamer message type: ${msg.type}`);
streamer.close(1008, 'Unsupported message type');
}
} catch(err) {
console.error(`ERROR: ws.on message error: ${err.message}`);
}
});
ws.on('close', function(code, reason) {
try {
console.error(`streamer disconnected: ${code} - ${reason}`);
} catch(err) {
console.error(`ERROR: ws.on close error: ${err.message}`);
}
});
ws.on('error', function(error) {
try {
console.error(`streamer connection error: ${error}`);
ws.close(1006 /* abnormal closure */, error);
} catch(err) {
console.error(`ERROR: ws.on error: ${err.message}`);
}
});
streamer = ws;
streamer.send(JSON.stringify(clientConfig));
});
//创建拉流端
let playerServer = new WebSocket.Server({ port: 5502, backlog: 1 });
playerServer.on('connection', function (ws, req) {
if (!streamer || streamer.readyState != 1 /* OPEN */) {
ws.close(1013 /* Try again later */, 'Streamer is not connected');
return;
}
ws.on('message', function (msg) {
try {
msg = JSON.parse(msg);
} catch (err) {
console.error(`Cannot parse player message: ${err}`);
ws.close(1008, 'Cannot parse');
return;
}
//player通过streamer的send方法进行信息交换
if (msg.type == 'offer') {
streamer.send(JSON.stringify(msg));
} else if (msg.type == 'iceCandidate') {
streamer.send(JSON.stringify(msg));
} else {
console.error(`unsupported message type: ${msg.type}`);
ws.close(1008, 'Unsupported message type');
return;
}
});
ws.on('close', function(code, reason) {
console.log(`player connection closed: ${code} - ${reason}`);
});
ws.on('error', function(error) {
console.error(`player ${playerId} connection error: ${error}`);
ws.close(1006 /* abnormal closure */, error);
});
player = ws;
ws.send(JSON.stringify(clientConfig));
});
3.然后推流端启动,连接websocket服务器,成功连接后,推流端会收到来自服务端发送的webrtc配置信息:
{ type: 'config', peerConnectionOptions: {} },收到该信息后,推流端可以调用浏览器的navigator.mediaDevices.getDisplayMedia方法来获取到桌面应用的视频流数据,具体获取视频流的写法如下:
Client A:
//音视频配置信息,video属性可以是一个对象,用于设置视频的分辨率,比如:
// video:{ width:1280, height:720 }
const CONSTRAINTS={
audio:false,
video:true
}
//websocket服务连接成功后调用该函数,传入的peerConnectionOptions是信令服务器发过来的webrtc配置项
async function initMedia(peerConnectionOptions){
try{
let stream = await navigator.mediaDevices.getDisplayMedia(CONSTRAINTS);
//playerRef.current指向一个video元素,用于预览获取到的画面
playerRef.current.srcObject = stream;
//该函数根据peerConnectionOptions创建一个webrtc实例,具体实现见后续步骤
setupWebRtcPlayer(peerConnectionOptions);
stream.getTracks().forEach(track=>{
// webrtc.current指向webrtc实例,这一步是把获取到的媒体流加入webrtc的媒体流轨道中,
// 当两个客户端连接成功后,拉流端可以从轨道中获取到推流端的媒体流。
webrtc.current.addTrack(track, stream);
});
}catch(e){
console.log("组件"+id+"出错:",e);
}
}
4.接下来,推流端将进入待命状态,直到拉流端来获取视频流。对于拉流端而言,它也需要连接websocket服务,在连接websocket服务成功后,它会和推流端一样创建一个webrtc实例(即setupWebRtcPlayer方法)。当然,推流端和拉流端需要实现的webrtc实例是不一样的,从上图中可以看出,在双方都连接websocket服务成功后,推流端执行下面三个方法:
a. Create PeerConnection
b.Add Streams
c.Create Offer
其中Add Streams对应上方代码的 stream.getTracks()...... ,是把媒体流存入webrtc中的过程。另外两个步骤的代码可以简化成下面这样:
Client A:
async function setupWebRtcPlayer(peerConnectionOptions){
try{
let pc1 = new RTCPeerConnection(peerConnectionOptions); //Create PeerConnection
let offer = await pc1.createOffer(); //Create Offer
await pc1.setLocalDescription(offer); //这里把创建的offer存储为本地描述
ws.current.send(JSON.stringify(offer)); //通过websocket服务发送offer,ws.current指向一个webscocket实例
return pc1;
}catch(e){
console.log("setupWebRtcPlayer fail:",e);
}
return;
}
5.接下来就是把创建的offer通过websocket服务发送给信令服务器,然后信令服务器转发给Client B,Client B在接收到offer后,也会创建一个webrtc实例(即Create PeerConnection),不过后续的操作就不太一样了,我们需要在Client B中将offer设置为远程描述,然后创建answer,将answer设置为Client B的本地描述,然后通过信令服务器将answer发给Client A,设置远程和本地描述的过程没有在途中展现,不过代码页挺简单的,如下:
Client B:
async function setupWebRtcPlayer(peerConnectionOptions,offer){
try{
let pc2 = new RTCPeerConnection(peerConnectionOptions); //Create PeerConnection
await pc2.setRemoteDescription(offer); //将Client A的offer设置为远程描述
let answer = await pc2.createAnswer(); //创建answer
await pc2.setLocalDescription(answer); //这里把创建的answer存储为本地描述
...... //通过信令服务器发送answer的代码就省略了
return pc2;
}catch(e){
console.log("setupWebRtcPlayer fail:",e);
}
return;
}
6.当Client A收到Client B发过来的answer之后,它会将answer设置为Client A的远程描述,注意我们在客户端的setupWebRtcPlayer函数中return 了pc1和pc2,这两个都是webrtc实例,可以调用相关的api,所以我们只需在Client A中执行 pc1.setRemoteDescription(answer) 即可将answer设置为远程描述。
7.到此为止,我们已经将两个客户端之间的offer(也叫sdp)交换完毕,剩下的就简单多了,两个客户端会自动向STUN Server查询自己的公网IP信息(内网不需要),然后得到ice,触发各自的onicecandidate事件,同时通过websocket服务,将各自的ice发送给对方。所以,我们需要再两个客户端上分别实现onicecandidate和执行addIceCandidate方法了。
Client A、Client B:
//这里监听icecandidate事件,然后发送对应的ice信息
pc1.onicecandidate = e=>{
ws.current.send(JSON.stringify({ type: "iceCandidate", candidate: e.candidate }));
}
//在两个客户端都需要对type = iceCandidate的消息进行监听,先复原收到的ice对象,然后执行addIceCandidate方法
ws.current.onmessage=e=>{
const msg = JSON.parse(e.data);
const { type } = msg;
switch(type){
......
case 'offer':
//sdp在接受到之后也需要进行复原,然后再执行相关操作
let offerDesc = new RTCSessionDescription(msg);
......
break;
case 'answer':
let answerDesc = new RTCSessionDescription(msg);
......
break;
case 'iceCandidate':
let candidate = new RTCIceCandidate(msg.candidate);
webrtc.current.addIceCandidate(candidate);
break;
......
}
}
8.当双方的ice交换完毕后,意味着Client A和Client B已经成功连接了,不过我们还需要执行最后一步,在Client B中将媒体流取出来,并在<video>中进行播放。这一步主要通过ontrack事件来完成,代码也很简单:
Client B:
pc2.ontrack=e=>{
//video.current指向的是一个video标签,e.streams[0]是指第一个媒体流。
video.current.srcObject = e.streams[0];
}
云渲染:
UE的云渲染也是基于webrtc去做的,大家可以把启动后的UE程序理解为Client A,我们的网页就是Client B,在启动信令服务器和UE程序后,Client B需要做的就是以下几个步骤:
1.Create PeerConnection
2.Create Offer //这里咱们不需要add stream,因为 add stream是UE来做的。
3.Send Offer SDP
4.Relay Answer SDP
5.On Ice Candidate
6.Send Ice Candidate
7.Relay Ice Candidate
8.Add Ice Candidate
9.on Add Stream //没错,因为我们没做add stream,所以我们就需要去接受视频流,添加ontrack事件。
看上去步骤挺多的,但其实就是调用一下websocket和webrtc相关的api而已,代码量实际上不大,当然,真正的云渲染不仅仅是传输视频流这么简单,我们还需要进行通信,还要传递鼠标交互甚至是文件等,这些内容就很多了,我们放到后面去讲,入门的话先学到这吧。
文章
3.2K人气
8粉丝
1关注
©Copyrights 2016-2022 杭州易知微科技有限公司 浙ICP备2021017017号-3 浙公网安备33011002011932号
互联网信息服务业务 合字B2-20220090