Skip to main content

模式:Builder (Builder Pattern)

Builder 模式用於以靈活且可讀的方式建構具有許多參數的複雜物件。Builder 不需要預先提供所有參數,而是透過方法調用累積配置,並在調用 build() 時生成最終物件。這種模式在測試中特別有用,因為你經常需要建立物件的細微變體,同時保持大多數字元段為合理的預設值。

在已發布的程式碼中,Builder 模式可能會因為中間結構體 (structs) 和多個函式調用而引入額外的 Gas 成本。此模式最適合用於不用顧慮 Gas 考量的測試中,並且要求可讀性和可維護性。

定義 Builder

Builder 結構體映射目標物件的欄位,但將它們包裝在 Option 型別中。這允許每個欄位保持未設定狀態,直到明確配置。一個典型的 Builder 提供:

  • new() 函式,建立一個空的 builder
  • Setter 方法,配置個別欄位並回傳 builder 以進行鏈式調用
  • build() 函式,使用未設定欄位的預設值來建構最終物件
module book::user;

use std::string::String;

/// A user account with multiple properties.
public struct User has drop {
name: String,
age: u8,
email: String,
balance: u64,
is_active: bool,
}

/// Creates a new user - requires all fields.
public fun new(
name: String,
age: u8,
email: String,
balance: u64,
is_active: bool,
): User {
User { name, age, email, balance, is_active }
}

public fun balance(self: &User): u64 { self.balance }
public fun is_active(self: &User): bool { self.is_active }
public fun age(self: &User): u8 { self.age }

對應的 builder:

#[test_only]
module book::user_builder;

use book::user::{Self, User};
use std::string::String;

/// Builder for creating `User` instances in tests.
public struct UserBuilder has drop {
name: Option<String>,
age: Option<u8>,
email: Option<String>,
balance: Option<u64>,
is_active: Option<bool>,
}

/// Creates an empty builder with all fields unset.
public fun new(): UserBuilder {
UserBuilder {
name: option::none(),
age: option::none(),
email: option::none(),
balance: option::none(),
is_active: option::none(),
}
}

// === Setter methods - each returns the builder for chaining ===

public fun name(mut self: UserBuilder, name: String): UserBuilder {
self.name = option::some(name);
self
}

public fun age(mut self: UserBuilder, age: u8): UserBuilder {
self.age = option::some(age);
self
}

public fun email(mut self: UserBuilder, email: String): UserBuilder {
self.email = option::some(email);
self
}

public fun balance(mut self: UserBuilder, balance: u64): UserBuilder {
self.balance = option::some(balance);
self
}

public fun is_active(mut self: UserBuilder, is_active: bool): UserBuilder {
self.is_active = option::some(is_active);
self
}

/// Builds the `User`, using defaults for any unset fields.
public fun build(self: UserBuilder): User {
let UserBuilder { name, age, email, balance, is_active } = self;
user::new(
name.destroy_or!(b"Default User".to_string()),
age.destroy_or!(18),
email.destroy_or!(b"user@example.com".to_string()),
balance.destroy_or!(0),
is_active.destroy_or!(true),
)
}

在這裡,new() 函式將所有欄位初始化為 option::none(),代表「未配置」狀態。每個 setter 方法將提供的值包裝在 option::some() 中並將其存儲在相應的欄位中。該模式的關鍵在於 build() 函式,它使用 destroy_or! 巨集來解包每個 Option:如果欄位已配置,則使用其值;否則,該巨集會回傳作為第二個參數提供的預設值。這種方法讓測試只指定它們關心的欄位,同時確保最終物件始終完全初始化。

使用範例

如果沒有 Builder,每個測試都必須指定所有欄位,即使只有一個欄位與測試相關:

#[test]
fun test_balance_check_without_builder() {
// We only care about `balance`, but must specify everything
let user = user::new(
b"Alice".to_string(),
25,
b"alice@example.com".to_string(),
1000, // <-- the only field we care about
true,
);
assert!(user.balance() == 1000);
}

#[test]
fun test_inactive_user_without_builder() {
// We only care about `is_active`, but must specify everything
let user = user::new(
b"Bob".to_string(),
30,
b"bob@example.com".to_string(),
500,
false, // <-- the only field we care about
);
assert!(user.is_active() == false);
}

有了 Builder,測試變得專注且自我文檔化 (self-documenting):

#[test]
fun test_balance_check() {
// Only specify what matters for this test
let user = new()
.balance(1000)
.build();

assert!(user.balance() == 1000);
}

#[test]
fun test_inactive_user() {
// Only specify what matters for this test
let user = new()
.is_active(false)
.build();

assert!(user.is_active() == false);
}

#[test]
fun test_underage_user() {
// Testing age-related logic
let user = new()
.age(16)
.build();

assert!(user.age() < 18);
}

每個測試都清楚地顯示哪個欄位重要。向 User 添加新欄位只需要更新 builder 的 build() 函式並提供預設值 —— 現有的測試保持不變。

方法鏈 (Method Chaining)

流暢的 Builder 語法的關鍵是方法鏈。每個 setter 方法按值 (by value) 接收 mut self,修改它,並回傳修改後的 builder。這是一個非常常見的例子:

public fun is_active(mut self: UserBuilder, is_active: bool): UserBuilder {
self.is_active = option::some(is_active);
self
}

因為該方法取得了 self 的所有權並回傳 UserBuilder,你可以將多個調用鏈接在一起:

let user = user_builder::new()
.name("Alice")
.balance(1000)
.is_active(true)
.build();

鏈中的每個方法消耗前一個 builder 並回傳一個新的。最後的 build() 調用消耗 builder 並生成目標物件。

在系統套件中的使用

Sui Framework 和 Sui System 套件在測試中廣泛使用 builders。最顯著的例子是:

Sui System 中的 ValidatorBuilder

sui-system 套件中的 ValidatorBuilder 展示了一個用於具有許多欄位的複雜型別(加密密鑰、網路地址和經濟參數)的綜合 builder:

use sui_system::validator_builder;

#[test]
fun test_validator_operations() {
let validator = validator_builder::preset()
.name("My Validator")
.gas_price(1000)
.commission_rate(500) // 5%
.initial_stake(100_000_000)
.build(ctx);

// 測試驗證者操作...
}

preset() 函式回傳一個預填了有效測試預設值的 builder,因此測試只需要覆蓋它們關心的欄位。

Sui Framework 中的 TxContextBuilder

TxContextBuilder 允許為特定測試場景自定義交易上下文:

use sui::test_scenario as ts;

#[test]
fun test_epoch_dependent_logic() {
let mut test = ts::begin(@0x1);
let ctx = test
.ctx_builder()
.set_epoch(100)
.set_epoch_timestamp(1000000)
.build();

// 依賴於 epoch 的測試邏輯...

test.end();
}

總結

  • Builder 透過 setter 方法累積配置,並透過 build() 生成最終物件。
  • 使用 Option 欄位使配置可選,並在 build() 中提供合理的預設值。
  • 方法鏈 (fun method(mut self, ...): Self) 創建流暢的 API。
  • Builders 減少了測試樣板代碼 (boilerplate),並將測試與目標結構體的變更隔離開來。
  • 將此模式保留給測試工具,在這些工具中可讀性比 Gas 成本更重要。