美文网首页我爱编程
MongoDB数据库设计实例 - KeystoneJS

MongoDB数据库设计实例 - KeystoneJS

作者: raaay0608 | 来源:发表于2017-11-01 11:47 被阅读0次

    前言

    先简单介绍一下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等)要留意这一点。

    相关文章

      网友评论

        本文标题:MongoDB数据库设计实例 - KeystoneJS

        本文链接:https://www.haomeiwen.com/subject/clvwpxtx.html