一、什么是WebRTC
WebRTC技術(shù)是激烈的開放的Web戰(zhàn)爭中一大突破-Brendan Eich, inventor of JAVAScript。
簡單來說,WebRTC 是一個(gè)音視頻處理+及時(shí)通訊的開源庫。在實(shí)時(shí)通信中,音視頻的采集和處理是一個(gè)很復(fù)雜的過程。比如音視頻流的編解碼、降噪和回聲消除等。由google發(fā)起開源,其中包含視頻音頻采集,編解碼,數(shù)據(jù)傳輸,音視頻展示等功能,我們可以通過技術(shù)快速地構(gòu)建出一個(gè)音視頻通訊應(yīng)用。雖然其名為WebRTC,但是實(shí)際上它不只是支持Web之間的音視頻通訊,還支持Android以及IOS端,此外由于該項(xiàng)目是開源的,我們也可以通過編譯C++代碼,從而達(dá)到全平臺(tái)的互通。
WebRTC的架構(gòu)圖為:

我們可以看到模塊化和分層的設(shè)計(jì),我們文章的目的是演示瀏覽器端對(duì)端的連接流程,焦點(diǎn)是服務(wù)端信令服務(wù)器的實(shí)現(xiàn)方式,但需要提前介紹一些WebRTC的基本概念和連接流程。
二、基礎(chǔ)概念
流和軌
- Track 軌道,可以理解每一路音頻或視頻,為一個(gè)軌,互不相交,類比火車軌道。
- MediaStream 媒體流,每個(gè)媒體流中包含若干軌道,可以將音頻軌,視頻軌打包在一起。
三、幾個(gè)關(guān)鍵類
- MediaStream 媒體流類,MeidiaStream用于將多個(gè)MediaStreamTrack對(duì)象打包到一起。一個(gè)MediaStream可包含audio track 與video track,并且可以添加或者刪除。
- RTCPeerConnection 連接類,包含非常多重要功能,屏蔽復(fù)雜技術(shù)細(xì)節(jié),便于應(yīng)用層使用,包括但不限于連接管理,P2P類型檢測,NAT穿透,中轉(zhuǎn)等。
- RTCDataChannel 非音視頻數(shù)據(jù)傳輸類,這個(gè)類在我們的例子中沒有涉及到。可以簡單理解為將媒體流信息或者數(shù)據(jù)信息塞到連接中,進(jìn)行傳輸。
四、端對(duì)端連接流程
兩個(gè)不同網(wǎng)絡(luò)環(huán)境瀏覽器,要實(shí)現(xiàn)點(diǎn)對(duì)點(diǎn)的實(shí)時(shí)音視頻對(duì)話,需要處理哪些問題?
媒體協(xié)商
雙方需要知道對(duì)方支持的媒體格式,SDP(Session Description Protocol)是一種會(huì)話描述協(xié)議,視頻通訊的雙方必須先交換SDP信息,才能進(jìn)一步互相通信。
網(wǎng)絡(luò)協(xié)商
雙方要了解對(duì)方的網(wǎng)絡(luò)情況,嘗試尋求一個(gè)可以互相通訊的鏈路,其中有尋路選擇,如果確實(shí)沒辦法建立點(diǎn)對(duì)點(diǎn)鏈路,會(huì)使用中繼服務(wù)器來進(jìn)行轉(zhuǎn)發(fā)。如果是內(nèi)網(wǎng),或者大部分NAT網(wǎng)絡(luò)環(huán)境下,是可以建立端到端連接。在解決網(wǎng)絡(luò)打通問題時(shí)候,有幾個(gè)概念。
- STUN(Session Traversal Utilities for NAT,NAT會(huì)話穿越應(yīng)用程序)是一種網(wǎng)絡(luò)協(xié)議,它允許位于NAT后的客戶端找出自己的公網(wǎng)地址,查出自己位于哪種類型的NAT之后以及NAT在公網(wǎng)的端口映射信息。這些信息被用來在兩端創(chuàng)建UDP連接通信。
- TURN (Traversal Using Relays around NAT),如果客戶端在NAT之后, 那么在一些網(wǎng)絡(luò)情景下,有可能建立點(diǎn)對(duì)點(diǎn)的通訊連接,這時(shí)就需要公網(wǎng)的服務(wù)器作為一個(gè)中繼, 對(duì)數(shù)據(jù)進(jìn)行轉(zhuǎn)發(fā)。
學(xué)習(xí)過程中,STUN和TURN服務(wù)器我們可使用coturn開源項(xiàng)目來搭建。
數(shù)據(jù)交換服務(wù)-信令服務(wù)器
WebRTC實(shí)現(xiàn)并沒有規(guī)定信令服務(wù)器的實(shí)現(xiàn)方式和相關(guān)協(xié)議,這給了業(yè)務(wù)方技術(shù)選型極大的靈活。我們今天就是使用php+Swoole協(xié)程實(shí)現(xiàn)一個(gè)簡單信令服務(wù)器。下面是一個(gè)端到端連接的流程圖,整個(gè)核心流程邏輯都在圖里面。

五、使用Swoole實(shí)現(xiàn)信令服務(wù)器
客戶端代碼模擬
<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;//當(dāng)前主題
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>
信令服務(wù)端實(shí)現(xiàn)
<?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'];
//當(dāng)前主題不存在
if (empty($connnection_map[$subject])) {
return;
}
foreach ($connnection_map[$subject] as $connection) {
//不給當(dāng)前連接發(fā)送數(shù)據(jù)
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. 房間入口
下面是本地的效果圖,首頁可以輸入房間號(hào)加入,如果為空會(huì)自動(dòng)生成一個(gè)隨機(jī)字符

2. 房間內(nèi)
下圖我在本地使用兩臺(tái)筆記本實(shí)現(xiàn)的一個(gè)效果圖,使用自簽的證書,這里特意展示了兩個(gè)不同的畫面來區(qū)分視頻同步效果。

請(qǐng)求流程分析
1. 在一臺(tái)電腦上點(diǎn)擊連接按鈕,通過綁定的點(diǎn)擊事件start()函數(shù),我們可以發(fā)現(xiàn),首先會(huì)創(chuàng)建一個(gè)websocket對(duì)象并發(fā)起連接,連接成功后,向信號(hào)服務(wù)器注冊設(shè)備,并獲取當(dāng)前設(shè)備的流媒體。獲取成功后,賦值給本地元素可以展示,并且賦值給全局變量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. 信令服務(wù)端器在收到subscribe和publish請(qǐng)求后,會(huì)在內(nèi)存中維護(hù)一個(gè)連接映射關(guān)系,核心邏輯是如果有其他連接進(jìn)來,會(huì)進(jìn)行廣播通知,這里并沒有實(shí)現(xiàn)一些細(xì)節(jié)邏輯,比如房間內(nèi)連接數(shù)量限制,房間滿了通知,退出連接通知等。
3. 另一個(gè)客戶端點(diǎn)擊連接會(huì)重復(fù)上一步驟,對(duì)端在收到其他客戶端加入房間通知后。
case 'call':
icecandidate(localStream);//創(chuàng)建連接,并注冊網(wǎng)絡(luò)協(xié)商成功后給信令服務(wù)器發(fā)送信息的事件
pc.createOffer({
offerToReceiveAudio: 1,
offerToReceiveVideo: 1
}).then(function (desc) {
pc.setLocalDescription(desc).then(//創(chuàng)建offer成功后,設(shè)置本地描述,并服務(wù)端綁定網(wǎng)絡(luò)信息,成功后給信令服務(wù)器發(fā)送SDP offer
function () {
publish('offer', pc.localDescription);
}
).catch(function (e) {
alert(e);
});
}).catch(function (e) {
alert(e);
});
break;
4. 信令服務(wù)端收到一端offer后會(huì)轉(zhuǎn)發(fā)給另一端,觸發(fā)客戶端的相應(yīng)邏輯,同樣會(huì)創(chuàng)建連接,并注冊網(wǎng)絡(luò)協(xié)商成功后給信令服務(wù)器發(fā)送信息的事件,同時(shí)會(huì)創(chuàng)建應(yīng)答,成功后也會(huì)設(shè)置本地描述,并向服務(wù)端發(fā)送綁定信息。同時(shí)向信令服務(wù)端發(fā)送answer信息,進(jìn)行中轉(zhuǎn)到對(duì)端。
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. 對(duì)端收到answer信息,設(shè)置遠(yuǎn)端的描述信息。當(dāng)雙方都完成offer,answer步驟后,此時(shí)雙方的媒體協(xié)商已經(jīng)完成。我們已經(jīng)綁定過網(wǎng)絡(luò)信息到服務(wù)端,各端會(huì)等待接收候選者列表。
case 'answer':
pc.setRemoteDescription(new RTCSessionDescription(data), function () {
}, function (e) {
alert(e);
});
break;
6. 收到候選者列表后,需要把各自的候選信息通過信令服務(wù)器中轉(zhuǎn)到對(duì)方。
pc.onicecandidate = function (event) {
if (event.candidate) {
publish('candidate', event.candidate);
}
};
7. 各端收到對(duì)方的候選者列表后,會(huì)把對(duì)端的候選者加入當(dāng)前連接通路的候選者列表中,然后雙方會(huì)進(jìn)行連接檢測等等一系列復(fù)雜的操作,當(dāng)找到一個(gè)最優(yōu)的鏈路之后,就會(huì)建立連接,進(jìn)行數(shù)據(jù)交互。
pc.addIceCandidate(new RTCIceCandidate(data), function () {
}, function (e) {
alert(e);
});
break;
信令服務(wù)端
我們介紹了建立連接的過程,針對(duì)服務(wù)端代碼,可以看到信令服務(wù)器端的代碼很少,加上http的服務(wù)總計(jì)100行代碼左右,怎樣達(dá)到通過同步編程的方式實(shí)現(xiàn)異步非阻塞IO,并且可以很輕松的實(shí)現(xiàn)并發(fā)百萬呢?
- 首先通過構(gòu)造函數(shù)$server = new SwooleCoroutineHttpServer('0.0.0.0', 9509, true);會(huì)創(chuàng)建server對(duì)象。
- 當(dāng)調(diào)用$server->start();方法后,會(huì)循環(huán)進(jìn)行accept,accept連接后,會(huì)創(chuàng)建一個(gè)協(xié)程,這個(gè)協(xié)程內(nèi)所有的消息收發(fā),都會(huì)引起協(xié)程調(diào)度。
- 可以低成本創(chuàng)建成千上萬協(xié)程,并發(fā)百萬沒問題,底層會(huì)為每個(gè)協(xié)程開辟獨(dú)立的棧空間,并基于多路復(fù)用技術(shù)(linux下為EPOLL)來進(jìn)行調(diào)度。
信令服務(wù)器利用Swoole協(xié)程技術(shù),單進(jìn)程支持異步非阻塞IO高并發(fā),但編程完全是同步阻塞的模式。如果想進(jìn)一步要利用多核,可以采用Process Pool,加reuse port(Linux kernel 3.9)技術(shù),開啟多個(gè)進(jìn)程同時(shí)處理,代碼倉庫中有一份server_co_pool.php的相關(guān)實(shí)現(xiàn)
$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);
服務(wù)端處理核心邏輯為將當(dāng)前連接加入內(nèi)存map中,以供新的連接到來查找廣播,連接關(guān)閉時(shí),清理對(duì)應(yīng)的主題和fd。
到此,我們使用Swoole協(xié)程實(shí)現(xiàn)WebRTC信令服務(wù)器結(jié)束。項(xiàng)目源碼已上傳至https://github.com/shiguangqi/SwooleWebRTC。
備注:當(dāng)前例子運(yùn)行環(huán)境為
- PHP 7.2.14 (cli)
- Swoole v4.4.16
- Darwin mbp 19.3.0 Darwin Kernel Version 19.3.0 和 18.04.1-Ubuntu
謝謝,歡迎各位老師批評(píng)指正。