Skip to content

所有权:最独特的一堵墙 ​

在别的语言里,你几乎不必操心"这个值归谁、何时释放":有垃圾回收的替你管,没有的用悬垂指针与双重释放换经验。Rust 修了一堵墙来解决它,这堵墙也是你读 Rust 代码时最先撞上的东西。读完本章,你应该能回答:

  1. 给你 gateway.rs 的 chat_completions,你能说出 body、request、requested 各自拥有什么,以及 request 在哪一行交出了所有权;
  2. 看到签名里的 &HeaderMap、&str、&mut 和不带 & 的 ChatRequest,你能立刻归类:拿走、只读,还是可写;
  3. provider.rs 里 self.0.clone() 为什么是故意复制;删掉它,故障转移会坏在哪。

进门:字节变成一个有属主的值 ​

一个 agent 把一段 JSON POST 到 jackdaw 的 /v1/chat/completions。字节进了进程,就是一块堆上的内存:谁来释放?释放两次、或还有人引用着就释放,各会怎样?Rust 的答案只有一条规则:每个值有唯一属主;属主离开作用域,值被释放,恰好一次。这就是所有权。主线是这串字节的旅程,第一站:网关的处理函数(axum 框架,第 14 章展开):

rust
async fn chat_completions(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    body: Bytes,
) -> Response {
    // ...(bearer token 检查省略)

    let request = match decode_request(&body) {
        Ok(request) => request,
        Err(message) => return error_response(StatusCode::BAD_REQUEST, message),
    };

    let requested = request.model_field().to_string();
    if request.wants_stream() {
        stream_reply(&state, request, &requested).await
    } else {
        complete_reply(&state, request, &requested).await
    }
}

读法:先看参数表。body: Bytes 是"拿走"——axum 把整次请求的字节作为一个新值交给函数,由它负责到底;headers: HeaderMap 同理;State/Arc 是传入共享状态的固定写法,第 14 章展开。decode_request(&body) 带 &,是借用:看一眼,不夺走。解出的 ChatRequest 绑定到 request,从这行起,解析后 JSON 的属主就是 request 这个名字。

解码这一步:

rust
fn decode_request(body: &[u8]) -> Result<ChatRequest, String> {
    let value: Value = serde_json::from_slice(body)
        .map_err(|error| format!("request body is not valid JSON: {error}"))?;
    if !value.is_object() {
        return Err("request body must be a JSON object".to_string());
    }
    Ok(ChatRequest(value))
}

读法:&[u8] 是对字节的只读借用;from_slice 解析出一个全新的 Value(serde_json 的任意 JSON 值,serde 第 6 章展开),在堆上重新分配,不是原字节的视图;ChatRequest(value) 把它包好,随返回值交还调用方——所有权沿返回值流回 chat_completions。Result 第 4 章见过:要么 Ok(值) 要么 Err(人话);map_err 在这里只是把错误改写成一句话。

看一眼,还是抱回家:&str 与 String ​

路由之前,网关要先知道"客户端点名要哪个模型":

rust
/// A chat request body, passed through untouched except for `model`.
#[derive(Debug, Clone)]
pub struct ChatRequest(pub Value);

impl ChatRequest {
    // ...
    /// The raw `model` string as received (for logs).
    pub fn model_field(&self) -> &str {
        self.0
            .get("model")
            .and_then(Value::as_str)
            .unwrap_or("<missing>")
    }
}

读法:ChatRequest(pub Value) 是个结构体,.0 是它包着的 JSON(#[derive(...)] 让编译器自动实现打印、克隆等,第 6 章展开)。&self 是只读借用;返回的 &str 是字符串切片(&str):指向 request 内部那几个字节的只读视图,不复制内容。"引用能活多久"的约束叫生命周期,多数场景编译器自动推导,你现在认得这个词就够。

再看 chat_completions 里那行 let requested = request.model_field().to_string();。方向先给:.to_string() 就是在复制。下一节你会看到 request 要被整个交出去,而 &str 视图不能比它指向的数据活得久;.to_string() 把内容复制进 requested——一个自己拥有字节的堆字符串(String),之后原件去哪、何时释放,都与它无关。临时看一眼用 &str,要长期持有就复制成 String,这是两者的分界线。

TIP

stream_reply(&state, request, &requested) 传的是 &String,形参却是 &str——Rust 自动转换,不用写 .as_str()。审 AI 生成的 diff 时,别在这里多想。

move:交出去,名字就作废 ​

上面那行调用里,request 不带 &:被拿走了。这叫所有权转移(move)——赋值或传参不带 &,所有权就交出去,原名字从此失效。这行之后再写 request.wants_stream(),编译器直接拒绝,报 use of moved value(E0382)。不是运行时保护,是编译期的墙。主线的出口在供应商:

rust
#[async_trait::async_trait]
pub trait Provider: Send + Sync {
    // ...
    /// Send the request and wait for the full JSON response.
    async fn complete(&self, request: ChatRequest) -> Result<ChatResponse, ProviderError>;
    // ...
}

读法:trait 是行为契约、async fn/.await 是等一次网络往返,第 7 章展开;trait 上方那行 #[async_trait::async_trait] 同样先当作固定写法,为什么非它不可,第 7 章拆开;Send + Sync 第一次读直接无视;ProviderError 是枚举,第 6 章展开。关键在参数:request: ChatRequest 不带 &,provider 拿走整个请求,router 与网关从此无权再碰;返回的 ChatResponse 是上游新造的值,所有权再沿返回值流回来。整条链:字节进门 → 解析成 request → move 进 router → move 给 provider → 新的响应值流回网关。

可写借用,与一次故意的 Clone ​

路由那头,group/<id> 可能对应多个候选模型:第一个 429 了就换下一个(故障转移,第 13 章展开)。客户端发的 body 长这样(示意),with_model 要做的就是把组名换成组里某个真实模型 id:

json
{ "model": "group/backup", "stream": false, "messages": [ /* ... */ ] }
rust
/// A copy of the body with `model` set to the upstream model id.
///
/// One deliberate `clone` per candidate: the original request must stay
/// intact so the failover loop can rewrite it for the next model.
pub fn with_model(&self, model_id: &str) -> ChatRequest {
    let mut body = self.0.clone();
    // Insert through the object map (not `body["model"] = ...`, which
    // panics on non-objects); callers guarantee an object body.
    if let Value::Object(map) = &mut body {
        map.insert("model".to_string(), Value::String(model_id.to_string()));
    }
    ChatRequest(body)
}

读法:从 .clone() 那行看起。self.0 是原件;.clone() 深拷贝一份,副本的属主是局部变量 body,此刻副本里 model 还是 group/backup。&mut body 是可写借用,全篇唯一一处:在 map.insert 把 model 换成上游 id 期间,body 不允许任何其他读或写。末尾 ChatRequest(body) 把副本交还调用方;原件从头到尾只被读过。

为什么必须复制?函数的注释自己写了:故障转移循环还要给下一个候选再改一次 model,原件必须完好;若改成拿走 self,第一次转移就把原件消耗掉了。.clone() 从不自动发生——看到它,就是有人明确决定"这里复制一次"(克隆);值不值,看数据大小与次数:每个候选一次、每次请求一两回,可以接受。执法者是借用检查器:同一时刻,要么任意多个只读借用,要么一个可写借用,编译期强制。它拦下的不是你,是你本会在凌晨调试的悬垂引用与数据竞争。

签名即文档:拿走、只读、可写 ​

把全章压成速查表。读任何 Rust 函数签名,先扫参数里的 &:

签名里看到含义本章例子
T(不带 &)拿走:所有权 move 进来,函数负责到底complete(&self, request: ChatRequest)
&T只读借用:看,不改,不负责释放authorized(headers: &HeaderMap, token: &str)
&mut T可写借用:独占修改权&mut body

代码可以由 AI 写,但签名是数据的路线图。审 diff 时,& 的有无往往比函数名更早告诉你数据会怎么动。

小结 ​

  • 每个值有唯一属主;字节由 axum 造出交给 handler,解析后的 JSON 归 request,它在交给 stream_reply/complete_reply 的那一行 move 出去,事先用 .to_string() 给自己留下一份。
  • & 只读、&mut 可写、不带 & 拿走——这套语言写在每个签名里。
  • .clone() 是明确决定的复制;jackdaw 里那次,是给故障转移留原件。

下一章把"值"升级成"类型":结构体与枚举,以及 ChatRequest 为什么薄薄包一层就有用。

自检 ​

  1. 你从 Python 来,习惯把对象传给函数后继续用。在 chat_completions 里,complete_reply(&state, request, &requested) 之后再写 request.wants_stream(),会发生什么?想继续用这个值,有哪些改法?
  2. 把 with_model 改成拿走 self(fn with_model(self, ...)),删掉 .clone(),直接 let mut body = self.0;。这个函数本身能编译吗?故障转移循环会在哪一步出问题?
  3. authorized(headers: &HeaderMap, token: &str) 为什么两个参数都用 &?如果改成拿走 HeaderMap,chat_completions 还能编译吗?
参考答案
  1. 编译失败:use of moved value: request(E0382)。所有权已随调用转移,名字作废。改法:要么在 move 前把要用的值取出来(源码里 requested 正是这么做的),要么让函数收 &ChatRequest 只读借用——要不要保留所有权,决定选哪种。
  2. 函数本身能编译。坏在调用方:第一次调用就消耗了请求,循环到第二个候选时没有原件可改,调用处报 use of moved value。这正是注释 "One deliberate clone per candidate" 防住的事故:每个候选改自己的副本,原件始终完好。
  3. 认证只需要读 header 和 token,拿走没有任何好处。改成拿走后,只要 chat_completions 在 authorized 之后不再使用 headers,依然能编译——move 合法,但白白放弃了整张 header 表;& 传达的是"看一眼,不负责释放"。读签名时,& 的有无就是第一眼要看的东西。