kurento-hello-world V6.0 源码分析
一、Web页面
后台服务用命令行启动:
$ mvn clean compile exec:java
启动成功后,在chorme浏览器的地址栏输入:
http://localhost:8080
即可看到如下页面
二、系统分析
2.1 示例程序的框架
这是一个Web应用程序,遵从客户-服务端的架构,主要分成三个部分:
Ø 页面客户端,包括HTML页面,页面按键调用的JavaScript函数,以及支持前两者的Web服务器----Tomcat,
Ø 应用程序服务端,使用Java EE应用程序服务框架,调用Kurento Java Client API。
Ø Kurento Media Server, 是真正进行媒体处理的多媒体服务器。
这三者之间的通信都是使用WebSocket+JSON实现。
2.2. 系统通信的时序图
系统通信的时序图如下:
三、 页面客户端
3.1. Web服务器的实现
这个页面应用程序的Web服务器使用了基于Spring Boot的Tomcat.
Web服务的pom.xml中相关的设置如下:
. . .
<properties>
<demo.port>8081</demo.port>
<!-- Main class -->
<start-class>org.kurento.tutorial.helloworld.HelloWorldApp</start-class>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
. . .
</dependencies>
. . .
在上面的配置中,
<artifactId>spring-boot-starter-web</artifactId> 告诉spring boot,我们要启动web应用;
<start-class>org.kurento.tutorial.helloworld.HelloWorldApp</start-class> 告诉 Spring Boot Maven在启动Tomcat后要执行的主类所在java文件位置;
3.2 处理页面请求的主类
接着看主类所在的Java文件
“src/main/java/org/kurento/tutorial/helloworld/HelloWorldApp.java”
代码如下:
package org.kurento.tutorial.helloworld;
. . .
@Configuration
@EnableWebSocket
@EnableAutoConfiguration
public class HelloWorldApp implements WebSocketConfigurer {
final static String DEFAULT_KMS_WS_URI = "ws://localhost:8888/kurento";
@Bean
public HelloWorldHandler handler() {
return new HelloWorldHandler();
}
@Bean
public KurentoClient kurentoClient() {
return KurentoClient.create(System.getProperty("kms.ws.uri", DEFAULT_KMS_WS_URI));
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(handler(), "/helloworld");
}
public static void main(String[] args) throws Exception {
new SpringApplication(HelloWorldApp.class).run(args);
}
}
其中,
@EnableAutoConfiguration 注解告诉Spring Boot根据添加的jar依赖猜测你想如何配置Spring。
由于 spring-boot-starter-web 添加了Tomcat和Spring MVC,所以auto-configuration将假定你正在开发一个web应用并相应地对Spring进行设置。
这个Java程序最后部分是main方法”public static void main(String[] args) throws Exception {”。
这只是一个标准的方法,它遵循Java对于一个应用程序入口点的约定。
我们的main方法通过调用run,将业务委托给了Spring Boot的SpringApplication类。SpringApplication将引导我们的应用,启动Spring,相应地启动被自动配置的Tomcat web服务器。
我们需要将 HelloWorldApp.class作为参数传递给run方法来告诉SpringApplication谁是主要的Spring组件。为了暴露任何的命令行参数,args数组也会被传递过去。
主类HelloWorldApp 实现了接口WebSocketConfigurer,
并通过注册函数
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(handler(), "/helloworld");
}
注册了一个 WebSocketHandler, 即:
@Bean
public HelloWorldHandler handler() {
return new HelloWorldHandler();
}
HelloWorldHandler类实现了TextWebSocketHandler来处理文本WebSocket的请求,它就是WebSocket的服务端。
通过这个类来处理对路径 “/helloworld” 下的WebSocket请求。
我们可以看到,在对应页面客户端 src/main/resources/static/js/index.js中, WebSocket客户端的路径设置如下:
var ws = new WebSocket('ws://' + location.host + '/helloworld');
3.3 页面客户端代码分析
为了和服务端的WebSocket服务通信,我们需要在客户端页面中使用 JavaScript类 WebSocket。
我们还需要使用 Kurento JavaScript 库,叫做 kurento-util.js, 来简化和服务端的WebRTC交互。
这个库依赖于 adapter.js, 它是谷歌开发JavaScript WebRTC库,它抽象并封装了各个浏览器间的差异,能方便页面程序开发人员的开发。
页面客户端还需要 jquery.js;
这些库的都在 src/main/resources/static/index.html 而在中被链接:
. . .
<script src="bower_components/jquery/dist/jquery.min.js"></script>
<script src="bower_components/bootstrap/dist/js/bootstrap.min.js"></script>
<script src="bower_components/ekko-lightbox/dist/ekko-lightbox.min.js"></script>
<script src="bower_components/adapter.js/adapter.js"></script>
<script src="bower_components/demo-console/index.js"></script>
<script src="js/kurento-utils.js"></script>
<script src="js/index.js"></script>
. . .
在 src/main/resources/static/js/index.js中被使用。
【NOTE】换句话说,我们在开发自己的应用时,可以不需要使用基于spring boot + tomcat的框架,
可以直接使用自己的Web服务框架,如Nginx, python+tornado等,链接上这些JavaScript库也能正常工作。
>>> 首先,
需要创建一个对路径”/helloworld”通信的WebSocket:
var ws = new WebSocket('ws://' + location.host + '/helloworld');
当点击页面的start按键后,将调用src/main/resources/static/js/index.js的start()函数,开始WebRTC通信:
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);
});
}
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 onIceCandidate(candidate) {
console.log('Local candidate' + JSON.stringify(candidate));
var message = {
id : 'onIceCandidate',
candidate : candidate
};
sendMessage(message);
}
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";
}
}
其中,kurentoUtils.WebRtcPeer.WebRtcPeerSendrecv()是js/kurento-utils.js定义的函数,它抽象了WebRTC的内部细节(如PeerConnection和getUserStream),
并且,它使用HTML视频标签 videoInput ---- 显示视频摄像头(本地流)和视频标签videoOupup----显示远端由Kurento Media Server提供的流,来启动一个全双工的WebRTC通信。
在这个函数中,将会调用generateOffer()函数。
这个函数(指generateOffer())会根据参数offeSDP生成客户端SDP请求,然后调用函数onOffer()。
在onOffer()函数中,会通过WebSocket把SDP和id信息发送到WebSocket信令服务端;
>>> 然后,
WebSocket的监听函数onmessage() 用来处理从WebSocket服务端发送到客户端的JSON信令消息。
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);
}
}
从服务端发送来的消息有三种,startResponse, error和iceCandidate,每种消息都有对应的处理函数。
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 onError(error) {
console.error(error);
}
>>> 最后,
用户点击页面stop按键后,将会调用src/main/resources/static/js/index.js中的stop()函数,
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);
}
它会通过WebSocket客户端,发送停止消息到WebSocket服务端,结束服务。
四、应用程序服务端
如前所述,由页面客户端通过WebSocket发送的信令消息会被应用程序服务端的
src/main/java/org/kurento/tutorial/helloworld/HelloWorldHandler.java的HelloWorldHandler类处理。
同时,他还需要创建一个和Kurento Media Server进行WebSocket通信的客户端:
src/main/java/org/kurento/tutorial/helloworld/HelloWorldApp.java 下
public class HelloWorldApp implements WebSocketConfigurer {
final static String DEFAULT_KMS_WS_URI = "ws://localhost:8888/kurento";
@Bean
public HelloWorldHandler handler() {
return new HelloWorldHandler();
}
@Bean
public KurentoClient kurentoClient() {
return KurentoClient.create(System.getProperty("kms.ws.uri", DEFAULT_KMS_WS_URI));
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(handler(), "/helloworld");
}
public static void main(String[] args) throws Exception {
new SpringApplication(HelloWorldApp.class).run(args);
}
}
当页面客户端通过websocket发送过来信令后,会在HelloWorldHandler类的 handlerTextMessage方法中进行分别处理:
public void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
JsonObject jsonMessage = gson.fromJson(message.getPayload(), JsonObject.class);
log.debug("Incoming message: {}", jsonMessage);
switch (jsonMessage.get("id").getAsString()) {
case "start":
start(session, jsonMessage);
break;
case "stop": {
UserSession user = users.remove(session.getId());
if (user != null) {
user.release();
}
break;
}
case "onIceCandidate": {
JsonObject jsonCandidate = jsonMessage.get("candidate").getAsJsonObject();
UserSession user = users.get(session.getId());
if (user != null) {
IceCandidate candidate = new IceCandidate(jsonCandidate.get("candidate").getAsString(),
jsonCandidate.get("sdpMid").getAsString(), jsonCandidate.get("sdpMLineIndex").getAsInt());
user.addCandidate(candidate);
}
break;
}
default:
sendError(session, "Invalid message with id " + jsonMessage.get("id").getAsString());
break;
}
}
>>>首先,
当页面客户端发送了”start”信令后,将调用start方法进行处理:
import org.kurento.client.EventListener;
import org.kurento.client.IceCandidate;
import org.kurento.client.KurentoClient;
import org.kurento.client.MediaPipeline;
import org.kurento.client.OnIceCandidateEvent;
import org.kurento.client.WebRtcEndpoint;
public class HelloWorldHandler extends TextWebSocketHandler {
@Autowired
private KurentoClient kurento;
private final ConcurrentHashMap<String, UserSession> users =
new ConcurrentHashMap<String, UserSession>();
private void start(final WebSocketSession session, JsonObject jsonMessage) {
try {
// 1. Media logic (webRtcEndpoint in loopback)
MediaPipeline pipeline = kurento.createMediaPipeline();
WebRtcEndpoint webRtcEndpoint = new WebRtcEndpoint.Builder(pipeline).build();
webRtcEndpoint.connect(webRtcEndpoint);
// 2. Store user session
UserSession user = new UserSession();
user.setMediaPipeline(pipeline);
user.setWebRtcEndpoint(webRtcEndpoint);
users.put(session.getId(), user);
// 3. SDP negotiation
String sdpOffer = jsonMessage.get("sdpOffer").getAsString();
String sdpAnswer = webRtcEndpoint.processOffer(sdpOffer);
JsonObject response = new JsonObject();
response.addProperty("id", "startResponse");
response.addProperty("sdpAnswer", sdpAnswer);
synchronized (session) {
session.sendMessage(new TextMessage(response.toString()));
}
// 4. Gather ICE candidates
webRtcEndpoint.addOnIceCandidateListener(new EventListener<OnIceCandidateEvent>() {
@Override
public void onEvent(OnIceCandidateEvent 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();
} catch (Throwable t) {
sendError(session, t.getMessage());
}
}
start()方法执行了下列动作:
1. 配置媒体处理逻辑
在这部分中,应用程序服务端配置了Kurento 如何处理媒体。
首先,使用KurentoClient对象kurento创建一个MediaPipeline对象,通过它,我们可以再创建我们想要的媒体元件并连接。
在这里,我们创建了一个WebRtcEndpoint媒体元件实例来接收WebRTC流并把它原样发回给客户端。
2. 存储用户session
首先要创建用户session,
它的定义 src/main/java/org/kurento/tutorial/helloworld/UserSession.java如下:
/**
* User session.
*
* @author David Fernandez (d.fernandezlop@gmail.com)
* @since 6.0.0
*/
public class UserSession {
private WebRtcEndpoint webRtcEndpoint;
private MediaPipeline mediaPipeline;
public UserSession() {
}
public WebRtcEndpoint getWebRtcEndpoint() {
return webRtcEndpoint;
}
public void setWebRtcEndpoint(WebRtcEndpoint webRtcEndpoint) {
this.webRtcEndpoint = webRtcEndpoint;
}
public MediaPipeline getMediaPipeline() {
return mediaPipeline;
}
public void setMediaPipeline(MediaPipeline mediaPipeline) {
this.mediaPipeline = mediaPipeline;
}
public void addCandidate(IceCandidate i) {
webRtcEndpoint.addIceCandidate(i);
}
public void release() {
this.mediaPipeline.release();
}
}
为了释放向kurento Media Server请求的资源,我们需要把用户session(例如MediaPipeline和WebRtcEndpoint)存储起来,以在用户调用stop时释放掉。
3. WebRTC SDP协商
在WebRTC中,SDP用来在端间实现媒体数据交换的协商。它是基于SDP提交和回答的数据交换机制。
这个协商是在方法processRequest的第三部分完成的,它使用了浏览器客户端的SDP提交,然后返回由WebRtcEndpoint生成的SDP回答。
4. 收集ICE候选者
在Version 6中,Kurento完全支持 Trickle ICE协议。
基于这个原因,WebRtcEndpoint可以接收异步的ICE候选者。
为了处理它,每个WebRtcEndpoint提供了一个监听器(addOnIceGatheringDoneListener)来监听当ICE收集处理已完成的事件。