Rust in Everything
基于 tower-http 0.7.1

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。

Service 实现:

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::Redirectaxum response::Redirect
是什么Service,任意请求进、3xx 出IntoResponse,handler 的返回值
目标构造时固定的 Uri&str,可以在 handler 里按请求拼
构造函数temporary(307)、permanent(308)、公开的 with_status_codeto(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。

📝NOTE
没有蠢问题

问: 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 的响应。

💬 关联讨论

发起讨论
加载中...