WebRTC实时通信协议详解 | 掘金技术征文

9,106 阅读13分钟

这一篇我们来讲一下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 使用体验征文大赛 | 掘金技术征文,征文活动正在进行中