一、 WebSocket 出现的背景
最开始 客户端(Client) 和 服务器(Server) 通信使用的是 HTTP 协议,HTTP 协议有一个的缺陷为:通信只能由客户端(Client)发起。
在一些场景下,这种单向请求的特点,注定了当 服务器(Server) 有连续的状态变化时, 客户端(Client) 要获知就非常麻烦。 客户端(Client) 只能使用轮询的方式,即每隔一段时间,就发出一个询问,来了解 服务器(Server) 有没有新的信息,最典型的场景就是聊天室。
轮询的方式导致效率很低,非常浪费资源, 客户端(Client) 必须不停地发起连接,或者 HTTP 连接始终打开。为此工程师们一直在思考更好的解决方案,因此 WebSocket 就这样诞生了。
二、WebSocket 的优缺点
WebSocket 优势:
- 实时性: 由于 WebSocket 的持久化连接,它可以实现实时的数据传输,避免了 Web 应用程序需要不断地发送请求以获取最新数据的情况。
- 双向通信: WebSocket 协议支持双向通信,这意味着 服务器(Server) 可以主动向 客户端(Client) 发送数据,而不需要 客户端(Client) 发送请求。
- 减少网络负载: 由于 WebSocket 的持久化连接,它可以减少 HTTP 请求的数量,从而减少了网络负载。
WebSocket 的劣势:
- 需要浏览器和 服务器(Server) 都支持: WebSocket 是一种相对新的技术,需要浏览器和服务器都支持。一些旧的浏览器和 服务器(Server) 可能不支持 WebSocket。
- 需要额外的开销: WebSocket 需要在服务器上维护长时间的连接,这需要额外的开销,包括内存和 CPU。
- 安全问题: 由于 WebSocket 允许 服务器(Server) 主动向 客户端(Client) 发送数据,可能会存在安全问题。 服务器(Server) 必须保证只向合法的 客户端(Client) 发送数据。
三、WebSocket 协议概述
WebSocket 协议是一种基于 TCP
的协议,用于在 客户端(Client) 和 服务器(Server) 之间建立持久连接,并且可以在这个连接上实时地交换数据。WebSocket 协议有自己的握手协议,用于建立连接,也有自己的数据传输格式。
当 客户端(Client) 发送一个 WebSocket 请求时,服务器(Server) 将发送一个协议响应以确认请求。在握手期间, 客户端(Client) 和 服务器(Server) 将协商使用的协议版本、支持的子协议、支持的扩展选项等。一旦握手完成,连接将保持打开状态, 客户端(Client) 和 服务器(Server) 就可以在连接上实时地传递数据。
WebSocket 协议使用的是 双向数据传输,即 客户端(Client) 和 服务器(Server) 都可以在任意时间向对方发送数据,而不需要等待对方的请求。它支持传输 二进制数据 和 文本数据,并可以自由地在它们之间进行转换。
WebSocket 通信过程
一个 WebSocket 连接包含以下四个主要阶段:
1、连接建立阶段(Connection Establishment)
在这个阶段, 客户端(Client) 和 服务器(Server) 之间的 WebSocket 连接被建立。 客户端(Client) 发送一个 WebSocket 握手请求, 服务器(Server) 响应一个握手响应,然后连接就被建立了。握手过程 如下:
WebSocket 为了兼容 HTTP 协议,是在 HTTP 协议的基础之上进行升级得到的。在客户端(Client) 和 服务器(Server) 端建立 HTTP 连接之后,客户端(Client) 会向 服务器(Server) 端发送一个升级到 WebSocket 的协议,如下所示:
GET /chat HTTP/1.1
Host: example.com:8000
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
通过设置 Upgrade 和 Connection 这两个 header,表示准备升级到 WebSocket 了。
除了这里列的属性之外,其他的 HTTP 自带的 header 属性都是可以接受的。
当 服务器(Server) 端收到客户端(Client) 的请求之后,会返回给客户端(Client) 一个响应,告诉客户端(Client) 协议已经从 HTTP 升级到 WebSocket 了。返回的响应可能是这样的:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
这里的 Sec-WebSocket-Accept 是根据客户端(Client) 请求中的 Sec-WebSocket-Key
来生成的。具体而言是将客户端(Client) 发送的 Sec-WebSocket-Key
和 字符串 “258EAFA5-E914-47DA-95CA-C5AB0DC85B11
” 进行连接。然后使用 SHA1
算法求得其 Hash
值,最后将 Hash
值进行 base64
编码即可,当 服务器(Server) 端返回 Sec-WebSocket-Accept
之后,客户端(Client) 可以对其进行校验,已完成整个握手过程。
2、连接开放阶段(Connection Open)
在这个阶段,WebSocket 连接已经建立并开放,客户端(Client) 和 服务器(Server) 可以在连接上互相发送数据。
3、连接关闭阶段(Connection Closing)
在这个阶段,一个 WebSocket 连接即将被关闭。它可以被客户端(Client) 或 服务器(Server) 发起,通过发送一个关闭帧来关闭连接。
4、连接关闭完成阶段(Connection Closed)
在这个阶段,WebSocket 连接已经完全关闭。客户端(Client) 和 服务器(Server) 之间的任何交互都将无效。
需要注意的是,WebSocket 连接在任何时候都可能关闭,例如网络故障、 服务器(Server) 崩溃等情况都可能导致连接关闭。因此,需要及时处理 WebSocket 连接关闭的事件,以确保应用程序的可靠性和稳定性。
WebSocket 的消息格式
WebSocket 的消息格式与 HTTP 请求和响应的消息格式有所不同。WebSocket 的消息格式可以是 文本 或 二进制数据,并且 WebSocket 消息的传输是在一个已经建立的连接上进行的,因此不需要再进行 HTTP 请求和响应的握手操作。
由图可知,WebSocket 的报文格式可以分为七大部分,分别是 1bit
的 FIN 标志位,3bit
的RSV 保留位,4bit
的 Opcode,1bit
的 Mask 标志位,7/7+16/7+64bit
的 payloadLen,可选字段 masking-key
,可选字段 payload
。具体解释下:
-
FIN 标志位:此标志位用于指示当前的帧是消息的最后一个分段。
- WebSocket 支持将长消息切割成若干帧发送,切分后,前边的帧的 FIN 字段均为
0
,最后一个帧的 FIN 为1
。 - 当消息没有分段时,这个帧便包含所有信息,FIN 标志位为1.【1bite】
- WebSocket 支持将长消息切割成若干帧发送,切分后,前边的帧的 FIN 字段均为
-
RSV1~3 :这是三个保留位,一般情况下为全
0
。- 当 客户端(Client) 、服务端协商采用 WebSocket 扩展时,这三个标志位可以
非 0
,且值的含义由扩展进行定义。 - 如果出现
非0
值但并未采用 WebSocket 扩展,连接出错。
- 当 客户端(Client) 、服务端协商采用 WebSocket 扩展时,这三个标志位可以
-
Opcode :
4bit
操作码,用于指示帧类型。-
Opcode 决定了如何解析后续的数据载荷部分,如果操作码是不认识的,接收端应该断开连接。可选的操作码如下:
%x0:表示一个延续帧。当 Opcode 为 0 时,表示本次数据传输采用了数据分片,当前收到的数据帧为其中一个数据分片。 %x1:表示这是一个文本帧(frame) %x2:表示这是一个二进制帧(frame) %x3-7:保留的操作代码,用于后续定义的非控制帧。 %x8:表示连接断开。 %x9:表示这是一个 ping 操作。 %xA:表示这是一个 pong 操作。 %xB-F:保留的操作代码,用于后续定义的控制帧。
-
其中注意 WebSocket 既可以传输文本数据,也可以传输二进制数据
-
-
Mask 标志位:指示帧的
payload
是否需要使用掩码覆盖。-
RFC6455 规定,当且仅当由客户端(Client) 向服务端发送的帧需要覆盖。
-
掩码覆盖的作用:解决 “缓冲区溢出”(忽略)
-
当 Mask 为
1
,但服务端接收的数据没有进行过掩码操作,服务端需要断开连接payload length:7位/7+16位(64k)/7+64位(超级大),单位是字节 模式区分: ①当7bite的payloadlength<126,此时为模式1 ② 7bite的payloadlength=126,16bite生效为模式2 ③ 7bite的payloadlength=126,64bite生效为模式3
-
masking-key
与mask
值有关,当 mask 为0
时,没有masking-key
; 为1
时,有4bit
的masking-key
-
-
payload-data :长度可变。包含扩展数据(x 字节)和应用数据(y 字节)
-
如果通信双方约定使用了 WebSocket 扩展,则扩展数据也存放于此,并声明扩展长度。
-
如果没有约定使用,则扩展数据为
0 字节
。
-
WebSocket 的报文格式中最重要的便是
Opcode
、payload length(三种模式)
,payload data
四、基于 esp-idf 如何使用 webSocket
如何使用
-
对于 esp-idf v5.0 以下版本, 有对应的 websocket 示例, 用户可以直接进行测试。
-
对于 esp-idf v5.0 以上版本, 提供了 esp_websocket_client 组件, 直接在对应的示例下面添加组件即可。
idf.py add-dependency "espressif/esp_websocket_client^1.2.3"
基于 ESP-IDF SDK 使用 Websocket 的案例:
使用 esp-idf v4.2.2 版本, 服务器(Server) 有时会异常断开, 模块会收到 服务器(Server) 发过来的 opcode=0x08
关闭帧, 这这种情况下设备要如何重新连接?这个机制是怎样的呢?
首先,op_code
为 0x08
是一个断开帧, 表示对端主动断开的, 我们是不需要在 WEBSOCKET_EVENT_DATA 里去判断 op_code
等于 0x08
的情况。在 WEBSOCKET CLOSED 事件中,内部会进行断开的处理,如果直接在 WEBSOCKET_EVENT_CLOSED 调用 esp_websocket_client_start(client)
接口重新连接 服务器(Server) , 则实际测试下来会进入到 close 事件里面, 但是并没有重新连接, 日志如下:
2024-09-27 12:10:14.783]# RECV ASCII>
[0;32mI (27116) WEBSOCKET: ====================Received opcode=1==================[0m
[0;33mW (27116) WEBSOCKET: ------> [3, "410212051", {}] <------
[2024-09-27 12:10:21.160]# RECV ASCII>
[0;32mI (33506) uart_events: netStatus:7
[2024-09-27 12:10:22.466]# RECV ASCII>
[0;32mI (34796) WEBSOCKET: ====================Received opcode=8==================[0m
[0;32mI (34796) WEBSOCKET: WEBSOCKET_EVENT_CLOSED[0m
[2024-09-27 12:10:26.185]# RECV ASCII>
[0;31mE (38486) TRANSPORT_WS: Error read response for Upgrade header GET /HBE-123456 HTTP/1.1Connection: UpgradeHost: 0c6eeb3d0d512aa2.octt.openchargealliance.org:21128User-Agent: ESP32 Websocket ClientUpgrade: websocketSec-WebSocket-Version: 13Sec-WebSocket-Key: VMy5tg1Rv1Gr/P7HhLCTVw==Sec-WebSocket-Protocol: ocpp1.6[0m
[0;31mE (38506) WEBSOCKET_CLIENT: Error transport connect[0m
[0;31mE (38516) WEBSOCKET_CLIENT: esp_websocket_client_abort_connection(160): Websocket already stop
[2024-09-27 12:10:29.403]# RECV ASCII>
[0;32mI (41746) uart_events: netStatus:6
[2024-09-27 12:10:37.633]# RECV ASCII>
[0;32mI (49976) uart_events: netStatus:6
[2024-09-27 12:10:45.848]# RECV ASCII>
[0;32mI (58196) uart_events: netStatus:6
[2024-09-27 12:10:51.190]# RECV ASCII>
[0;31mE (63456) task_wdt: Task watchdog got triggered. The following tasks did not reset the watchdog in time:[0m
[0;31mE (63456) task_wdt: - IDLE (CPU 0)[0m
[0;31mE (63456) task_wdt: Tasks currently running:[0m
[0;31mE (63456) task_wdt: CPU 0: websocket_task[0m
[0;31mE (63456) task_wdt: CPU 1: IDLE[0m
[0;31mE (63456) task_wdt: Print CPU 0 (current core) backtrace
Backtrace: 0x4013BF6A:0x3FFBE920 0x40082A71:0x3FFBE940 0x4000BFED:0x3FFF1030 0x40093AAD:0x3FFF1040 0x40091698:0x3FFF1060 0x400917A8:0x3FFF10A0 0x400DD000:0x3FFF10C0 0x400938D9:0x3FFF10F0
[0;31mE (63456) task_wdt: Print CPU 1 backtrace[0m
Backtrace: 0x4008BFF1:0x3FFBEF2
再进一步调试发现是因为客户端(Client) 如果收到 0x08
的 opcode
, 它不仅仅只是断开之前的连接, 还会释放掉之前创建的 handle
,所以用户需要在 WEBSOCKET_EVENT_CLOSED 事件里创建好 handle
, 然后直接去做重新连接的操作, 参考如下代码:
case WEBSOCKET_EVENT_CLOSED:
ESP_LOGI(TAG, "WEBSOCKET_EVENT_CLOSED");
esp_websocket_client_config_t websocket_cfg = {};
shutdown_signal_timer = xTimerCreate("Websocket shutdown timer", NO_DATA_TIMEOUT_SEC * 1000 / portTICK_PERIOD_MS,
pdFALSE, NULL, shutdown_signaler);
shutdown_sema = xSemaphoreCreateBinary();
websocket_cfg.task_stack = 8192;
websocket_cfg.uri = "ws://0c6eeb3d0d512aa2.octt.openchargealliance.org:21128/HBE-123456";
websocket_cfg.subprotocol="ocpp1.6";
ESP_LOGI(TAG, "Connecting to %s...", websocket_cfg.uri);
esp_websocket_client_handle_t client = esp_websocket_client_init(&websocket_cfg);
esp_websocket_register_events(client, WEBSOCKET_EVENT_ANY, websocket_event_handler, (void *)client);
esp_websocket_client_start(client);
break;
}
修改代码之后, 在 服务器(Server) 收到 0x08
的 opcode
之后, 可以重新连接成功。