用 hyper 和 matchit 写一个迷你 topcoat
主线位置:请求主线和发现主线的骨架。前面各章讲的是 topcoat 怎么做;这一章把其中四个核心想法各用几十行重写一遍,看清它们各自依赖什么。完整的实现、
$(...)到 JavaScript 的翻译器、WebSocket 重渲染和作业放在课程里。
flowchart TB submit["page! 宏:inventory::submit + module_path"] --> start["启动:遍历 inventory"] start --> url["url_for:去掉根模块,按 :: 切段,_ 开头跳过,下划线变连字符"] url --> table["matchit::Router"] req["请求"] --> table table --> cx["新建 Cx:按类型存值"] cx --> view["页面函数返回 Box<dyn View>"] view --> first["poll_first:首屏 + fallback"] first --> resp["Response 交给 hyper"] resp --> body["ViewBody::poll_frame:poll_swap 出一段,追加 template + swap 脚本"]
依赖(全部是本卷实验里已经解析出来的版本):
tokio = { version = "=1.54.1", features = ["full"] }
hyper = { version = "=1.12.0", features = ["server", "http1"] }
hyper-util = { version = "=0.1.21", features = ["tokio"] }
http-body = "=1.1.0"
bytes = "=1.12.1"
matchit = "=0.9.2"
inventory = "=0.3.25"
一、Cx:按类型存值的表
topcoat 的 Cx 有应用级和请求级两层、派生、依赖跟踪(Cx、AppContext 与 memoize)。最小的版本只要一张 TypeId 到 Box<dyn Any> 的表:
#[derive(Default)]
pub struct Cx {
values: HashMap<TypeId, Box<dyn Any + Send + Sync>>,
}
impl Cx {
pub fn with<T: Any + Send + Sync>(mut self, value: T) -> Self {
self.values.insert(TypeId::of::<T>(), Box::new(value));
self
}
pub fn get<T: Any>(&self) -> Option<&T> {
self.values.get(&TypeId::of::<T>())?.downcast_ref()
}
}
想一想 topcoat 为什么要在这上面再加一层「子 Cx 指向父 Cx」:with 在这里会移动整张表,而 topcoat 的 layer 要在不改动外层的前提下给内层加值。
二、View:首屏和后续
topcoat 的 View trait 只有两个方法(组件、并发渲染与流式)。迷你版保留这个形状,把输出简化成 String:
pub trait View: Send {
fn poll_first(&mut self, cx: &mut Context<'_>, out: &mut String) -> Poll<()>;
fn poll_swap(&mut self, cx: &mut Context<'_>) -> Poll<Option<(u32, String)>>;
}
suspense 在首屏写一个带 id 的占位,poll_swap 去 poll 内部的 future,好了就交出 (id, html):
impl View for Suspense {
fn poll_first(&mut self, _: &mut Context<'_>, out: &mut String) -> Poll<()> {
out.push_str(&format!("<div id=\"r{}\">{}</div>", self.id, self.fallback));
Poll::Ready(())
}
fn poll_swap(&mut self, cx: &mut Context<'_>) -> Poll<Option<(u32, String)>> {
let Some(body) = self.body.as_mut() else { return Poll::Ready(None) };
let html = std::task::ready!(body.as_mut().poll(cx));
self.body = None;
Poll::Ready(Some((self.id, html)))
}
}
再加一个 Seq(Vec<Box<dyn View>>) 把兄弟节点串起来:poll_first 依次写,poll_swap 谁先好就交出谁。这已经是「同一个任务里并发 poll 兄弟组件」的雏形,topcoat 的 JoinView 多做的是防饿死和按顺序写入。
三、发现:inventory 加 module_path!
topcoat 用属性宏 #[page] 登记;迷你版用一个 macro_rules!,关键只有一行 module_path!(),它在宏调用处展开,所以记下的是页面所在的模块(模块路由与发现):
pub struct PageEntry {
pub module: &'static str,
pub render: fn(&Cx) -> Box<dyn View>,
}
inventory::collect!(PageEntry);
macro_rules! page {
($render:path) => {
inventory::submit!(crate::PageEntry { module: module_path!(), render: |cx| Box::new($render(cx)) });
};
}
fn url_for(root: &str, module: &str) -> String {
let rest = module.strip_prefix(root).unwrap_or(module).trim_start_matches("::");
let segs: Vec<String> = rest
.split("::")
.filter(|s| !s.is_empty() && !s.starts_with('_'))
.map(|s| s.replace('_', "-"))
.collect();
format!("/{}", segs.join("/"))
}
unwrap_or(module) 是有意的宽松:根之外的页面不会 panic,而是按完整模块路径注册。topcoat 0.10.0 在这里是 expect(常见坑),两种选择各有道理:宽松不会让启动失败,但会注册出意外的 URL。
启动时遍历一次,插进 matchit:
let root = concat!(module_path!(), "::app");
let mut routes = matchit::Router::new();
for entry in inventory::iter::<PageEntry> {
let url = url_for(root, entry.module);
println!("route {url} <- {}", entry.module);
if let Err(error) = routes.insert(url, entry.render) {
eprintln!("route conflict: {error}");
}
}
四、流式响应体
topcoat 在 handler 里只等首屏,后续的 <template> 由 hyper 拉响应体时产生(一个请求从监听到 HTML)。迷你版的响应体实现 http_body::Body,第一帧是首屏加一段 swap 函数,之后每一帧是一个 poll_swap 的结果:
impl http_body::Body for ViewBody {
type Data = Bytes;
type Error = Infallible;
fn poll_frame(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Result<Frame<Bytes>, Infallible>>> {
if let Some(first) = self.first.take() {
return Poll::Ready(Some(Ok(Frame::data(Bytes::from(first + SWAP)))));
}
match std::task::ready!(self.view.poll_swap(cx)) {
Some((id, html)) => {
let chunk = format!("<template data-swap=\"{id}\">{html}</template><script>swap({id})</script>");
Poll::Ready(Some(Ok(Frame::data(Bytes::from(chunk)))))
}
None => Poll::Ready(None),
}
}
}
SWAP 是一行 JS:找到对应的 <template>,用它的内容替换 #r{id}。handler 只做三件事:matchit 查表、建 Cx、poll_fn 等到首屏就绪,然后把 ViewBody 交出去:
let cx = Cx::default().with(req.uri().path().to_owned());
let mut view = (matched.value)(&cx);
let mut first = String::new();
poll_fn(|tcx| view.poll_first(tcx, &mut first)).await;
let body = ViewBody { first: Some(first), view };
服务器循环就是 hyper 卷里的样子:accept、每个连接 tokio::spawn 一个 http1::Builder::serve_connection(auto:一个端口两个协议 讲了怎么换成同时支持 HTTP/2 的 auto::Builder)。
跑起来
两个页面:app 模块里的 home(一个 300 毫秒的 suspense),app::user_settings 里的 settings:
route / <- mini::app
route /user-settings <- mini::app::user_settings
用打印每次 read 时间的小客户端请求 /:
[ 1ms] HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
transfer-encoding: chunked
D2
<h1>home at /</h1><div id="r1">loading...</div><script>function swap(...)</script>
[ 303ms] 4B
<template data-swap="1"><p>slow part</p></template><script>swap(1)</script>
0
[ 303ms] <EOF>
无头 Chrome --dump-dom 打开同一个页面,body 里是 <h1>home at /</h1><p>slow part</p>:占位被替换了。/user-settings 由模块名 user_settings 推出,/nope 是 404。
和 topcoat 还差什么
这两百行刻意省掉的,正好是前面各章的主体:
| 迷你版 | topcoat |
|---|---|
Cx 一张表,with 移动整张表 | 两层上下文、子 Cx 派生、#[memoize] 与依赖跟踪(03) |
输出 String,没有转义 | view! 编译成指令,按位置转义,hoist 去重(04) |
catch_unwind 没有 | 每次 poll 都包一层,panic 变 500(02) |
| 没有 layer、没有 Origin 检查 | layer 链、OriginLayer 永远在最外层(02、07) |
| 资产、cookie、会话都没有 | asset! 标记、cookie jar 在首屏时 seal(06、07) |
浏览器端只有一个 swap | $(...) 双语言、signal、shard、morph(05) |
最后一行是课程的内容:怎么让一个过程宏同时生成 Rust 和 JavaScript,怎么让 shard 用自己的 identity 重渲染,怎么设计 NDJSON 帧和 WebSocket 上的 run。
练习
- 给
Seq加上「同一时刻只交出一个 swap,其余排队」以外的策略:比如按完成顺序一次交出所有已就绪的 swap,量一量首字节和总时长的变化。 - 在
handle外面包一层catch_unwind,让页面函数里的 panic 变成 500;再想想为什么 topcoat 要把它包在每一次 poll 上,而不是只包一次。 - 把
url_for改成和 topcoat 0.10.0 一样「根之外就 panic」,再写一个依赖库里带page!的例子,看看两种选择分别在什么时候暴露问题。
小结
Cx是TypeId到值的表;View只要poll_first和poll_swap;发现靠inventory::submit!加调用处的module_path!();流式靠一个自己实现poll_frame的响应体。- handler 只等首屏,后续内容由 hyper 拉响应体时产生;这就是 topcoat 里「流式开始后不能再写 cookie」的根源。
- 迷你版省掉的上下文派生、转义、
catch_unwind、layer、安全默认值和整个浏览器运行时,就是 topcoat 的主体;$(...)翻译器和重渲染放进课程。