在一些特殊网络环境下,传统的 TCP + 自定义加密协议的方式容易出现在 TCP 建连成功后首次发包没有响应的情况,这在海外连接场景非常常见,虽然数量不多但会导致阻塞性流程,导致用户无法正常使用我们提供的业务功能。

为此我们在移动端已经通过标准的 websocket 协议验证可修复这样的问题,本质的区别是通过 TCP + 自定义加密协议鉴权的方式三次握手后的收包并不是标准的 TLS Client Hello,一些对网络安全比较苛刻的环境将直接丢弃这些数据包,导致后续的业务协议收发失败。通过 websocket 协议的方式,TCP 三次握手后第一个包是标准的 TLS 握手包,网络安全设备会正常放行。

本文主要介绍如何通过 libwebsockets 库实现 websocket 协议的收发。以及一些细节问题的解决方案。

libwebsockets 库介绍

libwebsockets 是一个轻量级的 C 语言实现的 websocket 协议库,支持多种平台和操作系统,包括 Linux、Windows、macOS、Android 和 iOS 等。libwebsockets 提供了丰富的 API 接口,支持 websocket 的各种特性,如压缩、分片、二进制数据传输等。libwebsockets 的设计目标是高性能、低延迟和低内存占用,适合嵌入式设备和资源受限的环境。

WebSocket 协议流程

在一切开始前,我们先了解清楚 websocket 的整体建连发送数据的流程:

抓包后看到的数据也是标准的 TLS 握手包:

虽然还不确认到底网络设备是如何区分这些数据包的,但可以确定的是,websocket 协议的建连方式是可以解决这个问题的。

libwebsockets 的使用

编译

虽然我们是通过 conan 引入的 libwebsockets 库,但整个编译流程还是有一些细节值得介绍一下。

当你希望 libwebsockets 只提供客户端能力时,可通过 cmake 配置 LWS_WITHOUT_SERVER 为 0 来关闭服务器的功能,这样可以适当减少体积。另外由于业务上实现了 IPv6 和 IPv4 地址双栈竞速的逻辑,如果要开启 IPv6 支持,可通过配置 LWS_IPV6 为 1 来开启。参考命令如下:

1
2
3
4
cmake -DLWS_WITHOUT_SERVER=0 \
-DLWS_IPV6=1 \
-DCMAKE_BUILD_TYPE=Release \
..

初始化

在 libwebsockets 中,所有的操作都是通过上下文(context)来进行的。上下文是 libwebsockets 的核心数据结构,包含了所有的配置信息和状态信息。在使用 libwebsockets 之前,需要先创建一个上下文。

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
34
35
36
struct lws_context_creation_info info;
fcore_link_websocket_context_t* ws_context;

memset(&info, 0, sizeof(info));

if (!socket->closing_) {
fcore_link_websocket_execute_close(socket);
}

ws_context = fcore_link_websocket_context_create(socket);
socket->context_ = ws_context;

info.port = CONTEXT_PORT_NO_LISTEN;
info.timeout_secs = timeout / 1000;
info.options = LWS_SERVER_OPTION_DO_SSL_GLOBAL_INIT;
info.client_ssl_ca_filepath = fcore_global_conf_get_instance()->client_ca_file_path_;
// clang-format off
info.protocols = (struct lws_protocols[]){{
"your protocol name",
fcore_link_websocket_callback,
0,
4096,
0,
fcx_null,
0
}, LWS_PROTOCOL_LIST_TERM};
// clang-format on
FCX_DEBUG_APP("[websocket] init with client ca file path: %s", info.client_ssl_ca_filepath);

ws_context->lws_context = lws_create_context(&info);
if (!ws_context->lws_context) {
FCX_DEBUG_ERROR("Failed to create websocket context");
fcore_link_socket_execute_onclose(socket, kFCoreConnectFailed);
fcx_mutex_unlock(socket->nio_thread_mutex_);
return;
}

首先要构造一个 lws_context_creation_info 结构体,并设置一些必要的参数:

  • info.port = CONTEXT_PORT_NO_LISTEN; 表示不监听任何端口,因为我们只需要客户端功能。
  • info.timeout_secs = timeout / 1000; 设置超时时间。
  • info.options = LWS_SERVER_OPTION_DO_SSL_GLOBAL_INIT; 表示初始化全局 SSL 设置,不加此设置会导致 TLS/SSL 握手失败。
  • info.client_ssl_ca_filepath = fcore_global_conf_get_instance()->client_ca_file_path_; 设置 CA 证书文件路径,验证服务器证书必须
  • info.protocols 设置协议列表,这里我们只需要一个协议 nim,取决于你服务器的配置,最后的 LWS_PROTOCOL_LIST_TERM 宏表示协议列表的结束。

另外一个比较重要的点是 fcore_link_websocket_callback 函数,它是一个回调函数,用于处理 websocket 的各种事件,如连接建立、消息接收、连接关闭等。这个函数的具体实现需要根据业务需求来编写,后面我们详细介绍。

随后通过 lws_create_context 创建一个 lws_context 上下文并赋值给我们业务上的上下文对象,方便在 C 风格的回调函数中使用。这样一个最简单的初始化工作就完成了。接下来是建立连接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct lws_client_connect_info connect_info;
memset(&connect_info, 0, sizeof(connect_info));

connect_info.context = ws_context->lws_context;
connect_info.address = host;
connect_info.port = port;
connect_info.path = "/websocket";
connect_info.host = host;
connect_info.origin = host;
connect_info.protocol = "your protocol name";
connect_info.userdata = ws_context;
connect_info.ssl_connection = LCCSCF_USE_SSL;

ws_context->wsi = lws_client_connect_via_info(&connect_info);
if (!ws_context->wsi) {
FCX_DEBUG_ERROR("Failed to connect websocket server");
fcore_link_socket_execute_onclose(socket, kFCoreConnectFailed);
fcx_mutex_unlock(socket->nio_thread_mutex_);
return;
}

建立连接时需要依赖之前创建好的 lws_context 结构,通过 lws_client_connect_info 参数结构体来设置连接信息:

  • connect_info.context = ws_context->lws_context; 上下文
  • connect_info.address = host; 服务器地址,这个地址可以是一个 IP,通过 host 来做 SNI
  • connect_info.port = port; 服务器端口
  • connect_info.path = "/websocket"; websocket 业务协议的收发路径
  • connect_info.host = host; 主机名,主要用于 SNI
  • connect_info.origin = host; 源地址,客户端通常不需要指定,一般用于跨域请求
  • connect_info.protocol = "nim"; 协议名称,由服务器决定
  • connect_info.userdata = ws_context; 用户数据,通常是上下文对象
  • connect_info.ssl_connection = LCCSCF_USE_SSL; 是否使用 SSL/TLS,如果你希望允许自签证书或跳过服务器证书校验,可添加 LCCSCF_ALLOW_SELFSIGNED 和 LCCSCF_SKIP_SERVER_CERT_HOSTNAME_CHECK 选项

如果连接成功,lws_client_connect_via_info 函数会返回一个 wsi 结构,这个结构可以用于后续的消息收发和连接管理。

启动消息循环

libwebsockets 的消息循环是通过 lws_service 函数来实现的,该函数传入的第二个参数为 0 时表示非阻塞式。我们可以在一个单独的线程中运行这个函数,以便在后台处理 websocket 的消息。同时你可以在业务上定义一些字段表示业务流程是否已经结束,比如下面我定义了一个 is_closing 的字段来表示当前的 websocket 是否已经关闭:

1
2
3
4
5
6
7
8
9
static void* FCX_STDCALL websocket_service_thread(void* arg) {
fcore_link_websocket_context_t* ws_context = (fcore_link_websocket_context_t*)arg;
FCX_DEBUG_APP("[websocket] start websocket service thread")
while (!ws_context->is_closing && lws_service(ws_context->lws_context, 0) >= 0) {
}
FCX_DEBUG_APP("[websocket] end websocket service thread")
ws_context->service_thread = fcx_null;
return fcx_null;
}

每当有消息到达时,libwebsockets 会自动调用我们之前设置的回调函数 fcore_link_websocket_callback。这个函数的签名如下:

1
2
3
4
5
static int fcore_link_websocket_callback(struct lws* wsi,
enum lws_callback_reasons reason,
void* user,
void* in,
size_t len) {}

我们主要需要关注的是 wsireasonin 三个参数:

  • wsi:表示当前的 websocket 连接句柄,可以通过它来获取连接的状态和信息。
  • reason:表示当前的回调事件类型,如连接建立、消息接收、连接关闭等。
  • in:表示接收到的数据,长度为 len
  • user:表示用户数据,这里是我们之前传入的上下文对象。

在回调函数中,我们可以根据不同的事件类型来处理相应的逻辑。其中比较重要的几个事件有:

  • LWS_CALLBACK_CLIENT_ESTABLISHED

    表示连接建立成功,可以开始发送消息。在建立连接成功前你已经可以向发送队列投递 buffer 了,可以通过该时间回调检查 buffer 队列是否有内容以决定是否在建立连接后立即发送

  • LWS_CALLBACK_CLIENT_CONNECTION_ERROR

    表示连接失败,可以进行错误处理,当出现该错误时可以通过 in 和 len 来获取详细的错误信息

  • LWS_CALLBACK_CLIENT_WRITEABLE

    表示连接可写,可以发送消息。这个事件会在连接建立成功后触发,也会在发送完消息后触发,表示可以继续发送消息。当你将数据投递到发送队列后,可调用 lws_callback_on_writable 来通知 libwebsockets 连接可写,libwebsockets 会在下一个事件循环中调用 LWS_CALLBACK_CLIENT_WRITEABLE 事件。该时间回调在内部的 loop 线程中,你可以调用 lws_write 将缓存的 buffer 写入到发送队列中

  • LWS_CALLBACK_CLIENT_RECEIVE

    表示接收到消息,可以处理消息。该事件会在接收到消息时触发,消息内容在 in 和 len 中

另外还有关闭事件,它们在不同的场景有不同的用处:

  • LWS_CALLBACK_WS_PEER_INITIATED_CLOSE

    表示对端主动关闭连接,可以进行清理工作。该事件会在对端主动关闭连接时触发,客户端可判断连接到底是端上主动关闭还是由对端关闭

  • LWS_CALLBACK_CLIENT_CLOSED

    表示当前客户端连接已经关闭,可以进行清理工作。即使触发了 LWS_CALLBACK_WS_PEER_INITIATED_CLOSE 也同样会触发该事件

所以区分连接是有对端关闭还是本端关闭的,主要是通过 LWS_CALLBACK_WS_PEER_INITIATED_CLOSE 事件来判断的。

发送数据

libwebsockets 提供了两个版本的 API,一组是 low level API,另一组是 high level API。low level API 提供了更底层的控制,可以直接操作数据包的格式和内容,而 high level API 则提供了更简单易用的接口,适合大多数应用场景。由于需要更精细化的控制收发数据,我们选择了 low level API。从官方的示例代码中也区分了这两种 API 的使用方式,其中 low level API 发送数据指的就是上面提到的 lws_write 方法来将数据写入到发送队列中。但 low level API 要特殊处理一些功能,在介绍前我们先搞清楚 lws_write 的工作模式。

发送 websocket 数据时,业务上实现实际需要预留一部分空间来填充数据包头部信息,libwebsockets 会根据你发送的数据自动将头部信息填充到 buffer 中。我们需要在发送数据时给 buffer 预留出 LWS_PRE(libwebsockets 头文件中定义,大小为 4+10+2)字节的空间来存放数据包头部信息。所以你不能将你业务数据的 buffer 直接丢给 lws_write 方法。这样会导致严重的内存越界问题。实际内存分配的情况参考下图:

在调用发送数据前,我们先申请一个 buffer,大小为 LWS_PRE + data_len,然后将数据填充到 buffer 中。最后调用 lws_write 发送数据。伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
size_t data_len = strlen(data);
size_t buffer_len = LWS_PRE + data_len;
char* buffer = (char*)malloc(buffer_len);
memset(buffer, 0, buffer_len);
memcpy(buffer + LWS_PRE, data, data_len);
int n = lws_write(ws_context->wsi, (unsigned char*)buffer + LWS_PRE, data_len, LWS_WRITE_BINARY);
if (n < 0) {
// handle error
free(buffer);
return;
}

这样其实已经能简单的实现发送数据逻辑了,但如果你希望控制写入到发送队列的 buffer 大小,可以分批次将数据写入到发送队列中,控制 lws_write 第三个参数的 write mode 来告诉 libwebsockets 数据是不是写入完成了。以下是业务上正在使用代码的片段,可作为参考:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 遍历队列中的所有item
fcx_list_item_t* current_item = context->tx_queue->head;
while (current_item && !lws_send_pipe_choked(wsi)) {
fcx_buffer_t* buffer = (fcx_buffer_t*)current_item->data;
if (!buffer) {
current_item = current_item->next;
continue;
}
size_t bytes_sent = 0;
fcx_bool_t buffer_sent = fcx_false;

while (!lws_send_pipe_choked(wsi) && !FCX_BUFFER_IS_EMPTY(buffer)) {
size_t total_size = FCX_BUFFER_SIZE(buffer) - LWS_PRE;
unsigned char* buffer_start = (unsigned char*)FCX_BUFFER_DATA(buffer) + LWS_PRE + bytes_sent;
if (bytes_sent >= total_size) {
buffer_sent = fcx_true;
break;
}
size_t remaining_size = total_size - bytes_sent;
size_t frame_size =
remaining_size > WEBSOCKET_WRITE_BUFFER_LENGTH ? WEBSOCKET_WRITE_BUFFER_LENGTH : remaining_size;
int write_mode = bytes_sent == 0 ? LWS_WRITE_BINARY : LWS_WRITE_CONTINUATION;
if (frame_size < remaining_size) {
write_mode |= LWS_WRITE_NO_FIN;
}
int written = lws_write(wsi, buffer_start, frame_size, write_mode);
if (written < 0) {
FCX_DEBUG_ERROR("Failed to write to socket, frame size: %zu, written size: %d", frame_size, written);
break;
}
bytes_sent += written;
if (bytes_sent >= total_size) {
buffer_sent = fcx_true;
break;
}
// 单个包超过了 WEBSOCKET_WRITE_BUFFER_LENGTH,将已经发送的数据移除,待下次发送
fcx_buffer_remove(buffer, LWS_PRE, written);
if (lws_partial_buffered(wsi))
break;
}
fcx_list_item_t* next_item = current_item->next;
// 如果 buffer 已经完全发送,从队列中移除,并将指针指向下一个待发送 buffer
if (buffer_sent) {
fcx_buffer_cleanup(buffer);
fcx_list_remove_item(context->tx_queue, current_item);
current_item = next_item;
}
}

其中 write mode 的 LWS_WRITE_CONTINUATION | LWS_WRITE_NO_FIN 表示当前业务数据 buffer 是否已经写入完成。这个可跟业务实际情况调整。

接收数据

接收数据时,libwebsockets 会自动将接收到的数据存储在 in 指针指向的内存中,长度为 len。不像发送数据,接收数据时不需要预留空间。我们只需要在回调函数中处理接收到的数据即可。以下是一个简单的接收数据的示例:

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
case LWS_CALLBACK_CLIENT_RECEIVE: {
if (!in || len == 0)
break;
// 扩展接收缓冲区
size_t new_size = context->rx_buffer_size + len;
char* new_buffer = (char*)realloc(context->rx_buffer, new_size);
if (!new_buffer) {
FCX_DEBUG_ERROR("Failed to allocate receive buffer");
break;
}
context->rx_buffer = new_buffer;

// 复制新数据到缓冲区
memcpy(context->rx_buffer + context->rx_buffer_pos, in, len);
context->rx_buffer_size = new_size;
context->rx_buffer_pos += len;

// 检查是否是最后一个分片且没有剩余数据
if (lws_is_final_fragment(wsi) && lws_remaining_packet_payload(wsi) == 0) {
// 发送完整的消息
fcore_link_socket_post_receive_task(
context->socket, kFCoreNetOk, context->rx_buffer, context->rx_buffer_size);
// 重置缓冲区
free(context->rx_buffer);
context->rx_buffer = NULL;
context->rx_buffer_size = 0;
context->rx_buffer_pos = 0;
}
break;
}

总结

至此,libwebsockets 的初始化、建连、消息循环、发送和接收数据的基本流程已经介绍完毕,这是最近一段时间在对接该库时总结下来的一些经验和教训,希望能对预期使用 libwebsockets 的同学们有所帮助。