系統說明:
本系統是基於谷歌的webRTC瀏覽器端影片點對點技術實現網頁影片及語音即時通訊。採用開源專案licode作為聊天室管理,以及其web端js模組。
目前系統採用前後端分離以及多伺服器方式部署,採用nginx作為反向代理整合licode、apiserver、front。
環境要求:
作業系統:ubuntu14、nginx、nodejs、RibbitMQ
Licode安裝在ubuntu中安裝:
先從GitHub上下載原始碼
git clone https://github.com/ging/licode.gitcd licode解決HTTPS/SSL訪問錯誤:
由於是配置檔名不統一導致,而且在Sokect.js中的token.secure引數無法配置,故直接修改。
/licode/erizo_controller/erizoClient/src/Socket.js
將licode_default.js檔案修改SSL相關配置,注意ssl證書也需要修改
ssl相關設定為true;
然後複製licode_default.js為licode_config.js
獲得原始碼後進入licode/scripts/目錄,複製licode_default.js為licode_config.js,同時將licode_default.js和licode_config.js都修改為如下配置:
// 配置turnserver伺服器
config.erizoController.iceServers = [{'url': 'stun:stun.l.google.com:19302'},{'url': 'stun:伺服器IP:3478'}];//注意,配置的伺服器必須是可訪問的,否則啟動失敗
// 開啟 SSL
config.erizoController.ssl = true;
config.erizoController.listen_ssl = true; //default value: false
config.erizoController.listen_port = 8080; //default value: 8080
// 配置SSL 檔案
config.erizoController.ssl_key = '/full/path/to/ssl.key';
config.erizoController.ssl_cert = '/full/path/to/ssl.crt';
依次序執行安裝:
1、安裝系統依賴
./scripts/installUbuntuDeps.sh2、安裝Erizo和Nuve
./scripts/installErizo.sh./scripts/installNuve.sh3、安裝基本例項程式
./scripts/installBasicExample.sh4、執行licode
./licode/scripts/initLicode.sh5、執行基礎例項web服務
./scripts/initBasicExample.shLicode API官方地址:
http://licode.readthedocs.io/en/master/client_api/
包含 客戶端API 、服務端API
具體詳見官網。
turnserver安裝在使用WebRTC進行即時通訊時,需要使瀏覽器進行P2P通訊,但是由於NAT環境的複雜性,並不是所有情況下都能進行P2P,這時需要TURN Server來幫助客戶端之間轉發資料。rfc5766-turn-server是一個高效能的開源TURN Server實現。
以下是在EC2上使用Ubuntu作業系統安裝rfc5766-turn-server:
1. 下載安裝包:
$ wget http://ftp.cn.debian.org/debian/pool/main/r/rfc5766-turn-server/rfc5766-turn-server_3.2.4.4-1_amd64.deb
2. 安裝:
$ sudo apt-get update
$ sudo apt-get install gdebi-core
$ sudo gdebi rfc5766-turn-server_3.2.4.4-1_amd64.deb
安裝完後,在/usr/share/doc/rfc5766-turn-server下有很多文件可參考。
3. 配置:
$ sudo vi /etc/turnserver.conf
---------------------------------------
// 配置IP,EC2下需要配置listening-ip和external-ip
listening-ip=172.31.4.37 注意:如果是未知IP,可設定0.0.0.0
external-ip=54.223.149.60
// 當TURN Server用於WebRTC時,必須使用long-term credential mechanism
lt-cred-mech
// 增加一個使用者
user=username1:password1
// 設定realm
realm=mycompany.org
---------------------------------------
4. 啟動:
sudo turnserver -c /etc/turnserver.conf --daemon
5. 服務啟動後,在上一個WebRTC示例中更改iceServers後測試:
"iceServers": [{
"url": "stun:stun.l.google.com:19302"
}, {
"url": "turn:54.223.149.60",
"username": "username1",
"credential": "password1"
}]
更多安裝資訊在:http://turnserver.open-sys.org/downloads/v3.2.4.4/INSTALL
rfc5766-turn-server當然也有STUN Server的能力,但是需要給它配置2個IP,以幫助探測客戶端所在NAT環境的行為,這裡沒有做。
前端整合時序圖WebRTC相關時序:
瀏覽器透過candidate 來進行連線。連線之後,就無需與伺服器進行連線。
在獲得candidate時需要連線turnserver。具體是由瀏覽器webRTC模組執行。
Licode整合時序圖:
Nginx反向代理配置執行./scripts/initLicode.sh 和./scripts/initBasicExample.sh
Licode伺服器提供
http埠 3000 3001 websocket 埠8080
https 埠 3004 websocket 埠 8080
nginx配置如下:
前端程式nginx配置:
server {
listen 443 ssl;
ssl_certificate /usr/local/etc/nginx/server.crt;
ssl_certificate_key /usr/local/etc/nginx/server.key;
# ssl_session_cache shared:SSL:1m;
ssl_session_timeout 5m;
# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_prefer_server_ciphers on;
server_name '192.168.10.225';
if ($scheme = http) {
return 301 https://server_nameservernamerequest_uri;
}
location / {
root html; #前端靜態執行目錄
index index.html index.htm;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://127.0.0.1:8085/;
}
location /api{ #介面服務
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://127.0.0.1:8080/api;
}
#檔案上傳後存放目錄
location /upload{
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://127.0.0.1:8080/upload;
}
location /createToken{
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://192.168.10.228:3001/createToken;
}
}
程式碼示例Websock伺服器:
io.on('connect', function (socket) { socket.on('message', function (data) { var data = JSON.parse(data); console.log("請求資料", data); //當前登入使用者名稱 var userId = data.from.id; //當前使用者的聊天室id var roomId = data.roomId; socket.userId = userId; socket.roomId = roomId; function join(f) { //當用戶與聊天室id存在時 if (allUsers[roomId] == undefined) { allUsers[roomId] = {}; } if (allSockets[roomId] == undefined) { allSockets[roomId] = {}; } //儲存使用者資訊 allUsers[roomId][userId] = data.from; if (allSockets[roomId][userId] != undefined && allSockets[roomId][userId].scoket != undefined && f == true) {//給之前的連線傳送退出資訊 sendTo(allSockets[roomId][userId].scoket,{ event: "leave" }); } allSockets[roomId][userId] = {'scoket': socket}; } try { switch (data.event) { case "heartbeat": join(false); break; //當有新使用者加入時 case "join": join(true); showUserInfo(allUsers[roomId]); sendTo(socket, { event: "join", "message": "成功加入聊天室", "success": true }); break; //======== 即時通訊 IM ========= case "leave": delete allUsers[roomId][userId]; showUserInfo(allUsers[roomId]); break; case "msg"://IM訊息 join(); if (data.message == undefined || data.message == '' || data.message == null) { break; } //獲取room使用者 var roomId = data.roomId; var ulist = allUsers[roomId]; data.datetime = new Date(); if (ulist != undefined) { //查詢使用者連線傳送資訊 for (var u in ulist) { if (ulist[u]) sendTo(allSockets[roomId][u].scoket, data); } } break; case "historyMsg"://聊天曆史 join(); //獲取room使用者 var roomId = data.roomId; var ulist = allUsers[roomId]; break //======== 基於allWebsockets start ========= case "sigin"://登入系統 for (var ws in allWebsockets) { if (allWebsockets[ws]) sendTo(allWebsockets[ws], { event: "sigin", "message": "登入", "success": true, from: data.from }); } socket.mainId=data.from.id; socket.userId=data.from.id; allWebsockets[data.from.id] = socket; break; case "joinChatRoom"://要求加入影片會議 var userList = data.userList; if (userList) { for (var i = 0; i < userList.length; i++) { if (allWebsockets[userList[i]]) sendTo(allWebsockets[userList[i]], { event: "joinChatRoom", "message": data.message || "加入會議", from: data.from, roomId: data.roomId }); } } allWebsockets[data.from.id] = socket; break; case "rejectJoinChatRoom"://拒絕加入影片會議 sendTo(allWebsockets[data.to], { event: "rejectJoinChatRoom", "message": data.message || "加入會議", from: data.from, roomId: data.roomId }); break; //================================= } } catch (ex) { console.log("異常:\t", ex) } }); socket.on("disconnect", function () { if (true) { return; } var userId_ = socket.userId; var roomId_ = socket.roomId; if (userId_ != undefined && userId_ != null && userId_ != '') { if (roomId_||allUsers[roomId_] == undefined || allSockets[roomId_] == undefined) { return; } var ulist = allUsers[roomId_]; if (ulist != undefined) { //查詢使用者連線傳送資訊 for (var u in ulist) { if (allSockets[roomId_][u]) sendTo(allSockets[roomId_][u].scoket, {event: 'leave', userId: userId_}); } } //刪除已經退出的使用者 delete allUsers[roomId_][userId_]; if(socket.mainId) delete allWebsockets[socket.mainId]; if (allUsers[roomId_] != undefined) { delete allSockets[roomId_][userId_]; showUserInfo(allUsers[roomId_]); } } });}); function showUserInfo(allUsers) { console.log("線上使用者:", allUsers); sendTo(io, { event: "showUserList", "userList": allUsers, });} function removeUserInfo(roomId, userId) { delete allUsers[roomId][userId]; delete allSockets[roomId][userId];} function sendTo(connection, message) { console.log("send data:\t", message); if (connection != undefined) connection.send(message);}
啟動websoket伺服器 :
node server.js
前端整合:
需要在index.html引用socket.io.js
程式碼如下:
export default {
data() { return {
currentUser: this.$store.getters.getCurrentLoginInfo.user,
main_websocket:io.connect(Constants.wsUrl) };
}, methods: {
send(message) { message.from = {id: this.currentUser.id, name: this.currentUser.name, avator: this.currentUser.avator}; this.main_websocket.send(JSON.stringify(message));}},
created() {
//監聽websoket訊息 this.main_websocket.on('message', (data) => { console.error("on message:", data); switch (data.event) { case "joinChatRoom": var noticeName='roomId'+data.roomId; this.$Notice.open({title:'影片會議邀請',name:noticeName,render:(r)=>{ return r('div',[r('div',data.from.name+'邀請您加入影片會議,是否接受?'), r('Button',{ on: { click:()=>{ this.$router.push("/chat/" + data.roomId); this.$Notice.close(noticeName); } },props:{type:'primary',size:'small'}},'接受'), r('Button',{ on: {click:()=>{ this.send({event:"rejectJoinChatRoom",to:data.from.id,roomId:data.roomId}); this.$Notice.close(noticeName); } },props:{type:'error',size:'small'}},'拒絕') ]); },duration:10,onClose:()=>{ }}); break; case "sigin": break; case "rejectJoinChatRoom": this.$Notice.warning({title:'拒絕影片會議',desc:data.from.name+'拒絕加入影片會議。',duration:10}); break; case "message": this.$Notice.info({title:data.title|'訊息',desc:data.message,duration:10}); break; } }); this.send({event:"sigin"}); }};
影片會議邀約:
對應vuejs檔案:/front/src/views/chat/index.vue
步驟程式碼如下:
import http from '../../api/http' import Room from './licodeRoom.vue' export default { data() { return { roomShow: false, hospno: null, patient:null, roomId: null, doctorList: [], tableHeight: window.innerHeight - 80 } }, methods: { chosePatient() { http.post(this,'/pt/patient/choseOne',{hospno:this.hospno},(resp)=>{ var ret=resp.body; if(ret.code=='111'){ this.patient=ret.value; }else{ this.$Message.error(ret.msg); } }); }, joinRoom() { this.roomId = this.hospno; this.$router.push("/chat/"+this.roomId); this.$parent.send({event:'joinChatRoom',roomId:this.roomId,userList:this.doctorList}); }, closeDiv() { this.roomShow = false; this.hospno = null; this.roomId = null; }, loadRouter(){ var params=this.$route.params; console.error(params); if(params.roomId!=''&¶ms.roomId!='-'&¶ms.roomId!='null'&¶ms.roomId!='undefined'){ this.roomId=params.roomId; this.roomShow = true; } } }, components: { Room }, watch:{ '$route'(val){ #監聽路由變化' if(val&&val!='-'){ #如果路由引數存在且不為‘-’ 則載入路由,並彈出licodeRoom.vue的介面 加入影片會議 this.loadRouter(); } } }, mounted() { this.loadRouter(); }, created() { }}
licodeRoom.vue程式碼:
程式碼檔案:/front/src/views/chat/licodeRoom.vue
//設定影片位置 var fixedTop = function () { //將已有的影片換到主影片模式 var els = document.getElementsByClassName('main_video'); //小視窗排列 var el_list = document.getElementsByClassName("video"); console.error("影片數量==》" + el_list.length); if (els== undefined||els == null ||els.length== 0){ el_list[0].style.top="0px"; el_list[0].className = 'main_video'; } for (var i = 0; i < el_list.length; i++) { var el = el_list[i]; el.style.top = (i * 80) + 'px'; var p=el.querySelector(".licode_player"); if(p) { p.ondblclick = function () { document.getElementsByClassName("main_video")[0].className = 'video'; var pr = this.parentNode.parentNode; pr.className = 'main_video'; pr.style.top = "0px"; console.error(this.parentNode.id); fixedTop(); } } }}
//對應 export default 的methods的方法
//釋出影片流 subscribeToStreams(streams) { var cb = function (evt) { console.error('Bandwidth Alert', evt.msg, evt.bandwidth); }; for (var index in streams) { var stream = streams[index]; if (this.localStream.getID() !== stream.getID()) { this.room.subscribe(stream, {metadata: {type: 'subscriber'}}); stream.addEventListener('bandwidth-alert', cb); } }},
initLicode() { this.localStream = Erizo.Stream({ audio: true, video: true, data: true, attributes: {id: this.currentUser.id, name: this.currentUser.name}, desktopStreamId: this.currentUser.id, videoSize: [640, 480, 1920, 1080]//,videoFrameRate: [10, 20] }); axios.defaults.headers.post['Content-Type'] = 'application/json'; axios.defaults.headers.post['Accept'] = '*/*'; axios.post(Constants.licodeServer + 'createToken/', { room: this.id, username: encodeURI(this.currentUser.name), role: 'presenter' }).then((resp) => { var token = resp.data; this.room = Erizo.Room({token: token}); this.room.addEventListener('room-connected', (event) => { this.room.publish(this.localStream, {maxVideoBW: 2000, minVideoBW: 1000,metadata: {type: 'publisher'}}); console.error("this.localStream room-connected id\t" + this.localStream.getID()); this.subscribeToStreams(event.streams); }); this.room.addEventListener('stream-subscribed', (streamEvent) => { var stream = streamEvent.stream; var attrs = stream.getAttributes(); console.error("stream-subscribed\t接收影片流 id\t" + attrs.id); if (attrs.id == this.currentUser.id) return; var s = this.$el.querySelector(".main_video"); if (s != undefined && s != null) s.className = "video"; var elem = document.createElement("div"); elem.setAttribute("id", attrs.id); elem.setAttribute("title", attrs.name); elem.className = "main_video"; this.$el.querySelector("#video_list").appendChild(elem); var videoEl = document.createElement("div"); videoEl.className = "videoPlay"; videoEl.setAttribute("id", "video_" + attrs.id); elem.appendChild(videoEl); stream.show("video_" + attrs.id); // document.getElementById("audio_login").play(); //移除licode logo document.getElementById("video_" + attrs.id).getElementsByClassName('licode_link')[0].innerHTML=''; fixedTop(); }); this.room.addEventListener('stream-added', (event) => { console.error("stream-added id\t" + event.stream.getID()); var streams = []; streams.push(event.stream); this.subscribeToStreams(streams); }); this.room.addEventListener('stream-removed', (streamEvent) => { console.error("stream-removed"); // Remove stream from DOM var stream = streamEvent.stream; if (stream.getID() !== undefined) { var attrs = stream.getAttributes(); var element = document.getElementById(attrs.id); if (element != undefined && element != null) this.$el.querySelector("#video_list").removeChild(element); fixedTop(); } }); this.room.addEventListener('stream-failed', () => { console.error("stream-failed"); this.room.disconnect(); }); this.localStream.addEventListener('access-accepted', () => { console.error("this.localStream access-accepted"); this.room.connect(); var attrs = this.localStream.getAttributes(); var elem = document.createElement("div"); elem.setAttribute("id", attrs.id); elem.setAttribute("title", attrs.name); elem.className = "main_video"; this.$el.querySelector("#video_list").appendChild(elem); var videoEl = document.createElement("div"); videoEl.setAttribute("id", "video_" + attrs.id); videoEl.className = "videoPlay"; elem.appendChild(videoEl); this.localStream.show("video_" + attrs.id);//, {speaker: true}); //移除licode logo document.getElementById("video_" + attrs.id).getElementsByClassName('licode_link')[0].innerHTML=''; fixedTop(); }); this.localStream.init(); });}, quitRoom() { if(this.localStream){}else{return;} this.localStream.stop(); //取消訂閱影片 this.room.unsubscribe(this.localStream, (result, error) => { if (result === undefined) { console.error("Error unsubscribing", error); } else { console.error("Stream unsubscribed!"); } }); //取消釋出影片 this.room.unpublish(this.localStream, (result, error) => { if (result === undefined) { console.error("Error unpublishing", error); } else { console.error("Stream unpublished!"); } }); this.send({event:'leave'}); this.room.disconnect(); this.room = null; this.localStream = null;}
會議聊天室說明:
初始化本地影片流this.localStream = Erizo.Stream({ audio: true, video: true, data: true, attributes: {id: this.currentUser.id, name: this.currentUser.name}, desktopStreamId: this.currentUser.id, videoSize: [640, 480, 1920, 1080]//,videoFrameRate: [10, 20] });
獲取聊天室token在獲取token成功後初始化room,對room設定事件監聽:room-connected 聊天室連線成功
連線成功後釋出本地影片流localStream
stream-subscribed 影片流訂閱成功將獲得的影片流播放, 透過fixedTop()方法設定位置,及設定主影片視窗。
stream-added 影片流加入後進行整個room訂閱影片流。並對此影片流設定bandwith-alert監聽。stream-removed 影片流移除或斷開監聽對已經斷開的影片流的video進行移除,並且呼叫fixedTop來重新佈局設定主次屏。
stream-failed 影片連線失敗監聽room斷開連線。
對本地影片流監聽,access-accepeted:連線room:this.room.connect();
播放本地影片流,並且呼叫fixedTop來重新佈局設定主次屏。
初始化localStream。 this.localStream.init();