目的 幫助本身瞭解webrtc 實現端對端通訊
# 使用流程 git clone https://gitee.com/wjj0720/webrtc.git cd ./webRTC npm i npm run dev # 訪問 127.0.0.1:3003/test-1.html 演示h5媒體流捕獲 # 訪問 127.0.0.1:3003/local.html 演示rtc 本地傳輸 # 訪問 127.0.0.1:3003/p2p.html 演示局域網端對端視屏
WebRTC(Web Real-Time Communication) 網頁即時通訊 ,是一個支持網頁瀏覽器進行實時語音、視頻對話的API。 於2011年6月1日開源並在Google、Mozilla、Opera支持下被歸入萬維網聯盟的W3C推薦標準
閒話:目前主流實時流媒體 實現方式 RTP :(Real-time Transport Protocol) 創建在 UDP 協議上的一種協議加控制 HLS(HTTP Live Streamin)蘋果公司實現的基於HTTP的流媒體傳輸協議 RTMP(Real Time Messaging Protocol) Adobe公司基於TCP WebRTC google 基於RTP協議
目標:打開攝像頭將媒體流顯示到頁面
MediaDevices 文檔html
navigator.mediaDevices.getUserMedia({ video: true, // 攝像頭 audio: true // 麥克風 }).then(steam => { // video標籤的srcObject video.srcObject = stream }).catch(e => { console.log(e) })
RTCPeerConnection api提供了 WebRTC端建立、連接、保持、監控閉鏈接的方法的實現
RTCPeerConnection MDN
以 A<=>B 建立p2p鏈接爲例 A端: 1.建立RTCPeerConnection實例:peerA 2.將本身本地媒體流(音、視頻)加入實例,peerA.addStream 3.監聽來自遠端傳輸過來的媒體流 peerA.onaddstream 4.建立[SDP offer]目的是啓動到遠程(此時的遠端也叫候選人)))對等點的新WebRTC鏈接 peerA.createOffer 5.經過[信令服務器]將offer傳遞給呼叫方 6.收到answer後去[stun]服務拿到本身的IP,經過信令服務將其發送給呼叫放 B端: 1.收到信令服務的通知 建立RTCPeerConnection peerB, 2.也須要將本身本地媒體流加入通訊 peerB.addstream 3.監聽來自遠端傳輸過來的媒體流 peerA.onaddstream 4.一樣建立[SDP offer] peerA.createAnswer 5.經過[信令服務器]將Answer傳遞給呼叫方 6.收到對方IP 一樣去[stun]服務拿到本身的IP 傳遞給對方 至此完成p2p鏈接 觸發雙發onaddstream事件
信令服務前端
信令服務器: webRTC中負責呼叫創建、監控(Supervision)、拆除(Teardown)的系統 爲何須要: webRTC是p2p鏈接,那麼鏈接以前如何得到對方信息,有如何將本身的信息發送給對方,這就須要信令服務
SDPjquery
什麼是SDP SDP 徹底是一種會話描述格式 ― 它不屬於傳輸協議 它只使用不一樣的適當的傳輸協議,包括會話通知協議(SAP)、會話初始協議(SIP)、實時流協議(RTSP)、MIME 擴展協議的電子郵件以及超文本傳輸協議(HTTP) SDP協議是基於文本的協議,可擴展性比較強,這樣就使其具備普遍的應用範圍。 WebRTC中SDP SDP不支持會話內容或媒體編碼的協商。webrtc中sdp用於媒體信息(編碼解碼信息)的描述,媒體協商這一塊要用RTP來實現
stungit
1.什麼是STUN STUN(Session Traversal Utilities for NAT,NAT會話穿越應用程序)是一種網絡協議,它容許位於NAT(或多重NAT)後的客戶端找出本身的公網地址,查出本身位於哪一種類型的NAT以後以及NAT爲某一個本地端口所綁定的Internet端端口。這些信息被用來在兩個同時處於NAT路由器以後的主機之間建立UDP通訊。這種經過穿過路由直接通訊的方式叫穿牆 2.什麼是NAT NAT(Network Address Translation,網絡地址轉換),是1994年提出的。當在專用網內部的一些主機原本已經分配到了本地IP地址,但如今又想和因特網上的主機通訊時,因而乎在路由器上安裝NAT軟件。裝有NAT軟件的路由器叫作NAT路由器,它能夠經過一個全球IP地址。使全部使用本地地址的主機在和外界通訊時,這種經過使用少許的公有IP地址表明較多的私有IP地址的方式,將有助於減緩可用的IP地址空間的枯竭 3.WebRTC的穿牆 目前經常使用的針對UDP鏈接的NAT穿透方法主要有:STUN、TURN、ICE、uPnP等。其中ICE方式因爲其結合了STUN和TURN的特色 webrtc是用的就是這個 google提供的免費地址:https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/
前端github
<!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta http-equiv="X-UA-Compatible" content="ie=edge" /> <title>端對端</title> </head> <body> <div class="page-container"> <div class="message-box"> <ul class="message-list"></ul> <div class="send-box"> <textarea class="send-content"></textarea> <button class="sendbtn">發送</button> </div> </div> <div class="user-box"> <video id="local-video" autoplay class="local-video"></video> <video id="remote-video" autoplay class="remote-video"></video> <p class="title">在線用戶</p> <ul class="user-list"></ul> </div> <div class="mask"> <div class="mask-content"> <input class="myname" type="text" placeholder="輸入用戶名加入房間"> <button class="add-room">加入</button> </div> </div> <div class="video-box"> </div> </div> <script src="/js/jquery.js"></script> <script src="/js/socket.io.js"></script> <script> // 簡單封裝一下 class Chat { constructor({ calledHandle, host, socketPath, getCallReject } = {}) { this.host = host this.socketPath = socketPath this.socket = null this.calledHandle = calledHandle this.getCallReject = getCallReject this.peer = null this.localMedia = null } async init() { this.socket = await this.connentSocket() return this } async connentSocket() { if (this.socket) return this.socket return new Promise((resolve, reject) => { let socket = io(this.host, { path: this.socketPath }) socket.on("connect", () => { console.log("鏈接成功!") resolve(socket) }) socket.on("connect_error", e => { console.log("鏈接失敗!") throw e reject() }) // 呼叫被接受 socket.on('answer', ({ answer }) => { this.peer && this.peer.setRemoteDescription(answer) }) // 被呼叫事件 socket.on('called', callingInfo => { this.called && this.called(callingInfo) }) // 呼叫被拒 socket.on('callRejected', () => { this.getCallReject && this.getCallReject() }) socket.on('iceCandidate', ({ iceCandidate }) => { console.log('遠端添加iceCandidate'); this.peer && this.peer.addIceCandidate(new RTCIceCandidate(iceCandidate)) }) }) } addEvent(name, cb) { if (!this.socket) return this.socket.on(name, (data) => { cb.call(this, data) }) } sendMessage(name, data) { if (!this.socket) return this.socket.emit(name, data) } // 獲取本地媒體流 async getLocalMedia() { let localMedia = await navigator.mediaDevices .getUserMedia({ video: { facingMode: "user" }, audio: true }) .catch(e => { console.log(e) }) this.localMedia = localMedia return this } // 設置媒體流到video setMediaTo(eleId, media) { document.getElementById(eleId).srcObject = media } // 被叫響應 called(callingInfo) { this.calledHandle && this.calledHandle(callingInfo) } // 建立RTC createLoacalPeer() { this.peer = new RTCPeerConnection() return this } // 將媒體流加入通訊 addTrack() { if (!this.peer || !this.localMedia) return //this.localMedia.getTracks().forEach(track => this.peer.addTrack(track, this.localMedia)); this.peer.addStream(this.localMedia) return this } // 建立 SDP offer async createOffer(cb) { if (!this.peer) return let offer = await this.peer.createOffer({ OfferToReceiveAudio: true, OfferToReceiveVideo: true }) this.peer.setLocalDescription(offer) cb && cb(offer) return this } async createAnswer(offer, cb) { if (!this.peer) return this.peer.setRemoteDescription(offer) let answer = await this.peer.createAnswer({ OfferToReceiveAudio: true, OfferToReceiveVideo: true }) this.peer.setLocalDescription(answer) cb && cb(answer) return this } listenerAddStream(cb) { this.peer.addEventListener('addstream', event => { console.log('addstream事件觸發', event.stream); cb && cb(event.stream); }) return this } // 監聽候選加入 listenerCandidateAdd(cb) { this.peer.addEventListener('icecandidate', event => { let iceCandidate = event.candidate; if (iceCandidate) { console.log('發送candidate給遠端'); cb && cb(iceCandidate); } }) return this } // 檢測ice協商過程 listenerGatheringstatechange () { this.peer.addEventListener('icegatheringstatechange', e => { console.log('ice協商中: ', e.target.iceGatheringState); }) return this } // 關閉RTC closeRTC() { // .... } } </script> <script> $(function () { let chat = new Chat({ host: 'http://127.0.0.1:3003', socketPath: "/websocket", calledHandle: calledHandle, getCallReject: getCallReject }) // 更新用戶列表視圖 function updateUserList(list) { $(".user-list").html(list.reduce((temp, li) => { temp += `<li class="user-li">${li.name} <button data-calling=${li.calling} data-id=${li.id} class=${li.id === this.socket.id || li.calling ? 'cannot-call' : 'can-call'}> 通話</button></li>` return temp }, '')) } // 更新消息li表視圖 function updateMessageList(msg) { $('.message-list').append(`<li class=${msg.userId === this.socket.id ? 'left' : 'right'}>${msg.user}: ${msg.content}</li>`) } // 加入房間 $('.add-room').on('click', async () => { let name = $('.myname').val() if (!name) return $('.mask').fadeOut() await chat.init() // 用戶加入事件 chat.addEvent('updateUserList', updateUserList) // 消息更新事件 chat.addEvent('updateMessageList', updateMessageList) chat.sendMessage('addUser', { name }) }) // 發送消息 $('.sendbtn').on('click', () => { let sendContent = $('.send-content').val() if (!sendContent) return $('.send-content').val('') chat.sendMessage('sendMessage', { content: sendContent }) }) // 視屏 $('.user-list').on('click', '.can-call', async function () { // 被叫方信息 let calledParty = $(this).data() if (calledParty.calling) return console.log('對方正在通話'); // 初始本地視頻 $('.local-video').fadeIn() await chat.getLocalMedia() chat.setMediaTo('local-video', chat.localMedia) chat.createLoacalPeer() .listenerGatheringstatechange() .addTrack() .listenerAddStream(function (stream) { $('.remote-video').fadeIn() chat.setMediaTo('remote-video', stream) }) .listenerCandidateAdd(function (iceCandidate) { chat.sendMessage('iceCandidate', { iceCandidate, id: calledParty.id }) }) .createOffer(function (offer) { chat.sendMessage('offer', { offer, ...calledParty }) }) }) //呼叫被拒絕 function getCallReject() { chat.closeRTC() $('.local-video').fadeIn() console.log('呼叫被拒'); } // 被叫 async function calledHandle(callingInfo) { if (!confirm(`是否接受${callingInfo.name}的視頻通話`)) { chat.sendMessage('rejectCall', callingInfo.id) return } $('.local-video').fadeIn() await chat.getLocalMedia() chat.setMediaTo('local-video', chat.localMedia) chat.createLoacalPeer() .listenerGatheringstatechange() .addTrack() .listenerCandidateAdd(function (iceCandidate) { chat.sendMessage('iceCandidate', { iceCandidate, id: callingInfo.id }) }) .listenerAddStream(function (stream) { $('.remote-video').fadeIn() chat.setMediaTo('remote-video', stream) }) .createAnswer(callingInfo.offer, function (answer) { chat.sendMessage('answer', { answer, id: callingInfo.id }) }) } }) </script> </body> </html>
後端web
const SocketIO = require('socket.io') const socketIO = new SocketIO({ path: '/websocket' }) let userRoom = { list: [], add(user) { this.list.push(user) return this }, del(id) { this.list = this.list.filter(u => u.id !== id) return this }, sendAllUser(name, data) { this.list.forEach(({ id }) => { console.log('>>>>>', id) socketIO.to(id).emit(name, data) }) return this }, sendTo(id) { return (eventName, data) => { socketIO.to(id).emit(eventName, data) } }, findName(id) { return this.list.find(u => u.id === id).name } } socketIO.on('connection', function(socket) { console.log('鏈接加入.', socket.id) socket.on('addUser', function(data) { console.log(data.name, '加入房間') let user = { id: socket.id, name: data.name, calling: false } userRoom.add(user).sendAllUser('updateUserList', userRoom.list) }) socket.on('sendMessage', ({ content }) => { console.log('轉發消息:', content) userRoom.sendAllUser('updateMessageList', { userId: socket.id, content, user: userRoom.findName(socket.id) }) }) socket.on('iceCandidate', ({ id, iceCandidate }) => { console.log('轉發信道') userRoom.sendTo(id)('iceCandidate', { iceCandidate, id: socket.id }) }) socket.on('offer', ({id, offer}) => { console.log('轉發offer') userRoom.sendTo(id)('called', { offer, id: socket.id, name: userRoom.findName(socket.id)}) }) socket.on('answer', ({id, answer}) => { console.log('接受視頻'); userRoom.sendTo(id)('answer', {answer}) }) socket.on('rejectCall', id => { console.log('轉發拒接視頻') userRoom.sendTo(id)('callRejected') }) socket.on('disconnect', () => { // 斷開刪除 console.log('鏈接斷開', socket.id) userRoom.del(socket.id).sendAllUser('updateUserList', userRoom.list) }) }) module.exports = socketIO // www.js 這就不關鍵了 const http = require('http') const app = require('../app') const socketIO = require('../socket.js') const server = http.createServer(app.callback()) socketIO.attach(server) server.listen(3003, () => { console.log('server start on 127.0.0.1:3003') })
由於沒有錢買服務器 沒試過
coturn 聽說使用它搭建 STUN/TURN 服務很是的方便npm
# 編譯 cd coturn ./configure --prefix=/usr/local/coturn sudo make -j 4 && make install # 配置 listening-port=3478 #指定偵聽的端口 external-ip=39.105.185.198 #指定雲主機的公網IP地址 user=aaaaaa:bbbbbb #訪問 stun/turn服務的用戶名和密碼 realm=stun.xxx.cn #域名,這個必定要設置 #啓動 cd /usr/local/coturn/bin turnserver -c ../etc/turnserver.conf trickle-ice https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice 按裏面的要求輸入 stun/turn 地址、用戶和密碼 輸入的信息分別是: STUN or TURN URI 的值爲: turn:stun.xxx.cn 用戶名爲: aaaaaa 密碼爲: bbbbbb
let ice = {"iceServers": [ {"url": "stun:stun.l.google.com:19302"}, // 無需密碼的 // TURN 通常須要本身去定義 { 'url': 'turn:192.158.29.39:3478?transport=udp', 'credential': 'JZEOEt2V3Qb0y27GRntt2u2PAYA=', // 密碼 'username': '28224511:1379330808' // 用戶名 }, { 'url': 'turn:192.158.29.39:3478?transport=tcp', 'credential': 'JZEOEt2V3Qb0y27GRntt2u2PAYA=', 'username': '28224511:1379330808' } ]} // 能夠提供多iceServers地址,但RTC追選擇一個進行協商 // 實例化的是給上參數 RTC會在合適的時候去獲取本地牆後IP let pc = new RTCPeerConnection(ice); /* // 聽說這些免費的地址均可以用 stun:stun1.l.google.com:19302 stun:stun2.l.google.com:19302 stun:stun3.l.google.com:19302 stun:stun4.l.google.com:19302 stun:23.21.150.121 stun:stun01.sipphone.com stun:stun.ekiga.net stun:stun.fwdnet.net stun:stun.ideasip.com stun:stun.iptel.org stun:stun.rixtelecom.se stun:stun.schlund.de stun:stunserver.org stun:stun.softjoys.com stun:stun.voiparound.com stun:stun.voipbuster.com stun:stun.voipstunt.com stun:stun.voxgratia.org stun:stun.xten.com */
歡迎提問後端