WebRTC實時通訊協議詳解 | 掘金技術徵文

JJQ3發表於2019-05-06

這一篇我們來講一下WebRTC協議,之前我總結過一篇各種網路協議的總結,沒看過的朋友建議先看下這篇web知識梳理,有助於加深這篇關於WebRTC的理解。

基本概念

WebRTC是由Google主導的,由一組標準、協議和JavaScript API組成,用於實現瀏覽器之間(端到端之間)的音訊、視訊及資料共享。WebRTC不需要安裝任何外掛,通過簡單的JavaScript API就可以使得實時通訊變成一種標準功能。

現在各大瀏覽器以及終端已經逐漸加大對WebRTC技術的支援。下圖是webrtc官網給出的現在已經提供支援了的瀏覽器和平臺。

webrtc官網給出的現在支援webrtc的瀏覽器和平臺

Android上實現一個WebRTC專案

在深入講解協議之前,我們先來看例項。我們先來看下在Android中實現一個WebRTC的程式碼示例。

引入依賴包

首先,引入WebRTC依賴包,這裡我是使用Nodejs下的socket.io庫實現WebRTC信令伺服器的,所以也要引入socket.io依賴包。

dependencies {
    implementation 'io.socket:socket.io-client:1.0.0'
    implementation 'org.webrtc:google-webrtc:1.0.+'
    implementation 'pub.devrel:easypermissions:1.0.0'
}
複製程式碼

初始化核心類PeerConnectionFactory

PeerConnectionFactory.initialize(
                PeerConnectionFactory.InitializationOptions.builder(getApplicationContext())
                        .setEnableVideoHwAcceleration(true)
                        .createInitializationOptions());

        //建立PeerConnectionFactory
        PeerConnectionFactory.Options options = new PeerConnectionFactory.Options();
        mPeerConnectionFactory = new PeerConnectionFactory(options);
        //設定視訊Hw加速,否則視訊播放閃屏
        mPeerConnectionFactory.setVideoHwAccelerationOptions(mEglBase.getEglBaseContext(), mEglBase.getEglBaseContext());
複製程式碼

設定相關ICE設定

private void initConstraints() {
        iceServers = new LinkedList<>();
        iceServers.add(PeerConnection.IceServer.builder("stun:23.21.150.121").createIceServer());
        iceServers.add(PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer());

        pcConstraints = new MediaConstraints();
        pcConstraints.optional.add(new MediaConstraints.KeyValuePair("DtlsSrtpKeyAgreement", "true"));
        pcConstraints.optional.add(new MediaConstraints.KeyValuePair("RtpDataChannels", "true"));


        sdpConstraints = new MediaConstraints();
        sdpConstraints.mandatory.add(new MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true"));
        sdpConstraints.mandatory.add(new MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true"));

    }
複製程式碼

初始化控制元件

佈局檔案寫兩個控制元件,一個顯示本地視訊流,一個顯示遠端視訊流。

    <org.webrtc.SurfaceViewRenderer
        android:id="@+id/view_local"
        android:layout_width="150dp"
        android:layout_height="150dp"
        android:layout_gravity="center_horizontal"/>

    <org.webrtc.SurfaceViewRenderer
        android:id="@+id/view_remote"
        android:layout_width="150dp"
        android:layout_height="150dp"
        android:layout_gravity="center_horizontal"
        android:layout_marginTop="50dp"/>
複製程式碼

並對這兩個控制元件進行一些基礎設定

//初始化localView
        localView.init(mEglBase.getEglBaseContext(), null);
        localView.setKeepScreenOn(true);
        localView.setMirror(true);
        localView.setZOrderMediaOverlay(true);
        localView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT);
        localView.setEnableHardwareScaler(false);

        //初始化remoteView
        remoteView.init(mEglBase.getEglBaseContext(), null);
        remoteView.setMirror(false);
        remoteView.setZOrderMediaOverlay(true);
        remoteView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT);
        remoteView.setEnableHardwareScaler(false);
複製程式碼

採集本地視訊流並且渲染視訊

mVideoCapturer = createVideoCapture(this);

        VideoSource videoSource = mPeerConnectionFactory.createVideoSource(mVideoCapturer);
        mVideoTrack = mPeerConnectionFactory.createVideoTrack("videtrack", videoSource);

        //設定視訊畫質 i:width i1 :height i2:fps

        mVideoCapturer.startCapture(720, 1280, 30);

        AudioSource audioSource = mPeerConnectionFactory.createAudioSource(new MediaConstraints());
        mAudioTrack = mPeerConnectionFactory.createAudioTrack("audiotrack", audioSource);
        //播放本地視訊
        mVideoTrack.addRenderer(new VideoRenderer(localView));

        //建立媒體流並加入本地音視訊
        mMediaStream = mPeerConnectionFactory.createLocalMediaStream("localstream");
        mMediaStream.addTrack(mVideoTrack);
        mMediaStream.addTrack(mAudioTrack);
複製程式碼

建立PeerConnection物件

要想從遠端獲取資料,我們就必須建立 PeerConnection 物件。該物件的用處就是與遠端建立聯接,並最終為雙方通訊提供網路通道。

PeerConnection peerConnection = factory.createPeerConnection(
            iceServers,     //ICE伺服器列表,幹什麼用下面會詳細解釋
            constraints,   //MediaConstraints
            this);              //Context
複製程式碼

連線伺服器

注意這裡要把地址換成你的服務端的地址,我這裡WebRTC信令服務端使用的NodeJS編寫,然後用的是自己本地的tomcat地址。

//連線伺服器
        try {
            mSocket = IO.socket("http://192.168.31.172:3000/");
        } catch (URISyntaxException e) {
            e.printStackTrace();
        }
        mSocket.on("SomeOneOnline", new Emitter.Listener() {
            @Override
            public void call(Object... args) {
                isOffer = true;
                if (mPeer == null) {
                    mPeer = new Peer();
                }
                mPeer.peerConnection.createOffer(mPeer, sdpConstraints);
            }
        }).on("IceInfo", new Emitter.Listener() {
            @Override
            public void call(Object... args) {
                try {
                    JSONObject jsonObject = new JSONObject(args[0].toString());
                    IceCandidate candidate = null;
                    candidate = new IceCandidate(
                            jsonObject.getString("id"),
                            jsonObject.getInt("label"),
                            jsonObject.getString("candidate")
                    );
                    mPeer.peerConnection.addIceCandidate(candidate);
                } catch (JSONException e) {
                    e.printStackTrace();
                }
            }
        }).on("SdpInfo", new Emitter.Listener() {
            @Override
            public void call(Object... args) {
                if (mPeer == null) {
                    mPeer = new Peer();
                }
                try {
                    JSONObject jsonObject = new JSONObject(args[0].toString());
                    SessionDescription description = new SessionDescription
                            (SessionDescription.Type.fromCanonicalForm(jsonObject.getString("type")),
                                    jsonObject.getString("description"));
                    mPeer.peerConnection.setRemoteDescription(mPeer, description);
                    if (!isOffer) {
                        mPeer.peerConnection.createAnswer(mPeer, sdpConstraints);
                    }
                } catch (JSONException e) {
                    e.printStackTrace();
                }
            }
        });
        mSocket.connect();
複製程式碼

渲染遠端視訊流

@Override
        public void onAddStream(MediaStream mediaStream) {
            remoteVideoTrack = mediaStream.videoTracks.get(0);
            remoteVideoTrack.addRenderer(new VideoRenderer(remoteView));
        }
複製程式碼

使用DataChannel進行資料傳遞

/**
*DataChannel.Init 可配引數說明:
*ordered:是否保證順序傳輸;
*maxRetransmitTimeMs:重傳允許的最長時間;
*maxRetransmits:重傳允許的最大次數;
 **/
DataChannel.Init init = new DataChannel.Init();
dataChannel = peerConnection.createDataChannel("dataChannel", init);
複製程式碼

傳送訊息:

byte[] msg = message.getBytes();
DataChannel.Buffer buffer = new DataChannel.Buffer(
        ByteBuffer.wrap(msg),
        false);
dataChannel.send(buffer);
複製程式碼

onMessage()回撥收訊息:

ByteBuffer data = buffer.data;
byte[] bytes = new byte[data.capacity()];
data.get(bytes);
String msg = new String(bytes);
複製程式碼

WebRTC協議詳解

下面這張圖清楚的描述了WebRTC的協議分層,該圖引自《web效能權威指南》,如有侵權,立馬刪掉。

WebRTC協議分層
下面我們就一點點來剖析WebRTC。

傳輸層協議

WebRTC實時通訊傳輸音視訊的場景,講究的是實時,當下,處理音訊和視訊流的應用一定要補償間歇性的丟包,所以實時性的需求是大於可靠性的需求的。

如果使用TCP當傳輸層協議的話,如果中間出現丟包的情況,那麼後續的所有的包都會被緩衝起來,因為TCP講究可靠、有序,如果不清楚的朋友可以去看我上一篇關於TCP的內容講解,web知識梳理。而UDP則正好相反,它只負責有什麼訊息我就傳過去,不負責安全,不負責有沒有到達,不負責交付順序,這裡從底層來看是滿足WebRTC的需求的,所以WebRTC是採用UDP來當它的傳輸層協議的。

當然這裡UDP只是作為傳輸層的基礎,想要真正的達到WebRTC的要求,我們就要來分析在傳輸層之上,WebRTC做了哪些操作,用了哪些協議,來達到WebRTC的要求了。

RTCPeerConnection通道

RTCPeerConnection代表一個由本地計算機到遠端的WebRTC連線。 該介面提供了建立,保持,監控,關閉連線的方法的實現,簡而言之就是代表了端到端之間的一條通道。api呼叫上面程式碼裡已經看到了,接下來我們就一點點的來剖析這條通道都用了哪些協議。

P2P內網穿透

上面我們也提到了UDP其實只是在IP層的基礎上做了一些簡單封裝而已。而WebRTC如果要實現端到端的通訊效果的話,必定要面臨端到端之間很多層防火牆,NAT裝置阻隔這些一系列的問題。之前我試過寫了原生的webrtc 發現只要不在同一段區域網下面,經常會出現掉線連不上的情況。相同的道理這裡就需要做 NAT 穿透處理了。

NAT穿透是啥,在講NAT穿透之前我們需要先提幾個概念:

  • 公有IP地址是在Internet上全域性唯一的IP地址,僅有一個裝置可能擁有公有IP地址。
  • 私有IP地址是非全域性的唯一的IP地址,可能同時存在於很多不同的裝置上。私有IP地址永遠不會直接連線到internet。那私有IP地址的裝置如何訪問網路呢?

就是這個NAT(NetWork Address Translation),它允許單個裝置(比如路由器)充當Internet(公有IP)和專有網路(私有IP)之間的代理。所以我們就可以通過這個NAT來處理很多層防火牆後那個裝置是私有IP的問題。

路通了,那麼就有另一個問題了,兩個WebRTC客戶端之間,大概率會存在A不知道B的可以直接傳送到的IP地址和埠,B也不知道A的,那麼又該如何通訊呢?

這就要說到ICE了,也就是互動式連線建立。ICE允許WebRTC克服顯示網路複雜性的框架,找到連線同伴的最佳途徑並連線起來。

在大多數的情況下,ICE將會使用STUN伺服器,其實使用的是在STUN伺服器上執行的STUN協議,它允許客戶端發現他們的公共IP地址以及他們所支援的NAT型別,所以理所當然STUN伺服器必須架設在公網上。在大多數情況下,STUN伺服器僅在連線設定期間使用,並且一旦建立該會話,媒體將直接在客戶端之間流動。

具體過程讓我們來看圖會更清楚,WebRTC兩個端各自有一個STUN伺服器,通過STUN伺服器來設定連線,一旦建立連線會話,媒體資料就可以直接在兩個端之間流動。

在這裡插入圖片描述

剛才也說了大多數的情況,如果發生STUN伺服器無法建立連線的情況的話,ICE將會使用TURN中繼伺服器,TURN是STUN的擴充套件,它允許媒體遍歷NAT,而不會執行STUN流量所需的“一致打孔”,TURN伺服器實際上在WebRTC對等體之間中繼媒體,所以我這裡理解的話使用TURN就很難被稱為端對端之間通訊了。 同樣,我們畫個圖來形容TURN中繼伺服器的資料流動方式:

在這裡插入圖片描述
TURN 是在任何網路中為兩端提供連線的最可靠方式,但現實情況下運維 TURN 伺服器的投入也很大。因此,最好在其他直連手段都失敗的情況下,再使用 TURN。 所以一般情況下每個WebRTC解決方案都會準備好支援這兩種服務型別,並設計為處理TURN伺服器上的處理要求。

媒體協議

WebRTC 以完全託管的形式提供媒體獲取和交付服務:從攝像頭到網路,再從網路到螢幕。 從上面的Android demo中我們可以看到,我們除了一開始制定媒體流的約束以外,編碼優化、處理丟包、網路抖動、錯誤恢復、流量、控制等等操作我們都沒做,都是WebRTC自己來控制的。這裡WebRTC 是怎麼優化和調整媒體流的品質的呢? 其實WebRTC 只是重用了 VoIP 電話使用的傳輸 協議、通訊閘道器和各種商業或開源的通訊服務:

  • 安全實時傳輸協議(SRTP,Secure Real-time Transport Protocol) 通過 IP 網路交付音訊和視訊等實時資料的標準安全格式。

  • 安全實時控制傳輸協議(SRTCP,Secure Real-time Control Transport Protocol) 通過 SRTP 流交付傳送和接收方統計及控制資訊的安全控制協議。

資料協議

我們都知道UDP是不安全的,但是WebRTC要求所有傳輸的資料(音訊、視訊和自定義應用資料)都必須加密,所以這裡就要引入一個DTLS協議的概念。

DTLS說白了,其實就是因為TLS無法保證UDP上傳輸的資料的安全,所以在現存的TLS協議架構上提出了擴充套件,用來支援UDP。其實就是TLS的一個支援資料包傳輸的版本。

既然知道了DTLS可以說是TLS的擴充套件版以後,我們再來看看dtls解決了哪些問題。首先先來看TLS的問題,剛才我們也提到了TLS不能直接用於資料包環境,主要的原因是包可能會出現丟失或者重排序的情況,而TLS無法處理這種不可靠性,而無法處理這種不可靠性就帶來了兩個問題:

  1. TLS無法對某個記錄單獨解密,什麼意思呢?如果A和B之間傳遞訊息,訊息以1到10依次為序列號,如果訊息5沒收到,那麼6以後的訊息都會報錯,這裡無法通過TLS的完整性校驗,而且如果順序不對,也無法通過TLS的完整性校驗。
  2. TLS握手層假定握手訊息是可靠投遞的,如果訊息丟失則會中斷。

那麼DTLS是如何在儘可能與TLS相同的情況下解決以上兩個問題的呢?

首先在DTLS中,每個握手訊息都會在握手的時候分配一個序列號和分段偏移欄位,當收訊息的那一方收到一個握手訊息的時候,會根據這個序列號來判斷是否是期望的下一個訊息,如果不是則放入佇列中,這樣就滿足了有序交付的條件,如果順序不對就報錯,跟TLS一樣。而分段偏移欄位是為了補償UDP報文的1500位元組大小限制問題。

至於丟包問題,DTLS採用了兩端都使用一個簡單的重傳計時器的方法,還是上面序列號為1到10的例子,如果A發給B一個序列號為5的訊息,然後希望從B那裡獲取到序列號為6的訊息,但是沒收到,超時了,A就知道他發的5或者B給的6這個訊息丟失了,然後就會重新傳送一個重傳包,也就是5這個訊息。

為保證過程完整,A和B兩端都要生成自已簽名的證照,而WebRTC會自動為每一端生成自已簽名的證照,然後按照常規的 TLS 握手協議走。

DataChannel

除了傳輸音訊和視訊資料,WebRTC 還支援通過 DataChannel API 在端到端之間傳 輸任意應用資料。DataChannel 依賴於 SCTP(Stream Control Transmission Protocol,流控制傳輸協議),而 SCTP 在兩端之間建立的 DTLS 通道之上執行的。

DataChannel api呼叫和WebSocket類似,上面的Android專案中我們已經講過了,接下來我們來詳細講下DataChannel所依賴的SCTP協議。

SCTP

SCTP同時具備了TCP和UDP中最好的功能:面向訊息的 API、可配置的可靠性及交付語義,而且內建流量和擁塞控制機制。

因為本身UDP相對TCP來說比較簡單,之前也提到UDP只是對IP層的一個簡單封裝而已,所以這裡我們就通過比較TCP和SCTP的區別來簡單的講講SCTP到底是什麼東西和為什麼具備TCP和UDP兩者最好的功能。

  1. TCP是單流有序傳輸,SCTP是多流可配置傳輸 上一篇web知識梳理中我們也講過,TCP在一條連線中可以複用TCP連線,是單流的,而且是有順序的,如果一條訊息出了問題的話,後面的所有訊息都會出現阻塞的情況,強調順序。而SCTP可以區分多條不同的流,不同的流之間傳輸資料互不干擾,在有序和無序的問題上,SCTP是可配置的,又可以像TCP那樣交付次序可序化,也可以像UDP那樣亂序交付。

  2. TCP是單路徑傳輸,SCTP是多路徑傳輸 SCTP兩端之間的連線可以繫結多條IP,只要有一條連線是通的,那麼就是通的,熟悉TCP的朋友應該都知道,TCP之間只能用一個IP來連線。

  3. TCP連線建立是三次握手,SCTP則需要四次握手 上一篇web知識梳理中我們也已經講過TCP的三次握手了,SCTP的四次握手比TCP多了一個步驟:server端在收到連線請求時,不會像TCP三次握手那樣子收到請求訊息以後立馬分配記憶體,將其快取起來,而是返回一個COOKIE訊息。 client端需要回送這個COOKIE,server端對這個COOKIE進行校驗以後,從cookie中重新獲取有效資訊(比如對端地址列表),兩端之間才會連線成功。

  4. TCP以位元組為單位傳輸,SCTP以資料塊為單位傳輸 塊是SCTP 分組中的最小通訊單位,核心概念與HTTP 2.0分幀層中的那些概念基本一樣,沒看過的朋友可以參考web知識梳理

ok,到這裡基本把WebRTC通訊協議的應用,以及要用到的協議啊概念啊什麼的都過了一遍,要實現低延遲的,端到端的通訊傳輸不是一件容易的事情。相信隨著WebRTC不斷的完善,支援的端也會越來越多,效能也會越來越完善。

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

相關文章