下一代數據存儲的演示一般都是圍繞著社交媒體:以Twitter類演示應用居多。遺憾的是此類應用傾向於使用簡單的數據模型。這就是為什麼本章以及後續各章中要使用更豐富的電子商務領域模型了,其中包含了很多為人熟知的數據建模模式。而且也不難想像產品、分類、產品評論與訂單是如何在RDBMS中建模的。這會讓即將登場的示例更具啟發性,因為可以將它們與預想的Schema設計進行對比。
電子商務通常是專屬於RDBMS的一塊領域,這是有原因的。首先,電子商務站點通常要求有事務,而事務是RDBMS的主要特性。其次,直到最近為止,要求有富數據模型和完善的查詢的領域都會假定自己最適合RDBMS。下面的例子會對第二個假設提出質疑。
在繼續之前,需要做一點說明。在本書中介紹如何構建完整的電子商務後端並不實際。我們要做的是選取少量的電子商務實體,演示如何在MongoDB中對其進行建模,尤其會關注產品與分類、用戶與訂單,還有產品評論。針對每個實體,我都將展示示例文檔。隨後,我們還會看到一些數據庫特性,它們能進一步補充文檔的結構。
對很多開發者而言,數據建模總會伴隨著對像映射,為此你可能使用過對像關係映射庫,比如Java的Hibernate或者Ruby的ActiveRecord,這些庫幾乎就是在RDBMS上有效構建應用程序的必需品。但是MongoDB對此幾乎沒什麼需要,部分原因是文檔已經是類似對象的表述了。此外還和驅動有關,驅動為MongoDB提供了相當高階的接口,僅用驅動接口就能在MongoDB之上構建完整的應用程序。
有人說,對像映射器很方便,因為它們有助於進行驗證、類型檢查和關聯。很多成熟的MongoDB對像映射器在基本語言驅動之上又提供了一層額外的抽像,在大項目中可以考慮選擇其一。1但是,不管是否使用對像映射器,最終總是在和文檔打交道。這就是本章關注於文檔本身的原因。知道在一個精心設計的MongoDB Schema裡文檔是什麼樣的,這能讓你更好地使用該數據庫,有沒有對象映射器都是如此。
1. 想知道哪個對象映射器是你語言裡最流行的,可以看看http://mongodb.org裡的建議。
4.2.1 產品與分類
產品和分類是任何電子商務站點都必不可少的內容。在一個正規化的RDBMS模型中,產品傾向於使用大量的數據表,總會有張表用來存儲基本產品信息,比如名稱和SKU2,還有一些其他表用來關聯送貨信息和價格歷史。如果系統允許產品帶有任意屬性,那麼還需要一系列複雜的表來定義並存儲那些屬性,正如你在第1章的Magento示例中看到的那樣。這種多表Schema在RDBMS表聯結能力的幫助下很有用。
2. SKU是Stock Keeping Unit的縮寫,商品最小分類單元。——譯者注
在MongoDB中對產品建模應該會簡單很多,因為集合併不一定要有Schema,任何產品文檔都可以容納產品所需的各種動態屬性。通過使用數組來容納內部文檔結構,還可以將RDBMS裡的多表表述精簡成一個MongoDB的集合。更具體一點,下面是一個取自園藝商店的示例產品。
代碼清單4-1 示例產品文檔
doc = { _id: new ObjectId(\"4c4b1476238d3b4dd5003981\"), slug: \"wheel-barrow-9092\", sku: \"9092\", name: \"Extra Large Wheel Barrow\", description: \"Heavy duty wheel barrow...\", details: { weight: 47, weight_units: \"lbs\", model_num: 4039283402, manufacturer: \"Acme\", color: \"Green\" }, total_reviews: 4, average_review: 4.5, pricing: { retail: 589700, sale: 489700, }, price_history: [ {retail: 529700, sale: 429700, start: new Date(2010, 4, 1), end: new Date(2010, 4, 8) }, {retail: 529700, sale: 529700, start: new Date(2010, 4, 9), end: new Date(2010, 4, 16) }, ], category_ids: [new ObjectId(\"6a5b1476238d3b4dd5000048\"), new ObjectId(\"6a5b1476238d3b4dd5000049\")], main_cat_id: new ObjectId(\"6a5b1476238d3b4dd5000048\"), tags: [\"tools\", \"gardening\", \"soil\"], }
該文檔包含基本的name、sku
和description
字段。_id
字段裡還存儲著標準的MongoDB對像ID。此外,這裡定義了一個短名稱wheel-barrow-9092
,以便提供有意義的URL。MongoDB的用戶有時會抱怨URL裡的對象ID太難看了,通常來說,你不會喜歡下面這樣的URL:
http://mygardensite.org/products/4c4b1476238d3b4dd5003981
有意義的ID會更好一點:
http://mygardensite.org/products/wheel-barrow-9092
如果要為文檔生成一個URL,我通常會建議增加一個短名稱字段。這個字段應該有唯一性索引,這樣就能把其中的值用作主鍵。假設將這個文檔存儲在 products
集合裡,可以像下面這樣創建唯一性索引:
db.products.ensureIndex({slug: 1}, {unique: true})
如果在slug
上有唯一性索引,那麼需要在插入產品文檔時使用安全模式,這樣就能得知插入成功與否。需要的話,可以換一個不同的短名稱進行重試。舉個例子,假設園藝商店裡銷售多種手推車,在開售新的手推車時,代碼需要為新產品生成一個唯一的短名稱。以下是在Ruby中執行插入的代碼:
@products.insert({:name => \"Extra Large Wheel Barrow\", :sku => \"9092\", :slug => \"wheel-barrow-9092\"}, :safe => true)
這裡需要重點說明的是指定了:safe => true
。如果插入成功,沒有拋出異常,表明選擇了一個唯一的短名稱。但如果拋出異常,代碼就需要用一個新的短名稱進行重試。
接下來,有一個名為details
的鍵,指向包含不同產品詳細信息的子文檔,其中規定了重量、計重單位以及廠家的型號代碼,你也可以存儲其他特定屬性。舉例來說,如果在銷售種子,可以在其中包含預期產量與收穫時間;如果在銷售割草機,可以包含馬力、燃料類型和護根選項。details
屬性為這些動態屬性提供了一個很好的容器。
請注意,還可以在同一個文檔中存儲產品的當前價格和歷史價格。pricing
鍵指向一個包含零售價和特價的對象。price_history
則恰恰相反,指向一個價格數組。像這樣存儲文檔副本是一種常見的版本化技術。
隨後是一個產品標籤名稱的數組,在第1章裡我們看到過類似的標籤示例,這個技術值得反覆演示。這是最簡單、最佳的存儲條目相關標籤的途徑,同時還能保證查詢的高效性,因為可以索引數組鍵。
那麼關係呢?我們可以使用富文檔結構,比如子文檔和數組,在單個文檔中存儲產品細節、價格和標籤,但最終可能需要關聯其他集合中的文檔。開始時,我們會把產品關聯到一個分類裡,這種產品與分類之間的關係通常會表示為多對多關係,每個產品屬於多個分類,而每個分類又能包含多個產品。在RDBMS中,我們會使用聯結表表示這樣的多對多關係。聯結表在單個表中存儲兩個表間的所有關係引用。使用SQL的join
可以發起一條查詢,檢索產品以及它的全部分類,反之亦然。
MongoDB不支持聯結操作,因此需要一種不同的多對多策略。看看手推車的文檔,你會發現一個名為category_ids
的字段,其中包含一個對像ID的數組。每個對象ID都是一個指針,指向某個分類文檔的_id
字段。下面是一個演示用的分類文檔。
代碼清單4-2 分類文檔
doc = { _id: new ObjectId(\"6a5b1476238d3b4dd5000048\"), slug: \"gardening-tools\", ancestors: [{ name: \"Home\", _id: new ObjectId(\"8b87fb1476238d3b4dd500003\"), slug: \"home\" }, { name: \"Outdoors\", _id: new ObjectId(\"9a9fb1476238d3b4dd5000001\"), slug: \"outdoors\" } ], parent_id: new ObjectId(\"9a9fb1476238d3b4dd5000001\"), name: \"Gardening Tools\", description: \"Gardening gadgets galore!\", }
如果回頭看看產品文檔,仔細觀察category_ids
字段裡的對象ID,你會發現該產品關聯了剛才的Gardening Tools分類。在產品文檔中放入category_ids
數組鍵讓那些多對多查詢成為可能。舉例來說,查詢Gardening Tools分類裡的所有產品,代碼很簡單:
db.products.find({category_ids => category[\'_id\']})
要查詢指定產品的所有分類,可以使用$in
操作符,它類似於SQL的IN指令:
db.categories.find({_id: {$in: product[\'category_ids\']}})
有了剛才描述的多對多關係,再來說說分類文檔本身。你一定已經注意到了標準的_id、slug
、name
和description
字段,它們都很直截了當,可是父文檔數組的含義就不那麼清楚了。為什麼要用這麼大的篇幅為每個文檔冗餘存儲祖先分類?事實是分類總是被設想為有層級的,在數據庫中表示這種層級的方式有很多種。3選擇的策略總是依賴於應用程序的需求。本例中,由於MongoDB不支持關聯查詢,我們選擇了去正規化,將上級分類的名稱放入每個子分類的文檔裡。這樣一來,查詢Gardening Products分類時,就不需要執行額外的查詢來獲取上級分類(Outdoors和Home)的名稱和URL了。
3. 在這篇MySQL開發者的文章裡(http://mng.bz/83w4)介紹了兩種方法——鄰接列表和內嵌集合。
一些開發者可能會覺得這種級別的去正規化是不可接受的。還有其他方式可以用來表示樹結構,附錄B裡就討論了其中一種方式。但就目前而言,最佳的Schema是由應用程序需求決定的,無需受制於理論,試著接受各種可能性吧。在接下來的兩章裡你將看到更多對這種結構進行查詢與更新的例子,其中的基本原理會變得越來越明朗。
4.2.2 用戶與訂單
看看如何對用戶與訂單建模,以此闡明另一種常見關係——一對多關係,就是說每個用戶都有多張訂單。在RDBMS中,會在訂單表裡使用外鍵;此處的慣例很相似。請看代碼清單4-3。
代碼清單4-3 電子商務訂單,帶有條目明細、價格和送貨地址
doc = { _id: ObjectId(\"6a5b1476238d3b4dd5000048\") user_id: ObjectId(\"4c4b1476238d3b4dd5000001\") state: \"CART\", line_items: [ { _id: ObjectId(\"4c4b1476238d3b4dd5003981\"), sku: \"9092\", name: \"Extra Large Wheel Barrow\", quantity: 1, pricing: { retail: 5897, sale: 4897, } }, { _id: ObjectId(\"4c4b1476238d3b4dd5003981\"), sku: \"10027\", name: \"Rubberized Work Glove, Black\", quantity: 2, pricing: { retail: 1499, sale: 1299 } } ], shipping_address: { street: \"588 5th Street\", city: \"Brooklyn\", state: \"NY\", zip: 11215 }, sub_total: 6196 }
訂單中的第二個屬性user_id
保存了一個用戶的_id
,它實際是一個指向示例用戶(代碼清單4-4中的用戶,我們稍後會討論這段代碼)的指針。這一設計能方便地查詢關係中的任意一方。要找到一個用戶的所有訂單非常簡單:
db.orders.find({user_id: user[\'_id\']})
要獲取指定訂單的用戶同樣很簡單:
user_id = order[\'user_id\'] db.users.find({_id: user_id})
像這樣使用對像ID,能很方便地在訂單與用戶之間建立起一對多關係。
我們再來看看訂單文檔中的其他亮點。一般來說,我們會使用豐富的表示方式來承載文檔數據模型,文檔中既有訂單條目明細又有送貨地址。在正規化的關係型模型中,這些屬性會被放在不同的數據表裡。而這裡,條目明細包含一個子文檔數組,每個子文檔都描述了購物車裡的一個產品。送貨地址屬性指向一個對象,其中包含了地址信息。
讓我們花點時間討論一下這個表述的優點。首先,它易於人們理解,完整的訂單概念都能被封裝在一個實體裡,包括條目明細、送貨地址以及最終的支付信息。查詢數據庫時,可以通過一條簡單的查詢返回整個訂單對象。其次,可以把產品在購買時的信息保存在訂單文檔裡。最後,正如接下來的兩章裡會看到的,能輕而易舉地查詢並修改訂單文檔,這應該也是你能想到的。
用戶文檔也用了類似的模式,其中保存了一個地址文檔的列表,還有一個支付方法文檔的列表。此外,在文檔的最上層還能找到任何用戶模型裡都有的基本常見屬性。與產品的短名稱字段一樣,在用戶名字段上添加了唯一索引。
代碼清單4-4 用戶文檔,帶有地址和支付方法
{ _id: new ObjectId(\"4c4b1476238d3b4dd5000001\"), username: \"kbanker\", email: \"[email protected]\", first_name: \"Kyle\", last_name: \"Banker\", hashed_password: \"bd1cfa194c3a603e7186780824b04419\", addresses: [ {name: \"home\", street: \"588 5th Street\", city: \"Brooklyn\", state: \"NY\", zip: 11215}, {name: \"work\", street: \"1 E. 23rd Street\", city: \"New York\", state: \"NY\", zip: 10010} ], payment_methods: [ {name: \"VISA\", last_four: 2127, crypted_number: \"43f6ba1dfda6b8106dc7\", expiration_date: new Date(2014, 4) } ] }
4.2.3 評論
最後出場的示例數據模型是產品評論。一般而言,每個產品都會有多條評論,而該關係是用對像ID引用product_id
來編碼的,正如你在示例評論文檔中看到的那樣。
代碼清單4-5 產品評論文檔
{ _id: new ObjectId(\"4c4b1476238d3b4dd5000041\"), product_id: new ObjectId(\"4c4b1476238d3b4dd5003981\"), date: new Date(2010, 5, 7), title: \"Amazing\", text: \"Has a squeaky wheel, but still a darn good wheel barrow.\", rating: 4, user_id: new ObjectId(\"4c4b1476238d3b4dd5000041\"), username: \"dgreenthumb\", helpful_votes: 3, voter_ids: [ new ObjectId(\"4c4b1476238d3b4dd5000041\"), new ObjectId(\"7a4f0376238d3b4dd5000003\"), new ObjectId(\"92c21476238d3b4dd5000032\") ] }
大多數剩餘屬性的含義都不言而喻。我們存儲了評論的日期、標題和內容、用戶的評分,以及用戶的ID。有些意外的是還存儲了用戶名。畢竟,如果是RDBMS,可以通過關聯 用戶表來獲取用戶名。但因為在MongoDB中沒有關聯查詢,所以有兩個可選方案:針對每條評論再去查詢一次用戶集合,或者是接受去正規化。當所查詢的屬性(用戶名)極有可能不會改變時,針對每條評論發起一次查詢會很浪費。誠然,我們可以選擇正規化的做法,通過兩次MongoDB查詢來顯示所有的評論,但這裡正在為常見情況設計Schema。因為修改用戶名時需要在每個出現用戶名的地方都做修改,這意味著修改用戶名的代價更高了。但它的發生頻率非常低,這足以讓這種做法成為一個合理的設計選擇。
另一點值得注意的地方是在評論文檔裡保存了投票信息。用戶通常能對評論進行投票,這裡在投票者ID數組中保存了每個投票用戶的對象ID,這能避免用戶對同一評論多次投票,同時也讓我們有能力查詢某個用戶投過票的所有評論。注意,這裡還緩存了有用投票的總數,以便能基於有用程度對評論進行排序。
目前,我們已經覆蓋基本的電子商務數據模型了。如果這是你第一次接觸MongoDB數據模型,那麼要對其實用程度有所期待還是需要一定信心的。接下來的兩章裡會詳細探討該模型中剩下的東西,包括不重複地添加投票、修改訂單、智能地查詢產品,借此分別闡述查詢與更新。