iT邦幫忙

0

別再手寫 PHP DTO constructor:打造不可變、型別安全的值物件

  • 分享至 

  • xImage
  •  

我為什麼做這個

PHP 8.1 有了 readonly,8.4 又補上 property hooks,不可變物件的條件其實早就到位了,但實務上每天寫資料物件,還是會一直撞到同一批小摩擦。

我想解決的

  1. 每個 DTO 都要手寫 constructor,欄位一多,參數順序就是地雷,new Order($date, $time),字串順序寫反會導致難以發現錯誤。
  2. readonly 很好,但想改欄位、建構新物件時得整包重建
  3. 巢狀資料想更新最內層,結果其他元素全噴掉with(['items' => [['count' => 1]]]) 會讓原本 items 裡的其他東西丟失。
  4. 驗證邏輯散在各個 constructor 裡,每個類別各寫各的,沒有統一入口。
  5. 想把 null 欄位從輸出拿掉,每次都得手動 array_filter 清一遍。

每一條單獨看都不是大事,但把它們放在一起,會發現背後其實是同一件我想守住的事:一個物件只要存在,就必然是合法的,不存在「建好了卻處在非法狀態」這種可能。
這個立場在型別驅動設計裡有不少名字:always-valid domain model、make illegal states unrepresentable、parse, don't validate,講的都是同一件事,本質上都是 PHP 沒有現成工具可以乾淨地守住它。

所以我做了 ImmutableBase,一個零依賴、不綁任何框架的 PHP 套件,嚴守「存在即合法」原則建構不可變資料物件,涵蓋 Data Transfer Object(DTO)、Value Object(VO)、Single Value Object(SVO)三種,選擇支援 PHP 8.4+ 是因為 readonly 加上 property hooks 剛好讓「不可變 + 建構時驗證 + 型別收斂」這三件事能乾淨地湊在一起,這是刻意的選擇。

安裝

composer require reallifekip/immutable-base

需要 PHP 8.4+

無 constructor 的自動建構

直接丟 array 或 JSON 進去建構,輸入欄位的順序無所謂:

// ImmutableBase:不用 boilerplate constructor
readonly class Order extends DataTransferObject
{
    public string $date;
    public string $time;
}

Order::fromArray($data);  // 傳 array
Order::fromJson($json);   // 傳 JSON 字串

傳統寫法得自己寫 constructor,而且沒辦法直接吃外部 array 或 JSON,參數沒寫名稱還有寫反的風險:

class Order
{
    public function __construct(
        public readonly string $date,
        public readonly string $time,
    ) {}
}

new Order('2026-01-01', '00:00:00');  // 順序錯了型別也抓不到

DTO / VO / SVO 到底差在哪

這段我覺得值得單獨拉出來講,因為很多人卡在這三個的界線上,它們的差別不在「長得像不像」,在建構時要不要驗證、以及包的是結構還是單一值

  • DTO:純資料載體,負責傳輸與交換,就算定義了 validate(),建構時也不會被呼叫。
  • VO:有語義的資料結構,建構時會自動validate()
  • SVO:我用了一些小技巧從語言層面強制要求明確定義並實作 $value,把「單一一個值」包起來、賦予語義,例如用 ValidAge 包一個 int,建構時一樣自動驗證, validate()from()__toString()__invoke() 全都只對唯一的 $value 運作。
// SVO:包一個 int,並賦予「合法年齡」的語義
readonly class ValidAge extends SingleValueObject
{
    public int $value;

    public function validate(): bool
    {
        return $this->value >= 18;
    }
}

// VO:有結構、有語義,建構時自動驗證
readonly class User extends ValueObject
{
    public string $name;
    public ValidAge $age;

    public function validate(): bool
    {
        return mb_strlen($this->name) >= 2;
    }
}

// DTO:純載體,不驗證
readonly class SignUpUsersDTO extends DataTransferObject
{
    #[ArrayOf(User::class)]
    public array $users;
    public int $userCount;
}

SignUpUsersDTO 裡的 users#[ArrayOf(User::class)] 標記成「一個裝 User 的陣列」,建構時每個元素都會自動實例化或驗證,array 與 JSON 字串混著傳都吃得下。

宣告式預設值,而不是到處 null 合併

補預設值有兩種機制,#[Defaults] 寫在屬性上、吃常數運算式;defaultValues() 是動態的、可回傳物件或 Enum,優先級比 #[Defaults] 高:

readonly class CreateUserDTO extends DataTransferObject
{
    public string $name;
    #[Defaults('member')]
    public string $role;

    public static function defaultValues(): array
    {
        return ['role' => 'admin'];  // 優先於 #[Defaults]
    }
}

CreateUserDTO::fromArray(['name' => 'Kip']);  // role = 'admin'

有一個我特別在意的細節:「沒給」跟「明確給 null」是兩件事。 key 不存在才會套預設值;明確傳了 null,那就尊重 null

Config::fromArray([]);                 // theme = 'dark'(key 缺 → 套預設)
Config::fromArray(['theme' => null]);  // theme = null(明確 null → 尊重)

深層路徑更新,告別疊俄羅斯娃娃

想更新巢狀結構最裡面那一格,用 dot path 直接點進去,其他元素原封不動:

$order->with(['items.0.count' => 1]);   // dot notation
$order->with(['items[0].count' => 1]);  // bracket notation 也行

with() 一律回傳新的物件,原物件永遠不動,這是 immutable 的本分。

疊加組合,自動驗證鏈

VO 跟 SVO 的 validate() 是可選的,建構時整條繼承鏈會由上而下自動走一遍;想反過來從自己往上走,掛 #[ValidateFromSelf],驗證失敗想帶自訂訊息,用 #[Spec]

// 父類:所有 User 名字都必須符合長度規範
readonly class User extends ValueObject
{
    public string $name;
    public int $level;

    public function validate(): bool
    {
        return mb_strlen($this->name) >= 2;
    }
}

// 子類:額外要求等級必須大於等於 2
// 不用重寫父類規則,規則自動疊加並生效
readonly class VIP extends User
{
    public function validate(): bool
    {
        return $this->level >= 2;
    }
}

// 建構 VIP 時,User::validate() 與 VIP::validate() 會疊加,必須通過兩條驗證規則

每層只負責自己的約束,父類規則不用在子類重寫一遍,整條鏈合起來才是這個物件完整的合法性定義,這才是真正解掉『驗證散落』的關鍵,讓規則各歸各層、自動合成。

為什麼這樣設計?在我看來,繼承概念就該和現實世界一樣,以代碼範例為例說明,VIP是會員的一種,那麼理所當然就必須同樣遵守會員的建構規則

像 TypeScript 的型別限縮

SVO 強制必須宣告 $value但型別自己定,父類別不會把型別鎖死成 mixed 或一大包 union,寫 int 就是 int,語義對齊,這是用 interface + hooked property 做到的,沒有 reflection 開銷:

readonly class ValidAge extends SingleValueObject
{
    public int $value;  // 語義正確的型別
}

精準控制 null 輸出

#[SkipOnNull] 把 null 欄位從 toArray() / toJson() 拿掉,再用 #[KeepOnNull] 針對單一屬性反悔保留,不用每次手動過濾:

#[SkipOnNull]
readonly class User extends ValueObject
{
    #[KeepOnNull]
    public ?string $name;  // null 也保留
    public ?int $age;      // null 就不輸出
}

User::fromArray([])->toArray();  // ['name' => null]

冷啟動效能

預設靠 reflection 掃描屬性 metadata,這會導致每個 request 都付一次沉重的成本,然而執行 vendor/bin/ib-cacher 產出 ib-cache.php 後將改為啟動直接讀快取,跳過反射掃描:

vendor/bin/ib-cacher

文件即程式碼

vendor/bin/ib-writer 會掃過專案裡所有 ImmutableBase 子類別,生出 Mermaid 類別圖、Markdown 屬性表、跟 TypeScript 宣告,讓文件跟著程式碼走、不會對不上。

清晰直觀的錯誤

巢狀建構出錯時,例外訊息會帶完整的屬性路徑,而不是丟下一句模糊的話讓你自己找,例外示意:

SomeException: Order > $profile > 0 > $count > {錯誤訊息}

生命週期

immutable-base-life-cycle

小結

ImmutableBase 想解決的,就是 PHP 寫不可變資料物件時那一整批反覆出現的小摩擦:不用手寫 constructor、宣告式預設值、深層路徑更新、自動驗證鏈、型別收斂、輸出控制,但說到底這些功能都是為了同一件事,讓一個物件只要存在就一定合法

不管是 ImmutableBase 的使用回饋、PHP / Domain-Driven Design / Clean Architecture 的設計取捨,還是不同語言對不可變的設計哲學、不同架構對值物件的應用與落地手法,也都很歡迎交流

相關連結


圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言