1. 程式人生 > 其它 >可拖拽樹形資料伺服器端的一種實現

可拖拽樹形資料伺服器端的一種實現

樹形結構的資料常用於部門管理等有層次結構的場景。目前對於樹形結構的UI操作已經有相當多的庫了,如ztreejsTree;一些出名的元件庫也都內建的相應的元件,如element-ui。但就目前關於樹拖拽操作的後臺邏輯實現卻少有人提及,特別是在像mysql這種關係型資料下的實現。正好公司前些日子需要這個功能,形成了一個實現方案,整理後放出。

觀前提示

  • 非Java警告:案例使用kotlin+springboot寫成
  • 非傳統ORM警告:ORM框架選用ktorm。別問,問就是開心就好

不瞭解kotlin基本語法可能看著會有點惱火,畢竟它的糖實在是太多了。查詢使用的語句比較簡單,不瞭解ktorm,應該影響不大。

案例程式碼:gitee

業務需求

部分新使用者一次性匯入大量的部門資訊,需要調整部門間層級關係。原有的編輯框操作繁瑣也不直觀,所以想能不能通過拖拽實現層級關係調整。

經過調查,使用者匯入的部門數量最大在四位數的量級,部門層級最多在5級之內。由於有許可權的設定,所以也有查詢某部門下所有子部門的需求。

總的來說,拖拽功能是較低頻的操作,而查詢某部門下所有子部門是高頻操作。因此功能設計的總體目標也就出來:拖拽可以慢點,查詢需要快

資料庫設計

在關係型資料儲存樹形結構通常考慮四種方法:

  • 鄰接表(Adjacency List):儲存pId值
  • 路徑列舉(Path Enumerations):記錄此節點經過的路徑
  • 巢狀表(Nested Sets):比較麻煩,記錄左值、右值,計算方式有點麻煩
  • 閉包表(Closure Table):用另一張表輔助記錄

此次採用前兩方式混合。即

CREATE TABLE `tree_node`
(
    `id`          int unsigned NOT NULL AUTO_INCREMENT,
    `create_time` datetime     DEFAULT NULL,
    `name`        varchar(60) NOT NULL,
    `node_path`   varchar(500) DEFAULT NULL,
    `p_id`        int unsigned DEFAULT NULL,
    PRIMARY KEY (`id`)
) ENGINE=InnoDB

p_id記錄此節點的上級節點的id值。如果沒有上級節點則為空。
node_path記錄所有上級節點id值,用 ,分隔,如果沒有上級節點則為空。

關於根節點

在寫此案例時,為了簡便,將根節點設定為虛擬的。即在程式碼設定一個id為null為根節點,資料庫中所有p_id為空的節點都此虛擬節點的子節點。
如果不希望出虛擬節點則考慮新增一個識別符號,做某些運算注意避開就行。

功能實現

此次功能重點在拖拽和查詢,其他新增、編輯之類操作就略去。

查詢

加入node_path欄位就是為了提高查詢效率的。根據我們的設定,查詢任意節點下所有子節點僅需要一次查詢,其虛擬碼如下:

`node_path` like CONCAT(此節點的`node_path`,',',此節點的id,'%')

當然大部分情況下還需要包含當前指定節點資訊,所以還需要再根據id查詢一次。對於我們設定有虛擬節點的情況,還需要再加一個if分支,具體可見TreeNodeService#findSubTree方法。

組裝樹形結構

僅僅查詢扁平化的資料是不夠的,我們還需要將資料組裝成前端可用的資料。
對於我們這個規模的資料組裝倒不難。此處用空間換時間,用兩個迴圈和一個map來實現。大體思路為:

  1. 將所有資料先遍歷到map中去,以節點id為k,節點為value
  2. 再次遍歷所有節點,並從map中找到它的上級節點,將此節點加入到它上級的childList集合中
  3. 最後從map中取出根節點。(這裡稍微有點奇怪,只能從map中取根節點,在外面根節點是沒有值,不知道是不是kotlin函式僅支援值傳遞的原因。)

具體可見NodePathUtil#assembleTreeData方法。

拖拽

這裡是最麻煩。由於拖拽是在頁面操作完成,所以需要先了解常用拖拽元件的實現。考察過ztree、iview等元件的拖拽功能後,可以總結出這些元件在拖拽時提供資訊,將歸納總結如下:

  1. sourceNode:被拖動的節點
  2. targetNode:拖動到目標節點
  3. direction:相對方向。一般會有三個,如innerprevnext表示到目標節點的內部,前面,後面。不同元件下用的單詞不一樣。但需要注意這僅表示相對方向,即從第一層級拖到第十層級也只會有這三個方向。所以前端元件沒辦法很好表示跨層級移動。

經過上面的分析,我們得出拖拽動作需要最少資料如下:

data class TreeNodeMoveVO(
    val sourceId: Int,
    val sourcePid: Int?, // 被移動後新的pid
    val targetId: Int?,
    val direction: MoveDirection, // 移動方向列舉
)

正如前面的分析,我們對於節點移動具體資訊是不清楚的,沒辦法知道此節點是從哪一級移到哪一級的(事實上就算知道了對實現也沒太大幫助)。大體思路:

  1. 載入所有節點資訊
  2. 找出被移動的節點(sourceNode),並給它設定新pId
  3. 根據sourceNode新的pId遍歷出新的node_path,並賦值回去
  4. 對除sourceNode的節點進行遍歷,計算出新的node_path;把有變化的節點的加入待更新列表
  5. 更新sourceNode的值和待更新列表中節點資訊

具體程式碼見TreeNodeService#moveNode方法。

說明:

  1. 為什麼要根據pId遍歷出新的node_path?

node_path是以字串的形式儲存的,當中間層級節點被移動時,它的所有子節點都需要進行node_path以確保子樹查詢的正常性。正如之前分析,無法很好地獲取節點層級移動資訊,所以對node_path更新無論是採用是字串切分或是正則都會出錯。所以才使用此方法。如果有興趣可以採用其他方法試試。

  1. 為什麼要所有節點再進行遍歷呢?

正如上問題的原因一樣,這是為了更新sourceNode它的子節點node_path。雖然可以通過查詢直接獲取它的子節點,這裡的處理就看個人需求了。

備註

測試包內TreeNodeDataInit#initData可快速生成一千條三個層次的資料。

尾巴:我可能寫得不對,但不想抬槓玩。