webRTC_Quick_Start

该接触到webrtc,是因为某次搜索腾讯视频直播软件的技术实现,看到早年有人试图用webrtc开发机遇浏览器的多人视频聊天室。(当然了解后才发现视频直播和基于社交的单人视频聊天基本完全是不同的解决方案)再后来由于老板要求,需要探索下基于Hololens设备的远程协助的解决方案,这里涉及到了实现异地远程端对端的视频和其他数据传输。国内hololens开发还在依靠文档的阶段,所以思路并不是很多,最后还是谷歌给出了可靠的实现思路。奈何对webrtc了解太少,所以先撸了遍webrtc的一些代码,实现了基于浏览器的视多人协同室。在web开发方面只是菜鸟,所以这篇文章主要是以codelab的上手教程为主线,梳理下webrtc主要的实现思路和流程。之后还会有专门的文章来贴出基于hololens实现的协同解决方案和关键实现步骤。
BTW,现在一般关于webrtc的开发基本在这两个方向上,一个是基于浏览器的web应用开发,一个是基于c的一些底层开发(webrtc是开源项目)。

简介

web realtime communication由一组标准、协议和javascript api组成,用于实现浏览器之间(端到端)的音频、视频和数据共享。webrtc使得实时通信变成一种标准功能,,任何web应用都无需借助第三方插件和专有软件,通过简单地javascript API即可完成。
webrtc三个最重要的接口

MediaStream 获取音频和视频流
RTCPeerConnection 音频和视频数据通信
RTCDataChannel其他数据通信

背景知识

本文实现的案例和所有内容都是基于Chrome的,所以需要了解一些网页前端开发的内容,包括但不仅限于:
HTML
JavaScript
node.js
socket.io
后期实现还会用到.net相关的开发

支持平台

PC,Chrome,Firefox,Opera
Android
iOS

javascript支持

4个主要的API:getUserMedia,MediaRecorder,RTCPeerConnection,RTCDataChannel。

几个概念

[1]signaling 信令
webRTC利用RTCPeerConnection在浏览器之间传输流数据,但是还需要一种机制来定位通信、发送受控制的消息,这就是信令。对于webrtc来说并没有统一标准的信令方式,通常来讲利用socket/TCP/UDP/websocket理论上都是可行的。具体取决于使用代价。在本文实现的例子中,使用的是socket.io,简单来说是封装了TCP协议的一种web接口,通过http建立会话然后底层数据通过更高效的TCP实现。

一篇介绍socketio的文章

https://www.jianshu.com/p/4e80b931cdea

[2]STUN和TURN
webrtc设计之初是一种p2p的工作方式,用户直连。但是要和现实网络协同工作的话,客户端必须穿越NAT网关和防火墙,而且p2p网络在直连失败时需要反馈机制。作为webrtc过程中的一部分,webrtc api接口利用STUN服务器来得到客户端PC的IP地址,用TURN做为连接时的中继服务器(relay server)。详细可以阅读https://www.html5rocks.com/en/tutorials/webrtc/infrastructure/
(有必要以后会专门翻译)
[3]安全性
所有webrtc组件都必须加密,js api只能够被安全的源代码调用,比如https和localhost。通信方面,webrtc没有定义,需要在使用具体通信协议时自己注意。

环境安装

源码获取:
git clone https://github.com/googlecodelabs/webrtc-web
关于web服务器,你可以使用自己的服务器,也可以使用很方便的一个chrome服务器插件,web server for chrome。其实就是一个简单的web服务器,可以利用NODEJS自己搭建。默认的work跟设置index是一回事。
如图001 所示,设置工作路径为上一步源码的work路径,默认端口为local8887,确保勾选options中的automatically show index.html。通过开关来关闭和重启webserver。
设置完成画面如001
Markdown

获取视频流

章节完整代码全部在step-01目录
index.html:添加element元素,并且关联一个script元素main.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
'use strict';
// 例子中只获取视频数据
const mediaStreamConstraints = {
video: true,
};
// 视频流将会被赋给video元素
const localVideo = document.querySelector('video');
// 在video上产生的本地视频流
let localStream;
// 如果成功把MediaStream添加给video元素
function gotLocalMediaStream(mediaStream) {
localStream = mediaStream;
localVideo.srcObject = mediaStream;
}
// 如果错误打印错误信息
function handleLocalMediaStreamError(error) {
console.log('navigator.getUserMedia error: ', error);
}
// 初始化视频流
navigator.mediaDevices.getUserMedia(mediaStreamConstraints)
.then(gotLocalMediaStream).catch(handleLocalMediaStreamError);

打开本地服务器端口,应该可以看到003画面。
Markdown

代码解释:
getUserMedia(),浏览器向用户请求获取相机权限,如果成功的话,返回MediaStream,可以被media 元素通过srcobject属性使用。
如果getUserMedia成功,从网络摄像头得到的视频流就会被传给video元素当作视频源。

通过RTCPeerConnection传输视频流

目标:
1 使用shim
2 用RTCPeerConnection来stream video
3 控制视频数据的获取和传输
4 通过建立webrtc call在peers之间共享媒体数据和网络信息
目标是完成在同一个网页中,在两个RTCPeerConnection对象(peers)中建立连接。
首先html中添加两个video元素以及三个按键:

1
2
3
4
5
6
7
<video id="localVideo" autoplay playsinline></video>
<video id="remoteVideo" autoplay playsinline></video>
<div>
<button id="startButton">Start</button>
<button id="callButton">Call</button>
<button id="hangupButton">Hang Up</button>
</div>

一个显示从getUserMedia拿来的视频流,一个显示通过RTCPeerConnection传输过来的视频流。
然后添加adapter.js.是一个shim,可以将应用程序与规范更改和前缀差异隔离开来。
(一种小型函数库,截取API调用、修改传入参数、最后自行处理对应操作或者转交给其他地方执行。可以用来做新老api 的适配,尤其在一些并没有针对平台开的的程序。)
然后应该得到下面正确的画面:004
Markdown
代码解释:
webrtc利用rtcPC API建立链接,在webrtc客户(peers)之间传输视频流。该例子中,两个RTCPC对象pc1 pc2在同一个网页中。建立这个来凝结需要三个任务:

  • 给每一个端创建RTCPC,而且在每一个端,从getUserMedia添加本地视频流
  • 获取并且共享网络信息:潜在的连接端点称为ICE候选者
  • 获取并且共享本地和远程的描述:有关SDP格式的本地媒体的元数据
    设想Alice和Bob使用RTCPC来建立一个视频对话:

    首先

    Alice和Bob交换网络信息。‘finding expression’指的是利用ICE framework找到网络接口和端口的过程。
    1 Alice创建一个RTCPC对象,带有onicecandidate (addEventListener(‘icecandidate’))handler,这个同main.js里的下列代码协同:
    1
    2
    3
    4
    5
    let localPeerConnection;
    localPeerConnection = new RTCPeerConnection(servers);
    localPeerConnection.addEventListener('icecandidate', handleConnection);
    localPeerConnection.addEventListener(
    'iceconnectionstatechange', handleConnectionChange);

servers 参数在本例中并没有被用到,这里应该是你配置STUN和TURN服务器的地方。
webrtc是p2p连接,所以在大多数直连路由下用户可以直接连接。但是,webrtc需要跟现实网络匹配,客户端需要穿过NAT网关和防火墙,而且一旦直连失败的话需要反馈(上面提到)。
这个过程中的部分处理是:webrtc利用STUN服务器来得到你的电脑的IP,利用TURN服务器来当作中继服务器一旦p2p通信失败。更多参考https://www.html5rocks.com/en/tutorials/webrtc/infrastructure/
2 Alice调用getUserMedia,添加传给他的stream:

1
2
3
4
5
6
7
8
9
10
11
navigator.mediaDevices.getUserMedia(mediaStreamConstraints).
then(gotLocalMediaStream).
catch(handleLocalMediaStreamError);
function gotLocalMediaStream(mediaStream) {
localVideo.srcObject = mediaStream;
localStream = mediaStream;
trace('Received local stream.');
callButton.disabled = false; // Enable call button.
}
localPeerConnection.addStream(localStream);
trace('Added local stream to localPeerConnection.');

3 当网络连接候选人可用时,步骤1中的onicecandidate 处理被调用
4 Alice把序列化的候选人数据传输给Bob。在真实使用环境中,这个过程,signaling过程,通过消息服务实现(随后说)。在这个例子中,两个RTCPC对象在同一网页中,所需不需要任何外部的消息收发。
5 当Bob从Alice那里得到一个参与者消息时,会调用addIceCandidate(),将参与者添加给远程peer的描述。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function handleConnection(event) 
{
const peerConnection = event.target;
const iceCandidate = event.candidate;
if (iceCandidate) {
const newIceCandidate = new RTCIceCandidate(iceCandidate);
const otherPeer = getOtherPeer(peerConnection);
otherPeer.addIceCandidate(newIceCandidate)
.then(() => {
handleConnectionSuccess(peerConnection);
}).catch((error) => {
handleConnectionFailure(peerConnection, error);
});
trace(`${getPeerName(peerConnection)} ICE candidate:\n` +
`${event.candidate.candidate}.`);
}
}

其次

webrtc peers也需要寻找并且交换本地和远程的音视频信息,比如分辨率以及编解码器功能。这其中有个过程,叫offer和answer,使用SDP协议格式,通过交换元数据的块,实现媒体配置信息的交换。
1 Alice进行 RTCPC createOffer 方法。返回的约定信息提供了一个RTCSessionDescription:Alice的本地会话描述:

1
2
3
trace('localPeerConnection createOffer start.');
localPeerConnection.createOffer(offerOptions)
.then(createdOffer).catch(setSessionDescriptionError);

2 如果成功,Alice利用setLocalDescription()设置本地描述,然后把这个会话描述通过signaling channel传给Bob
3 Bob把Alice发送过来的信息用setRemoteDescription设置成remote description
4 Bob调用createAnswer(),传入从Alice得到的remote decription,这样一个同Alice匹配的本地会话被建立。createAnswer约定传递RTCSessionDescription:Bob将其设置为本地描述并且发送给Alice
5 当Alice接收到Bob的会话描述,Alice把用setRemoteDescription它设置为远程描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Logs offer creation and sets peer connection session descriptions.
function createdOffer(description) {
trace('Offer from localPeerConnection:\n${description.sdp}');
trace('localPeerConnection setLocalDescription start.');
localPeerConnection.setLocalDescription(description)
.then(() => {
setLocalDescriptionSuccess(localPeerConnection);
}).catch(setSessionDescriptionError);
trace('remotePeerConnection setRemoteDescription start.');
remotePeerConnection.setRemoteDescription(description)
.then(() => {
setRemoteDescriptionSuccess(remotePeerConnection);
}).catch(setSessionDescriptionError);
trace('remotePeerConnection createAnswer start.');
remotePeerConnection.createAnswer()
.then(createdAnswer)
.catch(setSessionDescriptionError);
}
// Logs answer to offer creation and sets peer connection session descriptions.
function createdAnswer(description) {
trace('Answer from remotePeerConnection:\n${description.sdp}.');
trace('remotePeerConnection setLocalDescription start.');
remotePeerConnection.setLocalDescription(description)
.then(() => {
setLocalDescriptionSuccess(remotePeerConnection);
}).catch(setSessionDescriptionError);
trace('localPeerConnection setRemoteDescription start.');
localPeerConnection.setRemoteDescription(description)
.then(() => {
setRemoteDescriptionSuccess(localPeerConnection);
}).catch(setSessionDescriptionError);
}

6 ping
流程图见005
Markdown

使用RTCDataChannel交换数据

index.html
添加两个textarea,然后通过交互控制发送消息,三个button

1
2
3
4
5
6
7
8
<textarea id="dataChannelSend" disabled
placeholder="Press Start, enter some text, then press Send."></textarea>
<textarea id="dataChannelReceive" disabled></textarea>
<div id="buttons">
<button id="startButton">Start</button>
<button id="sendButton">Send</button>
<button id="closeButton">Stop</button>
</div>

代码解释
利用RTCPeerConnection和RTCDataChannel交换消息,基本同RTCPeerConnection例子中的流程一致,但是多了两个处理函数:
sendData() 和creatteConnection()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
 function createConnection() {
dataChannelSend.placeholder = '';
var servers = null;
pcConstraint = null;
dataConstraint = null;
trace('Using SCTP based data channels');
// For SCTP, reliable and ordered delivery is true by default.
// Add localConnection to global scope to make it visible
// from the browser console.
window.localConnection = localConnection =
new RTCPeerConnection(servers, pcConstraint);
trace('Created local peer connection object localConnection');
sendChannel = localConnection.createDataChannel('sendDataChannel',
dataConstraint);
trace('Created send data channel');
localConnection.onicecandidate = iceCallback1;
sendChannel.onopen = onSendChannelStateChange;
sendChannel.onclose = onSendChannelStateChange;
// Add remoteConnection to global scope to make it visible
// from the browser console.
window.remoteConnection = remoteConnection =
new RTCPeerConnection(servers, pcConstraint);
trace('Created remote peer connection object remoteConnection');
remoteConnection.onicecandidate = iceCallback2;
remoteConnection.ondatachannel = receiveChannelCallback;
localConnection.createOffer().then(
gotDescription1,
onCreateSessionDescriptionError
);
startButton.disabled = true;
closeButton.disabled = false;
}
function sendData() {
var data = dataChannelSend.value;
sendChannel.send(data);
trace('Sent Data: ' + data);
}

RTCDataChannel类似于websocket,有一个send方法以及message事件。
dataConstraint,数据channel可以配置成不同类型数据的共享,比如可靠性优先与性能。详细这里不赘述,可以参考[Mozilla Developer Network][https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/createDataChannel]

发散思考
1 使用webrtc数据通道默认的SCTP协议,数据传输是可靠的。需要考虑什么时候性能优先,什么时候可靠性优先?
2 使用css来提升网页的布局和美观
3 移动设备上测试网页

建立信道传输消息

目标

1 使用npm安装package.json描述的依赖项
2 启用Node.js服务器,利用node-static 服务静态文件
3 使用socket.io在nodejs上建立消息服务器
4 基于此创建聊天室交换消息

内容

为了建立和维护一个webrtc call,clients或者说peers需要交换元数据:
Candidate(network)信息
提供媒体(比如分辨率和编码)信息的Offer和 Answer消息
也就是说,在发送音视频流和数据之前,metadata数据需要首先进行交换。这个过程就叫做signaling。
之前的例子中,发送和接收的RTCPC对象在同一网页中,所以所谓的singnaling就是在两个对象中传递元数据。
在真实的webrtc应用中,这两玩意在不同的设备和网页上,所以需要传输元数据的方法。
这样的话,你就需要使用一个信令服务器signaling server,一个可以在peers之间传输消息的服务器。实际上这些消息就是text,字符串话的javascript对象。

准备 安装nodejs

在localhost运行一个nodejs服务器。
命令行:node index.js 来启用服务器

应用

webrtc使用一个客户端的javascript API,但是针对真实情况,需要一个信令服务器以及STUN TURN服务器。
接下来我们使用socketio nodejs模块和js库来传输消息r,来建立一个简单的信令服务器。
下面例子中,the server也就是nodejs application在index.js中实现,跑在它(thewebapp)上的客户端在index.html上实现

nodejs应用有两个任务

1 它作为一个消息中继

1
2
3
4
socket.on('message', function (message) {
log('Got message: ', message);
socket.broadcast.emit('message', message);
});

2 管理webrtc视频聊天的“室”

1
2
3
4
5
6
7
8
9
10
if (numClients === 0) {
socket.join(room);
socket.emit('created', room, socket.id);
} else if (numClients === 1) {
socket.join(room);
socket.emit('joined', room, socket.id);
io.sockets.in(room).emit('ready');
} else { // max two clients
socket.emit('full', room);
}

以上设置房间最多支持两人通话。

HTML&JS

注意需要看控制台输出的话,Chrome-Ctrl-Shift-J, 或者Command-Option-J,如果是mac。
HTML:



main.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
'use strict';
var isInitiator;
window.room = prompt("Enter room name:");
var socket = io.connect();
if (room !== "") {
console.log('Message from client: Asking to join room ' + room);
socket.emit('create or join', room);
}
socket.on('created', function(room, clientId) {
isInitiator = true;
});
socket.on('full', function(room) {
console.log('Message from client: Room ' + room + ' is full :^(');
});
socket.on('ipaddr', function(ipaddr) {
console.log('Message from client: Server IP address is ' + ipaddr);
});
socket.on('joined', function(room, clientId) {
isInitiator = false;
});
socket.on('log', function(array) {
console.log.apply(console, array);
});

设置运行在Nodejs上的socketio

创建一个package.json在根目录:
{
“name”: “webrtc-codelab”,
“version”: “0.0.1”,
“description”: “WebRTC codelab”,
“dependencies”: {
​ “node-static”: “^0.7.10”,
​ “socket.io”: “^1.2.0”
}
}
这是告诉NPM(Node Package Manager)需要安装依赖项的配置文件。
运行 npm install安装相应的包。
根目录(不是js目录)创建index.js文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
'use strict';
var os = require('os');
var nodeStatic = require('node-static');
var http = require('http');
var socketIO = require('socket.io');
var fileServer = new(nodeStatic.Server)();
var app = http.createServer(function(req, res) {
fileServer.serve(req, res);
}).listen(8080);
var io = socketIO.listen(app);
io.sockets.on('connection', function(socket)
{
// convenience function to log server messages on the client
function log() {
var array = ['Message from server:'];
array.push.apply(array, arguments);
socket.emit('log', array);
}
socket.on('message', function(message) {
log('Client said: ', message);
// for a real app, would be room-only (not broadcast)
socket.broadcast.emit('message', message);
});
socket.on('create or join', function(room) {
log('Received request to create or join room ' + room);
var clientsInRoom = io.sockets.adapter.rooms[room];
var numClients = clientsInRoom ? Object.keys(clientsInRoom.sockets).length : 0;
log('Room ' + room + ' now has ' + numClients + ' client(s)');
if (numClients === 0) {
socket.join(room);
log('Client ID ' + socket.id + ' created room ' + room);
socket.emit('created', room, socket.id);
} else if (numClients === 1) {
log('Client ID ' + socket.id + ' joined room ' + room);
io.sockets.in(room).emit('join', room);
socket.join(room);
socket.emit('joined', room, socket.id);
io.sockets.in(room).emit('ready');
} else { // max two clients
socket.emit('full', room);
}
});
socket.on('ipaddr', function() {
var ifaces = os.networkInterfaces();
for (var dev in ifaces) {
ifaces[dev].forEach(function(details) {
if (details.family === 'IPv4' && details.address !== '127.0.0.1') {
socket.emit('ipaddr', details.address);
}
});
}
});
});

运行node index.js,并在浏览器打开localhost8080

思考

1 可以选择其他的消息机制?
2 处理大量同时的房间加入请求时应该怎么做?
3 尝试直接通过URL获取房间名字

结合peer连接和signaling

目标

1 使用在nodejs上运行的socketio搞定一个webrtc signaling服务
2 使用上述服务在peer之间交换webrtc元数据

HTML&JS

HTML:

1
2
3
4
5
6
7
8
<div id="videos">
<video id="localVideo" autoplay muted></video>
<video id="remoteVideo" autoplay></video>
</div>

<script src="/socket.io/socket.io.js"></script>
<script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
<script src="js/main.js"></script>

运行nodejs服务器

node index.js

思考

多人聊天、灵活输入房间名、分享房间名字、

拍照并发送

目标

1 使用canvas元素拍照以及显示
2 在远程用户之间交换数据

解释

实现怎么共享整个数据文件:通过getUserMedia拍照,主要有两步:
1 建立一个数据通道。注意这步里你没有向peer connection增减任何媒体流
2 用getUserMedia捕获摄像头的视频流

1
2
3
4
5
6
7
8
9
10
11
var video = document.getElementById('video');
function grabWebCamVideo() {
console.log('Getting user media (video) ...');
navigator.mediaDevices.getUserMedia({
video: true
})
.then(gotStream)
.catch(function(e) {
alert('getUserMedia() error: ' + e.name);
});
}

3 用户点击 Snap 按钮,从视频流里得到一帧画面并且在 canvas 元素中显示:

1
2
3
4
5
6
var photo = document.getElementById('photo');
var photoContext = photo.getContext('2d');
function snapPhoto() {
photoContext.drawImage(video, 0, 0, photo.width, photo.height);
show(photo, sendBtn);
}

4 用户点击 Send 按钮,将图像转换成字节流通过数据通道发送:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function sendPhoto() {
// Split data channel message in chunks of this byte length.
var CHUNK_LEN = 64000;
var img = photoContext.getImageData(0, 0, photoContextW, photoContextH),
len = img.data.byteLength,
n = len / CHUNK_LEN | 0;
console.log('Sending a total of ' + len + ' byte(s)');
dataChannel.send(len);
// split the photo and send in chunks of about 64KB
for (var i = 0; i < n; i++) {
var start = i * CHUNK_LEN,
end = (i + 1) * CHUNK_LEN;
console.log(start + ' - ' + (end - 1));
dataChannel.send(img.data.subarray(start, end));
}
// send the reminder, if any
if (len % CHUNK_LEN) {
console.log('last ' + len % CHUNK_LEN + ' byte(s)');
dataChannel.send(img.data.subarray(n * CHUNK_LEN));
}
}

5 接收端将字节流转换成图像并且给用户显示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function receiveDataChromeFactory() {
var buf, count;
return function onmessage(event) {
if (typeof event.data === 'string') {
buf = window.buf = new Uint8ClampedArray(parseInt(event.data));
count = 0;
console.log('Expecting a total of ' + buf.byteLength + ' bytes');
return;
}
var data = new Uint8ClampedArray(event.data);
buf.set(data, count);
count += data.byteLength;
console.log('count: ' + count);
if (count === buf.byteLength) {
// we're done: all data chunks have been received
console.log('Done. Rendering photo.');
renderPhoto(buf);
}
};
}
function renderPhoto(data) {
var canvas = document.createElement('canvas');
canvas.width = photoContextW;
canvas.height = photoContextH;
canvas.classList.add('incomingPhoto');
// trail is the element holding the incoming images
trail.insertBefore(canvas, trail.firstChild);
var context = canvas.getContext('2d');
var img = context.createImageData(photoContextW, photoContextH);
img.data.set(data);
context.putImageData(img, 0, 0);
}

正确的结果应该如图[006]
Markdown

思考

传输任意格式的文件?

参考链接

上手的主要API使用基本都结束了,基本内容都是翻译官方文档过来的。可以基本了解流程和API使用。推荐可以直接去看[apprtc][https://github.com/webrtc/apprtc] 的源码了。
1 webrtc wiki https://en.wikipedia.org/wiki/WebRTC
2 官网 https://webrtc.org/
3 html5webrtc学习教程 https://www.html5rocks.com/en/tutorials/webrtc/basics/
4 webrtc [Getting Started][https://webrtc.org/start/]