您当前的位置:首页 > 电脑百科 > 程序开发 > 语言 > php

使用Swoole协程实现 WebRTC 信令服务器

时间:2020-08-12 13:20:13  来源:  作者:

一、什么是WebRTC

WebRTC技术是激烈的开放的Web战争中一大突破-Brendan Eich, inventor of JAVAScript。

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

WebRTC的架构图为:

使用Swoole协程实现 WebRTC 信令服务器

 

我们可以看到模块化和分层的设计,我们文章的目的是演示浏览器端对端的连接流程,焦点是服务端信令服务器的实现方式,但需要提前介绍一些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协程实现 WebRTC 信令服务器

 

五、使用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. 房间入口

下面是本地的效果图,首页可以输入房间号加入,如果为空会自动生成一个随机字符

使用Swoole协程实现 WebRTC 信令服务器

 

2. 房间内

下图我在本地使用两台笔记本实现的一个效果图,使用自签的证书,这里特意展示了两个不同的画面来区分视频同步效果。

使用Swoole协程实现 WebRTC 信令服务器

 

请求流程分析

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 SwooleCoroutineHttpServer('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.16
  • Darwin mbp 19.3.0 Darwin Kernel Version 19.3.0 和 18.04.1-Ubuntu

谢谢,欢迎各位老师批评指正。



Tags:WebRTC   点击:()  评论:()
声明:本站部分内容及图片来自互联网,转载是出于传递更多信息之目的,内容观点仅代表作者本人,如有任何标注错误或版权侵犯请与我们联系(Email:2595517585@qq.com),我们将及时更正、删除,谢谢。
▌相关推荐
【网络通信 -- WebRTC】WebRTC 基础知识 -- ICE 交互总结【1】ICE 的一般概念简介ICE 角色offer (主动发起)的一方为 controlling 角色answer (被动接受)的一方为 controlle...【详细内容】
2021-11-30  Tags: WebRTC  点击:(24)  评论:(0)  加入收藏
前言最近对 WebRTC iOS 端源码进行了下载和编译,网上针对 WebRTC iOS 端的编译文章基本都是几年前的,有些地方已经不适用于最新版的 WebRTC 的编译,简单记录下载&编译的过程,以...【详细内容】
2021-11-10  Tags: WebRTC  点击:(38)  评论:(0)  加入收藏
所有的基于网络传输的音视频采集播放系统都会存在音视频同步的问题,作为现代互联网实时音视频通信系统的代表,WebRTC 也不例外。本文将对音视频同步的原理以及 WebRTC 的实现...【详细内容】
2021-08-17  Tags: WebRTC  点击:(75)  评论:(0)  加入收藏
一、什么是WebRTCWebRTC技术是激烈的开放的Web战争中一大突破-Brendan Eich, inventor of JavaScript。简单来说,WebRTC 是一个音视频处理+及时通讯的开源库。在实时通信中,音...【详细内容】
2020-08-12  Tags: WebRTC  点击:(89)  评论:(0)  加入收藏
▌简易百科推荐
序言:前段时间织梦因为版权的问题在网上闹得沸沸扬扬,也提醒了众多开发者选择cms上应该谨慎使用,今天给大家展示一款自己搭建的内容管理系统,不用担心版权的问题,而且非常容易维...【详细内容】
2021-11-30  小程序软件开发    Tags:管理系统   点击:(31)  评论:(0)  加入收藏
准备安装包(PHP: Hypertext Preprocessor)下载安装包以及组件wget https://www.php.net/distributions/php-8.0.0.tar.bz2wget https://github.com/phpredis/phpredis/archive...【详细内容】
2021-11-09  mimic96    Tags:PHP   点击:(40)  评论:(0)  加入收藏
golang context 很好用,就使用php实现了github地址 : https://github.com/qq1060656096/php-go-context context使用闭坑指南1. 将一个Context参数作为第一个参数传递给传入和...【详细内容】
2021-11-05  1060656096    Tags:PHP   点击:(41)  评论:(0)  加入收藏
一段数组为例:$list = array:4 [ 0 => array:7 [ "id" => 56 "mer_id" => 7 "order_id" => "wx163265961408769974" "is_postage" => 0 "store_name" => "奇...【详细内容】
2021-09-29  七七小影视    Tags:PHP   点击:(64)  评论:(0)  加入收藏
利用JS的CryptoJS 3.x和PHP的openssl_encrypt,openssl_decrypt实现AES对称加密解密,由于需要两种语言对同一字符串的操作,而CryptoJS 的默认加密方式为“aes-256-cbc”,PHP端也...【详细内容】
2021-09-16  李老师tome    Tags:对称加密   点击:(79)  评论:(0)  加入收藏
1、checkdate()验证格利高里日期即:日期是否存在。checkdate(month,day,year);month必需。一个从 1 到 12 的数字,规定月。day必需。一个从 1 到 31 的数字,规定日。year必需。...【详细内容】
2021-08-31  七七小影视    Tags:时间函数   点击:(80)  评论:(0)  加入收藏
对于各类开发语言来说,整数都有一个最大的位数,如果超过位数就无法显示或者操作了。其实,这也是一种精度越界之后产生的精度丢失问题。在我们的 PHP 代码中,最大的整数非常大,我...【详细内容】
2021-08-26  硬核项目经理    Tags:PHP   点击:(83)  评论:(0)  加入收藏
遵从所有教材以及各类数据结构相关的书书籍,我们先从线性表开始入门。今天这篇文章更偏概念,是关于有线性表的一个知识点的汇总。上文说过,物理结构是用于确定数据以何种方式存...【详细内容】
2021-07-19  硬核项目经理    Tags:线性表   点击:(94)  评论:(0)  加入收藏
一、开启IIS全部功能。二、部署PHP1.官网下载并解压PHP: https://windows.php.net/downloads/releases/2.将php.ini-development文件改为php.ini3.修改php.ini(1)去掉注释,并修...【详细内容】
2021-07-15  炘蓝火诗  今日头条  Tags:PHP环境   点击:(128)  评论:(0)  加入收藏
一、环境说明本文中使用本地VM虚机部署测试。OS:CentOS Linux release 7.8.2003 (Core)虚机配置:2核CPU、4G内存①系统为CentOS 7.8 x64最小化安装,部署前已完成系统初始化、...【详细内容】
2021-06-25  IT运维笔记  今日头条  Tags:PHP8.0.7   点击:(141)  评论:(0)  加入收藏
最新更新
栏目热门
栏目头条