數據庫schema不是CRUD服務的一切

謹以本文向我腦海中那些不成熟的想法致敬。javascript

序言

受疫情影響呆在家中的這段時間裏,我收尾了《Clean Architecture》。這本書給了我許多新知識和啓發,包括本文的中心論點——數據庫schema不是CRUD服務的一切,也是在讀書過程當中想到的。在書中,做者的原話是java

But the database is not the data model

它出如今書中第六部分《Details》的第一個章節中。做者認爲,從架構的角度來看,數據庫不是一個實體而是一個細節,不足以成爲架構中的一個元素。他甚至打了個比方:數據庫對於架構而言,就好像門把手對於房子通常。而且,做者進一步澄清了他的觀點:他口中所說的數據庫,不是指的數據模型。應用內的數據結構對架構而言相當重要,但數據庫並非數據模型。node

這不由讓我回憶起了本身早期寫設計文檔的套路。git

在個人從業生涯早期(說得好像我從業好久了同樣),每當須要開發一個新的Web服務時,必須先寫一份簡要的設計文檔,向上級清楚地表達個人實現思路,包括:程序員

  1. 如何與其它服務協做完成產品提出的需求;
  2. 服務的接口描述;
  3. 數據的存儲結構;
  4. 關鍵的算法等。

那時候的我會先考慮數據的存儲結構,而後定義接口,最後纔是與其它服務的協做。這些早期設計文檔的其中一個特色是:接口的響應格式,與數據的存儲結構是相同的。github

比方說我要設計一個網上商城的訂單服務,可能會提供以下查詢特定訂單的接口算法

GET /order/:id

其響應格式可能以下mongodb

{
  order: {
    id: 'F122663A-A5DC-451A-9B79-92DCE2EE41F1',
    price: '100.00',
    products: [
      {
        id: 1,
        name: 'MacBook Pro',
        price: '19999.00'
      },
      {
        id: 2,
        name: 'iPhone',
        price: '6999.00'
      }
    ]
  }
}

爲了保存這種「不平坦」的對象,將會用MongoDB做爲存儲——除了文檔的主鍵_id以外,其它字段在接口和存儲之間一一對應。數據庫

不只僅是響應格式,在這個Web服務內所操做的也是一樣結構的對象。用MongoDB Node.JS Driver得到的訂單剛好是一個JS對象,它與collection的文檔有着如出一轍的結構。以後這些對象會在代碼內處處流通,不加修飾地使用。定義了數據庫schema(就算是用MongoDB也有一套腦內的schema)後,其它的一切也就跟着肯定了。數組

業界甚至有工具能夠直接從數據庫獲得API,好比postgrest

不少時候,數據庫schema成了一個應用內事實上的數據模型。可是,即使它們能夠偶然同樣,也不要認爲它們總應該同樣。

適合存儲,不必定適合計算

以MySQL爲例,在CREATE TABLE語句中某一列的類型實際上決定的是存儲時分配的空間的多少。但適合存儲的類型,並不必定也適合業務邏輯的運算。

好比說,要在MySQL中存儲「開關型」的數據,即諸如「是否啓用」或「是否已支付」這樣非此即彼的狀態時,一般定義爲TINYINT類型,用0表示邏輯假(「未啓用」和「未支付」),1表示邏輯真(「已啓用」和「已支付」)。但對代碼而言,比起用數值類型,布爾類型纔是更恰當的選擇。尤爲是當所選擇的語言並無將0與false、1與true等價起來的時候——在Common Lisp中,(if 0 1 2)的求值結果爲1。

適合計算,不必定適合存儲

一般數據結構在內存中比在磁盤上要容易表達得多,因此代碼中使用的數據結構會比數據庫中存儲的要靈活很多,這一樣形成了二者的不匹配。

以我本身開發的提醒工具cuckoo爲例,應用內有兩種對象:任務和提醒。任務描述了要作的事情,提醒描述了在何時該告訴用戶。顯然,提醒是一個依賴於任務的弱實體。在cuckoo的代碼中,任務是Task類的實例對象,有一個名爲remind的成員變量存儲着提醒。

但這樣的結構不方便存儲在MySQL中。遵守關係型數據庫設計的第一範式,任務和提醒分別被存儲在t_taskt_remind表中,二者經過t_task.remind_id聯繫起來。

固然,也能夠在一開始就用MongoDB來存儲這些數據(甚至能夠用對象數據庫?不過我沒玩過)。尤爲是cuckoo只是一個小玩意兒,MySQL和MongoDB都足以勝任。但做爲一名有理想的程序員,在作設計的時候,不該該讓低層細節過度干預高層策略。(在《Clean Architecture》中,越是接近I/O的越是low-level,反之則是high-level。)

面向業務邏輯,而非存儲結構

業務邏輯和規則纔是一個服務的核心,應該把更多精力花在實現業務邏輯的數據結構和算法上。

以網上商城中常見的優惠券功能爲例。優惠券服務所管理的優惠券每每有着各類效果、條件,以及限制。爲了保持靈活性,優惠券類(下稱Coupon)的實例對象中會有三種接口類型的成員變量:

  1. Effect類型的變量effect,負責實現優惠效果的計算邏輯;
  2. Condition數組類型的變量conditions,負責實現使用條件的檢查邏輯;
  3. Restriction數組類型的變量restrictions,負責實現使用限制的檢查邏輯。

三個接口能夠有各類各樣的實現——定額減免、折扣減免、某年月日前可用、不可用於電子產品,等等。如此,優惠券功能具有了極大的靈活性,業務能夠爲所欲爲,產品能夠隨心所欲,老闆數錢數到手軟,公司業績蒸蒸日上。

那麼如何存儲EffectConditionRestrictionCoupon類的實例對象呢?沒有惟一的選擇,既能夠存儲在MySQL中,也能夠存儲在MongoDB中,或者別的什麼數據庫中。無論這些數據最終如何持久化,都不會影響做爲高層策略的優惠券業務邏輯。反過來,若是在代碼中處理的不是類、接口,以及實例對象,而是直接從數據庫中取出來的、貧血模型的行(或文檔),處理起來就不是很優雅了——能夠預見 ,代碼中會充斥着許多的if-else判斷邏輯。

數據庫只是幫忙從磁盤中讀取數據的軟件,它的schema不該該直接成爲應用的數據模型。

Interface Segregation Principle

不該該在HTTP接口的響應中直接暴露數據庫的schema。

不說別的,光是數據庫schema與接口規格所使用的命名規則就足以形成差別了。也許在MySQL中用snake case命名一列,卻又在HTTP響應的JSON對象中用camel case命名字段。

此外,除非這些接口僅僅實現增刪查改、沒有任何的業務邏輯或規則,不然一個服務更應當提供與業務需求剛好契合的接口。仍然以上文的優惠券服務爲例,儘管內部可能EffectConditionRestrictionCoupon等諸多概念,但煮不在意用戶不在意,他們只想看到用人話說出來的優惠券效果以及使用規則——用戶甚至不關心條件和限制有何不一樣。

若是優惠券服務直接將數據庫中的行(或文檔)序列化成JSON返回給調用者,會致使封裝的泄露。每個查詢優惠券的調用方,都必須瞭解優惠券的內部表示形式,必須知道效果由effect描述、用券後的訂單金額是多少、conditions中有關於過時與否的信息,等等。每增長一個優惠券服務的使用者,就相應地增長一套描述這些內容的代碼。甚至當優惠券服務自身重構的時候,也許牽連到衆多的調用方。

若是直接將存儲結構暴露給調用者的話,又何須再作一個Web服務呢。

切勿矯枉過正

的確存在這樣的例子,數據庫schema、數據模型,以及HTTP響應結構三者相同。這是由於比起維護數據庫schema與數據模型的轉換規則,以及DTO與數據模型的轉換規則而言,在領域代碼中直接使用數據庫schema來表達數據模型的成本更低一點。儘管數據庫schema不是Web服務的一切,但不少時候能夠因地制宜地妥協一下。

閱讀原文

相關文章
相關標籤/搜索