将 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/ 目录中的所有内容都移到顶层,并重新配置我们引导应用程序的方式,如下所示:
-
在项目的根目录中创建以下文件和文件夹:
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 文件包含我们应用程序的脚本和依赖项,并且始终位于根级别。
-
-
将入口文件指向
/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/。
-
在返回
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 操作:
-
通过 npm 安装 RethinkDB JavaScript 客户端驱动程序:
$ npm i rethinkdb
-
在 /configs/ 目录中创建一个 rethinkdb.js 文件,其中包含 RethinkDB 服务器连接详细信息,如下所示:
// configs/rethinkdb.js export default { host: 'localhost', port: 28015, dbname: 'nuxtdb' }
-
在 /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
-
此外,在 /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 这样简单的名称。
-
在 /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() }
-
在根
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) }
在这里,您可以看到打开连接中间件在所有模块路由之前注册,而关闭连接中间件最后注册,以便它们分别首先和最后被调用。
-
在接下来的步骤中,我们将使用以下带有 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 语句,以避免冗长和重复的代码行和块,但您应该在实际代码中包含它们。
-
在 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 块捕获它们并在前端显示它们。
-
在 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 异常。
-
在 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 获取特定文档。
-
在 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() })
-
最后,通过在位于 /_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 模块创建自定义函数来施加一些基本模式。让我们看看如何做到这一点:
-
通过
npm
安装Lodash
模块:$ npm i lodash
-
在 /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 中的以下方法。有关每个方法的更多信息,请访问以下链接:
-
https://lodash.com/docs/4.17.15#keys 获取 keys 方法的信息
-
https://lodash.com/docs/4.17.15#defaults 获取 defaults 方法的信息
-
https://lodash.com/docs/4.17.15#pick 获取 pick 方法的信息
-
-
在 user 模块中创建一个用户模式,其中包含您只想接受的以下键:
// modules/user/schema.js export default { slug: null, name: null, createdAt: null, updatedAt: null }
-
将
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 中的数据浏览器,以便在屏幕上实时查看实时提要:
-
粘贴以下 ReQL 查询,然后单击 “运行” 按钮:
r.db('nuxtdb').table('user').changes()
您应该在浏览器屏幕上看到以下信息:
Listening for events... Waiting for more results
-
在您的浏览器上打开另一个选项卡,并将其指向 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。因此,让我们探索一下这个模块如何帮助您实现这一点。