Skip to main content

模式:建造者 (Pattern: Builder)

建造者模式用於以靈活且易讀的方式建構具有許多參數的複雜物件。建造者不需要預先提供所有參數,而是透過函式呼叫累積設定,並在呼叫 build() 時產生最終物件。此模式尤其適合測試,因為你經常需要建立僅有些微差異的物件,同時讓大多數欄位維持合理的預設值。

在已發布的原始碼中,建造者模式可能因中介 struct 與多次函式呼叫而增加額外的 gas 成本。此模式最適合 gas 考量無須顧慮、但需要可讀性與可維護性的測試。

定義建造者 (Defining a Builder)​

建造者 struct 會對應目標物件的欄位,但會將其包裝為 Option 型別。這讓每個欄位都能維持未設定狀態,直到明確完成設定。典型的建造者會提供:

  • 用於建立空白建造者的 new() 函式
  • 用於設定個別欄位並回傳建造者以便鏈結呼叫的 setter 方法
  • 使用未設定欄位的預設值建構最終物件的 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 }

對應的建造者:

#[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!("Default User"),
age.destroy_or!(18),
email.destroy_or!("user@example.com"),
balance.destroy_or!(0),
is_active.destroy_or!(true),
)
}

此處的 new() 函式會將所有欄位初始化為 option::none(),代表「未設定」狀態。每個 setter 方法都會將提供的值包裝為 option::some(),並儲存在對應欄位中。此模式的關鍵是 build() 函式,它使用 destroy_or! 巨集解包每個 Option:若欄位已設定,便使用其值;否則,巨集會回傳第二個引數提供的預設值。此方式讓測試只需指定所關心的欄位,同時確保最終物件一律會完整初始化。

使用範例 (Example Usage)​

未使用建造者時,每個測試都必須指定所有欄位,即使測試只與其中一個欄位有關:

#[test]
fun test_balance_check_without_builder() {
// We only care about `balance`, but must specify everything
let user = user::new(
"Alice",
25,
"alice@example.com",
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(
"Bob",
30,
"bob@example.com",
500,
false, // <-- the only field we care about
);
assert!(user.is_active() == false);
}

使用建造者後,測試會更聚焦且具自我說明性:

#[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 新增欄位,只需更新建造者的 build() 函式並加入預設值——既有測試無須變更。

方法鏈結 (Method Chaining)​

流暢建造者語法的關鍵是方法鏈結。每個 setter 方法會依值接收 mut self、修改它,並回傳修改後的建造者。以下是非常常見的範例:

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();

鏈結中的每個方法都會消耗前一個建造者,並回傳新的建造者。最終的 build() 呼叫會消耗建造者並產生目標物件。

在系統套件中的使用方式 (Usage in system packages)​

Sui Framework 與 Sui System 套件廣泛使用建造者進行測試。最顯著的範例如下:

Sui System 中的 ValidatorBuilder (ValidatorBuilder in Sui System)​

Sui System 套件中的 ValidatorBuilder 展示了適用於多欄位複雜型別的完整建造者——包含密碼學金鑰、網路地址與經濟參數:

use sui_system::validator_builder;

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

// 測試驗證器操作……
}

preset(index) 函式會針對數個預先定義驗證器中的其中一個,回傳已預先填入有效測試預設值——金鑰、地址與經濟參數——的建造者,因此測試只需覆寫所關心的欄位。

Sui Framework 中的 TxContextBuilder (TxContextBuilder in Sui Framework)​

TxContextBuilder 可讓你針對特定測試情境自訂交易 context。建造者會傳遞給 begin_with_context() 以啟動情境,或傳遞給 next_with_context() 以推進既有情境:

use sui::test_scenario as ts;

#[test]
fun test_epoch_dependent_logic() {
let mut test = ts::begin_with_context(
ts::ctx_builder_from_sender(@0x1)
.set_epoch(100)
.set_epoch_timestamp(1_000_000),
);

// 測試依賴 epoch 的邏輯……

test.end();
}

總結 (Summary)​

  • 建造者會透過 setter 方法累積設定,並透過 build() 產生最終物件。
  • 使用 Option 欄位讓設定成為選用項目,並在 build() 中提供合理的預設值。
  • 方法鏈結(fun method(mut self, ...): Self)可建立流暢 API。
  • 建造者可減少測試樣板程式碼,並將測試與目標 struct 的變更隔離。
  • 將此模式保留給可讀性比 gas 成本更重要的測試公用程式。