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用这个字段配以一些机制,增强数据一致性、安全性,与存储的内容无关。

剩下的有效字段包括_idkeyname,且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
    }
]
>

可以看到_idkey有索引,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()结果太长,只写简单结果:_idstateauthorpublishDateslug设置了索引,其中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的传统方法,从属关系以人的角度看起来更直观。

在索引上,字段_idauthorpostcommentStatepublishedOn包括索引,没有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。

索引比较简单,有_idkey,其中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域是内嵌文档,类似postscontent域,比较典型。

索引方面,_idemailisAdmin设置了索引,应当是为了“通过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等)要留意这一点。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容