ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

C++用mongoose实现HTTP服务端和客户端开发实践

C++用mongoose实现HTTP服务端和客户端开发实践

1. 为什么用 mongoose 做 HTTP 服务端和客户端

1.1 它解决什么问题

做 C++ 网络开发的人应该都清楚,HTTP 这块看着简单,真要自己从 socket 往上写,状态机、粘包、Header 解析、Chunked 编码,一大堆细节。mongoose 就是帮你把这层脏活封装好的库。它是纯 C 设计,以 mongoose.c 和 mongoose.h 两个文件发布,C++ 项目直接拿过来参与编译就行,不需要安装额外的运行时,也不要求 Boost 这类巨头依赖。嵌入式和服务器环境都能跑,这是它最大的优势。

更关键的是,mongoose 同时提供 HTTP 服务端和客户端能力。一个进程里既可以mg_http_listen监听接口,又可以mg_http_connect主动请求别的服务。很多场景下,我们的程序既要对外暴露 API,又要在内部转发请求到别的系统,这种“双重角色”用同一个库处理,心智负担小很多。实际项目中,我最早是拿它做某设备的本地诊断服务:设备上跑一个 HTTP 接口给手机端查询状态,同时这个服务又要主动调用后台的鉴权接口,mongoose 一个库就把两边都覆盖了。

适合谁来用?如果你要在 Linux、Windows、嵌入式 RTOS 上快速集成 HTTP 能力,又不想引一整套厚重的框架,mongoose 是很合适的选择。当然,如果项目本身就是大规模分布式服务,需要各种高级特性,那可能还是得上更重的框架;但如果只是要一个轻量、可靠、能嵌入的 HTTP 层,mongoose 非常顺手。

1.2 核心工作方式:事件驱动回调

第一次接触 mongoose 的人,最不适应的是它的模型。它不是“收到请求调用一个函数,返回结果”,而是你在一个事件循环里注册回调,当连接上有事件发生时,回调被执行。程序里通常是这样运转的:

mg_mgr_init(&mgr); mg_http_listen(&mgr, "http://0.0.0.0:8080", my_callback, NULL); for (;;) mg_mgr_poll(&mgr, 1000);

mg_mgr_poll是事件泵,它每轮检查所有连接上有没有新数据、新连接、可写事件,然后调用对应的回调。单线程就能处理大量并发连接,不需要为每个连接开线程。

用生活类比就是:传统多线程方案像每个客人配一个专属服务员,客人一多成本就翻倍;mongoose 是餐厅里一个手脚麻利的服务员,一个时间段内同时跑好几桌,客人举手他就过来处理。回调里不能像线程模型那样任意阻塞,因为阻塞在某个请求上,整个事件循环都会被拖住。理解了这一点,后面很多设计取舍你会自动明白。

1.3 选型对比:为什么不是其他库

C++ 生态里 HTTP 库不少,我简单列一下常见选择:

方案服务端客户端依赖适合场景
Boost.Beast支持支持Boost.Asio,较重大规模异步服务,对性能极致追求
cpp-httplib支持支持轻量,但同步阻塞桌面工具、测试服务
Drogon支持支持较重,异步模型复杂Web 应用后端
mongoose支持支持两个文件,极轻嵌入式、网关、工具型服务

我选 mongoose 主要看中三点。第一是轻,嵌入式交叉编译时,一个 .c 文件比一堆头文件和链接库省事太多;第二是服务端和客户端 API 风格统一,都是回调模型,记住一套就行;第三是它对系统 API 的封装很干净,Windows 下自动用 Winsock,Linux 下用 socket,不需要你关心平台差异。影响范围上,从单片机上的设备管理接口,到 PC 端的本地调试服务,再到后台模块之间的轻量通信,都能覆盖。

2. 环境准备:把 mongoose 接进 C++ 工程

2.1 获取源码与目录结构

mongoose 官方提供的是“一体化”发布方式,下载下来就是两个文件:mongoose.c 和 mongoose.h。使用非常简单,放到你的项目目录里,和你的 C++ 源文件一起编译。我习惯把工程摆成下面这样:

demo/ CMakeLists.txt mongoose.c mongoose.h server.cpp client.cpp web/ index.html

你可以从官网下载最新版本,或者直接用你项目里已有的版本。需要注意一点:mongoose 的 API 在小版本之间偶尔会调整。下面代码基于当前主流的 7.x 版本写,如果你发现某个函数名对不上,先看看是不是下载到了老版本。

2.2 CMake 配置模板

CMake 里要同时启用 C 和 C++ 语言,因为 mongoose.c 是 C 文件。一个最小可用的配置如下:

cmake_minimum_required(VERSION 3.16) project(mongoose_demo C CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(server server.cpp mongoose.c) add_executable(client client.cpp mongoose.c) target_include_directories(server PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(client PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})

编译的时候,CMake 会自动用 C 编译器编译 mongoose.c,再用 C++ 编译器编译你的业务代码,最后链接到一起。如果你的项目在其他模块里已经引入了 mongoose.c,注意不要重复添加,否则符号冲突。Linux 下如果链接阶段报 pthread 相关的错误,可以把线程库加上:

find_package(Threads REQUIRED) target_link_libraries(server PRIVATE Threads::Threads) target_link_libraries(client PRIVATE Threads::Threads)

2.3 编译选项与依赖

mongoose 提供一些编译开关,通过宏来控制功能。最常见的几个:

  • MG_ENABLE_OPENSSL=1:启用 TLS/HTTPS 支持,链接 OpenSSL。
  • MG_ENABLE_IPV6=1:启用 IPv6。
  • MG_ENABLE_LOG=1:启用内置日志。
  • MG_ENABLE_MBEDTLS=1:嵌入式环境用 mbedTLS。

如果你只需要本地 HTTP,保持默认就够,不需要任何外部依赖。需要 HTTPS 的时候再打开,用https://开头的 URL 监听和连接。我个人建议先把不加 SSL 的版本跑通,再考虑加密链路,排查问题会容易得多。

3. 服务端开发:路由、参数与响应

3.1 最小服务端代码

服务端的核心就是一个回调函数加一个事件循环。收到 HTTP 请求时,事件类型是MG_EV_HTTP_MSG,ev_data可以转成struct mg_http_message *,里面有 method、uri、query、body、headers 等完整信息。

#include "mongoose.h" static void server_cb(struct mg_connection *c, int ev, void *ev_data, void *fn_data) { if (ev == MG_EV_HTTP_MSG) { struct mg_http_message *hm = (struct mg_http_message *) ev_data; mg_http_reply(c, 200, "Content-Type: text/plain\r\n", "hello\n"); } } int main(void) { struct mg_mgr mgr; mg_mgr_init(&mgr); mg_http_listen(&mgr, "http://0.0.0.0:8080", server_cb, NULL); printf("server on :8080\n"); for (;;) mg_mgr_poll(&mgr, 1000); mg_mgr_free(&mgr); return 0; }

mg_http_listen第二个参数是监听地址。0.0.0.0:8080表示监听所有网卡的 8080 端口。如果你只想本机访问,可以写成127.0.0.1:8080。这个回调会在每次事件触发时被调用,所以第一件事就是判断ev类型。

3.2 路由:按 URI 分发请求

随着接口变多,一个回调里写满 if-else 还是可以接受的。mongoose 提供mg_match做模式匹配,支持通配符。*匹配到下一个斜杠为止,#匹配剩下的全部内容。我最常用的写法是:

if (mg_match(hm->uri, mg_str("/api/hello"), NULL)) { mg_http_reply(c, 200, "Content-Type: application/json\r\n", "{\"code\":0,\"msg\":\"hello\"}\n"); } else if (mg_match(hm->uri, mg_str("/api/user/#"), NULL)) { // 这里可以拿到 URI 后面的用户 ID } else { mg_http_reply(c, 404, "Content-Type: application/json\r\n", "{\"code\":404}\n"); }

mg_match的第一个参数是被匹配的字符串,第二个参数是规则。第三个参数可以传入一个struct mg_str *保存匹配的通配符片段,比如/api/user/#中#捕获的部分。这种匹配方式够用,而且比正则快很多。实际项目里我会把路由表抽出来,用一张表维护路径和处理函数,避免回调函数膨胀成几百行。

需要注意,hm->uri是struct mg_str,不是 C 字符串,它不保证以\0结尾。如果要转成std::string,要用std::string(hm->uri.ptr, hm->uri.len),不能直接printf("%s", hm->uri.ptr)。

3.3 读取 GET 和 POST 参数

mongoose 提供了mg_http_get_var来解析 URL query 和表单 body。它接收一个struct mg_str *,所以 query、body、甚至 cookie 都可以喂给它。示例:

char name[128] = {0}; int len = mg_http_get_var(&hm->query, "name", name, sizeof(name)); if (len > 0) { // 用 name }

如果是 POST 表单,参数在 body 里,把&hm->query换成&hm->body即可:

mg_http_get_var(&hm->body, "username", user, sizeof(user)); mg_http_get_var(&hm->body, "password", pass, sizeof(pass));

这里有个容易被坑的点:mg_http_get_var解析的是application/x-www-form-urlencoded格式,如果你的客户端发的是application/json,那它不会帮你解出 JSON 字段。JSON 的解析不在 mongoose 的职责范围内。对于简单的 JSON 接口,我的做法是配合 nlohmann/json 这类轻量库,服务端收到 body 后转成对象再处理。

3.4 返回 JSON 和提供静态文件

返回 JSON 用mg_http_reply很简单。注意它是 printf 风格,所以如果你要输出的 JSON 里包含特殊字符,要用%.*s传入数据,不要直接把字符串放进格式串里,否则一旦内容里有%,整个输出就乱了。例如回显一个 POST 请求的原始 body:

mg_http_reply(c, 200, "Content-Type: application/json\r\n", "{\"code\":0,\"echo\":\"%.*s\"}\n", (int) hm->body.len, hm->body.ptr);

静态文件方面,mongoose 提供了mg_http_serve_dir。配置一个根目录,然后把非 API 的请求交给它处理:

struct mg_http_serve_opts opts; memset(&opts, 0, sizeof(opts)); opts.root_dir = "./web"; mg_http_serve_dir(c, hm, opts);

它会自行处理 MIME 类型、路径拼接和目录索引。更重要的是,它内置了路径穿越防护,不会因为你把 URI 里的../拼到本地路径上就泄露文件。如果你曾经吃过手写 file server 的亏,用这个函数是最省心的做法。

3.5 在回调里做简单鉴权

很多接口不希望裸奔,最轻量的方案是在回调开头检查请求头。mongoose 提供mg_http_get_header:

struct mg_str *auth = mg_http_get_header(hm, "Authorization"); if (auth == NULL || !mg_match(*auth, mg_str("Bearer *"), NULL)) { mg_http_reply(c, 401, "Content-Type: application/json\r\n", "{\"code\":401}\n"); return; }

这种检查是同步的,适合 token 本地校验。如果你需要回调里查数据库、调外部接口,那就别直接等,否则整个事件循环会被卡住。合理的做法是:把任务丢给工作线程,完成后用mg_mgr_wakeup把结果投递回 mongoose 的事件循环,再由回调把响应发出去。这一点在实际项目中救过大命。

4. 客户端开发:异步请求与同步封装

4.1 客户端也是同一套事件模型

很多人以为 mongoose 只有服务端,其实客户端的思路完全对称。mg_http_connect发起连接,之后依然是通过回调接收事件。连接建立时触发MG_EV_CONNECT,收到 HTTP 响应时触发MG_EV_HTTP_MSG,出错时触发MG_EV_ERROR。

一个最简单的客户端骨架:

struct mg_mgr mgr; mg_mgr_init(&mgr); struct mg_connection *c = mg_http_connect(&mgr, "http://127.0.0.1:8080", client_cb, NULL); if (c != NULL) { mg_printf(c, "GET /api/hello HTTP/1.0\r\nHost: 127.0.0.1:8080\r\n\r\n"); } for (;;) mg_mgr_poll(&mgr, 100);

注意,mg_http_connect是非阻塞的,它并不会等 TCP 三次握手完成才返回。调用mg_http_connect后,连接会异步建立。上面这种写法把请求数据直接排到发送缓冲里,等连接就绪后 mongoose 会自动发出去。

4.2 构造 GET 和 POST 请求

HTTP 请求说白了就是控制好回车换行和 Content-Length。GET 请求最简单:

mg_printf(c, "GET %s HTTP/1.0\r\nHost: %s\r\n\r\n", path, host);

POST 请求需要带 body,必须把Content-Length算对,否则服务端会一直等 body:

const char *body = "{\"user\":\"admin\",\"pass\":\"123\"}"; mg_printf(c, "POST /api/login HTTP/1.0\r\n" "Host: 127.0.0.1:8080\r\n" "Content-Type: application/json\r\n" "Content-Length: %d\r\n\r\n" "%s", (int) strlen(body), body);

这里我用 HTTP/1.0,是因为客户端场景下响应结束连接就关闭,省去了 Keep-Alive 和连接复用的处理,最简单可靠。如果你的服务端要求 HTTP/1.1,或者你想复用连接,可以改用 1.1 并处理Host、Connection头,但初学阶段没必要。

4.3 回调里解析响应数据

响应到达时,struct mg_http_message *hm里装的是完整 HTTP 响应。状态码可以从hm->message的开头解析,body 在hm->body。我习惯先取状态码,再根据状态判断是否解析 body:

static void client_cb(struct mg_connection *c, int ev, void *ev_data, void *fn_data) { if (ev == MG_EV_HTTP_MSG) { struct mg_http_message *hm = (struct mg_http_message *) ev_data; int status = 0; sscanf(hm->message.ptr, "HTTP/%*d.%*d %d", &status); printf("status=%d\n", status); printf("body=%.*s\n", (int) hm->body.len, hm->body.ptr); } }

mg_http_get_header在客户端响应上同样能用,比如读取响应里的Content-Type或Set-Cookie。要记住hm->body.ptr不一定是\0结尾,拷贝到std::string时必须带长度。

4.4 封装成同步请求帮助函数

纯异步回调写起来灵活,但很多业务代码还是想“调一个函数,拿到返回结果”。这种需求完全可以封装。我平时的做法是给每个请求建一个上下文结构体,回调通过fn_data拿到它,请求完成后置一个完成标记,主循环等到标记或超时:

struct ReqCtx { int done; int status; std::string body; }; static void client_cb(struct mg_connection *c, int ev, void *ev_data, void *fn_data) { ReqCtx *ctx = (ReqCtx *) fn_data; if (ev == MG_EV_CONNECT) { int status = *(int *) ev_data; if (status != 0) ctx->done = 2; } else if (ev == MG_EV_HTTP_MSG) { struct mg_http_message *hm = (struct mg_http_message *) ev_data; sscanf(hm->message.ptr, "HTTP/%*d.%*d %d", &ctx->status); ctx->body.assign(hm->body.ptr, hm->body.len); ctx->done = 1; } else if (ev == MG_EV_ERROR) { ctx->done = 2; } }

然后封装函数内部自己建一个mg_mgr,循环 poll 直到超时。注意不要在循环里让mg_mgr_poll的 timeout 设成 0,那会导致 CPU 空转。50 毫秒是一个比较合适的值,既不会迟钝也不会太吃 CPU。封装函数里同样需要注意:回调是在mg_mgr_poll内部触发的,所以千万不要在回调里再去访问和修改主线程正在用的非线程安全数据,除非你用互斥锁保护。

5. 完整可运行示例:服务端和客户端联调

5.1 服务端:一组简单的 API 集合

下面是一个完整的服务端示例,包含接口和静态文件服务。你把它存成server.cpp:

#include "mongoose.h" #include <stdio.h> #include <string.h> #include <stdlib.h> static void server_cb(struct mg_connection *c, int ev, void *ev_data, void *fn_data) { if (ev != MG_EV_HTTP_MSG) return; struct mg_http_message *hm = (struct mg_http_message *) ev_data; if (mg_match(hm->uri, mg_str("/api/hello"), NULL)) { mg_http_reply(c, 200, "Content-Type: application/json\r\n", "{\"code\":0,\"msg\":\"hello\"}\n"); } else if (mg_match(hm->uri, mg_str("/api/sum"), NULL)) { char a[16] = {0}, b[16] = {0}; mg_http_get_var(&hm->query, "a", a, sizeof(a)); mg_http_get_var(&hm->query, "b", b, sizeof(b)); int sum = atoi(a) + atoi(b); mg_http_reply(c, 200, "Content-Type: application/json\r\n", "{\"code\":0,\"sum\":%d}\n", sum); } else { struct mg_http_serve_opts opts; memset(&opts, 0, sizeof(opts)); opts.root_dir = "./web"; mg_http_serve_dir(c, hm, opts); } } int main(void) { struct mg_mgr mgr; mg_mgr_init(&mgr); mg_http_listen(&mgr, "http://0.0.0.0:8080", server_cb, NULL); printf("server started on 8080\n"); for (;;) mg_mgr_poll(&mgr, 1000); mg_mgr_free(&mgr); return 0; }

注意mg_http_get_var返回的是字符串,所以数值参数我用atoi转成整数。这个接口适合演示 query 解析,实际项目中建议多做参数校验,不要直接信任 input。

5.2 客户端:调用服务端接口

客户端示例存成client.cpp,发起一次 GET 和一次 POST:

#include "mongoose.h" #include <chrono> #include <cstdio> #include <cstring> #include <string> struct ReqCtx { int done = 0; int status = 0; std::string body; }; static void client_cb(struct mg_connection *c, int ev, void *ev_data, void *fn_data) { ReqCtx *ctx = (ReqCtx *) fn_data; if (ev == MG_EV_CONNECT) { int status = *(int *) ev_data; if (status != 0) { fprintf(stderr, "connect failed, status=%d\n", status); ctx->done = 2; } } else if (ev == MG_EV_HTTP_MSG) { struct mg_http_message *hm = (struct mg_http_message *) ev_data; sscanf(hm->message.ptr, "HTTP/%*d.%*d %d", &ctx->status); ctx->body.assign(hm->body.ptr, hm->body.len); ctx->done = 1; } else if (ev == MG_EV_ERROR) { fprintf(stderr, "http error: %s\n", (char *) ev_data); ctx->done = 2; } } static std::string extract_host(const char *url) { const char *p = strstr(url, "://"); const char *start = (p != NULL) ? p + 3 : url; const char *slash = strchr(start, '/'); if (slash != NULL) return std::string(start, slash - start); return std::string(start); } static bool send_request(const char *url, const char *method, const char *path, const char *body, int timeout_ms, ReqCtx *out) { struct mg_mgr mgr; mg_mgr_init(&mgr); ReqCtx ctx; struct mg_connection *c = mg_http_connect(&mgr, url, client_cb, &ctx); if (c == NULL) { mg_mgr_free(&mgr); return false; } std::string host = extract_host(url); if (strcmp(method, "GET") == 0) { mg_printf(c, "GET %s HTTP/1.0\r\nHost: %s\r\n\r\n", path, host.c_str()); } else { mg_printf(c, "POST %s HTTP/1.0\r\n" "Host: %s\r\n" "Content-Type: application/json\r\n" "Content-Length: %d\r\n\r\n" "%s", path, host.c_str(), (int) strlen(body), body); } auto begin = std::chrono::steady_clock::now(); while (ctx.done == 0) { auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>( std::chrono::steady_clock::now() - begin).count(); if (elapsed > timeout_ms) break; mg_mgr_poll(&mgr, 50); } *out = ctx; mg_mgr_free(&mgr); return ctx.done == 1; } int main(void) { ReqCtx r1; if (send_request("http://127.0.0.1:8080", "GET", "/api/sum?a=10&b=20", NULL, 3000, &r1)) { printf("GET /api/sum -> status=%d body=%s\n", r1.status, r1.body.c_str()); } else { printf("GET /api/sum failed\n"); } ReqCtx r2; const char *body = "{\"msg\":\"hello from client\"}"; if (send_request("http://127.0.0.1:8080", "POST", "/api/echo", body, 3000, &r2)) { printf("POST /api/echo -> status=%d body=%s\n", r2.status, r2.body.c_str()); } else { printf("POST /api/echo failed\n"); } return 0; }

这里的send_request每次创建独立的mg_mgr,好处是互不干扰,坏处是每次调用都有建连开销。高频调用场景建议改用全局 manager,把连接和事件循环常驻。

5.3 编译、运行和验证

按下面步骤操作:

mkdir -p build web echo '<html><body><h1>mongoose demo</h1></body></html>' > web/index.html cd build cmake .. make ./server & ./client curl 'http://127.0.0.1:8080/api/sum?a=1&b=2'

预期输出:

  • curl返回{"code":0,"sum":3}
  • 客户端程序打印 GET 和 POST 的请求结果
  • 浏览器打开http://127.0.0.1:8080/能看到刚才的 HTML 页面

第一次跑通整个链路后,再改接口、加鉴权、接数据库,就有稳定的地基了。

6. 常见问题与排查实录

6.1 高频问题速查表

现象可能原因处理方式
编译报错,找不到mg_http_listen版本太老或头文件不对更新到 7.x,确保 include 的是同一个 mongoose.h
链接失败,出现一堆undefined referencemongoose.c 没有参与编译确认 CMake 里把 mongoose.c 加进了 target,并启用 C 语言
服务端能编译但启动报端口占用端口被其他进程占用lsof -i:8080或netstat找出占用进程
客户端连不上,MG_EV_CONNECT的 status 非 0服务端没启动、地址错了、防火墙拦截先用 curl 验证服务端,再查监听地址
客户端请求发出去了,但一直收不到响应请求头里Content-Length不正确或没写Host用 HTTP/1.0 简化,仔细构造请求消息
回调里做耗时操作,整个服务卡死事件循环被阻塞耗时任务放到工作线程,用mg_mgr_wakeup回投
CPU 占用居高不下mg_mgr_poll的 timeout 为 0改成 10~100ms,或者按业务需要给出合理值
中文乱码Content-Type 缺少 charset 或长度单位算错Header 写text/html; charset=utf-8,body 长度按字节算

6.2 我踩过的几个坑

第一个坑,是把回调里的hm->uri.ptr直接当成char *用。它并不是以\0结尾的,直接strlen会越界。之后我养成了习惯:所有从struct mg_str转字符串的操作都带长度。

第二个坑,是在回调里把fn_data指向了一个栈上对象,但请求还没完成,函数就返回了,结果回调触发时访问到非法内存。现在我的规则是:用堆对象或者把上下文的生命周期拉长到整个请求结束,宁可多一句delete,也不能让回调踩悬空指针。

第三个坑和mg_http_reply的格式串有关。有一次我在 JSON 里动态拼客户输入,直接用了用户输入当格式串,结果遇到内容里的%就崩溃或乱码。正确做法是永远把动态内容作为参数传给格式串,比如"%.*s"。这在服务端和客户端都适用。

最后分享一个小技巧:调试阶段,服务端先用 curl 验证,客户端再单独用一个小测试服务验证。两边都跑通后再联调,出问题就能立刻判断是服务端的问题还是客户端的问题,不用在两端之间反复猜。

RELATED READING

延伸阅读

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