简介:这套基于libcurl的微信公众号消息推送实现方案,面向需要搭建报警通知机制的开发者和运维人员。资源以Visual Studio工程形式组织,包含C/C++源码、头文件、静态库与动态库(如libcurl相关DLL及SSL依赖库)以及编译配置文件,可直接参考或集成到自有监控告警系统中。压缩包共106个文件,主体为85个头文件与4个库文件,辅以少量cpp示例和运行脚本,整体仅3.59MB,内容紧凑、便于快速上手。目前已有319人学习下载。通过阅读核心示例源码,可掌握libcurl调用HTTPS接口、构造微信模板消息及处理返回状态的完整思路,同时规避证书与依赖库配置的常见问题,适合具备基础C/C++经验、希望快速实现微信报警推送的读者。
1. 一个用 libcurl 做微信公众号消息推送的 C 项目:为什么不直接上 SDK
很多人觉得给微信公众号发消息必须上官方 SDK,其实一个用 C 语言写的 libcurl 小程序就能搞定。libcurl-weixin-message 就是一个这样的项目:不引入任何 SDK,只用 libcurl 组织 HTTPS 请求,把获取 access_token、构造模板消息 JSON、POST 到微信服务器这整个闭环拉通。说白了,它能做三件事:管理微信接口凭证的缓存,拼出符合模板消息规范的 JSON,以及把消息真正发出去。适合跑在 Linux 服务器的定时任务里,也适合当成 C 程序员阅读微信公众号接口调用的入门源码。下面按架构、核心实现、编译运行、二次改造的顺序拆一遍。
2. 架构拆解:C 文件按职责划分,token 单独做缓存层
2.1 技术选型:为什么是 libcurl 而不是 SDK
先回答一个很多人拿到这个小项目会问的问题:微信官方和各家的云服务商都提供了现成 SDK,为什么还要自己拿 libcurl 封装?
我拆这份源码时第一感受是:作者选型的逻辑特别朴素。团队里既有的监控、报表、信息收集任务全是 C 语言写的,如果引入一个 C++ 的微信 SDK,意味着要换编译器、调构建链、适配不同平台的内存管理方式,为了发一条消息付出的维护成本太高。libcurl 是 Linux 上最普及的 HTTP 客户端,绝大多数发行版默认仓库里都有 libcurl 开发包,编译时加一个-lcurl就可以了,几乎零额外成本。
另一个更关键的原因是,自己拿原生 socket 去实现 HTTPS 请求很不现实。你需要处理 DNS 解析、TCP 三次握手、TLS 证书校验、HTTP 报文拼接、chunked 编码、重定向策略等等。libcurl 把这些全部封装好以后,调用方只需要设置 URL、请求方法、超时时间,再写一个回调函数接住响应体就行。而微信公众号接口恰恰是“发请求、收 JSON”这种最简单的交互模型,用 libcurl 来表达,代码能保持在几十行以内。
这个项目没有使用线程池、没有事件驱动框架,也没有引入任何第三方 JSON 库,整个工程不到十个 C 文件。这种朴素风格虽然缺少重量级框架的扩展性,但换来了两点实际好处:第一,编译依赖极少,拿到一台普通 Linux 机器就能构建;第二,代码阅读路径短,从 main 函数到 curl 调用之间的所有逻辑一眼能看完。对于想把“微信消息推送”嵌入到既有 C 服务的团队,这是非常务实的切入方式。
2.2 目录结构与文件职责
拿到源码后打开目录,结构大概是这样一个布局:
| 文件 | 职责 |
|---|---|
src/main.c | 程序入口,读取配置,串起 token 获取和消息发送的调用链 |
src/wechat_api.c/.h | 微信接口封装:token 获取、模板消息发送、错误码解析 |
src/json_builder.c/.h | 模板消息 JSON 拼装 |
src/http.c/.h | 基于 libcurl 的 GET/POST 封装,负责响应体收集 |
src/config.c/.h | 解析 key=value 配置文件 |
Makefile | 构建脚本 |
config.ini | 运行参数示例 |
从这个文件划分能看出分层策略:配置模块只负责读文件,json_builder 只负责生成请求体,http 模块只负责收发 HTTP,wechat_api 是唯一知道微信接口地址和参数语法的模块。以后要扩展成发送文本消息、图片消息,只需要在 json_builder 里新增一个构造函数,在 wechat_api 里新增一个发送函数,底层 libcurl 传输代码完全不需要动。
这里我特别想提一下 http.c 里的动态缓冲区。微信接口返回的 JSON 长度不固定,错误信息长了可能有几 KB,用固定数组容易截断;而且 libcurl 的响应回调每次收到的数据段长度也是随机的。所以项目在 http 模块里维护了一个带容量的响应缓冲结构,回调里逐段拷贝进去,最后保证返回给上层的是完整的以\0结尾的字符串。这是把 libcurl 接入 C 结构体的标准姿势,后面核心模块部分会展开讲。
2.3 配置解析与初始化流程
配置文件用的是最简单的 key=value 格式,没有引第三方 ini 解析库。对于只有 appid、secret、template_id、touser 这几个字段的场景,自己手写解析会更顺手,也能避免引入脆弱的字符串处理代码。核心实现如下:
// src/config.c int load_config(const char *path, config_t *cfg) { FILE *fp = fopen(path, "r"); if (!fp) return -1; char line[256]; while (fgets(line, sizeof(line), fp)) { char *eq = strchr(line, '='); if (!eq) continue; *eq = '\0'; char *key = line; char *val = eq + 1; while (*val == ' ' || *val == '\t') val++; char *nl = strchr(val, '\n'); if (nl) *nl = '\0'; if (strcmp(key, "appid") == 0) { strncpy(cfg->appid, val, APPID_LEN - 1); } else if (strcmp(key, "secret") == 0) { strncpy(cfg->secret, val, SECRET_LEN - 1); } else if (strcmp(key, "template_id") == 0) { strncpy(cfg->template_id, val, TEMPLATE_ID_LEN - 1); } else if (strcmp(key, "touser") == 0) { strncpy(cfg->touser, val, TOUSER_LEN - 1); } else if (strcmp(key, "first") == 0) { strncpy(cfg->first, val, VALUE_LEN - 1); } else if (strcmp(key, "keyword1") == 0) { strncpy(cfg->keyword1, val, VALUE_LEN - 1); } else if (strcmp(key, "remark") == 0) { strncpy(cfg->remark, val, VALUE_LEN - 1); } } fclose(fp); return 0; }这个解析函数不处理引号包裹、注释和转义,对生产级 ini 解析器来说是不够的,但对微信推送这种“配置固定、格式简单”的场景刚刚好。这里用了 strncpy 限制拷贝长度,避免超长配置内容直接覆盖到栈内存上,算是 C 项目里最基础的自我保护,也提醒了后续维护者:任何写入缓存的字符串都要带长度上限。
main.c 里初始化流程固定是四步:调用 load_config 读配置,调用 get_valid_token 拿 token,调用 build_template_json 构造消息体,最后调用 send_template_msg 把消息发出去。整个过程是同步的,失败就打印错误码退出,不自动重试。不重试不是偷懒,而是微信对接口调用频次有限制,盲目的循环重试在账号被限流时不解决问题,反而可能把临时故障放大成账号级异常。
提示:把 token 获取和消息发送的责任分开以后,后面想加“批量发送给 200 个用户”这样的功能,只需要在主流程里套一个循环,不需要动 token 逻辑。
2.4 主流程串接与全局状态
main.c 的代码很短,核心就十几行:
// src/main.c int main(int argc, char *argv[]) { config_t cfg; if (argc < 2) { fprintf(stderr, "usage: %s config.ini\n", argv[0]); return 1; } if (load_config(argv[1], &cfg) != 0) { fprintf(stderr, "load config failed\n"); return 1; } const char *token = get_valid_token(cfg.appid, cfg.secret); if (!token) { fprintf(stderr, "get access_token failed\n"); return 1; } char json[1024]; if (build_template_json(json, sizeof(json), cfg.touser, cfg.template_id, "", cfg.first, cfg.keyword1, cfg.remark) != 0) { fprintf(stderr, "build json failed\n"); return 1; } char resp[4096]; char url[768]; snprintf(url, sizeof(url), "https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=%s", token); if (http_post_json(url, json, resp, sizeof(resp)) != 0) { fprintf(stderr, "http post failed\n"); return 1; } if (parse_errcode(resp) == 0) { printf("send ok\n"); } else { printf("send failed: %s\n", resp); } return 0; }这段代码体现了这个项目的主线:每个模块只提供一个函数给上层调用,错误处理直接返回退出码。如果以后想改成常驻服务,只需要把 main 里的顺序调用包进一个 while 循环,加上 sleep 间隔,就可以做成一个持续运行的消息推送进程。
3. 核心模块实现:Token 获取与模板消息下发的完整闭环
3.1 模板消息 JSON 构造
微信模板消息的请求体是一个 JSON 对象,结构固定。最核心的字段是 touser(接收者 openid)、template_id(模板 ID)和 data(模板填充数据)。
{ "touser": "oXXXXXXXXXXXXXXXX", "template_id": "tmpl_XXXXXXXXXXXXXXXX", "url": "https://example.com/log", "data": { "first": { "value": "服务告警" }, "keyword1": { "value": "CPU 使用率 92%" }, "remark": { "value": "请及时处理" } } }这个项目里的 json_builder.c 就是用 snprintf 把这堆结构拼出来:
// src/json_builder.c int build_template_json(char *buf, size_t len, const char *touser, const char *template_id, const char *url, const char *first, const char *keyword1, const char *remark) { int n = snprintf(buf, len, "{" "\"touser\":\"%s\"," "\"template_id\":\"%s\"," "\"url\":\"%s\"," "\"data\":{" "\"first\":{\"value\":\"%s\"}," "\"keyword1\":{\"value\":\"%s\"}," "\"remark\":{\"value\":\"%s\"}" "}" "}", touser, template_id, url, first, keyword1, remark); if (n < 0 || (size_t)n >= len) { return -1; // 缓冲区不够 } return 0; }调用方在 wechat_api.c 里准备一个定长数组(比如 1024 字节),先按参数长度估算空间再调用这个函数。模板消息的 value 一般几十字节就够,所以实际上很少走到 -1 分支。保留这个返回分支是为了防止后面有人把 value 换成一大段 HTML 时缓冲区被撑爆,属于防御式写法。
还有两个实战细节。第一,value 里如果带中文,微信要求 UTF-8 编码,字符串里直接放中文字面量没问题;如果带换行或双引号,JSON 结构会被破坏,建议提前过滤,最省事的做法是把换行替换成空格,把双引号转义成反斜杠加引号。第二,模板消息的 data 字段名必须和模板定义里的一致,多一个 keyword2 或少一个 first 都会在发送阶段被微信拒绝,返回 40007 之类的错误码。
3.2 access_token 的获取、缓存与失效处理
微信要求每次发送消息前,先拿 appid 和 secret 换 access_token。这个 token 官方有效期是 7200 秒,所以项目里没有每次都向微信要新 token,而是做了一层全局缓存:
// src/wechat_api.c static char cached_token[TOKEN_LEN] = {0}; static time_t cached_time = 0; const char *get_valid_token(const char *appid, const char *secret) { time_t now = time(NULL); /* 缓存未过期就复用,避免频繁调用 token 接口被限流 */ if (cached_token[0] != '\0' && (now - cached_time) < 3600) { return cached_token; } char url[512]; snprintf(url, sizeof(url), "https://api.weixin.qq.com/cgi-bin/token" "?grant_type=client_credential" "&appid=%s&secret=%s", appid, secret); char resp[4096]; if (http_get(url, resp, sizeof(resp)) != 0) { return NULL; /* 网络异常,上层按失败处理 */ } /* 微信返回形如 {"access_token":"xxx","expires_in":7200} */ const char *start = strstr(resp, "\"access_token\":\""); if (!start) return NULL; start += strlen("\"access_token\":\""); const char *end = strchr(start, '"'); if (!end) return NULL; size_t n = (size_t)(end - start); if (n >= TOKEN_LEN) n = TOKEN_LEN - 1; memcpy(cached_token, start, n); cached_token[n] = '\0'; cached_time = now; return cached_token; }缓存有效期这里特意设置成了一个保守值:官方给的是两小时,但为了减少触发频率限制和避免服务器时钟偏差带来的问题,实际按一小时刷新一次。这类“缓存时间略短于接口有效期”的做法,在调用方对过期时间不敏感的场景下是常见选择。
这段代码用字符串搜索而不是 JSON 库来提取 token。正因为微信的 /cgi-bin/token 响应格式固定,字段顺序基本稳定,直接用 strstr 配合 strchr 抠字符串,比引一个 JSON 解析库省事得多。这也是 C 小项目里务必要学会的取舍:依赖越少越容易维护,前提是你能清楚判定接口的返回格式不会随意变化。
3.3 libcurl POST/GET 请求封装
http 模块是整个项目里和 libcurl 交互最密切的部分。它把 GET、POST 两种请求统一封装成 http_get 和 http_post_json 两个函数。POST 的实现如下:
// src/http.c #include <curl/curl.h> typedef struct { char *data; size_t len; size_t cap; } resp_buf_t; static size_t write_cb(void *ptr, size_t size, size_t nmemb, void *userdata) { resp_buf_t *buf = (resp_buf_t *)userdata; size_t total = size * nmemb; if (buf->len + total + 1 > buf->cap) { total = buf->cap - buf->len - 1; // 超出容量则截断 } memcpy(buf->data + buf->len, ptr, total); buf->len += total; buf->data[buf->len] = '\0'; return total; } int http_post_json(const char *url, const char *json, char *resp_buf, size_t resp_size) { CURL *curl = curl_easy_init(); if (!curl) return -1; struct curl_slist *headers = NULL; headers = curl_slist_append(headers, "Content-Type: application/json"); headers = curl_slist_append(headers, "Accept: application/json"); resp_buf_t buf = { resp_buf, 0, resp_size }; int ret = 0; curl_easy_setopt(curl, CURLOPT_URL, url); curl_easy_setopt(curl, CURLOPT_POST, 1L); curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json); curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); curl_easy_setopt(curl, CURLOPT_WRITEDATA, (void *)&buf); curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 20L); curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); CURLcode res = curl_easy_perform(curl); if (res != CURLE_OK) { snprintf(resp_buf, resp_size, "curl error: %s", curl_easy_strerror(res)); ret = -1; } curl_slist_free_all(headers); curl_easy_cleanup(curl); return ret; }几个参数值得单独说明。CURLOPT_CONNECTTIMEOUT 十秒,CURLOPT_TIMEOUT 二十秒,这两个值保证微信那边万一卡住,调用方最多等二十秒就能拿到失败结果,不至于整个监控脚本挂死。CURLOPT_NOSIGNAL 设成 1 是因为这个程序可能被嵌入到多线程服务里,libcurl 的信号超时机制在多线程下不可靠,改成超时轮询模式更安全。
write_cb 回调的返回值必须等于实际写入的字节数。如果返回值和实际处理长度不一致,libcurl 会判定传输失败。这是新手写 libcurl 最常见的翻车点,我见过好几个项目都是因为回调返回错了长度,明明收到了响应却被 libcurl 报 CURLE_WRITE_ERROR。
GET 请求的封装和 POST 几乎是双胞胎,区别只是去掉 CURLOPT_POST 和 CURLOPT_POSTFIELDS,URL 里的查询参数直接在调用方拼好。这里不重复贴代码,记住一条:token 接口的 appid、secret 以及发送接口的 access_token 都必须经过 URL 编码后再拼进 URL,避免出现引号或空格破坏请求行。
3.4 响应解析与错误码处理
微信接口在 HTTP 层面通常永远返回 200,真正的结果藏在 body 的 JSON 字段里,errmsg 能告诉你具体问题。项目里单独封装了一个解析函数:
// src/wechat_api.c int parse_errcode(const char *resp) { const char *p = strstr(resp, "\"errcode\""); if (!p) return 0; // 没有 errcode,说明响应不是微信标准结构 const char *colon = strchr(p, ':'); if (!colon) return -1; return atoi(colon + 1); }发送消息之后的合法返回是{"errcode":0,"errmsg":"ok"},异常返回会替换成对应的错误码。项目里调用方拿到 resp 后,先调 parse_errcode,等于 0 就打印成功日志,不等于 0 就把 code 和原始 body 一起打出来,方便后续对照微信文档排查。
对用这个项目的人,微信错误码里最需要记得的三个是:
| 错误码 | 含义 | 常见原因 |
|---|---|---|
| 40001 | 凭证无效 | token 缓存过期或 appid/secret 错误 |
| 40007 | 模板不匹配 | template_id 或 data 字段名与模板定义不一致 |
| 45009 | 频率超限 | 短时间内调用过多,或 token 刷新过于频繁 |
这里有个容易混淆的点:libcurl 返回的 HTTP 状态码和微信返回的 errcode 是两套体系。HTTP 200 只代表请求到达了微信服务器,不代表业务成功;同理,HTTP 5xx 也不一定代表微信逻辑出错,可能只是微信网关在踢开旧的 HTTP 版本连接。所以排查时先看 errcode,再看 HTTP 状态码,这个顺序不能反。
4. 编译运行与常见问题排查:从零到收到消息的完整验证
4.1 环境准备与编译步骤
这个项目的运行环境很简单,依赖只有三样:gcc、make、libcurl 开发头文件。在 Debian/Ubuntu 上一条命令装完:
apt-get install -y gcc make libcurl4-openssl-devCentOS/RHEL 上对应的是yum install gcc make libcurl-devel。装完进项目目录执行 make:
makeMakefile 里最核心的编译指令长这样:
CFLAGS = -Wall -O2 -g -I/usr/include/x86_64-linux-gnu LDFLAGS = -lcurl wechat_push: src/main.c src/wechat_api.c src/json_builder.c src/config.c src/http.c $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)第一次建议保留-Wall,把警告都亮出来看一遍。C 项目里警告往往是翻车的前兆,忽略警告等于忽略问题。-g可以在需要 gdb 定位问题时再打开,调试完可以去掉以减小二进制体积。
如果 make 报找不到 curl/curl.h 头文件,先检查 dev 包是否装齐;报 undefined reference to curl_easy_init,则是 LDFLAGS 里没有正确加-lcurl。这两种编译错误和项目代码没关系,纯粹是环境配置问题。
这里也推荐一个更通用的写法:用 pkg-config 自动探测 libcurl 的编译参数,避免把/usr/include/x86_64-linux-gnu这种平台相关路径写死:
CFLAGS += $(shell pkg-config --cflags libcurl) LDFLAGS += $(shell pkg-config --libs libcurl)4.2 配置要点与运行结果分析
运行前先准备一个 config.ini,最小配置如下:
appid = wx1234567890abcdef secret = abcdef1234567890abcdef1234567890 template_id = tmpl_XXXXXXXXXXXXXXXX touser = oXXXXXXXXXXXXXXXX first = 服务告警 keyword1 = CPU 使用率超过 90% remark = 请登录服务器查看然后执行:
./wechat_push config.ini一次成功的完整日志大致是:
[INFO] load config ok [INFO] get access_token ok, token length=64 [INFO] build template msg ok [INFO] send template msg ok, errcode=0如果走到 send 阶段打印出来的 errcode 不是 0,直接把 body 里的 errmsg 复制到微信公众平台后台的文档对照,一般都能定位。我见过不少人在这一步先怀疑程序写错了,其实大概率是 appid、secret 或 template_id 填错。
还有一种明显但容易被忽略的情况:日志停在get access_token ok后面什么都不打,多半是 http_post_json 返回 -1,也就是 libcurl 层面失败了。这时把 CURLOPT_VERBOSE 设成 1,能看到请求全过程,TLS 握手在哪一步断掉、DNS 解析花了多久,全部一目了然。
4.3 高频踩坑记录与排查思路
我把实际跑这个项目过程中遇到的四个典型问题列成下面的格式,每条按“现象→原因→解决”给出结论。
现象一:请求 token 接口就报网络错误,或者返回的不是合法 JSON。
原因:最常见是 appid 和 secret 写反了,或者从后台复制时多复制了空格。另一个常见情况是服务器出口 IP 被微信侧限流或封锁。
解决:先打印出最终拼出来的 URL,人工核对 appid 和 secret;再到微信公众平台后台确认该服务器 IP 已加入调用白名单。打印 URL 这个动作本身就能确认拼接参数有没有问题。
现象二:发送返回 errcode=0,但用户收不到消息。
原因:touser 里的 openid 不属于当前公众号下的用户,或者用户已经取关,openid 失效。
解决:在公众号后台的“用户管理”页面查一次目标用户的 openid,确认它就是当前公众号会话内的一个有效 openid。
现象三:libcurl 返回 CURLE_SSL_CONNECT_ERROR 或 SSL certificate problem。
原因:运行环境缺少可用的 CA 证书,TLS 握手时无法校验服务器证书。
解决:下载一份 CA 证书文件,在代码里通过 CURLOPT_CAINFO 指定它的路径;定位阶段可以临时把 CURLOPT_SSL_VERIFYPEER 设成 0 观察问题是否消失,但生产环境不要关证书校验。
现象四:程序偶发卡住,日志里出现超时退出。
原因:运行环境的 DNS 解析慢,或者 libcurl 的信号超时处理导致阻塞。
解决:给 CURLOPT_DNS_CACHE_TIMEOUT 设一个短值,确认服务器 DNS 配置正常;如果条件允许,把微信接口域名解析成 IP 后走直连,绕开 DNS 环节。
这四条里,前两条是配置层面的问题,占实际踩坑比例的七八成;后两条是环境层面的经典问题,和 C 语言、libcurl 的环境有关系。按我的经验,项目跑不通先核对 token 和 openid,再把网络证书排查一遍,基本能覆盖九成以上的故障。
5. 二次开发:从定时推送到全量告警网关
5.1 塞进 crontab 变成定时报表
最简单的改造是把编译好的可执行文件塞进 crontab,让它每天固定时间推送一条统计报表:
0 9 * * * /opt/wechat_push /opt/config.ini >> /var/log/wechat_push.log 2>&1这样每天早上九点自动发送,配合系统日志就能追溯每次推送的结果。
5.2 改成多收件人批量推送
公众号模板消息一次只能发给一个 openid,多人群发就要在 main.c 里循环。代码改动很小:
const char *openids[] = { "oAAA", "oBBB", "oCCC" }; for (int i = 0; i < 3; i++) { build_template_json(buf, sizeof(buf), openids[i], ...); http_post_json(url, buf, resp, sizeof(resp)); sleep(1); // 对频率限制的妥协,宁可慢不能挂 }sleep(1) 是我在真实改造时习惯保留的节奏控制,微信对高频调用限制很严格,批量发送时加个间隔能避免账号被标记为滥用。
5.3 接上监控告警的最终形态
把 main.c 里的固定配置改成从命令行接收参数,就能把它直接变成通用告警工具。比如:
int main(int argc, char *argv[]) { const char *content = (argc >= 2) ? argv[1] : "default message"; // 其余逻辑复用原有代码 }这样其他系统只要执行一行 shell 就能发告警,完全不用改 C 代码。我在自己的监控脚本里就是靠这种方式,把原先“抄 openid、改 JSON、手动调接口”的流程压缩成了一行命令。
5.4 一个值得坚持的工程习惯
从那以后,我每次改完这类 C 小项目都会强制走一遍“改代码—make clean—重新编译—发一条测试消息确认 errcode=0—再交付”的流程。用固定模板消息测一次,能同时验证配置、token 缓存和网络三个环节,比事后翻日志省下太多时间。希望帮到你。
本文还有配套的精品资源,点击获取