Realtime Web

前段时间研究了下WebSocket,趁着还没忘总结下。

其实除了WebSocket还研究了其他一些东西,顺便还写了个调试WebSocket的小工具,自己会写前端就是好。。。

先考个古

一直以来,前端都缺少一种实时的跟后端交互的机制。这其实是http协议的限制。http协议是典型的“请求-响应”式的协议,换句话说后端不能主动向前端“推送”数据,而必须等待前端先发起请求。

为了实现数据的实时更新,人们想了很多办法:

  • 短轮询:最简单的办法。既然必须要前端去获取数据更新,又要实时,干脆就不断的去服务端查询,比如每隔1秒就发起一次ajax看是否有新数据。典型的quick and dirty,会有很多无用的请求,对服务端压力也会比较大。
  • 长轮询:还是轮询,但处理每次请求时,如果没有新数据,服务端不会立刻返回,而是会先hold一会,直到有数据更新或者超时。前端在一次请求结束后立即发起下一次请求,而不是像短轮询一样定时发起请求。好处显而易见,对后端的请求少了很多,但其实没有解决根本问题。这里有个问题就是后端要hold一个请求多久?有文献提到过最好是30秒~120秒,这个要看实际情况,受网络环境影响都很大,比如防火墙。
  • HTTP Streaming:原理其实是HTTP Chunking,中文译作“分块传输编码”。当http响应中Transfer-Encoding的值等于chunked时,服务端不必一次性返回所有数据,而是可以分批次的发送。利用这一特性,可以实现后端向前端的实时的数据推送。在此基础上,又衍生出了CometServer-sent Event(一般简称SSE或EventSource,都是同一个东西)等技术。优点在于这是真正的实时推送,服务端可以根据需要控制数据何时发送。缺点在于只能单向通信(后端->前端),不是全双工的。所以一般要额外使用一些请求用于前端到后端的数据传输。
  • Flash/Silverlight之类的插件:这个没啥好说的了,都装了插件了,还有什么功能不能实现的。。。缺点在于普及率不高,而且都是私有的协议,感觉都是上个世代的东西了。
  • WebSocket:真正意义上的全双工实时通信。在HTML5引入WebSocket API后,其他方案注定都会变成历史的垃圾堆。。。缺点在于各个浏览器对HTML5的支持程度不一,但这只是时间问题。另一个问题是服务端要做为WebSocket做一些改造。毕竟无论轮询还是streaming,都是基于http的,现有的各种服务端基本都能直接支持。而WebSocket则是一种全新的协议。

综上,可以看出实现实时web的技术大概可以分为3类:轮询/streaming/websocket。借用一张图:

此外有一些概念需要澄清:

  • 长连接是一个很模糊的词,不是特指某种技术,长轮询/streaming/websocket都可以算是长连接,别纠结概念。。。长连接一般需要服务端做一些针对性的优化。
  • 同样,Comet也是一个很模糊的词。Wiki中称之为“umbrella term”,指的是一种服务端推送的“风格”,也不是某种具体的技术。不过一般是基于streaming实现的。很多服务端号称支持Comet,比如tomcat,但大多是自己搞了一套私有的API,尽量不要用,否则代码就会跟特定的容器强耦合了。
  • 很多人将HTTP Keep Alive称为长连接。但keep-alive的含义是在同一个TCP连接上可以进行多次http请求(http 1.0时代每次请求都必须打开一个新的TCP连接),跟我们所说的“实时通信”的长连接不是一个概念,注意区分。
  • 经常见到一个问题:TCP是否是全双工的?答案应该是肯定的,毕竟WebSocket也是基于TCP的。但其实这个问题并不准确,TCP只是定义了传输层的协议,可以双向传输。至于是否能全双工,是取决于底层硬件的。

WebSocket

WebSocket的原理其实挺简单的,socket通信在后端早就被玩了无数遍了,只不过对于web端还是个新鲜事。

首先要注意区分WebSocket协议和WebSocket API。
WebSocket是一种独立的基于TCP的通信协议,类似于HTTP、FTP。在OSI的网络模型中,它应该是属于应用层的。WebSocket协议在2011年被IETF定为标准RFC6455,并被RFC7936所补充规范。虽然名字叫做WebSocket,最初发明它的目的也是为了web的实时通信,但实际上WebSocket也可以用于其他环境,比如完全可以用java写一个WebSocket客户端。只不过这么用的比较少,毕竟与其用WebSocket不如直接用TCP Socket。
对于RFC6455,这里还有一个中文版
WebSocket API则是由W3C制定的在浏览器中使用WebSocket协议进行通信的标准API(主要是js环境中的WebSocket对象),是HTML5规范中的一项,似乎目前还没有正式发布,处于候选的状态,但主流的浏览器都已经支持了。

相对于传统的HTTP长连接,WebSocket的优点在于:

  1. 真正的双向通信。而HTTP只能由客户端发起请求。
  2. HTTP请求中带有大量的header,很多冗余信息,其实很多流量被浪费掉了,WebSocket则没有这个问题。
  3. WebSocket协议支持各种Extension,可以实现多路复用等功能。

WebSocket虽然是应用层协议,但却封装的非常“薄”,可以认为就是对TCP的一个简单封装(要不怎么叫Socket呢),在WebSocket中传输的都是字节流,至于如何解释,要交给上层去做。所以往往会有一个“子协议”的概念,比如后面将要提到的STOMP。

握手过程

WebSocket虽然是独立于HTTP的另一种协议,但建立连接时却需要借助HTTP协议进行握手,这也是WebSocket的一个特色,利用了HTTP协议中一个特殊的header:Upgrade。在双方握手成功后,就跟HTTP没什么关系了,会直接在底层的TCP Socket基础上进行通信。

握手请求的一个例子:

1
2
3
4
5
6
7
8
9
10
GET ws://localhost:8080/handlerA HTTP/1.1
Host: localhost:8080
Connection: Upgrade
Pragma: no-cache
Cache-Control: no-cache
Upgrade: websocket
Origin: http://localhost:8080
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: IbMym0RGM6WulBh40amXHw==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits

删除了一些无关的header,一些值得注意的地方:

  1. 必须是GET请求。WebSocket的URI都是ws://开头的。
  2. 必须包含Connection: UpgradeUpgrade: websocket两个header。
  3. Sec-WebSocket-Version用于指定WebSocket协议的版本,一般都是13。
  4. Sec-WebSocket-Key是一个base64编码的字符串,用于确认这是一个WebSocket握手请求而不是普通的http请求。服务端要将这个字符串解码然后和某个固定的字符串拼接后求SHA-1,然后base64编码后再返回。具体的计算过程可以参考RFC。
  5. Sec-WebSocket-Extensions用于协商能使用哪些扩展。
  6. 注意跨域问题,服务端必须要校验Origin字段。

如果握手成功,返回HTTP 101响应:

1
2
3
4
5
6
7
HTTP/1.1 101 Switching Protocols
Server: Apache-Coyote/1.1
Upgrade: websocket
Connection: upgrade
Sec-WebSocket-Accept: FcSPcCOgjs4tIy0aH9in+QmWXcg=
Sec-WebSocket-Extensions: permessage-deflate;client_max_window_bits=15
Date: Tue, 21 Mar 2017 06:17:04 GMT

注意其中的Sec-WebSocket-Accept字段,就是服务端根据Sec-WebSocket-Key计算后的值。客户端必须校验这个值,校验通过才能建立连接。

为啥WebSocket会使用这种设计?个人猜测,借助HTTP进行握手有一些好处:

  1. 这样设计WebSocket会使用和HTTP相同的端口(80或443),可以穿过很多防火墙。
  2. 直接使用HTTP header中已有的信息,比如Cookie。很多服务端都会在Cookie中种一个sessionid,WebSocket可以直接使用这个sessionid去识别不同的会话,Spring就是这么搞的。
  3. 暂时没想出来。。。

话说HTTP都是快20年之前的东西了,就有这种设计,也真是NB。。。

WebSocket Frame

握手成功后,双方就可以切换到WebSocket协议进行通信了。
WebSocket中数据交换的基本单位是“帧(Frame)”,其格式参考RFC中的第五章:Data Framing

可以对比TCP的header去理解。几个值得注意的地方:

  1. FIN位用于指示是最后一个帧,在分片的情况下才有用。
  2. OPCODE字段用于指示帧的类型,4位,所以最多有16种帧。但其实很多没用到:
    • %x0 代表一个继续帧
    • %x1 代表一个文本帧
    • %x2 代表一个二进制帧
    • %x3-7 保留用于未来的非控制帧
    • %x8 代表连接关闭
    • %x9 代表ping
    • %xA 代表pong
    • %xB-F 保留用于未来的控制帧
  3. 客户端发送数据时必须要有mask,不知道是为啥,也许是出于安全考虑?
  4. payload len是变长的,可能是7 bits、7+16 bits或者 7+64 bits。
  5. payload data由部分组成,分别是“扩展数据(Extension Data)”和“应用数据(Application Data)”。还记得握手时的Sec-WebSocket-Extensions么,如果双方同意使用某个扩展,才会有扩展数据。

从上文中可以看出,WebSocket将帧分为两类:

控制帧:

  • Close:用于关闭当前WebSocket连接。接收方也要响应一个Close帧,然后关闭TCP连接。
  • Ping/Pong:用于维持心跳。当收到一个Ping帧时,必须在响应中发送一个Pong帧。

数据帧:

  • Text:说明payload data是UTF-8编码的字节流。
  • Binary:字节流如何解释完全交给上层。

从数据帧的定义可以看出,WebSocket协议中定义好的“逻辑”很少,很多都需要上层应用去补充。所以说WebSocket是对TCP非常薄的一层封装,往往要搭配“子协议”使用。

话说,在看RFC的过程中还了解到一个有趣的东西:ABNF,可以用于形式化的描述网络协议,数学中还是有挺多好玩的东西的。

Extension

WebSocket协议定义了Extension,在frame中也定义了Extension Data,决定了服务端如何解释Application Data,进而可以对原有的协议做一些扩展,但却没有定义任何具体的Extension,RFC中的原文是:“This document doesn’t define any extension, but implementations MAY use extensions defined separately.”。

之前说过WebSocket可以支持多路复用,就是以Extension的形式出现的,但似乎还只是草案,见这里,似乎实际中也没什么应用。客户端倒是有个库可以支持,但服务端似乎大多不支持Extension。

也许以后才会出现更多的Extension吧。

客户端代码

如果直接使用原生的WebSocket API,大概是这个样子:

1
2
3
4
5
6
7
8
9
// 注意url必须是ws://开头的
var host = 'ws://localhost:8080/handlerA';
// 这个WebSocket是浏览器内置的对象,必须要浏览器支持
var ws = new WebSocket(host);
ws.binaryType = 'arraybuffer';
ws.onopen = function(e){on_open(e)};
ws.onmessage = function(e){on_message(e)};
ws.onclose = function(e){on_close(e)};

SockJS

SockJS本质上是一种fallback机制,由于不是所有的浏览器都支持WebSocket,所以在某些环境下我们必须要选择Streaming或轮询的方案,而人工选择这些方案实在太麻烦了,要兼容的情况也太多。SockJS致力于隐藏这些复杂性,自动根据服务端和客户端的情况选择合适的方案,然后对外提供统一的API,应用层无需考虑具体的传输方式。
不过,fallback可能有一些限制,原文:“for some fallbacks transports it is not possible to open more than one connection at a time to a single server.”

总的来说SockJS支持3类传输方式(就是上面讲过的),优先级依次降低:

  1. WebSocket,最优选择
  2. Streaming,如果不支持CORS跨域,还要用iframe+postMessage之类的去实现跨域
  3. Polling,最传统的轮询方式

客户端代码:

1
2
3
4
5
6
7
8
9
10
// 前提是先引入sockjs-client
// sockjs的url必须是http/https
var host = 'http://localhost:8080/sockjs/handlerA';
var ws = new SockJS(host);
// sockjs的api跟原生的websocket是基本一致的,它的目标就是提供一致的编码体验
ws.binaryType = 'arraybuffer';
ws.onopen = function(e){on_open(e)};
ws.onmessage = function(e){on_message(e)};
ws.onclose = function(e){on_close(e)};

SockJS建立连接时会先请求一次/info接口,比如你要连接的url是/sockjs/handlerA,就会先请求/sockjs/handlerA/info(其实就是一次普通的GET请求):

1
2
3
4
5
6
7
8
9
GET /sockjs/handlerA/info?t=1494472167320 HTTP/1.1
Host: localhost:8080
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
Accept: */*
Referer: http://localhost:8080/index.html
Accept-Encoding: gzip, deflate, sdch, br
Accept-Language: zh-CN,zh;q=0.8,en;q=0.6

服务端会返回一些信息,表示服务端支持哪些传输方式:

1
{"entropy":-490091108,"origins":["*:*"],"cookie_needed":true,"websocket":true}

SockJS会结合服务端的信息和客户端的环境,选择合适的传输方式,选择过程还有点复杂的,最多可能需要3~4轮的尝试,再加上DNS解析的耗时,所以建立连接可能会稍微慢一点。
以下只讨论使用WebSocket的情况。

如果服务端和客户端都支持WebSocket,之后的通信会通过一个特殊的URI,类似这种ws://localhost:8080/sockjs/handlerA/489/cn05bzby/websocket,这个url的格式是:ws://host:port/myEndpoint/{server-id}/{session-id}/{transport},其中:

  • {server-id} - useful for routing requests in a cluster but not used otherwise.
  • {session-id} - correlates HTTP requests belonging to a SockJS session.
  • {transport} - indicates the transport type, e.g. “websocket”, “xhr-streaming”, etc.

不过目测server-id和session-id都是随机生成的,每次连接都会改变,不知道有啥用。也许是在负载均衡中会用到,Spring的文档中专门讲到过WebSocket的负载均衡。

sockjs其实会对发送的信息做一些修改,打开chrome的调试工具就可以看到。比如客户端发送的信息会被包装为a["message1","message2"](JSON数组)的形式,还有字母h作为心跳(每25秒一次),o表示连接已建立,c表示关闭连接之类的,也算是一种简单的子协议吧。

参考资料:
https://github.com/sockjs/sockjs-client/wiki

STOMP

STOMP其实跟WebSocket没啥必然关系,它是一种mq协议,最初是设计出来给各种脚本语言用的,跟它对等的应该是AMQPMQTT等协议。由于工作原因,我之前还研究过一点MQTT、XMPP之类的。
如果说AMQP的特点是“强大”(企业级的mq规范,提出了各种model),MQTT的特点是“紧凑”(尤其适用于嵌入式设备),那STOMP的特点就是“简单”。正如它的名字(Simple Text Orientated Messaging Protocol),它的设计思路一直就是保持简单的协议、简单的API。而且它是一种Text-Based Protocol,跟HTTP类似,可读性非常好,也非常易于跨平台。只要你的语言提供Socket操作,你都能很快的实现一个STOMP客户端。甚至直接用telnet当作客户端也可以。

但简单也就意味着功能上要有所取舍。STOMP中完全没有AMQP中的queue、exchange等概念,换句话说,只有publish-subscribe模式,如果想要更灵活的路由和处理逻辑,就会有点麻烦。

WebSocket通信中最常用的子协议就是STOMP了,当然也有用MQTT的,你也可以用自己的私有协议,但用STOMP的好处在于:

  • STOMP的可靠性已经经过广泛验证
  • 支持STOMP的服务端很多:RabbitMQ、ActiveMQ等等
  • 浏览器上已经有了可用的客户端:stomp.js

STOMP 1.1的规范:http://stomp.github.io/stomp-specification-1.1.html
stomp.js的文档:http://jmesnil.net/stomp-websocket/doc/

话说STOMP协议真的是简单易懂,很快就能看完,自己实现一套估计也不费劲。相比之下看各种RFC真是痛苦。。。

STOMP中的消息都被抽象为“帧”(有点类似AMQP中message的概念),帧的格式和HTTP非常类似,分为command、header、body三部份。其中比较重要的就是SUBSCRIBE/SEND/MESSAGE帧。SUBSCIRBE帧用于订阅某个destination,SEND帧用于发送数据,MESSAGE帧用于从服务端接收数据。尤其注意下其中的destination header,有点像传统mq中的topic。STOMP不限定destination的格式,可以是任意格式字符串,由服务端去解释,不过一般都是/a/b这种类似路径的格式。

此外,STOMP还支持认证、事务、ACK之类的机制,不再赘述,详情请参考规范。

一些客户端代码示例:

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
// 获取stomp对象的几种方式
Stomp.client(url)
Stomp.over(ws)
// 连接服务端的几种方式
client.connect(login, passcode, connectCallback);
client.connect(login, passcode, connectCallback, errorCallback);
client.connect(login, passcode, connectCallback, errorCallback, host);
client.connect(headers, connectCallback);
client.connect(headers, connectCallback, errorCallback);
client.disconnect(function() {
alert("See you next time!");
};
client.heartbeat.outgoing = 20000; // 设置心跳
client.heartbeat.incoming = 0;
// client同时是生产者和消费者
client.send("/queue/test", {priority: 9}, "Hello, STOMP"); // 发送消息
client.send(destination, {}, body);
// 订阅消息
var subscription = client.subscribe("/queue/test", callback);
// 取消订阅
subscription.unsubscribe();
// start the transaction
var tx = client.begin();
// send the message in a transaction
client.send("/queue/test", {transaction: tx.id}, "message in a transaction");
// commit the transaction to effectively send the message
tx.commit();

WebSocket with Spring

Spring 4开始支持WebSocket,相关配置参考文档,我这边也有一个例子

注意添加必要的依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-messaging</artifactId>
<version>4.2.6.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-websocket</artifactId>
<version>4.2.6.RELEASE</version>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>3.1.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>javax.websocket</groupId>
<artifactId>javax.websocket-api</artifactId>
<version>1.1</version>
<scope>provided</scope>
</dependency>

Spring对WebSocket的支持还是挺全面的,支持直接使用low-level API,支持sockjs,也支持STOMP,跨域/Handshake Interceptor之类的也都支持。

值得注意的地方:

  1. 对容器版本有要求,低版本的容器中很可能无法使用。目前只支持Tomcat 7.0.47+、Jetty 9.1+、GlassFish 4.1+、WebLogic 12.1.3+,对于不支持的容器,要自己实现RequestUpgradeStrategy和WebSocketHttpRequestHandler等。
  2. Spring提供了很多可以配置的参数,比如线程池大小/buffer大小/消息大小限制/心跳等等,但很多参数文档中只是提了一下,没说怎么配。。。我还要去翻dtd文件或者google才知道如何配置。
  3. Spring提供了java版的SockJS client和STOMP client,一般用于调试。
  4. 可以自动识别http或sockjs的session。
  5. 对于websocket,spring没有专门的认证机制,而是直接用http的认证。
  6. 可以用ApplicationListener监听各种事件,非常有用。
  7. 专门为websocket新增了一个scope:@Scope(scopeName = "websocket"),可以为每个websocket session单独组装一个bean。

在使用STOMP时,Spring会作为一个简单的in-memory borker存在,但也加入了一些自己特殊的逻辑,关键是要理解消息的流动过程:

可以看出Spring的路由策略非常简单,只是基于消息的destination做前缀匹配。某些消息会直接流向broker,另一些消息则会流向Controller方法,经过一些业务逻辑处理后再流向broker。进入broker的消息则会被被直接发给客户端。Spring提供了非常多的注解用于消息的处理,详见文档。如果有些特殊逻辑不能在Controller中处理,也可以使用Channel Interceptor,上图中的request/broker/response三个channel都可以配置。

Spring默认会使用一个in-memory broker,但是也可以配置为外部的RabbitMQ、ActiveMQ等,称作relay broker,更加利于扩展和维护。

其他

关于EventSource的更多资料:
http://javascript.ruanyifeng.com/htmlapi/eventsource.html
http://stackoverflow.com/questions/5195452/websockets-vs-server-sent-events-eventsource

RabbitMQ支持的协议类型:
https://www.rabbitmq.com/protocols.html

几种MQ协议的对比:
https://blogs.vmware.com/vfabric/2013/02/choosing-your-messaging-protocol-amqp-mqtt-or-stomp.html

google的过程中经常接触到所谓的wire-level protocol,感觉这也是一个有些模糊的词。wire-level protocol不一定是binary的,也可能是text的。根据wiki的说法,SOAP也算是wire-level。个人感觉,只要协议中规定了数据中如何在网络中传输(关键是理解wire的概念),就算是wire-level protocol。与之相对的反义词是API,比如JMS、JDBC,只规定如何使用,不规定数据如何传输。

为啥会去研究websocket呢,因为直播中的弹幕有用到。顺便也研究了下直播常用的协议:RTMP(flash),HLS(苹果),RTP/RTCP(一般用于视频电话之类的)。我们在H5端用的是HLS,兼容性好,但是延迟比较大。为了克服这个缺点,有人提出可以用WebSocket传输视频帧,然后在canvas上绘制,居然还真有这种案例,感觉这是邪路啊。。。不过说不定像react-canvas(这货star都破万了)一样,意外的好用。。。

顺便还复习了一下TCP,感慨于TCP的各种精妙设计,ACK/滑动窗口/重发控制/流控/延迟应答等等,很多时候我们都只是在重复前人啊。。。