首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> 網頁程式設計 >> PHP 程式設計 >> PHP 綜合知識

ThinkPHP 左右值無限分類完整實作:核心方法解析與最佳實務

     編輯:PHP 綜合知識

前言

ThinkPHP 框架下實作巢狀集合模型,封裝常用操作為模型方法,提供獲取父節點、子節點、路徑、新增、刪除、修改等完整功能。本文解析核心邏輯並補充生產環境優化建議。

模型類別結構

<?php
namespace app\common\model;

use think\Model;

class Category extends Model {
    protected $table = 'category';
    protected $pk = 'id';

    // 左右值欄位名稱常數
    protected const LEFT_FIELD = 'lft';
    protected const RIGHT_FIELD = 'rgt';

    // 取得欄位名 (支援自訂)
    protected function leftField(): string { return self::LEFT_FIELD; }
    protected function rightField(): string { return self::RIGHT_FIELD; }
    protected function idField(): string { return $this->pk; }
}
?>

核心查詢方法解析

1. 根據 ID 獲取節點

<?php
public function getNodeById(int $nodeId): ?array {
    if ($nodeId <= 0) {
        throw new \InvalidArgumentException('Invalid node ID');
    }
    return $this->find($nodeId); // 快取查詢
}
?>

2. 獲取父節點 (直系/所有祖先)

<?php
/**
 * @param int $nodeId
 * @param int $type 0=所有祖先, 1=直系父節點
 * @return array|false
 */
public function getParentNode(int $nodeId, int $type = 0) {
    $node = $this->getNodeById($nodeId);
    if (!$node) return false;

    $left = $this->leftField();
    $right = $this->rightField();

    // 祖先條件:祖先.lft < 當前.lft AND 祖先.rgt > 當前.rgt
    $condition = [
        [$left, '<', $node[$left]],
        [$right, '>', $node[$right]],
    ];

    if ($type === 1) {
        // 直系父節點:lft 最大者 (最接近當前節點)
        return $this->where($condition)
            ->order($left, 'DESC')
            ->limit(1)
            ->find();
    }

    // 所有祖先:按 lft 升序 (根到父)
    return $this->where($condition)
        ->order($left, 'ASC')
        ->select();
}
?>

3. 計算子孫節點總數

<?php
public function getChildCount(int $nodeId): int {
    $node = $this->getNodeById($nodeId);
    if (!$node) return 0;

    $left = $this->leftField();
    $right = $this->rightField();
    // 公式:(rgt - lft - 1) / 2
    return (int)(($node[$right] - $node[$left] - 1) / 2);
}
?>

4. 獲取子節點 (所有/直系)

<?php
/**
 * @param int $nodeId
 * @param int $type 0=所有後代, 1=直系子節點
 * @return array
 */
public function getChild(int $nodeId, int $type = 0): array {
    $node = $this->getNodeById($nodeId);
    if (!$node) return [];

    $left = $this->leftField();
    $right = $this->rightField();

    // 葉子節點判斷
    if ($node[$right] - $node[$left] === 1) {
        return [];
    }

    // 所有後代條件
    $condition = [
        [$left, '>', $node[$left]],
        [$right, '<', $node[$right]],
    ];

    if ($type === 0) {
        return $this->where($condition)
            ->order($left, 'ASC')
            ->select();
    }

    // 直系子節點邏輯:lft = 父.lft + 1 為第一個子節點
    // 同級節點特徵:前一個節點的 rgt + 1 = 當前節點的 lft
    $allChildren = $this->where($condition)
        ->order($left, 'ASC')
        ->select();

    $directChildren = [];
    $expectedLeft = $node[$left] + 1;

    foreach ($allChildren as $child) {
        if ($child[$left] === $expectedLeft) {
            $directChildren[] = $child;
            $expectedLeft = $child[$right] + 1; // 下一個同級節點的 lft
        }
    }

    return $directChildren;
}
?>

5. 獲取完整路徑 (含自己)

<?php
public function getSinglePath(int $nodeId): array {
    $left = $this->leftField();
    $right = $this->rightField();
    $idField = $this->idField();

    // 使用關聯查詢:節點的 lft/rgt 在祖先的區間內
    $sql = "
        SELECT parent.* 
        FROM `{$this->table}` AS node
        JOIN `{$this->table}` AS parent 
            ON node.{$left} BETWEEN parent.{$left} AND parent.{$right}
        WHERE node.{$idField} = ?
        ORDER BY parent.{$left} ASC
    ";

    return $this->query($sql, [$nodeId]);
}
?>

寫入操作:新增、刪除、修改

新增節點 (三種模式)

<?php
/**
 * @param int $parentId 父節點 ID
 * @param array $data 新節點資料 (不含 lft/rgt)
 * @param int $type 0=最後子節點, 1=第一個子節點, 2=指定兄弟節點之後
 * @param int $targetId type=2 時的目標兄弟節點 ID
 * @return int|false 新節點 ID
 */
public function addNode(int $parentId, array $data, int $type = 0, int $targetId = 0) {
    if (empty($data)) throw new \InvalidArgumentException('Node data empty');

    $parent = $this->getNodeById($parentId);
    if (!$parent) throw new \Exception('Parent not found');

    $left = $this->leftField();
    $right = $this->rightField();

    // 計算新節點位置
    switch ($type) {
        case 0: // 最後子節點:父節點 rgt 之前
            $newLeft = $parent[$right];
            break;
        case 1: // 第一個子節點:父節點 lft + 1
            $newLeft = $parent[$left] + 1;
            break;
        case 2: // 指定兄弟之後:目標節點 rgt + 1
            $target = $this->getNodeById($targetId);
            if (!$target) throw new \Exception('Target sibling not found');
            $newLeft = $target[$right] + 1;
            break;
        default:
            throw new \InvalidArgumentException('Invalid type');
    }
    $newRight = $newLeft + 1;

    return $this->transaction(function () use ($newLeft, $newRight, $data, $left, $right) {
        // 1. 平移右側節點騰出空間
        $this->where($right, '>=', $newLeft)->setInc($right, 2);
        $this->where($left, '>', $newLeft)->setInc($left, 2);

        // 2. 插入新節點
        $data[$left] = $newLeft;
        $data[$right] = $newRight;
        $this->insert($data);
        return $this->getLastInsID();
    });
}
?>

刪除節點

<?php
/**
 * @param int $nodeId
 * @param int $type 1=刪除含自己, 0=僅刪除後代
 * @return bool
 */
public function rmNode(int $nodeId, int $type = 1): bool {
    $node = $this->getNodeById($nodeId);
    if (!$node) return false;

    $left = $this->leftField();
    $right = $this->rightField();

    return $this->transaction(function () use ($node, $type, $left, $right) {
        $nodeLeft = $node[$left];
        $nodeRight = $node[$right];
        $width = $nodeRight - $nodeLeft + 1;

        if ($type === 1) {
            // 刪除含自己:區間 [lft, rgt]
            $this->where($left, '>=', $nodeLeft)
                ->where($right, '<=', $nodeRight)
                ->delete();
            $shiftStart = $nodeRight; // 平移起點
        } else {
            // 僅刪除後代:區間 (lft, rgt)
            $this->where($left, '>', $nodeLeft)
                ->where($right, '<', $nodeRight)
                ->delete();
            $width -= 2; // 不含自己佔用的 2
            $shiftStart = $nodeRight; // 同樣從 rgt 之後平移
        }

        if ($width > 0) {
            // 平移右側節點填補
            $this->where($right, '>', $shiftStart)->setDec($right, $width);
            $this->where($left, '>', $shiftStart)->setDec($left, $width);
        }

        return true;
    });
}
?>

修改節點 (僅更新業務欄位,不動 lft/rgt)

<?php
public function modiNode(array $data): bool {
    if (empty($data) || empty($data[$this->pk])) {
        throw new \InvalidArgumentException('Invalid data or missing ID');
    }

    $id = $data[$this->pk];
    unset($data[$this->pk]);
    // 禁止修改 lft/rgt
    unset($data[$this->leftField()], $data[$this->rightField()]);

    return $this->where($this->pk, $id)->update($data) !== false;
}
?>

生產環境優化建議

1. 使用資料庫觸發器自動維護 lft/rgt

將平移邏輯移至資料庫 `BEFORE INSERT/UPDATE/DELETE` 觸發器,應用層只需插入/刪除節點,減少應用代碼複雜度與事務範圍。

2. 讀取操作加入快取

<?php
public function getSubtreeCached(int $nodeId): array {
    $cacheKey = "category:subtree:{$nodeId}";
    return think\facade\Cache::get($cacheKey, function () use ($nodeId) {
        return $this->getChild($nodeId, 0); // 所有後代
    }, 3600); // 快取 1 小時
}
?>

3. 大量寫入時鎖定整張表或使用應用層鎖

<?php
// 使用 Redis 分散式鎖
$lock = \think\facade\Cache::store('redis')->lock("category:write:{$parentId}", 10);
if ($lock->acquire()) {
    try {
        $this->addNode($parentId, $data, $type, $targetId);
    } finally {
        $lock->release();
    }
}
?>

4. 避免在循環中單獨調用寫入方法

批次匯入時,先收集所有操作,排序後在單一事務中執行,或使用「先插入臨時表,再重建整棵樹」策略。

總結

ThinkPHP 的 ORM 搭配巢狀集合模型,能以優雅的鏈式呼叫完成複雜樹操作。核心在於理解 區間包含關係 與 平移算法。生產環境務必加入 事務保護、快取層 與 併發控制,確保資料一致性與系統穩定性。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved