可拖拽樹形資料伺服器端的一種實現
樹形結構的資料常用於部門管理等有層次結構的場景。目前對於樹形結構的UI操作已經有相當多的庫了,如ztree、jsTree;一些出名的元件庫也都內建的相應的元件,如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來實現。大體思路為:
- 將所有資料先遍歷到map中去,以節點id為k,節點為value
- 再次遍歷所有節點,並從map中找到它的上級節點,將此節點加入到它上級的
childList
集合中 - 最後從map中取出根節點。(這裡稍微有點奇怪,只能從map中取根節點,在外面根節點是沒有值,不知道是不是kotlin函式僅支援值傳遞的原因。)
具體可見NodePathUtil#assembleTreeData
方法。
拖拽
這裡是最麻煩。由於拖拽是在頁面操作完成,所以需要先了解常用拖拽元件的實現。考察過ztree、iview等元件的拖拽功能後,可以總結出這些元件在拖拽時提供資訊,將歸納總結如下:
- sourceNode:被拖動的節點
- targetNode:拖動到目標節點
- direction:相對方向。一般會有三個,如
inner
、prev
、next
表示到目標節點的內部,前面,後面。不同元件下用的單詞不一樣。但需要注意這僅表示相對方向,即從第一層級拖到第十層級也只會有這三個方向。所以前端元件沒辦法很好表示跨層級移動。
經過上面的分析,我們得出拖拽動作需要最少資料如下:
data class TreeNodeMoveVO(
val sourceId: Int,
val sourcePid: Int?, // 被移動後新的pid
val targetId: Int?,
val direction: MoveDirection, // 移動方向列舉
)
正如前面的分析,我們對於節點移動具體資訊是不清楚的,沒辦法知道此節點是從哪一級移到哪一級的(事實上就算知道了對實現也沒太大幫助)。大體思路:
- 載入所有節點資訊
- 找出被移動的節點(sourceNode),並給它設定新pId
- 根據sourceNode新的pId遍歷出新的node_path,並賦值回去
- 對除sourceNode的節點進行遍歷,計算出新的node_path;把有變化的節點的加入待更新列表
- 更新sourceNode的值和待更新列表中節點資訊
具體程式碼見TreeNodeService#moveNode
方法。
說明:
- 為什麼要根據pId遍歷出新的node_path?
node_path是以字串的形式儲存的,當中間層級節點被移動時,它的所有子節點都需要進行node_path以確保子樹查詢的正常性。正如之前分析,無法很好地獲取節點層級移動資訊,所以對node_path更新無論是採用是字串切分或是正則都會出錯。所以才使用此方法。如果有興趣可以採用其他方法試試。
- 為什麼要所有節點再進行遍歷呢?
正如上問題的原因一樣,這是為了更新sourceNode它的子節點node_path。雖然可以通過查詢直接獲取它的子節點,這裡的處理就看個人需求了。
備註
測試包內TreeNodeDataInit#initData
可快速生成一千條三個層次的資料。