這個頁面應用程序使用WebRTC技術實現了一個一對一的呼叫,換言話說,這個應用提供了一個簡單的視頻電話html
運行這個DEMO以前,你須要先安裝Kurento Media Server.能夠看前面的介紹。
另外,你還須要先安裝好 JDK (at least version 7), Maven, Git, 和 Bower。
在Ubuntu上安裝這些的命令以下:
sudo apt-get install curl
curl -sL https://deb.nodesource.com/setup | sudo bash -
sudo apt-get install -y nodejs
sudo npm install -g bower
啓動應用程序以前,須要先下載源,並編譯運行,命令以下:
git clone https://github.com/Kurento/kurento-tutorial-java.git
cd kurento-tutorial-java/kurento-one2one-call
mvn clean compile exec:java
默認地,這個應用程序部署在8080端口上,可使用兼容WebRTC的瀏覽器打開URL http://localhost:8080
java
下面的圖片顯示了在瀏覽上運行這個DEMO時截圖。
這個應用程序(一個HTML頁面)的接口是由兩個HTML5視頻標籤組成的:
一個用來顯示本地流;
另外一個用來顯示遠端的流;
若是有兩用戶,A和B都使用這個應用程序,則媒體流的工做方式以下:
A的攝像頭的流發送到Kurento Media Server,Kurento Media Server會將這個流發送給B;
一樣地,B也會將流發送到Kurento Media Server,它再發給A。
這意味着,KMS提供了一個B2B (back-to-back) 的呼叫服務。
Figure 9.1: One to one video call screenshot
爲了實現上述的工做方式,須要建立一個由兩個WebRtc端點以B2B方式鏈接的媒體管道,媒體管道的示例圖以下:
Figure 9.2: One to one video call Media Pipeline
客戶端和服務端的通訊是經過基於WebSocket上的JSON消息的信令協議實現的,客戶端和服務端的工做時序以下:
1. 用戶A在服務器上註冊他的名字
2. 用戶B在服務器註冊他的名字
3. 用戶A呼叫用戶B
4. 用戶B接受呼叫
5. 通訊已創建,媒體在用戶A與用戶B之間流動
6. 其中一個用戶結束此次通訊
時序流程的細節以下圖所示:
Figure 9.3: One to many one call signaling protocol
如圖中所示,爲了在瀏覽器和Kurento之間創建WebRTC鏈接,須要在客戶端和服務端之間進行SDP交互。
特別是,SDP協商鏈接了瀏覽器的WebRtcPeer和服務端的WebRtcEndpoint。
下面的章節描述了服務端和客戶端的細節,以及DEMO是如何運行的。源碼能夠從GitHub上下載;
node
這個DEMO的服務端是使用Java的Spring Boot框架開發的。這個技術能夠嵌入到Tomcat頁面服務器中,從而簡化開發流程。
Note: You can use whatever Java server side technology you prefer to build
web applications with Kurento. For example, a pure Java EE application, SIP Servlets,
Play, Vertex, etc. We have choose Spring Boot for convenience.
下面的圖顯示了服務端的類圖。
這個DEMO的主類爲One2OneCallApp, 如代碼中所見,KurentoClient做爲Spring Bean在類中進行了實例化。
Figure 9.4: Server-side class diagram of the one to one video call app
@Configuration
@EnableWebSocket
@EnableAutoConfiguration
public class One2OneCallApp implements WebSocketConfigurer {
@Bean
public CallHandler callHandler() {
return new CallHandler();
}
@Bean
public UserRegistry registry() {
return new UserRegistry();
}
@Bean
public KurentoClient kurentoClient() {
return KurentoClient.create("ws://localhost:8888/kurento");
}
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(callHandler(), "/call");
}
public static void main(String[] args) throws Exception {
new SpringApplication(One2OneCallApp.class).run(args);
}
}
這個頁面應用程序使用了單頁面應用程序架構(SPA:Single Page Application architecture ),
並使用了WebSocket來做爲客戶端與服務端通訊的請求與響應。
特別地,主app類實現了WebSocketConfigurer接口來註冊一個WebSocketHandler來處理WebSocket請求。
CallHandler類實現了TextWebSocketHandler,用來處理文本WebSocket的請求。
這個類的主要實現的方法就是handleTextMessage, 這個方法實現了對請求的動做:
經過WebSocket返回對請求的響應。換句話說,它實現前面的時序圖中的信令協議的服務端部分。
在設計的協議中,有三種類型的輸入消息: 註冊,呼叫, incomingCallResponse和stop。
這些消息對應的處理都在switch中。
public class CallHandler extends TextWebSocketHandler {
private static final Logger log = LoggerFactory.getLogger(CallHandler.class);
private static final Gson gson = new GsonBuilder().create();
private ConcurrentHashMap<String, CallMediaPipeline> pipelines =
new ConcurrentHashMap<String, CallMediaPipeline>();
@Autowired
private KurentoClient kurento;
@Autowired
private UserRegistry registry;
@Override
public void handleTextMessage(WebSocketSession session, TextMessage message)
throws Exception {
JsonObject jsonMessage = gson.fromJson(message.getPayload(),
JsonObject.class);
UserSession user = registry.getBySession(session);
if (user != null) {
log.debug("Incoming message from user '{}': {}", user.getName(),jsonMessage);
} else {
log.debug("Incoming message from new user: {}", jsonMessage);
}
switch (jsonMessage.get("id").getAsString()) {
case "register":
try {
register(session, jsonMessage);
} catch (Throwable t) {
log.error(t.getMessage(), t);
JsonObject response = new JsonObject();
response.addProperty("id", "resgisterResponse");
response.addProperty("response", "rejected");
response.addProperty("message", t.getMessage());
session.sendMessage(new TextMessage(response.toString()));
}
break;
case "call":
try {
call(user, jsonMessage);
} catch (Throwable t) {
log.error(t.getMessage(), t);
JsonObject response = new JsonObject();
response.addProperty("id", "callResponse");
response.addProperty("response", "rejected");
response.addProperty("message", t.getMessage());
session.sendMessage(new TextMessage(response.toString()));
}
break;
case "incomingCallResponse":
incomingCallResponse(user, jsonMessage);
break;
case "stop":
stop(session);
break;
default:
break;
}
}
private void register(WebSocketSession session, JsonObject jsonMessage)
throws IOException {
...
}
private void call(UserSession caller, JsonObject jsonMessage)
throws IOException {
...
}
private void incomingCallResponse(UserSession callee, JsonObject jsonMessage)
throws IOException {
...
}
public void stop(WebSocketSession session) throws IOException {
...
}
@Override
public void afterConnectionClosed(WebSocketSession session,
CloseStatus status) throws Exception {
registry.removeBySession(session);
}
}
在下面的代碼片段中,咱們能夠看到註冊方法,基本上,它包含了從註冊信息中獲得的名字屬性,並檢測它是否被註冊過。
若是沒有,則新用戶被註冊且有一個接受的消息發送給它;
private void register(WebSocketSession session, JsonObject jsonMessage)
throws IOException {
String name = jsonMessage.getAsJsonPrimitive("name").getAsString();
UserSession caller = new UserSession(session, name);
String responseMsg = "accepted";
if (name.isEmpty()) {
responseMsg = "rejected: empty user name";
} else if (registry.exists(name)) {
responseMsg = "rejected: user '" + name + "' already registered";
} else {
registry.register(caller);
}
JsonObject response = new JsonObject();
response.addProperty("id", "resgisterResponse");
response.addProperty("response", responseMsg);
caller.sendMessage(response);
}
在call方法中,服務端會檢查在消息屬性欄中的名字是否已註冊,而後發送一個incomingCall消息給它。
或者,若是這個名字未註冊,則會有一個callResponse消息發送給呼叫者以拒絕此次呼叫。
private void call(UserSession caller, JsonObject jsonMessage)
throws IOException {
String to = jsonMessage.get("to").getAsString();
String from = jsonMessage.get("from").getAsString();
JsonObject response = new JsonObject();
if (registry.exists(to)) {
UserSession callee = registry.getByName(to);
caller.setSdpOffer(jsonMessage.getAsJsonPrimitive("sdpOffer").getAsString());
caller.setCallingTo(to);
response.addProperty("id", "incomingCall");
response.addProperty("from", from);
callee.sendMessage(response);
callee.setCallingFrom(from);
} else {
response.addProperty("id", "callResponse");
response.addProperty("response", "rejected: user '" + to+ "' is not registered");
caller.sendMessage(response);
}
}
stop方法結束此次呼叫。這個過程會被呼叫者和被叫者在通訊中被調用。
結果是這兩端會釋放媒體管道並結束通訊:
public void stop(WebSocketSession session) throws IOException {
String sessionId = session.getId();
if (pipelines.containsKey(sessionId)) {
pipelines.get(sessionId).release();
CallMediaPipeline pipeline = pipelines.remove(sessionId);
pipeline.release();
// Both users can stop the communication. A 'stopCommunication'
// message will be sent to the other peer.
UserSession stopperUser = registry.getBySession(session);
UserSession stoppedUser = (stopperUser.getCallingFrom() != null) ? registry
.getByName(stopperUser.getCallingFrom()) : registry
.getByName(stopperUser.getCallingTo());
JsonObject message = new JsonObject();
message.addProperty("id", "stopCommunication");
stoppedUser.sendMessage(message);
}
}
在 incomingCallResponse方法中,若是被叫用戶接受了這個呼叫,那麼就會以B2B方式建立媒體元素並鏈接呼叫者與被叫者。
一般,服務端會建立一個 CallMediaPipeline對象,用來封裝媒體管道的建立和管理。
而後,這個對象就用來在用戶瀏覽器間進行媒體交互協商。
瀏覽器上WebRTC端點與Kurento Media Server的WebRtcEndpoint間的協商
是經過客戶端生成的SDP(提交)與服務端生成的SDP(回答)實現的。
這個SDP的回答是由類CallMediaPipeline中Kurento Java Client生成的。
用於生成SDP的方法爲generateSdpAnswerForCallee(calleeSdpOffer) 和 generateSdpAnswerForCaller(callerSdpOffer):
private void incomingCallResponse(UserSession callee, JsonObject jsonMessage)
throws IOException {
String callResponse = jsonMessage.get("callResponse").getAsString();
String from = jsonMessage.get("from").getAsString();
UserSession calleer = registry.getByName(from);
String to = calleer.getCallingTo();
if ("accept".equals(callResponse)) {
log.debug("Accepted call from '{}' to '{}'", from, to);
CallMediaPipeline pipeline = null;
try {
pipeline = new CallMediaPipeline(kurento);
pipelines.put(calleer.getSessionId(), pipeline);
pipelines.put(callee.getSessionId(), pipeline);
String calleeSdpOffer = jsonMessage.get("sdpOffer").getAsString();
String calleeSdpAnswer = pipeline.generateSdpAnswerForCallee(calleeSdpOffer);
String callerSdpOffer = registry.getByName(from).getSdpOffer();
String callerSdpAnswer = pipeline.generateSdpAnswerForCaller(callerSdpOffer);
JsonObject startCommunication = new JsonObject();
startCommunication.addProperty("id", "startCommunication");
startCommunication.addProperty("sdpAnswer", calleeSdpAnswer);
callee.sendMessage(startCommunication);
JsonObject response = new JsonObject();
response.addProperty("id", "callResponse");
response.addProperty("response", "accepted");
response.addProperty("sdpAnswer", callerSdpAnswer);
calleer.sendMessage(response);
} catch (Throwable t) {
log.error(t.getMessage(), t);
if (pipeline != null) {
pipeline.release();
}
pipelines.remove(calleer.getSessionId());
pipelines.remove(callee.getSessionId());
JsonObject response = new JsonObject();
response.addProperty("id", "callResponse");
response.addProperty("response", "rejected");
calleer.sendMessage(response);
response = new JsonObject();
response.addProperty("id", "stopCommunication");
callee.sendMessage(response);
}
} else {
JsonObject response = new JsonObject();
response.addProperty("id", "callResponse");
response.addProperty("response", "rejected");
calleer.sendMessage(response);
}
}
這個DEMO的媒體邏輯是在類CallMediaPipeline中實現的,如上圖所見,媒體管道的組成很簡單:
由兩個WebRtcEndpoint直接相連組成。須要注意的WebRtcEndpoints須要作兩次鏈接,每次鏈接一個方向的。
public class CallMediaPipeline {
private MediaPipeline pipeline;
private WebRtcEndpoint callerWebRtcEP;
private WebRtcEndpoint calleeWebRtcEP;
public CallMediaPipeline(KurentoClient kurento) {
try {
this.pipeline = kurento.createMediaPipeline();
this.callerWebRtcEP = new WebRtcEndpoint.Builder(pipeline).build();
this.calleeWebRtcEP = new WebRtcEndpoint.Builder(pipeline).build();
this.callerWebRtcEP.connect(this.calleeWebRtcEP);
this.calleeWebRtcEP.connect(this.callerWebRtcEP);
} catch (Throwable t) {
if(this.pipeline != null){
pipeline.release();
}
}
}
public String generateSdpAnswerForCaller(String sdpOffer) {
return callerWebRtcEP.processOffer(sdpOffer);
}
public String generateSdpAnswerForCallee(String sdpOffer) {
return calleeWebRtcEP.processOffer(sdpOffer);
}
public void release() {
if (pipeline != null) {
pipeline.release();
}
}
}
在這個類中,咱們能夠看到方法generateSdpAnswerForCaller 和 generateSdpAnswerForCallee的實現,
這些方法引導WebRtc端點建立合適的回答。
jquery
如今來看應用程序客戶端的代碼。爲了調用前面提到的服務端的WebSocket服務,咱們使用了JavaScript類WebSocket。
咱們使用了特殊的Kurento JavaScript庫,叫作kurento-utils.js來簡化WebRTC的交互,
這個庫依賴於adapter.js,它是一個JavaScript WebRTC設備,由Google維護,用來抽象瀏覽器之間的差別。
最後,這個應用程序還須要jquery.js.
這些庫都連接到了index.html頁面中,並都在index.js中被使用。
在下面的代碼片段中,咱們能夠看到在path /call下WebSocket(變量ws)的建立,
而後,WebSocket的監聽者onmessage被用來實如今客戶端的JSON信令協議。
.
注意,在客戶端有四個輸入信息:resgisterResponse, callResponse,incomingCall, 和startCommunication,
用來實現通訊中的各個步驟。
例如,在函數 call and incomingCall (for caller and callee respectively)中,
kurento-utils.js的函數WebRtcPeer.startSendRecv用來啓動WebRTC通訊。
var ws = new WebSocket('ws://' + location.host + '/call');
ws.onmessage = function(message) {
var parsedMessage = JSON.parse(message.data);
console.info('Received message: ' + message.data);
switch (parsedMessage.id) {
case 'resgisterResponse':
resgisterResponse(parsedMessage);
break;
case 'callResponse':
callResponse(parsedMessage);
break;
case 'incomingCall':
incomingCall(parsedMessage);
break;
case 'startCommunication':
startCommunication(parsedMessage);
break;
case 'stopCommunication':
console.info("Communication ended by remote peer");
stop(true);
break;
default:
console.error('Unrecognized message', parsedMessage);
}
}
function incomingCall(message) {
//If bussy just reject without disturbing user
if(callState != NO_CALL){
var response = {
id : 'incomingCallResponse',
from : message.from,
callResponse : 'reject',
message : 'bussy'
};
return sendMessage(response);
}
setCallState(PROCESSING_CALL);
if (confirm('User ' + message.from + ' is calling you. Do you accept the call?')) {
showSpinner(videoInput, videoOutput);
webRtcPeer = kurentoUtils.WebRtcPeer.startSendRecv(videoInput, videoOutput,
function(sdp, wp) {
var response = {
id : 'incomingCallResponse',
from : message.from,
callResponse : 'accept',
sdpOffer : sdp
};
sendMessage(response);
}, function(error){
setCallState(NO_CALL);
});
} else {
var response = {
id : 'incomingCallResponse',
from : message.from,
callResponse : 'reject',
message : 'user declined'
};
sendMessage(response);
stop();
}
}
function call() {
if(document.getElementById('peer').value == ''){
window.alert("You must specify the peer name");
return;
}
setCallState(PROCESSING_CALL);
showSpinner(videoInput, videoOutput);
kurentoUtils.WebRtcPeer.startSendRecv(videoInput, videoOutput, function(offerSdp, wp) {
webRtcPeer = wp;
console.log('Invoking SDP offer callback function');
var message = {
id : 'call',
from : document.getElementById('name').value,
to : document.getElementById('peer').value,
sdpOffer : offerSdp
};
sendMessage(message);
}, function(error){
console.log(error);
setCallState(NO_CALL);
});
}
git
This Java Spring application is implementad using Maven.
The relevant part of the pom.xml is where Kurento dependencies are declared.
As the following snippet shows, we need two dependencies: the Kurento Client Java dependency
(kurento-client) and the JavaScript Kurento utility library (kurento-utils) for the client-side:
<dependencies>
<dependency>
<groupId>org.kurento</groupId>
<artifactId>kurento-client</artifactId>
<version>[5.0.0,6.0.0)</version>
</dependency>
<dependency>
<groupId>org.kurento</groupId>
<artifactId>kurento-utils-js</artifactId>
<version>[5.0.0,6.0.0)</version>
</dependency>
</dependencies>
Kurento framework uses Semantic Versioning for releases.
Notice that range [5.0.0,6.0.0) downloads the latest version of Kurento artefacts
from Maven Central in version 5 (i.e. 5.x.x). Major versions are released when incompatible changes are made.
Note: We are in active development. You can find the latest version of Kurento Java Client at Maven Central.
Kurento Java Client has a minimum requirement of Java 7.
To configure the application to use Java 7, we have to include the following properties in the properties section:
<maven.compiler.target>1.7</maven.compiler.target>
<maven.compiler.source>1.7</maven.compiler.source>
github