将 RethinkDB 与 Koa 集成

在本节中,我们将构建一个简单的 API,类似于我们在上一章中创建的 PHP API,用于列出、添加、更新和删除用户。在之前的 API 中,我们使用了 PHP 和 MySQL,而在本章中,我们将使用 JavaScript 和 RethinkDB。我们仍然使用 Koa 作为 API 的框架。但是这一次,我们将重组 API 目录,使其结构与您已经熟悉的 Nuxt 应用程序和 PHP API 的目录结构尽可能保持一致。让我们开始吧!

重构 API 目录

还记得您在使用 Vue CLI 时在项目中获得的默认目录结构吗?您在第十一章“编写路由中间件和服务器中间件”中学习了相关内容。使用 Vue CLI 安装项目后,如果您查看项目目录内部,您将看到一个基本的项目结构,您可以在其中找到一个 /src/ 目录来开发您的组件、页面和路由,如下所示:

├── package.json
├── babel.config.js
├── README.md
├── public
│   ├── index.html
│   └── favicon.ico
└── src
    ├── App.vue
    ├── main.js
    ├── router.js
    ├── components
    │   └── HelloWorld.vue
    └── assets
        └── logo.png

自第十二章“创建用户登录和 API 身份验证”以来,我们一直为跨域应用程序使用这种标准结构。例如,以下是您之前创建的 Koa API 的目录结构:

backend
├── package.json
├── backpack.config.js
├── static
│   └── ...
└── src
    ├── index.vue
    ├── ...
    ├── modules
    │   └── ...
    └── core
        └── ...

但是这一次,我们将从本章要创建的 API 中删除 /src/ 目录。因此,让我们将 /src/ 目录中的所有内容都移到顶层,并重新配置我们引导应用程序的方式,如下所示:

  1. 在项目的根目录中创建以下文件和文件夹:

    backend
    ├── package.json
    ├── backpack.config.js
    ├── middlewares.js
    ├── routes.js
    ├── configs
    │   ├── index.js
    │   └── rethinkdb.js
    ├── core
    │   └── ...
    ├── middlewares
    │   └── ...
    ├── modules
    │   └── ...
    └── public
        └── index.js

    同样,这里的目录结构仅仅是一个建议;您可以根据自己的喜好设计您的目录结构,使其最适合您。但是,让我们快速浏览一下这个建议的目录,并研究这些文件夹和文件的用途:

    • /configs/ 目录用于存储应用程序的基本信息和 RethinkDB 数据库连接的详细信息。

    • /public/ 目录用于存储启动应用程序的文件。

    • /modules/ 目录用于存储应用程序的模块,例如我们将在接下来的部分中创建的“user”模块。

    • /core/ 目录用于存储可以在整个应用程序中使用的通用函数或类。

    • middlewares.js 文件是从 /middlewares/ 和 /node_modules/ 目录导入中间件的核心位置。

    • routes.js 文件是从 /modules 目录导入路由的核心位置。

    • backpack.config.js 文件用于自定义我们应用程序的 webpack 配置。

    • package.json 文件包含我们应用程序的脚本和依赖项,并且始终位于根级别。

  2. 将入口文件指向 /public/ 目录中的 index.js 文件:

    // backpack.config.js
    module.exports = {
        webpack: (config, options, webpack) => {
            config.entry.main = './public/index.js'
            return config
        }
    }

    请记住,Backpack 中的默认入口文件是 /src/ 目录中的 index.js 文件。由于我们已将此 index 文件移动到 /public/ 目录,因此我们必须通过 Backpack 配置文件配置此入口点。

    如果您想了解更多关于 webpack 中入口点的信息,请访问 https://webpack.js.org/concepts/entry-points/。

  3. 在返回 Backpack 配置文件中的 config 对象之前,将 /configs/core/modules/middlewares 路径的别名添加到 webpack 配置的 resolve 选项中:

    // backpack.config.js
    const path = require('path')
    config.resolve = {
        alias: {
            Configs: path.resolve(__dirname, 'configs/'),
            Core: path.resolve(__dirname, 'core/'),
            Modules: path.resolve(__dirname, 'modules/'),
            Middlewares: path.resolve(__dirname, 'middlewares/')
        }
    }

    在我们的应用程序中使用别名来解析文件路径非常有用和方便。通常,我们使用相对路径导入文件,如下所示:

    import notFound from '../../Middlewares/notFound'

    现在,我们不必这样做,而是可以使用隐藏相对路径的别名从任何地方导入文件,从而使我们的代码更简洁:

    import notFound from 'Middlewares/notFound'

    如果您想了解更多关于 webpack 中 alias 和 resolve 选项的信息,请访问 https://webpack.js.org/configuration/resolve/resolvealias。

一旦您准备好上述结构并整理好入口文件,您就可以开始将带有 RethinkDB 的 CRUD 操作应用于此 API。但首先,您需要在您的项目中安装 RethinkDB JavaScript 客户端。让我们开始吧!

添加和使用 RethinkDB JavaScript 客户端

根据您的编程知识,关于 JavaScript、Ruby、Python 和 Java,您可以选择几种官方客户端驱动程序。还有许多社区支持的驱动程序,例如 PHP、Perl 和 R。您可以在 https://rethinkdb.com/docs/install-drivers/ 上查看它们。

在本书中,我们将使用 RethinkDB JavaScript 客户端驱动程序。我们将通过以下步骤指导您完成安装以及如何使用此驱动程序进行 CRUD 操作:

  1. 通过 npm 安装 RethinkDB JavaScript 客户端驱动程序:

    $ npm i rethinkdb
  2. 在 /configs/ 目录中创建一个 rethinkdb.js 文件,其中包含 RethinkDB 服务器连接详细信息,如下所示:

    // configs/rethinkdb.js
    export default {
        host: 'localhost',
        port: 28015,
        dbname: 'nuxtdb'
    }
  3. 在 /core/ 目录中创建一个 connection.js 文件,用于使用上述连接详细信息打开 RethinkDB 服务器连接,如下所示:

    // core/database/rethinkdb/connection.js
    import config from 'Configs/rethinkdb'
    import rethink from'rethinkdb'
    
    const c = async() => {
        const connection = await rethink.connect({
            host: config.host,
            port: config.port,
            db: config.dbname
        })
        return connection
    }
    
    export default c
  4. 此外,在 /middlewares/ 目录中创建一个带有 open.js 文件的打开连接中间件,并将其绑定到 Koa 上下文,作为连接到 RethinkDB 的另一种选择,如下所示:

    // middlewares/database/rdb/connection/open.js
    import config from 'Configs/rethinkdb'
    import rdb from'rethinkdb'
    
    export default async (ctx, next) => {
        ctx._rdbConn = await rdb.connect({
            host: config.host,
            port: config.port,
            db: config.dbname
        })
        await next()
    }

    我们从 PHP 的 PSR-4 中学到了一个好的实践,即使用目录路径来描述您的中间件(或 CRUD 操作),这样您就不必使用长名称来描述您的文件。例如,如果您没有为其使用描述性目录路径,您可能希望将此中间件命名为 rdb-connection-open.js 以尽可能清楚地描述它是什么。但是,如果您使用目录路径来描述中间件,那么您可以将文件命名为像 open.js 这样简单的名称。

  5. 在 /middlewares/ 目录中创建一个带有 close.js 文件的关闭连接中间件,并将其绑定到 Koa 上下文作为最后一个中间件,如下所示:

    // middlewares/database/rdb/connection/close.js
    import config from 'Configs/rethinkdb'
    import rdb from'rethinkdb'
    
    export default async (ctx, next) => {
        ctx._rdbConn.close()
        await next()
    }
  6. 在根 middlewares.js 文件中导入打开和关闭连接中间件,并将它们注册到应用程序,如下所示:

    // middlewares.js
    import routes from './routes'
    import rdbOpenConnection from
        'Middlewares/database/rdb/connection/open'
    import rdbCloseConnection from
        'Middlewares/database/rdb/connection/close'
    
    export default (app) => {
        //...
        app.use(rdbOpenConnection)
        app.use(routes.routes(), routes.allowedMethods())
        app.use(rdbCloseConnection)
    }

    在这里,您可以看到打开连接中间件在所有模块路由之前注册,而关闭连接中间件最后注册,以便它们分别首先和最后被调用。

  7. 在接下来的步骤中,我们将使用以下带有 Koa 路由器和 RethinkDB 客户端驱动程序的模板代码来完成 CRUD 操作。例如,以下代码展示了我们如何在用户模块中应用模板代码来获取 user 表中的所有用户:

    // modules/user/_routes/index.js
    import Router from 'koa-router'
    import rdb from 'rethinkdb'
    
    const router = new Router()
    
    router.get('/', async (ctx, next) => {
        try {
            // 对传入参数执行验证...
            // 执行 CRUD 操作:
            let result = await rdb.table('user')
                .run(ctx._rdbConn)
            ctx.type = 'json'
            ctx.body = result
            await next()
        } catch (err) {
            ctx.throw(500, err)
        }
    })
    
    export default router

    让我们仔细阅读这段代码并理解它的作用。在这里,您可以看到我们为 RethinkDB 客户端驱动程序使用了自定义的顶级命名空间 rdb,而不是您在 localhost:8080 上练习的 r 命名空间。此外,在我们的应用程序中使用 RethinkDB 客户端驱动程序时,我们必须始终在 ReQL 命令的末尾调用 run 方法,并传入 RethinkDB 服务器连接,以构建查询并将其传递给服务器执行。

    此外,我们必须在代码末尾调用 next 方法,以便将应用程序的执行传递给下一个中间件,特别是用于关闭 RethinkDB 连接的关闭连接中间件。在执行任何 CRUD 操作之前,我们应该对来自客户端的传入参数和数据执行检查。然后,我们应该将我们的代码包装在 try-catch 块中以捕获并抛出任何潜在的错误。

    请注意,在接下来的步骤中,我们将省略编写参数验证和 try-catch 语句,以避免冗长和重复的代码行和块,但您应该在实际代码中包含它们。

  8. 在 user 模块的 /_routes/ 文件夹中创建一个 create-user.js 文件,其中包含以下代码,用于将新用户插入数据库的 user 表中:

    // modules/user/_routes/create-user.js
    router.post('/user', async (ctx, next) => {
        let result = await rdb.table('user')
            .insert(document, {returnChanges: true})
            .run(ctx._rdbConn)
        if (result.inserted !== 1) {
            ctx.throw(404, 'insert user failed')
        }
        ctx.type = 'json'
        ctx.body = result
        await next()
    })

    如果插入失败,我们应该抛出错误,并将错误消息和 HTTP 错误代码传递给 Koa 的 throw 方法,以便我们可以使用 try-catch 块捕获它们并在前端显示它们。

  9. 在 user 模块的 /_routes/ 文件夹中创建一个 fetch-user.js 文件,用于使用 slug 键从 user 表中获取特定用户,如下所示:

    // modules/user/_routes/fetch-user.js
    router.get('/:slug', async (ctx, next) => {
        const slug = ctx.params.slug
        let user = await rdb.table('user')
            .filter(searchQuery)
            .nth(0)
            .default(null)
            .run(ctx._rdbConn)
        if (!user) {
            ctx.throw(404, 'user not found')
        }
        ctx.type = 'json'
        ctx.body = user
        await next()
    })

    我们在查询中添加了 nth 命令以按其位置显示文档。在我们的例子中,我们只想获取第一个文档,因此我们将整数 0 传递给此方法。我们还添加了 default 命令,以便在 user 表中找不到用户时返回 null 异常。

  10. 在 user 模块的 /_routes/ 文件夹中创建一个 update-user.js 文件,用于使用文档 ID 更新 user 表中的现有用户,如下所示:

    // modules/user/_routes/update-user.js
    router.put('/user', async (ctx, next) => {
        let body = ctx.request.body || {}
        let objectId = body.id
        let timestamp = Date.now()
        let updateQuery = {
            name: body.name,
            slug: body.slug,
            updatedAt: timestamp
        }
        let result = await rdb.table('user')
            .get(objectId)
            .update(updateQuery, {returnChanges: true})
            .run(ctx._rdbConn)
        if (result.replaced !== 1) {
            ctx.throw(404, 'update user failed')
        }
        ctx.type = 'json'
        ctx.body = result
        await next()
    })

    我们在查询中添加了 get 命令,以便在运行 update 之前首先按其 ID 获取特定文档。

  11. 在 user 模块的 /_routes/ 文件夹中创建一个 delete-user.js 文件,用于使用文档 ID 从 user 表中删除现有用户,如下所示:

    // modules/user/_routes/delete-user.js
    router.del('/user', async (ctx, next) => {
        let body = ctx.request.body || {}
        let objectId = body.id
        let result = await rdb.table('user')
            .get(objectId)
            .delete()
            .run(ctx._rdbConn)
        if (result.deleted !== 1) {
            ctx.throw(404, 'delete user failed')
        }
        ctx.type = 'json'
        ctx.body = result
        await next()
    })
  12. 最后,通过在位于 /_routes/ 文件夹中的 index.js 文件中的查询中添加 orderBy 命令,重构您在步骤 7 中刚刚创建的用于列出 user 表中所有用户的 CRUD 操作,如下所示:

    // modules/user/_routes/index.js
    router.get('/', async (ctx, next) => {
        let cursor = await rdb.table('user')
            .orderBy(rdb.desc('createdAt'))
            .run(ctx._rdbConn)
        let users = await cursor.toArray()
        ctx.type = 'json'
        ctx.body = users
        await next()
    })

我们在查询中添加了 orderBy 命令,以便我们可以按创建日期降序(最新优先)对文档进行排序。此外,RethinkDB 数据库返回的文档始终包含在作为 CRUD 操作回调的游标对象中,因此我们必须使用 toArray 命令来迭代游标并将对象转换为数组。

如果您想了解更多关于 orderBy 和 toArray 命令的信息,请分别访问 https://rethinkdb.com/api/javascript/order_by/https://rethinkdb.com/api/javascript/to_array/。

至此,您已成功地在您的 API 中使用 RethinkDB 实现了 CRUD 操作。同样,这既简单又有趣,不是吗?但是,我们仍然可以通过在 RethinkDB 数据库中强制执行模式来提高存储在数据库中的文档的 “质量”。我们将在下一节中学习如何做到这一点。

在 RethinkDB 中强制执行模式

就像 MongoDB 中的 BSON 数据库一样,RethinkDB 中的 JSON 数据库也是无模式的。这意味着数据库没有蓝图、公式或完整性约束。数据库构建方式的无组织规则可能会导致数据库的完整性问题。某些文档可能在同一个表(或 MongoDB 中的“集合”)中包含不同且不需要的键,以及包含正确键的文档。您可能会错误地注入一些键,或者忘记注入所需的键和值。因此,如果您想保持文档中的数据井井有条,在 JSON 或 BSON 数据库中强制执行某种模式可能是一个好主意。RethinkDB(或 MongoDB)没有用于强制执行模式的内部功能,但我们可以使用 Node.js Lodash 模块创建自定义函数来施加一些基本模式。让我们看看如何做到这一点:

  1. 通过 npm 安装 Lodash 模块:

    $ npm i lodash
  2. 在 /core/ 目录中创建一个 utils.js 文件,并导入 lodash 以创建一个名为 sanitise 的函数,如下所示:

    // core/utils.js
    import lodash from 'lodash'
    
    function sanitise (options, schema) {
        let data = options || {}
        if (schema === undefined) {
            const err = new Error('Schema is required.')
            err.status = 400
            err.expose = true
            throw err
        }
        let keys = lodash.keys(schema)
        let defaults = lodash.defaults(data, schema)
        let picked = lodash.pick(defaults, keys)
        return picked
    }
    
    export { sanitise }

    此函数仅选择您设置的默认键,并忽略 “schema” 中不存在的任何其他键。

    我们使用了 Lodash 中的以下方法。有关每个方法的更多信息,请访问以下链接:

  3. 在 user 模块中创建一个用户模式,其中包含您只想接受的以下键:

    // modules/user/schema.js
    export default {
        slug: null,
        name: null,
        createdAt: null,
        updatedAt: null
    }
  4. sanitise 方法和前面的模式导入到您想要强制执行模式的路由中;例如,在 create-user.js 文件中:

    // modules/user/_routes/create-user.js
    let timestamp = Date.now()
    let options = {
        name: body.name,
        slug: body.slug,
        createdAt: timestamp,
        username: 'marymoe',
        password: '123123'
    }
    let document = sanitise(options, schema)
    let result = await rdb.table('user')
        .insert(document, {returnChanges: true})
        .run(ctx._rdbConn)

在上面的代码中,示例字段 username 和 password 在插入之前清理数据时不会被注入到 user 表的文档中。

您可以看到此 sanitise 函数仅执行简单的验证。如果您需要更复杂和高级的数据验证,可以使用来自 hapi Web 框架的 Node.js joi 模块。

如果您想了解有关此模块的更多信息,请访问 https://hapi.dev/module/joi/。

接下来您必须探索的是 RethinkDB 中的变更提要(changefeeds)。这是本章的主要目的——向您展示如何利用 RethinkDB 的实时功能来创建实时应用程序。因此,让我们探索并使用 RethinkDB 中的变更提要!

RethinkDB 中的 changefeeds 简介

在使用 RethinkDB 客户端驱动程序在我们的应用程序中应用变更提要之前,让我们再次使用位于 localhost:8080/#dataexplorer 的管理 UI 中的数据浏览器,以便在屏幕上实时查看实时提要:

  1. 粘贴以下 ReQL 查询,然后单击 “运行” 按钮:

    r.db('nuxtdb').table('user').changes()

    您应该在浏览器屏幕上看到以下信息:

    Listening for events...
    Waiting for more results
  2. 在您的浏览器上打开另一个选项卡,并将其指向 localhost:8080/#dataexplorer。现在,您有两个数据浏览器。将其中一个从浏览器选项卡中拖出来,以便您可以将它们并排放置。然后,从其中一个数据浏览器中将新文档插入到 user 表中:

    r.db('nuxtdb').table('user').insert([
      { name: "Richard Roe", slug: "richard" },
      { name: "Marry Moe", slug: "marry" }
    ])

    您应该得到以下结果:

    {
      "deleted": 0,
      "errors": 0,
      "generated_keys": [
        "f7305c97-2bc9-4694-81ec-c5acaed1e757",
        "5862e1fa-e51c-4878-a16b-cb8c1f1d91de"
      ],
      "inserted": 2,
      "replaced": 0,
      "skipped": 0,
      "unchanged": 0
    }

    与此同时,您应该看到另一个数据浏览器实时地立即显示以下提要:

    {
      "new_val": {
        "id": "f7305c97-2bc9-4694-81ec-c5acaed1e757",
        "name": "Richard Roe",
        "slug": "richard"
      },
      "old_val": null
    } {
      "new_val": {
        "id": "5862e1fa-e51c-4878-a16b-cb8c1f1d91de",
        "name": "Marry Moe",
        "slug": "marry"
      },
      "old_val": null
    }

太棒了!您刚刚使用 RethinkDB 轻松地创建了实时提要!请注意,在每个实时提要中,您总是会得到这两个键:new_val 和 old_val。它们具有以下含义:

  • 如果您在 new_val 中获取数据,但在 old_val 中为 null,则表示新文档已插入到数据库中。

  • 如果您在 new_val 和 old_val 中都获取到数据,则表示数据库中的现有文档已更新。

  • 如果您在 old_val 中获取到数据,但在 new_val 中为 null,则表示数据库中的现有文档已删除。

在本章的最后一节中,当我们在 Nuxt 应用程序中使用这些键时,您将用到它们。所以,现在不用太担心它们。相反,下一个挑战是在 API 和 Nuxt 应用程序中实现它。为此,我们需要另一个 Node.js 模块——Socket.IO。因此,让我们探索一下这个模块如何帮助您实现这一点。