iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0
Software Development

一套真實運作中的 Laravel 系統,拆解它的原生機制系列 第 4

Day 04:一個 Model 身兼多職——Page 怎麼用 Nested Set 疊動態型別撐起整個頁面樹

  • 分享至 

  • xImage
  •  

前言:一個 Model 可以同時做幾件事?

「一個 Model 對應一張資料表,這不是很單純的事嗎?」

大部分教學範例裡的 Model 確實單純——一個欄位對應一個屬性,一個關聯對應一個方法。但真實系統裡,有些 Model 天生就要身兼多職,因為它要處理的資料本身就不單純。今天要看的 Page Model 就是一個好例子:它同時是一棵樹(頁面可以有子頁面)、也是一個依欄位值決定行為的多型物件(同一個 Model,依 template 欄位的不同,會表現出完全不同的行為)。

今日目標

  • 認識 Laravel 生態圈怎麼用 NodeTrait 實作階層樹狀結構(nested set)
  • 理解一個 Model 怎麼用一個欄位動態決定自己的「型別」,進而分支出不同行為
  • urltarget 兩個 accessor 怎麼依型別給出完全不同的答案
  • 認識自訂 resolveRouteBinding() 怎麼讓路由直接用 slug 查到頁面

本文主體

用 Nested Set 管理頁面樹

這個系統的「單頁內容」用階層樹狀結構管理——一個頁面底下可以掛子頁面,子頁面底下還可以再掛孫頁面。實作這種樹狀結構,Page Model 用了 kalnoy/nestedset 套件提供的 NodeTrait

class Page extends Model implements AuditableContract
{
    use Auditable;
    use HasFactory;
    use NodeTrait;
    // ...
}

NodeTrait 底層用的是 nested set 演算法(每個節點記錄左右邊界值),換來的好處是查詢一個節點的所有祖先或所有子孫,不需要遞迴查詢,一次 SQL 就能拿到整條路徑。Page 進一步疊了兩個組合查詢:

public function publishedAncestors(): AncestorsRelation
{
    return $this->ancestors()
        ->tap(new Alias)
        ->tap(new Published)
        ->defaultOrder();
}

public function publishedChildren(): HasMany
{
    return $this->children()
        ->tap(new Alias)
        ->tap(new Published)
        ->defaultOrder();
}

ancestors()children()NodeTrait 提供的基礎關聯方法,publishedAncestors()publishedChildren() 再疊上「只要已發佈的」「處理別名頁面」這兩層業務邏輯的 scope(明天會細講 scope 這個設計)。這代表 nested set 提供的是「怎麼查出樹狀結構」這個底層能力,實際業務要的「只顯示已發佈的祖先」則是疊加在它之上,兩層責任分得很清楚。

用一個欄位動態決定型別

Page 有一個 template 欄位,存的其實是後台一個類別的完整類別名稱字串。透過一個 backed enum(明天後天會展開講這個技巧),這個欄位被轉換成一個型別:

protected function type(): Attribute
{
    return Attribute::get(fn () => PageType::tryFrom($this->template));
}

這個 type 不是資料庫的欄位,是從 template 動態算出來的衍生屬性。有了這個型別,Page 就能依型別分支出完全不同的行為——同一個 url() accessor,依 type 的不同,給出完全不一樣的答案:

protected function url(): Attribute
{
    return Attribute::get(function () {
        if ($this->type === PageType::ALIAS) {
            return $this->alias?->url;
        }

        if ($this->type === PageType::LINK) {
            return $this->content['url'];
        }

        if (! $this->slug) {
            return '#';
        }

        $domain = $this->slug !== 'home' && $this->type === PageType::NEWS ? News::domain() : self::domain();

        return route('page', ['domain' => $domain, $this->slug === 'home' ? '/' : $this->slug]);
    });
}

一個「別名」頁面的網址,是它指向的目標頁面的網址;一個「連結」型頁面的網址,是存在 content JSON 裡的一個外部連結;一個一般頁面,才走正常的路由組出網址。target() accessor 也是同樣的分支邏輯,只有「連結」型頁面才可能需要在新分頁開啟。

❌ 用一堆 if/else 判斷資料庫欄位的字串值

if ($page->template === 'App\\Filament\\PageTemplates\\LinkTemplate') {
    // ...
}

裸字串比對容易打錯字、換類別名稱時不會被 IDE 抓到,也完全看不出這個欄位總共有哪些合法值。

✅ 先轉成型別,再依型別分支

$this->type === PageType::LINK

轉成 enum 之後,合法值一目了然,打錯字會在編譯期直接報錯,IDE 也能自動完成——這個轉換技巧本身,明天會拆開來細講。

自訂路由綁定:用 slug 直接查頁面

Page 還覆寫了 resolveRouteBinding(),讓路由參數可以直接用 slug 查到對應的頁面,而不是預設的用主鍵 ID:

public function resolveRouteBinding($value, $field = null): Model|bool
{
    return $value
        ? $this->withDepth()->where('slug', $value)->firstOrFail()
        : false;
}

這是 Laravel 路由模型綁定(route model binding)的擴充點——只要覆寫這個方法,路由參數就能自動用你想要的欄位去查資料,不用在每個 Controller 裡手動 Page::where('slug', $slug)->firstOrFail()

內容渲染:一個頁面的內容不是單純的一段文字

Page 的內容存成 JSON block 陣列,renderPage() 依每個 block 的型別(paragraph-rich-editorparagraph-markdownimage)分別處理成 HTML:

public function renderPage(): string
{
    $converter = app(ConverterInterface::class);

    return ! $this->content ? '' : collect($this->content['content'])
        ->map(function (array $block) use ($converter) {
            if ($block['type'] === 'paragraph-rich-editor') {
                return (string) $block['data']['content'];
            }

            if ($block['type'] === 'paragraph-markdown') {
                return (string) $converter->convert($block['data']['content'] ?? '');
            }

            if ($block['type'] === 'image') {
                return $this->renderBlockImage($block['data']);
            }

            return Arr::get($block, 'data.content', '');
        })->implode("\n");
}

一個頁面其實是由多個「區塊」組成的,各自可能是富文字、Markdown,或圖片,renderPage() 負責把這些異質的區塊組成最終要輸出的 HTML。

今日思考題

你的專案裡有沒有一個 Model,同時扛了「資料結構」跟「行為分派」兩種責任?這種設計方便還是複雜,取決於什麼條件?

今日重點回顧

  • Pagekalnoy/nestedsetNodeTrait 實作階層樹狀結構,一次查詢就能拿到整條祖先/子孫路徑
  • 樹狀結構的基礎關聯(ancestorschildren)之上,疊加了業務層的 scope(只顯示已發佈的)
  • template 欄位透過 backed enum 動態算出 typeurltarget accessor 依 type 分支出完全不同的行為
  • 自訂 resolveRouteBinding() 讓路由可以直接用 slug 查到頁面,不用手動查詢
  • 頁面內容存成多種區塊型別的 JSON,renderPage() 依區塊型別分別渲染

明日預告

明天要看查詢邏輯怎麼從裸寫的 where 條件,收斂成一個個可以組合、可以重複使用的 Query Scope class。


上一篇
Day 03:Model 嚴格模式抓出的一個真實 N+1 與一個藏了很久的 typo
系列文
一套真實運作中的 Laravel 系統,拆解它的原生機制4
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言