MongoDB数据库设计实例 - KeystoneJS
前言
先简单介绍一下KeystoneJS。这是一个依靠Node.js + MongoDB打造的,能够灵活配置的CMS系统。
使用官方提供的简单方式配置,可以配出标准类型的博客系统,包括文章系统(含有分类机制)、相册系统、私信系统、用户系统。若需要更高级的自定义配置,需要手写一些js文件。
官网地址 http://keystonejs.com/
中文官网 http://keystonejs.com/zh/
此篇即用最简单、标准的Keystone博客模版,记录KeystoneJS是如何使用MongoDB存储内容的。
KeystoneJS中的数据库
概览
初始化之后,会带有一个Admin账户,登陆账户,创建一个文章分类(PostCategory),创建两篇文章(Post),创建一个相册(Gallary)并上传少量图片。创建另一个用户guest
,并向管理员发起一个信息。
此时查看数据库中的集合,如下所示:
> show collections
app_updates
enquiries
galleries
postcategories
posts
users
除了app_updates
存储版本升级信息,这里不细说,其他的看下文。
博客系统
默认的博客系统包括文章(Post)和文章分类(PostCategory)。
分类(PostCategories)
首先创建一个叫做瞎扯
的分类,然后查看postcategories
集合。
> db.postcategories.find().pretty()
{
"_id" : ObjectId("59f9384970871a41d3ff7d66"),
"key" : "59f9384970871a41d3ff7d66",
"name" : "瞎扯",
"__v" : 0
}
>
其中__v
字段是mongoose(一个Node上常用的MongoDB数据库ORM)增加的,mongoose用这个字段配以一些机制,增强数据一致性、安全性,与存储的内容无关。
剩下的有效字段包括_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)
创建了两篇范例文章后,查看数据库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>所以多写一句话,让字数稍微多多多多多多那么一点。</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正确支持的场景(一般的英文标题等)会用传统的小写单词+横线连接的方式做slug。
对于categories
域,表达了多对多关系。MongoDB可以有多种多对多关系的表达方式,此处使用一个数组存储所有对Category的引用。因为在KeystoneJS中Category经常需要单独查询(列出所有Category等操作),所以把所有Category放到一个单独的集合postcategories
是更合适的做法,不适合使用纯粹的内嵌文档模式。而传统SQL用专门一张表表达多对多关系的方式,只能说MongoDB对Join操作支持不好,这不是NoSQL该用的模式。
state
期望表达的是个枚举类型,在MongoDB中直接使用字符串表达状态,区别于传统SQL数据库中,定义一个整形数字表达特定含义。暂且没看到MongoDB直接提供有枚举限制的机制。在应用中,通常需要手动编程做限制,例如mongoose定义Schema的时候可以添加enum
属性,限定域的值是合法的。
对于author
域,表达一对多关系(一个author多个post)。直接存储author的引用,标准的做法。
content
是存粹的内嵌文档,因为Content完全属于Post,不存在使得Content独立于Post单独查询的场景,所以是MongoDB的标准做法。
image
类似于content
。额外解释一下KeystoneJS的图片机制:上传图片的时候会保存到cloudinary(图片存储、CDN服务,和国内的七牛云差不多),并保存URL,本机不存图片本身。
索引方面,getIndexes()
结果太长,只写简单结果:_id
、state
、author
、publishDate
、slug
设置了索引,其中slug
索引设置了unique
属性保证唯一性。
评论(Comments)
此部分是之后补充的。使用keystone-demo包含有评论系统。
任意发布一篇文章之后添加一条评论。文章(post)的文档没有变化,没有comments
之类的字段。数据库中会有一个单独的postcomments
集合,存放整个系统中所有的评论:
> 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
字段。
对于“文章/帖子保存评论”这种场景,我见到很多是在“一”的文档中添加“多”的内嵌文档或者引用,例如对于一篇文章在数据库中的文档:
// 方法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风格的方法,在单一场景下(查询文章以及其下的评论),性能最好(只需一次查询同时获取文章和评论)。同时灵活性较差,例如查询“所有文章中的未读评论”就会很麻烦,性能也很差,对于博客系统,这种情况可以考虑添加专门的通知功能代替上述的场景,用以弥补。
KeystoneJS Demo中的方法是传统的SQL引用方法,对绝大多数场景的性能都有兼顾。
方法1在我看来算是折中,也能够兼顾多种场景,对比SQL的传统方法,从属关系以人的角度看起来更直观。
在索引上,字段_id
、author
、post
、commentState
、publishedOn
包括索引,没有unique
索引的域。
相册系统(Gallaries)
创建一个相册(Gallary),并在相册中包含了三张图片后,查询数据库的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
是相册封面。这里使用内嵌文档的数组保存相册内的图片对象。
由于这里保存的只有元数据和URL,体积较小,是适合的方式。如果直接保存二进制文件数据,那么要考虑MongoDB中单个文档不能超过16MB的限制,通常需要考虑其他方法。
若能保证文件都小于16M,可以把所有“文件”独立进一个collection,在gallaries
集合的images
数组中,保存文件的引用。
如果文件大于16M,考虑使用把文件保存在外部,保存URL,或者使用GridFS。
索引比较简单,有_id
和key
,其中key
索引有unique
属性。
用户系统(User)
除了系统初始化创建了一个Admin用户外,还手动创建了一个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
域是内嵌文档,类似posts
的content
域,比较典型。
索引方面,_id
、email
、isAdmin
设置了索引,应当是为了“通过email账号登陆”和“列出所有管理员”的应用场景。其中email
有unique属性保证唯一性。
信息系统(Enquries)
以guest登陆,向站管理员发送一个消息后查看数据库。
> 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
实际上保存了同样内容的markdown原文和html版本。
索引只有_id
。
踩的坑
KeystoneJS官方新手教程使用yo(Yeoman)搭建默认配置。yo在监测到当前用户为root时,会切换为使用自己的UID,导致一系列权限问题。
因为安装时生成的配置文件等是root:root且rw权限只给了u没有go,导致无法读取自己的配置文件。离奇的是手动chmod
增加权限后,yo依旧会失败,且权限恢复成原来的样子。
最后我是为此创建了一个新的普通用户才跑起来KeystoneJS。对于只有root用户的机器(VPS等)要留意这一点。