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

展豪發表於2019-04-27

書接上回

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

架構

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

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那麼他們是完全可以相容的,因為你們用的是一樣的的語言進行會話。

信令 signaling

從圖中我們還看到另一個東西,那就是信令(signaling)。這裡我不得不感嘆前輩的翻譯,這個翻譯真的是信達雅的典範。信令簡單來說,就是傳輸各種連線過程中的資訊。它在這裡傳遞了WebRTC3個重要資訊,也就是上一篇我們提到的offeranswercandidateofferanswer其實就是用於建立和交換雙方的會話描述,格式就是上面提到的SDP,這裡就不展開說了。而candidate也是來源與一個規範ICE framework,在建立通訊之前,我們需要獲得雙方的網路資訊,例如 IP、埠等,而這一個框架就是用於規範這一個過程,candidate便是用於儲存這些東西的。一般candidate是有多個的,因為我們的網路環境通常是很複雜的,按照我的理解每經過一次NAT都會又一個candidate。在WebRTC中,一般需要這樣操作

  1. A 建立了一個註冊了onicecandidate 響應方法的 RTCPeerConnection 物件
  2. 這個響應方法在網路candidates準備好後呼叫
  3. 這個響應方法, A 通過傳輸信令的通道傳送了一個字串化的的candidate資料到B
  4. B獲得A穿過來的candidate資訊後,他需要呼叫addIceCandidatecandidate新增到遠端的節點描述

值得注意的是JSEP 支援 ICE Candidate Trickling,這意味著,它允許在offer初始化之後繼續新增candidate,並且應答方也無需等待所有的candidate傳送完畢才開始嘗試建立連線,畢竟我可以一直加嘛,這個比較好理解。下面是一個candidate的主要內容,包含協議、IP、埠等

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

接下來,我們就開始建立我們的信令服務吧。

建立信令伺服器

既然我們的信令伺服器本質上就是用於傳遞文字資訊給雙方。那我們就可以用任意通訊協議,包裝我們需要信令的資訊,然後傳送給對方就好。前提是這個通訊需要是雙向的,你可以用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 joinmessage

  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的新版本,必須要用安全連線才能開啟媒體裝置。

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

或者PC與手機通訊

pc

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

大成功!!

總結

到這一篇為止,我們已經基本瞭解了WebRTC的架構和用法,並且實現了不同平臺間的P2P通訊。遺憾的是,現在這個Demo僅僅能在區域網內運作。對於真實的的世界,有各種複雜的網路配置,還有防火牆。下一篇,我們來了解下在網際網路中,我們怎麼通過STUNTURN來實現WebRTC吧。

謝謝各位的閱讀。

程式碼和參考文件

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

相關文章