一个请求从监听到 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({"t":"signal","id":"997b9b79b6af27b60c5f3ec25000f50c","v":false})--><h1>Home</h1><p hidden="" data-topcoat-bind:hidden="(cx.hydrate({"t":"Signal","id":"997b9b79b6af27b60c5f3ec25000f50c"})).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 pollViewBody产生,不再经过 layer;layer 量到的耗时只是首屏时间。Accept: application/x-ndjson时同一个页面变成 snapshot / swap 帧;格式按Accept选出时响应带vary: Accept。- 压缩在所有 layer 之外进行。