Rust in Everything
基于 topcoat-core 0.10.0

Cx、AppContext 与 memoize

主线位置:请求主线的中段。handle_inner 为每个请求新建一个 Cx(见从连接到分发),之后 page、layout、组件、layer 拿到的都是它或它派生出的子上下文。本页讲这张表本身,和建在它上面的 #[memoize]。

flowchart TB
  cx["Cx(Clone,两个字段)"] --> state["state:Arc 包着 CxState"]
  cx --> ident["identity:u128 哈希加歧义标记"]
  state --> shared["shared:Arc 包着 RequestShared,整个请求共用"]
  state --> rc["request_context:每次 with 复制一份"]
  state --> tr["tracker:只在 memoize 函数体里有"]
  shared --> id["id: CxId"]
  shared --> app["app_context:全局一份"]
  shared --> mc["memoize_cache"]
  shared --> ab["abort_store"]

一张按类型存值的表

Cx 在 topcoat-core 里,只有两个字段:

pub struct Cx {
    state: Arc<CxState>,
    identity: Identity,
}

struct CxState {
    shared: Arc<RequestShared>,
    request_context: Arc<RequestContext>,
    tracker: Option<Arc<ContextTracker>>,
}

struct RequestShared {
    id: CxId,
    app_context: Arc<AppContext>,
    memoize_cache: MemoizeCache,
    abort_store: AbortStore,
}

两层 Arc 分开了两种生命周期:

层内容谁共享
RequestShared请求 id、全局的 AppContext、memoize 缓存、abort_store同一个请求派生出的所有 Cx
CxState.request_context按类型存的请求级值只属于这一个作用域;with 会复制一份再插入

AppContext 是 anymap3::Map<dyn Any + Send + Sync>,在 RouterBuilder::app_context(value) 时放进去,build 之后整个应用只有一份。同一类型放两次,app_context 方法直接 panic(duplicate context entry for type ...),不会悄悄覆盖。

RequestContext 是 HashMap<TypeId, Binding>,每个 Binding 是一个 Arc<dyn Any> 加一个全局递增的 BindingId。这个 id 后面 #[memoize] 要用:它区分的不是「什么类型」,而是「哪一次插入」。

读值的四个函数:

函数取不到时
app_context::<T>(cx)panic
try_app_context::<T>(cx)None
request_context::<T>(cx)panic
try_request_context::<T>(cx)None,并记下「读过、没有」

app_context 的 panic 信息用的是 {:?} 格式化 type_name,所以类型名两边带引号。下面实验里跑出来的原文:

attempted to access app context of type `"ctx::Missing"`, but this type was not registered for this context
attempted to access request context of type `"ctx::Locale"`, but this type was not registered for this context

panic 被 Router::handle_with 的 catch_unwind 接住,客户端收到 500 internal server error。所以文档建议把 app_context::<Db>(cx) 包成 db(cx) 这样的小函数:取不到是部署配置错了,panic 是合理的,但只该写一次。

with、with_many 与派生

Cx::with 不改当前的 Cx,而是派生一个子 Cx:

pub fn with<T>(&self, value: T) -> Cx
where
    T: Any + Send + Sync,
{
    let mut request_context = (*self.state.request_context).clone();
    request_context.insert(value);
    self.scope(request_context)
}

scope 新建一个 CxState,shared 用 Arc::clone 共享,request_context 换成刚插入过的那份。所以:

  • 子作用域插入同类型的值会遮住父作用域的,父作用域看到的还是原来的值。
  • 每次 with 都把整张 HashMap 复制一遍(值本身是 Arc,只复制指针)。请求级的值通常只有十来个,这个开销可以忽略;但不适合在循环里一层层 with。
  • 子作用域和父作用域共享同一个 memoize 缓存、同一个请求 id。

with_many 接受实现了 ContextValues 的东西:一到八元组(宏生成),或者另一张 RequestContext。元组里的值按顺序插入,后插的覆盖先插的。router 组请求上下文时就靠这个顺序:先放调用方和 rewrite 传进来的值,最后放自己的路由表句柄、响应头收集器、请求 Parts 等,「Add the router's values last so they win」(router.rs 第 123 行)。

Cx 是 Clone 的,克隆只是两个 Arc 加一个 Copy 的 Identity。handler 返回之后还要用上下文的工作(tokio::spawn 出去的任务、流式响应体)都靠克隆一份 Cx 带走。一个请求从监听到 HTML 里 PageWithLayouts::handle 的 let owned = cx.clone() 就是这个用法。

identity:组件在树里的位置

Cx 的第二个字段 Identity 是一个 u128 哈希,由「父 identity + 调用点」用 FNV-1a 推导出来;调用点 SiteKey 是 file!()、line!()、column!() 加一个序号的哈希,在编译期就算好。循环里同一个调用点会重复出现,这时要用 cx.keyed(key) 把循环变量混进去,否则这个 identity 被标成「有歧义」,读它时报错。

identity 不参与按类型取值,它给 04、05 章用:suspense 的 region id、signal 的 id、shard 重跑时客户端带回来的 x-topcoat-identity 头都从它来。一个请求从监听到 HTML 里 3 次运行 region id 完全相同,原因就在这里。有一条限制和下面的 memoize 有关:memoize 函数体里读 identity 直接 panic(identity cannot be read inside memoized functions),因为缓存的结果会被不同位置的调用者共用。

#[memoize]:请求级缓存

#[memoize] 让一个带 cx: &Cx 参数的函数在同一个请求里按参数缓存结果,返回值从 T 变成 &T(借用期是 cx 的)。用 cargo expand 展开实验里的一个函数:

#[memoize]
async fn load_user(cx: &Cx, id: u32) -> String { /* ... */ }
async fn load_user<'__cx>(cx: &'__cx ::topcoat::context::Cx, id: u32) -> &'__cx String {
    ::topcoat::context::memoize_cache(cx)
        .memoize_async(
            cx,
            (&id,),
            (id,),
            async |cx, (id,)| {
                let cx = &cx;
                // ... 原函数体
            },
        )
        .await
}

三个实参:(&id,) 是缓存键,只拿来哈希;(id,) 是交给函数体的参数;最后是原函数体变成的闭包。闭包的类型也参与哈希,这是「每个 memoize 函数有自己的缓存」的实现方式。

缓存键

MemoizeCache 是一个只增不删的 FrozenMap<u128, Box<dyn Any>>:

fn hash<Marker, K>(key: &K) -> u128
where
    Marker: 'static,
    K: Hash,
{
    let (key0, key1) = sip_keys();
    let mut hasher = SipHasher13::new_with_keys(key0, key1);
    TypeId::of::<Marker>().hash(&mut hasher);
    key.hash(&mut hasher);
    hasher.finish128().as_u128()
}

键是闭包的 TypeId 加参数,用 SipHash-1-3 的 128 位输出,种子在进程启动时随机取一次。只比哈希,不比参数本身:参数从不克隆、不存储,所以借用参数(name: &str)也能用;代价是自定义的 Hash 漏了影响结果的字段,就会拿到别的调用的结果。文档在「Requirements」里专门提醒了这一点,并建议尽量 derive(Hash)。

并发调用共用一个 future

同一个键对应一个 AsyncMemoizeCell:

pub(super) async fn get_or_init<Marker, I, Fut>(&self, cx: &Cx, initialize: I) -> &V
// ...
{
    if let Some(value) = self.variants.reuse(cx) {
        return value;
    }
    // Asserted before taking the gate so reentry panics instead of deadlocking on it.
    self.recursion.assert_not_recursive::<Marker>();
    let _gate = self.gate.lock().await;
    if let Some(value) = self.variants.reuse(cx) {
        return value;
    }
    let (child, tracker) = cx.track();
    let mut future = pin!(self.recursion.scope(|| initialize(child)));
    // Clear ownership after every poll so sibling futures can wait on this cell and
    // the initializer can move between executor threads.
    let value = poll_fn(|task| self.recursion.scope(|| future.as_mut().poll(task))).await;
    self.variants.insert(cx, value, tracker.take_reads())
}

经典的「检查、加锁、再检查」:第一个调用者拿到 tokio::sync::Mutex,跑函数体;后到的调用者在锁上等,拿到锁时值已经在了,直接返回。同步版本 SyncMemoizeCell 结构一样,锁换成 std::sync::Mutex。

实验:三个组件,两次查询

topcoat = { version = "=0.10.0", features = ["full"] }
tokio = { version = "=1.54.1", features = ["full"] }
#[memoize]
async fn load_user(cx: &Cx, id: u32) -> String {
  let n = LOADS.fetch_add(1, Ordering::SeqCst) + 1;
  println!("load_user({id}) computing, call #{n}, db={}", app_context::<Db>(cx).0);
  tokio::time::sleep(Duration::from_millis(100)).await;
  format!("user-{id}")
}

#[component]
async fn card(cx: &Cx, label: &str, id: u32) -> Result<impl View> {
  let user = load_user(cx, id).await;
  Ok(view! { <li>(label) ": " (user.as_str())</li> })
}

#[page]
async fn home(cx: &Cx) -> Result<impl View> {
  // ...
  Ok(view! {
    <ul>
      card(label: "a", id: 1)
      card(label: "b", id: 1)
      card(label: "c", id: 2)
    </ul>
  })
}

main 里 module_router!().app_context(Db("primary")).build()。请求 /:

<ul><li>a: user-1</li><li>b: user-1</li><li>c: user-2</li></ul>
[200 0.108249s]

服务器打印:

load_user(1) computing, call #1, db=primary
load_user(2) computing, call #2, db=primary

三个组件在同一个任务里并发 poll(04 章的 JoinView),整个响应 108 毫秒,不是 300 毫秒;a 和 b 都要 id = 1,函数体只跑了一次,b 在锁上等到了 a 的结果。

依赖跟踪:同一个函数,不同作用域

缓存不只看参数。函数体里读过哪些请求级的值,也决定结果能不能复用。cx.track() 给函数体一个带 ContextTracker 的子 Cx;try_request_context 每次读值都往 tracker 里记一条 ContextRead { type_id, binding_id },取不到时 binding_id 记成 None。结果存进 Variants 时带着这串记录,下次调用时逐条比对调用者作用域里同类型的 BindingId 是否相同:

fn matches(&self, context: &RequestContext) -> bool {
    context.binding_id(self.type_id) == self.binding_id
}

同一个键因此可以有多个「变体」(first 一个,rest 若干)。实验里的 greeting 读 Locale:

#[memoize]
fn greeting(cx: &Cx) -> String {
  GREETS.fetch_add(1, Ordering::SeqCst);
  let locale = try_request_context::<Locale>(cx).map(|l| l.0).unwrap_or("none");
  format!("hello in {locale}")
}

let g1 = greeting(cx).clone();
let g2 = greeting(cx).clone();
let de = cx.with(Locale("de"));
let g3 = greeting(&de).clone();
let de2 = de.with(Db("ignored-for-tracking"));
let g4 = greeting(&de2).clone();
greeting: hello in none / hello in none / hello in de / hello in de, body ran 2 times
  • g1 计算,记下「Locale 没有」;g2 命中。
  • g3 的作用域里有了 Locale,BindingId 对不上,再算一次,存成第二个变体。
  • g4 又 with 了一个 Db,但函数体没读 Db,Locale 的绑定还是同一个,命中。

AppContext 不跟踪:它全局只有一份,不可能因作用域而不同。嵌套的 memoize 调用会把内层的读取记录合并到外层(ContextTracker::merge),命中缓存时也合并,所以外层的依赖是完整的。

同参数递归:panic 而不是死锁

函数体里以相同参数调用自己,第二次调用会在锁上等第一次,而第一次在等第二次。recursion::Guard 在拿锁之前检查:

// Its address identifies the synchronous call stack or async poll currently running on a thread.
thread_local!(static TOKEN: u8 = const { 0 });

初始化期间,cell 里存着「当前线程这个 thread-local 的地址」;同一线程上的重入看到地址相同,就 panic。异步版本只在每次 poll 期间设置这个地址,poll 返回就清空,所以同一任务里的兄弟 future 在 Pending 之后来等这个 cell,不会被误判成递归。实验:

#[memoize]
fn recurse(cx: &Cx, n: u64) -> u64 {
  *recurse(cx, n)
}
recursive `#[memoize]` initialization of `ctx::recurse::{{closure}}` with the same arguments

类型名是展开后的闭包,指向 recurse。参数不同的递归(文档里的阶乘)每一层是不同的键,正常工作。

sequenceDiagram
  participant A as card a
  participant B as card b
  participant M as AsyncMemoizeCell(id=1)
  A->>M: reuse:没有
  A->>M: 递归检查,拿到 gate
  A->>M: track,poll 函数体(sleep 100ms)
  B->>M: reuse:没有
  B->>M: 递归检查:owner 已清空,通过
  B->>M: 等 gate
  M-->>A: 值就绪,insert 变体,释放 gate
  B->>M: 拿到 gate,再 reuse:命中
  M-->>B: &String

Error 与 Result

topcoat::Result<T, E = topcoat::Error>。Error 是 Arc<dyn ErrorObject>,所以它是 Clone 的。任何 std::error::Error + Send + Sync + 'static 都能 ? 成它(From 实现,同时抓一个 Backtrace);Error::msg 包一段文字,context 加一层说明,downcast_ref 能穿过 context 层找到原来的错误。router 用的 not_found、redirect、rewrite 都是具体的错误类型,靠 downcast 识别(请求、响应与错误)。打开 anyhow feature 多一个 Error::from_anyhow。

Error 本身不实现 std::error::Error(否则和上面那条泛型 From 冲突)。这在 0.10.0 里和 #[memoize(as_ref)] 撞上了:

#[memoize(as_ref)]
async fn find_user(cx: &Cx, id: u32) -> Result<User> { /* ... */ }

async fn require_user(cx: &Cx) -> Result<&User> {
  let user = find_user(cx, 1).await?;
  Ok(user)
}

as_ref 把 Result<User, Error> 变成 Result<&User, &Error>,而 &Error 转不成 Error:

error[E0277]: `?` couldn't convert the error: `topcoat::Error: std::error::Error` is not satisfied
  --> src/bin/asref.rs:12:36
   |
12 |   let user = find_user(cx, 1).await?;
   |              ----------------------^ the trait `std::error::Error` is not implemented for `topcoat::Error`
   |              |
   |              this can't be annotated with `?` because it has type `Result<_, &topcoat::Error>`
   = note: required for `topcoat::Error` to implement `From<&topcoat::Error>`

0.10.0 里写成 .await.map_err(Clone::clone)? 就能编译:克隆的只是一个 Arc。tag 之后的 #514 补上了这个转换。

函数,而不是中间件

docs/context/functions_not_middlewares.md 是官方对「鉴权放在哪」的回答,整套 Cx 设计都在为它服务。它比较了三种写法:

写法问题
中间件认证后塞进 extensions,页面里 expect页面依赖别处配置的中间件;漏配或顺序错,要么 panic,要么这条路由悄悄没了检查
提取器 Auth(user) 放在 handler 签名里要求是显式的,但下面每一层组件都得接过 user 再往下传
接受 &Cx 的小函数,在用到的地方调用topcoat 的建议

推荐的拆法是一串小函数,每个只做一件事:session_cookie(cx) 读头,fetch_user(cx, id) 查库并 #[memoize(as_ref)],fetch_current_user(cx) 把两者接起来,require_auth(cx) 取不到就返回 UnauthorizedError。需要登录的组件自己调 require_auth(cx).await?,layout、page、侧边栏各调一次,数据库只查一次。

flowchart TB
  layout["layout:fetch_current_user(cx)"] --> fcu["fetch_current_user"]
  page["page:require_auth(cx)?"] --> ra["require_auth"]
  avatar["组件 user_avatar:require_auth(cx)?"] --> ra
  ra --> fcu
  fcu --> sc["session_cookie:读请求头"]
  fcu --> fu["fetch_user:#[memoize(as_ref)]"]
  fu --> db["数据库:本请求只查一次"]

这样写的好处是检查和使用挨在一起,组件挪到哪里检查就跟到哪里。代价同样明显:忘了调就没有检查,编译器不会提醒,也没有一个地方能列出「哪些路由要登录」。05 章的 shard 和 procedure 让这件事更要紧:它们有自己的端点,不经过页面和 layout,页面里的 require_auth 拦不住对 shard 端点的直接请求。10 设计取舍把这一条和 axum 的提取器、Dioxus 的 server function 放在一起比。

小结

  • Cx 是 Arc<CxState> 加一个 Identity。整个请求共享 RequestShared(AppContext、memoize 缓存);RequestContext 每个作用域一份,with / with_many 复制后插入,后插的覆盖先插的。
  • app_context / request_context 取不到时 panic,变成 500;try_ 版本返回 None。
  • #[memoize] 展开成对 MemoizeCache 的调用,键是闭包类型加参数的 128 位 SipHash,不比较参数本身;并发调用在一把异步锁上等同一个结果;函数体读过的请求级值按 BindingId 比对,作用域不同就再算一次。
  • 同参数递归在拿锁之前用 thread-local 地址检测,直接 panic,不会死锁。
  • Error 是 Arc 包装的,可克隆;0.10.0 里 memoize(as_ref) 得到的 Result<&T, &Error> 不能直接 ?,要 map_err(Clone::clone)。
  • 官方建议鉴权写成接受 &Cx 的函数、在用到的地方调用;代价是忘了调用就没有检查。

💬 关联讨论

发起讨论
加载中...