书接上回
上一篇中我们简单的介绍了WebRTC
的一些历史和API
的用法。在这一篇中,我们继续来学习一些关于WebRTC
的架构、协议,以及在真实网络情况下WebRTC
是如何进行通信的。
架构
WebRTC
是一个点对点通讯的框架,它的架构实现遵从 JESP
( JavaScript Session Establishment Protocol),如图

在图中,我们可以看到我们上一篇说到的会话描述(SessionDescription)
用于描述双方的会话信息,它也是一个标准格式,称为 Session Description Protoco,简称 SDP
,这一个SDP
对象序列化之后的样子。
v=0
o=- 3445510214506992100 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE video
a=msid-semantic: WMS EeiMAMV43kTkrOafBzAUtKcLGJupxSVVrbI4
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 102 123 127 122 125 107 108 109 124
c=IN IP4 0.0.0.0
...
a=ssrc:506345433 label:084a2d08-ec72-40ca-aeaa-6146cbe26fd9
简单来说,就是我们的应用把会话描述
交给WebRTC
然后就会帮我们把P2P
通信啥的都搞定。我们只有调用API
获得我们最终需要的信息即可。那这里可以会有小伙伴问了,为啥要用SDP
呢,看起来这么奇怪,谷歌完全可以自己做一套呢?答案当然是为了兼容性跟不重复造轮子,试想如果别的公司也弄了一个RTC
框架,只要用的也是SDP
那么他们是完全可以兼容的,因为你们用的是一样的的语言
进行会话。
信令 signaling
从图中我们还看到另一个东西,那就是信令(signaling)。这里我不得不感叹前辈的翻译,这个翻译真的是信达雅的典范。信令简单来说,就是传输各种连接过程中的信息。它在这里传递了WebRTC
3个重要信息,也就是上一篇我们提到的offer
、answer
和candidate
。offer
和answer
其实就是用于创建和交换双方的会话描述,格式就是上面提到的SDP
,这里就不展开说了。而candidate
也是来源与一个规范ICE framework,在建立通讯之前,我们需要获得双方的网络信息,例如 IP
、端口等,而这一个框架就是用于规范这一个过程,candidate
便是用于保存这些东西的。一般candidate
是有多个的,因为我们的网络环境通常是很复杂的,按照我的理解每经过一次NAT
都会又一个candidate
。在WebRTC
中,一般需要这样操作
A
创建了一个注册了onicecandidate
响应方法的RTCPeerConnection
对象- 这个响应方法在网络
candidates
准备好后调用 - 这个响应方法,
A
通过传输信令的通道发送了一个字符串化的的candidate
数据到B
- 当
B
获得A
穿过来的candidate
信息后,他需要调用addIceCandidate
把candidate
添加到远程的节点描述
值得注意的是JSEP
支持 ICE Candidate Trickling,这意味着,它允许在offer
初始化之后继续添加candidate
,并且应答方也无需等待所有的candidate
发送完毕才开始尝试建立连接,毕竟我可以一直加嘛,这个比较好理解。下面是一个candidate
的主要内容,包含协议、IP
、端口等
candidate:3885250869 1 udp 2122260223 172.17.0.1 37648 typ host generation 0 ufrag /Fde network-id 1 network-cost 50.
接下来,我们就开始建立我们的信令服务吧。
建立信令服务器
既然我们的信令服务器本质上就是用于传递文本信息给双方。那我们就可以用任意通讯协议,包装我们需要信令的信息,然后发送给对方就好。前提是这个通讯需要是双向的,你可以用Websocket
也可以用Ajax
+轮询的方式。怎么顺手怎么来。下面的例子我们用了socket.io
,这个库的好处是,它可以模拟socket
支持双向通讯,并且兼容各个浏览器,还有就是它原生支持房间(room
)的概念,也就是只要我往房间发数据,所有在这这个房间的客户端都能收到消息(广播),这种机制,给我们交换信息提供方便。
创建服务的代码我们就跳过了,直接看消息的处理部分。
io.sockets.on('connection', socket => {
// 打印 log 到客户端
function log() {
var array = ['服务器消息:'];
array.push.apply(array, arguments);
socket.emit('log', array);
}
socket.on('message', message => {
log('客户端消息:', message);
// 广播消息,真正的使用应该只发到指定的 room 而不是广播
socket.broadcast.emit('message', message);
});
socket.on("create or join", room => {
log('接受到创建或者加入房间请求:' + room);
var clientsInRoom = io.sockets.adapter.rooms[room];
var numClients = clientsInRoom ? Object.keys(clientsInRoom.sockets).length : 0;
log('Room ' + room + ' 现在有 ' + numClients + ' 个客户端');
if (numClients === 0) {
socket.join(room);
log('客户端 ID: ' + socket.id + ' 创建了房间:' + room);
socket.emit('created', room, socket.id);
} else if (numClients === 1) {
log('客户端 ID: ' + socket.id + ' 加入了房间: ' + room);
io.sockets.in(room).emit('join', room);
socket.join(room);
socket.emit('joined', room, socket.id);
io.sockets.in(room).emit('ready');
} else { // 一个房间只能容纳两个客户端
socket.emit('full', room);
}
});
});
主要是两个关键事件的响应create and join
和message
。
create and join
,当客户端发送create and join
事件时,后台对应的handler
方法会响应,并且试图获得这个房间的人数。- 如果是
0
,则这客户端是创建者,加入房间并发送创建的log
到客户端,最后发送一个created
的事件到客户端 - 如果当前已经有一个客户端了,则加入房间并发送加入的
log
到客户端,接着发送一个joined
的事件到客户端,最后发送一个ready
的事件到房间,让房间的所有客户端收到。 - 如果已经大于
1
了,则房间满员,直接发送full
事件到客户端
- 如果是
message
,客户端发送message
事件,对应方法会响应。这里由于我们前端写死了一个房间,因此,这里直接创建一个广播的message
事件,把消息直接广播给所有人,也就是通讯双方了。
客户端
服务端我们搞定了,然后我们看看前端是这么处理的。
HTML
<!DOCTYPE html>
<html lang="en">
<head>
...
</head>
<body>
<h1>带信令服务器的 WebRTC</h1>
<div id="videos">
<video id="localVideo" autoplay muted playsinline></video>
<video id="remoteVideo" autoplay playsinline></video>
</div>
<!-- 垫片,用于统一浏览器 API -->
<script src="js/adapter.js"></script>
<!-- socket.io 支持-->
<script src="/socket.io/socket.io.js"></script>
<script src="js/main.js"></script>
</body>
</html>
除了加入了socket.io
的支持,其余跟上一篇是一样的,不过这里为了简单,我们把按钮去掉了,也就是说一打开页面就进行初始化,并且第一个客户端等待第二个客户端的加入
JavaScript
我们先看初始化的部分,首先是连接我们的服务端,然后创建和加入房间,也就是往服务端发送create or join
事件。注意,这里为了简单我把加入的房间写死成foo
了
var room = 'foo';
var socket = io.connect();
// 创建或加入房间
if (room !== "") {
socket.emit('create or join', room);
console.log('尝试或加入房间: ' + room);
}
接着我们往下看,这里很熟悉,就是获得媒体设备部分,在获得媒体设备成功的回调中,我们主要关注两个方法的调用sendMessage
和maybeStart
。
var localVideo = document.querySelector('#localVideo');
var remoteVideo = document.querySelector('#remoteVideo');
navigator.mediaDevices.getUserMedia({
audio: false,
video: true})
.then(gotStream)
.catch(function(e) {
alert('获得媒体错误: ' + e.name);
});
function gotStream(stream) {
console.log('正在添加本地流');
localStream = stream;
localVideo.srcObject = stream;
sendMessage('got user media');
}
这里sendMessage
发送了got user media
到服务端。服务端收到信息后,会把创建message
事件把消息重新发送到所有的客户端,这里可以回去看上面关于服务端消息响应的代码解释。
function sendMessage(message) {
console.log('客户端发送消息: ', message);
socket.emit('message', message);
}
现在我们接着看客户端message
事件的响应。
// 消息处理
socket.on('message', function(message) {
console.log('客户端接收到消息:', message);
if (message === 'got user media') {
maybeStart();
} else if (message.type === 'offer') {
...
...
});
这里是统一的消息处理,忽略其他,我们先看got user media
消息的处理,这里其实就是简单的调用了一下maybeStart
方法,所以我们来看一下这个方法做了什么
function maybeStart() {
console.log('>>>>>>> maybeStart() ', isStarted, localStream, isChannelReady);
if (!isStarted && typeof localStream !== 'undefined' && isChannelReady) {
console.log('>>>>>> 正在创建 peer connection');
createPeerConnection();
pc.addStream(localStream);
isStarted = true;
console.log('isInitiator', isInitiator);
if (isInitiator) {
doCall();
}
}
}
在maybeStart
方法中,如果当前状态isStarted=false
,isChannelReady=true
和localStream
准备好了 就会创建了我们的RTCPeerConnection
对象,把icecandidate
,onaddstream
,removestream
注册上,然后把本地的媒体流(localStream
)加入RTCPeerConnection
对象中。
function createPeerConnection() {
try {
pc = new RTCPeerConnection(null);
pc.onicecandidate = handleIceCandidate;
pc.onaddstream = handleRemoteStreamAdded;
pc.onremovestream = handleRemoteStreamRemoved;
console.log('RTCPeerConnnection 已创建');
} catch (e) {
console.log('创建失败 PeerConnection, exception: ' + e.message);
alert('RTCPeerConnection 创建失败');
}
}
最后,把isStart
设置成true
避免再次初始化,然后如果当前房间创建者就开始调用doCall
开始发起通讯。
看到这里,有些同学可能可能注意到了,这些isInitiator
,isChannelReady
是在哪里设置的呢。那让我们回头看socket
的件响应方法把,下面的代码片段,就是在加入创建或房间的几个事件中,把状态相关的标识isInitiator
,isChannelReady
设置好。
socket.on('created', function(room, clientId) {
isInitiator = true;
console.log('创建房间:' + room + ' 成功')
});
socket.on('full', function(room) {
console.log('房间 ' + room + ' 已满');
});
socket.on('join', function (room){
console.log('另一个节点请求加入: ' + room);
console.log('当前节点为房间 ' + room + ' 的创建者!');
isChannelReady = true;
});
socket.on('joined', function(room) {
console.log('已加入: ' + room);
isChannelReady = true;
});
isChannelReady
,在join
或joined
事件响应中设置,也就是在有客户端加入房间时isInitiator
,在created
事件响应,也就是创建房间成功时
所以,我们回头看maybeStart
方法,其实它是在双方进入房间之后才会真正的执行创建RTCPeerConnection
等操作的,因为此时,isChannelReady
才会是true
。
大家不要晕,接下来就是doCall
方法了,这方法很简单,终于创建我们的offer
啦。
function doCall() {
console.log('发送 offer 到节点');
pc.createOffer(setLocalAndSendMessage, handleCreateOfferError);
}
function setLocalAndSendMessage(sessionDescription) {
pc.setLocalDescription(sessionDescription);
console.log('setLocalAndSendMessage 正在发送消息', sessionDescription);
sendMessage(sessionDescription);
}
创建offer
成功后,就是常规操作,把它存到本地,调用setLocalDescription
,最后调用sendMessage
方法,通过我们的服务,发给对方。接下来我们继续看下消息的处理
// 消息处理
socket.on('message', function(message) {
console.log('客户端接收到消息:', message);
...
} else if (message.type === 'offer') {
if (!isInitiator && !isStarted) {
maybeStart();
}
pc.setRemoteDescription(new RTCSessionDescription(message));
doAnswer();
} else if (message.type === 'answer' && isStarted) {
...
...
});
这里当接收方收到offer
之后,会首先判断有没有初始化(isStarted
)。否则调用maybeStart
进行初始化。初始化结束后,调用setRemoteDescription
把offer
存储到。接着就是调用doAnswer
来answer
了,这边跟doOffer
的方法流程基本一样
function doAnswer() {
console.log('发送 answer 到节点.');
pc.createAnswer().then(
setLocalAndSendMessage,
onCreateSessionDescriptionError
);
}
接下来,我们回到发起端,看看它拿到 answer
消息之后的处理
// 消息处理
socket.on('message', function(message) {
console.log('客户端接收到消息:', message);
...
} else if (message.type === 'answer' && isStarted) {
pc.setRemoteDescription(new RTCSessionDescription(message));
} else if (message.type === 'candidate' && isStarted) {
...
..
});
嗯,比较简单。就是把answer
存起来了
到现在为止,我们的offer
跟answer
已经交换好了,接着我们继续看candidate
的交换。先看oncandidate
的响应handleIceCandidate
。这个方法会在网络准备好之后,方法会一般多次调用,因为我们的网络环境通常是复杂的。这个方法把我们的candidate
包装成我们需要的格式,然后发送给对方。
function handleIceCandidate(event) {
console.log('icecandidate event: ', event);
if (event.candidate) {
sendMessage({
type: 'candidate',
label: event.candidate.sdpMLineIndex,
id: event.candidate.sdpMid,
candidate: event.candidate.candidate
});
} else {
console.log('End of candidates.');
}
}
好的,已经发出去了,然后就是消息处理
// 消息处理
socket.on('message', function(message) {
console.log('客户端接收到消息:', message);
...
...
} else if (message.type === 'candidate' && isStarted) {
var candidate = new RTCIceCandidate({
sdpMLineIndex: message.label,
candidate: message.candidate
});
pc.addIceCandidate(candidate);
} else if (message === 'bye' && isStarted) {
...
});
很简单,把消息包装成RTCIceCandidate
对象,然后调用addIceCandidate
保存起来。
终于,我们所有必要的消息都准备好了,WebRTC
就会为我们建立连接。然后通过offer
跟answer
的会话描述得到媒体流的信息,并且回调onaddstream
注册的方法,把媒体流赋予给remoteVideo
的video
标签
function handleRemoteStreamAdded(event) {
console.log('远程媒体流设置.');
remoteStream = event.stream;
remoteVideo.srcObject = remoteStream;
}
现在,我们可以开始愉快的视频了。打开两个浏览器并且用https
访问。因为上一篇提到过,在Chrome
的新版本,必须要用安全连接才能打开媒体设备。

或者PC
与手机通讯


大成功!!
总结
到这一篇为止,我们已经基本了解了WebRTC
的架构和用法,并且实现了不同平台间的P2P
通讯。遗憾的是,现在这个Demo
仅仅能在局域网内运作。对于真实的的世界,有各种复杂的网络配置,还有防火墙。下一篇,我们来了解下在互联网中,我们怎么通过STUN
跟TURN
来实现WebRTC
吧。
谢谢各位的阅读。
代码和参考文档