WebSocket 协议运行在TCP协议之上,与Http协议同属于应用层网络数据传输协议。WebSocket相比于Http协议最大的特点是:允许服务端主动向客户端推送数据
(从而解决Http 1.1协议实现中客户端只能通过轮询方式获取服务端推送数据造成的资源消耗和消息延时等问题)。
WebSocket 协议诞生于2008年6月,并在2011年12月成为 RFC6455
https://www.rfc-editor.org/rfc/rfc6455.txt 国际标准,诞生之初被应用于HTML5相关规范中(移动互联网时代,也多应用于服务端向客户端推送消息的场景)。
WebSocket协议定义中客户端和服务端只需要完成一次握手
,两者之间就可以建立持久性的连接,并进行双向数据传输。而且WebSocket 握手阶段采用的是HTTP 协议;完成协议握手后,后续的数据通信采用WebSocket数据格式通信。
这篇文章,我们按照如下顺序,学习一下 WebSocket 全向数据传输协议:
- WebSocket 诞生;
- WebSocket 简介;
- WebSocket 使用举例;
- WebSocket 协议握手;
- WebSocket 数据帧格式详解;
- WebSocket 关闭连接;
- WebSocket 心跳消息;
一、诞生
早期的很多网站为具备数据推送能力,所在用的技术基本都是HTTP轮询
。
轮询是由由客户端每隔一段时间(如每隔5s)向服务器发出HTTP请求,服务端接收到请求后向客户端返回最新的数据。
客户端的轮询方式一般为短轮询
或长轮询
。
短轮询
:
一般是由客户端每隔一段时间(如每隔5s)向服务器发起一次普通 HTTP 请求
。服务端查询当前接口是否有数据更新,若有数据更新则向客户端返回最新数据,若无则提示客户端无数据更新。长轮询
:
一般是由客户端向服务端发出一个设置较长网络超时时间的 HTTP 请求,并在Http连接超时前,不主动断开连接;待客户端超时或有数据返回后,再次建立一个同样的Http请求,重复以上过程
。
以上两种轮询方式也带来了很明显的缺点
:
- 首先,客户端需要不断的向服务器发出请求,在消耗较多客户端资源的情况下,服务端并不一定有新的数据下发;
- 其次,HTTP协议请求与回复消息中,需包含较长的头部信息,其中真正有效的数据有可能只占较小的一部分,带来较多的带宽资源消耗。
- 另外,若服务端在同一时间存在连续频繁的数据变化(例如:聊天室场景中),客户端获知数据更新相对较慢(可能存在时间的滞后性)无法保证客户端的用户体验。
因此,工程师们一直在思考,有没有更好的方法,可以减少资源的消耗,同时提高客户端的用户体验 !
,在以上情况下 WebSocket 孕育而生。
二、简介
WebSocket 协议运行在TCP协议之上,与Http协议同属于应用层网络数据传输协议。WebSocket相比于Http协议最大的特点是:允许服务端主动向客户端推送数据
。
WebSocket 协议诞生于2008年6月,并在2011年12月成为 RFC6455
https://www.rfc-editor.org/rfc/rfc6455.txt 国际标准,诞生之初被应用于HTML5相关规范中(移动互联网时代,也多应用于服务端向客户端推送消息的场景)。
WebSocket协议定义中客户端和服务端只需要完成一次握手
,两者之间就可以建立持久性的连接,并进行双向数据传输。
2.1 协议特点
建立在 TCP 协议之上,属于应用层协议
;与 HTTP 协议有着良好的兼容性
:
WebSocket 默认端口是80,WebSocket Over SSL的默认端口是443;
WebSocket 握手阶段采用的是 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器。数据格式比较轻量,性能开销小
:
WebSocket数据帧格式相对简单,用于协议控制的数据包头部相对较小(在不包含扩展的情况下,对于服务端到客户端的内容,此头部大小只有2至10字节)。数据实时性较高
:
由于WebSocket是全双工的,所以服务端可以随时主动给客户端下发数据
,相对于HTTP请求需要待客户端发起请求服务端才能响应,延迟明显更少,用户体验更高。- 可以发送的载荷数据可以是
文本数据
,也可以是二进制数据
;
WebSocket协议的数据载荷可以是普通的文本数据,也可以是二进制数据,数据量相对较大时,还可以分片多帧进行数据发送与接收。
2.2 协议标识
WebSocket 的协议标识符是ws
(如果Over SSL,则为wss
):
ws://example.com:80/some/path wss://example.com:443/some/path
三、使用举例
对于客户端
WebSocket 的使用方面:OkHttp框架为我们提供了较好的使用封装
。
/** * WebSocketAgent * * @author https://blog.csdn.net/xiaxl */ public class WebSocketAgent { // private static final String TAG = "WebSocketAgent"; // OkHttpClient private OkHttpClient mOkHttpClient; // WebSocket private WebSocket mWebSocket; private WebSocketCallback mWebSocketCallback; public WebSocketAgent() { mOkHttpClient = new OkHttpClient.Builder() .writeTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS) // 每隔 30s 客户端主动发送ping保活消息 .pingInterval(30, TimeUnit.SECONDS) .build(); Log.d(TAG, "newWebSocket connection"); } public void connect() { //建立连接 Request request = new Request.Builder() .url(AgentConstant.WEBSOCKET_APP_URL) .build(); mWebSocket = mOkHttpClient.newWebSocket(request, new OkHttpWebSocketListener()); } public void sendMessage(String message) { Log.d(TAG, "sendMessage text: " + message); if (mWebSocket != null) { mWebSocket.send(message); } else { Log.e(TAG, "mWebSocket is null, please call connect first.", null); } } public void sendMessage(byte... data) { Log.d(TAG, "sendMessage byte: " + data); if (mWebSocket != null) { ByteString bs = ByteString.of(data); mWebSocket.send(bs); } else { Log.e(TAG, "mWebSocket is null, please call connect first.", null); } } public void close(int code, String reason) { if (mWebSocket != null) { mWebSocket.close(code, reason); } else { Log.e(TAG, "mWebSocket is null, please call connect first.", null); } } public void setWebSocketCallback(WebSocketCallback callback) { mWebSocketCallback = callback; } public class OkHttpWebSocketListener extends WebSocketListener { @Override public void onOpen(WebSocket webSocket, Response response) { // 注意:这里回到的是异步线程 if (response.code() == 101) { // 连接成功 } } // 当收到文本(类型{@code 0x1})消息时调用 @Override public void onMessage(WebSocket webSocket, String text) { // 注意:这里回到的是异步线程 if (mWebSocketCallback != null) { mWebSocketCallback.onWebSocketMessage(text); } } // 当收到二进制(类型为{@code 0x2})消息时调用。 @Override public void onMessage(WebSocket webSocket, ByteString bytes) { // 注意:这里回到的是异步线程 } // 当远程对等体指示不再有传入的消息将被传输时调用。 @Override public void onClosing(WebSocket webSocket, int code, String reason) { // 注意:这里回到的是异步线程 if (mWebSocketCallback != null) { mWebSocketCallback.onWebSocketClosing(code, reason); } } // 当两个对等方都表示不再传输消息并且连接已成功释放时调用。 没有进一步的电话给这位听众。 @Override public void onClosed(WebSocket webSocket, int code, String reason) { // 注意:这里回到的是异步线程 if (mWebSocketCallback != null) { mWebSocketCallback.onWebSocketClosed(code, reason); } } // 由于从网络读取或向网络写入错误而关闭Web套接字时调用。 @Override public void onFailure(WebSocket webSocket, Throwable t, Response response) { // 注意:这里回到的是异步线程 if (mWebSocketCallback != null) { mWebSocketCallback.onWebSocketError(t, response); } // TODO 切换到主线程中后,考虑一下断开连接后的重试逻辑 } } public abstract static class WebSocketCallback { public void onWebsocketConnected() {} public void onWebSocketMessage(String text) {} public void onWebSocketClosing(int code, String reason) {} public void onWebSocketClosed(int code, String reason) {} public void onWebSocketError(Throwable t, Response response) {} } }
四、协议握手
WebSocket 协议握手复用了HTTP协议:
客户端通过HTTP请求
与WebSocket服务端协商升级协议到 Websocket 协议
。服务端
通过HTTP响应数据回应客户端的升级请求
,完成协议握手。
完成协议握手后,后续的数据交换则遵照 WebSocket 的协议进行。
4.1 握手请求
客户端通过 HTTP/1.1
协议 GET
请求发起协议升级请求:
GET /chat HTTP/1.1 Host: server.example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Version: 13 Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
请求Header头域字段说明:
Connection: Upgrade
:表示要升级协议;Upgrade: websocket
:表示要升级到websocket协议;Sec-WebSocket-Version: 13
:表示websocket的版本;Sec-WebSocket-Key
:与服务端响应首部的Sec-WebSocket-Accept
是配套的,提供基本的防护,比如恶意或者无意的连接;
Wireshark抓包如下:
4.2 握手响应
服务端通过HTTP Response 中 101状态码
返回响应数据,完成协议握手:
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
响应数据说明:
101 Switching Protocols
:HTTP协议切换为WebSocket协议。Sec-WebSocket-Accept
:与请求数据中的Sec-WebSocket-Key
数据对应,由请求数据中对应自己计算生成(计算方式后续详细说明)。
注:每个header都以/r/n
结尾,并且最后一行加上一个额外的空行/r/n
。
Wireshark抓包如下:
4.3 Sec-WebSocket-Accept计算
Sec-WebSocket-Accept
根据客户端请求首部的Sec-WebSocket-Key
计算出来。
计算公式为:
- 将
Sec-WebSocket-Key
跟258EAFA5-E914-47DA-95CA-C5AB0DC85B11
拼接。 - 通过
SHA1
计算出摘要,并转成base64字符串
。
// 伪代码举例 Base64( sha1( Sec-WebSocket-Key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 ) )
标准文档中关于计算方式的表述:
五、数据帧格式
在完成协议握手后,后续客户端与服务端数据交换均需要遵循 WebSocket 协议进行。
这里,我用 Wireshark 抓包了一个服务端推送到客户端的消息数据,可以让大家先对 Wireshark 数据格式有一个大概的认识。
WebSocket客户端、服务端通信的最小单位是帧(frame)
,由1个或多个帧组成一条完整的消息(message)。
- 发送端:将消息切割成多个帧,发送给服务端;
- 服务端:接收消息帧,并将关联的帧重新组装成完整的消息数据;
RFC6455
定义了 WebSocket数据帧的统一格式
。如下图所示,单位为比特,从左到右FIN
、RSV
等各占据1比特位,opcode
占据4比特。
FIN (Final)
:是否为最后一个分片(占1比特位)
如果是1,表示这是消息(message)的最后一个分片(fragment);
如果是0,表示不是是消息(message)的最后一个分片(fragment)。RSV1, RSV2, RSV3 (Reserved)
:扩展字段(共占3比特位)
一般情况下全为0。当客户端、服务端协商采用WebSocket扩展时,这三个标志位可以非0,且值的含义由扩展进行定义。
对于 Reserved 字段,这里不做详细说明,如有扩展需求,可自行查阅相关国际标准:
RFC6455: WebSocket协议
https://www.rfc-editor.org/rfc/rfc6455.txtOpcode
:操作码(占4比特位)
Opcode的值决定了应该如何解析后续的数据载荷(data payload)。
可选的操作代码如下:
Opcode | 含义 |
---|---|
0x0 | 表示一个延续帧,表示本次数据传输采用了数据分片,当前收到的数据帧为其中一个数据分片 |
0x1 | 表示这是一个文本帧(frame) |
0x2 | 表示这是一个二进制帧(frame) |
0x3-7 | 保留的操作代码,用于后续定义的非控制帧 |
0x8 | 表示连接断开 |
0x9 | 表示这是一个ping操作 |
0xA | 表示这是一个pong操作 |
0xB-F | 保留的操作代码,用于后续定义的控制帧 |
Mask
:掩码(占1比特位)
表示是否要对数据载荷进行掩码操作。
从客户端向服务端发送数据时,需要对数据进行掩码操作;从服务端向客户端发送数据时,不需要对数据进行掩码操作。
如果Mask是1
,那么在Masking-key
中会定义一个掩码键,并用这个掩码键来对数据载荷进行反掩码。
Payload length
:数据载荷的长度,单位是字节(占 7、7+16 或7+64 比特位)
假设 Payload length 中前7 个比特位
的值为x
:
如果x
为 0~125,则x
即为载荷数据的有效长度;
如果x
为 126,则后续16比特位代表一个16位的无符号整数,该无符号整数的值为载荷数据的有效长度;
如果x
为 127,则后续64个比特位代表一个64位的无符号整数(最高位为0),该无符号整数的值为载荷数据的有效长度。Masking-key
:掩码键(占0或32比特位)
所有从客户端传送到服务端的数据帧,数据载荷都进行了掩码操作(Mask值为1),会携带4字节的Masking-key。
对于 Masking-key 掩码算法,这里不做详细说明,如需了解,可自行查阅相关国际标准:
RFC6455: WebSocket协议
https://www.rfc-editor.org/rfc/rfc6455.txtPayload data
:载荷数据(占x+y字节)
载荷数据有两部分组成:扩展数据占 x 字节,应用数据占 y 字节
。
扩展数据:
如果没有协商使用扩展的话,扩展数据数据为0字节。所有的扩展都必须声明扩展数据的长度,或者可以如何计算出扩展数据的长度。此外,扩展如何使用必须在握手阶段就协商好。如果扩展数据存在,那么载荷数据长度必须将扩展数据的长度包含在内。
应用数据:
任意的应用数据,在扩展数据之后(如果存在扩展数据),占据了数据帧剩余的位置。
WebSocket数据帧举例
WebSocket客户端向服务端发送的数据抓包举例如下:
WebSocket服务端向客户端推送的数据抓包举例如下:
六、关闭连接
上边说道了发送消息时,WebSocket数据帧格式。
当WebSocket不再需要时,客户端或服务端可以选择关闭 WebSocket 连接。
WebSocket客户端关闭连接
抓包如下:
WebSocket服务端关闭连接
抓包如下:
可以看到以上抓包数据中,涉及到一个状态码Status code
:
连接关闭状态码 | 含义 |
---|---|
1000 | 正常关闭连接 |
1001 | 表示某个端“正在离开”,例如服务器关闭或客户端已离开页面 |
1002 | websocket协议错误 |
1003 | 正在关闭连接,某个端接受了不支持数据格式 |
1004~1006 | 保留字段 |
1007 | 正在关闭连接,某个端接受了无效数据格式(文本消息编码不是utf-8) |
1008 | 正在关闭连接,某个端接收到了违反政策的消息 |
1009 | 正在关闭连接,传输的数据量过大 |
1010 | 表示客户端正在关闭连接,因为它期望服务器协商一个或多个扩展,但服务器没有做出响应 |
1011 | 表示服务器正在关闭连接,因为它遇到了意外情况 |
七、心跳消息
WebSocket 是客户端与服务端的长链接,需要间隔一段发送Ping、Pong心跳,以抵挡运营商的Nat超时,来维持TCP连接不断开。
- 客户端需每隔一段时间(心跳间隔时间)发送一个
Ping
消息,到远端服务器侧; - 服务端收到客户端的
Ping
消息后,需在10s内回复一个Pong
消息;
客户端发送的Ping
消息抓包举例如下:
服务端发送的Pong
消息抓包举例如下:
八、参考:
RFC6455: WebSocket协议
https://www.rfc-editor.org/rfc/rfc6455.txt
RFC7936: WebSocket补充
https://www.rfc-editor.org/rfc/rfc7936.txt
维基百科:WebSocket
https://zh.m.wikipedia.org/zh-hans/WebSocket
阮一峰: WebSocket教程
https://www.ruanyifeng.com/blog/2017/05/websocket.html
WebSocket协议:
https://www.gaodi.net/chyingp/p/websocket-deep-in.html
= THE END =
文章首发于公众号”CODING技术小馆“,如果文章对您有帮助,欢迎关注我的公众号。