一起来学习 WebRTC (篇二) | 掘金技术征文

2,163 阅读11分钟

书接上回

上一篇中我们简单的介绍了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的新版本,必须要用安全连接才能打开媒体设备。

或者PC与手机通讯

pc

大成功!!

总结

到这一篇为止,我们已经基本了解了WebRTC的架构和用法,并且实现了不同平台间的P2P通讯。遗憾的是,现在这个Demo仅仅能在局域网内运作。对于真实的的世界,有各种复杂的网络配置,还有防火墙。下一篇,我们来了解下在互联网中,我们怎么通过STUNTURN来实现WebRTC吧。

谢谢各位的阅读。

代码和参考文档

Agora SDK 使用体验征文大赛 | 掘金技术征文,征文活动正在进行中