天天看點

《程式設計機制探析》第二十八章 ORM

《程式設計機制探析》第二十八章 ORM

本章的主題是ORM(Object Relation Mapping,對象與關系資料的映射)。

ORM是一種技術架構,其主要作用是在面向對象語言和關系資料庫之間搭建一個轉換橋梁。這個轉換是雙向的。ORM既可以把關系資料轉換為對象,也可以把對象轉換為關系資料。

ORM種類繁多,功能或繁或簡,這裡不便一一列舉。本章隻揀ORM的一些重要特性進行闡述。

ORM的最基本功能是關系資料到對象的轉換。程式進行資料庫查詢時,會擷取到一行行的關系資料的集合。這個關系資料集合的資料結構和關系資料表一模一樣,都是一個二維表結構,在表頭上,每一列都有自己的名字。比如,我們擷取一個表名為department(部門)的資料表中的所有資料,得到的結果資料集如下:

id name

01 Office

02 QC

03 IT

04 Design

05 Customer Service

可以看到,這就是一個帶有列名的數組結構。為什麼不直接傳回數組結構,而是提供一個Iterator呢?這是因為,資料庫用戶端(即調用資料庫的程序)為資料結果集配置設定的程序内資料緩沖空間是有限的,如果結果資料量超過資料緩沖空間的話,超出的那些資料就隻能在資料庫伺服器中等待下一次傳喚。而且,很多時候,程式不再需要後面的資料,這時候,這種一部分一部分擷取的Iterator Pattern的優勢就展現出來了。

關系資料到對象的映射很簡單。一行關系資料就是一個數組,隻要按照順序,根據列名,利用Reflection機制,将資料設定到對象的同名屬性中就可以了。這個地方無需細說。

一般的情況下,一個關系表定義和一個對象定義之間是一對一的關系。即一個對象定義映射一張表定義。但凡事都有例外。一些功能強大的ORM提供了更加豐富的映射關系,一個關系表定義映射多個對象定義,多個關系表定義映射一個對象定義,甚至還可以分級别映射,一個映射表定義可以映射多級對象定義(即映射到對象中的屬性對象),等等。除此之外,ORM還可能提供部分映射,即,把一張表的某些字段映射到對象中的某些屬性。這種特性叫做Fetch Group(按組擷取)。

這些ORM特性都有各自的特殊應用場景。不過,這不是本章所關心的。本章所關心的一個很有用的ORM特性是Lazy Fetch(延遲擷取)。這個特性是用來代替關聯表查詢的。

關聯表查詢,即兩張、或者兩張以上的關系表一起進行條件查詢。假設第一張表的記錄數是N1,第二張表的記錄數N2。這兩張表關聯查詢時,需要處理的記錄數就是N1 * N2,即兩張表的笛卡爾乘積。當關系表中的記錄數非常大的時候,關聯查詢的開銷就會非常巨大。這時候,我們就要考慮Lazy Fetch(延遲擷取)的方案,即,兩行表分開查詢,先按照某種條件,查詢其中一張表,然後,再根據查詢結果,查詢另一張表。

下面舉一個例子。前面已經有了一個department(部門)表,我們再引入一個employee(雇員)表。

id name department_id

001 John 01

002 Lee 01

003 Van 01

004 Lily 02

005 Harry 02

006 Long 02

007 Sunny 03

008 Tom 03

009 Tiger 03

這兩張表的定義為:

Create table department (id, name)

Create table employee (id, name, department_id)

外鍵關系: deparment_id <-> department.id

對象關系: Employee對象中有一個Department對象屬性。

我們先來看關聯查詢的例子:

Select employee.*, department.name

from employee, department

where employee.department_id = department.id

然後,我們把這個例子改成Lazy Fetch,即兩張表分開查詢。最直覺的方法是先查詢employee表,然後根據每個employee中的department_id去查詢department表。産生的SQL如下:

Select * from employee

Select * from department where id = 001

Select * from department where id = 001

Select * from department where id = 001

Select * from department where id = 002

Select * from department where id = 002

Select * from department where id = 002

Select * from department where id = 003

Select * from department where id = 003

Select * from department where id = 003

可以看到,這種方案的效率極低,産生了太多的SQL。這就是資料庫查詢中的所謂“1 + n”問題。下面,我們對這種方案進行改進。第一個想法就是合并其中重複的SQL,我們得到4條SQL語句:

Select * from employee

Select * from department where id = 001

Select * from department where id = 002

Select * from department where id = 003

這個結果仍然不能讓人滿意。SQL語句還是太多。我們繼續優化,把上述的SQL語句合并為兩條SQL。

Select * from employee

Select * from department where id in (001, 002, 003)

這樣,我們就把“1 + n”問題轉化成了“1 + 1”問題。

Lazy Fetch的實作流程總結如下:

(1)查詢第一張表

(2)根據結果集,收集第二張表的id,并消除重複記錄。

(3)根據第二張表的id集合,查詢第二張表

(4)根據對象關系和id對應關系,将兩張表查詢出來的對象組裝起來。

ORM的第一項功能——關系資料映射到對象,就講到這裡。下面我們來看ORM的第二項功能——SQL動态拼裝。

有時候,使用者需要利用各種條件組合來查詢資料,伺服器就需要根據使用者輸入的各種條件組合,拼裝出對應的SQL。除了最原始的字元串拼接發之外,還有兩種看起來比較清爽的動态拼裝SQL思路。

第一種思路叫做條件對象組裝。

這種思路基于一堆稱為條件對象(Criteria)的API(Application Programming Interface)。所謂條件對象,就是一堆邏輯操作,如:與對象(and)、或對象(or)、比較對象(大于、小于、等于)等等。

這些條件對象可以組裝起來,形成一個樹形結構的對象,這個對象輸出的結果就是一串SQL條件語句。

這個思路像什麼?沒錯,很像是前面章節中講過的“頁面元件”技術,都是在一堆對象的代碼中夾雜着字元串輸出語句,最後再統一輸出。

我是不贊同這種思路的。在我看來,SQL本身是一種可讀性很好的領域專用語言(DSL,Domain Specific Language),其可讀性遠遠超過條件對象(Criteria API)。

我認同這樣一種觀點——DSL Over API(領域專用語言優于程式設計函數定義),因為,DSL接近于自然語言,可讀性遠遠超過API。隻不過,在現實的世界中,API的定義十分簡單,而DSL的定義十分困難,因為DSL涉及到文法解析器和解釋器,這兩者都不是省油的燈,不是一般人可以寫出來的。

現在,既然有了現成的DSL(即SQL)卻舍而不用,反而去用最原始的Criteria API,這不是舍本逐末嗎?

我贊同第二種思路——SQL模闆技術。

這種思路基于前面章節中講述的“層次比對”文本生成技術。程式員在SQL中加入begin end ${} 之類的自定義标簽,将動态部分劃分出來,然後,根據使用者輸入條件構造一個顯示資料模型,最後,将SQL模闆和顯示資料模型比對起來,就可以得到最終的SQL語句。

當然,不嫌麻煩的話,也可以采用“絕對位置”Flyweight的方案。不過,SQL一般都不會太長,層次結構也不會太複雜,使用Flyweight方案的好處很可能不足以抵償帶來的麻煩。

ORM的第三項功能是SQL命名參數。

資料庫允許用“?”這樣的通配符來代替SQL中的參數,如:

select …. where id = ? and name = ?

update … set name = ? where id = ?

delete … where id = ?

insert …. values ( ?, ?)

使用這種帶有參數的SQL的時候,程式員需要把參數值按照順序放進一個數組中,和帶參數的SQL一起傳給資料庫。

這種帶參數的SQL有兩個問題。第一個問題是可讀性不好,所有的參數部分都是“?”,不知道具體應該對應怎樣的數值。第二個問題是參數值順序不好掌握,數組中的參數值必須對應在SQL中的“?”順序,這個順序是比較難以對應的,程式員不得不非常小心仔細,有時需要耗費相當的精力。

為了解決這個問題,一般的ORM架構中都引入了“SQL命名參數”的功能。

SQL命名參數用參數名代替了“?”和位置順序。比如:

select … where id = $id and name = $name

update …. set name = $name where id = $id

delete …. where id = $id

insert … values ($id, $name)

我們可以用“層次比對”技術來處理這個SQL,把$id和$name替換成?,并且按照參數名取出資料模型中的對應屬性,并根據參數名的順序填入到參數數組中,最後把帶有?的SQL和參數數組一起傳給資料庫。

可以看出,這個工作不僅是簡化了參數設定工作,同時還完成了“對象到關系資料的映射”的工作。比如,上面的update、delete、insert語句,就是根據一個對象的屬性資訊,對資料表進行增、删、改等操作。

ORM的第四項功能是緩存(Cache)。

首先,我們需要明确緩存的應用場景。緩存的目的是為了提高查找速度,但不是所有情況都可以應用緩存。當使用者對資料準确度要求很高的情況下,比如銀行轉賬,是不可以應用緩存的,因為緩存具有時效性,很可能過期。隻有在對資料準确度要求不高、能夠容忍一定程度過期資料的情況下,緩存才有用武之地。

衡量緩存優劣的最重要參數是命中率,即從緩存中查到所需資料的機率。我們可以用一個簡單的公式來大緻表述:命中率 = 緩存命中次數 / 緩存資料總量

ORM緩存分為兩種——ID緩存和Query緩存。

ID緩存,顧名思義,就是以關系表ID為索引的緩存。每行關系資料的ID都是唯一的,對應的對象的ID也都是唯一的。這種緩存很容易了解,不必贅述。

Query緩存,是針對SQL查詢語句的緩存。一條SQL查詢語句可能查出來一個關系資料集。這個關系資料集也可以存放在緩存中。當下次使用者再用同樣的SQL查詢語句的時候,就可以直接傳回Query緩存中的資料結果集。

ID緩存和Query緩存可以分開實作,也可以合并實作。

分開實作的話,兩個緩存各不相幹,各管一攤,實作上比較簡單,但是,時間空間效率和命中率都不高。比如,下面的兩條SQL語句。

select …. where department = “QC”

select … where id = $id

第一條SQL語句不是ID查詢,是Query查詢,對應的是Query緩存。第二條SQL語句對應的是ID查詢,對應的是ID緩存。這兩條SQL語句查詢出來的結果,分别存放到兩個不同的緩存空間中。

但是,第一條查詢語句中的結果集,很可能包含了第二條查詢語句的結果。也就是說,ID緩存和Query緩存有很大的可能性存在重複資料。

為了時間空間效率和命中率起見,ID緩存和Query緩存最好合起來實作,共用同一份緩存。其實作原理如下:

當ID查詢的時候,ORM緩存把ID作為鍵值,把對象存放到緩存中。這時候,實作的是ID緩存的功能。

當Query查詢的時候,ORM緩存把結果集一條條展開,把一個個對象的ID作為鍵值,把對象存放到緩存中,這時候,實作的是ID緩存的功能。然後,根據結果集構造一個ID清單,把SQL本身作為鍵值,把這個ID清單存放到緩存中,這時候,實作的是Query緩存的功能。

對緩存進行查詢的時候,如果鍵值是ID,那麼,就直接取出ID對應的對象。如果鍵值是SQL,那麼,就以SQL為鍵值,擷取的就是一個ID清單。然後,根據這個ID清單,從緩存中把對象一個個取出來,組成一個對象清單,最後傳回。

就這樣,ID緩存和Query緩存就統一起來了。

除了命中率問題,緩存還需要考慮的重要問題是過期資料清理問題。最簡單的過期資料清理政策是按時清理,定義一個清理周期,每隔一定時間就清除緩存中所有資料。

稍微複雜一點的過期資料清理政策是實時清理。當程式遇到任何一條增删改SQL語句的時候,就根據SQL中涉及到的表名,把緩存中所有相關的資料全都清理掉。這種政策很可能會誤殺不少沒有過期的資料。但緩存就是這樣,甯可誤殺一千個非過期資料,不可放過一個過期資料。

如果是增删改操作特别多的情況下(按理來說,這種情況下就不應該用緩存),還想使用緩存的話,那麼,可以采用更加靈活的資料清除政策,比如,由程式員自己指定清除那些資料,畢竟,程式員自己對于代碼邏輯是最了解的。

繼續閱讀