一塊兒來學習 WebRTC (篇二) | 掘金技術徵文

書接上回

上一篇中咱們簡單的介紹了WebRTC的一些歷史和API的用法。在這一篇中,咱們繼續來學習一些關於WebRTC的架構、協議,以及在真實網絡狀況下WebRTC是如何進行通訊的。javascript

架構

WebRTC是一個點對點通信的框架,它的架構實現聽從 JESP( JavaScript Session Establishment Protocol),如圖html

JESP

在圖中,咱們能夠看到咱們上一篇說到的會話描述(SessionDescription)用於描述雙方的會話信息,它也是一個標準格式,稱爲 Session Description Protoco,簡稱 SDP,這一個SDP對象序列化以後的樣子。前端

v=0
o=- 3445510214506992100 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE video
a=msid-semantic: WMS EeiMAMV43kTkrOafBzAUtKcLGJupxSVVrbI4
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 102 123 127 122 125 107 108 109 124
c=IN IP4 0.0.0.0
...
a=ssrc:506345433 label:084a2d08-ec72-40ca-aeaa-6146cbe26fd9
複製代碼

簡單來講,就是咱們的應用把會話描述交給WebRTC而後就會幫咱們把P2P通訊啥的都搞定。咱們只有調用API得到咱們最終須要的信息便可。那這裏能夠會有小夥伴問了,爲啥要用SDP呢,看起來這麼奇怪,谷歌徹底能夠本身作一套呢?答案固然是爲了兼容性跟不重複造輪子,試想若是別的公司也弄了一個RTC框架,只要用的也是SDP那麼他們是徹底能夠兼容的,由於大家用的是同樣的的語言進行會話。java

信令 signaling

從圖中咱們還看到另外一個東西,那就是信令(signaling)。這裏我不得不感嘆前輩的翻譯,這個翻譯真的是信達雅的典範。信令簡單來講,就是傳輸各類鏈接過程當中的信息。它在這裏傳遞了WebRTC3個重要信息,也就是上一篇咱們提到的offeranswercandidateofferanswer其實就是用於建立和交換雙方的會話描述,格式就是上面提到的SDP,這裏就不展開說了。而candidate也是來源與一個規範ICE framework,在創建通信以前,咱們須要得到雙方的網絡信息,例如 IP、端口等,而這一個框架就是用於規範這一個過程,candidate即是用於保存這些東西的。通常candidate是有多個的,由於咱們的網絡環境一般是很複雜的,按照個人理解每通過一次NAT都會又一個candidate。在WebRTC中,通常須要這樣操做git

  1. A 建立了一個註冊了onicecandidate 響應方法的 RTCPeerConnection 對象
  2. 這個響應方法在網絡candidates準備好後調用
  3. 這個響應方法, A 經過傳輸信令的通道發送了一個字符串化的的candidate數據到B
  4. B得到A穿過來的candidate信息後,他須要調用addIceCandidatecandidate添加到遠程的節點描述

值得注意的是JSEP 支持 ICE Candidate Trickling,這意味着,它容許在offer初始化以後繼續添加candidate,而且應答方也無需等待全部的candidate發送完畢纔開始嘗試創建鏈接,畢竟我能夠一直加嘛,這個比較好理解。下面是一個candidate的主要內容,包含協議、IP、端口等github

candidate:3885250869 1 udp 2122260223 172.17.0.1 37648 typ host generation 0 ufrag /Fde network-id 1 network-cost 50.
複製代碼

接下來,咱們就開始創建咱們的信令服務吧。web

創建信令服務器

既然咱們的信令服務器本質上就是用於傳遞文本信息給雙方。那咱們就能夠用任意通信協議,包裝咱們須要信令的信息,而後發送給對方就好。前提是這個通信須要是雙向的,你能夠用Websocket也能夠用Ajax+輪詢的方式。怎麼順手怎麼來。下面的例子咱們用了socket.io,這個庫的好處是,它能夠模擬socket 支持雙向通信,而且兼容各個瀏覽器,還有就是它原生支持房間(room)的概念,也就是隻要我往房間發數據,全部在這這個房間的客戶端都能收到消息(廣播),這種機制,給咱們交換信息提供方便。瀏覽器

建立服務的代碼咱們就跳過了,直接看消息的處理部分。安全

io.sockets.on('connection', socket => {

    // 打印 log 到客戶端
    function log() {
        var array = ['服務器消息:'];
        array.push.apply(array, arguments);
        socket.emit('log', array);
    }

    socket.on('message', message => {
        log('客戶端消息:', message);

        // 廣播消息,真正的使用應該只發到指定的 room 而不是廣播
        socket.broadcast.emit('message', message);
    });

    socket.on("create or join", room => {
        log('接受到建立或者加入房間請求:' + room);

        var clientsInRoom = io.sockets.adapter.rooms[room];
        var numClients = clientsInRoom ? Object.keys(clientsInRoom.sockets).length : 0;
        log('Room ' + room + ' 如今有 ' + numClients + ' 個客戶端');

        if (numClients === 0) {
            socket.join(room);
            log('客戶端 ID: ' + socket.id + ' 建立了房間:' + room);
            socket.emit('created', room, socket.id);
        } else if (numClients === 1) {
            log('客戶端 ID: ' + socket.id + ' 加入了房間: ' + room);
            io.sockets.in(room).emit('join', room);
            socket.join(room);
            socket.emit('joined', room, socket.id);
            io.sockets.in(room).emit('ready');
        } else { // 一個房間只能容納兩個客戶端
            socket.emit('full', room);
        }
    });

});

複製代碼

主要是兩個關鍵事件的響應create and joinmessagebash

  1. create and join,當客戶端發送create and join 事件時,後臺對應的handler方法會響應,而且試圖得到這個房間的人數。
    • 若是是0,則這客戶端是建立者,加入房間併發送建立的log到客戶端,最後發送一個created的事件到客戶端
    • 若是當前已經有一個客戶端了,則加入房間併發送加入的log到客戶端,接着發送一個joined的事件到客戶端,最後發送一個ready的事件到房間,讓房間的全部客戶端收到。
    • 若是已經大於1了,則房間滿員,直接發送full事件到客戶端
  2. message,客戶端發送message事件,對應方法會響應。這裏因爲咱們前端寫死了一個房間,所以,這裏直接建立一個廣播的message事件,把消息直接廣播給全部人,也就是通信雙方了。

客戶端

服務端咱們搞定了,而後咱們看看前端是這麼處理的。

HTML

<!DOCTYPE html>
<html lang="en">
<head>
   ...
</head>
<body>
    <h1>帶信令服務器的 WebRTC</h1>
    <div id="videos">
        <video id="localVideo" autoplay muted playsinline></video>
        <video id="remoteVideo" autoplay playsinline></video>
    </div>
    <!-- 墊片,用於統一瀏覽器 API -->
    <script src="js/adapter.js"></script>
    <!-- socket.io 支持-->
    <script src="/socket.io/socket.io.js"></script>
    <script src="js/main.js"></script>
</body>
</html>
複製代碼

除了加入了socket.io的支持,其他跟上一篇是同樣的,不過這裏爲了簡單,咱們把按鈕去掉了,也就是說一打開頁面就進行初始化,而且第一個客戶端等待第二個客戶端的加入

JavaScript

咱們先看初始化的部分,首先是鏈接咱們的服務端,而後建立和加入房間,也就是往服務端發送create or join事件。注意,這裏爲了簡單我把加入的房間寫死成foo

var room = 'foo';
var socket = io.connect();

// 建立或加入房間
if (room !== "") {
    socket.emit('create or join', room);
    console.log('嘗試或加入房間: ' + room);
}
複製代碼

接着咱們往下看,這裏很熟悉,就是得到媒體設備部分,在得到媒體設備成功的回調中,咱們主要關注兩個方法的調用sendMessagemaybeStart

var localVideo = document.querySelector('#localVideo');
var remoteVideo = document.querySelector('#remoteVideo');

navigator.mediaDevices.getUserMedia({
    audio: false,
    video: true})
    .then(gotStream)
    .catch(function(e) {
        alert('得到媒體錯誤: ' + e.name);
    });

function gotStream(stream) {
    console.log('正在添加本地流');
    localStream = stream;
    localVideo.srcObject = stream;
    sendMessage('got user media');
}
複製代碼

這裏sendMessage發送了got user media到服務端。服務端收到信息後,會把建立message事件把消息從新發送到全部的客戶端,這裏能夠回去看上面關於服務端消息響應的代碼解釋。

function sendMessage(message) {
    console.log('客戶端發送消息: ', message);
    socket.emit('message', message);
}
複製代碼

如今咱們接着看客戶端message事件的響應。

// 消息處理
socket.on('message', function(message) {
    console.log('客戶端接收到消息:', message);
    if (message === 'got user media') {
        maybeStart();
    } else if (message.type === 'offer') {
        ...
        ...
});
複製代碼

這裏是統一的消息處理,忽略其餘,咱們先看got user media消息的處理,這裏其實就是簡單的調用了一下maybeStart方法,因此咱們來看一下這個方法作了什麼

function maybeStart() {
    console.log('>>>>>>> maybeStart() ', isStarted, localStream, isChannelReady);
    if (!isStarted && typeof localStream !== 'undefined' && isChannelReady) {
        console.log('>>>>>> 正在建立 peer connection');
        createPeerConnection();
        pc.addStream(localStream);
        isStarted = true;
        console.log('isInitiator', isInitiator);
        if (isInitiator) {
            doCall();
        }
    }
}
複製代碼

maybeStart方法中,若是當前狀態isStarted=falseisChannelReady=truelocalStream準備好了 就會建立了咱們的RTCPeerConnection對象,把icecandidateonaddstreamremovestream註冊上,而後把本地的媒體流(localStream)加入RTCPeerConnection對象中。

function createPeerConnection() {
    try {
        pc = new RTCPeerConnection(null);
        pc.onicecandidate = handleIceCandidate;
        pc.onaddstream = handleRemoteStreamAdded;
        pc.onremovestream = handleRemoteStreamRemoved;
        console.log('RTCPeerConnnection 已建立');
    } catch (e) {
        console.log('建立失敗 PeerConnection, exception: ' + e.message);
        alert('RTCPeerConnection 建立失敗');
    }
}
複製代碼

最後,把isStart設置成true避免再次初始化,而後若是當前房間建立者就開始調用doCall開始發起通信。

看到這裏,有些同窗可能可能注意到了,這些isInitiatorisChannelReady是在哪裏設置的呢。那讓咱們回頭看socket的件響應方法把,下面的代碼片斷,就是在加入建立或房間的幾個事件中,把狀態相關的標識isInitiatorisChannelReady設置好。

socket.on('created', function(room, clientId) {
    isInitiator = true;
    console.log('建立房間:' + room + ' 成功')
});

socket.on('full', function(room) {
    console.log('房間 ' + room + ' 已滿');
});

socket.on('join', function (room){
    console.log('另外一個節點請求加入: ' + room);
    console.log('當前節點爲房間 ' + room + ' 的建立者!');
    isChannelReady = true;
});

socket.on('joined', function(room) {
    console.log('已加入: ' + room);
    isChannelReady = true;
});

複製代碼
  • isChannelReady,在joinjoined事件響應中設置,也就是在有客戶端加入房間時
  • isInitiator ,在created事件響應,也就是建立房間成功時

因此,咱們回頭看maybeStart方法,其實它是在雙方進入房間以後纔會真正的執行建立RTCPeerConnection等操做的,由於此時,isChannelReady纔會是true

你們不要暈,接下來就是doCall方法了,這方法很簡單,終於建立咱們的offer啦。

function doCall() {
    console.log('發送 offer 到節點');
    pc.createOffer(setLocalAndSendMessage, handleCreateOfferError);
}

function setLocalAndSendMessage(sessionDescription) {
    pc.setLocalDescription(sessionDescription);
    console.log('setLocalAndSendMessage 正在發送消息', sessionDescription);
    sendMessage(sessionDescription);
}
複製代碼

建立offer成功後,就是常規操做,把它存到本地,調用setLocalDescription,最後調用sendMessage方法,經過咱們的服務,發給對方。接下來咱們繼續看下消息的處理

// 消息處理
socket.on('message', function(message) {
    console.log('客戶端接收到消息:', message);
    ...
    } else if (message.type === 'offer') {
        if (!isInitiator && !isStarted) {
            maybeStart();
        }
        pc.setRemoteDescription(new RTCSessionDescription(message));
        doAnswer();
    } else if (message.type === 'answer' && isStarted) {
    ...
    ...
});
複製代碼

這裏當接收方收到offer以後,會首先判斷有沒有初始化(isStarted)。不然調用maybeStart進行初始化。初始化結束後,調用setRemoteDescriptionoffer存儲到。接着就是調用doAnsweranswer了,這邊跟doOffer的方法流程基本同樣

function doAnswer() {
    console.log('發送 answer 到節點.');
    pc.createAnswer().then(
        setLocalAndSendMessage,
        onCreateSessionDescriptionError
    );
}
複製代碼

接下來,咱們回到發起端,看看它拿到 answer消息以後的處理

// 消息處理
socket.on('message', function(message) {
   console.log('客戶端接收到消息:', message);
   ...
    } else if (message.type === 'answer' && isStarted) {
        pc.setRemoteDescription(new RTCSessionDescription(message));
    } else if (message.type === 'candidate' && isStarted) {
    ...
    ..
});
複製代碼

嗯,比較簡單。就是把answer存起來了

到如今爲止,咱們的offeranswer已經交換好了,接着咱們繼續看candidate的交換。先看oncandidate的響應handleIceCandidate。這個方法會在網絡準備好以後,方法會通常屢次調用,由於咱們的網絡環境一般是複雜的。這個方法把咱們的candidate包裝成咱們須要的格式,而後發送給對方。

function handleIceCandidate(event) {
    console.log('icecandidate event: ', event);
    if (event.candidate) {
        sendMessage({
            type: 'candidate',
            label: event.candidate.sdpMLineIndex,
            id: event.candidate.sdpMid,
            candidate: event.candidate.candidate
        });
    } else {
        console.log('End of candidates.');
    }
}
複製代碼

好的,已經發出去了,而後就是消息處理

// 消息處理
socket.on('message', function(message) {
    console.log('客戶端接收到消息:', message);
    ...
    ...
    } else if (message.type === 'candidate' && isStarted) {
        var candidate = new RTCIceCandidate({
            sdpMLineIndex: message.label,
            candidate: message.candidate
        });
        pc.addIceCandidate(candidate);
    } else if (message === 'bye' && isStarted) {
	...
});
複製代碼

很簡單,把消息包裝成RTCIceCandidate對象,而後調用addIceCandidate保存起來。

終於,咱們全部必要的消息都準備好了,WebRTC就會爲咱們創建鏈接。而後經過offeranswer的會話描述獲得媒體流的信息,而且回調onaddstream註冊的方法,把媒體流賦予給remoteVideovideo標籤

function handleRemoteStreamAdded(event) {
    console.log('遠程媒體流設置.');
    remoteStream = event.stream;
    remoteVideo.srcObject = remoteStream;
}
複製代碼

如今,咱們能夠開始愉快的視頻了。打開兩個瀏覽器而且用https訪問。由於上一篇提到過,在Chrome的新版本,必需要用安全鏈接才能打開媒體設備。

或者PC與手機通信

pc

大成功!!

總結

到這一篇爲止,咱們已經基本瞭解了WebRTC的架構和用法,而且實現了不一樣平臺間的P2P通信。遺憾的是,如今這個Demo僅僅能在局域網內運做。對於真實的的世界,有各類複雜的網絡配置,還有防火牆。下一篇,咱們來了解下在互聯網中,咱們怎麼經過STUNTURN來實現WebRTC吧。

謝謝各位的閱讀。

代碼和參考文檔

Agora SDK 使用體驗徵文大賽 | 掘金技術徵文,徵文活動正在進行中

相關文章
相關標籤/搜索