一、什么是WebRTC
WebRTC技术是激烈的开放的Web战争中一大突破-Brendan Eich, inventor of JAVAScript。
简单来说,WebRTC 是一个音视频处理+及时通讯的开源库。在实时通信中,音视频的采集和处理是一个很复杂的过程。比如音视频流的编解码、降噪和回声消除等。由google发起开源,其中包含视频音频采集,编解码,数据传输,音视频展示等功能,我们可以通过技术快速地构建出一个音视频通讯应用。虽然其名为WebRTC,但是实际上它不只是支持Web之间的音视频通讯,还支持Android以及IOS端,此外由于该项目是开源的,我们也可以通过编译C++代码,从而达到全平台的互通。
WebRTC的架构图为:
我们可以看到模块化和分层的设计,我们文章的目的是演示浏览器端对端的连接流程,焦点是服务端信令服务器的实现方式,但需要提前介绍一些WebRTC的基本概念和连接流程。
二、基础概念
流和轨
三、几个关键类
四、端对端连接流程
两个不同网络环境浏览器,要实现点对点的实时音视频对话,需要处理哪些问题?
媒体协商
双方需要知道对方支持的媒体格式,SDP(Session Description Protocol)是一种会话描述协议,视频通讯的双方必须先交换SDP信息,才能进一步互相通信。
网络协商
双方要了解对方的网络情况,尝试寻求一个可以互相通讯的链路,其中有寻路选择,如果确实没办法建立点对点链路,会使用中继服务器来进行转发。如果是内网,或者大部分NAT网络环境下,是可以建立端到端连接。在解决网络打通问题时候,有几个概念。
学习过程中,STUN和TURN服务器我们可使用coturn开源项目来搭建。
数据交换服务-信令服务器
WebRTC实现并没有规定信令服务器的实现方式和相关协议,这给了业务方技术选型极大的灵活。我们今天就是使用php+Swoole协程实现一个简单信令服务器。下面是一个端到端连接的流程图,整个核心流程逻辑都在图里面。
五、使用Swoole实现信令服务器
客户端代码模拟
<body>
<div style="display: block">
<button class="btn" onclick="start()">连接<tton>
<button class="btn" onclick="leave()">离开<tton>
</div>
<div>
<div class="videos">
<h1>Local</h1>
<video id="localVideo" autoplay><ideo>
</div>
<div class="videos">
<h1>Remote</h1>
<video id="remoteVideo" autoplay><ideo>
</div>
</div>
<script src="assets/js/adapter.js"></script>
<script type="text/JavaScript">
const ws_config = '<?= $signaling_server ?>';
const localVideo = document.getElementById('localVideo');
const remoteVideo = document.getElementById('remoteVideo');
const configuration = {
iceServers: [{
urls: '<?= $stun_server ?>'
}]
};
let room_id = getQueryVariable('room_id');
if (room_id == '' || room_id == null) {
room_id = Math.random().toString(36).slice(-8);
location.href = '?room_id=' + room_id;
}
let subject = 'room-' + room_id;//当前主题
let answer = 0;
let ws = null;
let pc, localStream;
function getMediaStream(stream) {
localVideo.srcObject = localStream;
localStream = stream;
}
function start() {
ws = new WebSocket(ws_config);
ws.onopen = function (e) {
subscribe(subject);
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
console.error('the getUserMedia is not supported!');
return;
}
navigator.mediaDevices.getUserMedia({
audio: true,
video: true
}).then(function (stream) {
if (localStream) {
stream.getAudioTracks().forEach((track) => {
localStream.addTrack(track);
stream.removeTrack(track);
});
} else {
localStream = stream;
}
localVideo.srcObject = localStream;
publish('call', null);
}).catch(function (e) {
console.error('Failed to get Media Stream!', e);
});
};
ws.onmessage = function (e) {
let package = JSON.parse(e.data);
let data = package.data;
console.log(e);
switch (package.event) {
case 'call':
icecandidate(localStream);
pc.createOffer({
offerToReceiveAudio: 1,
offerToReceiveVideo: 1
}).then(function (desc) {
pc.setLocalDescription(desc).then(
function () {
publish('offer', pc.localDescription);
}
).catch(function (e) {
alert(e);
});
}).catch(function (e) {
alert(e);
});
break;
case 'answer':
pc.setRemoteDescription(new RTCSessionDescription(data), function () {}, function (e) {
alert(e);
});
break;
case 'offer':
icecandidate(localStream);
pc.setRemoteDescription(new RTCSessionDescription(data), function () {
if (!answer) {
pc.createAnswer(function (desc) {
pc.setLocalDescription(desc, function () {
publish('answer', pc.localDescription);
}, function (e) {
alert(e);
});
}
, function (e) {
alert(e);
});
answer = 1;
}
}, function (e) {
alert(e);
});
break;
case 'candidate':
pc.addIceCandidate(new RTCIceCandidate(data), function () {
}, function (e) {
alert(e);
});
break;
}
};
}
function leave() {
pc.close();
}
function icecandidate(localStream) {
pc = new RTCPeerConnection(configuration);
pc.onicecandidate = function (event) {
if (event.candidate) {
publish('candidate', event.candidate);
}
};
try {
pc.addStream(localStream);
} catch (e) {
let tracks = localStream.getTracks();
for (let i = 0; i < tracks.length; i++) {
pc.addTrack(tracks[i], localStream);
}
}
pc.onaddstream = function (e) {
remoteVideo.srcObject = e.stream;
};
}
function publish(event, data) {
let obj = {
cmd: 'publish',
subject: subject,
event: event,
data: data
};
console.log(obj);
ws.send(JSON.stringify(obj));
}
function subscribe(subject) {
let obj = {
cmd: 'subscribe',
subject: subject
};
console.log(obj);
ws.send(JSON.stringify(obj));
}
function getQueryVariable(variable) {
var query = window.location.search.substring(1);
var vars = query.split("&");
for (var i = 0; i < vars.length; i++) {
var pair = vars[i].split("=");
if (pair[0] == variable) {
return pair[1];
}
}
return false;
}
</script>
</body>
信令服务端实现
<?php
use SwooleHttpRequest;
use SwooleHttpResponse;
const WEBROOT = __DIR__ . '/web';
$connnection_map = array();
error_reporting(E_ALL);
Corun(function () {
$server = new SwooleCoroutineHttpServer('0.0.0.0', 9509, true);
$server->set([
'ssl_key_file' => __DIR__ . '/ssl/ssl.key',
'ssl_cert_file' => __DIR__ . '/ssl/ssl.crt',
]);
$server->handle('/', function (Request $req, Response $resp) {
//websocket
if (isset($req->header['upgrade']) and $req->header['upgrade'] == 'websocket') {
$resp->upgrade();
$resp->subjects = array();
while (true) {
$frame = $resp->recv();
if (empty($frame)) {
break;
}
$data = json_decode($frame->data, true);
switch ($data['cmd']) {
case 'subscribe':
subscribe($data, $resp);
break;
case 'publish':
publish($data, $resp);
break;
}
}
free_connection($resp);
return;
}
/tp
$path = $req->server['request_uri'];
if ($path == '/') {
$resp->end(get_php_file(WEBROOT . '/index.html'));
} else {
$file = realpath(WEBROOT . $path);
if (false === $file) {
$resp->status(404);
$resp->end('<h3>404 Not Found</h3>');
return;
}
if (strpos($file, WEBROOT) !== 0) {
$resp->status(400);
return;
}
if (pathinfo($file, PATHINFO_EXTENSION) === 'php') {
$resp->end(get_php_file($file));
return;
}
if (isset($req->header['if-modified-since']) and !empty($if_modified_since = $req->header['if-modified-since'])) {
$info = stat($file);
$modified_time = $info ? date('D, d M Y H:i:s', $info['mtime']) . ' ' . date_default_timezone_get() : '';
if ($modified_time === $if_modified_since) {
$resp->status(304);
$resp->end();
return;
}
}
$resp->sendfile($file);
}
});
$server->start();
});
function subscribe($data, $connection)
{
global $connnection_map;
$subject = $data['subject'];
$connection->subjects[$subject] = $subject;
$connnection_map[$subject][$connection->fd] = $connection;
}
function unsubscribe($subject, $current_conn)
{
global $connnection_map;
unset($connnection_map[$subject][$current_conn->fd]);
}
function publish($data, $current_conn)
{
global $connnection_map;
$subject = $data['subject'];
$event = $data['event'];
$data = $data['data'];
//当前主题不存在
if (empty($connnection_map[$subject])) {
return;
}
foreach ($connnection_map[$subject] as $connection) {
//不给当前连接发送数据
if ($current_conn == $connection) {
continue;
}
$connection->push(
json_encode(
array(
'cmd' => 'publish',
'event' => $event,
'data' => $data
)
)
);
}
}
function free_connection($connection)
{
foreach ($connection->subjects as $subject) {
unsubscribe($subject, $connection);
}
}
function get_php_file($file)
{
ob_start();
try {
include $file;
} catch (Exception $e) {
echo $e;
}
return ob_get_clean();
}
1. 房间入口
下面是本地的效果图,首页可以输入房间号加入,如果为空会自动生成一个随机字符
2. 房间内
下图我在本地使用两台笔记本实现的一个效果图,使用自签的证书,这里特意展示了两个不同的画面来区分视频同步效果。
请求流程分析
1. 在一台电脑上点击连接按钮,通过绑定的点击事件start()函数,我们可以发现,首先会创建一个websocket对象并发起连接,连接成功后,向信号服务器注册设备,并获取当前设备的流媒体。获取成功后,赋值给本地元素可以展示,并且赋值给全局变量localStream。
ws.onopen = function (e) {
subscribe(subject);
navigator.mediaDevices.getUserMedia({
audio: true,
video: true
}).then(function (stream) {
localVideo.srcObject = stream;
localStream = stream;
localVideo.addEventListener('loadedmetadata', function(){
publish('call', null);
})
}).catch(function (e) {
alert(e);
});
};
2. 信令服务端器在收到subscribe和publish请求后,会在内存中维护一个连接映射关系,核心逻辑是如果有其他连接进来,会进行广播通知,这里并没有实现一些细节逻辑,比如房间内连接数量限制,房间满了通知,退出连接通知等。
3. 另一个客户端点击连接会重复上一步骤,对端在收到其他客户端加入房间通知后。
case 'call':
icecandidate(localStream);//创建连接,并注册网络协商成功后给信令服务器发送信息的事件
pc.createOffer({
offerToReceiveAudio: 1,
offerToReceiveVideo: 1
}).then(function (desc) {
pc.setLocalDescription(desc).then(//创建offer成功后,设置本地描述,并服务端绑定网络信息,成功后给信令服务器发送SDP offer
function () {
publish('offer', pc.localDescription);
}
).catch(function (e) {
alert(e);
});
}).catch(function (e) {
alert(e);
});
break;
4. 信令服务端收到一端offer后会转发给另一端,触发客户端的相应逻辑,同样会创建连接,并注册网络协商成功后给信令服务器发送信息的事件,同时会创建应答,成功后也会设置本地描述,并向服务端发送绑定信息。同时向信令服务端发送answer信息,进行中转到对端。
case 'offer':
icecandidate(localStream);
pc.setRemoteDescription(new RTCSessionDescription(data), function () {
if (!answer) {
pc.createAnswer(function (desc) {
pc.setLocalDescription(desc, function () {
publish('answer', pc.localDescription);
}, function (e) {
alert(e);
});
}
, function (e) {
alert(e);
});
answer = 1;
}
}, function (e) {
alert(e);
});
break;
5. 对端收到answer信息,设置远端的描述信息。当双方都完成offer,answer步骤后,此时双方的媒体协商已经完成。我们已经绑定过网络信息到服务端,各端会等待接收候选者列表。
case 'answer':
pc.setRemoteDescription(new RTCSessionDescription(data), function () {
}, function (e) {
alert(e);
});
break;
6. 收到候选者列表后,需要把各自的候选信息通过信令服务器中转到对方。
pc.onicecandidate = function (event) {
if (event.candidate) {
publish('candidate', event.candidate);
}
};
7. 各端收到对方的候选者列表后,会把对端的候选者加入当前连接通路的候选者列表中,然后双方会进行连接检测等等一系列复杂的操作,当找到一个最优的链路之后,就会建立连接,进行数据交互。
pc.addIceCandidate(new RTCIceCandidate(data), function () {
}, function (e) {
alert(e);
});
break;
信令服务端
我们介绍了建立连接的过程,针对服务端代码,可以看到信令服务器端的代码很少,加上http的服务总计100行代码左右,怎样达到通过同步编程的方式实现异步非阻塞IO,并且可以很轻松的实现并发百万呢?
信令服务器利用Swoole协程技术,单进程支持异步非阻塞IO高并发,但编程完全是同步阻塞的模式。如果想进一步要利用多核,可以采用Process Pool,加reuse port(Linux kernel 3.9)技术,开启多个进程同时处理,代码仓库中有一份server_co_pool.php的相关实现
$resp->subjects = array();
while (true) {
$frame = $resp->recv();
if (empty($frame)) {
break;
}
$data = json_decode($frame->data, true);
switch ($data['cmd']) {
case 'subscribe':
subscribe($data, $resp);//订阅
break;
case 'publish':
publish($data, $resp);//广播除自己以外的连接
break;
}
}
free_connection($resp);
服务端处理核心逻辑为将当前连接加入内存map中,以供新的连接到来查找广播,连接关闭时,清理对应的主题和fd。
到此,我们使用Swoole协程实现WebRTC信令服务器结束。项目源码已上传至https://github.com/shiguangqi/SwooleWebRTC。
备注:当前例子运行环境为
谢谢,欢迎各位老师批评指正。