webrtc入门

603 阅读11分钟

前言:

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可以通过元素获取,或者某些特殊的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事件,做容错处理,下面的代码有一半是在做这件事的。

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方法来获取到桌面应用的视频流数据,具体获取视频流的写法如下:

//音视频配置信息,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对应上方代码的15-19行,是把媒体流存入webrtc中的过程。另外两个步骤的代码可以简化成下面这样:

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,设置远程和本地描述的过程没有在途中展现,不过代码页挺简单的,如下:

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方法了。

//这里监听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中将媒体流取出来,并在中进行播放。这一步主要通过ontrack事件来完成,代码也很简单:

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而已,代码量实际上不大,当然,真正的云渲染不仅仅是传输视频流这么简单,我们还需要进行通信,还要传递鼠标交互甚至是文件等,这些内容就很多了,我们放到后面去讲,入门的话先学到这吧。