天天看點

Springboot+vue基于kurento實作浏覽器p2p視訊通話

  基于 kurento 實作的浏覽器 p2p 視訊通話。首先上效果圖:

Springboot+vue基于kurento實作浏覽器p2p視訊通話

  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 說明成功。

Springboot+vue基于kurento實作浏覽器p2p視訊通話

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 代碼需要自行補全。