- 后端
- 网络
- 通信
【免费下载链接】libwebsockets
canonical libwebsockets.org networking library
本篇以 libwebsockets 仓库中的minimal-examples-lowlevel/http-client/minimal-http-client-post-form示例为主体,完整讲解如何用lws_http_mp_sm_*系列 API 编程式发起带文本字段与文件上传的 multipart 表单 POST:包括示例的构建前提与运行命令、stdin 折叠到命令行的输入方式、请求/响应回调的完整流程、multipart 状态机内部如何生成 boundary 并预计算 Content-Length,以及服务端如何配合lws_spa解析上传内容、ctest 如何对 H1/H2 多种组合做回归验证。读完后,你可以在自己的 libwebsockets 客户端中复制出一套可直接运行的表单提交(含文件上传)实现。
示例定位:把浏览器表单 POST 变成程序行为
官方 README(README.md)对示例的定义是:
This example demonstrates a multipart POST to https://libwebsockets.org/testserver/formtest setting both a form variable and uploading a short file. The result of the POST form processing is captured and displayed in a hexdump.
即:向 libwebsockets.org 测试服务器的/testserver/formtest端点发送一次multipart/form-dataPOST,表单中同时包含一个文本字段和一个文件上传;服务器处理表单后的返回结果被捕获,并以 hexdump 打印出来。README 还指出,这相当于以编程方式提交你在 https://libwebsockets.org/testserver 页面 "POST" 选项卡(带文件上传)里看到的那个表单。
另一个 README 中的关键说明是:如果 lws 是用-DLWS_WITH_HTTP2=1构建的,客户端默认以 HTTP/2 发起这次 POST;未启用 H2 时则走 H1。这一点在后文 ctest 部分会再出现——测试用例专门覆盖了 H1/H2 两种传输。
该示例位于minimal-examples-lowlevel/http-client/目录下,与同目录的其他最小化 HTTP 客户端示例(GET 请求、并发请求、普通 POST 等)并列,见 http-client/README.md 的清单。示例目录包含 4 个文件:
| 文件 | 作用 |
|---|---|
| minimal-http-client-post-form.c | 示例主程序,约 430 行,是本文分析的主体 |
| CMakeLists.txt | 构建定义 + ctest 测试用例定义 |
| README.md | 使用说明 |
| libwebsockets.org.cer | mbedTLS/WolfSSL/OpenHiTLS 构建下显式使用的 CA 证书 |
构建与运行:构建前提、命令与命令行参数
构建
按 README 的说明,在示例目录内执行:
$ cmake . && make但能否编译成功取决于 libwebsockets 的配置开关。从 CMakeLists.txt 看,构建前先通过require_lws_config逐项检查配置,全部满足才生成目标:
set(requirements 1) require_lws_config(LWS_ROLE_H1 1 requirements) require_lws_config(LWS_WITH_CLIENT 1 requirements) # ctest for this requires server require_lws_config(LWS_WITH_SERVER 1 requirements) require_lws_config(LWS_WITHOUT_TEST_SERVER 0 requirements) require_lws_config(LWS_WITHOUT_TESTAPPS 0 requirements) require_lws_config(LWS_WITH_FILE_OPS 1 requirements)含义:
LWS_ROLE_H1与LWS_WITH_CLIENT是客户端 POST 的硬性前提;LWS_WITH_FILE_OPS是必需的,因为 multipart 状态机内部要open/fstat/read上传文件;LWS_WITH_SERVER、LWS_WITHOUT_TEST_SERVER 0、LWS_WITHOUT_TESTAPPS 0只影响 ctest(需要本地test-server作为回环验证目标),不影响程序本身;- 若检测到
LWS_WITH_MBEDTLS,CMake 会记录MBEDTLS=1,用于测试脚本中规避 valgrind(注释说明 mbedtls 在某些目标上并行测试时太慢)。
链接阶段按库形态二选一:websockets_shared或websockets(CMakeLists.txt#L96-L101)。
运行
README 给出的标准用法是把 URL 与表单字段通过stdin传入:
$ echo "https://libwebsockets.org/testserver/formtest --form text=mytext --form file=@libwebsockets.pc" | ./bin/lws-minimal-http-client-post-form这条命令里包含三类信息:
https://libwebsockets.org/testserver/formtest:裸参数,将被识别为要 POST 的 URL(见下文源码解析);--form text=mytext:一个普通表单字段,值不含@;--form file=@libwebsockets.pc:值以@开头,表示"把该文件内容作为表单file字段上传"。
其余命令行参数:
| 参数 | 作用 | 来源 |
|---|---|---|
-d <verbosity> | 内置开关,设置日志详细度 | lws_cmdline_option_handle_builtin(),main |
-l | 切换为连接本地https://localhost:7681/formtest,并追加LCCSCF_ALLOW_SELFSIGNED | switches 表与连接逻辑 |
-m | 内置 malloc 调试开关(见 ctest 用例-l -m) | 内置开关,CMake 测试用例 |
--h1 | 内置开关,强制 HTTP/1.1(否则 H2 构建下默认走 HTTP/2) | CMake 测试用例--h1 |
--port <n> | 覆盖 URL 解析出的端口,越界(<0 或 >65535)直接报错退出 | 端口覆盖逻辑 |
--form1 | 把请求路径改为/form1 | 路径覆盖逻辑 |
--help/ 无参数 | 打印开关帮助后退出 | main 开头 |
-l的用途值得说明:默认目标是公网上的 libwebsockets.org,而-l指向localhost:7681并允许自签名证书,用于在本机test-server上自测。
为什么可以用 echo 管道传参:stdin 折叠进命令行
程序在创建 context 后调用了:
if (lws_system_adopt_stdin(context, LWS_SAS_FLAG__APPEND_COMMANDLINE)) { lwsl_err("%s: failed to adopt stdin\n", __func__); goto bail; }(minimal-http-client-post-form.c#L414-L417)
这是 lws stdin 状态机特性的一个标志用法:LWS_SAS_FLAG__APPEND_COMMANDLINE会把 stdin 输入折叠进命令行 token 流。相关文档见 README.stdin.md 与 api-test-stdin-cmdline 的说明——最后一个不带换行的 token 会被解析为完整字符串、--switch=value、带空格取值或裸参数等形态。因此lws_cmdline_option_cx(cx, "--form", p)既能遍历 argv 中的--form,也能遍历从 stdin 读到的--form,这正是 README 用法可以"echo 一条管道"的原因。
核心源码走读:一个完整的表单 POST 客户端
上下文与连接建立
main()中 context 创建的关键配置(L371-L406):
info.options = LWS_SERVER_OPTION_DO_SSL_GLOBAL_INIT | LWS_SERVER_OPTION_H2_JUST_FIX_WINDOW_UPDATE_OVERFLOW; info.port = CONTEXT_PORT_NO_LISTEN; /* 纯客户端,不监听 */ info.timeout_secs = 600; /* Give large uploads up to 10 minutes */ info.protocols = protocols;CONTEXT_PORT_NO_LISTEN:本进程只做客户端;info.timeout_secs = 600:注释说明是给大文件上传留 10 分钟窗口;- 若构建使用 mbedTLS/WolfSSL/OpenHiTLS(这些库不像 OpenSSL 那样默认取系统信任库),且未加
-l,则显式设置 CA 文件:info.client_ssl_ca_filepath = "./libwebsockets.org.cer";(L397-L404),这解释了目录中libwebsockets.org.cer的用途。
连接动作并不在main()里直接发起,而是通过 lws 系统状态管理器:app_system_state_nf()监听到系统进入LWS_SYSTATE_OPERATIONAL时,注册一个延迟 250ms 的sul_connect_cb()(L335-L358)。sul_connect_cb()内:
- 用
lws_parse_uri_create(url)解析 URL,取puri->host / puri->port / puri->path填入lws_client_connect_info,允许--port覆盖端口; - 设置
i.method = "POST"、i.protocol = protocols[0].name(即"http")、i.pwsi = &client_wsi; i.ssl_connection = LCCSCF_USE_SSL(-l时再| LCCSCF_ALLOW_SELFSIGNED),i.retry_and_idle_policy指向一个 4s/600s 的重试策略;lws_client_connect_via_info(&i)发起连接,成功后由协议 0 的callback_http接管。
主循环非常简单,等完成标志置位即退出(L424-L429):
while (n >= 0 && completed != 1) n = lws_service(context, 0);退出码由bad决定:HTTP 状态码不是 200、连接出错或lws_write失败都会置位。
POST 请求生成流程:LWS_CALLBACK_CLIENT_APPEND_HANDSHAKE_HEADER
这是本示例的核心段落(L169-L198):
case LWS_CALLBACK_CLIENT_APPEND_HANDSHAKE_HEADER: { uint8_t **pin = (unsigned char **)in, *endin = (*pin) + len; pss->hmp = NULL; if (lws_http_is_redirected_to_get(wsi)) { lwsl_user("%s: ...: redirected to GET\n", __func__); break; } /* Mock curl's User-Agent so Cloudflare/Coverity WAF doesn't reject us */ if (lws_add_http_header_by_token(wsi, WSI_TOKEN_HTTP_USER_AGENT, (unsigned char *)"curl/8.12.1", 11, pin, endin)) return -1; pss->hmp = lws_http_mp_sm_init(wsi, form_cb, pin, endin); if (!pss->hmp) return 1; lws_client_http_body_pending(wsi, 1); lws_callback_on_writable(wsi); break; }逐点说明:
lws_http_is_redirected_to_get(wsi):如果这是被 3xx 重定向后的重试且服务器要求转成 GET,则不构造 POST 体;- 手工追加
User-Agent: curl/8.12.1头:源码注释写明是为了"Mock curl's User-Agent so Cloudflare/Coverity WAF doesn't reject us",即伪装成 curl 以免被目标站的 WAF 拦截; lws_http_mp_sm_init(wsi, form_cb, pin, endin):创建 multipart 状态机,form_cb是"下一个表单元素"回调,pin/endin是待发送请求头的缓冲区游标——init 内部会把Content-Type: multipart/form-data; boundary=...和算好的Content-Length直接写进请求头(原理见下一节);lws_client_http_body_pending(wsi, 1):告诉 lws "接下来还有 body 要发",随后lws_callback_on_writable(wsi)请求可写回调。这个 API 的语义在头文件注释中有完整描述:有 body 的请求必须在头阶段声明(Content-Length 或 Transfer-Encoding),随后每次LWS_CALLBACK_CLIENT_HTTP_WRITEABLE发一段,最后一段用lws_client_http_body_pending(wsi, 0)收尾(lws-client.h#L406-L436)。
逐段填充请求体:LWS_CALLBACK_CLIENT_HTTP_WRITEABLE
(L200-L237)
case LWS_CALLBACK_CLIENT_HTTP_WRITEABLE: if (lws_http_is_redirected_to_get(wsi)) break; if (!pss->hmp) return 0; n = lws_http_mp_sm_fill(pss->hmp, &p, end); if (n < 0) { lwsl_err(...); return 0; } size_t blen = (size_t)lws_ptr_diff(p, start); if (!n) { lws_client_http_body_pending(wsi, 0); /* 最后一段,结束 body */ lws_http_mp_sm_destroy(&pss->hmp); } if (lws_write(wsi, start, blen, n ? LWS_WRITE_HTTP : LWS_WRITE_HTTP_FINAL) != (int)blen) { lwsl_err("%s: lws_write failed\n", __func__); return 1; } pss->bytes_sent += blen; if (n) lws_callback_on_writable(wsi); return 0;这里体现了lws_http_mp_sm_fill()的返回约定(与头文件注释不同,以头文件和实现为准):返回值非 0 表示还有剩余数据(本实现中剩余时返回 1,错误时返回 -1),返回 0 表示所有表单元素已写完(*p被前移到写入终点,lws_ptr_diff(p, start)即本次产出的字节数)。于是:
- 非末段:
lws_write(..., LWS_WRITE_HTTP)并再次lws_callback_on_writable(wsi)循环填充; - 末段:
lws_write(..., LWS_WRITE_HTTP_FINAL)收尾,随后lws_client_http_body_pending(wsi, 0)+ 销毁状态机; - 1KB 大小的栈缓冲(
uint8_t buf[LWS_PRE + 1024])足以作为"每次 writable 填一段"的搬运单元——文件内容也是分块从磁盘read进缓冲的,因此内存占用与文件大小无关,这对嵌入式上传场景很关键。
form_cb:从命令行选项里取表单元素
form_cb()(L67-L105)实现了回调签名int cb(struct lws_context *cx, char *ft, size_t ft_len, const char **last)的约定:把下一个表单元素写成name=value字符串拷入ft,用*last作为游标记忆遍历进度;返回 0 表示"给出了一个元素",返回 1 表示"没有更多元素了"。
它的取元素逻辑是两轮遍历:
form_pass == 0:调用lws_cmdline_options_cx(cx, "--form", p)逐个取出--form值,只接受不含@的普通字段(text=mytext),含@的先跳过;遍历完进入form_pass = 1;form_pass == 1:再次遍历--form,这次只接受值含@的文件字段(file=@libwebsockets.pc);遍历完返回 1(耗尽)。
之所以要两轮,是因为 multipart 状态机在init 阶段就要遍历一遍所有元素来预计算 Content-Length(见下节),而stat()文件取大小在 init 阶段就会发生;"先文本字段、后文件字段"的顺序保证了同一轮游标语义稳定。
另外sanitize_ft()(L44-L65)会对=后面的值做清洗:去掉尾部/头部的\r、\n、空格和单双引号。这是因为 stdin 折叠进来的命令参数可能带引号或行尾控制字符,清洗后状态机里stat(eq + 1)才拿得到真实存在的文件名。
响应处理与结果校验
接收侧三个回调(L119-L165):
LWS_CALLBACK_ESTABLISHED_CLIENT_HTTP:用lws_http_client_http_response(wsi)捕获响应码(头文件明确要求在 ESTABLISHED 时取,因为之后保存响应头的内存会被释放),打印>>> SERVER RESPONSE ESTABLISHED! HTTP CODE: %d <<<;LWS_CALLBACK_RECEIVE_CLIENT_HTTP:每次用lws_http_client_read()读一段 body;读到的数据经LWS_CALLBACK_RECEIVE_CLIENT_HTTP_READ打印——lwsl_hexdump_notice(in, len)就是 README 所说的"结果以 hexdump 显示";LWS_CALLBACK_COMPLETED_CLIENT_HTTP/LWS_CALLBACK_CLOSED_CLIENT_HTTP:bad |= status != 200,completed++后lws_cancel_service()退出事件循环。注释特别说明:完成时先置client_wsi = NULL再退出,是为了避免"事务完成"与"连接关闭"两个事件重复计数(H2 管道化下 completion 不等于 close)。
深入lws_http_mp_sm_*:multipart 状态机的工作原理
三个 API 的公开声明在 lws-client.h:
typedef int (*lws_http_mp_sm_cb_t)(struct lws_context *cx, char *ft, size_t ft_len, const char **last); LWS_VISIBLE LWS_EXTERN struct lws_http_mp_sm * lws_http_mp_sm_init(struct lws *wsi, lws_http_mp_sm_cb_t cb, uint8_t **p, uint8_t *end); LWS_VISIBLE LWS_EXTERN void lws_http_mp_sm_destroy(struct lws_http_mp_sm **pphms); LWS_VISIBLE LWS_EXTERN int lws_http_mp_sm_fill(struct lws_http_mp_sm *phms, uint8_t **p, uint8_t *end);实现位于 lib/sansio/http/client/client-http.c。头文件里还保留了旧版lws_client_http_multipart()的声明,注释标注 "This is deprecated in favour of the below lws_http_mp apis"——新代码应使用状态机 API。
init:随机 boundary + 预计算 Content-Length
lws_http_mp_sm_init()(client-http.c#L2123-L2219)做了四件事:
- 生成随机 boundary:
24 个 '-'+lws_hex_random()填 16 字节,共 40 字符的边界串(boundary[24 + 16 + 1],见 lws_http_mp_sm 结构体)。随机化可避免与请求体中偶然出现的边界串冲突; - 写 Content-Type 头:
lws_snprintf(cla, ..., "multipart/form-data; boundary=%s", boundary)后经lws_add_http_header_by_token(wsi, WSI_TOKEN_HTTP_CONTENT_TYPE, ...)写入请求头缓冲; - 预扫描所有元素累加 Content-Length:循环调用你的回调取
name=value,按元素类型累加长度——文件元素累加Content-Disposition+Content-Type: application/octet-stream头长 +stat()得到的文件大小 + 结尾 CRLF;普通字段累加头长 + 值长 + CRLF;每个元素还有--boundary+ CRLF 的开销,最后再加闭合边界--boundary--+ CRLF(源码中这段注释原话:"We have to now add together the length of everything we will put in the body, in order to know the content-length now at header-time.")。这也解释了为什么示例必须"能一遍枚举完所有表单元素"的回调——init 时就要把所有元素(含文件 stat)都过一遍; - 写入 Content-Length 头:
lws_add_http_header_content_length(wsi, cl, p, end)。
任何一步失败(缓冲区放不下、stat失败、回调报错)都走bail释放并返回 NULL,示例随即返回 1 取消连接。
fill:三态状态机逐段产出数据
lws_http_mp_sm_fill()(client-http.c#L2230-L2299)内部是post_state三态机:
typedef enum { LWS_POST_STATE__NEXT, LWS_POST_STATE__FILE, LWS_POST_STATE__DATA, } post_state;- NEXT:调回调取下一个元素。回调返回"无更多元素"时写入
--boundary--\r\n闭合串并返回 0(全部完成);否则写入--boundary\r\n,若元素是@file则写入含filename=的Content-Disposition与Content-Type: application/octet-stream头,open()/fstat()打开文件后转入 FILE 态;普通字段写入Content-Disposition: form-data; name="..."头后转入 DATA 态; - FILE:
read(phms->fd, *p, chunk)把文件内容分块读入调用方缓冲(剩余空间不足 100 字节时返回 1 让调用方换缓冲区再来); - DATA:把
name=value的值 + 结尾 CRLF 写入缓冲。
注意状态机对文件元素固定使用Content-Type: application/octet-stream,并不推断 MIME 类型;如果你的目标服务器要求精确 MIME,这是需要自行评估的约束。
最终线上报文长什么样
client-http.c中该 API 上方有一段注释示例(L2084-L2100),展示了典型的 multipart 体形态:
--------------------------dbe229171d826cc3 Content-Disposition: form-data; name="version" f2dcc4ea --------------------------dbe229171d826cc3 Content-Disposition: form-data; name="email" my@email.com --------------------------dbe229171d826cc3--示例中--form text=mytext --form file=@libwebsockets.pc会产出两个 part:text字段和带filename="libwebsockets.pc"的文件 part,boundary 由随机 hex 生成。
服务端对照:/formtest 如何解析这次上传
README 强调这个示例 POST 的是"你也能在浏览器里访问到的同一个表单"。在仓库内,/formtest的处理逻辑由 test-server 的一个挂载点承接(test-server.c#L296-L302):
}, mount_post = { .mount_next = &mount_ziptest, .mountpoint = "/formtest", /* mountpoint URL */ .protocol = "protocol-post-demo", /* serve from dir */ .def = "", .origin_protocol = LWSMPRO_CALLBACK, .mountpoint_len = 9, },即把/formtest路由到protocol-post-demo协议。该协议实现(protocol_post_demo.c)展示了客户端 multipart 体的另一端如何被消费,与示例互为镜像:
- 声明了 4 个已知表单字段名
{"text", "send", "file", "upload"},LWS_CALLBACK_HTTP_BODY中用lws_spa_create()/lws_spa_process()(SPA 是 lws 的表单参数解析器,见 lws-spa.h)流式解析 multipart body; - 文件上传走
file_upload_cb():LWS_UFS_OPEN时在/tmp/post-file-<pid>-<counter>用O_CREAT | O_EXCL | O_NOFOLLOW建唯一临时文件(注释解释了防符号链接投毒的细节),LWS_UFS_CONTENT累积写入且超过 100000 字节直接丢弃,LWS_UFS_FINAL_CONTENT关 fd; LWS_CALLBACK_HTTP_BODY_COMPLETION时lws_spa_finalize()定稿,LWS_CALLBACK_HTTP_WRITEABLE中format_result()生成 HTML 表格(字段名/长度/值 + 上传文件名与长度)返回给客户端——这正是示例 hexdump 里收到的内容。
也就是说,端到端链路是:客户端form_cb逐元素供料 →lws_http_mp_sm_fill()产 multipart 体 → test-server 的 SPA + file_upload_cb 解析 → HTML 结果表 → 客户端 hexdump 展示。
ctest 回归:H1/H2 双协议 + 本地 test-server 验证
CMakeLists.txt 中定义了一整套 ctest:先通过 fixturehcpf_srv启动一个本地test-server(lws_get_free_port()分配随机空闲端口,scripts/ctest-background.sh后台拉起,可选 valgrind memcheck,结束时用ctest-background-kill.sh清理),然后运行 4 个用例:
add_test(NAME http-client-post-form COMMAND lws-minimal-http-client-post-form -l https://${LWS_CTEST_SERVER_URL_RESOLVE}:${PORT_HCP_SRV}/formtest) add_test(NAME http-client-post-form-m COMMAND lws-minimal-http-client-post-form -l -m https://${LWS_CTEST_SERVER_URL_RESOLVE}:${PORT_HCP_SRV}/formtest) add_test(NAME http-client-post-form-h1 COMMAND lws-minimal-http-client-post-form -l --h1 https://${LWS_CTEST_SERVER_URL_RESOLVE}:${PORT_HCP_SRV}/formtest) add_test(NAME http-client-post-form-m-h1 COMMAND lws-minimal-http-client-post-form -l -m --h1 https://${LWS_CTEST_SERVER_URL_RESOLVE}:${PORT_HCP_SRV}/formtest)组合覆盖了:默认协议 /-mmalloc 调试 /--h1强制 H1 /-m --h1,超时 20s,全部WORKING_DIRECTORY指向本示例目录(因为要上传的相对路径文件在示例目录内解析)。这印证了 README 的表述——默认走 H2(若构建启用),--h1显式锁定 H1。
复用到自己项目的要点
从本示例与源码结构可以提炼出移植清单:
- 三个 API 闭环:
LWS_CALLBACK_CLIENT_APPEND_HANDSHAKE_HEADER里lws_http_mp_sm_init()→ 每次LWS_CALLBACK_CLIENT_HTTP_WRITEABLE里lws_http_mp_sm_fill()+lws_write()→ 完成/出错时lws_http_mp_sm_destroy();init 前记得lws_client_http_body_pending(wsi, 1),末段后lws_client_http_body_pending(wsi, 0); - 回调契约:
form_cb必须能在"元素耗尽"时返回 1;init 阶段会完整遍历一遍所有元素(文件会stat),所以文件必须在 POST 发起前已就绪,且字段名/值中不要依赖回调的非幂等副作用; @前缀约定是状态机区分"文本字段"与"文件上传"的唯一语法,文件 part 固定Content-Type: application/octet-stream;- 分块填充意味着上传内存开销恒定(示例中 1KB 缓冲),适合嵌入式与大文件场景;
info.timeout_secs要按上传规模调整(示例给了 600s); - 非 OpenSSL TLS 后端需要显式提供 CA 文件(
client_ssl_ca_filepath),示例附带的libwebsockets.org.cer就是这个目的; - 本地验证:
-l切换localhost:7681并放宽自签名校验,或按 ctest 的方式自起一个test-server并在其上挂一个 SPA 解析 + 文件落盘的/formtest式处理器。
至此,从 README 的一行echo ... | lws-minimal-http-client-post-form命令,到lws_http_mp_sm_*状态机的 boundary 生成、Content-Length 预计算与三态填充,再到服务端 SPA 解析与 ctest 的 H1/H2 组合回归,整个最小化 multipart POST 表单上传示例的实现链条已完整呈现。
- 后端
- 网络
- 通信
【免费下载链接】libwebsockets
canonical libwebsockets.org networking library
相关推荐
TEN-framework 中的 libwebsockets 最小 HTTP 客户端示例:用 C 语言实现 multipart POST 表单与文件上传
TEN framework 中的 libwebsockets 最小 HTTP 客户端示例:用 C 语言实现 multipart POST 表单与文件上传 lib
人工智能AI Agent多模态语音AI 应用基于 libwebsockets 实现 multipart 表单文件上传:minimal-http-server-form-post-file 全解析
基于 libwebsockets 实现 multipart 表单文件上传:minimal http server form post file 全解析 libw
人工智能AI Agent多模态语音AI 应用libwebsockets 最小 HTTP 服务器:用 lwsac 管理 POST 表单解析(minimal-http-server-form-post-lwsac 深度解析)
libwebsockets 最小 HTTP 服务器:用 lwsac 管理 POST 表单解析(minimal http server form post lws
人工智能AI Agent多模态语音AI 应用
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考