Rust in Everything
基于 topcoat-router 0.10.0

请求、响应与错误

主线位置:请求主线的两端。handler 从 Cx 读请求、从 Body 提取请求体;返回的 Result 穿过 layer 链回到 handle_inner,在那里的 respond 里变成真正的 HTTP 响应。

flowchart TB
  h["handler 返回 Result"] --> ok{"Ok 还是 Err"}
  ok -->|"Ok(T)"| ir["T::into_response(cx)"]
  ok -->|"Err(Error)"| layers["沿 layer 链冒泡:layer 能看到、能改"]
  layers --> rw{"是 RewriteError 吗"}
  rw -->|是| again["换路径,新 Cx,重新 dispatch"]
  rw -->|否| resp["respond:按顺序 downcast"]
  resp --> known["已知的路由错误:403、400、413、404、405、3xx、401、503、429"]
  resp --> unknown["其他一切:500,原因丢掉"]
  ir --> fail{"转换失败?"}
  fail -->|是| resp
  fail -->|否| out["Response"]
  known --> out
  unknown --> out

读请求:都在 Cx 上

topcoat 的 handler 没有 axum 那样的「参数即提取器」。请求的元数据从 Cx 上用函数取,request.rs 里一组:parts、method、uri、version、headers、content_type、extensions、remote_addr、client_ip。它们都是 fn(cx: &Cx) -> &T,不消耗任何东西,可以在任意组件、任意深度里调用,这也是「函数,而不是中间件」那条建议(03 章)的基础。

rewrite 之后这些函数返回的是改写后的请求;original_parts、original_uri、original_method 等返回到达时的那一份(从连接到分发里,OriginalParts 在 rewrite 循环外只算一次)。

请求体只能读一次,所以一个 handler 最多一个请求体参数,类型实现 FromRequest:

pub trait FromRequest: Sized {
    fn from_request(cx: &Cx, body: Body) -> impl Future<Output = Result<Self>> + Send;
}

参数可以用模式,比如 Json(input): Json<CreateUser>;#[page] 的宏会把它改写成先取 body 再在函数开头 let 解构。内置的实现:

类型做什么失败时
Body原样交出,自己读流不检查大小限制
Bytes、BytesMut读完整个请求体超过 body_limit 是 413,读失败是 400
String读成 Bytes 再按 UTF-8 解码不是 UTF-8 是 400
Json<T>先查 Content-Type: application/json,再用 serde_path_to_error 反序列化400,消息里带出错的字段路径
Form<T>、RawFormapplication/x-www-form-urlencoded400
Html<String>、Css<String>读成字符串400
Multipartmultipart feature,底下是 multer400
WebSocketUpgradewebsocket feature,检查握手头方法不是 GET 是 405;不是 HTTP/1.1 是 400
Option<T>走 OptionalFromRequest:比如 Option<Json<T>> 在没有 Content-Type 时是 None同 T

Json、Form、Html 这些类型同时也实现了 IntoResponse:同一个包装类型既能提取也能响应。

Json<T> 的三种 400(json.rs),实测:

### curl -X POST -d {"name":"a","age":3} http://127.0.0.1:38111/users
HTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8
vary: accept-encoding
content-length: 67

bad request: expected request with `Content-Type: application/json`

### curl -X POST -H Content-Type: application/json -d {"name":"a","age":300} http://127.0.0.1:38111/users
HTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8
vary: accept-encoding
content-length: 105

bad request: invalid JSON value: invalid value: integer `300`, expected u8 at line 1 column 21 (at `age`)

### curl -X POST -H Content-Type: application/json -d {"name":"a" http://127.0.0.1:38111/users
HTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8
vary: accept-encoding
content-length: 90

bad request: invalid JSON syntax: EOF while parsing an object at line 1 column 11 (at `?`)

400 的描述原样发给客户端,错误模块的文档要求「use a client-safe message」;serde 的报错会把结构体的字段名带出去。

写响应:IntoResponse 带着 Cx,可以失败

IntoResponse:

pub trait IntoResponse {
    fn into_response(self, cx: &Cx) -> Result<Response>;
}

和 axum 的 IntoResponse 比有两处不同:多了 cx,转换时能读请求上下文(比如视图要知道该发 HTML 还是 NDJSON);返回 Result,转换本身可以失败(比如一个非法的头值),失败的错误和 handler 的错误走同一条路。

还有一个异步版本 AsyncIntoResponse,所有 IntoResponse 都自动实现它。它是给视图用的:BoxView 要 poll 到首屏才能组出响应,见一个请求从监听到 HTML。#[route] 和 #[layer] 的返回值要求的就是 AsyncIntoResponse。

内置的实现覆盖常见类型:()、StatusCode、字符串、字节、Body、HeaderMap、http::Response<B>,以及元组 (StatusCode, 头..., 体)、([(K, V); N], 体)。content/ 下是带内容类型的包装:

类型feature说明
Html、Css、Js、Json、Form、Wasm—设好 Content-Type
Sitemapsitemap生成 sitemap XML
Sse<S>sse事件流;KeepAlive 默认每 15 秒空闲发一个空注释
WebSocketUpgrade::on_upgradewebsocket返回 101,升级后的连接交给回调;只支持 HTTP/1.1 的 Upgrade
视图—BoxView 走 AsyncIntoResponse,HTML 或 NDJSON 帧,可能流式

错误即控制流

handler 的错误类型是 topcoat::Error(topcoat-core 里一个装箱的、可 downcast 的错误,03 章讲)。router 在 error/ 下提供一组构造函数,每个返回一个具体的错误类型,? 或 .into() 就变成 Error:

构造函数响应
not_found()404,体 not found
bad_request(desc)400,体 bad request: {desc}
unauthorized()、forbidden()401、403
content_too_large()413
method_not_allowed(methods)405,带 Allow 头
redirect(uri)、redirect_permanent(uri)307、308,带 Location
see_other(uri)303,带 Location;它也能当 Ok 值返回
too_many_requests()、service_unavailable()429、503
rewrite(path, body)不是响应,在 rewrite 循环里被拦下
internal_server_error(e)500

Option 和 Result 上有 RouterErrorExt 的 ok_or_not_found、ok_or_redirect、ok_or_unauthorized、ok_or_forbidden、ok_or_bad_request,把 None / Err 换成对应的路由错误:

#[route(GET "./users/find")]
async fn find() -> Result<&'static str> {
  let found: Option<&'static str> = None;
  Ok(found.ok_or_not_found()?)
}

#[route(POST "./login")]
async fn login() -> Result<SeeOther> {
  Ok(see_other("/dashboard"))
}
### curl http://127.0.0.1:38111/users/find
HTTP/1.1 404 Not Found
content-type: text/plain; charset=utf-8
content-length: 9

not found

### curl -X POST http://127.0.0.1:38111/login
HTTP/1.1 303 See Other
location: /dashboard
content-length: 0

「错误」在这里不只表示失败:重定向、rewrite、「这个用户没登录」都是用 Err 从调用栈深处直接跳出来的。好处是任何一层函数、任何一个嵌套组件里都能 ? 一个重定向,不用把返回类型一层层改成「响应或数据」。

respond:按顺序 downcast

错误一路冒泡到 handle_inner,由 respond 变成响应,核心是 error_into_response:

fn error_into_response(cx: &Cx, error: Error) -> Response {
    macro_rules! try_downcast {
        ($ident:ident as $ty:ty) => {
            match $ident.downcast_cloned::<$ty>() {
                Ok(error) => return into_response_or_500(cx, error),
                Err(error) => error,
            }
        };
    }
    let error = try_downcast!(error as ForbiddenError);
    let error = try_downcast!(error as BadRequestError);
    // ...
    let error = try_downcast!(error as TooManyRequestsError);

    into_response_or_500(cx, internal_server_error(error))
}

十一种已知的路由错误依次试一遍,认出一个就用它自己的 IntoResponse;都不是就包成 InternalServerError。而 InternalServerError 只有一个下划线开头的字段:

pub struct InternalServerError {
    _inner: Error,
}

Display 固定输出「internal server error」,IntoResponse 固定是 (500, "internal server error")。原来的错误被留在结构体里,但没有任何代码读它,也没有任何日志:从连接到分发里说过,router 和 core 里没有一行 tracing。数据库连不上、rewrite 成环、TowerLayer 里的超时,客户端和服务端日志看到的都只是一个 500。

要看到原因,只能在错误变成响应之前拦下来。最合适的位置是一个没有路径的 layer:它包住所有请求,next.run 返回的 Err 还是原来的错误。前两页实验里的 Pathless 就是这么打印出 Err(not found)、Err(rewrite to /loop-b) 的。

只在错误响应上加头:ResponseHeaders

layer 想给响应加一个头(比如请求 id),直接改 next.run 返回的 Response 不够:返回 Err 时还没有响应。response_headers(cx) 是每个请求一个的头收集器,handle_inner 在 respond 之后把它里面的头追加到最终响应上,无论那是 handler 的响应还是 404、500。文档里的例子就是一个没有路径的 layer 往里放 x-request-id,「Lands on the 404 for an unmatched URL as well as on a handler's own response」。

流式开始之后的重定向

视图响应的状态行在首屏发出时就定了。如果 suspense 里的组件在首屏之后才返回一个 redirect,已经没法改成 307。ViewBody::poll_frame 的做法是把它降级成一段客户端跳转:

Poll::Ready(Err(error)) => {
    *this.done = true;
    // The response committed with the first content, so a
    // redirect can no longer change the status line; it degrades
    // to a client-side navigation instead.
    match redirect_location(error) {
        Ok(location) => {
            let chunk = this.delivery.redirect(&location);
            Poll::Ready(Some(Ok(Frame::data(chunk.into()))))
        }
        Err(error) => Poll::Ready(Some(Err(error.into()))),
    }
}

实验:一个页面在首屏之前就 redirect("/login"),另一个页面的首屏正常,suspense 里的组件 200 毫秒后 redirect("/login?next=\"x\""):

#[page("./before")]
async fn before() -> Result<impl View> {
  Err::<(), _>(redirect("/login"))?;
  Ok(view! { <p>"never"</p> })
}

#[component]
async fn guard() -> Result<impl View> {
  tokio::time::sleep(Duration::from_millis(200)).await;
  Err::<(), _>(redirect("/login?next=\"x\""))?;
  Ok(view! { <p>"secret"</p> })
}
[    5ms] read 128 bytes:
HTTP/1.1 307 Temporary Redirect
location: /login
connection: close
content-length: 0

[    0ms] read 389 bytes:
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
...
<!DOCTYPE html><html><body><p>first screen</p><!--::topcoat::region::start(d139fe525a3cb181f54fb3f0ffe4eda4)--><p>checking</p><!--::topcoat::region::end(d139fe525a3cb181f54fb3f0ffe4eda4)--></body></html>

[  203ms] read 72 bytes:
3D
<script>window.location.replace("/login?next=\"x\"")</script>
0
  • 首屏之前:普通的 307。
  • 首屏之后:状态已经是 200,追加一段 window.location.replace(...) 就结束响应。用 replace 而不是给 location.href 赋值,注释说是为了不把这个半截页面留在历史记录里。地址里的 "、\ 被转义,< 被写成 \x3C,防止提前闭合 <script>。
  • NDJSON 格式下同一个情况是一帧 {"t":"redirect","location":"/login?next=\"x\""}。

这对鉴权有直接影响:如果「检查登录」写在 suspense 里的某个组件中,未登录用户先拿到的是一个 200 和首屏内容,然后才被脚本带走;首屏里如果已经有不该给他看的东西,它已经发出去了,关掉 JavaScript 的客户端也不会跳转。鉴权要放在首屏之前:page 本身、layout,或者 layer。

不是重定向的错误在流式阶段发生(比如 suspense 里的组件 200 毫秒后返回 not_found()),poll_frame 返回 Err,hyper 中止这个响应。实测 HTTP/1 下客户端先收到 200 和首屏,207 毫秒时连接被关闭,没有结尾的 0 长度块;curl 以退出码 18(传输不完整)结束。状态码是 200,404 的意思没有传到客户端。想在流式阶段把错误变成页面上的内容,要在视图里用 error_boundary 接住(04 章)。

[    5ms] read 389 bytes:
HTTP/1.1 200 OK
...
transfer-encoding: chunked

CB
<!DOCTYPE html><html><body><p>first screen</p><!--::topcoat::region::start(563fb7caab8260bea62e209677945068)--><p>checking</p><!--::topcoat::region::end(563fb7caab8260bea62e209677945068)--></body></html>

[  207ms] <EOF>

小结

  • 请求的元数据用 parts(cx)、headers(cx) 等函数从 Cx 上取,rewrite 后另有 original_*;请求体用 FromRequest 提取,一个 handler 最多一个,可以写模式解构。
  • Json<T> 先查 Content-Type,再反序列化,三种失败都是 400,描述(含字段路径)原样发给客户端。
  • IntoResponse 带 cx、返回 Result;视图走 AsyncIntoResponse;content/ 下的包装类型既提取又响应。
  • not_found、redirect、see_other、rewrite 等都是 Error,靠 ? 从任意深度跳出;respond 依次 downcast 十一种已知错误,其余一律 500,原因留在 _inner 里没人读、也不打日志。要看原因,在没有路径的 layer 里看 next.run 的 Err。
  • response_headers(cx) 上的头会加到最终响应上,包括错误响应。
  • 首屏之后的重定向变成 window.location.replace 脚本(NDJSON 下是一帧 redirect),其他错误则让响应中途断开、状态仍是 200;鉴权和「找不到」的判断都要放在首屏之前。

💬 关联讨论

发起讨论
加载中...