樹狀結構的業務
今天咱們要討論的樹,它不是現實結構的樹,也不是數據結構要討論的樹,而是
從業務視角抽象出來的樹形結構。樹形結構可以用在很多的業務上,比如組織結構中的上下級關係、商品分類管理、文件系統、後臺系統中的頁面和組件關係等等。
下面請繫好安全帶,我們將從數據庫設計、設計模式、前端組件三個方面來介紹關於樹狀結構設計的方方面面,助你通關全棧樹狀結構!
數據庫設計
樹狀結構最簡單最常用的方法其實是直接存儲在JSON裡面。現在有很多主流的NoSQL庫,比如MongoDB等,而且也有很多關係型數據庫也開始支持JSON存儲,比如MySQL。
使用JSON的好處是,維護整棵樹比較方便,直接整存整取就好了,不用去管中間是怎麼修改的,怎麼映射到數據庫的。但缺點是不太高效,比如想要編輯某個葉子節點,查詢和更新都沒有純關係型數據那麼方便。所以如果你的業務足夠簡單,數據量也很小,可以使用JSON。否則,還是推薦使用關係型數據來實現。
那如何在關係型數據應該如何設計,才能高效地存儲和操作樹形結構呢?我們用下圖來作為例子:
ps:這裡假設有多棵樹,根節點是亞洲、美洲等
我們首先想到的是用parent_id, 這個字段用來存儲“父節點”,根節點的parent_id為0,這樣就可以通過遞歸查詢得到一棵樹。
很明顯,如果只是一個parent_id,我們如果想獲得一棵樹,當這棵樹的深度比較深時,我們需要查詢很多次數據庫,效率非常低。那有沒有什麼辦法可以一次性把整棵樹都查出來呢?我們嘗試加一個root_id,用來表示這棵樹的根節點。
idparent_idroot_idname100亞洲200美洲311中國411日本511韓國631四川省
那麼問題來了,如果是想查詢某一個節點的子樹,該怎麼做呢?是不是覺得不太方便?下面我們推薦另外一種表示方式:full id path,每個節點記錄下從根節點到自己的id路徑,如下:
idfull_id_pathname1/1亞洲2/2美洲3/1/3中國4/1/4日本5/1/5韓國6/1/3/6四川省7/1/3/6/7成都市
這樣如果我們想查詢一棵樹,只需要使用like語句前綴匹配就行了。比如想查詢“四川省”下面有哪些市:
<code>SELECT * FROM table WHERE full_id_path like "/1/3/6%";/<code>
如果是更新了一棵樹中節點的關係,只需要維護好這個節點及其子節點的full_id_path字段就行了。一般來講,這種full id path設計能夠滿足絕大多數樹形結構的業務要求。
但如果你的id是UUID類型的,如果深度比較高,那full_id_path字段就會比較長,且並不易讀。這個時候我們建議使用一個唯一的,有業務意義的code來表示路徑,字段名改為叫full_path。比如要表示成都市:
<code>'/Asian/China/SiChuan/Chengdu'/<code>
數據庫的設計還是要根據業務來,沒有絕對的銀彈。有時候,我們會加上level字段表示每個節點在樹中的層級位置,用於在應用代碼層面更方便、高效地拼接樹。
設計模式 - 組合模式
接下來我們介紹在代碼層面,如何去優雅地使用樹形結構。其實前輩們已經為我們總結出了一種非常優秀的設計模式——組合模式,又稱為“部分整體模式”,專門針對樹形結構。
組合模式的精髓在於,你不用把一棵樹“樹”的概念和一個單獨的“節點”分開處理,而是都視為同一種對象來處理。下面我們依然以上面的區域關係為例,來介紹組合模式如何使用。
首先我們來看一個經典的組合模式中的三個概念:
- Component:抽象接口,定義組合的外觀行為;
- Composite:容器對象,表示“有孩子”的節點;
- Leaf:葉子節點,表示“沒有孩子”的節點。
下面上Java代碼實現:
<code>/** * 組合模式抽象接口 */ public interface LocationComponent { String getPath(); void display(); void add(LocationComponent component); void remove(LocationComponent component); Map getChildren(); }/<code>
<code>/** * 容器對象,表示有孩子的節點 */ public class LocationComposite implements LocationComponent { private Long id; private String name; private String fullPath; private Map children = new HashMap<>(); @Override public String getPath() { return fullPath; } @Override public void display() { System.out.println(name); } @Override public void add(LocationComponent component) { component.fullPath = this.fullPath + "/" + component.id; children.put(component.getPath(), component); } @Override public void remove(LocationComponent component) { children.remove(component.getPath()); } @Override public Map getChildren() { return children; } }/<code>
<code>/** * 葉子節點 */ public class LocationLeaf implements LocationComponent { private Long id; private String name; private String fullPath; @Override public String getPath() { return fullPath; } @Override public void display() { System.out.println(name); } @Override public void add(LocationComponent component) { throw new UnsupportedOperationException(); } @Override public void remove(LocationComponent component) { throw new UnsupportedOperationException(); } @Override public Map getChildren() { throw new UnsupportedOperationException(); } }/<code>
那麼問題來了,我有必要把節點分成Leaf和Composite嗎?Leaf也實現Component接口,但拋那麼多UnsupportedOperationException意義何在?我為什麼不用同一個對象來表示Composite和Leaf?
其實從這裡我們就可以看到,經典的設計模式也不是銀彈。我們學設計模式,學的是思想,而不是固定的套路,最終還是要結合業務。比如上面的代碼,明顯就不適合我們的“區域”業務,比如我想在高新區下面再細分“街道”,這個代碼就很難擴展了。
但如果你的業務是做一個文件系統,我們可以很明顯的知道,文件和文件夾的區別。文件就是一個Leaf,它必然不支持add、remove、getChildren等操作,而文件夾是必須有這些操作的,是一個Composite。這個時候就可以用上面的代碼設計了。同時,上面的Map也可以換成List等其它容器類型。
所以我們要活學活用,針對我們的區域業務,可以直接用一個Component來表示:
<code>/** * 區域接口,可擴展成無限深度 */ public class AreaComponent { private Long id; private String name; private String fullPath; private Map children = new HashMap<>(); public String getPath() { return fullPath; } public void display() { System.out.println(name); } public void add(AreaComponent component) { component.fullPath = this.fullPath + "/" + component.id; children.put(component.getPath(), component); } public void remove(AreaComponent component) { children.remove(component.getPath()); } public Map getChildren() { return children; } }/<code>
前端設計
作為一個有志向的全棧工程師,當然不能只滿足於數據庫和後端層面。前端組件代碼也要自己上手~
相信現在的前端小夥伴們都應該熟悉一種或多種令人聞風喪膽的“三大”前端主流框架。現在的前端框架都推薦“組件化”開發,把頁面分成一個一個小的組件。很明顯,組件層層嵌套,其實本質上也是一個樹的形式,最終也會渲染出一個DOM樹對象。
我們以Vue為例,對於上文提到的區域劃分業務,如果後端返回的是一條條帶有full_path的扁平數據,前端應該如何優雅地構建基於業務的樹形結構呢?答案就是使用
遞歸組件。遞歸組件,簡單來說就是在組件中內使用組件本身, 對於Vue來說,其核心就在於使用name字段。效果大概是這樣:
還是按照慣例,上代碼。先來一個表示“區域”的組件:
<code>{{self.name}}
/<code>
入口:
<code> /<code>
當然了,這只是其中一種寫法,你也可以在外面組裝好一個帶children的對象傳進去。
思考:計算和組裝放在前端還是後端?
又到了我們一天一度的前後端撕逼環節。作為一個假裝是全棧的後端同學來說,筆者認為針對這個問題,我有必要說一句公道話:在前端組裝比較好。
眾所周知,在當今前端越來越繁榮的大環境下,前端承擔著越來越重要的角色,有很多數據的計算也會放在前端。針對於這種樹狀結構的拼裝來說,放在前後端其實都可以的。但是放在前端有一個好處,那就是可以將計算消耗的時間和資源從服務端轉移到客戶端。
現在的後端架構也越來越傾向於讀寫分離,所以在讀的時候,多半不會進行太多的操作,不需要組裝整棵樹。這種情況下,建議直接把數據返回前端,由前端來組裝成整棵樹。
當然,這只是一個建議,具體在什麼時機組裝,還是由業務規則以及團隊商量決定~
好了,以上就是從數據庫到前端的樹形結構實現,有任何問題歡迎留言討論~