一、 项目简介
webrtc是近几年兴起的一种流媒体通信框架。其目的是实现一套web浏览器无插件的流媒体框架。它可以极大地方便网页开发人员实现丰富的音视频页面应用。
当然webrtc作为html5标准中的一套接口,在实现上不仅仅是针对网页开发者的,还可支持Android、iOS、MAC、Linux、Windows等多平台上的原生开发。
webrtc框架的特性包括:P2P(点对点)媒体数据传输、数据通道加密、基于rtp/rtcp的传输质量控制能力。比如google webrtc的代码实现,在媒体数据传输过程中的拥塞控制、重传、rtp前向纠错等方面做了深入的研究和实现。
但是要把webrtc架构用在实现应用上,光有一份webrtc框架的代码实现或者直接使用浏览器上webrtc模块的接口还是不够的。因为webrtc框架本身虽然提供了方便的外部接口、给出了建立媒体连接所需的消息(我们称之为信令),但是信令如何传递是需要应用开发人员进行考虑的事情。
因此,本项目就是提供一个构建简单易用的点对点的信令服务的思路,用以实现在两个对等的媒体应用间传递webrtc信令的功能。
二、技术栈
本服务的搭建使用到了如下技术:
(1)开发语言:python2.7;虚拟python环境软件:virtualenv
(2)集成开发环境:pycharm professional 2018.2
(3)服务框架:python flask框架
(4)实现客户端与服务端对等交互的框架:socket.io
(5)服务端缓存:Redis
(6)服务端持久化:MySQ1
(7)webrtc接口:google chrome浏览器(版本87)
三、webrtc网页开发接口
本节主要介绍使用webrtc进行网页开发时常用的若干接口。
3.1 mediaDevices.getUserMedia
HTML5的getUserMedia API为用户提供访问硬件设备媒体(摄像头、视频、音频等)的接口,基于该接口,开发者可以在不依赖任何浏览器插件的条件下访问硬件媒体设备。
语法
navigator.mediaDevices.getUserMedia(constraints)
.then(function(mediaStream) { ... })
.catch(function(error) { ... })
参数
containers:指定请求的媒体类型,主要包含video和audio,必须至少一个类型或者两个同时可以被指定。
[失败] 如果浏览器无法找到指定的媒体类型或者无法满足相对应的参数要求,那么返回的Promise对象就会处于rejected状态,NotFoundError作为rejected回调的参数。
[成功] 如果成功,返回的Promis对象处于resolved 状态,MediaStream实例回作为resolve 回调的参数,该MediaStream实例包含了音、视频的轨道实例,后续可用于向webrtc实例添加媒体轨道。
【例】要求获取最低为1280 x 720分辨率的视频和音频,containers值是:
{
audio: true,
video: {
width: { min: 1024, ideal: 1280, max: 1920 },
height: { min: 776, ideal: 720, max: 1080 }
}
}
3.2 RTCPeerConnection
创建本地设备数据流到远端媒体交互方的一个实例,该实例具有webrtc的特性。
语法
var pc = new RTCPeerConnection()
3.2.1 RTCPeerConnection.ontrack
该webrtc实例在接收到对端的一条媒体轨道时触发的回调函数
语法
pc.ontrack = function(event) { ... };
function参数
event:它封装了媒体轨道及轨道所属流的信息,通过它可以获取track实例及其特征,或者track所属的streams列表
【例】获取远端视频轨并注册为页面的received_video标签的数据源实例:
pc.ontrack = function(event) {
if(event.track.kind == "video"){
media_stream = new MediaStream;
media_stream.addTrack(event.track);
document.getElementById("received_video").srcObject = media_stream;
}
};
3.2.2 RTCPeerConnection.addTrack()
向webrtc实例添加媒体轨道
语法
pc.addTrack(track, stream...)
参数
track:添加的媒体轨道
stream:媒体轨道所属的流
【例】获取本地音视频轨,添加到webrtc实例(pc)中:
navigator.mediaDevices.getUserMedia({audio: true, video: true})
.then(function(mediaStream) {
for(const track of mediaStream.getTracks()){
pc.addTrack(track, mediaStream);
}
})
.catch(function(error) { ... })
3.2.3 RTCPeerConnection.createOffer()
创建该webrtc实例的SDP offer(用于媒体类型协商、加密协商等)信令,准备开始主动地建立webrtc媒体连接
语法
pc.createOffer([options])
.then(function(desc) { ... })
.catch(function(error) { ... })
参数
options:一个可选的RTCOfferOptions字典参数
[失败] 如果webrtc实例已经关闭,InvalidStateError作为rejected回调的参数;或者在webrtc实例是安全连接时未配置加密信息,NotReadableError作为rejected回调的参数;或者在webrtc实例状态有问题时,OperationError作为rejected回调的参数。
[成功] 如果成功,返回的Promis对象处于resolved 状态,desc实例回作为resolve 回调的参数,该desc实例包含了需要过信令服务器传递到远端webrtc实例的SDP offer信令。
3.2.4 RTCPeerConnection.setLocalDescription()
向该webrtc实例设置本地的SDP信令
语法
pc.setLocalDescription(sessionDescription)
.then(function() { ... })
.catch(function(error) { ... })
参数
sessionDescription:一个RTCSessionDescription实例,内容是SDP信令描述
[失败] 该SDP信令不能被webrtc实例接受。
[成功] 该SDP信令被webrtc实例成功接受
3.2.5 RTCPeerConnection.createAnswer()
创建该webrtc实例的SDP answer信令,准备开始被动地建立webrtc媒体连接
语法
pc.createAnswer([options])
.then(function(desc) { ... })
.catch(function(error) { ... })
参数
options:一个RTCAnswerOptions字典参数
[失败] 如果webrtc实例是安全连接时且无法识别身份,NotReadableError作为rejected回调的参数;或者在webrtc无法产生SDP answer时,OperationError作为rejected回调的参数。
[成功] 如果成功,返回的Promis对象处于resolved 状态,desc实例回作为resolve 回调的参数,该desc实例包含了需要过信令服务器响应到远端webrtc实例的SDP answer信令。
3.2.6 RTCPeerConnection.setRemoteDescription()
向该webrtc实例设置远端的SDP信令
语法
pc.setRemoteDescription(sessionDescription)
.then(function() { ... })
.catch(function(error) { ... })
参数
sessionDescription:一个RTCSessionDescription实例,内容是SDP信令描述
[失败] 该SDP信令不能被webrtc实例接受。
[成功] 该SDP信令被webrtc实例成功接受
3.2.7 RTCPeerConnection.onicecandidate
该webrtc实例在获取到ICE candidate(用于P2P连接通道的建立)时触发的回调函数
语法
pc.onicecandidate= function(event) { ... };
function参数
event:它封装了ICE candidate的数据,通过它的candidate属性获取实例
3.2.8 RTCPeerConnection.addIceCandidate()
向webrtc实例添加接收自远端的ICE candidate
语法
pc.addIceCandidate(candidate)
.then(function() { ... })
.catch(function(error) { ... })
参数
candidate:接收自远端的ICE candidate实例
3.2.9 实例
该实例场景是假设整个应用框架已经具备了信令服务器,假设现在有Peer A 和 Peer B要在网页上建立webrtc媒体连接,其中A是主动创建SDP offer的一方:
【例】 Peer A:添加媒体轨,创建SDP offer并设置到其webrtc实例中,发送offer到PeerB,等待接收Peer B的SDP answer,并设置到其webrtc实例中,当有ICE candidate生成,则发送到Peer B;
Peer B :接收到对端SDP offer后,设置到该webrtc实例(pc)中,并添加媒体轨,创建SDP answer并且设置到该实例中, 当有ICE candidate生成,则发送到Peer A:
--------------------------------------------------- Peer A ------------------------------------------------------
...
// when a media track is received,use it
pc.onTrack = function(event){
//use the "event.track"
};
// when an ICE Candidate is created, send to Peer
pc.onicecandidate= function(event) {
// send "event.candidate" Peer
};
// when an ICE Candidate is received as "candidate", set to "pc"
pc.addIceCandidate(candidate)
////next code is different between Peer A and Peer B
// add some media “track” and “stream” to “pc”
pc.addTrack(track, stream)
// create Offer SDP
pc.createOffer().then(function(offer) {
myPeerConnection.setLocalDescription(offer).then(functioin(){
// send Offer SDP to peer B via signal server
...
});
});
// wait to receive Answer SDP from peer B
...
// received the SDP Answer, as “answer”, from Peer A
pc.setRemoteDescription(answer)
...
--------------------------------------------------- Peer B ------------------------------------------------------
...
// when a media track is received,use it
pc.onTrack = function(event){
//use the "event.track"
};
// when an ICE Candidate is created, send to Peer
pc.onicecandidate= function(event) {
// send "event.candidate" Peer
};
// when an ICE Candidate is received as "candidate", set to "pc"
pc.addIceCandidate(candidate)
////next code is different between Peer A and Peer B
// received the SDP Offer, as “offer”, from Peer A
pc.setRemoteDescription(offer)
// add some media “track” and “stream” to “pc”
pc.addTrack(track, stream)
// create Answer SDP
pc.createAnswer().then(function(answer) {
myPeerConnection.setLocalDescription(answer).then(functioin(){
// send Answer SDP to peer A via signal server
...
});
});
...
上述代码只是出于展示方便,从Peer A和Peer B两个角度对web 上使用webrtc接口进行了描述。在实际工程中,客户端(Peer A、Peer B都是客户端,它们是相对于信令服务器而言的客户端)代码是同一份,因此要同时考虑到客户端在作为SDP offer方(连接发起方)和SDP answer方的差异点和共同点。在某些复杂应用场景中,需要考虑在同一个web页面(或客户端应用)中,建立回环的webrtc连接的情况,应用层应兼容该回环情况或禁止用户执行可能出现回环连接的操作(比如选择和自己建立连接)。
四、信令服务
在第三节中介绍了在web上使用webrtc功能接口的主要流程和注意点。
可以看出建立两端间的webrtc连接时,SDP offer/answer 和 ICE candidate的传递是至关重要的环节。在这里,我们把SDP offer/answer 和 ICE candidate统称为“信令”。
本节所要讲的就是传递“信令”的服务器的技术选型。
4.1 信令服务技术选型
学过网络技术的读者,对TCP/UDP都应该有所了解。在两台计算机之间最简单的消息发送方法,无疑是使用基本的套接字(socket),建立TCP或者UDP连接,直接进行消息收发。
而在传统的网页服务器中,服务端先起动http服务,监听来自浏览器或其他http客户端发来的请求,然后服务端根据客户端发来请求的内容进行响应。对于在服务端和客户端之间需要保持长连接的场景,比如实时交互类的网页应用,使用http轮询实现长连接,会长时间占用服务器资源,降低性能,而且实现起来比较复杂。
因此在需要C/S端交互的场景下,可以使用websocket或者socket.io技术,更加方便快捷地实现客户端和服务端的对等实时交互。
本节采用socket.io技术方案,socket.io底层是使用了websocket能力或http长轮询实现的C/S对等交互。socket.io有js实现、c++实现等,可以方便的集成进web开发项目和跨平台开发,平台兼容性较好。且在python flask框架中,已经具备了Flask-SocketIO组件用于实现socket.io服务端的功能。
4.2 socket.io的js库开发接口
socket.io.js库是用于开发web网页客户端的组件库。
socket.io.js获取地址:https://cdnjs.com/libraries/socket.io
接口介绍:https://socket.io/docs/v3/client-api/index.html
github地址:https://github.com/socketio/socket.io
4.2.1 实例
假设已经具备了一个socket.io服务,该服务器的http服务根地址是"https://localhost",socket.io服务命名空间"/socketio/",全路径服务地址是"https://localhost/socketio/"。以下代码是在一个网页的脚本块中使用了socket.io.js库并与服务端建立socket.io连接的例子。
大致交互流程:
(1)启动socket.io服务。
(2)客户端向服务发起连接。
(3)连接成功,则客户端向服务发起“self_introduce”消息,由服务端解析自我介绍消息,记录该用户为在线用户;若该用户不是已经注册的用户,则服务端响应一个“anonymous_username”消息给该客户端,其中含有服务端分配的一个随机用户昵称。
(4)客户端定时向服务端发起“users_list”消息,请求当前在线用户列表;服务端响应“users_list”消息,客户端解析在线用户列表,以备向列表中的其他客户端传递消息。
(5)客户端A向服务端发起“relay_msg_sig”消息,其中携带“from”、“to”、“msg”、“sig”、“timestamp”字段;服务端解析该消息并向“to”字段记录的客户端B中转该消息;客户端B处理该中转消息,并根据需要向服务端发起“relay_msg_sig”消息,注意回复的消息中携带的“from”、“to”与收到的消息中的对应字段应相反。
在第(5)步这里可以看到,已经实现了一个P2P的消息中转功能,结合第三节中介绍的webrtc js接口的使用,可以很方便的将webrtc的SDP offer/answer和ICE candidates信令传递到对端客户端上。
//...
<script src="/socket.io/socket.io.js"></script>
<script>
$(function (){ //页面加载时调用
//定时器,用于定时请求
var timer = null;
//获取用户名标签中的字符串
var username = document.getElemById("username");
//向'https://localhost/socketio/'服务自动建立socket.io连接
var socket = io('https://localhost/socketio/');
//连接建立成功的回调函数,是socket.io.js库保留的回调函数
socket.on("connect", function() {
//该用户通过socket.io连接向服务进行自我介绍
socket.emit('self_introduce', {from:username});
});
//其他由socket.io.js库保留回调的函数
socket.on("connect_error", (error) =>{
//...
});
socket.on("connect_timeout", (error) =>{
//...
});
socket.on("reconnect", (error) =>{
//...
});
socket.on("reconnect_attempt", (error) =>{
//...
});
socket.on("reconnecting", (error) =>{
//...
});
socket.on("reconnect_failed", (error) =>{
//...
});
socket.on("ping", (error) =>{
//...
});
socket.on("pong", (error) =>{
//...
});
socket.on("disconnect", (error) =>{
//...
});
socket.on("error", (error) =>{
//...
});
//以下是开发者自定义的消息回调函数
socket.on("anonymous_username", (data) =>{
//服务端响应给客户端的随机生成用户名
username = data['username']
});
socket.on("users_list", (data) =>{
//服务端响应给客户端当前在线用户列表
users_list = data['users_list']
});
socket.on("relay_msg_sig", (data) =>{
//服务端中转的来自user_from的消息
user_from = data['from']
user_to = data['to']
msg = data['msg']
sig = data['sig']
timestamp = data['timestamp']
//...
//做一些消息解析处理和请求
});
timer = setInterval(refresh_users, 2000)
}
function refresh_users(){
var message = {from:username}
//向服务器请求在线用户列表
socket.emit('list_users', message)
}
</script>
//...
4.3 socket.io客户端的c++库
socket.io-client-cpp是用于向c++开发项目中集成socket.io客户端能力的库。
github地址:https://github.com/socketio/socket.io-client-cpp
4.3.1 实例
假设同4.2.1的例子。以下代码是在一个c++项目中集成了socket.io-client-cpp库并与服务端建立socket.io连接的例子。
// 文件名:mainwindow.h
#ifndef MAINWINDOW_H
#define MAINWINDOW_H
//...其他头文件
//socket.io-client-cpp库的头文件
#include "sio_client.h"
class MainWindow
{
public:
MainWindow();
~MainWindow();
public:
void prepareStreaming();
void refreshUserList();
//...其他函数
private:
//以下是socket.io-client-cpp库用到的自定义回调函数
void OnRelayMsgSig(sio::event const& data);
void OnUsersList(sio::event const& data);
void OnAnonymousUsername(sio::event const& data);
//以下是socket.io-client-cpp库用到的保留回调函数
void OnSioConnected(std::string const& nsp);
void OnSioClosed(sio::client::close_reason const& reason);
void OnSioFailed();
public:
std::string m_username;
std::string m_namespace; //socketio服务的命名空间,此例中为“/socketio/”
std::shared_ptr<sio::client> m_pSioClient; //socketio客户端实例
QTimer * m_pTimer;
std::string m_sPeerTo;
//其他的类成员变量
};
#endif // MAINWINDOW_H
// 文件名:mainwindow.cpp
#include "mainwindow.h"
#include <sstream>
MainWindow::MainWindow()
: m_username("")
, m_namespace("")
, m_pSioClient(nullptr)
, m_pTimer(nullptr)
{
//socket.io服务端命名空间
m_namespace = "/socketio/";
}
MainWindow::~MainWindow()
{
if(m_pTimer)
{
m_pTimer->stop();
delete m_pTimer;
m_pTimer = nullptr;
}
//关闭socket.io实例
if(m_pSioClient.get()){
m_pSioClient->socket(m_namespace)->off_all();
m_pSioClient->socket(m_namespace)->off_error();
}
}
void MainWindow::prepareStreaming()
{
//服务端https服务根地址
std::string rootPath = "https://localhost";
if(rootPath.size() == 0)
{
return;
}
if(m_pSioClient.get())
{
return;
}
//新建socket.io客户端实例
m_pSioClient.reset(new sio::client());
//获取与socket.io服务空间“/socketio/”对应的通信实例
sio::socket::ptr sock = m_pSioClient->socket(m_namespace);
//设置socket.io客户端实例在服务空间上的自定义消息处理函数
using std::placeholders::_1;
sock->on("relay_msg_sig",std::bind(&MainWindow::OnRelayMsgSig,this,_1));
sock->on("users_list",std::bind(&MainWindow::OnUsersList,this,_1));
sock->on("anonymous_username", std::bind(&MainWindow::OnAnonymousUsername, this, _1));
//设置socket.io客户端实例的保留消息处理函数
m_pSioClient->set_socket_open_listener(std::bind(&MainWindow::OnSioConnected, this, std::placeholders::_1));
m_pSioClient->set_close_listener(std::bind(&MainWindow::OnSioClosed,this,_1));
m_pSioClient->set_fail_listener(std::bind(&MainWindow::OnSioFailed,this));
//socket.io客户端实例向服务建立连接
m_pSioClient->connect(rootPath.c_str());
//设置定时消息
m_pTimer = new QTimer();
connect(m_pTimer, &QTimer::timeout, this, &MainWindow::refreshUserList);
m_pTimer->start(2000);
}
//定时从socket.io服务更新在线用户的列表
void MainWindow::refreshUserList()
{
if(m_pSioClient.get() && m_username.compare("") !=0)
{
sio::socket::ptr sock = m_pSioClient->socket(m_namespace);
//创建消息体
sio::message::ptr objPtr = sio::object_message::create();
//增加“from”字段
objPtr->get_map()["from"] = sio::string_message::create(m_username);
//向服务空间发送“list_users”消息
sock->emit("list_users", objPtr);
}
}
//处理由socket.io服务中转的、来自其他客户端的消息
void MainWindow::OnRelayMsgSig(const sio::event &data)
{
//以字典格式提取消息
std::map<std::string, sio::message::ptr> tmpMap = data.get_message()->get_map();
//解析消息字段
std::string strFrom = tmpMap.at("from")->get_string();
std::string strTo = tmpMap.at("to")->get_string();
sio::message::ptr tmpMsg = tmpMap.at("msg");
sio::message::ptr tmpSig = tmpMap.at("sig");
std::string strTimestamp = tmpMap.at("timestamp")->get_string();
if(strTo.compare(strFrom) == 0)
{
return;
}
//对该消息发送者进行响应
m_sPeerTo = strFrom;
if(tmpSig->get_flag() == sio::message::flag_string){
std::string strSig = tmpSig->get_string();
//处理并发送消息
//...
}
}
//接收到socket.io服务响应的在线用户列表
void MainWindow::OnUsersList(const sio::event &data)
{
//以列表格式提取消息
std::vector<sio::message::ptr> tmpVec = data.get_message()->get_map().at("users_list")->get_vector();
//遍历在线用户列表
//...
}
//接收到socket.io服务响应的随机昵称
void MainWindow::OnAnonymousUsername(const sio::event &data)
{
json tmpJson;
m_username = data.get_message()->get_map().at("username")->get_string();
}
//socket.io连接建立通知
void MainWindow::OnSioConnected(std::string const& nsp)
{
//与指定的服务空间成功建立连接
if(m_pSioClient.get() && nsp.compare(m_namespace) == 0) {
sio::socket::ptr sock = m_pSioClient->socket(m_namespace);
sio::message::ptr objPtr = sio::object_message::create();
objPtr->get_map()["from"] = sio::string_message::create(m_username);
//向socket.io服务发送自我介绍
sock->emit("self_introduce", objPtr);
}
}
void MainWindow::OnSioClosed(const sio::client::close_reason &reason){}
void MainWindow::OnSioFailed(){}
4.3 socket.io的服务端
本节采用的socket.io的服务端是python flask框架中的组件:Flask-SocketIo,其包名为flask_socketio。
Flask-SocketIo项目地址:https://flask-socketio.readthedocs.io/en/latest/
在正式介绍socket.io例子前,简单介绍一下python Flask框架的使用,方便读者理解Flask服务。
4.3.1 python Flask简介
想要快速了解Flask框架的使用,推荐跟随这个项目去学习:https://www.cnblogs.com/zhongyehai/category/1555138.html
想要细致的学习Flask框架,请阅读官方网站:flask.pocoo.org 或者中文翻译版:https://dormousehole.readthedocs.io/en/latest/。
如果你已经很熟悉Flask的使用了,可以跳过这一小节,直接看4.3.2小节。
4.3.2 实例
假设同4.1.1和4.2.1两个例子。以下代码是在一个python项目中集成了flask、flask_socketio等组件、建立一个socket.io服务的例子。
server.py文件主要是提供了socketio服务的业务处理逻辑:
(1)它首先将Flask的app实例通过socketio_server函数封装,具备socket.io服务能力,服务空间为“/socketio/”。
(2)socket.io服务处理“connect”、“disconnect”等保留消息。
(3)socket.io服务处理“self_introduce”、“list_users”、“relay_msg_sig”等自定义的业务消息。
# 文件路径:apps/socketio/server.py
from flask import request, session, json
from flask_socketio import SocketIO, send, emit, join_room, leave_room
from flask_socketio import Namespace
from utils.redis_cli import Redis
import shortuuid
from threading import Lock
# 以下是Redis中的表结构 (key value 解释)
# 'users' SET(username) 根据‘users’ key查询多个在线用户名
# username SET(sid) 根据用户名key查询多个关联的socektio会话号,可能同一个用户在多个客户端进行socket.io连接
# sid STRING(username) 根据socketio会话号查询关联的用户名
class SocketServerNamespace(Namespace):
lock = Lock()
def on_connect(self):
#flask请求中带有的socket.io会话号“sid”
sid = request.sid
print 'connect, sid:{}'.format(sid)
def on_disconnect(self):
sid = request.sid
#从Redis中根据sid获取关联的在线用户名
recorded_username = Redis.r().get(sid)
#Redis事务
with Redis.r().pipeline() as p:
if recorded_username:
#清除在线用户名和sid的关联记录
p.srem(recorded_username, sid)
#清除该在线用户名记录
p.srem('users', recorded_username)
else:
print 'sid:{} closed, but user is not online'.format(sid)
#清除该sid记录
p.delete(sid)
#执行该事务
p.execute()
print 'disconnect, sid:{}, username:{}, '.format(sid, recorded_username)
#处理来自socket.io客户端的“self_introduce”消息
def on_self_introduce(self, data):
print(data)
username = data['from']
sid = request.sid
#username为空
if not username:
#生成随机昵称
username = shortuuid.random(32)
#返回匿名随机昵称给客户端
self.emit('anonymous_username', {'username': username}, sid)
with Redis.r().pipeline() as p:
#增加该用户为在线用户
p.sadd('users', username)
#增加一条username到sid的映射记录
p.sadd(username, sid)
#设置sid对应的username
p.set(sid, username)
p.execute()
print 'self_introduced, sid:{}, username:{}'.format(sid, username)
#处理来自socket.io客户端的“list_users”消息
def on_list_users(self, data):
print(data)
sid = request.sid
user_from = data['from']
//获取在线用户列表
users = list(Redis.r().smembers('users'))
//发送给请求的客户端
self.emit('users_list', {'users_list': users}, sid)
print('received sid:{}, username:{}, list_users:{} '.format(sid, user_from, users))
#处理来自socket.io客户端的“relay_msg_sig”消息
def on_relay_msg_sig(self, data):
print(data)
user_from = data['from']
user_to = data['to']
msg = data['msg']
sig = data['sig']
timestamp = data['timestamp']
//查找目的客户端
sids_to = Redis.r().smembers(user_to)
//中转消息
for sid in sids_to:
self.emit('relay_msg_sig', data, sid)
# def on_join(data):
# username = data['username']
# room = data['room']
# join_room(room)
# send(username + ' has entered the room.', room=room)
#
# def on_leave(data):
# username = data['username']
# room = data['room']
# leave_room(room)
# send(username + ' has left the room.', room=room)
#封装socket.io服务
def socketio_server(app):
#将Flask的app实例封装进socketio实例
socketio = SocketIO(app)
#配置socket.io的服务空间
socketio.on_namespace(SocketServerNamespace('/socketio/'))
#配置默认错误处理函数
@socketio.on_error_default
def default_error_handler(e):
print(request.event["message"])
print(request.event["args"])
return socketio
view.py文件主要是提供浏览器访问socketio蓝图主页的访问入口和业务行为,明确响应的web页面模板
# 文件路径:apps/socketio/view.py
from flask import Blueprint, views, request, render_template, session, url_for, redirect, g
import config
#注册名为"socketio"的Flask蓝图,url地址是"https://ip:port/socketio"
bp = Blueprint('socketio', __name__, url_prefix='/socketio')
#浏览器访问"https://ip:port/socketio/test"地址时,以"socketio_index.html"网页模板进行响应
@bp.route('/test')
def index():
return render_template("socketio/socketio_index.html")
#正式用户访问入口函数
class SignInViews(views.MethodView):
def get(self):
userid = session.get(config.FRONT_USER_ID)
#如果用户ID不在seession中,则跳转到前端登录页面,并设置返回入口为本函数"socketio.signin";用户ID存在,则响应"socketio_index.html"网页模板
if not userid:
return render_template('front/front_signin.html', return_to=url_for("socketio.signin"))
else:
return render_template("socketio/socketio_index.html")
#浏览器访问"https://ip:port/socketio"地址时,以SignInViews入口函数进行响应,并将其命名为'signin'
bp.add_url_rule('/', view_func=SignInViews.as_view('signin'))
bbs.py主要是定义了整个Flask服务提供的所有蓝图、socketio服务、跨域保护、配置等,当作为运行模块时,启动该Flask服务。
# 文件路径:bbs.py
from flask import Flask
import config
from apps.cms import bp as cms_bp
from apps.common import bp as common_bp
from apps.front import bp as front_bp
from apps.socketio import bp as socketio_bp
from apps.JSPlayerCtrl import bp as PlayerCtrl_bp
from exts import db
from flask_wtf import CSRFProtect
from apps.socketio import socketio_server
#创建Flask应用
app = Flask(__name__)
//配置Flask
app.config.from_object(config)
app.register_blueprint(cms_bp)
app.register_blueprint(common_bp)
app.register_blueprint(front_bp)
#注册socketio_bp蓝图
app.register_blueprint(socketio_bp)
app.register_blueprint(PlayerCtrl_bp)
db.init_app(app)
#开启跨域保护
CSRFProtect(app)
#在app上启动socketio服务,并封装为socketio
socketio = socketio_server(app)
if __name__ == '__main__':
#启动https服务
socketio.run(app, '10.112.75.19',certfile='utils/server.crt', keyfile='utils/server.key')
五、具备webrtc P2P连接能力的web客户端
在第三节中介绍了webrtc的js接口,我们知道建立webrtc点对点连接需要在连接的两端传递SDP和ICE candidates信令。
在第四节中介绍了基于python Flask框架以及使用socket.io跨平台开发组件如何搭建起一个中转消息的socket.io服务以及如何在web端和支持c++的开发平台上实现对应的socket.io客户端。
在本节中,将把前两节的内容结合起来,提供一个web端的例子,该例子利用socket.io客户端来建立webrtc连接,并在web页面上展示本地和对端的音视频流。
5.1 实例
以下代码是用于演示的demo代码,如果有什么不妥的地方,烦请指出共同讨论。
socketio_function.js是web页面依赖的脚本,主要实现了socket.io客户端连接的业务代码,也包括了webrtc连接建立的全部流程。其中socket.io客户端的部分和4.2.1的实例是一样的。
//文件位置:static/socketio/js/socketio_function.js
var socket = null
var username = null
var timer = null
var pc = null
var pc_another = null
$(function (){
username = document.getElementById("username");
socket = io('https://' + window.location.host + '/socketio/');
socket.on('connect', function () {
if(username){
username = username.textContent
}
else {
username = null
}
socket.emit('self_introduce', {from:username});
});
socket.on('connect_error', (error) => {
// ...
});
socket.on('connect_timeout', (timeout) => {
// ...
});
socket.on('reconnect', (attemptNumber) => {
// ...
});
socket.on('reconnect_attempt', (attemptNumber) => {
// ...
});
socket.on('reconnecting', (attemptNumber) => {
// ...
});
socket.on('reconnect_failed', () => {
// ...
});
socket.on('ping', () => {
// ...
});
socket.on('pong', (latency) => {
// ...
});
socket.on('disconnect', () => {
});
socket.on('error', (error) => {
// ...
});
socket.on('anonymous_username', (data) => {
username = data['username'];
refresh_users()
});
socket.on('users_list', (data) => {
users_list = data['users_list'];
//获取html元素:在线用户列表,并更新它
var online_users =document.getElementById("online_users");
c = online_users.childNodes;
for (var i=c.length-1; i>=0; i--){
c[i].remove();
};
for(var user of users_list) {
var li = document.createElement('li');
li.textContent = user;
li.onclick= openChoice;
online_users.appendChild(li);
}
});
socket.on('relay_msg_sig', (data) => {
user_from = data['from']
user_to = data['to']
msg = data['msg']
sig = data['sig']
timestamp = data['timestamp']
var message=document.getElementById("textarea1")
message.textContent =message.textContent + '\n' + user_from +': '+ user_to +': '+ msg +': '+ sig +' :' + timestamp;
//接收到信令是SDP Offer
if(sig === 'sdp_offer')
{
//禁止webrtc建立回环连接
if( user_from !== user_to ) {
//关闭已经存在的webrtc实例
if(pc) {
pc.close()
}
//新建一个webrtc 实例
pc = new RTCPeerConnection();
//设置ICE canddiates回调函数
pc.onicecandidate = function(evt){
if(evt.candidate) {
//向对端中转该ICE candidates
relay_msg_sig(user_from, evt.candidate, "ice");
}
}
//设置远端媒体轨接收的回调函数
pc.ontrack = function (evt) {
var block_chat = null
//获取html元素:远端媒体播放窗
var reomte_video = document.getElementById("remote_video")
if (reomte_video) {
}
else {
//动态创建html元素:远端媒体播放窗
block_chat = document.getElementById("block_chat")
reomte_video = document.createElement('video')
reomte_video.id = 'remote_video'
reomte_video.autoplay = true
}
//用获取到的track配置远端媒体播放窗的数据源
var SRC_OBJECT = 'srcObject' in reomte_video ? reomte_video.srcObject :
'mozSrcObject' in reomte_video ? reomte_video.mozSrcObject :
'webkitSrcObject' in reomte_video ? reomte_video.webkitSrcObject : reomte_video.srcObject;
var media_stream = null
if(SRC_OBJECT === null)
{
media_stream = new MediaStream;
'srcObject' in reomte_video ? reomte_video.srcObject = media_stream :
'mozSrcObject' in reomte_video ? reomte_video.mozSrcObject = media_stream :
'webkitSrcObject' in reomte_video ? reomte_video.webkitSrcObject = media_stream : reomte_video.srcObject = media_stream;
}
else{
media_stream = 'srcObject' in reomte_video ? reomte_video.srcObject :
'mozSrcObject' in reomte_video ? reomte_video.mozSrcObject :
'webkitSrcObject' in reomte_video ? reomte_video.webkitSrcObject : reomte_video.srcObject;
}
if(evt.track.kind === "video") {
media_stream.addTrack(evt.track)
}
else if(evt.track.kind === "audio"){
media_stream.addTrack(evt.track)
}
if (block_chat) {
block_chat.appendChild(reomte_video)
}
};
//向webrtc实例设置远端SDP,这里为SDP Offer
pc.setRemoteDescription(msg);
//开启本地媒体采集
getUserMedia({video: true, audio: false},
function (stream) {
for (const track of stream.getTracks()) {
//本地视频轨添加到本地视频播放窗
if(track.kind === "video") {
var video = document.getElementById('local_video');
//将视频流设置为video元素的源
var media_stream = new MediaStream;
media_stream.addTrack(track)
var SRC_OBJECT = 'srcObject' in video ? video.srcObject = media_stream :
'mozSrcObject' in video ? video.mozSrcObject = media_stream :
'webkitSrcObject' in video ? video.webkitSrcObject = media_stream : video.srcObject = media_stream;
//播放视频
video.play();
//本地视频轨添加到webrtc实例中
pc.addTrack(track, stream);
}
else if (track.kind === "audio") {
//本地音频轨添加到webrtc实例中
pc.addTrack(track, stream);
}
};
//创建webrtc实例的SDP Answer
pc.createAnswer().then((desc) => {
//设置SDP Answer到本地webrtc实例
pc.setLocalDescription(desc)
//本地SDP Answer发送到对端
relay_msg_sig(user_from, desc, "sdp_answer");
});
},
function () {}
);
}
}
else if(sig === 'sdp_answer')
{
//接到的远端中转信令是SDP Answer,则设置到本地webrtc实例中
pc.setRemoteDescription(msg).catch((error) => {
console.error(error);
});
}
else if(sig === 'ice')
{
//接到的远端中转信令是ICE candidates,则设置到本地webrtc实例中
if(user_from === user_to) {
}
else {
pc.addIceCandidate(msg).catch((error) => {
console.error(error);
});
}
}
});
//配置定时器,定时更新在线用户列表
timer = setInterval(refresh_users, 2000)
window.onclose = function () {
}
});
//获取本地媒体采集实例
function getUserMedia(constrains,success,error){
if(navigator.mediaDevices.getUserMedia){
//最新标准API
navigator.mediaDevices.getUserMedia(constrains).then(success).catch(error);
} else if (navigator.webkitGetUserMedia){
//webkit内核浏览器
navigator.webkitGetUserMedia(constrains).then(success).catch(error);
} else if (navigator.mozGetUserMedia){
//Firefox浏览器
navagator.mozGetUserMedia(constrains).then(success).catch(error);
} else if (navigator.getUserMedia){
//旧版API
navigator.getUserMedia(constrains).then(success).catch(error);
}
}
//更新在线用户
function refresh_users()
{
var message = {from: username}
send_json("list_users", message)
}
//向socket.io发送中转消息的封装函数
function relay_msg_sig(to, msg, sig)
{
var time=new Date()
var message = {from:username, to: to, msg: msg, sig: sig, timestamp: time}
send_json('relay_msg_sig', message)
}
function close_src(){
if(socket.connected)
{
socket.close()
socket = null
}
if(timer) {
window.clearInterval(timer)
timer = null
}
if(pc)
{
pc.close()
pc = null
}
}
function send(){
var message_input = $("input[name='message']");
var message = message_input.val();
var message_to =document.getElementById("message_to").textContent;
if(message_to === '')
{
message_to = username;
}
//send_json("data", message)
relay_msg_sig(message_to, message, 'sig')
}
//主动发起webrtc连接的封装函数
function start_chat_online(){
//关闭已经存在的webrtc实例
if(pc)
{
pc.close()
}
//设置消息中转的目的端昵称
var message_to = document.getElementById("message_to").textContent;
//如果没有目的端,默认发给自己,用于测试消息中转服务是否正常
if(message_to === "")
{
message_to = username
}
//新建webrtc实例
pc = new RTCPeerConnection();
//以下与上文类似,差别为这里不是生成SDP Answer,而是生成SDP Offer
pc.ontrack = function (evt) {
var block_chat = null
var reomte_video = document.getElementById("remote_video")
if (reomte_video) {
}
else {
block_chat = document.getElementById("block_chat")
reomte_video = document.createElement('video')
reomte_video.id = 'remote_video'
reomte_video.autoplay = true
}
var SRC_OBJECT = 'srcObject' in reomte_video ? reomte_video.srcObject :
'mozSrcObject' in reomte_video ? reomte_video.mozSrcObject :
'webkitSrcObject' in reomte_video ? reomte_video.webkitSrcObject : reomte_video.srcObject;
var media_stream = null
if(SRC_OBJECT === null)
{
media_stream = new MediaStream;
'srcObject' in reomte_video ? reomte_video.srcObject = media_stream :
'mozSrcObject' in reomte_video ? reomte_video.mozSrcObject = media_stream :
'webkitSrcObject' in reomte_video ? reomte_video.webkitSrcObject = media_stream : reomte_video.srcObject = media_stream;
}
else{
media_stream = 'srcObject' in reomte_video ? reomte_video.srcObject :
'mozSrcObject' in reomte_video ? reomte_video.mozSrcObject :
'webkitSrcObject' in reomte_video ? reomte_video.webkitSrcObject : reomte_video.srcObject;
}
if(evt.track.kind === "video") {
media_stream.addTrack(evt.track)
}
else if(evt.track.kind === "audio"){
media_stream.addTrack(evt.track)
}
if (block_chat) {
block_chat.appendChild(reomte_video)
}
};
pc.onicecandidate = function(evt){
if(evt.candidate) {
relay_msg_sig(message_to, evt.candidate, "ice");
}
}
getUserMedia({video: true, audio: true},
function (stream){
for (const track of stream.getTracks()) {
if (track.kind === "video") {
var video = document.getElementById('local_video');
var media_stream = new MediaStream;
media_stream.addTrack(track)
//将视频流设置为video元素的源
var SRC_OBJECT = 'srcObject' in video ? video.srcObject = media_stream :
'mozSrcObject' in video ? video.mozSrcObject = media_stream :
'webkitSrcObject' in video ? video.webkitSrcObject = media_stream : video.srcObject = media_stream;
//播放视频
video.play();
pc.addTrack(track, stream);
}
else if (track.kind === "audio") {
pc.addTrack(track, stream);
}
}
pc.createOffer().then((desc) =>{
pc.setLocalDescription(desc)
relay_msg_sig(message_to, desc, "sdp_offer");
}).catch((error) => {
console.error(error);
});
},
function (e) {});
}
function send_json(event, message){
if(socket.connected)
{
socket.emit(event,message);
}
}
socketio_index.html文件是在4.3.2小节中的view.py文件中使用到的web主页。
在该主页中使用了上面的socketio_function.js脚本文件。
在该主页中定义了本地视频窗的html标签和“视频聊天”按钮等。
<!--文件位置:templates/socketio/socketio_index.html-->
{% extends 'socketio/socketio_base.html' %}
{% block title %}首页-BBS论坛{% endblock %}
{% block head %}<script src="{{ url_for('static', filename='socketio/js/socketio_function.js') }}"></script>{% endblock %}
{% block chat %}
<div >
<textarea type="text" class="form-control" id="textarea1"></textarea>
</div>
<div >
<input type="text" class="form-control inputbox" id="message" name="message" placeholder="消息">
<button class="btn btn-warning btn-block sendbtn" id="submit-btn" onclick="send()">发送消息</button>
</div>
<div>
<button class="btn btn-warning btn-block sendbtn" id="online-chat-btn" onclick="start_chat_online()">视频聊天</button>
<video id="local_video" ></video>
</div>
{% endblock %}
socketio_base.html文件是上面socektio_index.html文件的模板。
本文件定义了若干需要的js脚本、css样式表和网页的一些元素。
本文件最后定义了一个在线用户列表,用于选择消息发送的目的端;定义了一个block chat块,用于在具体的页面中设置不一样的展示元素。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{% block title %}{% endblock %}</title>
<script src="{{ url_for('static', filename='common/js/jquery.min.js') }}"></script>
<link href="{{ url_for('static', filename='common/bootstrap.min.css') }}" rel="stylesheet">
<script src="{{ url_for('static', filename='common/js/bootstrap.min.js') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='socketio/FriendList.css') }}">
<script src="{{ url_for('static', filename='socketio/js/friend_list.js') }}"></script>
<script src="{{ url_for('static', filename='common/js/bbsajax.js') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='common/sweetalert/sweetalert2.min.css') }}">
<script src="{{ url_for('static', filename='common/sweetalert/sweetalert2.min.js') }}"></script>
<script src="{{ url_for('static', filename='socketio/js/socket.io.js') }}"></script>
{% block head %}{% endblock %}
</head>
<body onbeforeunload="close_src()">
<nav class="navbar navbar-default">
<div class="container-fluid">
<div class="navbar-header">
<a class="navbar-brand" href="#">wodner-BBS论坛</a>
</div>
<!-- Collect the nav links, forms, and other content for toggling -->
<div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
<ul class="nav navbar-nav">
<li><a href="#">首页</a></li>
</ul>
<form class="navbar-form navbar-left">
<div class="form-group">
<input type="text" class="form-control" placeholder="请输入关键字">
</div>
<button type="submit" class="btn btn-default">搜索</button>
</form>
<ul class="nav navbar-nav navbar-right">
{% if session.get(config.FRONT_USER_NAME) %}
<li><a href="#" id="username">{{ session.get(config.FRONT_USER_NAME) }}</a></li>
<li><a href="#">注销</a> </li>
{% else %}
<li><a href="{{ url_for('front.signin') }}">登录</a></li>
<li><a href="#">注册</a></li>
{% endif %}
</ul>
</div>
</div>
</nav>
<style>
</style>
<div class="column sidemenu">
<a>在线用户</a>
<div>
<ul id="online_users" style="list-style-type:circle"></ul>
</div>
</div>
<div id='block_chat' class="column content">
<p id="message_to" ></p>
{% block chat %}{% endblock %}
</div>
</body>
</html>
以下是该项目的web展示页面的截图,两个web页面分别使用匿名登录测试页面,一方向另一方发起webrtc会话请求。由于是在同一台机器上的两个页面,远端和近端采集的画面是一致的,上面的画面是本地画面,下面的画面是远端画面,只是传输到对端时画面有一丝延迟。