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" : "

这里是Content Brief部分,大概是一句话的简介。

", "extended" : "

这里是Content Extended部分,应该是正文。

\r\n

所以多写一句话,让字数稍微多多多多多多那么一点。

" }, "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" : "

Just to try the slug...

", "extended" : "

hmmmmmm.

" }, "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" : "[email protected]",
    "isAdmin" : true,
    "name" : {
        "last" : "User",
        "first" : "Admin"
    },
    "__v" : 0
}
{
    "_id" : ObjectId("59f93e7870871a41d3ff7d6d"),
    "password" : "$2a$10$La5hXQxJz8Gwn9oOQ8OBruQnbsMt4D5vdggANhbtdfo./mQJ3L6nG",
    "email" : "[email protected]",
    "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" : "[email protected]",
    "createdAt" : ISODate("2017-11-01T04:34:57.971Z"),
    "message" : {
        "md" : "只是测试一下contact...",
        "html" : "

只是测试一下contact...

\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等)要留意这一点。

你可能感兴趣的:(MongoDB数据库设计实例 - KeystoneJS)