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

每週至少一篇原創技術文章
週一早上【8:50】準時推送
偶爾也會分享生活的點滴與感悟
這是本公衆號的第 3 篇原創文章前端

樹狀結構的業務

今天我們要討論的樹,它不是現實結構的樹,也不是數據結構要討論的樹,而是「從業務視角抽象出來的樹形結構」。
帶你通關全棧樹型結構設計:從數據庫到前端vue

樹形結構能夠用在不少的業務上,好比組織結構中的上下級關係、商品分類管理、文件系統、後臺系統中的頁面和組件關係等等。
帶你通關全棧樹型結構設計:從數據庫到前端node

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

數據庫設計

樹狀結構最簡單最經常使用的方法實際上是直接存儲在JSON裏面。如今有不少主流的NoSQL庫,好比MongoDB等,並且也有不少關係型數據庫也開始支持JSON存儲,好比MySQL。後端

使用JSON的好處是,「維護整棵樹比較方便」,直接整存整取就行了,不用去管中間是怎麼修改的,怎麼映射到數據庫的。但缺點是不過高效,好比想要編輯某個葉子節點,查詢和更新都沒有純關係型數據那麼方便。因此若是你的業務足夠簡單,數據量也很小,可使用JSON。不然,仍是推薦使用關係型數據來實現。設計模式

那如何在關係型數據應該如何設計,才能高效地存儲和操做樹形結構呢?咱們用下圖來做爲例子:安全

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


ps:這裏假設有多棵樹,根節點是亞洲、美洲等
數據結構

咱們首先想到的是用parent_id, 這個字段用來存儲「父節點」,根節點的parent_id爲0,這樣就能夠經過遞歸查詢獲得一棵樹。架構

很明顯,若是隻是一個parent_id,咱們若是想得到一棵樹,當這棵樹的深度比較深時,咱們須要查詢不少次數據庫,效率很是低。那有沒有什麼辦法能夠一次性把整棵樹都查出來呢?咱們嘗試加一個root_id,用來表示這棵樹的根節點。

id  parent_id   root_id name
1   0   0   亞洲
2   0   0   美洲
3   1   1   中國
4   1   1   日本
5   1   1   韓國
6   3   1   四川省

那麼問題來了,若是是想查詢某一個節點的子樹,該怎麼作呢?是否是以爲不太方便?下面咱們推薦另一種表示方式:「full id path」,每一個節點記錄下從根節點到本身的id路徑,以下:

id  full_id_path    name
1   /1  亞洲
2   /2  美洲
3   /1/3    中國
4   /1/4    日本
5   /1/5    韓國
6   /1/3/6  四川省
7   /1/3/6/7    成都市

這樣若是咱們想查詢一棵樹,只須要使用like語句前綴匹配就好了。好比想查詢「四川省」下面有哪些市:

SELECT * FROM table 
WHERE full_id_path like "/1/3/6%";

若是是更新了一棵樹中節點的關係,只須要維護好這個節點及其子節點的full_id_path字段就好了。通常來說,這種full id path設計可以知足絕大多數樹形結構的業務要求。

但若是你的id是「UUID」類型的,若是深度比較高,那full_id_path字段就會比較長,且並不易讀。這個時候咱們建議使用一個惟一的,有業務意義的code來表示路徑,字段名改成叫full_path。好比要表示成都市:

'/Asian/China/SiChuan/Chengdu'

數據庫的設計仍是要根據業務來,沒有絕對的銀彈。有時候,咱們會加上level字段表示每一個節點在樹中的層級位置,用於「在應用代碼層面更方便、高效地拼接樹」。

設計模式 - 組合模式

接下來咱們介紹在代碼層面,如何去優雅地使用樹形結構。其實前輩們已經爲咱們總結出了一種很是優秀的設計模式——組合模式,又稱爲「部分總體模式」,專門針對樹形結構。

組合模式的精髓在於,你不用把一棵樹「樹」的概念和一個單獨的「節點」分開處理,而是都視爲同一種對象來處理。下面咱們依然以上面的區域關係爲例,來介紹組合模式如何使用。

首先咱們來看一個經典的組合模式中的三個概念:

  • Component:抽象接口,定義組合的外觀行爲;
  • Composite:容器對象,表示「有孩子」的節點;
  • Leaf:葉子節點,表示「沒有孩子」的節點。
  • 下面上Java代碼實現:

/**
 * 組合模式抽象接口
 */
public interface LocationComponent {

    String getPath();

    void display();

    void add(LocationComponent component);

    void remove(LocationComponent component);

    Map<String, LocationComponent> getChildren();
}
/**
 * 容器對象,表示有孩子的節點
 */
public class LocationComposite implements LocationComponent {

    private Long id;
    private String name;
    private String fullPath;
    private Map<String, LocationComponent> 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<String, LocationComponent> getChildren() {
        return children;
    }
}
/**
 * 葉子節點
 */
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<String, LocationComponent> getChildren() {
        throw  new UnsupportedOperationException();
    }
}

那麼問題來了,我有必要把節點分紅Leaf和Composite嗎?Leaf也實現Component接口,但拋那麼多UnsupportedOperationException意義何在?我爲何不用同一個對象來表示Composite和Leaf?
帶你通關全棧樹型結構設計:從數據庫到前端

其實從這裏咱們就能夠看到,經典的設計模式也不是銀彈。「咱們學設計模式,學的是思想,而不是固定的套路,最終仍是要結合業務」。好比上面的代碼,明顯就不適合咱們的「區域」業務,好比我想在高新區下面再細分「街道」,這個代碼就很難擴展了。

但若是你的業務是作一個「文件系統」,咱們能夠很明顯的知道,文件和文件夾的區別。文件就是一個Leaf,它必然不支持add、remove、getChildren等操做,而文件夾是必須有這些操做的,是一個Composite。這個時候就能夠用上面的代碼設計了。同時,上面的Map也能夠換成List等其它容器類型。

因此咱們要活學活用,針對咱們的區域業務,能夠直接用一個Component來表示:

/**
 * 區域接口,可擴展成無限深度
 */
public class AreaComponent {
    private Long id;
    private String name;
    private String fullPath;
    private Map<String, AreaComponent> 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<String, AreaComponent> getChildren() {
        return children;
    }
}

前端設計

做爲一個有志向的全棧工程師,固然不能只知足於數據庫和後端層面。前端組件代碼也要本身上手~

相信如今的前端小夥伴們都應該熟悉一種或多種使人聞風喪膽的「三大」前端主流框架。如今的前端框架都推薦「組件化」開發,把頁面分紅一個一個小的組件。很明顯,組件層層嵌套,其實本質上也是一個樹的形式,最終也會渲染出一個DOM樹對象。
帶你通關全棧樹型結構設計:從數據庫到前端
組件樹

咱們以Vue爲例,對於上文提到的區域劃分業務,若是後端返回的是一條條帶有full_path的扁平數據,前端應該如何優雅地構建基於業務的樹形結構呢?答案就是使用「遞歸組件」。

遞歸組件,簡單來講就是「在組件中內使用組件自己」, 對於Vue來講,其核心就在於使用name字段。效果大概是這樣:

帶你通關全棧樹型結構設計:從數據庫到前端
請忽略個人審美

仍是按照慣例,上代碼。先來一個表示「區域」的組件:

<template>
  <div class="node" :style="{paddingLeft: self.level * 20 + 'px'}">
    <p>{{self.name}}</p>
    <div v-for="child in children" :key="child.id">
      <Area v-if="`children.length != 0`" :self="child" :all="all"/>
    </div>
  </div>
</template>

<script>
export default {
  name: 'Area',
  props: {
    self: Object,
    all: Array
  },
  computed: {
    children() {
      // 根據full_path和level,過濾出子組件
   return this.all.filter(item => item.full_path.startsWith(this.self.full_path) && item.level == this.self.level + 1);
    }
  }
}
</script>

入口:

<template>
  <div id="app">
    <Area :self="all[0]" :all="all" />
  </div>
</template>

<script>
import Area from './components/Area.vue'

export default {
  name: 'App',
  components: {
    Area
  },
  data() {
    return {
      // 全部數據
      all: [
        { id: 1, level: 1, full_path: '/1', name: '亞洲' },
        { id: 2, level: 1, full_path: '/2', name: '美洲' },
        { id: 3, level: 2, full_path: '/1/3', name: '中國' },
        { id: 4, level: 2, full_path: '/1/4', name: '日本' },
        { id: 5, level: 3, full_path: '/1/3/5', name: '四川省' },
        { id: 6, level: 3, full_path: '/1/3/6', name: '浙江省' },
        { id: 7, level: 4, full_path: '/1/3/5/7', name: '成都市' }
      ]
    }
  }
}
</script>

固然了,這只是其中一種寫法,你也能夠在外面組裝好一個帶children的對象傳進去。

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

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

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

衆所周知,在當今前端愈來愈繁榮的大環境下,前端承擔着愈來愈重要的角色,有不少數據的計算也會放在前端。針對於這種樹狀結構的拼裝來講,放在先後端其實均可以的。可是放在前端有一個好處,那就是「能夠將計算消耗的時間和資源從服務端轉移到客戶端」

如今的後端架構也愈來愈傾向於讀寫分離,因此在讀的時候,多半不會進行太多的操做,不須要組裝整棵樹。這種狀況下,建議直接把數據返回前端,由前端來組裝成整棵樹。

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

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

都看到這裏了,說明你承認個人文章。
若是對你有幫助,不妨支持我一下:
你的一個小小的閱讀、關注、留言、轉發、在看,
都是我寫做路上最大的鼓勵。

猛戳下面那個二維碼關注:

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

相關文章
相關標籤/搜索