關(guān)系數(shù)據(jù)庫已經(jīng)統(tǒng)治數(shù)據(jù)存儲30 多年了,但是無模式(或NoSQL)數(shù)據(jù)庫的逐漸流行表明變化正在發(fā)生。盡管 RDBMS 為在傳統(tǒng)的客戶端服務(wù)器架構(gòu)中存儲數(shù)據(jù)提供了一個堅實的基礎(chǔ),但它不能輕松地(或便宜地)擴展到多個節(jié)點。在高度可伸縮的 Web 應(yīng)用程序(比如 Facebook 和 Twitter)的時代,這是一個非常不幸的弱點。
盡管關(guān)系數(shù)據(jù)庫的早期替代方案(還記得面向?qū)ο蟮臄?shù)據(jù)庫嗎?)不能解決真正緊急的問 題,NoSQL 數(shù)據(jù)庫(比如 Google 的 Bigtable 和 Amazon 的 SimpleDB)卻作為對 Web 的高可伸縮性需求的直接響應(yīng)而崛起。本質(zhì)上,NoSQL 可能是一個殺手問題的殺手應(yīng)用程序 —隨著 Web 2.0 的演變,Web 應(yīng)用程序開發(fā)人員可能會遇到更多,而不是更少這樣的應(yīng)用程序。
在這期 Java 開發(fā) 2.0 中,我將向您介紹無模式數(shù)據(jù)建模,這是經(jīng)過關(guān)系思維模式訓(xùn)練的許多開發(fā)人員使用 NoSQL 的主要障礙。您將了解到,從一個域模型(而不是關(guān)系模型)入手是簡化您的改變的關(guān)鍵。如果您使用 Bigtable(如我的示例所示),您可以借助 Gaelyk:Google App Engine 的一個輕量級框架擴展。
NoSQL:一種新的思維方式?
當(dāng)開發(fā)人員談?wù)摲顷P(guān)系或 NoSQL 數(shù)據(jù)庫時,經(jīng)常提到的第一件事是他們需要改變思維方式。我認為,那實際上取決于您的初始數(shù)據(jù)建模方法。如果您習(xí)慣通過首先建模數(shù)據(jù)庫結(jié)構(gòu)(即首先確定表及 其關(guān)聯(lián)關(guān)系)來設(shè)計應(yīng)用程序,那么使用一個無模式數(shù)據(jù)存儲(比如 Bigtable)來進行數(shù)據(jù)建模則需要您重新思考您的做事方式。但是,如果您從域模型開始設(shè)計您的應(yīng)用程序,那么 Bigtable 的無模式結(jié)構(gòu)將看起來更自然。
非關(guān)系數(shù)據(jù)存儲沒有聯(lián)接表或主鍵,甚至沒有外鍵這個概念(盡管這兩種類型的鍵以一種更松散的 形式出現(xiàn))。因此,如果您嘗試將關(guān)系建模作為一個 NoSQL 數(shù)據(jù)庫中的數(shù)據(jù)建模的基礎(chǔ),那么您可能最后以失敗告終。從域模型開始將使事情變得簡單;實際上,我已經(jīng)發(fā)現(xiàn),域模型下的無模式結(jié)構(gòu)的靈活性正在重新煥發(fā)生 機。
從關(guān)系數(shù)據(jù)模型遷移到無模式數(shù)據(jù)模型的相對復(fù)雜程度取決于您的方法:即您從基于關(guān)系的設(shè)計開 始還是從基于域的設(shè)計開始。當(dāng)您遷移到 CouchDB 或 Bigtable 這樣的數(shù)據(jù)庫時,您 的確會喪失 Hibernate(至少現(xiàn)在)這樣的成熟的持久存儲平臺的順暢感覺。另一方面,您卻擁有能夠親自構(gòu)建它的 “綠地效果”。在此過程中,您將深入了解無模式數(shù)據(jù)存儲。
實體和關(guān)系
無模式數(shù)據(jù)存儲賦予您首先使用對象來設(shè)計域模型的靈活性(Grails 這樣的較新的框架自動支持這種靈活性)。您的下一步工作是將您的域映射到底層數(shù)據(jù)存儲,這在使用 Google App Engine 時再簡單不過了。
在文章 “Java 開發(fā) 2.0:針對 Google App Engine 的 Gaelyk” 中,我介紹了 Gaelyk —— 一個基于 Groovy 的框架,該框架有利于使用 Google 的底層數(shù)據(jù)存儲。那篇文章的主要部分關(guān)注如何利用 Google 的 Entity對象。下面的示例(來自那篇文章)將展示對象實體如何在 Gaelyk 中工作。
清單1. 使用 Entity 的對象持久存儲
- def ticket = new Entity("ticket")
- ticket.officer = params.officer
- ticket.license = params.plate
- ticket.issuseDate = offensedate
- ticket.location = params.location
- ticket.notes = params.notes
- ticket.offense = params.offense
這種對象持久存儲方法很有效,但容易看出,如果您頻繁使用票據(jù)實體 —例如,如果您正在各種 servlet 中創(chuàng)建(或查找)它們,那么這種方法將變得令人厭煩。使用一個公共 servlet(或 Groovlet)來為您處理這些任務(wù)將消除其中一些負擔(dān)。一種更自然的選擇——我將稍后展示——將是建模一個 Ticket對象。
返回比賽
我不會重復(fù) Gaelyk 簡介中的那個票據(jù)示例,相反,為保持新鮮感,我將在本文中使用一個賽跑主題,并構(gòu)建一個應(yīng)用程序來展示即將討論的技術(shù)。
如圖 1 中的 “多對多” 圖表所示,一個 Race擁有多個 Runner,一個 Runner可以屬于多個 Race。
圖1. 比賽和參賽者
如果我要使用一個關(guān)系表結(jié)構(gòu)來設(shè)計這個關(guān)系,至少需要 3 個表:第 3 表將是鏈接一個 “多對多” 關(guān)系的聯(lián)接表。所幸我不必局限于關(guān)系數(shù)據(jù)模型。相反,我將使用 Gaelyk(和 Groovy 代碼)將這個 “多對多” 關(guān)系映射到 Google 針對 Google App Engine 的 Bigtable 抽象。事實上,Gaelyk 允許將 Entity當(dāng)作 Map,這使得映射過程相當(dāng)簡單。
無模式數(shù)據(jù)存儲的好處之一是無須事先知道所有事情,也就是說,與使用關(guān)系數(shù)據(jù)庫架構(gòu)相比,可 以更輕松地適應(yīng)變化。(注意,我并非暗示不能更改架構(gòu);我只是說,可以更輕松地適應(yīng)變化。)我不打算定義我的域?qū)ο笊系膶傩?—我將其推遲到 Groovy 的動態(tài)特性(實際上,這個特性允許創(chuàng)建針對 Google 的 Entity對象的域?qū)ο蟠恚O喾矗覍盐业臅r間花費在確定如何查找對象并處理關(guān)系上。這是 NoSQL 和各種利用無模式數(shù)據(jù)存儲的框架還沒有內(nèi)置的功能。
Model 基類
我將首先創(chuàng)建一個基類,用于容納 Entity對象的一個實例。然后,我將允許一些子類擁有一些動態(tài)屬性,這些動態(tài)屬性將通過 Groovy 的方便的 setProperty方法添加到對應(yīng)的 Entity實例。setProperty針對對象中實際上不存在的任何屬性設(shè)置程序調(diào)用。(如果這聽起來聳人聽聞,不用擔(dān)心,您看到它的實際運行后就會 明白。)
清單2展示了位于我的示例應(yīng)用程序的一個 Model實例的第一個 stab:
清單2. 一個簡單的 Model 基類
- package com.b50.nosql
- import com.google.appengine.api.datastore.DatastoreServiceFactory
- import com.google.appengine.api.datastore.Entity
- abstract class Model {
- def entity
- static def datastore = DatastoreServiceFactory.datastoreService
- public Model(){
- super()
- }
- public Model(params){
- this.@entity = new Entity(this.getClass().simpleName)
- params.each{ key, val ->
- this.setProperty key, val
- }
- }
- def getProperty(String name) {
- if(name.equals("id")){
- return entity.key.id
- }else{
- return entity."${name}"
- }
- }
- void setProperty(String name, value) {
- entity."${name}" = value
- }
- def save(){
- this.entity.save()
- }
- }
注意抽象類如何定義一個構(gòu)造函數(shù),該函數(shù)接收屬性的一個 Map ——我總是可以稍后添加更多構(gòu)造函數(shù),稍后我就會這么做。這個設(shè)置對于 Web 框架十分方便,這些框架通常采用從表單提交的參數(shù)。Gaelyk 和 Grails 將這樣的參數(shù)巧妙地封裝到一個稱為 params的對象中。這個構(gòu)造函數(shù)迭代這個 Map并針對每個 “鍵 / 值” 對調(diào)用 setProperty方法。
檢查一下 setProperty方法就會發(fā)現(xiàn) “鍵” 設(shè)置為底層 entity的屬性名稱,而對應(yīng)的 “值” 是該 entity的值。
Groovy 技巧
如前所述,Groovy 的動態(tài)特性允許我通過 get和 set Property方法捕獲對不存在的屬性的方法調(diào)用。這樣,清單 2 中的 Model的子類不必定義它們自己的屬性 —它們只是將對一個屬性的所有調(diào)用委托給這個底層 entity對象。
清單 2 中的代碼執(zhí)行了一些特定于 Groovy 的操作,值得一提。首先,可以通過在一個屬性前面附加一個 @來繞過該屬性的訪問器方法。我必須對構(gòu)造函數(shù)中的 entity對象引用執(zhí)行上述操作,否則我將調(diào)用 setProperty方法。很明顯,在這個關(guān)頭調(diào)用 setProperty將打破這種模式,因為 setProperty方法中的 entity變量將是 null。
其次,構(gòu)造函數(shù)中的調(diào)用 this.getClass().simpleName將設(shè)置 entity的 “種類” —— simpleName屬性將生成一個不帶包前綴的子類名稱(注意,simpleName的確是對 getSimpleName的調(diào)用,但 Groovy 允許我不通過對應(yīng)的 JavaBeans 式的方法調(diào)用來嘗試訪問一個屬性)。
最后,如果對 id屬性(即,對象的鍵)進行一個調(diào)用,getProperty方法很智能,能夠詢問底層 key以獲取它的 id。在 Google App Engine 中,entities的 key屬性將自動生成。
Race 子類
定義 Race子類很簡單,如清單 3 所示:
清單3. 一個 Race 子類
- package com.b50.nosql
- class Race extends Model {
- public Race(params){
- super(params)
- }
- }
當(dāng)一個子類使用一列參數(shù)(即一個包含多個 “鍵 / 值” 對的 Map)實例化時,一個對應(yīng)的 entity將在內(nèi)存中創(chuàng)建。要持久存儲它,只需調(diào)用 save方法。
清單4. 創(chuàng)建一個 Race 實例并將其保存到 GAE 的數(shù)據(jù)存儲
- import com.b50.nosql.Runner
- def iparams = [:]
- def formatter = new SimpleDateFormat("MM/dd/yyyy")
- def rdate = formatter.parse("04/17/2010")
- iparams["name"] = "Charlottesville Marathon"
- iparams["date"] = rdate
- iparams["distance"] = 26.2 as double
- def race = new Race(iparams)
- race.save()
清單4 是一個 Groovlet,其中,一個 Map(稱為 iparams)創(chuàng)建為帶有 3 個屬性 ——一次比賽的名稱、日期和距離。(注意,在 Groovy 中,一個空白 Map通過 [:]創(chuàng)建。)Race的一個新實例被創(chuàng)建,然后通過 save方法存儲到底層數(shù)據(jù)存儲。
可以通過 Google App Engine 控制臺來查看底層數(shù)據(jù)存儲,確保我的數(shù)據(jù)的確在那里,如圖 2 所示:
圖2. 查看新創(chuàng)建的Race
查找程序方法生成持久存儲的實體
現(xiàn)在我已經(jīng)存儲了一個 Entity,擁有查找它的能力將有所幫助。接下來,我可以添加一個 “查找程序” 方法。在本例中,我將把這個 “查找程序” 方法創(chuàng)建為一個類方法(static)并且允許通過名稱查找這些 Race(即基于 name屬性搜索)。稍后,總是可以通過其他屬性添加其他查找程序。
我還打算對我的查找程序采用一個慣例,即指定:任何名稱中不帶單詞 all的查找程序都企圖找到 一個實例。名稱中包含單詞 all的查找程序(如 findAllByName)能夠返回一個實例 Collection或 List。清單 5 展示了 findByName查找程序:
清單5. 一個基于 Entity 名稱搜索的簡單查找程序
- static def findByName(name){
- def query = new Query(Race.class.simpleName)
- query.addFilter("name", Query.FilterOperator.EQUAL, name)
- def preparedQuery = this.datastore.prepare(query)
- if(preparedQuery.countEntities() > 1){
- return new Race(preparedQuery.asList(withLimit(1))[0])
- }else{
- return new Race(preparedQuery.asSingleEntity())
- }
- }
這個簡單的查找程序使用 Google App Engine 的 Query和 PreparedQuery類型來查找一個類型為 “Race” 的實體,其名稱(完全)等同于傳入的名稱。如果有超過一個 Race符合這個標(biāo)準(zhǔn),查找程序?qū)⒎祷匾粋€列表的第一項,這是分頁限制 1(withLimit(1))所指定的。
對應(yīng)的 findAllByName與上述方法類似,但添加了一個參數(shù),指定 您想要的實體個數(shù),如清單 6 所示:
清單 6. 通過名稱找到全部實體
- static def findAllByName(name, pagination=10){
- def query = new Query(Race.class.getSimpleName())
- query.addFilter("name", Query.FilterOperator.EQUAL, name)
- def preparedQuery = this.datastore.prepare(query)
- def entities = preparedQuery.asList(withLimit(pagination as int))
- return entities.collect { new Race(it as Entity) }
- }
與前面定義的查找程序類似,findAllByName通過名稱找到 Race實例,但是它返回 所有 Race。順便說一下,Groovy 的 collect方法非常靈活:它允許刪除創(chuàng)建 Race實例的對應(yīng)的循環(huán)。注意,Groovy 還支持方法參數(shù)的默認值;這樣,如果我沒有傳入第 2 個值,pagination將擁有值 10。
清單7. 查找程序的實際運行
- def nrace = Race.findByName("Charlottesville Marathon")
- assert nrace.distance == 26.2
- def races = Race.findAllByName("Charlottesville Marathon")
- assert races.class == ArrayList.class
清單 7中的查找程序按照既定的方式運行:findByName返回一個實例,而 findAllByName返回一個 Collection(假定有多個 “Charlottesville Marathon”)。
“參賽者” 對象沒有太多不同
現(xiàn)在我已能夠創(chuàng)建并找到 Race的實例,現(xiàn)在可以創(chuàng)建一個快速的 Runner對象了。這個過程與創(chuàng)建初始的 Race實例一樣簡單,只需如清單 8 所示擴展 Model:
清單 8. 創(chuàng)建一個參賽者很簡單
- package com.b50.nosql
- class Runner extends Model{
- public Runner(params){
- super(params)
- }
- }
看看 清單 8,我感覺自己幾乎完成工作了。但是,我還需創(chuàng)建參賽者和比賽之間的鏈接。當(dāng)然,我將把它建模為一個 “多對多” 關(guān)系,因為我希望我的參賽者可以參加多項比賽。
沒有架構(gòu)的域建模
Google App Engine 在 Bigtable 上面的抽象不是一個面向?qū)ο蟮某橄螅患矗也荒茉瓨哟鎯﹃P(guān)系,但可以共享鍵。因此,為建模多個 Race和多個 Runner之間的關(guān)系,我將在每個 Race實例中存儲一列 Runner鍵,并在每個 Runner實例中存儲一列 Race鍵。
我必須對我的鍵共享機制添加一點邏輯,但是,因為我希望生成的 API 比較自然 —我不想詢問一個 Race以獲取一列 Runner鍵,因此我想要一列 Runner。幸運的是,這并不難實現(xiàn)。
在清單 9 中,我已經(jīng)添加了兩個方法到 Race實例。但一個 Runner實例被傳遞到 addRunner方法時,它的對應(yīng) id被添加到底層 entity的 runners屬性中駐留的 id的 Collection。如果有一個現(xiàn)成的 runners的 collection,則新的 Runner實例鍵將添加到它;否則,將創(chuàng)建一個新的 Collection,且這個 Runner的鍵(實體上的 id屬性)將添加到它。
清單9. 添加并檢索參賽者
- def addRunner(runner){
- if(this.@entity.runners){
- this.@entity.runners << runner.id
- }else{
- this.@entity.runners = [runner.id]
- }
- }
- def getRunners(){
- return this.@entity.runners.collect {
- new Runner( this.getEntity(Runner.class.simpleName, it) )
- }
- }
當(dāng)清單 9 中的 getRunners方法調(diào)用時,一個 Runner實例集合將從底層的 id集合創(chuàng)建。這樣,一個新方法(getEntity)將在 Model類中創(chuàng)建,如清單 10 所示:
清單10. 從一個id 創(chuàng)建一個實體
- def getEntity(entityType, id){
- def key = KeyFactory.createKey(entityType, id)
- return this.@datastore.get(key)
- }
getEntity方法使用 Google 的 KeyFactory類來創(chuàng)建底層鍵,它可以用于查找數(shù)據(jù)存儲中的一個單獨實體。
最后,定義一個新的構(gòu)造函數(shù)來接受一個實體類型,如清單 11 所示:
清單11. 一個新添加的構(gòu)造函數(shù)
- public Model(Entity entity){
- this.@entity = entity
- }
如清單 9、10和 11、以及 圖 1的對象模型所示,我可以將一個 Runner添加到任一 Race,也可以從任一Race獲取一列 Runner實例。在清單 12 中,我在這個等式的 Runner方上創(chuàng)建了一個類似的聯(lián)系。清單 12 展示了 Runner類的新方法。
清單12. 參賽者及其比賽
- def addRace(race){
- if(this.@entity.races){
- this.@entity.races << race.id
- }else{
- this.@entity.races = [race.id]
- }
- }
- def getRaces(){
- return this.@entity.races.collect {
- new Race( this.getEntity(Race.class.simpleName, it) )
- }
- }
這樣,我就使用一個無模式數(shù)據(jù)存儲創(chuàng)建了兩個域?qū)ο蟆?/p>
通過一些參賽者完成這個比賽
此前我所做的是創(chuàng)建一個 Runner實例并將其添加到一個 Race。如果我希望這個關(guān)系是雙向的,如圖1中我的對象模型所示,那么我也可以添加一些 Race實例到一些Runner,如清單 13 所示:
清單 13. 參加多個比賽的多個參賽者
- def runner = new Runner([fname:"Chris", lname:"Smith", date:34])
- runner.save()
- race.addRunner(runner)
- race.save()
- runner.addRace(race)
- runner.save()
將一個新的 Runner添加到 race并添加對Race的save的調(diào)用后,這個數(shù)據(jù)存儲已使用一列ID 更新,如圖 3 中的屏幕快照所示:
圖3. 查看一項比賽中的多個參賽者的新屬性
通過仔細檢查Google App Engine 中的數(shù)據(jù),可以看到,一個Race實體現(xiàn)在擁有了一個Runners 的list,如圖 4 所示。
圖4. 查看新的參賽者列表
同樣,在將一個 Race添加到一個新創(chuàng)建的 Runner實例之前,這個屬性并不存在,如圖 5 所示。
圖5. 一個沒有比賽的參賽者
但是,將一個 Race關(guān)聯(lián)到一個 Runner后,數(shù)據(jù)存儲將添加新的 races ids 的 list。
圖6. 一個參加比賽的參賽者
無模式數(shù)據(jù)存儲的靈活性正在刷新 —屬性按照需要自動添加到底層存儲。作為開發(fā)人員,我無須更新或更改架構(gòu),更談不上部署架構(gòu)了!
NoSQL 的利弊
當(dāng)然,無模式數(shù)據(jù)建模也有利有弊。回顧上面的比賽應(yīng)用程序,它的一個優(yōu)勢是非常靈活。如果我 決定將一個新屬性(比如 SSN)添加到一個 Runner,我不必進行大幅更改 —事實上,如果我將該屬性包含在構(gòu)造函數(shù)的參數(shù)中,那么它就會自動添加。對那些沒有使用一個 SSN 創(chuàng)建的舊實例而言,發(fā)生了什么事情?什么也沒發(fā)生!它們擁有一個值為 null的字段。
另一方面,我已經(jīng)明確表明要犧牲一致性和完整性來換取效率。這個應(yīng)用程序的當(dāng)前數(shù)據(jù)架構(gòu)沒有 向我施加任何限制 —理論上我可以為同一個對象創(chuàng)建無限個實例。在 Google App Engine 引擎的鍵處理機制下,它們都有惟一的鍵,但其他屬性都是一致的。更糟糕的是,級聯(lián)刪除不存在,因此如果我使用相同的技術(shù)來建模一個 “一對多” 關(guān)系并刪除父節(jié)點,那么我得到一些無效的子節(jié)點。當(dāng)然,我可以實現(xiàn)自己的完整性檢查 —但關(guān)鍵是,我必須親自動手(就像完成其他任務(wù)一樣)。
使用無模式數(shù)據(jù)存儲需要嚴明的紀(jì)律。如果我創(chuàng)建各種類型的 Races —有些有名稱,有些沒有,有些有 date屬性,而另一些有 race_date屬性 —那么我只是在搬起石頭砸自己(或使用我的代碼的人)的腳。
當(dāng)然,也有可能聯(lián)合使用 JDO、JPA 和 Google App Engine。在多個項目上使用過關(guān)系模型和無模式模型后,我可以說 Gaelyk 的低級 API 最靈活,使用最方便。使用 Gaelyk 的另一個好處是能夠深入了解 Bigtable 和一般的無模式數(shù)據(jù)存儲。
結(jié)束語
流行時尚來了又去,有時無需理會它們(明智的建議來自一個衣櫥里滿是休閑服的家伙)。但 NoSQL 看起來不太像一種時尚,更像是高度可伸縮的 Web 應(yīng)用程序開發(fā)的一個新興基礎(chǔ)。NoSQL 數(shù)據(jù)庫不會替代 RDBMS,但是,它們將補充它。無數(shù)成功的工具和框架基于關(guān)系數(shù)據(jù)庫,RDBMSs 本身似乎沒有面臨過時的危險。
總之,NoSQL 數(shù)據(jù)庫的作用是向?qū)ο?mdash;—關(guān)系數(shù)據(jù)模型提供一個及時的替代方案。它們向我們展示,有些事情是可行的,并且對于一些特定的、高度強制的用例甚至更好。無模式 數(shù)據(jù)庫最適用于需要高速數(shù)據(jù)檢索和可伸縮性的多節(jié)點 Web 應(yīng)用程序。它們還有一個極好的副作用,即允許開發(fā)人員從一個面向域的視角、而不是關(guān)系視角進行數(shù)據(jù)建模。