想看英文可以移步官方文檔
理解例子的源碼
Kurento 提供了 Kurento Java Client 來控制Kurento Media
Server(KMS),這個例子用了Kurento Java Client(KJC)來控制KMS。并且在Kurento Java Client上用了Java 的Spring-Boot框架。
1.應(yīng)用的邏輯
很簡單,瀏覽器將攝像頭和麥克風(fēng)獲得的本地流傳輸給遠程的KMS(或本級部署的)然后再不經(jīng)調(diào)制的傳輸回本地瀏覽器進行顯示。
要完成這個動作,我們需要創(chuàng)造一個只包含一個Media Element的Media Pipeline。本例中那個唯一的 Media Element 就是一個 WebRtcEndpoint,它具有全雙工交換WebRTC媒體流的能力。它自己與自己相連,以保證送出的流通過一圈再傳送回來,也就是我們要實現(xiàn)的鏡像功能(loopback)。
介紹如下

這塊其實很好理解,不用太過注意這個圖。
2.架構(gòu)
由于是瀏覽器應(yīng)用,所以也服從CS架構(gòu)。
在客戶端,使用 JavaScript 來實現(xiàn)客戶端邏輯。在服務(wù)器端,用基于 Spring-Boot 的 Java 實現(xiàn),調(diào)用 Kurento Java Client API 來控制 Kurento Media Server。
總之,這個應(yīng)用是一個三層結(jié)構(gòu)的應(yīng)用,一共有三層實體分別為
客戶端 —— 客戶端服務(wù)器&Kurento Java Client —— KMS
注意這里的客戶端服務(wù)器就是KJC,只是看的角度不一樣。它處理來自客戶端的請求,同時控制KMS,即,既是客戶端的服務(wù)器,又是KMS的客戶端。
由于是三層結(jié)構(gòu),就需要兩個WebSocket了,一個用于前兩個之間的連接,遵循Custom Signaling Protocol,另一個用于后兩者的連接,遵循Kurento Protocol。
想進一步了解可以看關(guān)于這塊的詳細信息
接下來用一個SD來體現(xiàn)這三者之間的關(guān)系。該圖中包含的詳細信息十分重要,每一個信息的交互都會在后邊的代碼中體現(xiàn)。

3.代碼分析
(1)服務(wù)器端
服務(wù)器端就是SD圖中間的綠色實體,也就是三層架構(gòu)中位于中間層的Application Server
再次標(biāo)注一下,服務(wù)器端用了基于Spring-Boot的Java來實現(xiàn)。
-
首先來看一下服務(wù)器端代碼的整體架構(gòu)
圖2
圖中列出的幾個類就是我們的Java的代碼中的所有類了。一會再看代碼的時候我們會一步一步的發(fā)現(xiàn)這個圖的指向具體意思。這個圖也是一個對于理解代碼非常重要的圖。
- 主類 HelloWorldApp
@Bean
public HelloWorldHandler handler() {
return new HelloWorldHandler();
}
@Bean
public KurentoClient kurentoClient() {
return KurentoClient.create();
}
我們可以看到,在HelloWorldApp類中,HelloWorldHandler和KurentoClient都被實例化為了 Bean. Bean是Spring-Boot框架中一個重要的結(jié)構(gòu)名稱,具體可以去了解Spring-Boot框架中的這部分內(nèi)容,這里你可以就把它當(dāng)成,是 HelloWorldApp 是一個大容器,里面裝了兩個小容器分別是 HelloWorldHandler 和 KurentoClient 。這就是為什么圖2中 HelloWorldApp 實體有兩條尖頭分別指向 HelloWorldHandler 和 KurentoClient 實體
這里的KurentoClient在創(chuàng)建時默認了KMS服務(wù)器在本機上。如果你的KMS服務(wù)器部署在遠程的話,就需要向如下代碼一樣創(chuàng)建KurentoClient
@Bean
public KurentoClient kurentoClient() {
return KurentoClient.create("ws://197.162.38.40:8888/kurento");
}
public class HelloWorldApp implements WebSocketConfigurer {
可以看到 HelloWorldApp 類在創(chuàng)建時 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 類在創(chuàng)建時 implements 了TextWebSocketHandler
該類的核心部分是handleTextMessage方法,這個方法通過手動編寫邏輯實現(xiàn)了上文提到的兩個 WebSocket 中遵循 Custom Signaling Protocol 的那個 WebSocket 的處理程序,也就是作為Client Server的那部分功能,體現(xiàn)在圖1中 JavaScriptClient 和 ApplicationServer 之間的信息交互。而通過直接調(diào)用 Kurento Java Client API 來完成第二個 websocket 的通信,是自動的,不用手動碼邏輯。
注意,這里的代碼每一處都可以在圖1中的信息交互中找到對應(yīng)的表示。
根據(jù)JsonMessage的不同分了三個不同的case,其中最重要的是start,里面調(diào)用了start函數(shù),見下文。
start方法主要完成了如下四個工作,注釋也都寫出來了。
配置媒體處理邏輯
MediaPipeline pipeline = kurento.createMediaPipeline();
WebRtcEndpoint webRtcEndpoint = new WebRtcEndpoint.Builder(pipeline).build();
webRtcEndpoint.connect(webRtcEndpoint);
1.創(chuàng)建MediaPipeline
2.用pipeline創(chuàng)建WebRtcEndpoint
3.創(chuàng)建好的webRtcEndpoint自己和自己相連
- 這幾步在前文都清楚得提到過
存儲用戶會話
UserSession user = new UserSession();
user.setMediaPipeline(pipeline);
user.setWebRtcEndpoint(webRtcEndpoint);
users.put(session.getId(), user);
由于在最后會調(diào)用stop來釋放,所以需要存儲用戶會話來保證這個功能,也就是要存儲MediaPipeline和WebRtcEndpoint。SDP通訊
String sdpOffer = >>jsonMessage.get("sdpOffer").getAsString();
從jsonMessage中獲得sdpOffer部分并轉(zhuǎn)換成String。
String sdpAnswer = webRtcEndpoint.processOffer(sdpOffer);
把sdpOffer作為參數(shù)傳給webRtcEndpoint形成sdpAnswer,調(diào)用了webRtcEndpoint的processOffer方法,這里對應(yīng)圖1中的左邊前兩條數(shù)據(jù)交換箭頭,其中processOffer方法完成了兩條線中間右邊Application Server和KMS之間的一系列數(shù)據(jù)交換。
JsonObject response = new JsonObject();
response.addProperty("id", "startResponse");
response.addProperty("sdpAnswer", sdpAnswer);
synchronized (session) { session.sendMessage(new TextMessage(response.toString())); }
這里是把sdpAnswer加入一個json里再加上一些必要信息然后再轉(zhuǎn)換成String并在會話里送出。
- WebRtc 在 peer 之間交換媒體數(shù)據(jù)遵循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 協(xié)議,使得WebRtcEndpoint 可以異步得收集 ICE candidates。也正是因此,每一個WebRtcEndpoint 都需要一個監(jiān)聽器,可以在每次ICE 收集程序結(jié)束后接收到一個事件的觸發(fā)
webRtcEndpoint.addIceCandidateFoundListener(new EventListener<IceCandidateFoundEvent>() {
@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添加了一個監(jiān)聽器。
2.webRtcEndpoint.gatherCandidates(),開始收集ICE Candidates。
然后我們來分析第一個代碼。并將它與圖1的信息交互進行對照.
很簡單,在構(gòu)造監(jiān)聽器的時候重寫它的onEvent方法,接收參數(shù)為IceCandidateFoundEvent類型,這是 Kurento Java Client API 中的函數(shù)webRtcEndpoint.gatherCandidates();可以得到的結(jié)果類型,當(dāng)監(jiān)聽器發(fā)現(xiàn)這個類型的參數(shù)出現(xiàn),或者說一旦gatherCandidates()方法開始得到結(jié)果時,就會觸發(fā)監(jiān)聽器。監(jiān)聽器內(nèi)部所做的事情就是把candidate轉(zhuǎn)成Json然后加上附加信息再轉(zhuǎn)成String然后發(fā)出去。
現(xiàn)在可以對著代碼去圖1里找一下對應(yīng)的線了。同樣,左邊bar的信息交互是這里手動碼出來的動作,右邊bar的信息交互是由 Kurento Java Client API 自動實現(xiàn)的,不需要費心再碼。
(2)客戶端
var ws = new WebSocket('wss://' + location.host + '/helloworld');
首先看到,它用了 JavaScript 類來創(chuàng)建webSocket,并且把這個webSocket的處理路徑放在了/helloworld,這就是前文提到的,客戶端是怎樣和 server 端實現(xiàn) webSocket 的通信。
well,再總結(jié)一遍,其實就是在客戶端用 JavaScript 建一個webSocket,并把處理路徑設(shè)置為/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);
}
設(shè)置onload監(jiān)聽器,new 一個consloe,輸出“Page loaded”,然后獲取videoInput和videoOutput對象(這兩個的id是在html里面定義的),然后setState到可以啟動
window.onbeforeunload = function() {
ws.close();
}
設(shè)置onbeforeunload監(jiān)聽器,語意為關(guān)閉窗口前關(guān)掉 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);
}
}
設(shè)置onmessage監(jiān)聽器,有一個參數(shù)message,進來以后把它Jason化,然后switch。一共會有三種類型的message ,分別是startResponse, error,和 iceCandidate。
然后就是每種message之后的操作。
(1)正常的操作是在I_CAN_START狀態(tài)中的start按鈕的onClick方法中做的,調(diào)用start()函數(shù)來執(zhí)行操作進行信息交互,而start里的操作就是圖1中JavaScriptClient端的各種信息交互操作了。
注意??,正常情況下這些函數(shù)基本都是在setState里面的設(shè)置按鈕的onClick方法里調(diào)用的,稍微有點隱蔽。而setState本身也是一個神奇的函數(shù),它是用來設(shè)置整個頁面的狀態(tài)的,狀態(tài)不同每個按鈕的狀態(tài)也就不同,點了以后的操作也不一樣。這是一個很神奇的操作。
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()方法做了什么,設(shè)置狀態(tài)輸出文字什么的就不說了,最主要是它用了kurento-utils.js里的WebRtcPeer.WebRtcPeerSendrecv()方法來創(chuàng)建一個 WebRtc 通信。
這里可能會有一些疑問,這里不是只是JavaScriptClient和ApplicationServer之間的通信嗎,換句話說這里不是應(yīng)該只是JavaScriptClient發(fā)出的webSocket請求嗎怎么又用了 Kurento 的一個 util.js 的庫。
其實是這樣的,這里就是JavaScriptClient和ApplicationServer之間的通信,雖然調(diào)用的是 kurento 的庫,但實現(xiàn)的是 WebRtc 的通信啊,WebRtc 本來就是基于瀏覽器的,如果你在JavaScriptClient和ApplicationServer之間不啟動 WebRtc ,ApplicationServer和KMS之間的 Kurento 也就毫無意義了。所以這里雖然用了 Kurento 的一個庫但還牽扯不到 Kurento,還只是瀏覽器原生的 WebRtc 通信的建立。
(2)在點了start按鈕并且start()了以后,就進入了處理 WebRtc 通信的階段,其中startResponsecase中做的是開始JS客戶端的SDP信息交互操作,iceCandidate同樣,是調(diào)用那個kurento-utils.js里的 WebRtc 原生方法來實現(xiàn)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的一些設(shè)置,但我沒有看,應(yīng)該也比較好理解。所以,就到這里吧,還有什么之后看到了再隨時補充。
