Rust in Everything
基于 topcoat 0.10.0

用 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。

练习

  1. 给 Seq 加上「同一时刻只交出一个 swap,其余排队」以外的策略:比如按完成顺序一次交出所有已就绪的 swap,量一量首字节和总时长的变化。
  2. 在 handle 外面包一层 catch_unwind,让页面函数里的 panic 变成 500;再想想为什么 topcoat 要把它包在每一次 poll 上,而不是只包一次。
  3. 把 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 的主体;$(...) 翻译器和重渲染放进课程。

💬 关联讨论

发起讨论
加载中...