Rust in Everything
基于 topcoat 0.10.0

一个请求从监听到 HTML

主线位置:请求主线全程。本页把一个页面请求从 topcoat::start 走到响应体的最后一个字节,每一步只点到为止,细节在 02 router 的各页。

sequenceDiagram
  participant C as 客户端
  participant S as internal_serve
  participant R as Router
  participant L as layer:log
  participant P as layout 与 page
  participant B as ViewBody
  C->>S: TCP 连接
  S->>S: spawn 一个任务,auto::Builder
  C->>R: GET /(RouterService::call)
  R->>R: catch_unwind,新 Cx,matchit
  R->>L: OriginLayer 之后进 layer 链
  L->>P: next.run
  P-->>L: 首屏就绪,Response 带着 ViewBody
  L-->>R: 200 OK(0 毫秒)
  R-->>C: 头部 + 首个分块:占位
  B-->>C: 300 毫秒:swap 脚本 + template a
  B-->>C: 600 毫秒:template b,结束

程序

一个页面,外面套一个 #[layout](出 <html> 外壳),再外面一个 #[layer](打印 next.run 前后的时间)。页面里有一个 signal、一个 $(...) 绑定,和两个 suspense,分别等 300 毫秒和 600 毫秒。都写在 main.rs 一个模块里,module_router!() 把它们都放在 /。

topcoat = { version = "=0.10.0", features = ["full"] }
tokio = { version = "=1.54.1", features = ["full"] }
use std::time::{Duration, Instant};
use topcoat::{
  Result,
  context::Cx,
  router::{Body, Next, Slot, layer, layout, module_router, page, response::Response},
  runtime::signal,
  view::{View, component, suspense, view},
};

static START: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();

fn t() -> u128 {
  START.get_or_init(Instant::now).elapsed().as_millis()
}

#[tokio::main]
async fn main() {
  let router = module_router!().build();
  topcoat::start(router).await.unwrap();
}

#[layer]
async fn log(cx: &Cx, body: Body, next: Next<'_>) -> Result<Response> {
  let path = topcoat::router::request::uri(cx).path().to_owned();
  println!("[{:>5}ms] layer: before next.run {path}", t());
  let response = next.run(cx, body).await;
  match &response {
    Ok(r) => println!("[{:>5}ms] layer: next.run returned {}", t(), r.status()),
    Err(e) => println!("[{:>5}ms] layer: next.run returned Err({e})", t()),
  }
  response
}

#[layout]
async fn shell(slot: Slot<'_>) -> Result<impl View> {
  println!("[{:>5}ms] layout: render", t());
  Ok(view! {
    <!DOCTYPE html>
    <html><body>(slot)</body></html>
  })
}

#[page]
async fn home(cx: &Cx) -> Result<impl View> {
  println!("[{:>5}ms] page: render", t());
  let open = signal(cx, || false);
  Ok(view! {
    <h1>"Home"</h1>
    <p :hidden=$(!open.get())>"hi"</p>
    <script>"document.body.dataset.mode = document.compatMode"</script>
    suspense(fallback: view! { <p>"loading a"</p> }, slow(name: "a", ms: 300))
    suspense(fallback: view! { <p>"loading b"</p> }, slow(name: "b", ms: 600))
  })
}

#[component]
async fn slow(name: &str, ms: u64) -> Result<impl View> {
  tokio::time::sleep(Duration::from_millis(ms)).await;
  println!("[{:>5}ms] slow({name}) done", t());
  Ok(view! { <p>"slow " (name) " done"</p> })
}

curl -N 也能看到流式效果,但看不出每一段是什么时候到的。所以另写了一个小客户端:用 tokio 的 TcpStream 发一个 Connection: close 的 HTTP/1.1 请求,每次 read 返回就打印相对时间和读到的原始字节,分块编码的长度行也原样留着。

输出

服务器以 PORT=38101 启动,客户端 GET /。连跑 3 次,三段到达的时间是 0–1、301–303、601–602 毫秒。下面是其中一次(去掉了 date 头):

[    1ms] read 901 bytes:
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
vary: Accept
vary: accept-encoding
connection: close
transfer-encoding: chunked

2CA
<!DOCTYPE html><html><body><!--::topcoat::signal({&quot;t&quot;:&quot;signal&quot;,&quot;id&quot;:&quot;997b9b79b6af27b60c5f3ec25000f50c&quot;,&quot;v&quot;:false})--><h1>Home</h1><p hidden="" data-topcoat-bind:hidden="(cx.hydrate({&quot;t&quot;:&quot;Signal&quot;,&quot;id&quot;:&quot;997b9b79b6af27b60c5f3ec25000f50c&quot;})).get().not()">hi</p><script>document.body.dataset.mode = document.compatMode</script><!--::topcoat::region::start(d9672569e9cf26d6f04005ded30361f9)--><p>loading a</p><!--::topcoat::region::end(d9672569e9cf26d6f04005ded30361f9)--><!--::topcoat::region::start(36f78e87cfe5f0de3b4af55dca7f9fba)--><p>loading b</p><!--::topcoat::region::end(36f78e87cfe5f0de3b4af55dca7f9fba)--></body></html>

[  302ms] read 1035 bytes:
404
<script>
window.topcoat ??= {
    swap(id) {
        // ...(共 20 行,下文摘出关键几行)
    },
};
document.currentScript.remove();
</script><template data-topcoat-swap="d9672569e9cf26d6f04005ded30361f9"><p>slow a done</p></template><script>topcoat.swap("d9672569e9cf26d6f04005ded30361f9")</script>

[  601ms] read 168 bytes:
9D
<template data-topcoat-swap="36f78e87cfe5f0de3b4af55dca7f9fba"><p>slow b done</p></template><script>topcoat.swap("36f78e87cfe5f0de3b4af55dca7f9fba")</script>
0

[  601ms] <EOF>

服务器这边的打印:

[    0ms] layer: before next.run /
[    0ms] layout: render
[    0ms] page: render
[    0ms] layer: next.run returned 200 OK
[  301ms] slow(a) done
[  600ms] slow(b) done

逐段标注:

时间分块内容
0–1 毫秒响应头 + 0x2CA 字节一份完整的 HTML 文档,</html> 都写完了。signal 的初值放在注释 ::topcoat::signal(...) 里;$(...) 在服务端求值得到 hidden="",译出的 JS 放在 data-topcoat-bind:hidden 里;每个 suspense 先放 fallback,前后各一个 ::topcoat::region::start(id) / end(id) 注释
300 毫秒0x404 字节一段 swap 脚本(只在第一次追加时发)、一个 <template data-topcoat-swap="id"> 装着 a 的内容、一行 topcoat.swap("id")
600 毫秒0x9D 字节 + 结束块b 的 <template> 和调用,没有再发 swap 脚本;随后 0 长度块,响应结束

追加的内容都在 </html> 之后。浏览器的 HTML 解析器对 </html> 之后的元素是宽容的,会把它们挂回 <body> 末尾,所以内联脚本照常执行。用无头 Chrome 打开这个页面再 --dump-dom,得到的 DOM 里两个 region 都已经换成了「slow a done」「slow b done」,<template> 和脚本都被删掉了,<body data-mode="CSS1Compat"> 说明文档仍是标准模式。

region 的 id 是 32 位十六进制,3 次运行里完全相同:它由组件在树里的位置算出来,不是随机数(topcoat-core 的 Identity,03、04 章讲)。

一步一步

1. 绑端口,进入 accept 循环

start 读 HOST、PORT 两个环境变量(默认 127.0.0.1、3000),绑一个 TcpListener,交给 serve;serve 再带上 Ctrl+C / SIGTERM 作为关停信号调用 serve_until。serve_until 先 dev::notify_ready(只在 topcoat dev 设了 TOPCOAT_DEV_URL 时才连回开发服务器报「就绪」),再进入 router crate 的 internal_serve。

internal_serve 是一个 select! 循环:要么 accept 到一个连接,要么关停信号到了。每个连接 tokio::spawn 一个任务,在任务里建一个 hyper-util 的 auto::Builder,调 serve_connection_with_upgrades。HTTP/1 和 HTTP/2 由 auto 看前几个字节决定(hyper 卷的 auto)。

2. 交给 Router,先接住 panic

hyper 对每个请求调 RouterService::call。它实现的是 hyper 的 Service(call(&self),没有 poll_ready),做两件事:把连接的对端地址作为 RemoteAddr 插进请求的 extensions,把 hyper 的 Incoming 包成 topcoat 的 Body,然后 router.handle(...)。

Router::handle_with 把整个请求的 future 放进 poll_fn,每一次 poll 都包一层 catch_unwind;任何一次 poll 里 panic,这个请求就直接变成 500。为什么要每次 poll 都包,见从连接到分发。

3. 组上下文,分发

handle_inner 先按可信代理配置算出客户端 IP,再为这个请求新建一个 Cx:全局的 AppContext 加上这次请求的值(路由表的句柄、请求 Parts、路径参数、响应头……)。然后进入一个 rewrite 循环,每一圈调一次 dispatch:

let (cx, next) = match inner.endpoints.at(parts.uri.path()) {
    Some((endpoint_index, endpoint, params)) => {
        // ...
        let route_index = endpoint.get(&parts.method).or_else(|| endpoint.any());
        let next = match route_index {
            Some(index) => Next::new(&[], Terminal::Route(&inner.routes[index])),
            None => Next::new(&inner.always_layers, Terminal::MethodNotAllowed(endpoint)),
        };
        // ...
    }
    None => (cx, Next::new(&inner.always_layers, Terminal::NotFound)),
};

// The origin check precedes all route and pathless layers.
let result = inner.origin.handle(&cx, body, next).await;

matchit 按路径找端点,端点按方法找路由;最外面永远是 OriginLayer(跨站的写请求在这里就 403 了)。匹配上的路由自己带着它的 layer 栈,所以这里传的 layer 切片是空的。

4. layer 链,然后是页面

这个请求匹配到的路由是 PageWithLayouts(page 加上路径是它前缀的所有 layout),外面包着 log 这个 layer。Next::run 先跑 log,log 调 next.run,轮到 PageWithLayouts::handle:

fn handle<'cx>(&'cx self, cx: &'cx Cx, body: Body) -> RouteFuture<'cx> {
    // The response body outlives the handler, so the view owns the pair
    // and a copy of the request context, and drives itself in place as
    // the outermost view of the build.
    let inner = Arc::clone(&self.inner);
    let owned = cx.clone();
    Box::pin(async move {
        let view = MoveView::new(async move {
            let view = inner.render(&owned, body);
            MoveView::drive(ScopeView::new(view)).await
        });
        view.async_into_response(cx).await
    })
}

注释里那句「The response body outlives the handler」是理解后面一切的关键:响应体(还没渲染完的视图)要活得比 handler 长,所以它拿走一份 Cx 的克隆,自己驱动自己。inner.render 先调 page 的 render,再从内到外一层层套 layout,所以服务器日志里 layout 的打印在 page 前面:最外层的 layout 先被 poll,poll 到 (slot) 时才轮到 page。

5. 首屏就绪就返回响应

stream 是视图变成响应的地方:

async fn stream<V: View + Unpin + 'static>(mut view: V, cx: &Cx) -> Result<Response> {
    let delivery = view_response_delivery(cx);
    let mut pinned_view = Pin::new(&mut view);
    let first = poll_fn(|cx| pinned_view.as_mut().poll_first(cx)).await?;
    let rendered = first.content.render_response(cx);
    let first_content = delivery.first(rendered.html);
    if first.live {
        let body = ViewBody {
            // ...
            first: Some(first_content),
            script: (delivery == ViewResponseDelivery::Html).then_some(SWAP_SCRIPT),
            view,
            // ...
        };
        // ...

poll_first 一直 poll 到「首屏能发了」:两个 suspense 的子组件都还在睡,于是用 fallback 填上。first.live 为真表示后面还有更新,响应体就是一个 ViewBody:它持有首屏字符串和视图本身。stream 到这里就返回了 Ok(Response)。

这就是服务器日志第四行的含义:log 这个 layer 在 0 毫秒就拿到了 200 OK,而 300、600 毫秒的两段内容是 hyper 后来 poll 响应体时才产生的,不再经过任何 layer。在 layer 里量「请求耗时」,量到的只是首屏时间;在 layer 里改响应体,也只改得到 ViewBody 这个外壳。

6. hyper 拉响应体,追加 template

hyper 写完响应头,开始 poll ViewBody::poll_frame:第一次直接交出首屏;之后每次都去 poll_swap,某个 region 有了新内容就格式化成一个分块,没有就 Pending。HTML 格式下一块长这样(swap):

if let Some(script) = script {
    f.write_str(script);
}
write!(f, "<template data-topcoat-swap=\"{region}\">").unwrap();
swap.replacement.render_into(cx, &mut f);
write!(f, "</template><script>topcoat.swap(\"{region}\")</script>").unwrap();

script 是 Option,第一次 take() 之后就是 None,所以 swap 脚本只发一次。脚本本身用 TreeWalker 找到 id 对应的一对注释,删掉中间的节点,把 <template> 的内容插进去,再把 <template> 和自己都删掉。window.topcoat ??= {...} 的写法,按源码注释是「steps aside for an applier the page installed itself」:页面自己先装了一个 topcoat.swap 的话,就不覆盖它。

7. 写回响应头,压缩

dispatch 返回后,handle_inner 用 respond 把 Result 变成响应(错误也在这里变成 404、500 之类),把 handler 期间登记的响应头写上,最后在所有 layer 之外做压缩:router.rs 第 162 行。这次请求没带 Accept-Encoding,所以只留下了一个 vary: accept-encoding。

同一个页面,另一种格式

请求头里带 Accept: application/x-ndjson,同一个页面换成一行一个 JSON 帧。浏览器运行时自己发请求(客户端导航、预取、重跑页面)时就带这个 Accept:

[    0ms] read 928 bytes:
{"t":"snapshot","html":"<!DOCTYPE html><html><body><!--::topcoat::signal(...)--><h1>Home</h1>...</body></html>"}
[  303ms] read 91 bytes:
{"t":"swap","region":"d9672569e9cf26d6f04005ded30361f9","html":"<p>slow a done</p>"}
[  603ms] read 96 bytes:
{"t":"swap","region":"36f78e87cfe5f0de3b4af55dca7f9fba","html":"<p>slow b done</p>"}
[  603ms] <EOF>

(第一帧的 HTML 太长,中间用 ... 省略了。)选哪种格式的逻辑在 view_response_delivery:上下文里放了 ViewResponseDelivery 就用它,否则看 Accept 头有没有 application/x-ndjson。格式由 Accept 决定时,响应会带上 vary: Accept,让缓存把两种格式分开存。

和 axum 对比

axum 卷的「一个请求穿过 axum」用 oneshot 直接调 Router,不起服务器。topcoat 也可以这么做(Router::handle 是一个普通的 async fn),但有两处不同:

  • axum 的 handler 返回时响应体通常已经完整;topcoat 的页面响应在首屏时就返回,后面的字节在 layer 之外继续产生。axum 里要做流式,是 handler 自己返回一个流式 body(如 Sse);topcoat 里这是页面的默认行为,只要用了 suspense。
  • axum 的中间件是 tower Service,请求进来时类型已经套好;topcoat 的 layer 是 dyn Layer 的切片,Next 每次往前走一格(路由表与 layer 链)。

小结

  • 一次页面请求:start → accept 循环里每连接一个任务 → RouterService::call 插 RemoteAddr → handle_with 每次 poll 都 catch_unwind → 新 Cx、matchit 分发 → OriginLayer → layer 链 → PageWithLayouts → stream。
  • 首屏是一份完整的 HTML 文档;suspense 的位置用 region 注释圈起来,完成的内容以 <template data-topcoat-swap> 加一行 topcoat.swap() 追加在 </html> 之后,swap 脚本只发一次。
  • stream 在首屏就返回 Response,后续分块由 hyper poll ViewBody 产生,不再经过 layer;layer 量到的耗时只是首屏时间。
  • Accept: application/x-ndjson 时同一个页面变成 snapshot / swap 帧;格式按 Accept 选出时响应带 vary: Accept。
  • 压缩在所有 layer 之外进行。

💬 关联讨论

发起讨论
加载中...