用 Rust 写了个微信公众号 SDK,聊聊过程中踩的坑
用 Rust 写了个微信公众号 SDK,聊聊过程中踩的坑
最近用 Rust 撸了一个微信公众号的 API SDK,目的是方便以后在其他项目里直接调用公众号的各种接口。本来以为就是封装一堆 HTTP 请求的体力活,没想到过程中还是踩了不少坑。记录一下,希望对后来人有帮助。
为什么用 Rust?
其实微信公众号的 SDK,Python 和 Java 社区早就有很成熟的了。但我其他项目用的是 Rust,每次要调公众号接口都得手动拼请求,太烦了。Rust 社区这块确实比较空白,干脆自己造一个轮子。
技术栈选的是 reqwest + tokio 做异步 HTTP,serde 做序列化。看起来很标准是吧?坑就出在序列化上面。
坑一:quick-xml 不支持 serde 的 flatten
微信的消息回调用的是 XML 格式(对,2026 年了还在用 XML),所以需要一个 XML 解析库。我选了 quick-xml,它有 serde 支持,看起来很美好。
一开始我的消息模型是这样设计的:
#[derive(Deserialize)]
struct MessageBase {
to_user_name: String,
from_user_name: String,
create_time: i64,
msg_id: Option<i64>,
}
#[derive(Deserialize)]
struct TextMessage {
#[serde(flatten)]
base: MessageBase, // 复用公共字段
content: String,
}
用 #[serde(flatten)] 把公共字段抽出来,代码多优雅啊。
结果一跑测试直接炸了:
Custom("invalid type: map, expected a string")
查了半天才搞明白,quick-xml 的 serde 实现不支持 flatten。这是因为 XML 和 JSON 的数据模型不同,flatten 需要把结构体当成 map 来处理,XML 解析器做不到。
解决办法:老老实实把公共字段复制到每个消息结构里。代码是丑了点,但能跑。
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
struct TextMessage {
to_user_name: String,
from_user_name: String,
create_time: i64,
msg_id: Option<i64>,
content: String,
}
有点蠢但是管用。这算是用 Rust 做 XML 处理的一个经典坑了。
坑二:微信的 XML 字段命名鬼才
微信的消息格式大部分字段是 PascalCase 的,比如 ToUserName、FromUserName,用 #[serde(rename_all = "PascalCase")] 就能搞定。
但是!地理位置消息的字段叫 Location_X 和 Location_Y——带下划线的!这既不是 PascalCase 也不是 snake_case,是个混合体。
rename_all = "PascalCase" 会把 location_x 映射成 LocationX,但实际 XML 里是 Location_X,直接就解析失败了。
解决办法:对这俩字段单独加 rename:
#[serde(rename = "Location_X")]
pub location_x: f64,
#[serde(rename = "Location_Y")]
pub location_y: f64,
小坑,但排查起来挺浪费时间的,因为报错信息只会说 "missing field",不会告诉你名字没对上。
坑三:Access Token 的并发刷新问题
微信的 Access Token 有效期 2 小时,过期了要重新获取。一开始想得很简单:过期了就重新请求呗。
但异步场景下有个问题:如果同时有 10 个请求发现 token 过期了,会不会同时发 10 个刷新请求?这不光浪费,微信还有频率限制,搞不好就被封了。
最后的方案是用 tokio::sync::RwLock,读的时候用读锁(不阻塞),需要刷新时升级到写锁。拿到写锁之后还要再检查一次(double-check),因为可能别的协程已经刷新过了:
pub async fn get_token(&self) -> Result<String> {
// 快速路径:读锁检查
{
let state = self.state.read().await;
if let Some(ref s) = *state {
if Instant::now() < s.expires_at {
return Ok(s.access_token.clone());
}
}
}
// 慢速路径:写锁刷新
let mut state = self.state.write().await;
// Double-check!
if let Some(ref s) = *state {
if Instant::now() < s.expires_at {
return Ok(s.access_token.clone());
}
}
// 真正去刷新
let new_state = self.fetch_token().await?;
// ...
}
另外还加了提前 5 分钟刷新的 buffer,避免正好卡在过期边界上。
坑四:两个 "Token" 傻傻分不清
微信公众号开发里有两个东西都叫 Token,新手很容易搞混:
- Access Token - 调用 API 的凭证,通过 AppID + AppSecret 从接口获取,有效期 2 小时,SDK 自动管理
- 服务器配置 Token - 在公众号后台设置的一个自定义字符串,用于验证消息回调的签名
第一个是 SDK 内部自动获取的,你不用管。第二个是你自己随便定义的,需要在代码和公众号后台保持一致。
一开始我把 Config 的 token 字段改成了 Optional,觉得不接收消息的时候不需要配。后来想想还是改回了必填——既然是 SDK,就应该覆盖完整场景,不然用户想接消息了还得改代码。
坑五:IP 白名单
第一次跑集成测试的时候,返回了这个错误:
invalid ip 39.155.78.146, not in whitelist
微信要求调用 API 的服务器 IP 必须加到白名单里。这个在本地开发的时候容易忘记。去公众号后台 → 设置与开发 → 基本配置 → IP 白名单里把你的公网 IP 加上就行。
如果你在公司内网,记得加的是出口 IP,不是内网 IP。
坑六:公众号类型和权限
集成测试跑起来之后,8 个测试只过了 5 个,另外 3 个返回 48001: api unauthorized。
排查了半天以为是代码的问题,结果是公众号类型的问题。微信的权限体系大概是这样的:
- 未认证订阅号:只能用基础接口(token、素材、草稿)
- 认证订阅号:可以用用户管理、发布文章等
- 服务号:权限最全,包括支付、网页授权等
所以如果你测试某些接口报 48001,先看看自己的号是什么类型,别在代码上浪费时间。
坑七:发布文章的两种方式
微信发布文章有两个不同的接口,搞清楚区别很重要:
- freepublish(发布):文章发布后生成一个链接,但不会推送给粉丝,也不会出现在历史消息里。适合做菜单跳转、分享链接。
- mass send(群发):文章会推送到粉丝的消息列表里,有次数限制。
而且不管用哪种方式,都要先把文章存成草稿,再拿草稿的 media_id 去发布或群发。不能直接发布内容,这个流程一开始没搞清楚走了弯路。
关于 reqwest 的 Response 只能消费一次
这是个 Rust 所有权的经典问题。下载素材的时候我先检查 content-type 是不是 JSON(可能是错误响应),如果是就读 text 检查错误,否则读 bytes 返回文件内容:
let resp = self.http.get(&url).send().await?;
if content_type.contains("json") {
let text = resp.text().await?; // resp 被 move 了
// 检查错误...
}
Ok(resp.bytes().await?.to_vec()) // 编译报错!resp 已经被消费了
resp.text() 会消费掉 response,之后就不能再调 resp.bytes() 了。
解决办法:先统一读成 bytes,再根据需要转成字符串检查:
let bytes = resp.bytes().await?.to_vec();
if content_type.contains("json") {
if let Ok(text) = std::str::from_utf8(&bytes) {
// 检查是不是错误响应
}
}
Ok(bytes)
最后
整个 SDK 大概 26 个文件,覆盖了公众号开发常用的所有接口。写下来最大的感受是:微信的接口设计确实有历史包袱,XML 和 JSON 混用、命名风格不统一、文档和实际行为有出入,这些都增加了封装的复杂度。
但好处是 Rust 的类型系统在这种场景下特别有价值——编译通过之后基本就能跑,不用担心运行时突然冒出来的类型错误。
项目开源在 GitHub:WeChatOA_SDK
如果你也在用 Rust 做微信开发,欢迎试用和反馈。