Skip to content

用类型讲故事:结构体与枚举 ​

第 4 章你能读单个函数了;这一章看数据本身怎么被设计。jackdaw 用结构体说清"一个东西有哪些字段",用枚举说清"一个值只可能是哪几种"。读完本章,你能回答:

  • jackdaw.toml 里的 strategy = "order" 这行字符串,在哪一步、经什么规则变成了 Strategy::Order?
  • 给 Strategy 新增一个变体,哪些代码会立刻编译失败?
  • 读 load() 时,哪一步可能得到 None、哪一步可能产生 Err?错误信息是谁拼出来的?

结构体:把一张 TOML 表变成类型 ​

先看 jackdaw 吃进去的文件。下面这份是 config.rs 测试里拼出的合法样例:网关、一个供应商、一个路由组,三个顶层表。

toml
[gateway]
port = 3426
auth_token_env = "JACKDAW_TOKEN"

[providers.openai]
base_url = "https://api.openai.com/v1"
api_key_env = "OPENAI_API_KEY"

[groups.coder]
strategy = "order"
models = ["openai/gpt-5.2"]

没有结构体,这份文件进程序只是一堆嵌套的字符串表:每处使用都自己取值、自己猜类型,键名拼错要等运行到那行才炸。结构体把"配置长什么样"一次声明成类型,之后交给编译器核对:

rust
/// The full top-level configuration.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
    pub gateway: GatewayConfig,
    pub providers: BTreeMap<String, ProviderConfig>,
    #[serde(default)]
    pub groups: BTreeMap<String, GroupConfig>,
}

// ...

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GroupConfig {
    pub strategy: Strategy,
    /// Group members: `<provider>/<model-id>` references, tried in order.
    pub models: Vec<String>,
}

从 pub struct Config 看起:三个字段对应三个顶层表,gateway: GatewayConfig 是结构体套结构体;// ... 是本书节选约定,表示省略的 GatewayConfig(装 port 与 auth_token_env)和 ProviderConfig(装 base_url 与 api_key_env)。providers 的类型 BTreeMap<String, ProviderConfig> 读作"名字 → 一份配置"——BTreeMap 是按键排序的 map,集合家族里 HashMap 的近亲,这里先当作映射读即可,它与 HashMap 的取舍不影响本章。#[serde(default)] 允许整个 [groups] 段缺省:删掉 coder 组,加载照样成功。

derive 与 serde:编译器替你写的部分 ​

#[derive(Debug, Clone, Deserialize)] 是本章第二个主角:派生。它让编译器按通用规则替类型实现常用能力:Debug 让值能用 {:?} 打印——下文 validate 的报错里 {member:?} 打印的是 String,它的 Debug 标准库自带;自家类型想要这份能力,靠的就是这行派生。Clone 允许显式 .clone();Deserialize 来自序列化库 serde——一个外部箱,箱与依赖从哪来第 8–9 章讲,这里只需知道它声明"我知道怎么从 TOML 文本装配出这个类型的值"——于是 load() 里一行 toml::from_str(&text),文件文本就成了类型化的 Config。

#[serde(deny_unknown_fields)] 单独记一下:多写的字段(比如把 strategy 拼成 stratagy)直接报加载错误,而不是被静默忽略——config.rs 的测试 unknown_field_is_a_load_error 盯着这件事。

审 AI 生成的配置代码时

看到 AI 定义的配置结构体只挂了 #[derive(Deserialize)]、没有 deny_unknown_fields,值得问一句:字段拼错被悄悄吞掉,是这个程序想要的吗?

枚举:一个类型,几种已知形状 ​

port: u16 表示任何 u16 都合法;strategy 却只有两个合法值。用字符串装它,"smart" 也能通过编译,等运行才炸。枚举把"只能是这样几种"写进类型:

rust
/// How a group picks its starting member. Serde matches the lowercase TOML
/// spelling, so an unknown strategy (say, magpie's `smart`) is a load error.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Strategy {
    /// Always start at the first member on the list.
    Order,
    /// Move the starting member forward on every request.
    Rotate,
}

从 doc 注释看起,它把机制说完了:serde 按小写拼写匹配,#[serde(rename_all = "lowercase")] 把变体 Order 对应 TOML 里的 "order"。中间状态走一遍:文件里 strategy = "order" → from_str 读到字符串 "order" → 按小写比对变体 → 产出 Strategy::Order,存进 GroupConfig.strategy。对照 magpie:它的路由策略有 smart / order / rotate / usage 四种,jackdaw 刻意只做 order 和 rotate(教程只要最小核);差异被类型接住——配置写 strategy = "smart" 得到的是一条加载错误,测试 unknown_strategy_is_rejected 验证的正是它。

同一副眼镜看 agent 发来的 chat 请求(OpenAI 兼容形态):role 取值封闭,是枚举;messages 是 Vec;temperature 可有可无,是 Option;整份请求靠 serde 进出。jackdaw 真正的消息类型第 14 章随网关展开,现在你只需会做这道"哪个键是什么类型"的阅读题。

match 的穷尽性:编译器替你查漏 ​

模式匹配的 match 按结构拆值,最硬的保证叫穷尽性:每个可能的变体都必须有分支,漏一个编译不过。假设给 Strategy 加第三个变体,config.rs 里每个 match 它的地方都会立刻报错——你不必记得"还有哪里要改",编译器替你列清单;if-else 链给不了这个保证。枚举变体还能携带数据,match 顺手拆开。validate() 检查组员引用就是一例:

rust
// ...
for member in &group.models {
    match ModelRef::parse(member) {
        Err(why) => {
            errors.push(format!("[groups.{id}] models: member {member:?}: {why}"));
        }
        // Groups stay flat on purpose (magpie nests up to 8 levels;
        // jackdaw keeps one level to stay readable).
        Ok(ModelRef::Group { .. }) => {
            errors.push(format!(
                "[groups.{id}] models: member {member:?} is a group — nested \
                 groups are not supported. Use `<provider>/<model-id>` members only."
            ));
        }
        Ok(ModelRef::Provider { provider, .. }) => {
            if !self.providers.contains_key(&provider) {
                errors.push(format!(
                    "[groups.{id}] models: member {member:?} names unknown \
                     provider `{provider}`. Known providers: {}. Add a \
                     [providers.{provider}] table or fix the typo.",
                    self.provider_names().join(", ")
                ));
            }
        }
    }
}
// ...

从 ModelRef::parse(member) 看起:它把 "openai/gpt-5.2" 解析成 model.rs 里的 ModelRef 枚举,包在 Ok/Err 里(第 4 章你见过解析 provider/model 字符串的函数)。三个分支各绑定各的:Err(why) 把失败原因绑给 why 拼进报错;Ok(ModelRef::Group { .. }) 只认"是携带字段的 Group 变体",字段一概不要;Ok(ModelRef::Provider { provider, .. }) 只取出 provider 字段,拿去 providers 表查存在性——{ .. } 读作"其余字段我不关心"。走一遍中间状态:成员 "group/other" 落第二分支被拒(magpie 组可嵌套最多 8 层,jackdaw 刻意只留一层);"nope/gpt-5.2" 落第三分支,查无此 provider。两种结果都是可行动的报错,不是崩溃。

Option 与 Result:缺席与失败各有类型 ​

"可能没有"与"可能失败",不少语言用 null、异常或魔法值对付;Rust 给它们各自的类型:选项类型 Option<T> 是 Some(值) 或 None,结果类型 Result<T, E> 是 Ok(值) 或 Err(错误)。两者都是枚举,前面的一切照用。

找配置文件时,家目录可能取不到:

rust
/// Where to look when `--config` was not given: local file first, then the
/// per-user location. Order matters — a local file overrides the user one.
fn config_candidates() -> Vec<PathBuf> {
    let mut tried = vec![PathBuf::from(LOCAL_CONFIG_NAME)];
    match std::env::home_dir() {
        Some(home) => tried.push(home.join(".config").join("jackdaw").join("jackdaw.toml")),
        None => tried.push(PathBuf::from("~/.config/jackdaw/jackdaw.toml")),
    }
    tried
}

从 std::env::home_dir() 看起:它返回 Option,因为系统可能给不出家目录。Some(home) 把家目录绑给 home,拼出真实路径;None 手里没有任何值,只能压一个占位路径进列表,好让报错里看得出"这里找过"。

加载主流程把两种类型串成一条链:

rust
pub fn load(explicit: Option<&Path>) -> Result<Config, ConfigError> {
    let path = match explicit {
        Some(given) => Some(given.to_path_buf()),
        None => config_candidates().into_iter().find(|path| path.is_file()),
    };
    let Some(path) = path else {
        return Err(ConfigError::no_config(&config_candidates()));
    };

    let text = std::fs::read_to_string(&path).map_err(|error| ConfigError::Read(path, error))?;
    let config: Config =
        toml::from_str(&text).map_err(|error| ConfigError::Parse(error.to_string()))?;
    config
        .validate()
        .map_err(|errors| ConfigError::Validation(format!("- {}", errors.join("\n- "))))?;
    Ok(config)
}

从签名看起:explicit 是 --config 给的路径,可能没有,所以是 Option;第一个 match 把它换成"找到的第一个存在的文件",没有则 let Some(path) = path else { ... } 提前返回——这是模式匹配的另一种句式:是 Some 就把值绑给 path 继续,是 None 就走 else。之后每个 ? 含义一致:这步若是 Err,把错误原样交还调用者;若是 Ok,解包继续(问号运算符)。三处 map_err 各自把底层错误换成 ConfigError 的对应变体。返回类型里的 ConfigError 是又一个携带数据的枚举——比如 Read(PathBuf, std::io::Error) 同时装着哪个文件、底层错误是什么;它的定义与错误处理分工第 11 章展开。把失败也写成类型,调用方就无法装作没有失败。回看开场第三问:哪步 None(let-else)、哪步 Err(三个 ?)、错误信息谁拼(map_err 的闭包),你现在都有答案了。

小结 ​

  • 结构体把 TOML 表的结构写成类型,枚举把"只能这几种"写成类型;serde 的派生负责文本与类型之间的装配。
  • match 的穷尽性把"新增变体忘了改某处"从运行时事故变成编译错误。
  • Option 与 Result 把缺席与失败放进返回类型,? 是它们的快速通道。
  • 下一章:trait——行为的契约,以及它为什么是 jackdaw 的脊柱。

自检 ​

  1. 用你最熟的语言(如 TypeScript)表达"strategy 只能是 order 或 rotate",再对照 Strategy 枚举加 rename_all:Rust 这套多给了什么保护?代价是什么?
  2. 把样例配置里 coder 组的成员改成 group/coder(引用它自己):validate() 会命中哪个 match 分支?结果是什么?
  3. load() 里 read_to_string 那行若去掉 .map_err(...) 只留 ?,为什么编译不过?
参考答案
  1. TypeScript 的字面量联合 'order' | 'rotate' 能约束程序内的值;差别在外部边界——TOML 文本进来的那一刻,Rust 的 Deserialize 把"校验 + 装配"合成一步,进程序后拿到的必是 Strategy 两个变体之一,match 还受穷尽性保护。代价是多一层注解,变体名与配置拼写要两处联动。
  2. ModelRef::parse 把 group/coder 解析成 Ok(ModelRef::Group { .. }),命中第二分支,errors 里多一条 "nested groups are not supported";errors 非空使 validate() 返回 Err,load 包成 ConfigError::Validation,启动失败。jackdaw 的组刻意不嵌套(magpie 最多 8 层)。
  3. read_to_string 的错误类型是 std::io::Error,而 load 声明返回 Result<_, ConfigError>;? 要求此处错误类型与函数返回的一致(或存在自动转换,这里没有)。.map_err(|error| ConfigError::Read(path, error)) 先把 io::Error 换成 ConfigError::Read,? 才能直接提前返回。