WebRTC技能是激烈的开放的Web战役中一大打破-Brendan Eich, inventor of JavaScript。

大略来说,WebRTC 是一个音视频处理+及时通讯的开源库。
在实时通信中,音视频的采集和处理是一个很繁芜的过程。
比如音视频流的编解码、降噪和反应肃清等。
由Google发起开源,个中包含视频音频采集,编解码,数据传输,音视频展示等功能,我们可以通过技能快速地构建出一个音视频通讯运用。
虽然其名为WebRTC,但是实际上它不但是支持Web之间的音视频通讯,还支持Android以及IOS端,此外由于该项目是开源的,我们也可以通过编译C++代码,从而达到全平台的互通。

WebRTC的架构图为:

webrtcphp应用Swoole协程实现 WebRTC 信令办事器 RESTful API

我们可以看到模块化和分层的设计,我们文章的目的是演示浏览器端对真个连接流程,焦点是做事端信令做事器的实现办法,但须要提前先容一些WebRTC的基本观点和连接流程。

二、根本观点

流和轨

Track 轨道,可以理解每一起音频或视频,为一个轨,互不相交,类比火车轨道。
MediaStream 媒体流,每个媒体流中包含多少轨道,可以将音频轨,视频轨打包在一起。

三、几个关键类

MediaStream 媒体流类,MeidiaStream用于将多个MediaStreamTrack工具打包到一起。
一个MediaStream可包含audio track 与video track,并且可以添加或者删除。
RTCPeerConnection 连接类,包含非常多主要功能,屏蔽繁芜技能细节,便于运用层利用,包括但不限于连接管理,P2P类型检测,NAT穿透,中转等。
RTCDataChannel 非音视频数据传输类,这个类在我们的例子中没有涉及到。
可以大略理解为将媒体流信息或者数据信息塞到连接中,进行传输。

四、端对端连接流程

两个不同网络环境浏览器,要实现点对点的实时音视频对话,须要处理哪些问题?

媒体协商

双方须要知道对方支持的媒体格式,SDP(Session Description Protocol)是一种会话描述协议,视频通讯的双方必须先交流SDP信息,才能进一步相互通信。

网络协商

双方要理解对方的网络情形,考试测验寻求一个可以相互通讯的链路,个中有寻路选择,如果确实没办法建立点对点链路,会利用中继做事器来进行转发。
如果是内网,或者大部分NAT网络环境下,是可以建立端到端连接。
在办理网络打通问题时候,有几个观点。

STUN(Session Traversal Utilities for NAT,NAT会话穿越运用程序)是一种网络协议,它许可位于NAT后的客户端找出自己的公网地址,查出自己位于哪种类型的NAT之后以及NAT在公网的端口映射信息。
这些信息被用来在两端创建UDP连接通信。
TURN (Traversal Using Relays around NAT),如果客户端在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 = &#39;<?= $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>

信令做事端实现

<?phpuse Swoole\Http\Request;use Swoole\Http\Response;const WEBROOT = __DIR__ . '/web';$connnection_map = array();error_reporting(E_ALL);Co\run(function () { $server = new Swoole\Coroutine\Http\Server('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,并且可以很轻松的实现并发百万呢?

首先通过布局函数$server = new Swoole\Coroutine\Http\Server('0.0.0.0', 9509, true);会创建server工具。
当调用$server->start();方法后,会循环进行accept,accept连接后,会创建一个协程,这个协程内所有的收发,都会引起协程调度。
可以低本钱创建成千上万协程,并发百万没问题,底层会为每个协程开辟独立的栈空间,并基于多路复用技能(Linux下为EPOLL)来进行调度。

信令做事器利用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

备注:当前例子运行环境为

PHP 7.2.14 (cli)Swoole v4.4.16Darwin mbp 19.3.0 Darwin Kernel Version 19.3.0 和 18.04.1-Ubuntu

感激,欢迎各位老师批评示正。

我为大家准备了一份中高等的教程福利!
助你金九银十拿高薪!

作为web开拓的佼佼者PHP并不逊色其他措辞,加上swoole后更加是为虎傅翼!
进军通信 、物联网行业开拓百度舆图、百度订单中央等!
年后更是霸占程序员招聘措辞第二名,寒冬裁员期过后正是各大企业扩大招人的期间,现在市场低级程序员泛滥,进阶中高等程序员绝对是各大企业急需的人才,这套教程适宜那些1-6年的PHP开拓者进阶中高等提升自己,在春招中找到高薪职位!

领取办法:点赞关注

领取办法:点赞关注小编后私信【资料】获取资料领取办法!