前言
先簡單介紹一下KeystoneJS。這是一個依靠Node.js + MongoDB打造的,能夠靈活配置的CMS系統(tǒng)。
使用官方提供的簡單方式配置,可以配出標準類型的博客系統(tǒng),包括文章系統(tǒng)(含有分類機制)、相冊系統(tǒng)、私信系統(tǒng)、用戶系統(tǒng)。若需要更高級的自定義配置,需要手寫一些js文件。
官網(wǎng)地址 http://keystonejs.com/
中文官網(wǎng) http://keystonejs.com/zh/
此篇即用最簡單、標準的Keystone博客模版,記錄KeystoneJS是如何使用MongoDB存儲內(nèi)容的。
KeystoneJS中的數(shù)據(jù)庫
概覽
初始化之后,會帶有一個Admin賬戶,登陸賬戶,創(chuàng)建一個文章分類(PostCategory),創(chuàng)建兩篇文章(Post),創(chuàng)建一個相冊(Gallary)并上傳少量圖片。創(chuàng)建另一個用戶guest,并向管理員發(fā)起一個信息。
此時查看數(shù)據(jù)庫中的集合,如下所示:
> show collections
app_updates
enquiries
galleries
postcategories
posts
users
除了app_updates存儲版本升級信息,這里不細說,其他的看下文。
博客系統(tǒng)
默認的博客系統(tǒng)包括文章(Post)和文章分類(PostCategory)。
分類(PostCategories)
首先創(chuàng)建一個叫做瞎扯的分類,然后查看postcategories集合。
> db.postcategories.find().pretty()
{
"_id" : ObjectId("59f9384970871a41d3ff7d66"),
"key" : "59f9384970871a41d3ff7d66",
"name" : "瞎扯",
"__v" : 0
}
>
其中__v字段是mongoose(一個Node上常用的MongoDB數(shù)據(jù)庫ORM)增加的,mongoose用這個字段配以一些機制,增強數(shù)據(jù)一致性、安全性,與存儲的內(nèi)容無關。
剩下的有效字段包括_id,key,name,且key只是_id的字符串版本。沒有其他多余的東西。
接著查看索引:
> db.postcategories.getIndexes()
[
{
"v" : 1,
"key" : {
"_id" : 1
},
"name" : "_id_",
"ns" : "r-blog.postcategories"
},
{
"v" : 1,
"unique" : true,
"key" : {
"key" : 1
},
"name" : "key_1",
"ns" : "r-blog.postcategories",
"background" : true
}
]
>
可以看到_id和key有索引,key額外添加了unique屬性。在KeyStone默認博客配置中,需要通過_id或其字符串查詢,少有直接通過name進行的查詢。
文章(Posts)
創(chuàng)建了兩篇范例文章后,查看數(shù)據(jù)庫posts集合:
> db.posts.find().pretty()
{
"_id" : ObjectId("59f9388a70871a41d3ff7d67"),
"slug" : "59f9388a70871a41d3ff7d67",
"title" : "這是一篇瞎扯的文章",
"categories" : [
ObjectId("59f9384970871a41d3ff7d66")
],
"state" : "published",
"__v" : 1,
"author" : ObjectId("59f937eb70871a41d3ff7d64"),
"content" : {
"brief" : "<p>這里是Content Brief部分,大概是一句話的簡介。</p>",
"extended" : "<p>這里是Content Extended部分,應該是正文。</p>\r\n<p>所以多寫一句話,讓字數(shù)稍微多多多多多多那么一點。</p>"
},
"image" : {
"public_id" : "tqcx3wzhgshzjp22zfh0",
"version" : 1509505196,
"signature" : "89f18cac7b111d0865515cf25455c10c6824a59b",
"width" : 640,
"height" : 640,
"format" : "jpg",
"resource_type" : "image",
"url" : "http://res.cloudinary.com/keystone-demo/image/upload/v1509505196/tqcx3wzhgshzjp22zfh0.jpg",
"secure_url" : "https://res.cloudinary.com/keystone-demo/image/upload/v1509505196/tqcx3wzhgshzjp22zfh0.jpg"
},
"publishedDate" : ISODate("2017-10-31T16:00:00Z")
}
{
"_id" : ObjectId("59f939cb70871a41d3ff7d6c"),
"slug" : "this-is-an-example-post-with-english-title",
"title" : "This is an example post with english title",
"categories" : [ ],
"state" : "published",
"__v" : 0,
"author" : ObjectId("59f937eb70871a41d3ff7d64"),
"content" : {
"brief" : "<p>Just to try the slug...</p>",
"extended" : "<p>hmmmmmm.</p>"
},
"publishedDate" : null
}
>
第一篇文章盡可能用到了全部的域;第二篇僅僅是為了測試slug。在slug不被支持的場景(中文標題等)直接使用ID作為slug;在slug正確支持的場景(一般的英文標題等)會用傳統(tǒng)的小寫單詞+橫線連接的方式做slug。
對于categories域,表達了多對多關系。MongoDB可以有多種多對多關系的表達方式,此處使用一個數(shù)組存儲所有對Category的引用。因為在KeystoneJS中Category經(jīng)常需要單獨查詢(列出所有Category等操作),所以把所有Category放到一個單獨的集合postcategories是更合適的做法,不適合使用純粹的內(nèi)嵌文檔模式。而傳統(tǒng)SQL用專門一張表表達多對多關系的方式,只能說MongoDB對Join操作支持不好,這不是NoSQL該用的模式。
state期望表達的是個枚舉類型,在MongoDB中直接使用字符串表達狀態(tài),區(qū)別于傳統(tǒng)SQL數(shù)據(jù)庫中,定義一個整形數(shù)字表達特定含義。暫且沒看到MongoDB直接提供有枚舉限制的機制。在應用中,通常需要手動編程做限制,例如mongoose定義Schema的時候可以添加enum屬性,限定域的值是合法的。
對于author域,表達一對多關系(一個author多個post)。直接存儲author的引用,標準的做法。
content是存粹的內(nèi)嵌文檔,因為Content完全屬于Post,不存在使得Content獨立于Post單獨查詢的場景,所以是MongoDB的標準做法。
image類似于content。額外解釋一下KeystoneJS的圖片機制:上傳圖片的時候會保存到cloudinary(圖片存儲、CDN服務,和國內(nèi)的七牛云差不多),并保存URL,本機不存圖片本身。
索引方面,getIndexes()結果太長,只寫簡單結果:_id、state、author、publishDate、slug設置了索引,其中slug索引設置了unique屬性保證唯一性。
評論(Comments)
此部分是之后補充的。使用keystone-demo包含有評論系統(tǒng)。
任意發(fā)布一篇文章之后添加一條評論。文章(post)的文檔沒有變化,沒有comments之類的字段。數(shù)據(jù)庫中會有一個單獨的postcomments集合,存放整個系統(tǒng)中所有的評論:
> db.postcomments.find().pretty()
{
"_id" : ObjectId("59f96344bd9d6a6ae2edc7a6"),
"content" : "這是一個條評論",
"post" : ObjectId("59f962edbd9d6a6ae2edc7a5"),
"author" : ObjectId("59f9629bbd9d6a6ae2edc7a2"),
"publishedOn" : ISODate("2017-11-01T06:01:40.748Z"),
"commentState" : "published",
"__v" : 0
}
>
對于[文章-評論]這種一對多的關系,只在“多”的部分加入對“一”的引用,即post字段。
對于“文章/帖子保存評論”這種場景,我見到很多是在“一”的文檔中添加“多”的內(nèi)嵌文檔或者引用,例如對于一篇文章在數(shù)據(jù)庫中的文檔:
// 方法1
{
"_id": ObjectId("..."),
"title": "...",
"content": "...",
"comments": [
ObjectId("......"), // 引用一個comment文檔
ObjectId("......")
]
}
或者
// 方法2
{
"_id": ObjectId("..."),
"title": "...",
"content": "...",
"comments": [
{ content: "這是一條評論", author: ObjectiId(...) },
{ content: "這是另一條評論", author: ObjectiId(...) }
]
}
KeystoneJS Demo中的方法,和之后列出的方法1、方法2,是MongoDB中表達一對多關系的三種常見方式。
方法2是最有MongoDB風格的方法,在單一場景下(查詢文章以及其下的評論),性能最好(只需一次查詢同時獲取文章和評論)。同時靈活性較差,例如查詢“所有文章中的未讀評論”就會很麻煩,性能也很差,對于博客系統(tǒng),這種情況可以考慮添加專門的通知功能代替上述的場景,用以彌補。
KeystoneJS Demo中的方法是傳統(tǒng)的SQL引用方法,對絕大多數(shù)場景的性能都有兼顧。
方法1在我看來算是折中,也能夠兼顧多種場景,對比SQL的傳統(tǒng)方法,從屬關系以人的角度看起來更直觀。
在索引上,字段_id、author、post、commentState、publishedOn包括索引,沒有unique索引的域。
相冊系統(tǒng)(Gallaries)
創(chuàng)建一個相冊(Gallary),并在相冊中包含了三張圖片后,查詢數(shù)據(jù)庫的gallaries集合
> db.galleries.find().pretty()
{
"_id" : ObjectId("59f9396170871a41d3ff7d68"),
"key" : "59f9396170871a41d3ff7d68",
"name" : "第一個相冊",
"images" : [
{
"public_id" : "og9nkng8sqqivtdypf1z",
"version" : 1509505412,
"signature" : "1dd91f44e892f8ee997b425a6eb929b3f5644cdc",
"width" : 40,
"height" : 40,
"format" : "png",
"resource_type" : "image",
"url" : "http://res.cloudinary.com/keystone-demo/image/upload/v1509505412/og9nkng8sqqivtdypf1z.png",
"secure_url" : "https://res.cloudinary.com/keystone-demo/image/upload/v1509505412/og9nkng8sqqivtdypf1z.png",
"_id" : ObjectId("59f9398570871a41d3ff7d6b")
},
{
"public_id" : "fqm4p1ahwzfx39omw6ej",
"version" : 1509505412,
"signature" : "37f70094993c047d7c899e338b1cee110dffd9d5",
"width" : 128,
"height" : 128,
"format" : "png",
"resource_type" : "image",
"url" : "http://res.cloudinary.com/keystone-demo/image/upload/v1509505412/fqm4p1ahwzfx39omw6ej.png",
"secure_url" : "https://res.cloudinary.com/keystone-demo/image/upload/v1509505412/fqm4p1ahwzfx39omw6ej.png",
"_id" : ObjectId("59f9398570871a41d3ff7d6a")
},
{
"public_id" : "tbawweh0prvbqaunz33g",
"version" : 1509505412,
"signature" : "a8ed854badac8aff4c024b703c914c9c84c4934c",
"width" : 640,
"height" : 640,
"format" : "jpg",
"resource_type" : "image",
"url" : "http://res.cloudinary.com/keystone-demo/image/upload/v1509505412/tbawweh0prvbqaunz33g.jpg",
"secure_url" : "https://res.cloudinary.com/keystone-demo/image/upload/v1509505412/tbawweh0prvbqaunz33g.jpg",
"_id" : ObjectId("59f9398570871a41d3ff7d69")
}
],
"publishedDate" : ISODate("2017-11-01T03:02:57Z"),
"__v" : 1,
"heroImage" : {
"public_id" : "vbu4jrpfe5bowlz8ar7s",
"version" : 1509505412,
"signature" : "b47d9bcfcac93ec4a453a4b80b498704b589a2b9",
"width" : 640,
"height" : 640,
"format" : "jpg",
"resource_type" : "image",
"url" : "http://res.cloudinary.com/keystone-demo/image/upload/v1509505412/vbu4jrpfe5bowlz8ar7s.jpg",
"secure_url" : "https://res.cloudinary.com/keystone-demo/image/upload/v1509505412/vbu4jrpfe5bowlz8ar7s.jpg"
}
}
>
其中heroImage是相冊封面。這里使用內(nèi)嵌文檔的數(shù)組保存相冊內(nèi)的圖片對象。
由于這里保存的只有元數(shù)據(jù)和URL,體積較小,是適合的方式。如果直接保存二進制文件數(shù)據(jù),那么要考慮MongoDB中單個文檔不能超過16MB的限制,通常需要考慮其他方法。
若能保證文件都小于16M,可以把所有“文件”獨立進一個collection,在gallaries集合的images數(shù)組中,保存文件的引用。
如果文件大于16M,考慮使用把文件保存在外部,保存URL,或者使用GridFS。
索引比較簡單,有_id和key,其中key索引有unique屬性。
用戶系統(tǒng)(User)
除了系統(tǒng)初始化創(chuàng)建了一個Admin用戶外,還手動創(chuàng)建了一個guest用戶。
> db.users.find().pretty()
{
"_id" : ObjectId("59f937eb70871a41d3ff7d64"),
"password" : "$2a$10$rv9yNFRQiJ/jQznF2FYmguhEbM8QFHBLK6J3SiaXmAhk/GbUvJH6y",
"email" : "changrui0608@gmail.com",
"isAdmin" : true,
"name" : {
"last" : "User",
"first" : "Admin"
},
"__v" : 0
}
{
"_id" : ObjectId("59f93e7870871a41d3ff7d6d"),
"password" : "$2a$10$La5hXQxJz8Gwn9oOQ8OBruQnbsMt4D5vdggANhbtdfo./mQJ3L6nG",
"email" : "guest@guest.guest",
"isAdmin" : true,
"name" : {
"last" : "guest",
"first" : "guest"
},
"__v" : 0
}
>
密碼是哈希過的,提高安全性。name域是內(nèi)嵌文檔,類似posts的content域,比較典型。
索引方面,_id、email、isAdmin設置了索引,應當是為了“通過email賬號登陸”和“列出所有管理員”的應用場景。其中email有unique屬性保證唯一性。
信息系統(tǒng)(Enquries)
以guest登陸,向站管理員發(fā)送一個消息后查看數(shù)據(jù)庫。
> db.enquiries.find().pretty()
{
"_id" : ObjectId("59f94ef170871a41d3ff7d6e"),
"enquiryType" : "message",
"phone" : "1234567",
"email" : "guest@guest.guest",
"createdAt" : ISODate("2017-11-01T04:34:57.971Z"),
"message" : {
"md" : "只是測試一下contact...",
"html" : "<p>只是測試一下contact...</p>\n"
},
"name" : {
"first" : "你好"
},
"__v" : 0
}
>
有意思的是message實際上保存了同樣內(nèi)容的markdown原文和html版本。
索引只有_id。
踩的坑
KeystoneJS官方新手教程使用yo(Yeoman)搭建默認配置。yo在監(jiān)測到當前用戶為root時,會切換為使用自己的UID,導致一系列權限問題。
因為安裝時生成的配置文件等是root:root且rw權限只給了u沒有go,導致無法讀取自己的配置文件。離奇的是手動chmod增加權限后,yo依舊會失敗,且權限恢復成原來的樣子。
最后我是為此創(chuàng)建了一個新的普通用戶才跑起來KeystoneJS。對于只有root用戶的機器(VPS等)要留意這一點。