前言
先簡(jiǎn)單介紹一下KeystoneJS捅膘。這是一個(gè)依靠Node.js + MongoDB打造的,能夠靈活配置的CMS系統(tǒng)。
使用官方提供的簡(jiǎn)單方式配置,可以配出標(biāo)準(zhǔn)類型的博客系統(tǒng)颖杏,包括文章系統(tǒng)(含有分類機(jī)制)蛋辈、相冊(cè)系統(tǒng)、私信系統(tǒng)胃碾、用戶系統(tǒng)。若需要更高級(jí)的自定義配置,需要手寫一些js文件曹体。
官網(wǎng)地址 http://keystonejs.com/
中文官網(wǎng) http://keystonejs.com/zh/
此篇即用最簡(jiǎn)單、標(biāo)準(zhǔn)的Keystone博客模版盖奈,記錄KeystoneJS是如何使用MongoDB存儲(chǔ)內(nèi)容的混坞。
KeystoneJS中的數(shù)據(jù)庫(kù)
概覽
初始化之后,會(huì)帶有一個(gè)Admin賬戶,登陸賬戶究孕,創(chuàng)建一個(gè)文章分類(PostCategory)啥酱,創(chuàng)建兩篇文章(Post),創(chuàng)建一個(gè)相冊(cè)(Gallary)并上傳少量圖片厨诸。創(chuàng)建另一個(gè)用戶guest
镶殷,并向管理員發(fā)起一個(gè)信息。
此時(shí)查看數(shù)據(jù)庫(kù)中的集合微酬,如下所示:
> show collections
app_updates
enquiries
galleries
postcategories
posts
users
除了app_updates
存儲(chǔ)版本升級(jí)信息绘趋,這里不細(xì)說(shuō),其他的看下文颗管。
博客系統(tǒng)
默認(rèn)的博客系統(tǒng)包括文章(Post)和文章分類(PostCategory)陷遮。
分類(PostCategories)
首先創(chuàng)建一個(gè)叫做瞎扯
的分類,然后查看postcategories
集合垦江。
> db.postcategories.find().pretty()
{
"_id" : ObjectId("59f9384970871a41d3ff7d66"),
"key" : "59f9384970871a41d3ff7d66",
"name" : "瞎扯",
"__v" : 0
}
>
其中__v
字段是mongoose(一個(gè)Node上常用的MongoDB數(shù)據(jù)庫(kù)ORM)增加的帽馋,mongoose用這個(gè)字段配以一些機(jī)制,增強(qiáng)數(shù)據(jù)一致性比吭、安全性绽族,與存儲(chǔ)的內(nèi)容無(wú)關(guān)。
剩下的有效字段包括_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默認(rèn)博客配置中岁诉,需要通過(guò)_id
或其字符串查詢,少有直接通過(guò)name
進(jìn)行的查詢跋选。
文章(Posts)
創(chuàng)建了兩篇范例文章后涕癣,查看數(shù)據(jù)庫(kù)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部分,大概是一句話的簡(jiǎn)介前标。</p>",
"extended" : "<p>這里是Content Extended部分坠韩,應(yīng)該是正文。</p>\r\n<p>所以多寫一句話炼列,讓字?jǐn)?shù)稍微多多多多多多那么一點(diǎn)只搁。</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
}
>
第一篇文章盡可能用到了全部的域;第二篇僅僅是為了測(cè)試slug俭尖。在slug不被支持的場(chǎng)景(中文標(biāo)題等)直接使用ID作為slug氢惋;在slug正確支持的場(chǎng)景(一般的英文標(biāo)題等)會(huì)用傳統(tǒng)的小寫單詞+橫線連接的方式做slug洞翩。
對(duì)于categories
域,表達(dá)了多對(duì)多關(guān)系焰望。MongoDB可以有多種多對(duì)多關(guān)系的表達(dá)方式骚亿,此處使用一個(gè)數(shù)組存儲(chǔ)所有對(duì)Category的引用。因?yàn)樵贙eystoneJS中Category經(jīng)常需要單獨(dú)查詢(列出所有Category等操作)熊赖,所以把所有Category放到一個(gè)單獨(dú)的集合postcategories
是更合適的做法来屠,不適合使用純粹的內(nèi)嵌文檔模式。而傳統(tǒng)SQL用專門一張表表達(dá)多對(duì)多關(guān)系的方式震鹉,只能說(shuō)MongoDB對(duì)Join操作支持不好俱笛,這不是NoSQL該用的模式。
state
期望表達(dá)的是個(gè)枚舉類型传趾,在MongoDB中直接使用字符串表達(dá)狀態(tài)迎膜,區(qū)別于傳統(tǒng)SQL數(shù)據(jù)庫(kù)中,定義一個(gè)整形數(shù)字表達(dá)特定含義浆兰。暫且沒看到MongoDB直接提供有枚舉限制的機(jī)制星虹。在應(yīng)用中,通常需要手動(dòng)編程做限制镊讼,例如mongoose定義Schema的時(shí)候可以添加enum
屬性,限定域的值是合法的平夜。
對(duì)于author
域蝶棋,表達(dá)一對(duì)多關(guān)系(一個(gè)author多個(gè)post)。直接存儲(chǔ)author的引用忽妒,標(biāo)準(zhǔn)的做法玩裙。
content
是存粹的內(nèi)嵌文檔,因?yàn)镃ontent完全屬于Post段直,不存在使得Content獨(dú)立于Post單獨(dú)查詢的場(chǎng)景吃溅,所以是MongoDB的標(biāo)準(zhǔn)做法。
image
類似于content
鸯檬。額外解釋一下KeystoneJS的圖片機(jī)制:上傳圖片的時(shí)候會(huì)保存到cloudinary(圖片存儲(chǔ)决侈、CDN服務(wù),和國(guó)內(nèi)的七牛云差不多)喧务,并保存URL赖歌,本機(jī)不存圖片本身。
索引方面功茴,getIndexes()
結(jié)果太長(zhǎng)庐冯,只寫簡(jiǎn)單結(jié)果:_id
、state
坎穿、author
展父、publishDate
返劲、slug
設(shè)置了索引,其中slug
索引設(shè)置了unique
屬性保證唯一性栖茉。
評(píng)論(Comments)
此部分是之后補(bǔ)充的篮绿。使用keystone-demo包含有評(píng)論系統(tǒng)。
任意發(fā)布一篇文章之后添加一條評(píng)論衡载。文章(post)的文檔沒有變化搔耕,沒有comments
之類的字段。數(shù)據(jù)庫(kù)中會(huì)有一個(gè)單獨(dú)的postcomments
集合痰娱,存放整個(gè)系統(tǒng)中所有的評(píng)論:
> db.postcomments.find().pretty()
{
"_id" : ObjectId("59f96344bd9d6a6ae2edc7a6"),
"content" : "這是一個(gè)條評(píng)論",
"post" : ObjectId("59f962edbd9d6a6ae2edc7a5"),
"author" : ObjectId("59f9629bbd9d6a6ae2edc7a2"),
"publishedOn" : ISODate("2017-11-01T06:01:40.748Z"),
"commentState" : "published",
"__v" : 0
}
>
對(duì)于[文章-評(píng)論]這種一對(duì)多的關(guān)系弃榨,只在“多”的部分加入對(duì)“一”的引用,即post
字段梨睁。
對(duì)于“文章/帖子保存評(píng)論”這種場(chǎng)景鲸睛,我見到很多是在“一”的文檔中添加“多”的內(nèi)嵌文檔或者引用,例如對(duì)于一篇文章在數(shù)據(jù)庫(kù)中的文檔:
// 方法1
{
"_id": ObjectId("..."),
"title": "...",
"content": "...",
"comments": [
ObjectId("......"), // 引用一個(gè)comment文檔
ObjectId("......")
]
}
或者
// 方法2
{
"_id": ObjectId("..."),
"title": "...",
"content": "...",
"comments": [
{ content: "這是一條評(píng)論", author: ObjectiId(...) },
{ content: "這是另一條評(píng)論", author: ObjectiId(...) }
]
}
KeystoneJS Demo中的方法坡贺,和之后列出的方法1官辈、方法2,是MongoDB中表達(dá)一對(duì)多關(guān)系的三種常見方式遍坟。
方法2是最有MongoDB風(fēng)格的方法拳亿,在單一場(chǎng)景下(查詢文章以及其下的評(píng)論),性能最好(只需一次查詢同時(shí)獲取文章和評(píng)論)愿伴。同時(shí)靈活性較差肺魁,例如查詢“所有文章中的未讀評(píng)論”就會(huì)很麻煩,性能也很差隔节,對(duì)于博客系統(tǒng)鹅经,這種情況可以考慮添加專門的通知功能代替上述的場(chǎng)景,用以彌補(bǔ)怎诫。
KeystoneJS Demo中的方法是傳統(tǒng)的SQL引用方法瘾晃,對(duì)絕大多數(shù)場(chǎng)景的性能都有兼顧。
方法1在我看來(lái)算是折中幻妓,也能夠兼顧多種場(chǎng)景蹦误,對(duì)比SQL的傳統(tǒng)方法,從屬關(guān)系以人的角度看起來(lái)更直觀肉津。
在索引上胖缤,字段_id
、author
阀圾、post
哪廓、commentState
、publishedOn
包括索引初烘,沒有unique
索引的域涡真。
相冊(cè)系統(tǒng)(Gallaries)
創(chuàng)建一個(gè)相冊(cè)(Gallary)分俯,并在相冊(cè)中包含了三張圖片后,查詢數(shù)據(jù)庫(kù)的gallaries
集合
> db.galleries.find().pretty()
{
"_id" : ObjectId("59f9396170871a41d3ff7d68"),
"key" : "59f9396170871a41d3ff7d68",
"name" : "第一個(gè)相冊(cè)",
"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
是相冊(cè)封面哆料。這里使用內(nèi)嵌文檔的數(shù)組保存相冊(cè)內(nèi)的圖片對(duì)象缸剪。
由于這里保存的只有元數(shù)據(jù)和URL,體積較小东亦,是適合的方式杏节。如果直接保存二進(jìn)制文件數(shù)據(jù),那么要考慮MongoDB中單個(gè)文檔不能超過(guò)16MB的限制典阵,通常需要考慮其他方法奋渔。
若能保證文件都小于16M,可以把所有“文件”獨(dú)立進(jìn)一個(gè)collection壮啊,在gallaries
集合的images
數(shù)組中嫉鲸,保存文件的引用。
如果文件大于16M歹啼,考慮使用把文件保存在外部玄渗,保存URL,或者使用GridFS狸眼。
索引比較簡(jiǎn)單藤树,有_id
和key
,其中key
索引有unique
屬性拓萌。
用戶系統(tǒng)(User)
除了系統(tǒng)初始化創(chuàng)建了一個(gè)Admin用戶外也榄,還手動(dòng)創(chuàng)建了一個(gè)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
}
>
密碼是哈希過(guò)的司志,提高安全性。name
域是內(nèi)嵌文檔降宅,類似posts
的content
域骂远,比較典型。
索引方面腰根,_id
激才、email
、isAdmin
設(shè)置了索引额嘿,應(yīng)當(dāng)是為了“通過(guò)email賬號(hào)登陸”和“列出所有管理員”的應(yīng)用場(chǎng)景瘸恼。其中email
有unique屬性保證唯一性。
信息系統(tǒng)(Enquries)
以guest登陸册养,向站管理員發(fā)送一個(gè)消息后查看數(shù)據(jù)庫(kù)东帅。
> 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" : "只是測(cè)試一下contact...",
"html" : "<p>只是測(cè)試一下contact...</p>\n"
},
"name" : {
"first" : "你好"
},
"__v" : 0
}
>
有意思的是message
實(shí)際上保存了同樣內(nèi)容的markdown原文和html版本。
索引只有_id
球拦。
踩的坑
KeystoneJS官方新手教程使用yo(Yeoman)搭建默認(rèn)配置靠闭。yo在監(jiān)測(cè)到當(dāng)前用戶為root時(shí)帐我,會(huì)切換為使用自己的UID,導(dǎo)致一系列權(quán)限問(wèn)題愧膀。
因?yàn)榘惭b時(shí)生成的配置文件等是root:root且rw權(quán)限只給了u沒有g(shù)o拦键,導(dǎo)致無(wú)法讀取自己的配置文件。離奇的是手動(dòng)chmod
增加權(quán)限后檩淋,yo依舊會(huì)失敗芬为,且權(quán)限恢復(fù)成原來(lái)的樣子。
最后我是為此創(chuàng)建了一個(gè)新的普通用戶才跑起來(lái)KeystoneJS蟀悦。對(duì)于只有root用戶的機(jī)器(VPS等)要留意這一點(diǎn)媚朦。