web

浏览器API和协议

无线通信

Posted by Lorry on December 30, 2018

文章字数:46605, 阅读全文大约需要:133 分钟

浏览器网络概述

现代浏览器不仅仅是一个简单的套接字管理器. 从表明看是一套资源获取机制, 但实质也有自己的平台, 由自己的优化标准, API和服务, 如上图所示

连接管理和优化

  • Web应用不会维护单个网络套接字的生命周期,而是委托给浏览器
  • 自动化关键性能优化
    • 套接字重用
    • 请求优先级
    • late binding晚绑定
    • 协议协商
    • 强制连接限制
  • 请求生命周期管理套接字管理区分

套接字池, 由源分组得到, 每个吃由自己的连接限制和安全约束. 延迟的请求正在排队, 排优先级, 然后绑定到池中单独的套接字上.除非服务端故意关闭连接, 同一个socket可以自动对多个请求重用

    • 应用协议
    • domain name
    • port number
    • (http, www.example.com, 80)
  • Socket pool
    • 一组属于同一个origin的sockets
    • 常用网络最大pool 为6个套接字 套接字池管理可以自动重用TCP连接.还有以下的优化:
  • 按照请求优先级排队
  • 重用socket最小化延迟和提升吞吐量
  • 预先打开socket去参与请求
  • 优化和是关闭空闲sockets
  • 优化所有sockets分配的带宽

网络安全和沙盒

将不信任的应用代码放到沙盒中运行, 保障安全性, 比如浏览器不允许直接的API连接原生网络套接字

  • 连接限制
    • 浏览器管理所有打开的socket池
    • 规定连接数量
  • 请求格式化和响应处理
    • 格式化所有外发的请求保证格式一致, 符合协议的语义, 保护服务端
    • 响应也会处理, 保护客户端
  • TLS协商
    • 浏览器进行TLS握手和必要的证书检查
    • 验证失败用户会得到警告, 比如服务端的自签名证书
  • 同源策略
    • 浏览器强制应用请求必须是发至某个origin

      资源和客户端缓存

      最快的请求就是不请求.缓存就是让浏览器不发请求而直接从缓存中获取资源.

  • 浏览器评估每个资源的缓存指令(Cache-Control, Etag, Las-Modified)
  • 自动再验证过期资源
  • 自动管理缓存大小和资源回收
  • 提供会话验证和cookie管理
    • 一个会话认证可以在多个标签和窗口中共享
    • 一个窗口或标签也可以共享多个会话认证
    • 一旦用户注销之后, 其他打开窗口的会话都会失效

应用API和协议

没有一个最好的协议或API, 各有所长

  XMLHttpRequest Server-Sent Events WebSocket
Request streaming no no yes  
Response streaming limited yes yes  
Framing mechanism HTTP event stream binary framing  
Binary data transfers yes no (base64) yes  
Compression yes yes limited  
Application transport protocol HTTP HTTP WebSocket  
Network transport protocol TCP TCP TCP  

故意忽略了WebRTC, 因为他是P2P的传输模型

XMLHttpRequest

XHR是浏览器级别的API, 可以允许客户端使用js传输数据. 它是AJAX背后的关键技术.

  • 浏览器负责管理连接的建立, pooling和termination
  • 浏览器决定最好的HTTP(S)传输(HTTP/1.x,2)
  • 浏览及处理HTTP缓存, 重定向和内容类型协商
  • 浏览器强制安全, 授权和隐私限制

XHR的历史

最早只是浏览器的自我实现, 2006年W3C才颁布了XHR标准, 2008年又颁布了XHR level2, 包含的新特性有:

  • 支持请求超时
  • 支持二进制和基于文本的数据传输
  • 支持应用覆盖媒体类型和响应编码
  • 支持每个请求的进程
  • 支持高效的文件上传
  • 支持安全的跨域请求 2011年, XML level2标准也合并到最初的XHR标准里了.

    跨域请求CORS

    应用提供数据和URL, 浏览器格式化请求并处理每个连接的整个生命周期, 应用可以自定义请求头字段(setRequestHeader()), 也有下列几个保留请求头

  • Accept-Charset, Accept-Encoding, Access-Control-*
  • Host, Upgrade, Connection, Referer, Origin
  • Cookie, Sec-, Proxy- 保护Origin头至关重要, 因为直接影响同源策略, 为什么要有同源? 浏览器储存了很多用户数据, 比如鉴权的token, cookies,和其他隐私数据, 如果thirdparty.com可以被加载, 那么它也可以访问到origin.com的个人数据, 是十分危险的.

那如果非要从不同的源或去资源呢? CORS就来了

// script origin: (http, example.com, 80)
var xhr = new XMLHttpRequest();
// same origin request
xhr.open('GET', '/resource.js'); 
xhr.onload = function() { ... };
xhr.send();

var cors_xhr = new XMLHttpRequest();
// cross origin request
cors_xhr.open('GET', 'http://thirdparty.com/resource.js'); 
cors_xhr.onload = function() { ... };
cors_xhr.send();

同域和跨域在API层看来是只有url的区分的

以下是跨域的请求报文, Origin是浏览器自动设置的, Access-Control-Allow-Origin是可选的服务器设置头, 可见thirdparty.com是允许example origin访问的, 如果想拒绝访问, 那么忽略这个属性即可.如果设置为 * 则是表示任意origin都可访问, 设置时请三思

=> Request
GET /resource.js HTTP/1.1
Host: thirdparty.com
Origin: http://example.com 
...

<= Response
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://example.com

CORS提供了额外的安全预警保证服务器可以知道CORS

  • CORS请求忽略用户验证比如cookies和HTTP鉴权
  • 客户端被限制在简单的跨域请求, 指能使用GET, POST HEAD方法, 并且HTTP头可以被发送端和接收端都获取到

为了可以使用cookie和HTTP鉴权, 客户端必须设置一个额外的属性 withCredentials, 服务端也必须响应合适的头(Access-Control-Allow-Credentials)编码知道允许应用包含用户隐私数据. 类似的, 如果像自定义不同的方法, 可以使用Access-Control-Allow-Headers: My-Custom-Header进行预请求

=> Preflight request

OPTIONS /resource.js HTTP/1.1 
Host: thirdparty.com
Origin: http://example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: My-Custom-Header
...

<= Preflight response
HTTP/1.1 200 OK 
Access-Control-Allow-Origin: http://example.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: My-Custom-Header
...

(actual HTTP request) 

使用OPTIONS预请求验证permission third-party origin成功预响应

可以看到, 预请求需要多收发一次数据, 会增加网络延迟, 但是浏览器会缓存, 避免每个请求都进行相同的验证

XHR下载数据

XHR可以传输两类数据

  1. 基于文本
  2. 二进制数据

浏览器可以自动的为各种原生数据类型编码或解码

  • ArrayBuffer
    • 定长二进制数据buffer
  • Blob
    • 不变的数据的二进制大对象
  • Document
    • 实例化的HTML和XML文档
  • JSON
    • 代表简单数据结构的Javascript对象
  • Text
    • 简单文本字符串
var xhr = new XMLHttpRequest();
xhr.open('GET', '/images/photo.webp');
// 定义返回数据类型为blob
xhr.responseType = 'blob'; 

xhr.onload = function() {
  if (this.status == 200) {
    var img = document.createElement('img');
    // 创建唯一对象URI并设置到src中
    img.src = window.URL.createObjectURL(this.response); 
    img.onload = function() {
        // 图片加载之后释放对象URI
        window.URL.revokeObjectURL(this.src); 
    }
    document.body.appendChild(img);
  }
};

xhr.send();

XHR上传数据

上传数据与下载数据只有send方法不一样, 其他的都差不多

var xhr = new XMLHttpRequest();
xhr.open('POST','/upload');
xhr.onload = function() { ... };
// 上传字符串到服务器
xhr.send("text string"); 

// 创建一个动态的表格
var formData = new FormData(); 
formData.append('id', 123456);
formData.append('topic', 'performance');

var xhr = new XMLHttpRequest();
xhr.open('POST', '/upload');
xhr.onload = function() { ... };
// 发送该表格对象给服务器
xhr.send(formData); 

var xhr = new XMLHttpRequest();
xhr.open('POST', '/upload');
xhr.onload = function() { ... };
var uInt8Array = new Uint8Array([1, 2, 3]); 
// 发送二进制文件
xhr.send(uInt8Array.buffer); 

send中的参数支持

  • DOMString
  • Document
  • FormData
  • Blob
  • File
  • ArrayBuffer对象 会根据以上的参数类型自行编码并设置对应的HTTP content-type, 然后发送请求.如果需要用户自行上传文件: 获取对象的引用并传给XHR ```js var blob = ArrayBuffer([1,2,3]);

// 设置每个chunk的大小为1MB const BYTES_PER_CHUNK = 1024*1024; const SIZE = blob.size;

var start = 0; var end = BYTES_PER_CHUNK;

while(start < SIZE) { var xhr = new XMLHttpRequest(); xhr.open(‘POST’, ‘/upload’); xhr.onload = () => console.log(‘xhr load finished’)

// 设置请求头为Content-Range start-end/total
xhr.setRequestHeader('Content-Range', start+'-'+end+'/'+SIZE);
xhr.send(blob.slice(start, end));

start = end;
end = start + BYTES_PER_CHUNK; } ``` 分块的好处是如果其中一块传输失败之后, 不用整个blob重试, 而只需要重试这一个分块即可 ## 监听下载和上传过程 XHR提供了API去监听过程时间, 表明请求当前状态 |Event type	|Description	|Times fired| |---|---|---| |loadstart	|Transfer has begun|	once| |progress	|Transfer is in progress|	zero or more| |error	|Transfer has failed|	zero or once| |abort	|Transfer is terminated|	zero or once| |load	|Transfer is successful	|zero or once| |loadend	|Transfer has finished	|once|

所有的XHR都是以loadstart开头, loadend结尾

var xhr = new XMLHttpRequest();
xhr.open('GET','/resource');
xhr.timeout = 5000; 

// 注册成功请求回调
xhr.addEventListener('load', function() { ... }); 
// 注册失败回调
xhr.addEventListener('error', function() { ... }); 

// progress时间处理函数
var onProgressHandler = function(event) {
  if(event.lengthComputable) {
    var progress = (event.loaded / event.total) * 100; 
    ...
  }
}

// 在XHR上传中注册progress事件
xhr.upload.addEventListener('progress', onProgressHandler); 
// 在XHR下载中注册progress事件
xhr.addEventListener('progress', onProgressHandler); 
xhr.send();

XHR是没有超时的, 意味着”in progress”状态可以持续永久, 最佳实践是在应用中使用有意义的timeout超时处理错误.

XHR的流数据

在官方标准中没有正式的流使用案例, 但是又有使用场景:

  • 在客户端可用时上传数据
  • 在数据到达服务端后下载数据 限制有两点:
    1. send方式在上传过程中需要完整的payload
    2. response, responseText, ResponseXML属性不能赋值给流

现在正在出台相应的流标准, 能绕过的只有下载, 上传还不行

var xhr = new XMLHttpRequest();
xhr.open('GET', '/stream');
xhr.seenBytes = 0;

xhr.onreadystatechange = function() { // 监听状态变化
  if(xhr.readyState > 2) {
    var newData = xhr.responseText.substr(xhr.seenBytes); // 从partial response中解压新的数据
    // process newData

    xhr.seenBytes = xhr.responseText.length; // 更新处理字节的offset 
  }
};

xhr.send();

上述方案可以在大多数浏览器中使用, 但是性能不是很好.

  • 手动追踪seenBytes,并切片, responseText是一个完整的响应, 对小传输来说不是问题, 但是如果是大文件的下载, 特别是对内存限制的移动端,就是问题.释放响应buffer的唯一方法是完成这个请求, 打开另一个
  • partial response只能在responseText属性中获取, 这限制了只能使用text-only传输, 没有办法传输二进制
  • 一旦partial data被读取, 必须验证消息边界, 应用逻辑必须定义自己的数据格式, 然后缓存并实例这个流去解压缩单个消息
  • 浏览器对待接收到的数据缓存有区别, 有的可以立即释放数据, 有的会缓存小响应, 在大的chunk中释放
  • 浏览器对渐进式读取的content-type要求不一, 有的要求text/html, 有的只在application/x-javascript

综上, XHR不适合进行流的传输.

XHR不适合, 还有SSE(server only text-based)和WebSocket(bidirectional text-based&binary)

实时消息提醒和传输

客户端可以发请求更新数据, 但是如果服务端有数据更新需要通知客户端怎么办?

polling轮询

客户端周期性发送请求询问服务端是否有数据,如果服务端有响应则返回, 否则响应空. 这个周期的选择就很重要. 长了无法保证实时性, 短了造成不必要payload

function checkUpdates(url) {
  var xhr = new XMLHttpRequest();
  xhr.open('GET', url);
  xhr.onload = function() { ... }; 
  xhr.send();
}

// 每60s发一次请求
setInterval(function() { checkUpdates('/updates') }, 60000);

轮询适合实践间隔长, 且服务器会周期性发送数据, 传输的数据也大的应用, 假设一个邮件的应用

  • 每60s, 客户端发送一个XHR请求检查更新
  • 每个XHR请求会包含最新的消息ID
  • 服务器比较客户端ID和它自己的消息列表
  • 服务器响应一个新的列表或空列表(无更新)
  • 平均邮件的延时: 30s
  • 轮询的overload: 平均HTTP/1.x的overhead为800字节, 因为客户端是登陆态, 还需要额外的权限cookie和消息ID, 就有了另外50bytes, 所以请求的overhead为850bytes, 如果有10000个客户端, 所有的轮询都是60s(每秒167个请求), 那么overload就为: (850bytes * 8bits * 10000)/60s = 1.13Mbps, 这就是不含任何新消息给客户端的恒定的速率.
  • 是不是interval太长了, delay为30s接收不了?可以, 减小interval会导致更高的吞吐量和overhead.(除数变小了)

长轮询的问题就在于如果没有数据更新会发多余的请求, 那么换个方式, 让每次的请求都保持住, 等到服务器有更新的时候再响应?

这就是长轮询 long-polling,

function checkUpdates(url) {
  var xhr = new XMLHttpRequest();
  xhr.open('GET', url);
  xhr.onload = function() { // 收到更新, 处理更新数据
    ...
    checkUpdates('/updates'); // 打开新的长轮询XHR, 持续循环
  };
  xhr.send();
}

checkUpdates('/updates'); // 初始化轮询

长轮询动态调整消息到达速率来最小化消息延迟.

oerload的性能呢? 其实也不高, 因为每个连接都是新的连接, 都需要加额外的overload, 如果服务端的message发送速率很高, 长轮询就会比周期轮询产生更多的overload. 周期轮询相当于是只检验最新的message(message 聚合), 可以减少请求的数量, 优化手持设备的电池寿命

XHR 使用案例和性能

几行js代码(new, open, send, onload即可), 浏览器自动帮我们处理的事情:

  • 格式化HTTP请求和实例化响应
  • 强制相关策略, 比如同源
  • 处理内容协商(gzip)
  • 处理请求和响应缓存
  • 处理授权信息, 重定向等

但是也有局限

  • 没有官方的XHR标准
  • stream既不高效也不方便
  • 不同浏览器有不同行为
  • 高效的二进制分帧时不可能的
  • 总而言之, XHR对流很不友好
  • 实时传输策略也不完美, 虽然有periodic polling和long-polling
  • 服务端推送时间SSE

  • SSE是有效的基于文本的服务端到客户端的流传输, 比如实时通知或服务端更新.SSE引入了两个组件
  • 服务器的EventSource api
  • event stream 事件流数据格式–发送单个更新

以上两者的结合可以:

  • 通过单个的长连接更低的传输延迟
  • 高效的浏览器消息parsing, 不会出现无限缓冲buffer
  • 自动追踪最后一个消息, 并自动重连
  • 客户端消息的通知跟DOM事件一样

EventSource API

EventSource接口抽象了所有的低层连接的建立和消息实例化的过程, 只暴露了一个浏览器API

var source = new EventSource("/path/to/stream-url"); // 打开一个stream端的新SSE连接

source.onopen = function () { ... }; // 可选的回调, 在连接建立时触发
source.onerror = function () { ... }; // 可选回调, 连接失败触发

source.addEventListener("foo", function (event) { 
  processFoo(event.data);
}); // 监听foo事件

source.onmessage = function (event) {  
  log_message(event.id, event.data);

  if (event.id == "CLOSE") {
    source.close(); // 关闭SSE连接
  }
} // 监听所有事件, 没有具体的名称

SSE比XHR节省内存, XHR需要在关闭前缓存收到的响应, 而XHR是实时丢弃的.

event stream 协议可以让浏览器知道消息的ID, 类型, 界限(boundary).

EventSource提供了自动连接并追踪最新消息的功能, 方便服务端的消息重传和流恢复.

客户端只需要:

  • 打开新连接
  • 执行接受事件的通知
  • 在结束时终止流

Event Stream 协议

一个SSE的事件流时作为一个流式的HTTP响应传输的

  • 客户端初始化一个普通的HTTP请求
  • 服务端响应一个自定义的 text/event-stream content-type, 然后流式传输UTF8编码的事件数据. ```text => Request GET /stream HTTP/1.1 –> 普通请求 Host: example.com Accept: text/event-stream

<= Response HTTP/1.1 200 OK –> 响应200的 text/event-stream Connection: keep-alive Content-Type: text/event-stream Transfer-Encoding: chunked

retry: 15000 –> 服务器设置客户端重连时间(如果连接断掉)

data: First message is a simple string. –> 没有消息类型的简单文本

data: {“message”: “JSON payload”} –> 没有消息类型的JSON payload

event: foo data: Message of type “foo”–> foo消息类型的简单文本

id: 42 event: bar data: Multi-line message of data: type “bar” and id “42” –> 包含消息ID和消息类型的多行事件数据

id: 43 data: Last message, id “43” –> 包含ID的简单文本

事件流协议式尝试去理解并实现:
- 事件payload 是一个或多个相邻的数据字段
- 事件可以携带可选的ID和事件类型字符串
- 事件边界通过换行标记
- 每次结束时触发一个DOM事件通知应用.
  - 如果有类型, 则触发自定义DOM事件
  - 没有类型, 触发ommessage回调

message时被分成一个或多个数据字段, 合在一起直接传输给应用. 所以服务端可以push任何文本格式(纯文本, JSON等), 应用必须按照原有格式进行解码.

> SSE传输是UTF8编码, 不是二进制传输, 也可以基于base64编码任意的二进制对象, 但是会增大33%的overhead

> 不用担心UTF9的高开销, 和其他HTTP请求一样是可以被压缩的(gzip).

> 不使用二进制流是故意的, SSE只是希望被设计成一个简单高效的服务端对客户端传输基于文本的数据, 如果想传输其他二进制payload, WebSocket更适合

最后还需要自动实例化事件, SSE提供了内建的对重建断开连接的支持, 恢复客户端在断开连接时丢失的消息.默认情况下在连接丢失会自动重连, SSE建议2-3秒延时, 也是大多数浏览器的默认值.server也可以设置任意的自定义间隔通过发送 *retry*字段给客户端, 比如上例的15000ms

看看重连的机制:

(existing SSE connection) retry: 4500 –> 设置重连间隔为4.5s

id: 43 –> 简单文本事件, ID为43 data: Lorem ipsum

(connection dropped) (4500 ms later)

=> Request GET /stream HTTP/1.1 –> 自动客户端重连, 会携带最后看到的事件ID Host: example.com Accept: text/event-stream Last-Event-ID: 43

<= Response HTTP/1.1 200 OK –> 服务端响应 text/event-stream Content-Type: text/event-stream Connection: keep-alive Transfer-Encoding: chunked

id: 44 –> 简单文本事件, ID为44 data: dolor sit amet


- 如果丢失数据是可以被接受的, 没有事件ID和具体的逻辑, 就让客户端重连并继续流
- 如果需要恢复消息, 服务器需要具体化相关的事件ID, 比如客户端可以上报last-seen ID, 同源的服务端需要事件一些本地缓存恢复机制, 并传输丢失的消息给客户端

## SSE使用案例和性能
SSE是一个高性能的服务端到客户端的基于文本的实时数据的流传输
- 在数据准备好的那一刻立即从服务端传输到浏览器(低延迟)
- 最小化消息开销
  - long-lived 连接
  - 事件流协议
  - gzip buffer
- 浏览器解决所有的消息实例化
- 没有无限缓冲buffer
- 提供方便的EventSoueceAPI
  - 自动重连
  - 消息通知(以DOM事件的方式)

SSE有两个关键的限制
1. 只允许服务端到客户端, 不能从客户端上传数据
2. 事件流协议专用于UTF-8数据, 二进制虽然可行, 但是很低效

UTF-8限制可以在应用层解决, SSE通知应用有一个新的二进制资源准备好了, 然后应用发送XHR请求去获取, 会新增一个往返来回延迟, 但可以有效利用XHR的优势:
- 响应缓存
- 传输编码(transfer-encoding)
如果asset是流的话是不能被缓存的.

> SSE over TLS
> 中间件代理或防火墙不允许SSE的流传输, 可以包裹在TLS中进行传输.

# WebSocket
websocket是双向的面向消息的文本和二进制数据流, 他是最接近原生网络套接字的API.但是也提供了很多额外的功能:
- 连接协商和强制同源策略
- 与已有的HTTP互通
- 面向消息通信 , 有效的消息分帧
- 子协议协商和扩展性

webSocket是最通用, 最具有灵活性的传输方式.可以传输任意格式, 可以由任意一方传输, 但是问题也在于自定义, 应用必须考虑原来由浏览器考虑的东西, 比如压缩, 缓存等.
## WebSocket API
初始化一个API
```js
var ws = new WebSocket('wss://example.com/socket') // 新建一个socket实例
ws.onerror = function(error) {...}   // 异常处理
ws.onclose = function() {...}        // 关闭回调

ws.onopen = () => {                  // 打开事件
  ws.send('Connnection established') // 客户端发送消息到服务端
}

ws.onmessage = () => {          // 监听服务器消息
  if (msg.data instance Blob) { // 处理二进制数据
    processBlob(msg.data)
  } else {                      // 处理文本数据
    processText(msg.data)
  }
}

WS与WSS

ws是纯文本, wss 是加密的信道(TCP+TLS).为什么不直接使用熟悉的http要自定义呢? 因为双端通信除了浏览器和服务器之外还有很多的使用非HTTP协议的场景. 比如与node的express等通信模块

接受文本和二进制数据

websocket通信只包含message和应用代码, 不用担心buffer, parsing, reconstructing 接收到的数据.举个例子说: 如果服务器发出了1MB的payload, 应用的onmessage回调会在整个消息可用的时候被客户端调用

websocket协议没有对应用payload有限制, 文本和二进制都是公平竞争. 协议只关心两个message的information:

  1. payload的长度作为 variable-length 字段
  2. payload的类型, 从UTF8到二进制传输

当客户端收到服务端的数据, 会自动转换成基于文本的DOMString对象, 或者二进制传输的Blob对象, 然后直接传给应用. 唯一的选项是可以告诉浏览器把二进制数据用ArrayBufefer而不是Blob对象, 这两者的区别是:

  • blob存在外存, arraybuffer在内存.
  • blob为不变的文件原生的数据, 如果不需要更改数据也不需要切成小的chunks, 可以选择, 比如下载一个图片
  • 除此之外arraybuffer更适合
var ws = new WebSocket('wss://example.com/socket')
ws.binaryType = 'arraybuffer' // 转换成arraybuffer而不是blob

ws.onmessage = function(msg) {
  if (msg.data instanceof ArrayBuffer) {
    processArrayBuffer(msg.data)
  } else {
    processText(msg.data)
  }
}

实际上是把这个设置放进user agent进行区分

使用js解码二进制数据 ArrayBuffer是 通用的定长的二进制buffer, 可以用来创建一个或多个ArrayBufferView 对象, 类C的二进制数据结构如下:

struct someStruct {
 char username[16];
 unsigned short id;
 float scores[32];
};

在取得这个类型的ArrayBuffer对象后, 可以使用同一个创建多个views

var buffer = msg.data;
var usernameView = new Unit*Array(buffer, 0, 16) // 0-15
var idView = new Unit16Array(buffer, 16, 1) // 16-17
var scoresView = new Float32Array(buffer, 18,32) // 18-40
consol.log("ID: " + idView[0] + " username: " + usernameView[0]);
for (var j = 0; j < 32; j++) { console.log(scoresView[j]) }

buffer为父缓存, 第一个参数是起始offset, 第二个参数是buffer的长度, 与源结构someStrut对应

发送Text和二进制数据

var ws = new WebSocket('wss://example.com/socket')
ws.onopen = () => {
  socket.send('Hello, server') // 纯文本
  socket.send(JSON.stringify({msg: 'payload'})) // JSON

  var buffer = new ArrayBuffer(128) // Buffer
  socket.send(buffer)

  var intview = new Unit32Array(buffer) // BufferView
  socket.send(intview)

  var blob = new Blob([buffer]) // Blob
  socket.send(blob)
}

send()方法是异步的, 会按照触发时的顺序进行队列发送, 如果其中有一个是很大的message, 那么就会有类似HOB的现象.

var ws = new WebSocket('wss://example.com/socket');

ws.onopen = () => {
  subscribeToApplicationUpdates((evt) => { // 监听应用更新
    if (ws.bufferdAmount === 0) { // 检查client端buffer数据都发送完毕时
      ws.send(evt.data) // 发送新的数据
    }
  })
}

为了绕过这个问题, 可以把大的message切分成小的chunk, 监听 bufferdAmount值来避免线头阻塞, 甚至指定自己的优先级队列来延迟message, 而不是盲目的将所有的socket都按send的顺序排

子协议协商

WebSocket协议没有假设每条消息的格式, 只有单独一个bit来追踪消息是否包含text或者binary, 这样可提升解码的效率, 除此之外消息内容是的不透明.

不像HTTP请求有很多元数据可以放在header中, 如果有额外的关于消息的元数据, 收发双端必须部署自己的通信子协议:

  • 收发双端提前协商好固定的消息格式
  • 如果收发双端需要传输不同的数据类型, 需要一致的消息头,告诉另一方应该怎么解码
  • 混合文本和二进制消息可以被用作通信payload和metadata, text可以像HTTP header一样, 然后跟着是二进制消息作为应用payload

一旦决定序列化格式, 如何保证收发双端都了解, 如何让他们保持同步呢? 有一个 subprotocol negotiation API, 专指客户端发向服务端的一个用于Websocket握手的数组.

var ws = new WebSocket('wss://example.com/socket', ['appProtocol', 'appProtocol-v2']); // 数组结构的子协议在websocket握手的时候告知

ws.onopen = function() {
  if (ws.protocol === 'appProtocol-v2') { // 检查服务端选择的协议类型
    ...
  } else {
    ...
  }
}

WebSocket构造器接受一个数组作为子协议参数, 服务器选择任意一个进行握手, 一旦协商成功, onopen 就会在客户端触发, ws的protocol熟悉就包含了服务端选择的protocol. 如果服务端不支持客户端提供的任何一个, 握手是不会完成的, onerror 回调会被触发.

Websocket 协议

包含两个高层(high level)组件

  • HTTP握手用于协商连接参数
  • 二进制消息分帧用于支持低开销的基于消息的传输

可以在现有的HTTP中实现双向通信, 需要占用80或443端口, 但是websocket不限于HTTP技术, 可以在任意一个端口实现握手, 是一个独立的协议.

二进制分帧层

收发双端都通过面向消息的API进行通信

  • 发端提供任意的UTF-8或二进制payload
  • 收端在消息完全接受后被通知

WebSocket使用了自定义的二进制分帧格式, 可以将应用消息分成一个或多个帧, 传到目的地, 重新组装, 最后一旦整个消息被接受后通知收端

  • Frame
    • 最小通信单元, 每个包含变长的frame头和可能携带所有或部分应用消息payload
  • Message
    • 完整的序列帧, 映射应用消息逻辑

是否将消息分帧是由收发双端决定, 应用对个别Websocket帧或如何分帧毫不关心, 但是我们需要理解

  • 每个frame的第一个bit(FIN)表明是否该frame为消息的最后一帧, 一个消息可能指包含在一帧中
  • (4位)操作码表明传输帧的类型
    • text为1
    • binary为2
    • 关闭为8
    • ping为9
    • pong为10
  • 掩码位说明是否payload是否由掩码(只对客户端到服务器端)
  • 净荷长度由可变长度字段表示:
    • 0-125, 就是净荷长度
    • 126则接下来的两个字节表示的无符号整数才是这一帧的长度
    • 127则接下来的八个字节表示的无符号整数才是这一帧的长度
  • 掩码键包含32位值, 用于掩护净荷
  • 净荷包含应用数据和收发双端在连接建立时协商好的自定义扩展数据

所有客户端发起帧的净荷都是被用具体的在帧header中的值mask的, 用于防止客户端执行的有害脚本对不支持WebSocket的缓存攻击.

服务端发送的WebSocket2-10字节, 客户端要包含mask, 所以增加4字节, 6-14字节.没有其他任何元数据, 所有的WebSocket都通过交换帧进行通信, 会把净荷当成不透明的应用数据的blob.

WebSocket也由HOB,在有大消息需要传输时对延时敏感的应用来说是很难受的, 且不支持多路复用, 每个WebSockt都需要一条TCP,对于HTTP/1.x而言有连接数的限制(6个). 也正在指定新的”多路复用扩展”正在制定, 可以在一条TCP上提供多个虚拟websockt连接, 每个都用信道ID封装帧标签.但是每个信道还是容易产生阻塞, 所以可能的绕过方案是使用不同的信道, 或者告诉TCP连接使用平行的多路复用多条消息. 这只是在HTTP/1.x连接的限制, 在HTTP和WebSocket中没有具体的标准, HTTP/2有内建的复用流, 多个WebSocket连接可以通过包裹在HTTP/2帧机制中的WebSocket帧在单个session中传输.

协议扩展

数据格式和WebSocket协议的语义可以通过新的操作码和数据字段扩展.

  • 多路复用扩展
    • 将WebSocket逻辑独立出来, 实现共享底层TCP连接
    • 虚拟Websocket
    • 信道ID
  • 压缩扩展
    • 创建WebSocket扩展去为WebSocket协议添加压缩功能的框架
    • 类似于HTTP中的transfer-encoding

要使用这些扩展, 需要客户端在Upgrade握手时初始化雾浮起必须选择在连接的整个生命周期中使用这个扩展.

截至2013年, 浏览器还没有支持WebSocket的多路复用, 对压缩扩展的支持也很有限, Chrome和WebKit会发送”x-webkit-deflare-frame”扩展, 但是这是过时的会被启用的字段 应用需要密切关注传输的数据类型采用不同的压缩方案.

HTTP升级协商(Upgrade Negotiation)

升级协商时通过HTTP进行的:

  • WebSocket可以运行在80或443端口, 这通常是客户端唯一打开的端口
  • 允许使用自定义的WebSocket头字段来协商进行重用和扩展HTTP Upgrade流:
    • Sec-WebSocket-Version
      • 客户端发送预期的版本. 如果服务端不支持客户端版本, 服务端返回支持的版本列表
    • Sec-WebSocket-Key
      • 客户端发送时自动生成, 是服务端的一个challenge, 验证服务端支持的请求版本
    • Sec-WebSocket-Accept
      • 客户端响应的包含Sec-WebSocket-Key的签名. 表明服务端可以理解请求的协议版本
    • Sec-WebSockt-Protocol
      • 协商子协议
        • 客户端提供一个支持的列表
        • 服务端回应单个协议的名字
    • Sec-WebSocket-Extensions
      • 使用WebSocket扩展
        • 客户端提供支持的扩展
        • 服务端通过返回相同的header确认一个或多个扩展

HTTP Upgrade和协商新的WebSocket连接的过程:

GET /socket HTTP/1.1
Host: thirdparty.com
Origin: http://example.com
Connection: Upgrade
Upgrade: websocket  --> 请求进行升级到WebSocket
Sec-WebSocket-Version: 13  --> WebSocket协议版本
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== --> 自动生成的验证服务端是否支持的键
Sec-WebSocket-Protocol: appProtocol, appProtocol-v2 --> 服务端可选的支持的子协议
Sec-WebSocket-Extensions: x-webkit-deflate-message, x-custom-extension --> 服务端可选的扩展

为了完成握手, 服务端必须:

HTTP/1.1 101 Switching Protocols --> 101确认WebSocket升级
Upgrade: websocket
Connection: Upgrade
Access-Control-Allow-Origin: http://example.com --> 跨域的Header
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= --> 签名的Key值表明服务端支持
Sec-WebSocket-Protocol: appProtocol-v2 --> 服务端选择的子协议
Sec-WebSocket-Extensions: x-custom-extension  --> 服务端选择的扩展

这个Sec-WebSocket-Key是通过SHA1 hash之后的值, base-64的字符串

一次成功的握手需要客户端包含协议版本和Key, 服务端响应101,并且把那个Key返回确认协议版本.

一旦成功握手完成, 连接就可以用来双向通信了, 从现在起, 收发双端就不会有显式的HTTP通信, WebSocket协议可以接管他.

代理, 中间件和WebSocket 用户出于安全考虑只开放了80和443端口, 必须走HTTP协议, HTTP协议就需要经过中间件, 可能中间件并不能理解这种新协议, 会导致以下问题:

  • 连接Upgrade失败
  • WebSocket帧缓存
  • 内容修改
  • 错误分类WebSocket的流 WebSocket Key和Accept握手可以定位一些问题, 但是对”透明的代理”来说变得不透明了, 也不高效了,因为可能会分析甚至修改数据 解决办法就是建立一个安全的端到端通道, 比如WSS, 通过TLS会话来进行HTTP升级握手.对移动端来说更为重要, 因为移动端会经过很多的中间件

    Websocket使用案例和性能

    WebSocket是唯一一个允许在同一个TCP里进行双向通信的连接, 能提供双向的文本和二进制文件传输的低时延

  • XHR是对”事务”请求响应传输通信的优化:
    • 客户端发送完整的HTTP请求给服务端
    • 服务端响应一个完整的响应
    • 不支持请求流, 除非Stream API可用, 但这个API也不支持跨平台
  • SSE支持高效的低延时的服务端到客户端的基于文本的流
    • 客户端初始化SSE连接
    • 服务端使用event source 协议来升级客户端
    • 客户端在首次初始化握手之后就不能再向服务端发送请求

切换任何协议都不能减少拥塞延时, 但是可以减少消息队列延时, 因为SSE和WebSocket都是一有消息立马就发送出去, 而XHR polling需要等到poll的时候才会发送.

消息开销

一旦WebSocket建立之后的message可以被分成一个或多个的frame, 每个frame包含2个或14个byte的开销. 因为framing是通过自定义二进制格式, 不管utf8还是二进制应用数据都一样.

SSE每个消息只需要5字节开销, 但是只能严格发送UTF-8的内容 HTTP/1.x需要额外的500-800字节的元数据和Cookie HTTP/2压缩了元数据, 减小了开销, 如果消息头没发生变化, 开销可以减小到8bytes

这些开销都不包括IP, TCP和TLS帧, 这些还会额外添加60-100bytes, 不管什么协议

数据效率和压缩

每个XHR相比于普通的HTTP协商, 可以传输gzip或基于文本的数据编码格式, 类似的SSE因为严格UTF8传输, 可以被有效压缩成gzip Websocket比较复杂

  • 可以传输text和二进制数据
  • 不能在整个session中压缩
  • 二进制payload可能已经被压缩了
  • WebSocket需要对每个消息选择需要应用的压缩策略

现在正在开发每个消息的压缩扩展, 但是还没有浏览器使用, 因此除非对二进制payload和text-based的message有自己的压缩策略, 不如会导致比较高的传输开销

自定义应用协议

浏览器优化了HTTP的数据传输,他了解协议, 并且提供了一系列的服务

  • 鉴权
  • 缓存
  • 压缩

相反, 流允许传输自定义协议, 但是成本就是不能使用浏览器的机制

  • 初始化HTTP握手会用到一些, 比如握手时传输cookie进行鉴权, 如果失败则不允许升级到WebSocket
  • 一旦会话建立起来, 所有的数据流都是对浏览器不透明的.
  • 这样自定义协议的传输灵活性的缺点就显而易见了, caching, 状态管理, 传输消息元数据都可能会与浏览器自有的产生一定的差距.

所以最好的使用策略就是不需要缓存的使用websocket,比如一些实时更新和应用控制消息, 其他的可以通过XHR来获取

部署WebSocket

因为HTTP的脉冲性, 通常的超时都设的比较激进, 但是对long-lived的WebSocket来说就不行了, 所以需要考虑以下三点

  1. 自有网络的路由, 均衡负载和代理
  2. 外部网络的透明和显式代理(ISP和运营商代理)
  3. 客户端路由, 防火墙和代理

不能控制客户端网络, 实际上很有可能客户端完全禁用WebSocket, 所以要考虑降级策略. 类似的, 也不能控制中间代理和外部网络, 但是可以由TLS来帮忙, 可以保证端到端的安全连接, 绕过这些中间代理

最后是部署的问题, 每一台负载均衡器, 路由器和web服务端都必须针对尝试连接进行调优, 例如Nginx1.3.13+可以代理WebSocket通信, 但默认超时为60s, 需进行设置

location /websocket {
    proxy_pass http://backend;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 3600; --> 60分钟的读超时
    proxy_send_timeout 3600; --> 60分钟的写超时
}

部署负载均衡服务器, 比如HAProxy

defaults http
  timeout connect 30s
  timeout client  30s
  timeout server  30s
  timeout tunnel  1h --> 为专用信道设置60分钟超时

性能检测清单

  • 为可靠性部署使用安全的WebSocket(WSS over TLS)
  • 注意polyfill的性能
  • 利用子协议协商来确定应用协议
  • 优化二进制payload来最小化传输体积
  • 考虑压缩UTF8内容来最小化传输体积
  • 为接受的二进制payload设置正确的二进制类型
  • 在客户端监视buffered数据的数量
  • 把大应用消息进行切分, 避免HOLB
  • 利用其他可应用的传输

移动端的优化, 可参考前文无线通信

WebRTC

原生接口, 不依赖于插件或第三方应用

  • MediaStream: 获取视频或音频资源
  • RTCPeerConnection: 音频或视频通信
  • RTCDataChannel: 任意应用类型通信

RTC 协议基于 UDP

WebRTC标准

可集成到已有的通信系统中

  • VOIP
  • 不同的 SIP(Session Initial Protocol) 客户端
  • PSTN(public switch telephone Network) WebRTC 不仅仅用于浏览器的 API 中, 有更多的应用可以让Web 与世界实时通信

    音视频引擎

    接入硬件获取音视频的权限, 不需要第三方插件或驱动, 而仅仅是一个 API, 但是原生的流是不够的:

  • 每个流都需要进行质量提升
  • 同步
  • 输出的比特率必须与当前的带宽和延迟相匹配

在接受端过程是相反的, 客户端必须实时解码流, 能够适应网络的 jitter 和延迟, 总而言之, 获取和处理音视频流是一件麻烦事, 但是 WebRTC 有一套完整的机制

整个过程都由浏览器完成, 浏览器动态的调整pipeline 根据音视频的不同参数和网络状况.一旦这些工作完成, 便会到本地的屏幕和扬声器上, 发送给 peers, 或者使用 H5的某个 API 进行后处理.

会用 getUserMedia 获取音视频

  • MediaStream 包含一个或多个独立的 Track, MediaStreamTrack
  • MediaStreamTrack互相之间是同步的
  • 输入可以是
    • 麦克风
    • 摄像头
    • 本地或远程文件
  • MediaStream 的输出有多种
    • 本地音视频
    • peer
    • js code 后处理

使用 getUserMedia() 可以设置必须或可选的应用需求

<!-- output element -->
<video autoplay></video> 

<script>
  var constraints = {
    // 请求音轨
    audio: true, 
    // 请求视频
    video: { 
      // 必须满足的条件
      mandatory: {  
        width: { min: 320 },
        height: { min: 180 }
      }
      // 可选的条件
      optional: [  
        { width: { max: 1280 }},
        { frameRate: 30 },
        { facingMode: "user" }
      ]
    }
  }

  navigator.getUserMedia(constraints, gotStream, logError);  
  // 成功获取到流
  function gotStream(stream) { 
    var video = document.querySelector('video');
    video.src = window.URL.createObjectURL(stream);
  }
  // 失败回调
  function logError(error) { ... }
</script>

可以将 Steam 放到很多 API 中

  • web audio 音频
  • canvas 获取或后处理视频
  • CSS3和 webGL 2D/3D 效果

音频解码: Opus

  • 6–510 Kbit/s, 可自适应 视频解码: VP8
  • 720p at 30 FPS: 1.0~2.0 Mbps
  • 360p at 30 FPS: 0.5~1.0 Mbps
  • 180p at 30 FPS: 0.1~0.5 Mbps

实时网络传输

  • 时延敏感
  • audio 和 video 设置为可容忍丢包, 时间和低延迟比可靠性更重要
  • 音视频解码器可以自动弥补一些小的数据 gap, 最小化对质量的影响
  • 为丢包或延迟收包制定恢复逻辑

如果卡顿我们会立刻感受到, 但是如果采样率低了一些, 除非是金耳朵, 否则是感受不到的.

这就是使用 UDP 的原因

  • 不保证消息传输
    • 没有 Ack, 重传和超时
  • 不保证消息顺序
    • 没有包序列号, 没有重排序, 没有 HOLB
  • 没有拥塞控制
    • 没有客户端和网络的反馈机制

UDP是浏览器实时传输的基础, 但是为了满足WebRTC 的所有需求, 浏览器也应该更多的协议以及基于协议的服务

  • UDP 的 p2p连接必须:
    • ICE Interactive Connectivity Establishment
      • STUN Session Traversal Utilities NAT
      • TURN Traversal Using Relays around NAT
  • SDP Session Description Protocol 协商p2p时的连接参数, 是在通信范围外的, 所以不在图中.
  • DTLS Datagram Transport Layer Security 保护p2p时的数据安全
  • 多路复用流, 提供拥塞和流控制, 提供部分可靠传输和其他基于 UDP 的服务
    • SCTP Stream Control Transport Protocol
    • SRTP Secure Real-Time Transport Protocol

简介 RTCPeerConnection API

RTCPeerConnection 控制整个p2p的连接过程, 概括:

  • 设置
  • 管理
  • 状态

具体来说, RTCPeerConnection

  • 为 NAT(Network Address Translator)控制 ICE 网络
  • 发生自动的 STUN 为 peers 之间维持连接(keepalive)
  • 为本地流保持 track(跟踪)
  • 为远程流保持 track(跟踪)
  • 在需要时触发自动的流再协商
  • 提供必要的 API 来生成可选的连接, 接受回应, 允许查询连接的当前状态

DataChannel API 提供了peers 间的任意应用数据交换, 每个 DataChannel 可以被下列两个进行配置

  1. 可靠或部分可靠的发送消息传输
  2. 顺序或无序的发送消息传输 不可靠的无序传输与原生 UDP 语义相同, 消息的顺序不是那么重要, 但是也可以通过具体化最大重传数量或重传限时的配置使 channel”部分可靠(partially reliable)”, WebRTC 栈将处理 acknowledgments 和超时.

建立 P2P 连接

需要比之前所有连接更多的工作(相比于 XHR, EventSource, WebSocket), 因为其他三个都是定义好的 HTTP 握手机制来协商连接的参数, 并且所有的三个都默认假设目标服务器是可以被客户端访问到的. 比如: server 有公网 IP, 或者在同一个本地网中

WebRTC 相反, 每个 peer 都在自己的私有网络中, 在一个或多个 NATs 的背后, oeer 之间是不能直接访问到彼此.为了初始化回话, 需要为每个 peer 搜集所有可能的 IP 和端口, 传输 NATs, 然后运行连接检查来寻找可用的那一个, 之后也不能保证可以成功.

为了可以成功建立 P2P 连接, 必须首先解决一下几个问题

  1. 通知另一方想要进行 P2P 连接的意图, 这样它才能为接下来的包进行监听
  2. 为 P2P 连接的两端定义潜在的路由路径, 依赖于 peers 之间的信息
  3. 交换必要的关于不同媒体和数据流的参数的信息, 比如协议, 编码格式等

内建的 ICE 协议自有必要的路由和连接检查, 但是传输通知(signaling)和初始化协商还是依赖于应用

信号和会话协商

必须首先知道是否可以访问到对方以及对方愿意建立连接. 需要先发一个 offer, 另一个 peer 必须返回一个 answer, 但是问题在于如果另一个 peer 并没有监听接下来的包, 我们怎么通知到他我们想要发信息给他呢? 至少, 需要一个共享的信号通道

channel.svg)

标准并没有定义 signaling 栈, 因为有很多其他的信号协议正在现网中使用, 比如:

  • Session Initiation Protocol (SIP)
    • 应用级别的信号协议, 广泛应用于 VoIP 和基于 IP 的视频会议
  • Jingle
    • XMPP 协议的信号扩展, 用于 IP 语音和 IP 视频会议的会话控制
  • ISDN User Part(ISUP)
    • 用于在全球多种公共交换电信网络设置电话拨打的信号协议

信号服务器可以为已有的通信网扮演网关, 可以负责通知目标 peer offer, 并且路由返回的响应到初始化的 WebRTC 客户端来初始化 channel, 可以由一个或多个服务器组成, 并且自定义通信的协议.

Skype: audio 和 video 通信就是 P2P 的, 但是 Skype 用户需要连接 Skype 的信号服务器, 用他们的专有协议来建立初始 P2P 连接

会话描述协议 Session Description Protocol(SDP)

假设应用都建立在共享的信号通道上, 可以通过下列步骤初始化 WebRTC 连接:

var signalingChannel = new SignalingChannel(); // 初始化一个共享的信号通道
var pc = new RTCPeerConnection({}) // 初始化一个 RTCPeerConnection 对象

navigator.getUserMedia({"audio": true), gotStream, logError) // 请求音频流

function gotStream(stream) {
  pc.addStream(stream) // 注册本地音频流到 RTCPeerConnection 对象上

  pc.createOffer(function(offer) { // 创建 SDP offer
    pc.setLocalDescription(offer) // 应用生成的 sdp 作为本地 peer 连接的描述
    signalingChannel.send(offer.sdp) // 通过 signaling 通道发送生成的 sdp offer
  })
}

function logError() {...}

SDP 本身不传输任何媒体, 他只是用于描述会话文件, 是一个简单的基于文本的协议. 应用不需要 直接处理 SDP. JavaScript Session Establishment Protocol(JSEP) 抽象所有的 SDP 内在愿意, 放进了 RTCPeerConnection 对象中

一旦 offer 生成, 可以通过 Signaling Channel发送给远端, SDP编码是根据应用来的, 可以被直接以之前的简单文本 blob 进行传世, 也可以被编码成其他格式, 比如 jingle 协议提供一个从 SDP 到 XMPP(XML) 的mapping

(... snip ...)
m=audio 1 RTP/SAVPF 111 ... --> 返回安全的音频文件
a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level
a=candidate:1862263974 1 udp 2113937151 192.168.1.73 60834 typ host ... --> Candidate 为媒体流准备的 IP, port 和 Protocol
a=mid:audio
a=rtpmap:111 opus/48000/2 
a=fmtp:111 minptime=10 --> Opus codec 和基本配置
(... snip ...)

为了建立 P2P 连接, 两个 peers 都需要遵循同步的工作流来进行 SDP 交换各自的音频视频和其他数据流

  1. Amy 注册一个或多个流到本地 RTCPeerConnection 对象上, 创建一个 offer, 设置会话的 local description(setLocalDescription)
  2. Amy 发送一个生成的会话 offer 给另一个 peer Bob
  3. 一旦 Bob 收到 offer, 他会设置 Amy 的描述为远程会话描述, 通过 RTCPeerConnection 对象注册自己的流, 生成响应的 SDP, 设置它为会话的本地描述
  4. Bob 发送生成的会话响应给 Amy
  5. Amy 收到 Bob 的 SDP 响应后用它设置初始会话的远端描述.

因此, 一旦 SDP 会话描述被通过 Signaling channel 交换之后, 双方都已经协商好了即将交换的流的类型和设置, 基本上准备好开始传输了!还剩下连接性检查和 NAT traversal

ICE(Interactive Connectivity Establishment)

peers之间必须可以相互路由, 但是却因为匿名的防火墙和peers 间的 NAT 设备, 使得非常难以去实现.

如果 peers 都在同一个内部网络中, 没有防火墙和 NATs, 为了建立连接,可以直接简单向操作系统查询IP 地址.

如果在各自不同的私有网络中呢?也可以按照上述流程查询私有 IP地址, 但肯定是不能直接连接的.幸运的是 WebRTC 框架已经帮我们想好了

  • 每个 RTCPeerConnection 对象包含了一个 ICE agent 的东西
  • ICE agent 负责收集 candidates 的本地 IP 和端口 tuple
  • ICE agent 负责 peers 之间的连接性检查
  • ICE agent 负责发送 keepalive的连接

一旦会话描述设置完成, 本地的 ICE 代理会自动开始为 peer查询可能的 candidate IP, port 元组

  1. ICE 代理想本地系统查询本地 IP 地址
  2. 如果配置了, ICE 代理查询外部的 STUNserver 来获取peer 的公共 IP 和端口 tuple
  3. 如果配置了, ICE 代理会在 TURN 服务器后面添加为最后的 candidate, 如果P2P 连接失败, 依赖的数据将会通过具体的中间件发送.

来看一个 ICE 的例子

var ice = {iceServers: [
  {url: 'stun:stun.l.google.com:19302'}, // STUN服务器, 使用谷歌公共测试服务器
  {url: 'turn:turnserber.com', username: 'user', credential: 'pass'} // 为 P2P 失败后的可靠数据的TRUN 服务器,
]}
var signalingChannel = new SignalingChannel()
var pc = new RTCPeerConnection(ice)

navigator.getUserMedia({audio: true}, gotStream, logError)

function gotStream(stream) {
  pc.addStream(stream);
  pc.createOffer(offer => {
    pc.setLocalDescriptor(offer) // 应用本地会话描述, 初始化 ICE 收集过程
  })
}
pc.onicecandidate = evt => {
  of (evt.target.iceGatherState === 'complete') { // ice 收集完成
    local.createOffer( offer => {
      console.log(offer.sdp)
      signalingChannel.send(offer.sdp) // 重新生成 SDP offer(现在是发现了 ICE candidate)
    })
  }
}
// a=candidate:1862263974 1 udp 2113937151 192.168.1.73 60834 typ host ...--> peer 的私有ICE candidate
// a=candidate:2565840242 1 udp 1845501695 50.76.44.100 60834 typ srflx ...-->  从 STUN 服务器返回的公共 ICE candidate

ICE 发送一个消息(STUN 绑定请求), 另一个 peer 必须通过成功的 STUN 响应来确认. 完成后, 最后可以有路由表来进行 P2P 连接. 相反的, 如果所有的 cnadidates 都失败了,RTCPeerConnection 被标记为失败, 连接会退化到 TURN 可靠服务器去建立连接.

ICE会根据 candidate 连接检查来进行自动排序, 首先检查本地 IP 地址, 然后是公共的, 最后是TURN, 并周期的发送 STUN 请求, 来保持并确认连接.

Incremental Provisioning (Trickle ICE)

查询本地的IP地址是非常简单的, 但是要查询STUN server的话是相当费时的, 所以有了渐增的提供策略

  • peers在没有ICE的情况下交换SDP offers
  • ICE在他们被发现是通过signaling channel被发送
  • ICE一旦有新的参与者描述可用时立即执行连接性检查

依赖signaling channel传输递增的更新到另一个peer, 而不是等待ICE收集所有的peer执行完毕,

var ice = {
  iceServers: [
    {url: 'stun:stun.l.google.com:19302'},
    {url: 'turn:turnserver.com', username: 'user', credential: 'pass'}
  ]
}

var pc = RTCPeerConnection(ice)
navigator.getUserMedia({audio: true}, gotStream, logError)

function gotStream(stream) {
  pc.addStream(stream)

  pc.createOffer(offer => {
    pc.setLocalDescription(offer)
    signalingChannel.send(offer.sdp) // --> 没有ICE参与者的SDP发送
  })
}

pc.oncandidate(evt => {
  if (evt.candidate) {
    signalingChannel.send(evt.candidate) // --> 当单独的ICE 参与者被本地ICEagent发现后发送
  }
})

signalingChannel.onmessage = msg => {
  if(msg.candidate) {
    pc.addIceCandidate(msg.candidate) // --> 注册原曾的ICE参与者, 并开始连接检查
  }
}

Trickle ICE 利用了 signaling channel 生成更多的流量, 提升了初始化P2P的连接事件. 一句话 尽快发送offer, 然后当ICE candidates被发现时逐渐增加

追踪IC收集和连接状态

内建的ICE框架可以管理candidates的

  • 发现
  • 连接检查
  • keepalives

如果上述都工作良好, 那么所有的这些对应用层来说都是透明的. 但是也需要在初始化RTCPeerConnection时配置STUN和TURN. 但是当有连接出问题时, 定位和解决问题就很重要, 可以通过向ICE 代理进行状态查询, 并订阅通知

var ice = {"iceServers": [
  {"url": "stun:stun.l.google.com:19302"},
  {"url": "turn:turnserver.com", "username": "user", "credential": "pass"}
]};

var pc = new RTCPeerConnection(ice)

logStatus('ICE gathering state:', pc.iceGatheringState)
pc.onicecandidate = evt => {
  logStatus('ICE gathering state change', evt.target.iceGatheringState)
}

logStatus('ICE connection state:', pc.iceConnectionState)
pc.oniceconnectionstatechange = evt => {
  logStatus('ICE connect state chage', evt.target.iceConnectionState)
}

iceGatheringState有三种状态:

  • new
    • 对象刚刚被建立, 还没有网络
  • gathering
    • ICE agent正在搜集本地candidate
  • complete
    • ICE agent完成搜集过程

iceConnectionState有七种状态

  • new
    • ICE agent正在搜集candidates并且/或正在等待远程candidate被提供
  • checking
    • ICE agent在至少一个组件获取到了远程candidate,正在检查candidate的匹配性, 但是还没有发现连接, 在checking的时候, 可能依然在搜集
  • connected
    • ICE agent发现对所有组件都可用的连接但依然在检查其他匹配的candidate看看有没有更好的, 此时依然可能在搜集
  • complete
    • ICE agent完成搜集, 检查并且发现了所有组件的连接
  • failed
    • ICE agent完成所有candidate的检查, 没有发现至少一个组件可用的连接.
  • disconnected
    • 一个或多个组件的生命检查失败.
  • closed
    • ICE agent关闭并不再响应STUN请求

合并在一起看

  • signaling
  • offer-answer 工作流
  • 会话参数协商SDP
  • ICE协议建立P2P的内部工作原理 这戏就是通过WebRTC初始化P2P连接的所有材料

初始化WebRTC连接

<video id="local_video" autoplay></video> 
<video id="remote_video" autoplay></video> 

<script>
  var ice = {
    iceServer: [
      {
        url: 'stun:stun.l.google.com:12345'
      },
      {
        url: 'turn:turnserver.com',
        username:'user',
        credential: 'pass'
      }
    ]
  }

  var signalingChannel = new SignalingChannel()
  var pc = new RTCPeerConnection(ice)

  navigator.getUserMedia({audio: true, video: true}, gotStream, logError)

  function gotStream(evt) {
    pc.addStream(evt.stream)
    var loacl_video = document.getElementById('local_video')
    local_videw.src = window.URL.createObjectURL(evt.stream)

    pc.createOffer(offer => {
      pc.setLocalDescrition(offer)
      signalingChannel.send(offer.sdp)
    })
  }

  pc.oncandidate = evt => {
    if (evt.candidate) {
      signalingChannel.send(evt.Candidate)
    }
  }

  signalingChannel.onmessage = msg => {
    if (msg.candidate) {
      pc.addIceCandidate(msg.candidate)
    }
  }

  pc.onaddStream = evt => {
    var remote_video = document.getElementById('remote_video')
    remote_vidoe.src = window.URL.createObjecURL(evt.stream)
  }

  function logError() {..}
</script>

响应WebRTC连接

<video id="local_video" autoplay></video>
<video id="remote_video" autoplay></video>

<script>
  var signalingChannel = new SignalingChannel();

  var pc = null;
  var ice = {"iceServers": [
    {"url": "stun:stunserver.com:12345"},
    {"url": "turn:turnserver.com", "username": "user", "credential": "pass"}
  ]};
  signalingChannel.onmessage = (msg) => {
    if (msg.offer) { //--> 监听并处理被signalingChannel发送的远程offers
      pc = new RTCPeerConnection(ice)
      pc.setRemoteDescription(msg.offer)

      pc.onicecandidate = evt => {
        if (evt.candidate) {
          signalingChannel.send(evt.candidate)
        }
      }

      pc.onaddstream = evt => {
        var remote_video = document.getElementById('remote_video')
        remote_video.src = window.URL.createObjectURL(evt.stream)
      }

      navigator.getUserMedia({audio:true, video:true}, gotStream, logError)
    } else if(msg.candidate) { // --> 注册远程ICE candidate然后开始连接性检查
      pc.addIceCandidate(msg.candidate)
    }

    function gotStream(evt) {
      pc.addStream(evt.stream)
      var local_video = document.getElementById('local_video')
      local_video.src = window.URL.createObjectURL(evt.stream)

      pc.createAnswer(answer => { // --> 生成描述peer连接的SDP answer并且发送给peer
        pc.setLocalDescription(answer)
        signalingChannel.send(answer.sdp)
      })
    }

    function logError() {...}
  }

</script>

区别最大的点就是这里是生成了answer, 而在之前初始化的时候是生成了offer在signalingChannel中传输

有一个库, 可以简化上述代码, 把signalingChannel和RTCPeerConnection封装起来

<script src="http://simplewebrtc.com/latest.js"></script>

<div id="local_video"></div>
<div id="remote_video"></div>

<script>
  var webrtc = new SimpleWebRTC({
    localVideoEl: "local_video",
    remoteVideosEl: "remote_video",
    autoRequestMedia: true
  });

  webrtc.on("readyToCall", function () {
      webrtc.joinRoom("your awesome room name");
  });
</script>

传输媒体和应用数据

上述只说明了P2P的初始化, 仅仅只是完成了整个连接的一半, 初始化完成之后, peers有了原生的UDP连接, 但是没有流控制, 拥塞控制, 错误检查, 以及一些带宽和延迟的估计机制, 很容易搞垮网络, 而且UDP的传输时未加密的, 所以WebRTC在UDP上加了很多协议来弥补上述的一些问题:

  • DTLS(datagram transport layer security) 用于协商secret key, 然后加密媒体数据来保证安全传输
    • TLS基于TCP. 不能用于UDP, DTLS提供了与TLS相同的安全保证, DTLS解决了下述的问题:
      • TLS需要可靠的, 按序的, 和fragmentation友好传输的这些握手记录来协商tunnel
      • TLS集成检查会失败, 如果记录在多个包中分片
      • TLS集成检查会失败, 如果记录是无序的
    • 无法很好的绕过上述的 TLS 握手序列问题.所以引入了 mini-TCP
      • 显式添加 offset 和 sequence number.
      • 处理丢包: 如果没在预定的间隔内收到包,则会重发握手记录
    • 两端的 peers 需要生成自签名的证书(WebRTC自动生成), 并且遵循通常的 TLS 握手协议
    • DTLS 有两个重要的规则来实现分片和无序的记录处理
      • DTLS 记录必须在单个网络包中
      • 一个块密码必须用 record 数据来加密
  • SRTP(secure real-time transport)用于传输音频和视频流
    • 无视质量和媒体流的大小, 网络栈有自己的流和拥塞控制算法, 每个链接都会在低比特率(<500 Kbps)然后开始调整 stream 的质量去匹配可用的带宽
    • 媒体和网络引擎动态的调整流的质量, 在整个连接周期中.
    • WebRTC 不能保证提供的是 HD 就能按最高质量传输, 可能有不够的带宽, 丢包等,会动态适应
    • SRTP定义了一个标准包格式(如上图). SRTP 不提供任何机制或保证及时性, 可靠性和错误恢复. 他只简单的将数字化的音频samples 和视频帧包裹在额外的元数据当中, 来协助接受者处理每个流
      • 自增的 sequence number, 可以让接受者检测过期媒体数据
      • timestamp 代表媒体负载的第一个比特的采样时间, 用来同步不同的媒体流
      • SSRC identifier, 唯一的流 ID 用来在一个媒体流中管理每个 packet
      • 其他的元数据 other metadate
      • 携带了一个加密的媒体负载, 并有一个授权标签, 可用于校验传输包的完整性
    • SRTP 提供了所有媒体引擎所需的数据, 但是控制单个 SRTP 包的传输就由 SRTCP来反馈和控制, SRTCP会跟踪发送和丢失的字节和分组数量, 跟踪每个SRTP分组的序号, 交错到达的抖动, 以及其他SRTP的统计信息.
    • SRTP和SRTCP都是运行在UDP之上的, 实现对应用提供的音频和视频流的实时优化和适配.
    • SRTP和SRTCP都没有协商密钥, 而要实现加密传输就必须使用DTLS协商的共享密钥
  • SCTP(Stream Control Transport Protocol) 用于传输应用数据
    • 这是通过DataChannel API的端到端传输, SRTP和SRTCP是专为传输媒体所设计的, 不适合传输应用数据
    • 需要满足以下要求
      • 多个独立信道复用
        • 每个信道必须支持有序或乱序交付
        • 每个信道必须支持可靠或不可靠交付
        • 每个信道可以支持应用定义的优先级
      • 提供一个面向消息的API
        • 每条消息都能在传输层被分段和组装
      • 必须实现流量和拥塞控制
      • 必须保证数据的机密性和完整性
    • DTSL可以满足最后一条, 其余的需要SCTP来完成 |TCP |UDP| SCTP| |–|–|–| |Reliability |reliable| unreliable| configurable| |Delivery |ordered |unordered |configurable| |Transmission |byte-oriented |message-oriented |message-oriented| |Flow control |yes |no |yes| |Congestion control |yes |no |yes|
    • SCTP有以下需要注意的概念
      • Association 关联
        • 和连接同义
      • Stream 流
        • 单消息传输通道, 应用有序传输, 但也可以配置实现乱序传输
      • Message 消息
        • 提交给协议的应用消息
      • Chunk 块
        • SCTP包的最小通信单元
    • 两个端点的SCTP关联可以容纳多个独立的流, 每个流可以独立传输单个或多个应用消息, 每个应用消息可以分成一个或多个块, 这些块封装在了SCTP分组中交付, 到了另一端再进行组装
      • 这个跟HTTP/2 分帧层的结构很像, 区别是, SCTP部署在更低的层级, 可以更有效的传输和复用任意的应用数据.
      • header携带 12bytes(96bits)的数据
      • packet携带一个或多个数据块, 上图是只有一个数据块
        • 所有数据只有0*0类型
        • 乱序(U)位表明是否是乱序DATA 块
        • B和E用于表明在将一个消息分成多个块的起始和终止
          • B=1, E=0表明是消息的第一个分段
          • B=E=0表明是中间段
          • B=0,E=1表明是最后段
          • B=E=1表明是未分段的消息
        • 表明DATA块的大小(size),将包含header
          • 比如16字节的块头, 加净荷(payload)数据的大小
        • TSN是一个32位数用来进行SCTP的内部包确认和重复传输分组的探测, 用来对中间段的排序
        • 流标识符用来标记当前数据所属的流
        • 流序号是一个递增的消息编号, 表示关联的流, 单个消息的分段的消息有同一个流序号
        • PPID是应用自定义的字段用来进行额外的传输块元数据描述
          • 在DataChannel中, 0 * 51表示UTF8的数据, 0 * 52表示二进制数据
    • SCTP协商的初始参数怎么来呢? 会有一个类似TCP的握手, 也有流量和拥塞控制.
    • SCTP还欠缺
      • 基础的STCP标准提供乱序交付, 但是不支持通过配置实现可靠性, 需要额外引入扩展–> Partial Reliability Extension
      • SCTP不支持流的优先级安排, 没有规定用于保存的优先级字段.

        数据信道DataChannel

        DataChannel也可以实现peers之间的双向任意数据交换, 类似WebSocekt但是更加的底层.一旦RTCPeerConnection建立之后, 连接的peers可以打开一个或多个channel来交换文本或二进制数据

// 在DataChannel上注册类似WebSocket的回调
function handleChannel(chan) {
  chan.onerror = function(error) {
    ...
  }
  chan.onclose = function() {
    ...
  }
  chan.onopen = function(evt) {
    chan.send('DataChannel connection established')
  }
  chan.onmessage = function(msg) {
    // 如果是二进制数据
    if (msg.data instanceof Blob) {
      processBlob(msg.data)
    } else {
      processText(msg.data)
    }
  }
}

var singalingChannel = new SignalingChannel();
var pc = new RTCPeerConnection(iceConfig);
// 以最合适的交付语义实例化DataChannel
var dc = pc.createDataChannel('namedChannel', {reliable: false});
... // 正常的RTCPeerConnection offer和answer的代码
// 在初始化的DataChannel注册callbacks
handleChannel(dc)
// 注册由远端peer初始化的DataChannel的回调
pc.ondatachannel = handelChannel;

类型和监听的事件跟WebSocket都很相似, 不同点在于:

  • 在构造函数中DataChannel不需要URL, DataChannel是在RTCPeerConnection对象的一个工厂方法
  • 任何一端都可以初始新的DataChannel的会话, 建立后会触发onDataChannel的回调
  • WebSocket是运行在可靠且有序的TCP连接中, 每个DataChannel可以配置成自定义的传输和可靠性

|*|WebSocket| DataChannel| |–|–|–| |Encryption| configurable |always| |Reliability| reliable| configurable| |Delivery |ordered| configurable| |Multiplexed| no (extension)| yes| |Transmission| message-oriented| message-oriented| |Binary transfers| yes| yes| |UTF-8 transfers| yes| yes| |Compression| no (extension)| no| 最大的区别应该就是基于的传输层不一样, WebSocket基于TCP, 而DataChannel基于三个协议

  • UDP 端到端的链接
  • DTLS 传输数据的加密
  • SCTP 复用, 流和拥塞控制等功能

    设置和协商

    不管要发送什么类型的数据, 双端必须首先完成一个完整的offer/answer过程, 协商使用的协议和端口, 并且成功完成连接性检验.

下例是SCTP连接中生成的SDP字符:

(... snip ...)
m=application 1 DTLS/SCTP 5000 // 使用DTLS的SCTP
c=IN IP4 0.0.0.0 // 0.0.0.0表明使用增量ICE
a=mid:data
a=fmtp:5000 protocol=webrtc-datachannel; streams=10 // SCTP之上的DataChannel协议, 最高10个平行流 
(... snip ...)

还可以通过配置只时候用端到端的 data-only 连接

var signalingChannel = new SignalingChannel()
var pc = new RTCPeerConnection(iceConfig)
var dc = pc.createDataChannel('namedChannel', {reliable: false}) // 在 RTCPeerConnection 上创建一个新的不可靠 DataChannel连接, 

var mediaConstraints = {
  mandatory: {
    OfferToRecieveAudio: false,
    OfferToRecieveVideo: false
  }
} // 媒体限制

pc.createOffer(offer => {
 ... 
}, null, mediaConstraints); // 生成只有 data 的 offer

SDP 没有提到关于每个 DataChanneld 参数信息, 在所有的应用数据发生之前, WebRTC 客户端初始化了一个发送 DATA_CHANNEL_OPEN消息

一旦 channel 参数发送之后, 两端都可以开始交换数据, 每个建立的 channel 被作为独立的 SCTP 流传输. channels 是同一个 SCTP 联合(association)中多路复用的, 避免了 HOLB.

可以通过配置跳过 DATA_CHANNEL_OPEN 的发送

signalingChannel.send({
  newChannel:true,
  label: 'negotiated channel',
  options: {
    negotiated:true,
    id:10, // 唯一的, 应用具体化的 channelID(整数)
    reliable:true,
    ordered:true,
    protocol: 'appProtocol-v3'
  }
}); // 向另一端发送 channel 的配置
signalingChannel.onmessage = (msg) => {
  if (msg.newChannel) { // 接受另一端发送回来的 channel 配置来创建新的 channel
    dc = pc.createDataChannel(msg.label, msg.option)
  }
}

实际上, 性能上没有多大区别所以没有特殊需求还是使用 RTCPeerConnection 对象来处理协商

配置消息顺序和可靠性

DataChannel 可以通过 WebSocket 兼容的 api 进行p2p传输任意数据, DataChannel 也提供了一个弹性的传输, 可以自定义传输每个通道的传输语义来满足应用需求和传输的数据类型

  • 提供有序和乱序的消息
  • 提供可靠或部分可靠的消息

配置成有序且可靠就相当于 TCP 了. 无序且可靠其实也相当于 TCP 只是不会再有 HOLB, 部分可靠有两种方式实现:

  1. retransmit
    1. 消息不会重发超过应用定义的次数
  2. timeout
    1. 消息不会重发在应用定义的生命周期(毫秒级)之后 注意上述两种方式不能同时配置
conf = {}; // default order and reliable
conf = { ordered: false };  // reliable, no-ordered
conf = { ordered: true,  maxRetransmits: customNum };  // partial reliable with count, order
conf = { ordered: false, maxRetransmits: customNum };  // partial reliable with count, unorder
conf = { ordered: true,  maxRetransmitTime: customMs };  // order patial reliable with timeout
conf = { ordered: false, maxRetransmitTime: customMs };  // unorder and partial reliable with count

conf = { ordered: false, maxRetransmits: 0 };  // unorder, unreliable UDP

var signalingChannel = new SignalingChannel();
var pc = new RTCPeerConnection(iceConfig);

...

var dc = pc.createDataChannel("namedChannel", conf);  // 初始化 datachannel

if (dc.reliable) {
  ...
} else {
  ...
}

方便的地方在于 p2p 可以设置任意一个 DataChannel 的 conf, 可以让优先级高的可靠并有序, 优先级低的使用 UDP.

如果传输比较大的消息怎么办? 假设以下场景:

  • 两端协商使用无序的不可靠传输
    • maxRetransmit 设置为0, 等同于 UDP
  • 丢包率平均在1%以下
  • 某一端打算发送一个大的大约120KB 的消息

WebRTC 客户端被设置成最大的传输单元为1280bytes, 也是 一个 IPv6包的建议传输 MTU. 但是我们把 IP, UDP, DTLS, SCTP 协议的开销都考虑进去, 分别需要20-40, 8, 20-40. 28bytes, 平均一下大约130字节, 还剩1150字节的净荷, 那么就需要总共107个包来传输120KB 的应用消息, 丢包率1%, 如果有一个包丢失, 就会导致整条消息的发送失败.有两个办法(最好同时使用):

  1. 使用不可靠 channel,每条消息应该适应到刚好单个 packet 可以包含的大小, 也就是1150字节以下
  2. 如果消息不能装载单个包中, 需要应用重传策略.但是重传策略如何设置 count 或 timeout? p2p 的环境变幻莫测, 不好设置, 所以最好的办法还是采用方法1

WebRTC 使用案例和性能

构建一个低延时的端到端传输是十分具有挑战性的, 需要考虑NAT 遍历, 连接检查, 信号, 安全, 拥塞控制和其他很多的细节. 而 WebRTC 封装了这些细节, 暴露出一个通用的 API. 即便如此, 不断变化的带宽, 端到端peers 之间的延迟, 媒体传输的高需求, 和不可靠传输的奇葩问题, 都会带来困难

音频, 视频和数据流

端到端的音视频使WebRTC的一个核心用法, getUserMedia API可以使应用获取媒体流, 内建的音视频引擎负责优化, 错误恢复, 流之间的同步.但就算使使用激进的优化和压缩, 音视频传输依然很延迟和带宽所限制

  • HD 质量的流需要1-2Mbps的带宽
  • HD 流需要最小3.5G+的无线连接

大多数运营商会提供不对称的上下传服务, 通常使10:1, 10Mbps的下载, 1Mbps的上传

多方架构

必须要注意如何让单个流被集中和在peers中被分配

1对1的链接使非常容易管理和部署的, peers直接跟互相对话, 不需要更多的优化, 但是扩展到N-way call, 每个peer负责与每一个其他的peer链接(mesh network), 也就是需要维护N-1个链接, 总共需要N*(N-1)个连接,这种架构很容易把带宽占满

尽管mesh网络非常容易设置, 但他是不高效的. 为解决这个问题, 可替代的方案使使用star 拓扑, 每个peer连接在一个supernode上, 这个supernode负责分配流给所有的parties, 每个peer只需要维护和分发N-1个流, 每个其他的peer可以直接通过supernode来通信

supernode可以使另一个peer, 或者使一个专用的处理和分发实时数据的服务器, 最简单的办法就是让初始化者作为supernode, 更好的策略使选择最好吞吐量的peer, 但是也需要额外的”选举”和信号机制

挑选supernnode不是WebRTC的事儿, 需要在应用中处理

最后, supernode可以为专用的甚至第三方服务器, WebRTC可以进行端到端的分布式通信, 但并不意味着没有了中心架构的地方, 单个peer可以与代理服务器建立peer连接, 同时获取WebRTC传输架构和服务器提供的额外服务(高带宽, 低延时, 根据peer带宽动态传输比特率等)的好处

架构和容量选择

除了计划和满足单个peer连接所需要的带宽之外, 每个WebRTC还需要中心化架构, 为

  • signaling
  • NAT and firewall traversal
  • identity verification
  • other services

WebRCT把所有的signaling都放在应用侧, 意味着应用必须最少提供可以发送和接受其他peer消息的能力, signaling数据的大小视不同的用户数, 协议, 编码, 更新频率而定,类似的, signaling 服务的延迟也会对call setup时间和其他signaling交换有巨大的影响.

  • 使用低延迟传输, 比如WebSocket或者SSE
  • 估计和提供充足的荣来来处理必要的signaling
  • 如果可以的化, 一旦peer连接建立, peer可以转换到DataChannel为signaling, 可以帮助减少中心服务器必须处理的signaling的大小, 也能减少signaling通信的延迟.

因为NAT和firewall的流行, 大多数WebRTC应用都需要STUN服务器来进行必要的IP查询来建立端到端连接, 好消息是STUN服务器用在连接设置时, 坏消息时它必须使用STUN协议并且提供处理必要的查询负载

  • 除非两个WebRTC实在同一个局域网中, 否则就需要提供STUN服务器
  • 不像signaling 服务器可以使用任意的协议, STUN服务器只能响应STUN请求, 需要公共server或者必须提供你自己的服务器, stund时常用的开源库

即使有了STUN, 依然有8%到10%的端到端连接将会因为网络策略的特殊性而失败

  • 网络管理员禁止了UDP, 那样就需要使用TURN来将UDP转化为TCP

通常情况下multiparty 网关服务器可以扮演TURN的角色, 但是不像TURN服务器, 可以扮演简单的包代理, 一个只能的代理可能需要更多的CPU和GPU资源来处理每个流优先于转发给每个连接方的最后输出.

数据效率和压缩

WebRTC视频和音频引擎将动态调整媒体流的比特率.应用可以设置并更新媒体限制(比如: 视频清晰度, 帧率等), 引擎去做调整的事情

但是DataChannel不一样, 他是用来传输任意类型的应用数据, 类似WebSocket, DataChannel API可以接受任意二进制或UTF-8编码的应用数据. 但是不进行任何进一步操作去减少传输数据的大小. 这是WebRTC应用去优化二进制净荷并压缩UTF-8内容的责任

进一步说, 不像WebSocket, 是基于可靠的有序传输, WebRTC应用必须考虑额外的因为UDP, DTLS和SCTP协议, 和部分可靠数据传输的特殊性.

WebSocket提供了一套自动压缩传输数据的extension, WebRTC没有, 会把应用传的消息完全透传.

性能检测清单

Signaling service

  • 使用低延时传输
  • 提供充足的容量
  • 一旦连接建立考虑使用DataChannel来使用signaling

Firewall 和 NAT traversal

  • 初始化RTCPeerConnection时提供一个STUN服务器
  • 尽可能使用trickle ICE, 更多的信号, 更快的设置
  • 提供TURN服务器, 为P2P连接失败做准备
  • 预期并提供足够的容量来满足TRUN传输

Data distribution

  • 考虑使用supernode 或一个为多方通信的中间件
  • 考虑优化传输给其他终端前的中间件接受的数据

Data efficiency

  • 为视频和音频流具体化合适的媒体限制
  • 通过DataChannel优化二进制净荷和UTF8内容的发送
  • 监视DataChannel缓存的数据数量, 根据网络连接的条件动态变化

Delivery and reliability

  • 使用无序传输, 避免线头阻塞
  • 如果使用有序传输, 最小化消息大小减小线头阻塞的影响
  • 发送小的消息(< 1150bytes) 来最小化分片应用消息时的包丢失影响
  • 为部分可靠传输设置适当的重传数量和超时. 正确的设置时根据消息的大小, 应用数据类型, peers之间的延时来定的.