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的函数、在用到的地方调用;代价是忘了调用就没有检查。