基于 kurento 實作的浏覽器 p2p 視訊通話。首先上效果圖:
![](https://img.laitimes.com/img/__Qf2AjLwojIjJCLyojI0JCLiIXZ05WZj91YpB3IwczX0xiRGZkRGZ0Xy9GbvNGL2EzXlpXazxSM5cUY2Z0RaFDbYR2bONjYohnMMBjVtJWd0ckW65UbM5WOHJWa5kHT20ESjBjUIF2X0hXZ0xCMx81dvRWYoNHLrdEZwZ1Rh5WNXp1bwNjW1ZUba9VZwlHdssmch1mclRXY39CXldWYtlWPzNXZj9mcw1ycz9WL49zZwpmLxQjM1UzN1kTM2AjNwAjMwIzLc52YucWbp5GZzNmLn9Gbi1yZtl2Lc9CX6MHc0RHaiojIsJye.jpg)
kurento 官網提供了 springboot 一對一的視訊通話的 demo,我需要将它移植到我們的 springboot+vue 的前後端分離的類似于 QQ 聊天項目中。移植時遇到了不少問題,于是放棄了 demo 中使用的 Spring-websocket 方案,使用 spring-boot-starter-websocket 重寫。
1、環境配置
1. 安裝 kurento 媒體伺服器
WebRTC是一組協定和 api ,可通過對等連接配接為浏覽器和移動應用程式提供實時通信(RTC)功能。而 kurento 在它的基礎上提供了更多的功能:媒體傳輸,處理,記錄和播放等。詳細介紹請前往 kurento 官網 檢視。
kurento 媒體伺服器僅支援直接安裝在 Ubuntu 上。為了能夠在 win10 平台安裝測試、在 centos7 伺服器上運作,隻能通過 docker 安裝。安裝指令如下:
docker pull kurento/kurento-media-server:latest
docker run -itd --name kms -p 8888:8888 kurento/kurento-media-server:latest /bin/bash
安裝完成後可用如下指令驗證:
curl \
--include \
--header "Connection: Upgrade" \
--header "Upgrade: websocket" \
--header "Host: 127.0.0.1:8888" \
--header "Origin: 127.0.0.1" \
http://127.0.0.1:8888/kurento
如果出現如下結果,則說明安裝成功:
HTTP/1.1 500 Internal Server Error
Server: WebSocket++/0.7.0
2. 安裝 stun/turn 打洞伺服器
由于 NAT 隐藏了端點的真實IP位址,端點之間建立端到端直接連接配接就非常困難。這就需要 stun 和 turn 伺服器的協助。stun 為端點提供公共 IP 位址建立 p2p 連接配接,如果連接配接成功,則不再需要中繼的協助。如果 NAT 穿透失敗,那麼就需要使用 turn 伺服器提供中繼服務進行通訊。
網上關于 stun/turn 伺服器的部署教程非常多。由于安裝 kurento 時使用了 docker,是以這裡直接使用 docker 安裝。github 上有人将所有操作寫在了 dockerfile 裡,直接使用這個安裝更友善,避免踩坑。https://github.com/konoui/kurento-coturn-docker.git
cd /kurento-coturn-docker/coturn/
# 記住加點
sudo docker build --tag coturn .
# 背景運作 coturn
sudo docker run -p 3478:3478 -p 3478:3478/udp coturn
該 dockerfile 配置 turn 的使用者名和密碼都是 kurento。如需自定義,請在 dockerfile 檔案中的 26-27 行中自定義。
ENV TURN_USERNAME kurento
ENV TURN_PASSWORD kurento
然後前往 https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/ 該網站中輸入
turn:ip:3478
以及使用者名密碼,出現如圖所示的 relay 和 done 說明成功。
3. kurento 設定打洞伺服器位址
直接進入 docker 找到 kurento 的配置檔案:
docker exec -it kms /bin/bash
apt-get update
apt-get install vim
cd /etc/kurento/modules/kurento/
vim WebRtcEndpoint.conf.ini
添加一行:
turnURL=使用者名:密碼@ip:3478?transport=udp
1.5、運作 kurento 官網提供的 demo
這是官網提供的一對一視訊 demo。按照裡面提供的 github 位址克隆到本地然後可以打包運作。
注意,如果要運作 kurento 官網中的 demo,可能會無法正常通話。預設情況下,demo 調用的 stun 伺服器是它自己的。正如官網所說,如果需要使用自己配置好的 stun 伺服器,需要執行如下指令:
mvn -U clean spring-boot:run -Dkms.url=ws://kms_host:kms_port/kurento
但我依然無法成功。是以需要在 demo 中的 index.js 中添加自己的配置。在
function call()
和
function incomingcall()
的
var options = {...}
之前添加:
// 添加自己的中繼伺服器的配置,否則會預設指向谷歌的伺服器
var iceservers = {
"iceServers": [
{
urls:"stun:your_server_ip:3478"
},
{
urls: ["turn:your_server_ip:3478"],
username: "kurento",
credential: "kurento"
}
]
};
然後對 options 進行如下修改:
var options = {
localVideo: videoInput,
remoteVideo: videoOutput,
onicecandidate: onIceCandidate,
onerror: onError,
configuration: iceservers
};
這樣就能運作成功。
2、後端實作
springboot 所需依賴為:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.kurento</groupId>
<artifactId>kurento-client</artifactId>
</dependency>
<dependency>
<groupId>org.kurento</groupId>
<artifactId>kurento-utils-js</artifactId>
</dependency>
前後端使用 websocket 進行通信。VideoHandler 類注冊為一個執行個體,負責處理
/videoCall
的通信。方法
onMessage()
用于接受消息,根據消息類型調用不同的方法進行處理:
@OnMessage
public void onMessage(Session session, String message) throws Exception {
JsonObject jsonMessage = gson.fromJson(message, JsonObject.class);
UserSession user = getUserSessionBySession(session);
switch (jsonMessage.get("type").getAsString()) {
case "login":
try {
login(session, jsonMessage);
} catch (Throwable t) {
ErrorResponse(t, session, "loginResponse");
}
break;
case "call":
try {
call(user, jsonMessage);
} catch (Throwable t) {
ErrorResponse(t, session, "callResponse");
}
break;
case "incomingCallResponse":
incomingCallResponse(user, jsonMessage);
break;
case "onIceCandidate": {
JsonObject candidate = jsonMessage.get("candidate").getAsJsonObject();
if (user != null) {
IceCandidate cand =
new IceCandidate(candidate.get("candidate").getAsString(), candidate.get("sdpMid")
.getAsString(), candidate.get("sdpMLineIndex").getAsInt());
user.addCandidate(cand);
}
break;
}
case "stop":
stop(session);
break;
default:
break;
}
}
當收到前端發送
type = login
的消息時,使用者的線上狀态得以記錄在 HashMap 中;
// 這裡存儲 useruserID-UserSession
private static ConcurrentHashMap<String, UserSession> usersByUserID = new ConcurrentHashMap<>();
// 這裡則是 Session.id-UserSession
private static ConcurrentHashMap<String, UserSession> usersBySessionId = new ConcurrentHashMap<>();
private void login(Session session, JsonObject jsonObject) throws IOException {
String userID = jsonObject.get("userID").getAsString();
UserSession user = new UserSession(session, userID);
usersByUserID.put(userID, user);
usersBySessionId.put(session.getId(), user);
logger.info("userID: " + userID + "is online.");
}
當收到
type = call
的消息時,伺服器判斷被呼叫者是否線上/正忙,如果線上且空閑就發送消息給被呼叫者,等待他的應答;
private void call(UserSession caller, JsonObject jsonMessage) throws IOException {
String callerID = jsonMessage.get("callerID").getAsString();
String calleeID = jsonMessage.get("calleeID").getAsString();
JsonObject response = new JsonObject();
if (!exists(calleeID)) {
response.addProperty("type", "callResponse");
response.addProperty("callResponse", "notOnline");
caller.sendMessage(response);
logger.info("videoCall: " + callerID + " call to " + calleeID + ": NotOnline.");
return;
}
UserSession callee = getUserSessionByUserID(calleeID);
// 判斷對方是不是正忙
if (callee.getState() == 1) {
response.addProperty("type", "callResponse");
response.addProperty("callResponse", "isBusy");
caller.sendMessage(response);
logger.info("videoCall: " + callerID + " call to " + calleeID + ": isBusy.");
return;
}
// 設定呼叫者正忙狀态
caller.setStateCalling();
caller.setSdpOffer(jsonMessage.getAsJsonPrimitive("sdpOffer").getAsString());
caller.setCallingTo(calleeID);
response.addProperty("type", "incomingCall");
response.addProperty("callerID", callerID);
callee.sendMessage(response);
callee.setCallingFrom(callerID);
logger.info("videoCall: " + callerID + " call to " + calleeID + ": send incomingCall to " + calleeID);
}
當收到
type = incomingCallResponse
的消息時,如果被呼叫者接受了消息,伺服器将建立一個 CallMediaPipeline 對象,以封裝媒體管道的建立和管理,然後,該對象用于與使用者的浏覽器協商媒體交換…
代碼過長,請點選 VideoHandler.java 檢視。
詳細的 websocket 通信流程以及格式請檢視 websocket流程。詳細實作請看代碼。
此外,springboot 項目必須配置 https。ssl證書可以通過 openjdk 免費申請。請自行搜尋配置方法。
3、vue 實作
vue 的主要實作請檢視 socketForVideo.js 。并且在 vue 項目的
main.js
中把它設為全局 api 供調用:
import * as wssApi from './assets/js/socketForVideo.js'
Vue.prototype.$wssApi = wssApi
vue 中使用 websocket 與後端進行通信:
let wss = new WebSocket('wss://localhost:8443/videoCall'); // 記得改成伺服器ip
// 發送消息
function sendMessage(message) {
var jsonMessage = JSON.stringify(message);
console.log('Sending message: ' + jsonMessage);
wss.send(jsonMessage);
}
當使用者登入成功時,需要調用 login 函數告知伺服器:
其中 login() 的代碼:
export function login(loginID) {
var loginMessage = {
type: 'login',
userID: loginID
};
sendMessage(loginMessage);
}
當使用者點選通話按鈕向好友發起通話時,調用 call() 函數,啟用 webrtc 通信,調用攝像頭,并向伺服器發送申請:
export var videoInput;
export var videoOutput;
export function call(callerID, calleeID) {
callerIDtmp = callerID;
calleeIDtmp = calleeID;
// 添加自己的中繼伺服器的配置,否則會預設指向谷歌的伺服器
var iceservers = {
"iceServers": [
{
urls:"stun:your_ip:3478"
},
{
urls: ["turn:your_ip:3478"],
username: "kurento",
credential: "kurento"
}
]
};
var options = {
localVideo : videoInput,
remoteVideo : videoOutput,
onicecandidate : onIceCandidate,
onerror: onError,
configuration: iceservers
}
// 調js庫,啟用WebRtc通信
webRtcPeer = new kurentoUtils.WebRtcPeer.WebRtcPeerSendrecv(options,
function(error) {
if (error) {
return console.error(error);
}
webRtcPeer.generateOffer(onOfferCall);
});
}
function onOfferCall(error, offerSdp) {
if (error)
return console.error('Error generating the offer');
console.log('Invoking SDP offer callback function');
var message = {
type : 'call',
callerID : callerIDtmp,
calleeID : calleeIDtmp,
sdpOffer : offerSdp
};
sendMessage(message);
}
vue 元件中用于顯示視訊的代碼:
<div class="col-md-6">
<b-button variant="primary" class="text-center" @click="call">call</b-button>
</div>
<div id="videoBig">
<video id="videoOutput" autoplay width="640px" height="480px"></video>
</div>
<div id="videoSmall">
<video id="videoInput" autoplay width="240px" height="180px" poster="../assets/logo.png"></video>
</div>
然後傳遞到 js 中:
<script>
export default {
name: 'video',
computed : {
input: {
get: function() {
return document.getElementById('videoInput');
}
},
output: {
get: function() {
return document.getElementById('videoOutput');
}
}
},
mounted() {
this.$wssApi.setInputAndOutput(this.input, this.output);
},
methods: {
call() {
this.$wssApi.call(this.callerID, this.calleeID);
},
}
}
</script>
這樣點選 call 就能發起通話了。
vue 中同樣需要針對伺服器發來的不同消息進行不同的處理:
wss.onmessage = function(message) {
var parsedMessage = JSON.parse(message.data);
console.info('Received message: ' + message.data);
switch (parsedMessage.type) {
case 'loginResponse':
// registerResponse(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;
case 'iceCandidate':
webRtcPeer.addIceCandidate(parsedMessage.candidate, function(error) {
if (error)
return console.error('Error adding candidate: ' + error);
});
break;
default:
console.error('Unrecognized message', parsedMessage);
}
}
例如在發起通話申請時會收到伺服器的處理結果,需要處理對方是否線上、拒絕等情況。如果對方接受,則開啟視訊通話:
function callResponse(message) {
if (message.callResponse == 'notOnline') { // 對方不線上
console.info('Your friend is not online. Closing call');
// stop();
} else if(message.callResponse == 'isBusy' ) { // 對方正忙
console.info('Your friend is busy. Closing call');
stop();
} else if(message.callResponse == 'rejected') {
console.info('You are rejected.');
stop();
} else {
webRtcPeer.processAnswer(message.sdpAnswer, function(error) {
if (error)
return console.error(error);
});
}
}
完整代碼請前往我的 github 檢視:videoCall-springboot-vue。
springboot 代碼可跑通,而 vue 代碼需要自行補全。