我們需要什麼樣的 ORM 框架

Inshua發表於2024-03-22

瞭解我的人都知道, 本人一直非常排斥 ORM 框架, 由於物件關係阻抗不匹配, 一直覺得它沒有什麼用, 運算元據庫最好的手段是 sql+動態語言. 但這兩年想法有了重大改變.

2013 年用 js 實踐過一個 GUI 的開發, 結論是對於軟體工程來說, 靜態型別是必須的.

但在資料庫方面我卻一直迴避了這個問題, 實際上這個問題在資料庫的互動中同樣存在. 資料庫的 scheme 可以認為是靜態的, 不惟表結構, 隨表結構產生的檢視等, 其實都是靜態的. 所以說資料庫是動態語言或者動態VM, 這個說法是錯誤的. 但是靜態的資料庫卻提供了一種動態手段, 這就是即興化的查詢. 關係運算可以打破靜態結構提供一次性的 scheme, 這是一個特別的現象. 資料庫在靜態型別的基礎上又提供了動態型別————當然, 關係運算的設計者並沒有考慮這些, 關係和關係運算, 是關係型資料庫的全部出發點.

靜態型別適合工程化, 動態型別也有不可或缺的價值, 我們看到, 如果從動態型別靜態型別的角度來考察, 關係型資料庫協調的很好, 我們甚至意識不到問題的存在. 我們可以像畫靜態圖一樣畫 ER 圖, 又能在圖的基礎上即興的給出查詢, 想要幾個欄位就要幾個欄位, 甚至支援運算列. 而這些即興的查詢隨時可以建一個檢視把它們固化下來, 變成靜態型別.

資料庫欄位一旦有變動, 相關的檢視會失效, 這時資料庫會提醒受到影響的資料庫物件. 如果 IDE 得當, 可以很容易實現資料庫欄位重構相應檢視的重構, 即使不支援複雜的重構, 如分拆表, 至少可以列出引用者, 確定受影響的範圍再行重構.

那麼, 資料庫很完美我們不要用程式語言如何?

也不盡然, 資料庫的 scheme 對於程式語言來說並不完美, 如果我們把表看做類的話, 資料庫裡類的級別很高, 不支援一些即興的如匿名內部類這樣的東西. 不過這也是一個方向, 有人甚至基於資料庫做作業系統.

現在我們回到 ORM 問題.

可能你已經意識到我在想什麼了.

物件導向程式語言和關係型資料庫雖然位於兩個領域, 但在靜態型別上, 它們是可以對應的, 這也就是 ORM 框架能解決的地方. 但是, 我們經常在程式裡放即興的 SQL 程式碼, 這些程式碼都會面臨阻抗失配的問題:

  1. 首先這種即興的關係運算的結果沒有對應的類, 我們通常用 Map 來表達, 這種蹩腳的表達遠不如動態語言方便
  2. 一旦發生資料庫重構, 這些 SQL 僅僅是字串, 重構時這些 SQL 無法被 IDE 覆蓋, 甚至在編譯時都無法丟擲錯誤.

在我以往的認知裡 ORM 只是增刪改方便, 對 SQL 查詢很不適用. 故 MyBatis 大大優於 hibernate, 而 d2js 大大優於 MyBatis. groovy sql 則在 d2js 和 mybatis 之間, ORM 帶來的好處可以忽略不計. 最近兩年我發現這個認知有很大問題, 主要就在上面的第 2 點. 靜態型別的好處不光是增刪改方便, 重要的是隻有靜態型別適合工程化, 所以 ORM 不是削足適履, 而是必須的.

遺憾的是 ORM 在應對即興的 SQL 方面仍然有欠缺, 並且流行的解決方案都沒有處理好這個問題.

  1. MyBatis 透過 XML 寫 SQL, 導致 SQL 和業務場景分離, 程式碼跳來跳去, 更難工程化
  2. 也有透過註解寫 SQL 的, 解決了場景割裂的問題, 如 JPA 等, MyBatis 好像也支援了, 新版的 Java 有了文件字串支援多行了, 似乎好用起來了, 但同樣也無法避免上面談到的問題
  3. JPA 提出了一種自己的 SQL, Hibernate 也搞了一個 HQL, 這可以說毫無意義, 首先, 這些 SQL 還是字串, 同樣面臨重構問題, 其次, 資料庫的 SQL 功能遠比這些低階 SQL 強, 反過來如果資料庫比它弱它也對映不過去. 比如現在 pg 有一個 pgvector, Hibernate 要把它包進去難度可想而知
  4. 還有一些 ORM 搞起了 reactive, 號稱 DSL, 像這樣 .select().from() ... 這種甚至不是 SQL, 表達關係運算捉襟見肘, 更無法複製到 SQL Console 執行, 大大增加了迭代難度
  5. ORM 自己搞的 SQL 或 reactive dsl 的學習成本進一步降低了開發效率, 除了顯示開發者會搞 SQL Parser 沒有別的價值
  6. DLINQ 風格, DLINQ 解決了重構問題, 但 DLINQ 支援的 SQL 畢竟是有限的, 同樣無法發揮資料庫 SQL 的全部功能

本文提出一點新思路:

  1. 在 ORM 基礎上使用動態語言搞即興查詢, 例如 groovy, 我試過這個方案, 開發效率很高, 和 d2js 接近, 當然這個方案同樣面臨重構的困境, 畢竟 SQL 仍然是字串
  2. 加強靜態語言, 為即興查詢創造相應的靜態型別. 這涉及到一個工序的調整, 不能按現在的 MyBatis 等方式開發. 開發過程應當是這樣, 編寫完 SQL 後, 貼上到 Java 程式碼前, 使用某些資料庫手段, 如 EXPLAIN 等, 得出所有關聯的欄位, 再透過 ORM 逆對映, 得到一個即興查詢類. 上述"使用某些資料庫手段, 如 EXPLAIN 等, 得出所有關聯的欄位, 再透過 ORM 逆對映, 得到一個即興查詢類"可以作為一個 IDE 小工具提供.

這種類可以是這樣

class OrderByUserIdQuery extends Query{
    class Params{
        Object[] placeHolders = [User.PlaceHolder, Order.PlaceHolder];
        int userId = placeHodlers[0].id;
        getUserId, setUserId...
        String orderNum = placeHolders[1].orderNum;
        getOrderNum, setOrderNum...
    }    
    class Result{
        Object[] placeHolders = [Order.PlaceHolder];        
        Date created = placeHolders[0].created;
        getter; setter;
        Date expired = placeHolders[1].expired;
        getter; setter;
    }
    
    String sql = "select o.created, o.expired from order o, user u where o.user = u.id and u.id = :userId and order_num = :orderNum";

    @Override
    Class getResultClass = Result.class;
    @Override
    Class getParamsClass = Params.class;
}

這個方案較為完美, 只需要調整一下工作流做一個小工具就可以————我發現這和 DataSet 設計器做的事情很像. 這個方案唯一的缺陷是這些程式碼畢竟看起來就很累.

如進一步吸收 d2js 的特色, 這個方案可進一步升級

sql{.
    select o.created, o.expired from order o, user u where o.user = u.id and u.id = :userId and order_num = :orderNum
.}

這裡 IDE 應透過分析 SQL 識別出 SQL 中可以關聯到 OO 的相關 class 及 field.

剛才分析的是即興查詢問題, ORM 方案還有兩個具體問題:

  1. 資料庫欄位和 OO 語言資料型別的適配
  2. 資料庫外來鍵和 OO 語言的適配

第一個問題現在往往搞一堆註解來解決, 但以註解對應資料庫的欄位很不 OO, 例如在欄位裡, NUMBER(64) 構成一個型別, 而在Java裡都用 int 去對應. 我的想法是應當全盤型別化.
什麼意思? 看程式碼:

interface Column{
    boolean nullable();
    boolean primary();
    void validate() throws ValidationError;
}
class Number extends java.lang.Number implements Column{
    int pricision;
}
class Number64 extends Number{
    pricision = 64;
}

可能有人要問了, Number(N) 中 N 是可變的, 難道要搞無窮無盡的 Number? 這就多慮了, N 固然是無限的, 但一個資料庫中有哪些是已知的, 框架給幾個常用的, 沒有給的自己補充幾個就好了.

當然即使如此上面的 nullable 和 primary 也是行不通的, 宣告欄位提供的是型別而非例項, Column 的例項才有 nullable 和 primary. 如改為靜態方法, 靜態方法又不支援覆蓋. 所以只能

PrimaryKey<Nullable<Number64>> userId

這裡一個可能的解決方案是即興類, 但無疑 Java 並不支援————Java 的匿名類只能定義在例項部分, 除了動態語言比如 ruby, scala kotlin 等等都不支援這種科幻特性.

即興類退一步的版本是為每個欄位定義為一個類, 這個方案的好處是很容易引用欄位名, 對工程化非常有用. 例如上面的 sql 語句, 假如每個欄位都是一個型別名, 識別起來就極為簡單了, 用起來也不費勁. 如上面的

sql{.
    select o.created, o.expired from order o, user u where o.user = u.id and u.id = :userId and order_num = :orderNum
.}

假如 User.Created 是一個型別, 則從 sql 中的 o.created 很容易就定位到該欄位了, 對工程化非常有利.
設想一下它的形態大致是這樣:

class User extends Entity{
    class Id extends Integer implements PrimaryKey, Serial;
    class Name extends VarChar64 implements Nullable{
        @Override void valiate() throws ValidationError {

        }
    } 
    public Id id;
    public Name name;
}

這也很有意思. 當然, 這種程式碼在 Java 裡就只能生成了.

第二個問題似乎框架都解決的不錯了, JPA 也給了一些註解方案, 凡是支援 JPA 必然支援外來鍵關聯, 我就不多廢話了.

相關文章