在一些特殊网络环境下,传统的 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 | cmake -DLWS_WITHOUT_SERVER=0 \ |
初始化
在 libwebsockets 中,所有的操作都是通过上下文(context)来进行的。上下文是 libwebsockets 的核心数据结构,包含了所有的配置信息和状态信息。在使用 libwebsockets 之前,需要先创建一个上下文。
1 | struct lws_context_creation_info info; |
首先要构造一个 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 | struct lws_client_connect_info connect_info; |
建立连接时需要依赖之前创建好的 lws_context
结构,通过 lws_client_connect_info
参数结构体来设置连接信息:
connect_info.context = ws_context->lws_context;
上下文connect_info.address = host;
服务器地址,这个地址可以是一个 IP,通过 host 来做 SNIconnect_info.port = port;
服务器端口connect_info.path = "/websocket";
websocket 业务协议的收发路径connect_info.host = host;
主机名,主要用于 SNIconnect_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 | static void* FCX_STDCALL websocket_service_thread(void* arg) { |
每当有消息到达时,libwebsockets 会自动调用我们之前设置的回调函数 fcore_link_websocket_callback
。这个函数的签名如下:
1 | static int fcore_link_websocket_callback(struct lws* wsi, |
我们主要需要关注的是 wsi
、reason
和 in
三个参数:
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 | size_t data_len = strlen(data); |
这样其实已经能简单的实现发送数据逻辑了,但如果你希望控制写入到发送队列的 buffer 大小,可以分批次将数据写入到发送队列中,控制 lws_write 第三个参数的 write mode 来告诉 libwebsockets 数据是不是写入完成了。以下是业务上正在使用代码的片段,可作为参考:
1 | // 遍历队列中的所有item |
其中 write mode 的 LWS_WRITE_CONTINUATION
| LWS_WRITE_NO_FIN
表示当前业务数据 buffer 是否已经写入完成。这个可跟业务实际情况调整。
接收数据
接收数据时,libwebsockets 会自动将接收到的数据存储在 in
指针指向的内存中,长度为 len
。不像发送数据,接收数据时不需要预留空间。我们只需要在回调函数中处理接收到的数据即可。以下是一个简单的接收数据的示例:
1 | case LWS_CALLBACK_CLIENT_RECEIVE: { |
总结
至此,libwebsockets 的初始化、建连、消息循环、发送和接收数据的基本流程已经介绍完毕,这是最近一段时间在对接该库时总结下来的一些经验和教训,希望能对预期使用 libwebsockets 的同学们有所帮助。