帶你通關全棧樹型結構設計:從數據庫到前端

樹狀結構的業務

今天咱們要討論的樹,它不是現實結構的樹,也不是數據結構要討論的樹,而是

從業務視角抽象出來的樹形結構

帶你通關全棧樹型結構設計:從數據庫到前端

樹形結構可以用在很多的業務上,比如組織結構中的上下級關係、商品分類管理、文件系統、後臺系統中的頁面和組件關係等等。

帶你通關全棧樹型結構設計:從數據庫到前端

下面請繫好安全帶,我們將從數據庫設計、設計模式、前端組件三個方面來介紹關於樹狀結構設計的方方面面,助你通關全棧樹狀結構!

帶你通關全棧樹型結構設計:從數據庫到前端

數據庫設計

樹狀結構最簡單最常用的方法其實是直接存儲在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的對象傳進去。

思考:計算和組裝放在前端還是後端?

又到了我們一天一度的前後端撕逼環節。作為一個假裝是全棧的後端同學來說,筆者認為針對這個問題,我有必要說一句公道話:在前端組裝比較好。

帶你通關全棧樹型結構設計:從數據庫到前端

眾所周知,在當今前端越來越繁榮的大環境下,前端承擔著越來越重要的角色,有很多數據的計算也會放在前端。針對於這種樹狀結構的拼裝來說,放在前後端其實都可以的。但是放在前端有一個好處,那就是可以將計算消耗的時間和資源從服務端轉移到客戶端

現在的後端架構也越來越傾向於讀寫分離,所以在讀的時候,多半不會進行太多的操作,不需要組裝整棵樹。這種情況下,建議直接把數據返回前端,由前端來組裝成整棵樹。

當然,這只是一個建議,具體在什麼時機組裝,還是由業務規則以及團隊商量決定~

好了,以上就是從數據庫到前端的樹形結構實現,有任何問題歡迎留言討論~


分享到:


相關文章: