想看英文可以移步官方文档
理解例子的源码
Kurento 提供了 Kurento Java Client 来控制Kurento Media
Server(KMS),这个例子用了Kurento Java Client(KJC)来控制KMS。并且在Kurento Java Client上用了Java 的Spring-Boot框架。
1.应用的逻辑
很简单,浏览器将摄像头和麦克风获得的本地流传输给远程的KMS(或本级部署的)然后再不经调制的传输回本地浏览器进行显示。
要完成这个动作,我们需要创造一个只包含一个Media Element的Media Pipeline。本例中那个唯一的 Media Element 就是一个 WebRtcEndpoint,它具有全双工交换WebRTC媒体流的能力。它自己与自己相连,以保证送出的流通过一圈再传送回来,也就是我们要实现的镜像功能(loopback)。
介绍如下
这块其实很好理解,不用太过注意这个图。
2.架构
由于是浏览器应用,所以也服从CS架构。
在客户端,使用 JavaScript 来实现客户端逻辑。在服务器端,用基于 Spring-Boot 的 Java 实现,调用 Kurento Java Client API 来控制 Kurento Media Server。
总之,这个应用是一个三层结构的应用,一共有三层实体分别为
客户端 —— 客户端服务器&Kurento Java Client —— KMS
注意这里的客户端服务器就是KJC,只是看的角度不一样。它处理来自客户端的请求,同时控制KMS,即,既是客户端的服务器,又是KMS的客户端。
由于是三层结构,就需要两个WebSocket了,一个用于前两个之间的连接,遵循Custom Signaling Protocol,另一个用于后两者的连接,遵循Kurento Protocol。
想进一步了解可以看关于这块的详细信息
接下来用一个SD来体现这三者之间的关系。该图中包含的详细信息十分重要,每一个信息的交互都会在后边的代码中体现。
3.代码分析
(1)服务器端
服务器端就是SD图中间的绿色实体,也就是三层架构中位于中间层的Application Server
再次标注一下,服务器端用了基于Spring-Boot的Java来实现。
-
首先来看一下服务器端代码的整体架构
图中列出的几个类就是我们的Java的代码中的所有类了。一会再看代码的时候我们会一步一步的发现这个图的指向具体意思。这个图也是一个对于理解代码非常重要的图。
- 主类 HelloWorldApp
@Bean
public HelloWorldHandler handler() {
return new HelloWorldHandler();
}
@Bean
public KurentoClient kurentoClient() {
return KurentoClient.create();
}
我们可以看到,在HelloWorldApp
类中,HelloWorldHandler
和KurentoClient
都被实例化为了 Bean. Bean是Spring-Boot框架中一个重要的结构名称,具体可以去了解Spring-Boot框架中的这部分内容,这里你可以就把它当成,是 HelloWorldApp 是一个大容器,里面装了两个小容器分别是 HelloWorldHandler 和 KurentoClient 。这就是为什么图2中 HelloWorldApp 实体有两条尖头分别指向 HelloWorldHandler 和 KurentoClient 实体
这里的KurentoClient
在创建时默认了KMS服务器在本机上。如果你的KMS服务器部署在远程的话,就需要向如下代码一样创建KurentoClient
@Bean
public KurentoClient kurentoClient() {
return KurentoClient.create("ws://197.162.38.40:8888/kurento");
}
public class HelloWorldApp implements WebSocketConfigurer {
可以看到 HelloWorldApp 类在创建时 implements 了 WebSocketConfigurer
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(handler(), "/helloworld");
}
通过 Override WebSocketConfigurer
的registerWebSocketHandlers(WebSocketHandlerRegistry registry)
方法,将 HelloWorldHandler 类的实例handler
作为WebSocketHandler
来处理 WebSocket 请求,且处理路径为“/helloworld”
- WebSocket处理类 HelloWorldHandler
可以看到 HelloWorldHandler 类在创建时 implements 了TextWebSocketHandler
该类的核心部分是handleTextMessage
方法,这个方法通过手动编写逻辑实现了上文提到的两个 WebSocket 中遵循 Custom Signaling Protocol 的那个 WebSocket 的处理程序,也就是作为Client Server的那部分功能,体现在图1中 JavaScriptClient 和 ApplicationServer 之间的信息交互。而通过直接调用 Kurento Java Client API 来完成第二个 websocket 的通信,是自动的,不用手动码逻辑。
注意,这里的代码每一处都可以在图1中的信息交互中找到对应的表示。
根据JsonMessage
的不同分了三个不同的case,其中最重要的是start,里面调用了start
函数,见下文。
start
方法主要完成了如下四个工作,注释也都写出来了。
配置媒体处理逻辑
MediaPipeline pipeline = kurento.createMediaPipeline();
WebRtcEndpoint webRtcEndpoint = new WebRtcEndpoint.Builder(pipeline).build();
webRtcEndpoint.connect(webRtcEndpoint);
1.创建MediaPipeline
2.用pipeline
创建WebRtcEndpoint
3.创建好的webRtcEndpoint
自己和自己相连
- 这几步在前文都清楚得提到过
存储用户会话
UserSession user = new UserSession();
user.setMediaPipeline(pipeline);
user.setWebRtcEndpoint(webRtcEndpoint);
users.put(session.getId(), user);
由于在最后会调用stop来释放,所以需要存储用户会话来保证这个功能,也就是要存储MediaPipeline
和WebRtcEndpoint
。SDP通讯
String sdpOffer = >>jsonMessage.get("sdpOffer").getAsString();
从jsonMessage
中获得sdpOffer
部分并转换成String。
String sdpAnswer = webRtcEndpoint.processOffer(sdpOffer);
把sdpOffer
作为参数传给webRtcEndpoint
形成sdpAnswer
,调用了webRtcEndpoint
的processOffer
方法,这里对应图1中的左边前两条数据交换箭头,其中processOffer
方法完成了两条线中间右边Application Server和KMS之间的一系列数据交换。
JsonObject response = new JsonObject();
response.addProperty("id", "startResponse");
response.addProperty("sdpAnswer", sdpAnswer);
synchronized (session) { session.sendMessage(new TextMessage(response.toString())); }
这里是把sdpAnswer
加入一个json里再加上一些必要信息然后再转换成String并在会话里送出。
- WebRtc 在 peer 之间交换媒体数据遵循SDP (Session Description protocol),也就是说用的是SDPOffer和SDPAnswer机制。
- 这一步完成了图1中上半部份的SDP通信的功能,包括处理来自JavaScript的客户端请求和控制远端KMS两部分动作。
- 注意⚠️,JavaScriptClient的websocket请求用这里的
HelloWorldHandler
来处理是由于在Index.js里制定了websocket的处理路径是/helloworld
,并且在主程序HelloWorldApp
里将handler
注册到了/helloworld
路径里。收集ICE candidates
Kurento 6 以后全面支持Trickle ICE 协议,使得WebRtcEndpoint 可以异步得收集 ICE candidates。也正是因此,每一个WebRtcEndpoint 都需要一个监听器,可以在每次ICE 收集程序结束后接收到一个事件的触发
webRtcEndpoint.addIceCandidateFoundListener
(new EventListener
() {
@Override
public void onEvent(IceCandidateFoundEvent event) {
JsonObject response = new JsonObject();
response.addProperty("id", "iceCandidate");
response.add("candidate", JsonUtils.toJsonObject(event.getCandidate()));
try {
synchronized (session) {
session.sendMessage(new TextMessage(response.toString())); } } catch (IOException e) { log.error(e.getMessage()); } } }
);
第一件事
webRtcEndpoint.gatherCandidates();
第二件事
总体上来看这段代码一共做了两件事。
1.webRtcEndpoint.addIceCandidateFoundListener()
,给webRtcEndpoint
添加了一个监听器。
2.webRtcEndpoint.gatherCandidates()
,开始收集ICE Candidates。
然后我们来分析第一个代码。并将它与图1的信息交互进行对照.
很简单,在构造监听器的时候重写它的onEvent
方法,接收参数为IceCandidateFoundEvent
类型,这是 Kurento Java Client API 中的函数webRtcEndpoint.gatherCandidates();
可以得到的结果类型,当监听器发现这个类型的参数出现,或者说一旦gatherCandidates()
方法开始得到结果时,就会触发监听器。监听器内部所做的事情就是把candidate
转成Json然后加上附加信息再转成String然后发出去。
现在可以对着代码去图1里找一下对应的线了。同样,左边bar的信息交互是这里手动码出来的动作,右边bar的信息交互是由 Kurento Java Client API 自动实现的,不需要费心再码。
(2)客户端
var ws = new WebSocket('wss://' + location.host + '/helloworld');
首先看到,它用了 JavaScript 类来创建webSocket
,并且把这个webSocket
的处理路径放在了/helloworld
,这就是前文提到的,客户端是怎样和 server 端实现 webSocket 的通信。
well,再总结一遍,其实就是在客户端用 JavaScript 建一个webSocket
,并把处理路径设置为/helloworld
;然后再在主 java 程序中进行注册,把处理程序注册到处理路径也就是/helloworld
上就OK了。
var videoInput;
var videoOutput;
var webRtcPeer;
var state = null;
const I_CAN_START = 0;
const I_CAN_STOP = 1;
const I_AM_STARTING = 2;
window.onload = function() {
console = new Console();
console.log('Page loaded ...');
videoInput = document.getElementById('videoInput');
videoOutput = document.getElementById('videoOutput');
setState(I_CAN_START);
}
设置onload
监听器,new 一个consloe
,输出“Page loaded”,然后获取videoInput
和videoOutput
对象(这两个的id是在html里面定义的),然后setState
到可以启动
window.onbeforeunload = function() {
ws.close();
}
设置onbeforeunload
监听器,语意为关闭窗口前关掉 websocket 。
ws.onmessage = function(message) {
var parsedMessage = JSON.parse(message.data);
console.info('Received message: ' + message.data);
switch (parsedMessage.id) {
case 'startResponse':
startResponse(parsedMessage);
break;
case 'error':
if (state == I_AM_STARTING) {
setState(I_CAN_START);
}
onError('Error message from server: ' + parsedMessage.message);
break;
case 'iceCandidate':
webRtcPeer.addIceCandidate(parsedMessage.candidate, function(error) {
if (error)
return console.error('Error adding candidate: ' + error);
});
break;
default:
if (state == I_AM_STARTING) {
setState(I_CAN_START);
}
onError('Unrecognized message', parsedMessage);
}
}
设置onmessage
监听器,有一个参数message
,进来以后把它Jason化,然后switch。一共会有三种类型的message
,分别是startResponse
, error
,和 iceCandidate
。
然后就是每种message
之后的操作。
(1)正常的操作是在I_CAN_START状态中的start按钮的onClick
方法中做的,调用start()
函数来执行操作进行信息交互,而start里的操作就是图1中JavaScriptClient端的各种信息交互操作了。
注意⚠️,正常情况下这些函数基本都是在setState
里面的设置按钮的onClick
方法里调用的,稍微有点隐蔽。而setState
本身也是一个神奇的函数,它是用来设置整个页面的状态的,状态不同每个按钮的状态也就不同,点了以后的操作也不一样。这是一个很神奇的操作。
function start() {
console.log('Starting video call ...');
// Disable start button
setState(I_AM_STARTING);
showSpinner(videoInput, videoOutput);
console.log('Creating WebRtcPeer and generating local sdp offer ...');
var options = {
localVideo : videoInput,
remoteVideo : videoOutput,
onicecandidate : onIceCandidate
}
webRtcPeer = new kurentoUtils.WebRtcPeer.WebRtcPeerSendrecv(options,
function(error) {
if (error)
return console.error(error);
webRtcPeer.generateOffer(onOffer);
});
}
这里看start()
方法做了什么,设置状态输出文字什么的就不说了,最主要是它用了kurento-utils.js里的WebRtcPeer.WebRtcPeerSendrecv()
方法来创建一个 WebRtc 通信。
这里可能会有一些疑问,这里不是只是JavaScriptClient和ApplicationServer之间的通信吗,换句话说这里不是应该只是JavaScriptClient发出的webSocket请求吗怎么又用了 Kurento 的一个 util.js 的库。
其实是这样的,这里就是JavaScriptClient和ApplicationServer之间的通信,虽然调用的是 kurento 的库,但实现的是 WebRtc 的通信啊,WebRtc 本来就是基于浏览器的,如果你在JavaScriptClient和ApplicationServer之间不启动 WebRtc ,ApplicationServer和KMS之间的 Kurento 也就毫无意义了。所以这里虽然用了 Kurento 的一个库但还牵扯不到 Kurento,还只是浏览器原生的 WebRtc 通信的建立。
(2)在点了start按钮并且start()
了以后,就进入了处理 WebRtc 通信的阶段,其中startResponse
case中做的是开始JS客户端的SDP信息交互操作,iceCandidate
同样,是调用那个kurento-utils.js里的 WebRtc 原生方法来实现ICE的信息交互操作。反正最重要的东西是图1,它确切反映了所有类的动作还有类与类之间的信息交互。
function onOffer(error, offerSdp) {
if (error)
return console.error('Error generating the offer');
console.info('Invoking SDP offer callback function ' + location.host);
var message = {
id : 'start',
sdpOffer : offerSdp
}
sendMessage(message);
}
function onError(error) {
console.error(error);
}
function onIceCandidate(candidate) {
console.log('Local candidate' + JSON.stringify(candidate));
var message = {
id : 'onIceCandidate',
candidate : candidate
};
sendMessage(message);
}
function startResponse(message) {
setState(I_CAN_STOP);
console.log('SDP answer received from server. Processing ...');
webRtcPeer.processAnswer(message.sdpAnswer, function(error) {
if (error)
return console.error(error);
});
}
function stop() {
console.log('Stopping video call ...');
setState(I_CAN_START);
if (webRtcPeer) {
webRtcPeer.dispose();
webRtcPeer = null;
var message = {
id : 'stop'
}
sendMessage(message);
}
hideSpinner(videoInput, videoOutput);
}
function setState(nextState) {
switch (nextState) {
case I_CAN_START:
$('#start').attr('disabled', false);
$('#start').attr('onclick', 'start()');
$('#stop').attr('disabled', true);
$('#stop').removeAttr('onclick');
break;
case I_CAN_STOP:
$('#start').attr('disabled', true);
$('#stop').attr('disabled', false);
$('#stop').attr('onclick', 'stop()');
break;
case I_AM_STARTING:
$('#start').attr('disabled', true);
$('#start').removeAttr('onclick');
$('#stop').attr('disabled', true);
$('#stop').removeAttr('onclick');
break;
default:
onError('Unknown state ' + nextState);
return;
}
state = nextState;
}
function sendMessage(message) {
var jsonMessage = JSON.stringify(message);
console.log('Senging message: ' + jsonMessage);
ws.send(jsonMessage);
}
function showSpinner() {
for (var i = 0; i < arguments.length; i++) {
arguments[i].poster = './img/transparent-1px.png';
arguments[i].style.background = "center transparent url('./img/spinner.gif') no-repeat";
}
}
function hideSpinner() {
for (var i = 0; i < arguments.length; i++) {
arguments[i].src = '';
arguments[i].poster = './img/webrtc.png';
arguments[i].style.background = '';
}
}
/**
* Lightbox utility (to display media pipeline image in a modal dialog)
*/
$(document).delegate('*[data-toggle="lightbox"]', 'click', function(event) {
event.preventDefault();
$(this).ekkoLightbox();
});
之后的官方文档还讲解了dependency的一些设置,但我没有看,应该也比较好理解。所以,就到这里吧,还有什么之后看到了再随时补充。