SSE 与重定向
主线位置:请求主线的最后一步,和 IntoResponse 与 IntoResponseParts 同一处。这两种响应都只是
IntoResponse的实现:Redirect是一个状态码加一个头,Sse是两个头加一个流式 body。不同的是Sse的 body 在 handler 返回之后才开始产生数据,由 hyper 一帧一帧 poll。
flowchart TB stream["Stream:Result Event E"] --> kas["KeepAliveStream(可选)"] kas --> body["SseBody:每个 Event 一帧"] body --> hyper["hyper 逐帧写出"] kas -- 空闲满间隔 --> ka["注释行 :"] ka --> body redir["Redirect"] --> tup["(状态码, Location)"] tup --> resp["Response"]
Sse
Sse<S> 只包着一个流。Sse::new 要求流的每一项是 Result<Event, E>(E: Into<BoxError>),IntoResponse 加两个头,把流包成 SseBody:
fn into_response(self) -> Response {
(
[
(http::header::CONTENT_TYPE, mime::TEXT_EVENT_STREAM.as_ref()),
(http::header::CACHE_CONTROL, "no-cache"),
],
Body::new(SseBody {
event_stream: SyncWrapper::new(self.stream),
}),
)
.into_response()
}
SseBody::poll_frame 是流到 body 的直接翻译:流给一个 Event,body 就交出一帧数据(event.finalize() 的字节);流给 Err,body 就报错;流结束,body 结束。没有缓冲,没有合并,所以每个事件都会立刻交给 hyper。它不实现 size_hint,长度未知,HTTP/1.1 上 hyper 用 chunked 编码(见 body 的解码与编码)。
Event 的编码
Event 不是一组字段,而是一块正在写的缓冲区,加一个记录「哪些字段写过了」的位标志 EventFlags。每调一次构造方法,就往缓冲区末尾追加一行,所以线上的字段顺序就是调用顺序。除了 data 以外的字段都经过 field:
fn field(&mut self, name: &str, value: impl AsRef<[u8]>) {
let value = value.as_ref();
assert_eq!(
memchr::memchr2(b'\r', b'\n', value),
None,
"SSE field value cannot contain newlines or carriage returns",
);
let buffer = self.buffer.as_mut();
buffer.extend_from_slice(name.as_bytes());
buffer.put_u8(b':');
buffer.put_u8(b' ');
buffer.extend_from_slice(value);
buffer.put_u8(b'\n');
}
| 方法 | 写出 | 限制 |
|---|---|---|
event | event: 名字 | 只能调一次;不能有 \r \n |
id | id: 值 | 只能调一次;不能有 \r \n \0 |
comment | : 文字(字段名为空) | 可以调多次;不能有 \r \n |
retry | retry: 毫秒数 | 只能调一次 |
data / json_data | data: 内容,内容里每个换行拆成新的一行 | 只能调一次 |
单行字段遇到换行就 panic,是因为 SSE 以行为单位解析,一个值里的换行会被客户端当成新字段的开始,等于让值里的内容注入任意字段。data 不一样:多行数据在 SSE 里本来就写成多行 data:,客户端会用换行把它们拼回去,所以 axum 替你拆,而不是拒绝。拆的工作在 EventDataWriter::write_buf:
let mut last_split = 0;
for delimiter in memchr::memchr2_iter(b'\n', b'\r', buf) {
let _ = writer.write_all(&buf[last_split..=delimiter]);
let _ = writer.write_all(b"data: ");
last_split = delimiter + 1;
}
let _ = writer.write_all(&buf[last_split..]);
每遇到一个 \r 或 \n,就把它连同前面的内容写出,再补一个 data: 。\r 和 \n 是分开处理的,下面的实验能看到这一点对 \r\n 的影响。json_data 用 serde_json::to_writer 往同一个 writer 里写,所以 JSON 里的换行也会被拆开。
最后 finalize 补一个 \n,和最后一行的 \n 合成一个空行,表示事件结束。
use axum::response::{IntoResponse, sse::{Event, KeepAlive, Sse}};
use std::panic::{UnwindSafe, catch_unwind};
fn try_build(name: &str, f: impl FnOnce() -> Event + UnwindSafe) {
match catch_unwind(f) {
Ok(ev) => {
// finalize happens when the body is polled; build a one-event SSE body to read the bytes
let res = Sse::new(futures::stream::iter([Ok::<_, std::convert::Infallible>(ev)])).into_response();
let bytes = futures::executor::block_on(http_body_util::BodyExt::collect(res.into_body())).unwrap().to_bytes();
println!("{name:<18} ok {:?}", String::from_utf8_lossy(&bytes));
}
Err(p) => {
let msg = p.downcast_ref::<String>().cloned().or_else(|| p.downcast_ref::<&str>().map(|s| s.to_string())).unwrap();
println!("{name:<18} panic {msg:?}");
}
}
}
fn main() {
std::panic::set_hook(Box::new(|_| {}));
try_build("data with \\r\\n", || Event::default().data("a\r\nb"));
try_build("event with \\n", || Event::default().event("a\nb"));
try_build("comment with \\r", || Event::default().comment("a\rb"));
try_build("id with \\0", || Event::default().id("a\0b"));
try_build("event twice", || Event::default().event("a").event("b"));
try_build("data twice", || Event::default().data("a").data("b"));
let r = catch_unwind(|| KeepAlive::new().text("x\ny"));
println!("{:<18} {}", "KeepAlive::text", if r.is_err() { "panic" } else { "ok" });
}
M2 Max、rustc 1.99.0、--release,跑 3 次输出相同:
data with \r\n ok "data: a\rdata: \ndata: b\n\n"
event with \n panic "assertion `left == right` failed: SSE field value cannot contain newlines or carriage returns\n left: Some(1)\n right: None"
comment with \r panic "assertion `left == right` failed: SSE field value cannot contain newlines or carriage returns\n left: Some(1)\n right: None"
id with \0 panic "assertion `left == right` failed: Event ID cannot contain null characters\n left: Some(1)\n right: None"
event twice panic "Called `Event::event` multiple times"
data twice panic "Called `Event::data*` multiple times"
KeepAlive::text panic
"a\r\nb"没有 panic,但\r和\n各拆了一次,中间多出一行空的data:。按 SSE 的规则\r、\n都单独算行结束,客户端拼出来的数据是a、空行、b三行,比原文多了一个换行。要发含\r\n的文本,先把它换成\n。- 检查用的是
assert_eq!,所以 panic 信息带着left: Some(1):memchr找到的换行位置。 - 这些 panic 发生在构造
Event的地方。如果事件是在流里构造的,panic 就发生在 hyper poll body 的时候。 KeepAlive::text用的是Event::default().comment(text),同样会 panic。
线上的样子
use axum::{
Router,
response::sse::{Event, KeepAlive, Sse},
routing::get,
};
use futures::stream::{self, Stream};
use std::{convert::Infallible, time::Duration};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::{TcpListener, TcpStream},
time::{Instant, sleep},
};
fn events() -> impl Stream<Item = Result<Event, Infallible>> {
stream::unfold(0u32, |step| async move {
let ev = match step {
0 => Event::default().event("greet").id("1").data("line1\nline2"),
1 => Event::default().json_data(serde_json::json!({"n": 2})).unwrap(),
_ => {
sleep(Duration::from_secs(3600)).await;
return None;
}
};
Some((Ok(ev), step + 1))
})
}
#[tokio::main]
async fn main() {
let app = Router::new().route(
"/sse",
get(|| async { Sse::new(events()).keep_alive(KeepAlive::new().interval(Duration::from_millis(400))) }),
);
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() });
let mut conn = TcpStream::connect(addr).await.unwrap();
conn.write_all(b"GET /sse HTTP/1.1\r\nhost: x\r\n\r\n").await.unwrap();
let start = Instant::now();
let mut buf = vec![0u8; 4096];
while start.elapsed() < Duration::from_millis(900) {
let left = Duration::from_millis(900) - start.elapsed();
match tokio::time::timeout(left, conn.read(&mut buf)).await {
Ok(Ok(n)) if n > 0 => {
let text = String::from_utf8_lossy(&buf[..n]);
let ms = start.elapsed().as_millis() / 100 * 100;
if text.starts_with("HTTP/1.1") {
let head: Vec<&str> = text.split("\r\n").filter(|l| !l.starts_with("date:")).collect();
println!("~{ms:>4} ms head+: {:?}", head.join("\r\n"));
} else {
println!("~{ms:>4} ms {text:?}");
}
}
_ => break,
}
}
}
客户端直接写一个 HTTP/1.1 请求,打印每次 read 读到的原始字节,时间向下取整到 100 ms;响应头里的 date 去掉了。M2 Max、rustc 1.99.0、--release,跑 3 次输出相同:
~ 0 ms head+: "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncache-control: no-cache\r\ntransfer-encoding: chunked\r\n\r\n2C\r\nevent: greet\nid: 1\ndata: line1\ndata: line2\n\n\r\nF\r\ndata: {\"n\":2}\n\n\r\n"
~ 400 ms "3\r\n:\n\n\r\n"
~ 800 ms "3\r\n:\n\n\r\n"
- 两个头之外,
transfer-encoding: chunked是 hyper 加的。 2C是十六进制的 44,正好是event: greet\nid: 1\ndata: line1\ndata: line2\n\n的字节数:一个事件就是一个 chunk。前两个事件和响应头在同一次read里到达,是因为它们几乎同时写出,不代表 axum 合并了它们。data("line1\nline2")变成两行data:;json_data写出的是紧凑 JSON。- 之后流一直
Pending,每 400 ms 来一个 3 字节的 chunk::\n\n,一个空注释行加事件结束的空行。浏览器的EventSource会忽略它,它的作用是让连接上定期有字节经过,免得中间的代理或负载均衡器把空闲连接断掉。
上面每一行都是 name: value,冒号后有一个空格。WHATWG 的规范里这个空格可有可无(解析时去掉值开头的一个空格)。axum 0.7 之前写的是 name:value,只有值本身以空格开头时才补一个空格,免得值里的那个空格被客户端当成分隔符去掉。#2149「Add space between SSE field and value for compatibility」的理由是:「other client side tools may detect the data field rely on the space」,PR 里举了 openai-python 和 go-openai 的流读取代码为例,「in this case, axum powered http server responsed sse event will not be correctly parsed by theses libs」。改动首次发布于 axum 0.7.0,CHANGELOG 记为「For SSE, add space between field and value for compatibility」。这次改动把 field 里的 put_u8(b' ') 从「值以空格开头才写」改成了无条件写,data 的前缀也从 data: 变成 data: 。keep-alive 的 :\n\n 是另外写死的常量,所以仍然没有空格。
KeepAlive
KeepAlive 有两个字段:要发的事件和间隔。KeepAlive::new 的默认值是 Event::DEFAULT_KEEP_ALIVE(一个编译期常量 ":\n\n")和 15 秒。Sse::keep_alive 要开 tokio feature(默认开),它把流包进 KeepAliveStream,里面是一个 tokio::time::Sleep。poll_next:
match this.inner.as_mut().poll_next(cx) {
Poll::Ready(Some(Ok(event))) => {
self.reset();
Poll::Ready(Some(Ok(event)))
}
Poll::Ready(Some(Err(error))) => Poll::Ready(Some(Err(error))),
Poll::Ready(None) => Poll::Ready(None),
Poll::Pending => {
ready!(this.alive_timer.poll(cx));
let event = this.keep_alive.event.clone();
self.reset();
Poll::Ready(Some(Ok(event)))
}
}
总是先问内层的流;流有事件就转发并重置计时器,流 Pending 才去看计时器,到点了就发 keep-alive 并重置。所以 keep-alive 不是固定节拍,而是「距上一次发出任何东西满一个间隔」。字段名里的 max_interval 说的就是这个:两次发送之间最长隔多久。
KeepAlive 里存的事件已经 finalize 成 Bytes(KeepAlive::event 调 Event::finalized(event.finalize())),每次发送 clone 只是增加引用计数。
把上面的程序改两处:流在前两个事件之后先等 1000 ms,再发第三个事件,然后永远 Pending;客户端读 1700 ms。
2 => {
sleep(Duration::from_millis(1000)).await;
Event::default().comment("hi").retry(Duration::from_millis(2500)).data("third")
}
间隔仍是 400 ms。1700 ms 内会收到几个 :\n\n,分别在什么时候?第三个事件在线上长什么样?
Redirect
Redirect 存一个状态码和一个 String。三个公开构造函数对应三个状态码:
| 构造函数 | 状态码 | 方法和 body | 文档里的用途 |
|---|---|---|---|
Redirect::to | 303 See Other | 客户端改用 GET | 「useful after successful form submission, file upload or when you generally don't want the redirected-to page to observe the original request method and body」 |
Redirect::temporary | 307 Temporary Redirect | 保留原方法和 body | 「same behavior as Redirect::to, except it will preserve the original HTTP method and body」 |
Redirect::permanent | 308 Permanent Redirect | 保留原方法和 body,永久 | 文档只给出 MDN 链接 |
POST 表单处理完跳到结果页用 to;接口搬家、要求客户端带着原请求重发用 temporary;永久改 URL 用 permanent。三个都调私有的 with_status_code:
// This is intentionally not public since other kinds of redirects might not
// use the `Location` header, namely `304 Not Modified`.
//
// We're open to adding more constructors upon request, if they make sense :)
fn with_status_code(status_code: StatusCode, uri: &str) -> Self {
assert!(
status_code.is_redirection(),
"not a redirection status code"
);
Self {
status_code,
location: uri.to_owned(),
}
}
构造时不校验 URI,只存字符串。到 into_response 才转成 HeaderValue,失败就是 500:
fn into_response(self) -> Response {
match HeaderValue::try_from(self.location) {
Ok(location) => (self.status_code, [(LOCATION, location)]).into_response(),
Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(),
}
}
use axum::response::{IntoResponse, Redirect};
fn main() {
for (name, r) in [
("to", Redirect::to("/done")),
("temporary", Redirect::temporary("/v2/upload")),
("permanent", Redirect::permanent("https://example.com/new")),
("bad location", Redirect::to("/a\nb")),
] {
let res = r.into_response();
println!("{name:<13} {} location={:?}", res.status(), res.headers().get("location"));
}
}
M2 Max、rustc 1.99.0、--release,跑 3 次输出相同:
to 303 See Other location=Some("/done")
temporary 307 Temporary Redirect location=Some("/v2/upload")
permanent 308 Permanent Redirect location=Some("https://example.com/new")
bad location 500 Internal Server Error location=None
Location 原样写出,相对路径也不补全,由客户端按请求 URL 解析。带换行的地址在 HeaderValue::try_from 处失败,回 500,没有 Location。把 Redirect 放进带状态码的元组会被覆盖,见 IntoResponse 与 IntoResponseParts 的动脑筋。
axum 0.4 里还有 Redirect::found(302),axum 自带的尾斜杠重定向用的是 301。#682「Use 308 instead of 301 for trailing slash redirects」把后者换成 308,同时把 found 标为 deprecated,提示是「This results in different behavior between clients, so Redirect::temporary or Redirect::to should be used instead」。axum 0.4.4 的 CHANGELOG 给出原因:「This is to prevent clients from changing non-GET requests to GET requests」。被删掉的 found 的文档也写过:「Many clients wrongly apply Redirect::to (303 See Other) semantics for this status code, so it should be avoided where possible.」(去掉了原文的链接标记)303 / 307 / 308 则把「改不改方法」写死在状态码里。随后 #800「Remove deprecated APIs」在 axum 0.5.0 删掉了 found(CHANGELOG:「breaking: Redirect::found has been removed」),只剩今天这三个。
动脑筋答案
M2 Max、rustc 1.99.0、--release,跑 3 次输出相同:
~ 0 ms head+: "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncache-control: no-cache\r\ntransfer-encoding: chunked\r\n\r\n2C\r\nevent: greet\nid: 1\ndata: line1\ndata: line2\n\n\r\nF\r\ndata: {\"n\":2}\n\n\r\n"
~ 400 ms "3\r\n:\n\n\r\n"
~ 800 ms "3\r\n:\n\n\r\n"
~1000 ms "1E\r\n: hi\nretry: 2500\ndata: third\n\n\r\n"
~1400 ms "3\r\n:\n\n\r\n"
- 0 ms:前两个事件发出,计时器重置。
- 400、800 ms:流一直
Pending,计时器到点两次,各发一个:\n\n,每次都重置。 - 1000 ms:第三个事件到达,计时器重置到 1400 ms。原本 1200 ms 该到的那次 keep-alive 没有发生。
- 1400 ms:又空闲满 400 ms,发第三个
:\n\n。1700 ms 内一共 3 个。 - 第三个事件是
0x1E= 30 字节:: hi(注释,字段名为空)、retry: 2500(2.5 秒格式化成毫秒数)、data: third,顺序和调用顺序一致,最后一个空行结束。
小结
Sse设Content-Type: text/event-stream和Cache-Control: no-cache,SseBody把流里的每个Event变成一帧,不缓冲;HTTP/1.1 上一个事件就是一个 chunk。Event是一块按调用顺序追加的缓冲区,每行name: value,空行结束。event、id、comment遇到换行 panic,data把换行拆成多行data:;\r\n会被拆成两次。KeepAlive默认 15 秒、内容:\n\n;只在内层流Pending时看计时器,任何发出的事件都会重置计时,间隔是「最长空闲时间」。Redirect只有to(303)、temporary(307)、permanent(308);Location原样写出,转HeaderValue失败回 500。302 / 301 在 0.4.4 被弃用、0.5.0 删除。