尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

libwebsocket多协议机制解析:协议表、vhost与回调分发实战

libwebsocket多协议机制解析:协议表、vhost与回调分发实战 libwebsocket 在嵌入式 WebSocket 领域基本是绕不开的库了尤其做物联网网关、设备管理、消息通道这类服务时一个进程里既要挂着 WebSocket 给前端推送数据又要顺便开个 HTTP 接口给其他系统调用甚至同一端口上还想跑两种业务逻辑这些东西全都压在一个 C 库上。我接触 libwebsocket 多协议工作也是从给一个网关设备做远程配置服务开始的一个端口同时要处理日志实时推送、设备状态二进制上报、还有几个 REST 风格的管理接口当时没搞明白协议表怎么组织回调里又该如何识别自己这条连接属于哪个协议结果折腾了好几天客户端握手一直失败或者所有请求都跑进同一个回调里。这篇文章就把我在项目里完整梳理过一遍的 libwebsocket 多协议机制讲清楚从最底层的协议表模型到回调分发逻辑再到实际手写一套多协议服务的配置和踩坑记录希望对你也有帮助。1. 先搞明白libwebsocket 里的“协议”到底是什么1.1 协议项不是“协议实现”而是一组行为定义很多人第一次看 libwebsocket 示例代码会把struct lws_protocols里的每一项当成“一种网络协议”比如把my_protocol理解成 WebSocket、把http理解成 HTTP。这个理解不算全错但容易误导后续的配置。实际上struct lws_protocols数组中的每一项更准确的叫法是“一种服务类型”它给出的不是协议的编解码逻辑而是三样东西对外暴露的名称在 WebSocket 握手时作为子协议名参与协商。回调函数协议在每个连接生命周期事件里的处理入口。连接上下文参数每个连接要分配多大内存作为私有数据接收缓冲区多大。也就是说WebSocket 本身的握手、分帧、掩码处理、关闭流程libwebsocket 全部自己包办了。你写协议项是在告诉它“你帮我处理完这些通用网络细节之后把这条连接的用户数据和一个事件回调绑定起来业务逻辑你来写”。我见过不少人把协议项写得特别多似乎觉得协议越多功能越强其实这是一个误区。协议项数多了以后回调接口、数据隔离、握手协商的处理逻辑都会跟着复杂。协议应该对应“不同业务类型”而不是“不同接口函数”。同一类业务哪怕函数拆得再细也建议通过同一个协议项外加消息字段来区分。1.2 一个协议项到底包含哪些字段以常用的 LWS 4.x 为例struct lws_protocols的关键字段是这么定义的struct lws_protocols { const char *name; lws_callback_function *callback; size_t per_session_data_size; size_t rx_buffer_size; unsigned int id; void *user; size_t tx_packet_size; size_t tx_ringbuffer_size; };简单说name这个协议对外叫什么。如果它是 WebSocket 子协议客户端握手时会带上这个 namelibwebsocket 会用它与客户端声明的子协议列表做匹配。callback这个协议的所有连接事件都走这个回调函数。per_session_data_size每条连接创建时libwebsocket 会按这个大小分配一块零初始化的内存用来存你的会话状态。rx_buffer_size接收缓冲大小影响单次能拿到的数据量上限后面我会专门讲这个字段的坑。id一个自定义编号方便你在回调里做判断不依赖字符串比较。user协议级别的用户数据指针不属于具体连接。这段逻辑其实很像在定义一张“路由表”网络包进来以后内核层做协议解析、连接管理等脏活然后根据选中的协议项把控制流转交给你写的回调函数。1.3 vhost 和 context协议表挂在谁下面协议表从来不是单独存在的。libwebsocket 里有两层容器context 和 vhost。context是进程级对象负责事件循环、日志、SSL 证书、线程配置等全局能力。vhost是服务级对象代表一个监听端口上的服务实例它绑定一张协议表。一个 context 可以创建多个 vhost每个 vhost 可以监听不同端口各自拥有独立的协议表。比如设备管理服务跑在 8080内部遥测服务跑在 8081两边协议完全不同这在同一个进程里是完全可行的。把协议表理解成“vhost 的能力声明”是关键。vhost 在创建时会把协议表拷贝或引用到自己的协议管理结构里去之后每次新连接进来就只在本 vhost 的协议表里做选择。多个 vhost 之间的协议列表互不干扰这也带来一个实用的隔离手段如果某个协议只希望被内网访问而另一个协议需要对外暴露我建议直接拆两个 vhost而不是放在同一 vhost 里靠回调去判断来源 IP那样既不容易维护漏一处检查就是安全隐患。2. 多协议是怎么被路由到具体回调的2.1 一条连接建立时的三步选择过程搞懂多协议最核心的是搞懂新连接进来后libwebsocket 到底怎么挑出该用哪一项协议。这个过程可以拆成三步第一步TCP 层完成握手交给 HTTP/WS 协议解析。libwebsocket 先判断这是普通 HTTP 请求还是带 Upgrade 头的 WebSocket 握手请求。第二步如果是 WebSocket Upgrade 请求它开始解析 HTTP 头里的Sec-WebSocket-Protocol字段。客户端可以在这个字段里写多个候选子协议用逗号分隔例如Sec-WebSocket-Protocol: chat, metrics第三步libwebsocket 拿到客户端声明的子协议列表后按顺序与 vhost 协议表中的name做匹配。一旦命中第一个能匹配上的协议项这条连接就“绑定”到该协议上之后的连接生命周期事件全部发给该协议对应的回调函数。这一步的匹配顺序很重要是“客户端声明顺序优先”还是“协议表顺序优先”实测表现是libwebsocket 会按客户端传入的顺序逐一查表先命中先返回。如果你的客户端一次声明了chat, metrics那大概率会匹配到chat因为它在声明列表里排在前面。反过来如果客户端压根没有声明任何子协议服务器不会拒绝连接而是直接用协议表的第一个协议项作为默认协议。2.2 协议一旦绑定就不能随意切换这是我认为 libwebsocket 多协议机制里最需要记住的一点协议绑定是在连接握手阶段完成的之后整条连接的生命周期内基本不会变更绑定的协议项。所以你不要指望在回调里根据业务消息临时把这条连接“切”到另一个协议的处理逻辑。遇到这种需求更合理的做法是用同一个协议项在消息体里加一个“服务类型”字段做分发。或者让客户端断开后用新的子协议重连。我自己早期一个项目就想当然地在回调里做了协议切换结果状态管理混乱后来干脆改成“一个协议项内做消息路由”清爽多了。2.3 回调事件其实就是连接状态机的转译协议项绑定了连接之后连接每个阶段的网络事件都会被翻译成回调事件。以服务端为例最常用的事件有几个LWS_CALLBACK_ESTABLISHED // 握手完成连接建立 LWS_CALLBACK_RECEIVE // 收到数据 LWS_CALLBACK_SERVER_WRITEABLE // 连接可写可以主动发数据了 LWS_CALLBACK_CLOSED // 连接关闭 LWS_CALLBACK_PROTOCOL_INIT // vhost 初始化该协议时触发 LWS_CALLBACK_PROTOCOL_DESTROY // vhost 销毁该协议时触发其中LWS_CALLBACK_PROTOCOL_INIT和LWS_CALLBACK_PROTOCOL_DESTROY是协议级别的回调不是连接级别的。它们在整个 vhost 生命周期内只会触发一次典型的用途是做协议私有数据的初始化和清理。多协议场景下回调函数的第一个参数why会告诉你发生的是什么事件第二个参数wsi会告诉你是哪条连接。同一个回调函数被多个协议共用时不要依赖why去判断协议身份必须通过wsi获取当前连接绑定的协议信息或者利用协议指针做区分。否则两个协议的事件串在一起调试起来非常痛苦。3. 实操一个进程同时提供两个 WebSocket 子协议和 HTTP 服务3.1 先搭一个最小工程我用的是 libwebsockets 4.3 稳定版编译时开掉不需要的高级功能只保留核心和 HTTP。以 CMake 为例一个最小工程只需要这些文件my_gateway/ ├── CMakeLists.txt └── main.cCMakeLists.txt 可以这样写cmake_minimum_required(VERSION 3.10) project(multiprotocol_demo C) set(CMAKE_C_STANDARD 99) find_package(PkgConfig REQUIRED) pkg_check_modules(LWS REQUIRED IMPORTED_TARGET libwebsockets) add_executable(multiprotocol_demo main.c) target_link_libraries(multiprotocol_demo PkgConfig::LWS)如果你的环境没法用 pkg-config也可以直接指定 include 路径和链接路径target_include_directories(multiprotocol_demo PRIVATE /usr/local/include) target_link_libraries(multiprotocol_demo PRIVATE websockets)3.2 定义三个协议项我打算在同一个 8080 端口上提供三类服务chat文本消息推送用于日志、命令下发。metrics二进制遥测数据上报用于设备状态采集。http普通 HTTP 请求返回一个状态页。协议表定义如下struct per_session_chat { char nickname[32]; unsigned int msg_count; }; struct per_session_metrics { unsigned char last_seq; unsigned int total_bytes; }; static int chat_cb(struct lws *wsi, enum lws_callback_reasons reason, void *user, void *in, size_t len); static int metrics_cb(struct lws *wsi, enum lws_callback_reasons reason, void *user, void *in, size_t len); static int http_cb(struct lws *wsi, enum lws_callback_reasons reason, void *user, void *in, size_t len); static const struct lws_protocols protocols[] { { .name chat, .callback chat_cb, .per_session_data_size sizeof(struct per_session_chat), .rx_buffer_size 2048, }, { .name metrics, .callback metrics_cb, .per_session_data_size sizeof(struct per_session_metrics), .rx_buffer_size 4096, }, { .name http, .callback http_cb, .per_session_data_size 0, .rx_buffer_size 0, }, { NULL, NULL, 0, 0 } };注意最后一项必须是全 NULL 的哨兵项否则协议表的遍历不会正确终止这在忘记写的时候会引发莫名奇妙的崩溃或协议匹配错乱。http协议项的per_session_data_size是 0因为 HTTP 请求通常不需要长期会话数据。libwebsocket 对 HTTP 请求的处理走的是无状态模式如果需要保存请求期的一些临时数据请使用lws_set_http_request_data等接口专门管理而不是硬塞给协议会话。3.3 创建 context 和 vhostcontext 的配置集中在struct lws_context_creation_info里关键配置如下struct lws_context_creation_info info; memset(info, 0, sizeof(info)); info.port 8080; info.protocols protocols; info.gid -1; info.uid -1; struct lws_context *context lws_create_context(info); if (!context) { fprintf(stderr, lws_create_context failed\n); return -1; } while (!lws_service(context, 50)) { lws_callback_on_writable_all_protocol_vhost(context, NULL); }lws_service(context, 50)是事件循环驱动函数50 表示最多阻塞等待 50 毫秒。对新手上路来说这里会有一个常见误区不要在每个循环周期里调用lws_callback_on_writable_all_protocol_vhost这样会导致每轮都去唤醒所有连接。更合理的写法是在需要推送数据时才调用lws_callback_on_writable(wsi)或lws_callback_on_writable_all_protocol。3.4 回调函数内如何识别自己的协议先看chat协议的回调static int chat_cb(struct lws *wsi, enum lws_callback_reasons reason, void *user, void *in, size_t len) { struct per_session_chat *ps (struct per_session_chat *)user; switch (reason) { case LWS_CALLBACK_ESTABLISHED: snprintf(ps-nickname, sizeof(ps-nickname), user_%p, wsi); ps-msg_count 0; lws_callback_on_writable(wsi); break; case LWS_CALLBACK_RECEIVE: if (ps) { ps-msg_count; lws_callback_on_writable(wsi); } break; case LWS_CALLBACK_SERVER_WRITEABLE: if (ps) { unsigned char buf[128]; int n snprintf((char *)buf, sizeof(buf), echo:msg[%u], ps-msg_count); lws_write(wsi, buf, n, LWS_WRITE_TEXT); } break; case LWS_CALLBACK_CLOSED: // 做清理 break; } return 0; }metrics协议回调类似只是在收发数据时使用LWS_WRITE_BINARY并且可以根据per_session_metrics里的计数做二进制报文的拼接。HTTP 协议回调则需要比普通 WebSocket 多处理一个 URL 判断。最简单的方式是利用lws_hdr_copy或者lws_streq判断请求路径static int http_cb(struct lws *wsi, enum lws_callback_reasons reason, void *user, void *in, size_t len) { const char *uri lws_get_url_part_by_name(wsi, uri, NULL, 0); switch (reason) { case LWS_CALLBACK_HTTP: if (lws_streq(uri, /status)) { unsigned char buf[512]; int n snprintf((char *)buf, sizeof(buf), HTTP/1.1 200 OK\r\n Content-Type: text/plain\r\n \r\n gateway is running\n); lws_write(wsi, buf, n, LWS_WRITE_HTTP_HEADERS); lws_write(wsi, (unsigned char *)gateway is running\n, 19, LWS_WRITE_HTTP_HEADERS); } else { return -1; } break; case LWS_CALLBACK_HTTP_BODY: case LWS_CALLBACK_HTTP_BODY_COMPLETION: break; case LWS_CALLBACK_CLOSED_HTTP: break; } return 0; }这里如果再往深走一步你会发现 HTTP 和 WebSocket 在同一 vhost 里的协作其实有固定的顺序lws 先解析 HTTP 头如果识别到 Upgrade 头是 WebSocket就切到协议匹配流程如果没有 Upgrade 头就继续走 HTTP 回调。所以“同端口既 HTTP 又 WebSocket”是完全允许的但前提是你必须在协议表里有一个能处理 HTTP 请求的协议项否则 HTTP 请求会被直接丢弃。3.5 编译和验证多协议服务是否生效编译mkdir build cd build cmake .. make ./multiprotocol_demo测试 HTTP 可以直接用 curlcurl http://127.0.0.1:8080/status测试 WebSocket 子协议我习惯用 Python 的 websockets 库快速验证import asyncio import websockets async def test_chat(): async with websockets.connect(ws://127.0.0.1:8080, subprotocols[chat]) as ws: await ws.send(hello) print(await ws.recv()) async def test_metrics(): async with websockets.connect(ws://127.0.0.1:8080, subprotocols[metrics]) as ws: await ws.send(b\x01\x02\x03) print(await ws.recv()) asyncio.run(test_chat()) asyncio.run(test_metrics())如果握手成功后打印出 echo 内容说明协议选择、回调分发、数据收发这条链路是通的。如果subprotocols[chat]没握手成功大概率就是你服务端协议名字和客户端声明的不一致或者协议表最后少了空条目。4. 多协议场景下的数据隔离与生命周期管理4.1 per-session data 确实是一块独立内存协议表里每个协议项的per_session_data_size都只对绑定到该协议的连接有效。对于chat协议的连接lws 会分配sizeof(struct per_session_chat)的内存对于metrics协议的连接则分配sizeof(struct per_session_metrics)。两块内存在不同连接之间互不相通。换句话说实际内存大小是由“该连接绑定的协议项”决定的而不是统一按某个最大值分配。这也是多协议服务一个容易出问题的点如果你在协议 A 的会话内存里强转成协议 B 的会话结构因为两者大小可能不一致轻则读到脏数据重则越界踩坏堆内存。我的建议是每个协议的回调里对user指针只做一次类型转换然后整套逻辑都基于这个类型不要跨协议混用。如果确实需要从另一个协议访问共享数据应该放到协议私有数据或 vhost 私有数据里这比直接跨协议访问会话内存干净得多。4.2 协议私有数据多协议之间共享状态的正确姿势lws 提供了两个很有用的函数void *lws_protocol_vh_priv_zalloc(struct lws_vhost *vhost, const struct lws_protocols *protocol, int size); void *lws_protocol_vh_priv_get(struct lws_vhost *vhost, const struct lws_protocols *protocol);它们做的事情是给“指定 vhost 下的指定协议”分配一块 vhost 级私有数据。注意这块数据生命周期是 vhost 级别的不是连接级别的。也就是说同一个协议下的所有连接共享的是同一块私有数据。我经常用它在多协议服务里做一个“共享注册中心”。例如chat协议维护一份在线客户端列表metrics协议上报设备状态时需要把状态广播给chat的在线客户端这时chat协议的私有数据里放一个链表头metrics协议回调通过lws_protocol_vh_priv_get拿到链表然后把数据逐个推给chat连接。这样既避免跨协议访问会话内存也让两个协议有了清晰的数据通道。4.3 回调里不要做耗时操作这是所有多协议服务都会踩的坑我再强调一次libwebsocket 默认是单线程事件循环。一个回调函数如果执行时间过长比如做阻塞式数据库查询、同步文件写入、大计算量编解码整个进程所有协议的所有连接都会跟着卡住。具体表现为一个 HTTP 请求慢一点没关系但如果它在回调里睡了 2 秒那么其他客户端的数据收发也会延迟 2 秒。多协议服务的隔离性没有你想象的那么强。真要处理耗时任务有两种常见做法把任务扔到独立的工作线程任务完成后通过线程安全队列通知事件循环再由lws_callback_on_writable触发发送。如果只是拼装数据尽量在回调里完成轻量操作重活放到连接外。我当时在 metrics 协议里接收设备上报后要写数据库最开始图省事直接在回调里调 SQLite 同步接口结果在线监控一上量整个服务的响应时间惨不忍睹。改成“回调里只做队列入队工作线程负责批量写库”之后性能才恢复正常。5. 常见问题与排查技巧实录5.1 子协议协商失败握手一直断这是多协议配置中最常见的故障。通常的表现是客户端连不上服务端日志里出现Handshake failed或No server protocol match一类的消息。我整理过一个排查清单现象可能原因解决办法握手失败客户端声明的子协议与服务端协议表 name 不匹配检查客户端subprotocols和协议表的name是否一致注意大小写握手失败协议表最后没有 NULL 终止项补上{ NULL, NULL, 0, 0 }握手成功但一直回调第一个协议客户端没有声明子协议lws 默认选了协议表第一项客户端显式声明子协议或者把默认协议放到表头握手成功但数据收发异常rx_buffer_size设置得太小大数据被丢弃适当调大 rx_buffer_size或检查发送方是否超帧长发送同端口 HTTP 请求无效协议表里没有 HTTP 回调项增加一个 name 为 http 的协议项并在回调里处理 HTTP 事件5.2 rx_buffer_size 到底影响什么这是很多人误解较多的参数。rx_buffer_size不是连接接收缓冲区总大小而是单次回调能拿到的一块数据上限。WebSocket 协议是流式的lws 内部会把数据切块每次触发LWS_CALLBACK_RECEIVE时len不会超过这个缓冲区的可用空间。如果一次收到的 WebSocket 消息非常大lws 会分多次回调每次给你一部分数据。你要做的是在回调里自己维护一个累积缓冲把所有分段拼起来直到收到完整消息边界比如业务约定的分包协议而不是期待一次回调拿到全部数据。我在 metrics 协议里收到一个 16KB 的遥测文件上传时最开始把rx_buffer_size设成 2048结果每次只拿到 2048 字节。后来要么调大到 32KB要么在回调里做分片累积。实测下来分片累积更省内存也更符合流式传输的模型但自己要小心处理重叠边界。5.3 回调返回非 0 值会导致连接异常关闭lws 回调函数的返回值语义很敏感。返回 0 表示正常返回 -1 在某些事件里会让 lws 直接关闭该连接。很多新手把switch里漏掉的default分支写成返回 -1结果某些意外事件触发时连接直接被断开。我的习惯是写一个通用的默认返回值除非确实需要主动关闭否则不要返回负数default: break;养成这个习惯之后多协议服务里因为“莫名断开”而排查的事件明显减少。5.4 协议私有数据在多 vhost 之间不要混用lws_protocol_vh_priv_zalloc的名字里带着vh_就说明它是挂在 vhost 下面的。不同 vhost 即使使用同一个协议名也是完全不同的私有数据实例。如果你有两个 vhost 同时跑metrics协议各自调用lws_protocol_vh_priv_get拿到的是各自 vhost 的数据对象。这一点在拆多 vhost 隔离业务时特别值得留意。我当时把“内网管理”和“外网设备接入”拆成两个 vhost两边都用了同名协议结果共享统计信息时因为拿不到对方的私有数据而困惑了很久。解决方法是把真正需要跨 vhost 共享的数据放到 context 级别的全局对象里或者通过注册函数由 context 统一管理。6. 真实项目里的取舍和建议6.1 协议不是越多越好子协议数量要克制我见过一个团队把每个业务模块都定义成一个 WebSocket 子协议最终协议表里塞了十几个条目。这种设计表面上“各管各的”实际上引入不少额外成本每个协议都要写一套独立的回调逻辑代码重复率高。客户端需要维护一张子协议名和服务端地址的映射表一旦序列化协议调整两边要同步改。调试时抓包要看是否命中了某一条心智负担明显变重。大多数实时通信场景一个 WebSocket 子协议加上 JSON 消息体里的type字段足够覆盖十几种业务消息了。子协议更适合做“通信模式”的划分而不是“业务模块”的划分。比如文本实时交互走chat二进制数据上传走metrics这种资源形态差异大的才值得分开。6.2 什么时候该拆 vhost而不是堆协议拆 vhost 虽然会多占一个端口但换来的是更清晰的隔离边界。我的经验是以下情况优先拆 vhost不同协议有完全不同的安全策略例如外网 WebSocket 和内部管理 API。不同协议需要绑定不同网卡或不同 SSL 证书。不同协议对事件循环的响应时限要求差异很大。如果只是同端口下前缀不同、业务相近那还是放同一个 vhost 里更方便。vhost 不是越多越好因为每个 vhost 都有自己的一套协议表、内存和回调数量太多反而会增加 context 层调度的复杂度。6.3 其它内置协议和扩展方向除了 WebSocket 子协议和 HTTPlibwebsockets 还能通过编译选项开启一些额外的“协议能力”比如 MQTT 客户端支持、原始套接字模式、Unix domain socket 服务等。严格来说这些也算 lws 多协议工作的一部分但它们的配置方式和 WebSocket 子协议差异很大很多是独立的事件处理模型。我做网关项目时用过的组合是“HTTP WebSocket 子协议”这也是 libwebsockets 最主流的多协议用法。如果你后续要把设备上报直接接到 MQTT Broker我建议还是用专门的 MQTT 客户端库跟 lws 的 WebSocket 部分分工协作而不是硬靠一个库包打天下。最后分享一个小经验多协议调试时先在服务端日志里打开详细级别观察握手阶段打印的协议匹配过程。lws 的日志输出能看到客户端声明的子协议列表和服务端匹配到的协议项这一步通过以后问题基本就锁死在业务回调里了。协议路由的思路本来就不复杂复杂的是在多个协议共享一条事件循环时保持边界清晰只要把协议表、vhost、回调生命周期这三件事理清楚多协议服务并没有想象中那么难伺候。
返回列表