原始 body:两个支付回调
主线位置:共享主线(
Bytes)的下游。body 的每一块是 hyper 读缓冲(一个BytesMut)split_to+freeze出来的,这是构建主线的终点;http-body-util 把这些块合成一个Bytes交给 handler:只有一块时走Bytes::copy_to_bytes,也就是split_to,不拷贝;多块时新建一个BytesMut拷一次,再freeze。
flowchart TB rbuf["hyper 读缓冲 BytesMut"] --> mem["read_mem:split_to + freeze"] mem --> lim["Limited 计数"] lim --> list["Collected:块排队"] list -->|一块| one["split_to,不拷贝"] list -->|多块| many["BytesMut 拷一次"] one --> h["handler 的 Bytes"] many --> h h --> s["转 String:拷一次"] s --> sig["验签"]
本页的站点源码链接都指向本站仓库的提交 89a61b1。那个提交的 Cargo.lock 锁的是 bytes 1.12.0、axum 0.8.9、axum-core 0.5.6、http-body-util 0.1.3、hyper 1.10.1;下面引用的几段依赖代码在这些版本和本卷实验用的版本(bytes 1.12.1、http-body-util 0.1.5、hyper 1.12.0)之间没有变化。
两条路由
// M5c:微信支付 v3 异步回调(JSON)。验签需原始 body 逐字节一致 → 用 Bytes。
.route(
"/api/pay/wechat/notify",
post(|headers: HeaderMap, body: axum::body::Bytes| async move {
let mut map = std::collections::HashMap::new();
for (k, v) in headers.iter() {
if let Ok(s) = v.to_str() {
map.insert(k.as_str().to_ascii_lowercase(), s.to_string());
}
}
let body_str = String::from_utf8_lossy(&body).to_string();
let (code, resp) = module_course::server::handle_wechat_notify(map, body_str).await;
// ...
}),
)
// 阶段 I:Stripe webhook(JSON)。验签同样需要原始 body。
.route(
"/api/pay/stripe/notify",
post(|headers: HeaderMap, body: axum::body::Bytes| async move {
let signature = headers
.get("stripe-signature")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
let body_str = String::from_utf8_lossy(&body).to_string();
let code = module_course::server::handle_stripe_notify(signature, body_str).await;
// ...
}),
)
axum::body::Bytes 就是 bytes::Bytes 的重新导出。两个闭包的参数顺序(HeaderMap 在前、消费 body 的 Bytes 在最后)和返回值的写法在 axum 卷的 路由与静态资源 里讲过;同一个文件里的支付宝回调用 Form<HashMap<String, String>>,它的签名是对解析出的参数排序拼接后算的,不需要原始字节。这一页只看 body 这个值。
为什么要原始字节
两个平台的签名都是对 body 原样算的。Stripe 的 verify_signature 对 "{t}.{body}" 算 HMAC-SHA256(第 79 行):
mac.update(format!("{ts}.{body}").as_bytes());
mac.verify_slice(&sig).is_ok()
微信支付的 verify_notify 对 "{timestamp}\n{nonce}\n{body}\n" 验 RSA 签名。验签通过之后,handle_stripe_notify 和 handle_wechat_notify 才用 serde_json::from_str 解析同一个字符串。
顺序不能反过来。先解析成 JSON 再序列化回去,得到的字节和平台发来的通常不同:
fn main() {
// 支付平台发来的原始 body(有空格、有 \u 转义、键的顺序是平台定的)
let raw: &[u8] = br#"{"type": "checkout.session.completed", "id": "evt_1", "note": "café"}"#;
let value: serde_json::Value = serde_json::from_slice(raw).unwrap();
let again = serde_json::to_vec(&value).unwrap();
println!("原始: {}", String::from_utf8_lossy(raw));
println!("再序列化: {}", String::from_utf8_lossy(&again));
println!("字节相同: {}", raw == &again[..]);
}
serde_json 1.0.154(默认 feature),连跑 3 次输出相同:
原始: {"type": "checkout.session.completed", "id": "evt_1", "note": "café"}
再序列化: {"id":"evt_1","note":"café","type":"checkout.session.completed"}
字节相同: false
空格没了,é 变成了 UTF-8 的 é,键按字母排了序(默认的 Map 是 BTreeMap)。签名对不上。所以 handler 要拿到的是「body 的字节,原样」,这正是 Bytes 提取器给的东西。
这个 Bytes 是怎么来的
从 socket 到 body 参数经过三层,每层的机制都在别的卷里讲过,这里只看每一步对字节做了什么。
hyper:每一块是读缓冲的一段。 HTTP/1 的 body 字节由 Buffered::read_mem 交出:
fn read_mem(&mut self, cx: &mut Context<'_>, len: usize) -> Poll<io::Result<Bytes>> {
if !self.read_buf.is_empty() {
let n = std::cmp::min(len, self.read_buf.len());
Poll::Ready(Ok(self.read_buf.split_to(n).freeze()))
} else {
let n = ready!(self.poll_read_from_io(cx))?;
Poll::Ready(Ok(self.read_buf.split_to(::std::cmp::min(len, n)).freeze()))
}
}
read_buf 是一个 BytesMut。split_to(n) 把前 n 个字节切成一个新的 BytesMut,两者共享同一块分配(第一次 split 时提升成 KIND_ARC,见 split 与 freeze);freeze 只换一张 vtable。所以每个数据块都是一个指向 hyper 读缓冲的 Bytes,不拷贝。读缓冲每次读多少、一个 body 会被切成几块,见 hyper 卷的 读写缓冲;这块缓冲从分配到复用的全过程,见 一块缓冲的一生。
axum:套上 Limited,再 collect。 axum-core 0.5.6 的 Bytes::from_request(src/extract/request_parts.rs 第 100 行)只有一句:
let bytes = req
.into_limited_body()
.collect()
.await
.map_err(FailedToBufferBody::from_err)?
.to_bytes();
into_limited_body 用 http-body-util 的 Limited 包住 body,默认上限 2 MiB(body 提取器)。Limited 只数每块的 remaining(),不碰字节。
http-body-util:Collected 里块原样排队,to_bytes 时才合并。 collect 把每个非空数据块 push 进一个 BufList(内部是 VecDeque<Bytes>),不拼接(http-body-util 的「读 body」一节)。Collected::to_bytes 是 self.bufs.copy_to_bytes(self.bufs.remaining()),BufList 覆盖了 Buf::copy_to_bytes(http-body-util 0.1.3 src/util.rs):
#[inline]
fn copy_to_bytes(&mut self, len: usize) -> Bytes {
// Our inner buffer may have an optimized version of copy_to_bytes, and if the whole
// request can be fulfilled by the front buffer, we can take advantage.
match self.bufs.front_mut() {
Some(front) if front.remaining() == len => {
let b = front.copy_to_bytes(len);
self.bufs.pop_front();
b
}
Some(front) if front.remaining() > len => front.copy_to_bytes(len),
_ => {
let rem = self.remaining();
assert!(len <= rem, "`len` greater than remaining");
let mut bm = BytesMut::with_capacity(len);
if rem == len {
// .take() costs a lot more, so skip it if we don't need it
bm.put(self);
} else {
bm.put(self.take(len));
}
bm.freeze()
}
}
}
两条路:
- 只有一块:第一个分支,调这一块自己的
copy_to_bytes。块的类型是Bytes,它的copy_to_bytes是self.split_to(len);len等于全长时split_to直接mem::replace交出自己,连data的 clone 都没有。handler 拿到的就是 hyper 交上来的那个Bytes。默认实现和这个覆盖的区别见 Buf 的「copy_to_bytes」一节。 - 多块:最后一个分支。
BytesMut::with_capacity(len)分配一块正好len字节的缓冲,put逐块extend_from_slice(容量不为 0,不走 BufMut 里讲的「容量为 0 时接管源的分配」那条路),最后freeze。这时BytesMut还是KIND_VEC且len == cap,freeze走From<Vec<u8>>,落在可提升存储上,不再分配(五种存储)。整个 body 拷一次;put每读完一块就advance过去,BufList::advance把读完的块弹出队列,随即 drop。
实验:一块和十六块
用 Router::oneshot 发一个 64 KiB 的流式 body,一次分成 1 块,一次分成 16 块,handler 照抄本站 Stripe 回调的形状。计数分配器只记 16 KiB 以上的分配,这样 axum 路由自己的小分配不会混进来:
bytes = "=1.12.1"
tokio = { version = "=1.54.1", features = ["full"] }
axum = "=0.8.9"
tower = { version = "=0.5.3", features = ["util"] }
futures-util = "0.3"
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
use axum::{
Router,
body::{Body, Bytes, to_bytes},
extract::Request,
http::HeaderMap,
routing::post,
};
use tower::ServiceExt;
const KIB: usize = 1024;
const BIG: usize = 16 * KIB; // 只记录 16 KiB 以上的分配
struct Counting;
static BIG_N: AtomicUsize = AtomicUsize::new(0);
static BIG_SIZES: [AtomicUsize; 16] = [const { AtomicUsize::new(0) }; 16];
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
if layout.size() >= BIG {
let i = BIG_N.fetch_add(1, Relaxed);
if i < 16 {
BIG_SIZES[i].store(layout.size(), Relaxed);
}
}
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
unsafe { System.dealloc(ptr, layout) }
}
}
#[global_allocator]
static GLOBAL: Counting = Counting;
fn big_allocs() -> Vec<usize> {
let n = BIG_N.swap(0, Relaxed).min(16);
(0..n).map(|i| BIG_SIZES[i].load(Relaxed)).collect()
}
// 和本站 /api/pay/stripe/notify 一样:HeaderMap + Bytes,再转成 String
async fn notify(_headers: HeaderMap, body: Bytes) -> String {
let body_ptr = body.as_ptr() as usize;
let unique = body.is_unique();
let body_str = String::from_utf8_lossy(&body).to_string();
format!("{body_ptr} {} {unique} {}", body.len(), body_str.as_ptr() as usize)
}
async fn run(app: &Router, label: &str, chunk_size: usize, total: usize) {
let chunks: Vec<Bytes> = (0..total / chunk_size)
.map(|_| Bytes::from(vec![b'{'; chunk_size]))
.collect();
let first_chunk = chunks[0].as_ptr() as usize;
let stream = futures_util::stream::iter(chunks.into_iter().map(Ok::<_, std::io::Error>));
let req = Request::post("/notify").body(Body::from_stream(stream)).unwrap();
big_allocs(); // 清零
let res = app.clone().oneshot(req).await.unwrap();
let big = big_allocs();
let text = to_bytes(res.into_body(), 1024).await.unwrap();
let text = String::from_utf8_lossy(&text);
let mut it = text.split(' ');
let body_ptr: usize = it.next().unwrap().parse().unwrap();
let len = it.next().unwrap();
let unique = it.next().unwrap();
let str_ptr: usize = it.next().unwrap().parse().unwrap();
println!("{label}: len={len}");
println!(" Bytes 指向第一块: {}", body_ptr == first_chunk);
println!(" Bytes is_unique: {unique}");
println!(" String 和 Bytes 同址: {}", str_ptr == body_ptr);
println!(" 16 KiB 以上的分配: {big:?}");
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let app = Router::new().route("/notify", post(notify));
run(&app, "1 块 x 64 KiB", 64 * KIB, 64 * KIB).await;
run(&app, "16 块 x 4 KiB", 4 * KIB, 64 * KIB).await;
}
M2 Max、rustc 1.99.0、--release,连跑 3 次输出相同:
1 块 x 64 KiB: len=65536
Bytes 指向第一块: true
Bytes is_unique: true
String 和 Bytes 同址: false
16 KiB 以上的分配: [65536]
16 块 x 4 KiB: len=65536
Bytes 指向第一块: false
Bytes is_unique: true
String 和 Bytes 同址: false
16 KiB 以上的分配: [65536, 65536]
- 一块:handler 的
Bytes和发出去的那一块地址相同,collect没有拷贝。唯一一次 64 KiB 的分配是 handler 里的to_string()。is_unique为true,因为这一块是Bytes::from(Vec)(len == cap,可提升存储),一路上没有被 clone 过(clone 与提升)。 - 十六块:地址不同,多了一次 64 KiB 的分配,就是上面
BytesMut::with_capacity(len)那一次;freeze之后它是一个新的可提升Bytes,所以同样is_unique。第二次 64 KiB 还是to_string()。
oneshot 里的块是自己造的。真实连接上的块来自 hyper 的读缓冲,结果有一处不同,留给下面的动脑筋。
is_unique 是多少?起一个真正的 axum::serve,客户端用 TcpStream 把请求头和 body 一次 write_all 写出去。/frames 不收集,打印 hyper 交上来的每一块;/whole 像本站一样用 Bytes 收:
use std::net::SocketAddr;
use axum::{
Router,
body::{Body, Bytes},
routing::post,
};
use futures_util::StreamExt;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
const KIB: usize = 1024;
// 像本站的 webhook 一样用 Bytes 收 body
async fn whole(body: Bytes) -> String {
format!("Bytes: len={} is_unique={}", body.len(), body.is_unique())
}
// 不收集,看 hyper 交上来的每一块
async fn frames(body: Body) -> String {
let mut stream = body.into_data_stream();
let mut sizes = Vec::new();
let mut first_unique = None;
while let Some(chunk) = stream.next().await {
let chunk = chunk.unwrap();
first_unique.get_or_insert(chunk.is_unique());
sizes.push(chunk.len());
}
format!("{} 块,第一块 is_unique={:?},各块 {:?}", sizes.len(), first_unique.unwrap(), sizes)
}
// 请求头和 body 一次 write_all 写出去
async fn send(addr: SocketAddr, path: &str, size: usize) -> String {
let mut s = TcpStream::connect(addr).await.unwrap();
let mut req =
format!("POST {path} HTTP/1.1\r\nhost: x\r\nconnection: close\r\ncontent-length: {size}\r\n\r\n").into_bytes();
req.extend(std::iter::repeat_n(b'{', size));
s.write_all(&req).await.unwrap();
let mut resp = Vec::new();
s.read_to_end(&mut resp).await.unwrap();
let resp = String::from_utf8_lossy(&resp);
resp.split("\r\n\r\n").nth(1).unwrap_or("-").to_string()
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/whole", post(whole)).route("/frames", post(frames));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
for size in [2 * KIB, 64 * KIB, 256 * KIB] {
println!("{} KiB", size / KIB);
println!(" {}", send(addr, "/frames", size).await);
println!(" {}", send(addr, "/whole", size).await);
}
}
本站的支付回调 body 通常只有几 KiB。2 KiB 那一行,/whole 打印的 is_unique 是 true 还是 false?64 KiB 呢?答案在本页末尾。
handler 里又拷了什么
String::from_utf8_lossy(&body) 返回 Cow<str>:body 是合法 UTF-8 时是 Cow::Borrowed,只做一遍校验,不分配;接着 .to_string() 把它拷进一个新的 String。这就是上面实验里每次都有的那一次 64 KiB。之后:
| 步骤 | 位置 | 拷贝 |
|---|---|---|
String::from_utf8_lossy(&body).to_string() | app/src/server/pay_routes.rs 第 44、60 行 | 整个 body 一次 |
Stripe:每个 v1= 签名一次 format!("{ts}.{body}") | payment/src/stripe.rs 第 79 行 | 每个签名一次 |
微信:format!("{timestamp}\n{nonce}\n{body}\n") | payment/src/wechat.rs 第 151 行 | 一次 |
serde_json::from_str::<serde_json::Value>(&body) | course/src/server.rs 第 2168、2265 行 | 字符串值各自分配 |
body 这个 Bytes 在 to_string() 之后就没有再用,闭包结束时 drop。签名验证本可以直接对 &[u8] 做(HMAC 的 update 收的就是字节),但 handle_*_notify 在另一个 crate 里,接口是 String;回调 body 只有几 KiB,一次请求多拷两三次,不是要省的地方。
问: 验签要求逐字节一致,from_utf8_lossy 会把非法 UTF-8 换成 U+FFFD,这不就把字节改了吗?
答: 只在 body 不是合法 UTF-8 时才改。合法时 from_utf8_lossy 返回 Cow::Borrowed,to_string() 得到的字节和 body 完全相同。两个平台发的都是 UTF-8 的 JSON;真碰到非法字节,替换之后签名必然对不上,handle_*_notify 回验签失败,结果和「拒绝非 UTF-8 的 body」一样。想把这种情况单独区分出来,可以改用 std::str::from_utf8(&body),它在非法时返回 Err,合法时同样不拷贝。
动脑筋答案
M2 Max、rustc 1.99.0、--release,其中一次的输出:
2 KiB
1 块,第一块 is_unique=false,各块 [2048]
Bytes: len=2048 is_unique=false
64 KiB
4 块,第一块 is_unique=false,各块 [16308, 16384, 32768, 76]
Bytes: len=65536 is_unique=true
256 KiB
11 块,第一块 is_unique=false,各块 [16307, 16384, 32768, 32456, 32664, 16332, 16332, 32664, 32664, 32664, 909]
Bytes: len=262144 is_unique=true
连跑 3 次,2 KiB 和 64 KiB 的四行每次相同;256 KiB 的块数是 11、12、11,各块大小每次不同,is_unique 不变。
- 2 KiB:
false。请求头和 body 一次读进了 hyper 的读缓冲,body 是read_mem切出来的一块,collect走单块那条路,handler 拿到的就是这一块。它的存储是BytesMut冻结来的共享块,hyper 的read_buf还拿着同一块分配,引用计数大于 1,所以is_unique为false。这是本站支付回调的常态:零拷贝,代价是这个Bytes活着时 hyper 的那块读缓冲不能被整块收回。本站在to_string()之后就不再用它,影响可以忽略;要是把这种小Bytes长期存起来,就是 一个小切片留住整块缓冲 的情形。 - 64 KiB 和 256 KiB:
true。body 分几次读进来,成了多块,collect拷进一块新缓冲,freeze成可提升的Bytes,只有 handler 一个所有者。第一块的is_unique仍是false,说明拷贝之前每一块都是读缓冲的一段。
能这样用 is_unique 区分,靠的是 BytesMut 冻结来的存储有自己的 is_unique 实现;1.6.0 里这一项填错了函数,1.6.1 才修好,见 clone 与提升 的冷知识。
小结
- 微信支付和 Stripe 的签名对原始 body 算,解析再序列化会改掉空格、转义和键序,所以两个回调用
Bytes收 body,验签之后才解析。 - handler 的
Bytes由 http-body-util 的BufList::copy_to_bytes合成:只有一块时交出那一块(Bytes::copy_to_bytes即split_to),不拷贝;多块时BytesMut::with_capacity(len)拷一次再freeze。 - 真实连接上,几 KiB 的回调 body 通常是一块,指向 hyper 的读缓冲,
is_unique为false;大 body 是多块,拷贝之后is_unique为true。 - 本站随后
String::from_utf8_lossy(&body).to_string()拷一次,验签时format!再拷一次;对几 KiB 的回调无关紧要。