前段时间研究了下WebSocket,趁着还没忘总结下。
其实除了WebSocket还研究了其他一些东西,顺便还写了个调试WebSocket的小工具,自己会写前端就是好。。。
先考个古
一直以来,前端都缺少一种实时的跟后端交互的机制。这其实是http协议的限制。http协议是典型的“请求-响应”式的协议,换句话说后端不能主动向前端“推送”数据,而必须等待前端先发起请求。
为了实现数据的实时更新,人们想了很多办法:
- 短轮询:最简单的办法。既然必须要前端去获取数据更新,又要实时,干脆就不断的去服务端查询,比如每隔1秒就发起一次ajax看是否有新数据。典型的quick and dirty,会有很多无用的请求,对服务端压力也会比较大。
- 长轮询:还是轮询,但处理每次请求时,如果没有新数据,服务端不会立刻返回,而是会先hold一会,直到有数据更新或者超时。前端在一次请求结束后立即发起下一次请求,而不是像短轮询一样定时发起请求。好处显而易见,对后端的请求少了很多,但其实没有解决根本问题。这里有个问题就是后端要hold一个请求多久?有文献提到过最好是30秒~120秒,这个要看实际情况,受网络环境影响都很大,比如防火墙。
- HTTP Streaming:原理其实是HTTP Chunking,中文译作“分块传输编码”。当http响应中
Transfer-Encoding
的值等于chunked
时,服务端不必一次性返回所有数据,而是可以分批次的发送。利用这一特性,可以实现后端向前端的实时的数据推送。在此基础上,又衍生出了Comet和Server-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的优点在于:
- 真正的双向通信。而HTTP只能由客户端发起请求。
- HTTP请求中带有大量的header,很多冗余信息,其实很多流量被浪费掉了,WebSocket则没有这个问题。
- WebSocket协议支持各种Extension,可以实现多路复用等功能。
WebSocket虽然是应用层协议,但却封装的非常“薄”,可以认为就是对TCP的一个简单封装(要不怎么叫Socket呢),在WebSocket中传输的都是字节流,至于如何解释,要交给上层去做。所以往往会有一个“子协议”的概念,比如后面将要提到的STOMP。
握手过程
WebSocket虽然是独立于HTTP的另一种协议,但建立连接时却需要借助HTTP协议进行握手,这也是WebSocket的一个特色,利用了HTTP协议中一个特殊的header:Upgrade
。在双方握手成功后,就跟HTTP没什么关系了,会直接在底层的TCP Socket基础上进行通信。
握手请求的一个例子:
|
|
删除了一些无关的header,一些值得注意的地方:
- 必须是GET请求。WebSocket的URI都是
ws://
开头的。 - 必须包含
Connection: Upgrade
和Upgrade: websocket
两个header。 Sec-WebSocket-Version
用于指定WebSocket协议的版本,一般都是13。Sec-WebSocket-Key
是一个base64编码的字符串,用于确认这是一个WebSocket握手请求而不是普通的http请求。服务端要将这个字符串解码然后和某个固定的字符串拼接后求SHA-1,然后base64编码后再返回。具体的计算过程可以参考RFC。Sec-WebSocket-Extensions
用于协商能使用哪些扩展。- 注意跨域问题,服务端必须要校验Origin字段。
如果握手成功,返回HTTP 101
响应:
|
|
注意其中的Sec-WebSocket-Accept
字段,就是服务端根据Sec-WebSocket-Key
计算后的值。客户端必须校验这个值,校验通过才能建立连接。
为啥WebSocket会使用这种设计?个人猜测,借助HTTP进行握手有一些好处:
- 这样设计WebSocket会使用和HTTP相同的端口(80或443),可以穿过很多防火墙。
- 直接使用HTTP header中已有的信息,比如Cookie。很多服务端都会在Cookie中种一个sessionid,WebSocket可以直接使用这个sessionid去识别不同的会话,Spring就是这么搞的。
- 暂时没想出来。。。
话说HTTP都是快20年之前的东西了,就有这种设计,也真是NB。。。
WebSocket Frame
握手成功后,双方就可以切换到WebSocket协议进行通信了。
WebSocket中数据交换的基本单位是“帧(Frame)”,其格式参考RFC中的第五章:Data Framing。
可以对比TCP的header去理解。几个值得注意的地方:
- FIN位用于指示是最后一个帧,在分片的情况下才有用。
- OPCODE字段用于指示帧的类型,4位,所以最多有16种帧。但其实很多没用到:
- %x0 代表一个继续帧
- %x1 代表一个文本帧
- %x2 代表一个二进制帧
- %x3-7 保留用于未来的非控制帧
- %x8 代表连接关闭
- %x9 代表ping
- %xA 代表pong
- %xB-F 保留用于未来的控制帧
- 客户端发送数据时必须要有mask,不知道是为啥,也许是出于安全考虑?
- payload len是变长的,可能是7 bits、7+16 bits或者 7+64 bits。
- 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,大概是这个样子:
|
|
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类传输方式(就是上面讲过的),优先级依次降低:
- WebSocket,最优选择
- Streaming,如果不支持CORS跨域,还要用iframe+postMessage之类的去实现跨域
- Polling,最传统的轮询方式
客户端代码:
|
|
SockJS建立连接时会先请求一次/info
接口,比如你要连接的url是/sockjs/handlerA
,就会先请求/sockjs/handlerA/info
(其实就是一次普通的GET请求):
|
|
服务端会返回一些信息,表示服务端支持哪些传输方式:
|
|
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协议,最初是设计出来给各种脚本语言用的,跟它对等的应该是AMQP、MQTT等协议。由于工作原因,我之前还研究过一点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之类的机制,不再赘述,详情请参考规范。
一些客户端代码示例:
|
|
WebSocket with Spring
Spring 4开始支持WebSocket,相关配置参考文档,我这边也有一个例子。
注意添加必要的依赖:
|
|
Spring对WebSocket的支持还是挺全面的,支持直接使用low-level API,支持sockjs,也支持STOMP,跨域/Handshake Interceptor之类的也都支持。
值得注意的地方:
- 对容器版本有要求,低版本的容器中很可能无法使用。目前只支持Tomcat 7.0.47+、Jetty 9.1+、GlassFish 4.1+、WebLogic 12.1.3+,对于不支持的容器,要自己实现RequestUpgradeStrategy和WebSocketHttpRequestHandler等。
- Spring提供了很多可以配置的参数,比如线程池大小/buffer大小/消息大小限制/心跳等等,但很多参数文档中只是提了一下,没说怎么配。。。我还要去翻dtd文件或者google才知道如何配置。
- Spring提供了java版的SockJS client和STOMP client,一般用于调试。
- 可以自动识别http或sockjs的session。
- 对于websocket,spring没有专门的认证机制,而是直接用http的认证。
- 可以用ApplicationListener监听各种事件,非常有用。
- 专门为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/滑动窗口/重发控制/流控/延迟应答等等,很多时候我们都只是在重复前人啊。。。