行为的契约:trait、泛型与 async
第 6 章的 struct 与 enum 回答的是数据"长什么样";这一章换成动词:数据"会做什么"。读完你应该能回答:
- 打开
jackdaw/src/provider.rs,说出一个类型要自称供应商(provider)必须兑现哪三个方法; - 解释为什么路由的候选列表装的是
Box<dyn Provider>,而不是某家具体厂商的类型; - 看到
#[async_trait::async_trait]、async fn、.await,说出谁被装箱、谁在让出、谁在驱动。
契约:会做什么,才算一个 Provider
先看没有契约会怎样。jackdaw 是网关:同一条请求可能发往任意 OpenAI 兼容的上游;跑单元测试时,你又希望不发真 HTTP,用内存假数据走同一条路径。如果路由代码直接依赖某个具体类型,那么每换一家上游、每加一个模拟替身,路由和测试都得跟着改——"换一个"没有统一的说法。
特征(trait)就是这份契约:一组方法签名,只声明"会做什么",不提供数据、不给实现。类型用 impl Provider for Xxx 兑现签名;兑现了,就能被当作一个 provider 使用。对照第 6 章:struct 讲名词,trait 讲动词。
/// One upstream LLM API. [`crate::openai`] implements it for any
/// OpenAI-compatible endpoint; unit tests implement it with in-memory fakes.
#[async_trait::async_trait]
pub trait Provider: Send + Sync {
/// Provider name, as used in `[providers.<name>]` and model references.
fn name(&self) -> &str;
/// Send the request and wait for the full JSON response.
async fn complete(&self, request: ChatRequest) -> Result<ChatResponse, ProviderError>;
/// Send the request and return the raw SSE byte stream. A successful
/// return means the upstream already *accepted* the request (the HTTP
/// status was checked); failures after the first byte are delivered
/// through the stream itself — at that point failover is impossible.
async fn complete_stream(&self, request: ChatRequest) -> Result<ByteStream, ProviderError>;
}读法:从 pub trait Provider: Send + Sync 这行看起,冒号后面是对实现者的额外承诺,本章最后一节解释。三个方法的签名都止于分号、没有方法体——契约只定签名:name 报名字;complete 等待完整 JSON 回应;complete_stream 返回一条流(stream)。参数与错误类型也来自这份契约:ChatRequest 是请求体的包装(第 5 章你追踪过它的所有权),ProviderError 是失败的分类(第 12 章展开)。文档注释值得读:crate::openai 为真实端点实现这份契约,单元测试用内存假数据实现它——同一契约,两种兑现。
impl 块:固有方法与兑现契约
兑现发生在 impl 块里。同文件里还有另一种 impl——不兑现任何契约,只给自己定义的类型加方法:
#[derive(Debug, Clone)]
pub struct ChatRequest(pub Value);
impl ChatRequest {
// ...
/// Whether the caller asked for an SSE stream. Absent means `false`,
/// matching the OpenAI API.
pub fn wants_stream(&self) -> bool {
self.0
.get("stream")
.and_then(Value::as_bool)
.unwrap_or(false)
}
// ...
}读法:先看 pub struct ChatRequest(pub Value):它包着第 6 章见过的 JSON 值——网关刻意不建模 tools、temperature 这些字段,只原样转发,文件头注释讲明了这个取舍。再看 wants_stream:&self 是只读借用(第 5 章),self.0 取出内层 JSON,get("stream") 到 unwrap_or 是第 6 章的 Option 管道,字段缺失时按 OpenAI 语义返回 false。区分两种 impl:本例 impl ChatRequest 是固有方法;写 impl Provider for 某类型 的才是兑现契约。
泛型与特征对象:两条使用契约的路
有了契约,怎么写出"对任何 Provider 都成立"的代码?Rust 给两条路。
第一条是泛型:签名形如 fn pick<T: Provider>(p: T, ...),读作"T 是满足 Provider 契约的任意类型"。编译器为每个实际用到的 T 单独生成一份代码,这叫单态化——没有运行时开销,但类型必须在编译期全部确定。
第二条处理泛型办不到的事:路由(第 13 章展开)手里是一张运行时才确定的候选列表,第一位与第二位可能是不同类型的具体 provider,而 Vec 的元素必须同一类型。答案是特征对象:Box<dyn Provider>。dyn 读作"动态";Box 是最简单的智能指针,把值放到堆上,自己只占一个固定的大小,不随所装的值变。于是 Vec<Box<dyn Provider>> 的每个元素都同样大——一个指针指向堆上的 provider,另一个指向它的方法表——底下是谁不再重要。代价是调用时多一次指针跳转,换来的是运行时才决定的多态。
一句话分工:编译期知道类型,用泛型;运行时才知道,用 Box<dyn Trait>。网关大多用后者。
为什么需要 async-trait
trait 上方那行 #[async_trait::async_trait] 是属性宏——读作"编译前改写代码"的工具(这里简化了,宏的真实机制更复杂,但作为读法够用)。它解决的问题是:异步函数的真实身份是"返回一个匿名 Future 类型的普通函数",每个 impl 里的 async fn 体都会编译出一个不同的匿名类型;而 dyn Provider 需要统一的方法签名才能生成方法表。签名各不相同,表就造不出来——早期 Rust 因此干脆禁止在 trait 里写 async fn。
解法是装箱:宏把 async fn complete(...) -> Result<...> 改写成返回 Pin<Box<dyn Future<...>>> 的普通函数,把每个实现者各自的 Future 类型装进 Box、统一成一个类型,签名一致,dyn Provider 成立。同一套手法的手写版就在文件里——complete_stream 的返回类型:
/// Byte chunks of a streaming (SSE) response body, in arrival order.
pub type ByteStream = Pin<Box<dyn Stream<Item = Result<Bytes, ProviderError>> + Send>>;读法:这是一行类型别名——给长类型起个短名。从里往外读:Stream<Item = ...> 是"逐块产出 Bytes(字节块)或错误"的流;Box<dyn ...> 装箱统一类型;Pin 把值钉在内存里不许搬家,Future 与流的内部机制需要这一点(第一次读只需认得 Pin<Box<...>> 这个组合,不必展开);+ Send 见下节。补一句边界:新版 Rust 已允许在 trait 里直接写 async fn,但与 dyn 的组合仍有限制,生态里 async-trait 依旧常见——读到它,知道它在做装箱改写即可。
await 是让出点,不是线程
动机:网关同时服务多个 agent 的请求,每个都在等上游回包;如果每次等待都占住一个操作系统线程,线程数会先撑爆。async 的目标是等待时不占线程。读级别三个事实:
- 调用 async fn 时函数体并不执行,立刻返回的是一个 Future——"将来才有值"的计算描述,不
.await就不执行。 .await是让出点:驱动这个 Future 前进;若此刻在等网络,当前任务就交出执行权,线程去跑别的任务,事件就绪后从断点继续。让出的是任务,不是线程,更不会新开线程。- 推动这一切的是运行时:Rust 标准库不带,jackdaw 用事实标准 Tokio——那个"知道何时该叫哪个任务回来"的调度器(第 9、14 章展开)。
类比快餐店取餐牌:下完单你回座位,牌亮再取,没有店员为你空等,一个线程同时挂着几十上百个任务。关键失配:Future 不会自己亮牌,盯着的永远是运行时。回到机制,这就是 async 的全部读法。
最后是 Send / Sync:trait Provider: Send + Sync 意为"实现者承诺可安全地跨线程移动与共享"。网关是并发服务,同一个 provider 会被多个任务同时使用,承诺因此写进契约。看到相关编译报错,第一反应是"数据竞争被拦下来了"——第 3 章说过,并发属于必须自己看懂的时刻。
小结
- trait 是行为的契约:
trait Provider让路由只认契约、不认厂商,接任意上游、用假实现跑测试都成立。 impl Trait for Type兑现契约;泛型在编译期单态化,Box<dyn Provider>在运行时统一类型,网关大多用后者。#[async_trait::async_trait]把 async fn 的返回值装箱统一,dyn才可用;ByteStream是同一手法的手写版。.await是让出点,Tokio 是驱动者;Pin<Box<...>>认得即可。- 语言篇到此收官。下一章解剖 jackdaw 的 Cargo.toml 与目录树,看这些代码怎么被组织、构建、测试(第 8 章)。
自检
- 你带着 interface / Protocol 的经验来。对照
trait Provider,说出 trait 与它们的两点不同。 - 有人把故障转移的候选列表从
Vec<Box<dyn Provider>>改成Vec<OpenAiProvider>(示意名,指 openai.rs 里实现 Provider 的具体类型)。预测:再往列表里加一个测试用的假 provider 时会发生什么?为什么? complete_stream为什么返回Pin<Box<dyn Stream<...> + Send>>,而不是把流写成泛型参数?
参考答案
- 任举两点:trait 可以给别处已定义的类型补实现,不限于自己定义的类型;trait 方法可带默认实现,实现者不写就用默认版;此外 Rust 里泛型走静态分发、
dyn Trait走动态分发,而多数语言的 interface 只有动态分发一种。 - 编译期报错。
Vec<OpenAiProvider>的元素类型已经固定,假 provider 是另一个类型,装不进去;要混装不同实现,只能统一到共同契约Box<dyn Provider>。 - 流的具体类型由实现细节决定(哪家 HTTP 客户端、哪种响应体流),调用方不需要也不应该知道;装箱成统一类型后,trait 的方法签名才固定,
dyn Provider才成立;+ Send保证它能跨任务安全使用——网关是并发的。