Redirect 服务
主线位置:作用点主线上只有「
call时」,而且什么都不看:不包内层,也不读请求,每次都回同一个 3xx 和同一个Location。它和ServeDir一样是叶子服务(services模块文档的说法是「return responses without wrapping other Services」),Error是Infallible,poll_ready永远就绪。
flowchart TB
build["Redirect::with_status_code(状态码, uri)"] --> chk{"is_redirection"}
chk -->|否| p1["panic:not a redirection status code"]
chk -->|是| hv["uri.to_string() 转 HeaderValue"]
hv --> svc["Redirect:状态码 + Location"]
svc -->|"call(任意请求)"| fut["ResponseFuture"]
fut -->|poll| resp["Response::default() + 状态码 + Location"]
整个模块
services/redirect.rs 一共 159 行,去掉文档没剩多少:
pub struct Redirect<ResBody> {
status_code: StatusCode,
location: HeaderValue,
// Covariant over ResBody, no dropping of ResBody
_marker: PhantomData<fn() -> ResBody>,
}
pub fn with_status_code(status_code: StatusCode, uri: Uri) -> Self {
assert!(
status_code.is_redirection(),
"not a redirection status code"
);
Self {
status_code,
location: HeaderValue::try_from(uri.to_string())
.expect("URI isn't a valid header value"),
_marker: PhantomData,
}
}
temporary是 307,permanent是 308,都转调with_status_code。没有 301、302、303 的快捷构造,要自己传状态码。307 / 308 要求客户端用原来的方法和 body 重发(客户端怎么做见 FollowRedirect 的策略),301 / 302 则允许把 POST 改成 GET。- 两处检查都在构造时:状态码不是 3xx 就 panic;
Location转换失败就 panic。服务跑起来以后不会再失败。 _marker用PhantomData<fn() -> ResBody>:类型参数只用来决定响应 body 是什么类型,结构体里并不存 body。写成fn() -> ResBody,Redirect的Send、Sync就不受ResBody影响,并且对它协变,注释里说的就是这个。Clone也是手写的,不要求ResBody: Clone。
impl<R, ResBody> Service<R> for Redirect<ResBody>
where
ResBody: Default,
{
type Response = Response<ResBody>;
type Error = Infallible;
type Future = ResponseFuture<ResBody>;
// poll_ready:永远 Ready
fn call(&mut self, _req: R) -> Self::Future {
ResponseFuture {
status_code: self.status_code,
location: Some(self.location.clone()),
_marker: PhantomData,
}
}
}
请求类型是一个完全没有约束的 R,不要求是 http::Request。响应的 body 由 ResBody::default() 造(ResponseFuture::poll 里的 Response::default()),所以 ResBody 要实现 Default,响应永远是空 body,没有 Content-Type。构造时 ResBody 推断不出来,通常要写成 Redirect::<Body>::permanent(...)。
Location 是构造时定死的。它不会把请求的路径、查询串拼上去:模块文档里「把 HTTP 请求都重定向到 HTTPS」的例子,http://example.com/任何路径 都会被送到 https://example.com/。要保留路径,得自己写一个服务或 handler,从请求里取出路径再拼。
跑一遍
tower-http = { version = "=0.7.1", features = ["redirect"] }
tower = { version = "=0.5.3", features = ["util"] }
axum = "=0.8.9"
tokio = { version = "=1.54.1", features = ["macros", "rt"] }
http = "1"
use axum::{body::Body, response::IntoResponse, Router};
use http::{header, Request, StatusCode, Uri};
use tower::ServiceExt;
use tower_http::services::Redirect;
fn show(tag: &str, status: StatusCode, loc: Option<&http::HeaderValue>) {
println!("{tag:<38} {status} location={loc:?}");
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
std::panic::set_hook(Box::new(|_| {}));
// 1. 请求是什么都行,连 () 都可以
let res = Redirect::<Body>::temporary(Uri::from_static("/login")).oneshot(()).await.unwrap();
show("temporary, request = ()", res.status(), res.headers().get(header::LOCATION));
// 2. 当 fallback:目标固定,不带上原来的路径
let app = Router::new().fallback_service(Redirect::<Body>::permanent(Uri::from_static("https://example.com/")));
let res = app.oneshot(Request::get("http://example.com/docs/a?x=1").body(Body::empty()).unwrap()).await.unwrap();
show("permanent as fallback, GET /docs/a?x=1", res.status(), res.headers().get(header::LOCATION));
// 3. 304 也算 3xx
let res = Redirect::<Body>::with_status_code(StatusCode::NOT_MODIFIED, Uri::from_static("/x")).oneshot(()).await.unwrap();
show("with_status_code(304)", res.status(), res.headers().get(header::LOCATION));
// 4. 不是 3xx:构造时 panic
let r = std::panic::catch_unwind(|| Redirect::<Body>::with_status_code(StatusCode::OK, Uri::from_static("/x")));
println!("{:<38} panic: {:?}", "with_status_code(200)", r.err().and_then(|e| e.downcast_ref::<&str>().map(|s| s.to_string())));
// 5. Uri 能不能装下 HeaderValue 不接受的字节
for s in ["/a\tb", "/a\nb", "/a\x7fb"] {
println!("{:<38} {:?}", format!("Uri parse {s:?}"), s.parse::<Uri>().map(|u| u.to_string()));
}
let res = Redirect::<Body>::temporary("/中文".parse().unwrap()).oneshot(()).await.unwrap();
show("temporary(\"/中文\")", res.status(), res.headers().get(header::LOCATION));
// 6. axum 的 Redirect:响应,不是服务;坏 Location 回 500
for (tag, r) in [("axum Redirect::to", axum::response::Redirect::to("/next")), ("axum Redirect::to(\"/a\\nb\")", axum::response::Redirect::to("/a\nb"))] {
let res = r.into_response();
show(tag, res.status(), res.headers().get(header::LOCATION));
}
}
输出(M2 Max、rustc 1.99.0,跑 3 次相同):
temporary, request = () 307 Temporary Redirect location=Some("/login")
permanent as fallback, GET /docs/a?x=1 308 Permanent Redirect location=Some("https://example.com/")
with_status_code(304) 304 Not Modified location=Some("/x")
with_status_code(200) panic: Some("not a redirection status code")
Uri parse "/a\tb" Err(InvalidUri(InvalidUriChar))
Uri parse "/a\nb" Err(InvalidUri(InvalidUriChar))
Uri parse "/a\u{7f}b" Err(InvalidUri(InvalidUriChar))
temporary("/中文") 307 Temporary Redirect location=Some("/\xe4\xb8\xad\xe6\x96\x87")
axum Redirect::to 303 See Other location=Some("/next")
axum Redirect::to("/a\nb") 500 Internal Server Error location=None
- 请求是
():Service<R>对R没有约束,连()都能oneshot。它不读请求,所以也不在乎请求是什么。 - 当 fallback:
/docs/a?x=1被送到https://example.com/,路径和查询串都丢了。 - 304 能通过检查:
StatusCode::is_redirection只看是不是 300 到 399,304 Not Modified、300 Multiple Choices 都算。304 带一个Location没有意义,axum 正是因为这个才不公开它的with_status_code(见下)。 expect实际上不会触发:HeaderValue拒绝的是控制字符(除了 tab)和 DEL,而Uri在解析阶段就把 tab、换行、DEL 都拒了;Uri接受的非 ASCII 字节,HeaderValue也接受,/中文原样成了Location的字节。拿到一个合法的Uri,expect("URI isn't a valid header value")就没有机会 panic。
和 axum 的 Redirect 对照
axum 也有一个 Redirect,名字一样,东西不一样:
tower-http services::Redirect | axum response::Redirect | |
|---|---|---|
| 是什么 | Service,任意请求进、3xx 出 | IntoResponse,handler 的返回值 |
| 目标 | 构造时固定的 Uri | &str,可以在 handler 里按请求拼 |
| 构造函数 | temporary(307)、permanent(308)、公开的 with_status_code | to(303)、temporary(307)、permanent(308);with_status_code 是私有的 |
坏的 Location | 构造时 panic(实际上碰不到) | into_response 时变成 500,正文是错误信息 |
| 非 3xx | 构造时 panic | 构造时 panic(同一句 not a redirection status code) |
axum 的 with_status_code 上面有一段注释:「This is intentionally not public since other kinds of redirects might not use the Location header, namely 304 Not Modified.」tower-http 把它公开了,于是能造出上面那个带 Location 的 304。
在 axum 里,按请求决定去向的跳转用 axum 的 Redirect 写在 handler 里;整段路径都跳到一个固定地址(旧域名、维护页),用 Router::fallback_service 或 route_service 挂一个 tower-http 的 Redirect,不用写 handler。
问: Redirect 为什么要一个 ResBody 类型参数,直接用 Full<Bytes> 不行吗?
答: 叶子服务要和它所在的路由、栈对上响应类型。axum 的 Router::fallback_service 要求响应 body 能转成 axum 的 Body,hyper 服务要的是另一种,tonic 又是另一种。Redirect 只要求 ResBody: Default,用空 body 就行,于是由使用者决定它是 axum::body::Body 还是别的。代价是构造时类型推断不出来,常常要写 Redirect::<Body>::...。
小结
Redirect是叶子服务:Error = Infallible,poll_ready永远就绪,请求类型任意(()也行),不读请求。temporary回 307、permanent回 308,with_status_code自选;非 3xx 在构造时 panic,304、300 能通过。Location构造时定死,不拼请求的路径和查询串;响应是ResBody::default()的空 body。Location转换的expect对合法Uri实际上碰不到:Uri先拒绝了控制字符。- axum 的
Redirect是响应,目标可以按请求拼,默认to是 303,坏Location变 500;固定目标的整段跳转用 tower-http 的服务,按请求决定的用 axum 的响应。