ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

libwebsockets多协议同端口实战:HTTP与WebSocket共存机制解析

libwebsockets多协议同端口实战:HTTP与WebSocket共存机制解析 libwebsockets我习惯直接叫它lws最容易被新手忽略的一点是它从来不是一个只能跑WebSocket协议的库。它默认就支持一个进程、一个监听端口上同时挂多套协议HTTP、WebSocket、甚至H2而且每套协议都有自己独立的状态存储、回调入口和生命周期管理。这就是标题里说的“多协议工作”。如果你之前只是照着demo把WebSocket跑起来遇到“又要提供REST接口、又要做实时推送、还想顺手把静态页面也发出去”的需求第一反应可能是去外面分别起两个服务再在前面套一层Nginx。但你完全可以只用一个lws进程把所有流量收口在一个端口上。这篇文章我就把lws的多协议机制从数据结构、回调分发到实际编码完整捋一遍适合正在折腾C语言网络服务、或者想把服务端部署结构做简单的开发者看。1. 先搞明白libwebsockets的多协议到底“多”的是什么1.1 三个层次context、vhost 与 wsi想理解lws的多协议得先接受它分层的抽象模型。最顶层是lws_context它是一个全局的服务实例负责管理事件循环、TLS配置、内存池和所有监听套接字。一个进程一般只创建一个context。中间层是lws_vhost每个vhost可以理解为“一组监听配置 一组协议表”它可以绑定一个端口也可以和其他vhost共享同一个端口。最底层是lws_wsi它对应一个已经建立的连接无论是HTTP请求连接还是WebSocket全双工连接。多协议体现在两个地方一是同一个vhost上可以注册多个struct lws_protocols这是最直接的多协议二是同一个context下可以挂多个vhost每个vhost有独立的端口和协议表。两者叠加你就能在一个进程里同时提供“80端口HTTPWebSocket混合服务”和“443端口HTTPS服务”而不用拆成多个进程。我自己的理解是lws把“协议”定义为“回调函数 会话数据格式 接收缓冲区大小”的组合而不是传统意义上写死在代码里的协议栈。它通过事件驱动把所有连接上的数据先吃进来再根据连接属于哪个协议把数据交给你注册的回调去处理。1.2 为什么现实中需要多协议并存单协议服务的痛点是拆服务。比如你做一款IoT设备管理后台设备端需要长连接接收指令Web端需要REST API查询设备状态前端页面还得有人发出静态HTML。传统做法是设备连接走一个MQTT或自定义TCP服务REST走Spring Boot静态页面交给Nginx。三个服务、三个端口、三套部署、三份日志运维成本全堆在这。lws的思路是把这些流量统一到一张协议表里。设备连接走注册好的某个WebSocket子协议REST请求走HTTP协议回调静态页面由HTTP协议内部路由。所有连接都在同一个事件循环里跑所有资源都在同一个进程内部分配调试的时候抓一个端口的包就够了。还有个容易被忽略的场景升级平滑期。你想把老客户端的HTTP轮询改成WebSocket推送但是不能一刀切下线HTTP接口。多协议并存可以让你在同一个端口上同时保留两条通道老的继续走REST新的走WS等服务端数据统计确认迁移比例后再关掉旧通道。1.3 单端口部署带来的实际收益有一个非常现实的收益是TLS证书。如果拆成多个服务每个HTTPS入口都要单独配证书、处理证书续期。而lws把所有监听都收口在同一个context里TLS证书只加载一次握手逻辑统一走一套。第二个收益是防火墙和端口管理。物联网设备部署在客户现场网管只给你开一个端口的情况非常常见。多协议合流后80或443一个端口就能同时承担设备长连接、控制命令下发、固件下载、状态查询这些所有工作不用去跟客户IT部门反复磨端口。第三个收益是内存和线程的资源复用。lws默认是单线程事件循环连接多起来之后不需要像多线程模型那样为每个连接分配线程栈。协议再多共享的还是同一套内存池和poll循环。这一点在资源受限的嵌入式环境里尤其值钱。2. 核心机制拆解protocols数组、回调与事件循环2.1 struct lws_protocols 的字段到底在控什么多协议最终都会落到一个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; /* 自定义id方便区分是哪个协议 */ void *user; /* 协议级共享数据指针 */ size_t tx_packet_size; /* 发送分片大小一般为0用默认值 */ unsigned int pkt_sequential; /* 是否顺序处理发送包 */ };name字段不只是给人看的。WebSocket握手时客户端请求头可以带Sec-WebSocket-Protocol服务端就是拿它跟数组里的name逐个做字符串匹配。callback不用多解释协议所有事件都会进这个函数。per_session_data_size是关键lws每接收一个新连接就会按这个大小给连接分配一块独立内存然后把这块内存的首地址通过回调函数的user参数传给你。这相当于框架帮你做了会话隔离。rx_buffer_size决定lws从内核读数据时缓冲区的上限。如果业务消息很大这里设小了会触发分块回调增加协议处理复杂度。我一般习惯直接设成业务最大消息尺寸比如聊天服务设成4096或8192。2.2 回调是什么时候、被谁调用的lws内部维护一个poll循环典型代码就是while (lws_service(context, 50) 0);lws_service会等待并处理所有已注册的fd。每当某个连接上有数据可读、可写或者关闭事件lws会根据这个连接保存在wsi里的协议指针找到对应的回调函数和会话数据然后调用int callback(struct lws *wsi, enum lws_callback_reasons reason, void *user, void *in, size_t len);reason是回调原因枚举常见的有LWS_CALLBACK_ESTABLISHED连接建立、LWS_CALLBACK_RECEIVE收到数据、LWS_CALLBACK_SERVER_WRITEABLE可以写数据了、LWS_CALLBACK_CLOSED连接关闭。你不需要自己在业务代码里管理fd事件框架把“什么时候调”这件事全部接管了。这里有一个对多协议极其重要的结论不管注册了多少个协议回调都跑在同一个线程里、同一个循环里。这是好事因为你的业务逻辑天然不需要加锁。但也意味着任何一个回调里出现了阻塞操作比如sleep(1)、或者做数据库同步查询整个服务的所有协议都会被拖慢。我见过一个同事在WS收到设备数据后直接同步发了一个HTTP请求去别的服务结果所有在线设备都开始延迟排查了半天。2.3 多协议在握手阶段怎么定位HTTP升级与子协议协商客户端访问一个地址第一个请求往往是HTTP。lws内部先按HTTP协议解析请求头然后根据请求头里的字段决定把这连接交给哪套逻辑。如果是一个普通HTTP请求连接保持HTTP角色事件进HTTP协议回调。如果客户端请求头里有Upgrade: websocket、Connection: Upgrade和Sec-WebSocket-Protocollws就会进入WebSocket角色的升级流程。这个流程里最重要的匹配逻辑是子协议协商。举个例子。客户端发来GET /ws/chat HTTP/1.1 Host: 192.168.1.10:8080 Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: xxxxx Sec-WebSocket-Version: 13 Sec-WebSocket-Protocol: chat-wslws在protocols数组里找name等于chat-ws的项。找到了就把连接挂到这个协议下回给客户端HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Protocol: chat-ws从这一刻起这个连接上后续的数据收发都只走chat-ws协议的回调。没找到匹配项lws默认会拒绝升级返回握手失败。如果你的协议数组第一个是HTTP协议而客户端恰好没带子协议连接就会落入HTTP协议作为普通请求处理。2.4 一句话总结协议选择规则规则的底层逻辑其实很简单协议是按“连接”绑定的不是按数据包绑定的。一个连接一旦经过握手确定了协议后续所有包都是同一协议。多协议维护的本质是维护一张“名字到回调集合”的映射表lws在握手阶段查表在后续事件里查表查到的回调只管处理这个连接。搞清楚这条主线后面看代码就会很顺。3. 实操复现写一个HTTPWebSocket静态页面的多协议服务3.1 设计一个混合接入的业务场景这次我把目标定得具体一点用一个lws进程监听8080端口对外提供三类服务。第一是REST接口客户端GET /api/status时返回一段JSON比如当前在线设备数。第二是WebSocket通道客户端连接/ws/chat服务端收到任意文本消息后向所有在线客户端广播。第三是静态页面访问GET /时返回一个最简单的HTML页面页面上用WebSocket连接聊天通道。这三个服务分别对应HTTP协议、WS协议但WS和页面消费的是同一个业务域。这个场景很典型直接把一个雏形的管理后台/聊天室/设备监控面板串起来了。3.2 依赖准备和工程结构环境准备不用太复杂。Ubuntu系统上直接装库sudo apt-get install libwebsockets-dev自己编译的话从官网拉源码后cmake一把就能过。代码文件就一个main.c编译命令gcc -o multi_proto main.c -lwebsockets需要注意头文件路径老版本可能是libwebsockets.h新版通常在/usr/include/libwebsockets.h。3.3 完整代码实现与关键行解读下面是核心代码我按模块拆开讲。首先是协议回调先写HTTP协议回调#include libwebsockets.h #include string.h #include stdio.h static int client_fds[64]; static int client_count 0; static int callback_http(struct lws *wsi, enum lws_callback_reasons reason, void *user, void *in, size_t len) { char uri[128]; switch (reason) { case LWS_CALLBACK_HTTP: lws_hdr_copy(wsi, uri, sizeof(uri), WSI_TOKEN_GET_URI); if (strcmp(uri, /api/status) 0) { unsigned char buf[LWS_PRE 128]; const char *body {\online\:\12\}; int n sprintf((char *)buf LWS_PRE, HTTP/1.1 200 OK\r\n Content-Type: application/json\r\n Content-Length: %d\r\n \r\n%s, (int)strlen(body), body); lws_write(wsi, buf LWS_PRE, n, LWS_WRITE_HTTP); return 0; } /* 简单处理静态页面 */ if (strcmp(uri, /) 0) { static const char html[] htmlbody h1lws multi-protocol/h1 /body/html; unsigned char buf[LWS_PRE 512]; int n sprintf((char *)buf LWS_PRE, HTTP/1.1 200 OK\r\n Content-Type: text/html\r\n Content-Length: %d\r\n \r\n%s, (int)strlen(html), html); lws_write(wsi, buf LWS_PRE, n, LWS_WRITE_HTTP); return 0; } return -1; default: break; } return 0; }这里是LWS_CALLBACK_HTTP这个reasonlws在解析完HTTP头之后会触发它。lws_hdr_copy是取请求URI的标准办法很多人第一次写lws HTTP服务都在这里卡住总想从in参数里拿URI其实URI要从header token里取。接着是WebSocket协议回调static int callback_ws(struct lws *wsi, enum lws_callback_reasons reason, void *user, void *in, size_t len) { (void)user; switch (reason) { case LWS_CALLBACK_ESTABLISHED: if (client_count 64) client_fds[client_count] lws_get_socket_fd(wsi); break; case LWS_CALLBACK_RECEIVE: /* 收到消息向所有在线ws客户端广播 */ for (int i 0; i client_count; i) { struct lws *target lws_get_peer_wsi(wsi); /* 简化真实场景应保存wsi */ } /* 强制所有客户端可写延迟到writeable回调里发送 */ for (int i 0; i client_count; i) { /* 需要一个保存lws指针的静态数组我这里先展示思路 */ } break; case LWS_CALLBACK_CLOSED: /* 移除断开连接 */ break; default: break; } return 0; }上面这段我故意留了简化痕迹真实项目里不能只存socket fd因为lws的wsi才是操作句柄。正确的做法是保存struct lws *指针数组然后在writeable回调中发送。但这行代码主要是让你看清协议回调的骨架。我下面给一个能真正运行的正确版本思路在ESTABLISHED里把wsi指针存进静态数组在RECEIVE里逐一对每个wsi调用lws_callback_on_writable然后在LWS_CALLBACK_SERVER_WRITEABLE里用lws_write把缓存的数据发出去。协议表和主函数static struct lws_protocols protocols[] { { http, callback_http, sizeof(void *), 4096, 0, NULL, 0 }, { chat-ws, callback_ws, sizeof(void *), 4096, 0, NULL, 0 }, { NULL, NULL, 0, 0, 0, NULL, 0 } }; int main(void) { 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, create context failed\n); return 1; } while (lws_service(context, 50) 0); lws_context_destroy(context); return 0; }协议表最后一项必须是空的{ NULL, NULL, 0, 0, 0, NULL, 0 }这是结束标记漏了会崩。两个协议挂在同一个vhost下这也是多协议最基础的使用姿势。3.4 编译、启动和验证代码保存好之后直接编译运行然后分三路验证。HTTP验证curl http://127.0.0.1:8080/api/status能看到{online:12}说明HTTP协议回调生效。WebSocket验证我推荐用websocat或wscatwscat -c ws://127.0.0.1:8080/ws/chat --header Sec-WebSocket-Protocol: chat-ws连上之后再开一个终端同样连接在任一端输入字符串看另一端是否收到。如果收到说明子协议协商成功WS协议的回调正确触发。静态页面验证curl http://127.0.0.1:8080/返回HTML就说明HTTP协议里的URI路由也正常工作。你可以在浏览器里打开这个页面然后看控制台能不能建立WS连接这样三类服务就在一个端口上完成了合流。3.5 还不够的话多个vhost与多端口有时候同一套协议不能满足两个业务域的需求比如一个进程既要给内部管理端提供HTTP接口又要给外部设备提供长连接两边鉴权逻辑完全不一样。这时可以在同一个context下创建多个vhost。API大致是这样struct lws_context_creation_info info; memset(info, 0, sizeof(info)); info.port 8080; info.protocols internal_protocols; context lws_create_context(info); struct lws_vhost *vhost2 lws_create_vhost(context, info2);info2里设置port 8081、protocols device_protocols。这样一个进程监听两个端口两套协议表互相隔离但共享同一个事件循环和TLS配置参数。实际部署里这种手法常用于区分内部流量和外部流量。4. 多协议协作的三个关键细节数据隔离、共享与切换4.1 per_session_data_size 是协议隔离的基石很多新手看到回调函数里的user参数会一头雾水。其实它就是每个连接独立分配的那块会话内存的首地址。lws在为某个连接初始化协议时会根据该协议的per_session_data_size值分配内存。如果你在HTTP协议里写struct http_session { char client_ip[48]; int request_count; };然后在protocols数组里对应写sizeof(struct http_session)那么每个HTTP连接都自动拥有独立的http_session实例。回调里你可以安全地把user强转成struct http_session *。WS协议也可以有完全不同的结构struct ws_session { char username[32]; int room_id; int last_ping; };两者的会话数据结构完全不同但因为协议表把它们分开了lws为每个连接分配的内存类型也就互不干扰。多协议并存的代码不会因为数据格式不同而互相踩内存。这是整个多协议设计里最核心的隔离机制。但要注意user指针在你自己的回调里不代表“线程安全”。lws默认单线程所以没有问题如果你自己又开了额外的线程去操作这些会话内存那还是需要加锁。4.2 协议之间共享数据的常见套路协议之间不是完全隔绝的。比如REST接口要查询当前在线设备数而这个数据是在WS协议那边维护的。怎么做最简单的办法是用全局变量或者一个全局结构体。由于lws默认单线程你在HTTP回调里读、在WS回调里写只要都在lws线程内就不会有并发问题。这个共享结构可以是一个计数器、一个消息队列、甚至一个订阅列表。实际项目中更推荐把共享数据挂在context级别。创建context时设置info.user字段回调里通过lws_context_user(lws_get_context(wsi))拿回来这样不用全局变量一个进程多套实例也不会串数据。4.3 需要切换协议lws_set_wsi_protocol有一种场景是连接先以HTTP角色进来完成某些鉴权动作后服务端希望把这个连接升级成自定义的数据协议。lws提供lws_set_wsi_protocol(wsi, target_protocol)可以做协议切换但要注意切换后的回调生命周期管理它会触发旧协议的关闭流程同时初始化新协议的会话数据。不过必须提醒这种动态切换不能跟WebSocket握手升级混为一谈。WebSocket握手升级用的是HTTP Upgrade机制一般不在业务代码里手动调用而lws_set_wsi_protocol多用于运行过程中为连接动态换绑协议。我实际使用中的体会是能通过握手阶段把协议定好就别拖到运行中再换换协议牵扯的边界情况很多比如接收缓冲区里残留的数据、TLS状态下重新协商的时序处理不好容易留下隐蔽bug。4.4 写回数据的正确姿势多协议服务里跨协议推送最容易写错。很多初学者在HTTP回调里拿到一条请求想立刻向所有WS连接发消息就尝试直接遍历WS连接并调用lws_write。这在lws里是不安全的因为在当前回调流程里目标wsi可能正处于不可写状态。正确姿势是先把数据存到共享区然后对目标wsi调用lws_callback_on_writable(wsi)把“可写”事件挂到事件循环上。等lws服务到这个wsi时会触发目标协议的LWS_CALLBACK_SERVER_WRITEABLE回调你在这个回调里统一lws_write。这套机制的好处是发数据这个动作永远发生在目标wsi自己的协议上下文里不会跨协议直接操作对方内部状态。5. 实战中容易踩的五个坑以及排查思路5.1 问题速查表现象常见原因解决思路所有连接周期性卡顿某个协议回调里有阻塞操作检查回调里有没有sleep、同步锁、同步网络请求WebSocket握手总是失败protocols数组里没有匹配的子协议名确认客户端Sec-WebSocket-Protocol与数组name完全一致收到数据不完整rx_buffer_size小于单次业务包调大rx_buffer_size或用LWS_CALLBACK_RECEIVE分帧逻辑重组两个协议互相串数据per_session_data_size设置错误确认每个协议的会话结构体大小正确回调里强转类型要对应新协议加入后行为异常协议表顺序或结束标记问题保留最后的空协议项检查第一个非空协议是否被误设为默认协议5.2 日志与抓包的排查配合lws自身日志非常详细。编译时加-DLWS_LOGGING运行前设置环境变量export LWS_LOG_LEVEL1023能看到每个连接的握手过程、协议匹配结果、read/write事件。排查子协议协商问题优先看日志里有没有accept ws或reject标记。日志不够再用网络抓包工具看TCP层。抓一次握手包重点看请求头的Sec-WebSocket-Protocol和服务端响应头。很多时候问题不在lws代码而是客户端带错了协议名字。5.3 保命调试经验我踩过的最大一个坑是在回调里做了重计算导致lws无法及时处理内核缓冲区里的数据最终触发客户端超时重连。现在我的开发习惯是回调函数里只做状态机切型和数据入队重活放到独立工作线程去处理。进程启动时开几个worker线程共享一个带锁的任务队列回调push任务后立刻返回。多协议再多只要保持“回调快进快出”整个服务的稳定性就有保障。另一个经验是新协议上线先只跑测试端口不要直接混进生产vhost。lws的协议表改变会影响事件循环的初始化单独vhost调试起来定位问题快很多。等协议稳定了再和主vhost合并。6. 最后分享一点我的使用心得和lws打了几年交道我最大的感受是它的多协议能力被严重低估了。很多人把它当WebSocket库用遇到HTTP需求就绕道其实lws的HTTP协议、静态文件服务、多vhost隔离这些能力拼在一起就是一个小而完整的网关。做设备后端这类场景一个lws进程把设备接入、管理API、页面下发全部吃掉部署时只需要一个二进制、一个配置文件非常省心。如果你接下来想深入建议沿着三条线继续抄作业一是给多协议服务加上TLS证书只挂一份四个协议共享加密通道二是用lws_create_vhost把内部API和外部设备流量彻底分开日志也各走各的三是在回调里结合lws自带的工作队列把重计算任务挪出去。等你把这三件事做完再回头看“多协议工作”这个概念就会觉得它根本不是某个高级特性而只是lws作为网络服务框架的基础设计。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进