构建者模式:Rust 中的链式调用与类型状态构建者

构建者模式,说白了就是解决「构造函数参数太多」这个老大难问题。我最早在 Java 里接触它,那时候觉得这玩意儿就是个语法糖。直到在 Rust 里做项目,我才真正体会到它的威力——尤其是在处理那些可选参数多、构造逻辑复杂的场景时。

为什么 Rust 需要 Builder 模式?

Rust 没有方法重载,也没有默认参数。你想想看,如果一个结构体有十几个字段,其中大部分是可选的,你要怎么构造它?

我见过有人这么写:

let config = Config::new(
    "127.0.0.1",
    8080,
    true,
    None,
    None,
    Some(10),
    None,
    false,
    true,
);

嗯,这代码谁看了不头疼?参数顺序一错,bug 就来了。而且你根本不知道每个参数是干什么的。

Builder 模式就是来解决这个问题的。它把构造过程拆成一个个方法调用,每个方法都有名字,清晰明了。

Rust 中 Builder 模式的基本实现

先看一个最简单的例子。假设我们要构建一个 HTTP 客户端配置:

pub struct HttpClientConfig {
    base_url: String,
    timeout_secs: u64,
    max_retries: u32,
    use_tls: bool,
    proxy: Option<String>,
}

pub struct HttpClientConfigBuilder {
    base_url: String,
    timeout_secs: u64,
    max_retries: u32,
    use_tls: bool,
    proxy: Option<String>,
}

impl HttpClientConfigBuilder {
    pub fn new(base_url: &str) -> Self {
        Self {
            base_url: base_url.to_string(),
            timeout_secs: 30,      // 默认值
            max_retries: 3,        // 默认值
            use_tls: true,         // 默认值
            proxy: None,           // 默认值
        }
    }

    pub fn timeout(mut self, secs: u64) -> Self {
        self.timeout_secs = secs;
        self
    }

    pub fn max_retries(mut self, retries: u32) -> Self {
        self.max_retries = retries;
        self
    }

    pub fn disable_tls(mut self) -> Self {
        self.use_tls = false;
        self
    }

    pub fn proxy(mut self, proxy: &str) -> Self {
        self.proxy = Some(proxy.to_string());
        self
    }

    pub fn build(self) -> HttpClientConfig {
        HttpClientConfig {
            base_url: self.base_url,
            timeout_secs: self.timeout_secs,
            max_retries: self.max_retries,
            use_tls: self.use_tls,
            proxy: self.proxy,
        }
    }
}

使用起来就是链式调用:

let config = HttpClientConfigBuilder::new("https://api.example.com")
    .timeout(60)
    .max_retries(5)
    .disable_tls()
    .build();

每个方法都返回 self,这就是链式调用的核心。我个人习惯把 Builder 作为结构体的关联函数,而不是单独一个模块,这样调用起来更自然。

类型状态构建者:编译期检查

上面那个实现有个问题:你可以在调用 build() 之前忘记设置某些必填字段。比如 base_url 是必填的,但如果你忘了传,编译器不会报错,运行时才会出问题。

类型状态构建者(Typestate Builder)就是来解决这个的。它利用 Rust 的类型系统,在编译期就确保你调用了所有必填方法。

我在项目中遇到过这样一个场景:一个支付 SDK 的配置,有 5 个必填字段和 10 个可选字段。用普通 Builder,测试时总有人漏掉必填项。后来我改成了类型状态构建者,问题就再没出现过。

看代码:

// 定义状态标记类型
pub struct MissingBaseUrl;
pub struct MissingApiKey;
pub struct Ready;

pub struct PaymentClientBuilder<T, U> {
    base_url: Option<String>,
    api_key: Option<String>,
    timeout_secs: u64,
    _state: std::marker::PhantomData<(T, U)>,
}

impl PaymentClientBuilder<MissingBaseUrl, MissingApiKey> {
    pub fn new() -> Self {
        Self {
            base_url: None,
            api_key: None,
            timeout_secs: 30,
            _state: std::marker::PhantomData,
        }
    }

    pub fn base_url(self, url: &str) -> PaymentClientBuilder<String, MissingApiKey> {
        PaymentClientBuilder {
            base_url: Some(url.to_string()),
            api_key: self.api_key,
            timeout_secs: self.timeout_secs,
            _state: std::marker::PhantomData,
        }
    }
}

impl PaymentClientBuilder<String, MissingApiKey> {
    pub fn api_key(self, key: &str) -> PaymentClientBuilder<String, String> {
        PaymentClientBuilder {
            base_url: self.base_url,
            api_key: Some(key.to_string()),
            timeout_secs: self.timeout_secs,
            _state: std::marker::PhantomData,
        }
    }
}

impl PaymentClientBuilder<String, String> {
    pub fn timeout(mut self, secs: u64) -> Self {
        self.timeout_secs = secs;
        self
    }

    pub fn build(self) -> PaymentClient {
        PaymentClient {
            base_url: self.base_url.unwrap(),
            api_key: self.api_key.unwrap(),
            timeout_secs: self.timeout_secs,
        }
    }
}

现在,如果你忘了设置 base_url 就调用 build(),编译器会直接报错:

// 编译错误!MissingBaseUrl 没有 build 方法
let client = PaymentClientBuilder::new()
    .api_key("sk-xxx")
    .build();

核心思想:每个状态只暴露该状态下允许调用的方法。你只能从 MissingBaseUrl 状态调用 base_url(),从 MissingApiKey 状态调用 api_key(),从 Ready 状态调用 build()

什么时候用类型状态构建者?

场景 推荐方案 原因
所有字段都有默认值 普通 Builder 简单,代码量少
1-2 个必填字段 普通 Builder + 构造函数 把必填字段放 new() 里
3 个以上必填字段 类型状态构建者 编译期保证必填项
字段之间有依赖关系 类型状态构建者 比如先设 A 才能设 B

我个人建议:如果 Builder 的方法超过 8 个,或者有 3 个以上必填字段,就上类型状态构建者。前期多写几行代码,后期能省很多调试时间。

Builder 模式的最佳实践

做 Rust 项目这几年,我总结了几条 Builder 的使用原则:

  • 默认值要合理:timeout 默认 30 秒,retries 默认 3 次。别让调用方猜默认值是什么。
  • 方法名要自解释disable_tls()tls(false) 好。读代码的人一眼就能看出意图。
  • build() 里做校验:比如 timeout 不能为 0,retries 不能超过 10。校验失败就 panic 或返回 Result。
  • 考虑 Clone:如果 Builder 需要复用,实现 Clone trait。我经常在测试里复用同一个 Builder 改一两个字段。

小技巧:可以用 #[derive(Default)] 给 Builder 的默认值,然后手动覆盖需要改的字段。这样代码更简洁。

Builder 模式的知识体系

下面这张图总结了 Builder 模式在 Rust 中的核心脉络:

Builder 模式在 Rust 中的实现路径 构造复杂对象 必填字段数量? 少(≤2) 普通 Builder 多(≥3) 类型状态构建者 链式调用 编译期检查 安全、可读的构造

避坑指南

我曾经在 Builder 里犯过一个低级错误:把 build() 的参数类型搞错了,导致调用方传了 &str 但 Builder 内部需要 String。结果编译通过了,运行时却 panic。后来我养成了一个习惯:build() 里做一次完整的类型转换和校验,确保所有字段都符合预期。

还有一个坑:类型状态构建者的泛型参数太多,代码会变得很难看。我建议用 type alias 给中间状态起个名字:

type BuilderWithUrl = PaymentClientBuilder<String, MissingApiKey>;
type BuilderWithUrlAndKey = PaymentClientBuilder<String, String>;

这样代码读起来就舒服多了。

注意:类型状态构建者虽然安全,但会增加代码复杂度。如果你的 Builder 只有 3-4 个方法,用普通 Builder 就够了。别为了炫技而过度设计。

好了,关于 Builder 模式就聊这么多。记住一句话:Builder 的核心不是链式调用,而是让构造过程变得安全、可读、可维护。类型状态构建者只是 Rust 给我们的一个额外武器,用不用取决于你的场景。


公众号:蓝海资料掘金营,微信deep3321