十七、使用 Nuxt 创建实时应用

在本章中,我们将进一步探讨 Nuxt,看看如何使用它与其他框架一起制作实时应用。我们将继续使用膝关节炎作为后端 API,但是用 ReTimkDB 和 SoCKE.IO“增强”它。换句话说,我们将通过这两个非常棒的框架和工具,将我们的后端 API 变成一个实时 API。同时,在他们的帮助下,我们将把我们的前端 Nuxt 应用变成一个实时 Nuxt 应用。如果愿意,您可以在单域方法上开发这两个实时应用。然而,这本书支持跨域方法,这样我们就不会混淆前端和后端的依赖关系,并随着时间的推移而感到困惑。因此,这将是另一个有趣和令人兴奋的篇章,供您学习!

在本章中,我们将介绍以下主题:

  • 引入数据库
  • 与膝关节炎的整合
  • 介绍 Socket.IO
  • 将 Socket.IO 与 Nuxt 集成

让我们开始吧!

引入数据库

RejectionDB 是一个用于实时应用的开源 JSON 数据库。每当您订阅的实时提要(changefeeds)的数据库表发生更改时,它就会将 JSON 数据从数据库实时推送到您的应用。尽管 changefeeds 是 RejectDB 实时功能的核心,但如果您愿意,可以跳过此功能。您可以像 MongoDB 一样使用 RejectionDB 来存储和查询您的 NoSQL 数据库。

尽管您可以使用 MongoDB 中的变更流来访问实时数据变更,但它需要一些配置来启动,而实时提要在 RejectionDB 中默认情况下可以使用,并且您可以在不进行任何配置的情况下立即接入。让我们开始在您的系统中安装DB 服务器,并在下一节中了解如何使用它。

安装数据库服务器

在撰写本书时,RejectionDB 的当前稳定版本是 2019 年 12 月 19 日发布的2.4.0活死人之夜。根据平台(Ubuntu 或 OS)的不同,有几种安装 DB 服务器的方法。您可以在查阅指南 https://rethinkdb.com/docs/install/ 为您的平台。注意:Windows 2.0 中尚不支持。有关 Windows 的此问题的更多信息,请访问https://rethinkdb.com/docs/install/windows

在本书中,我们将在Ubuntu 20.04 LTS(Focal Fossa)上安装 RejectionDB 2.4.0。如果您使用的是 Ubuntu 19.10(Eoan Ermine)、Ubuntu 19.04(Disco Dingo)或较早版本的 Ubuntu,如 18.04 LTS(仿生海狸),则其工作原理相同。让我们开始:

  1. 将数据库存储库添加到 Ubuntu 存储库列表中,如下所示:
$ source /etc/lsb-release && echo "deb https://download.rethinkdb.com/apt $DISTRIB_CODENAME main" | sudo tee /etc/apt/sources.list.d/rethinkdb.list
  1. 使用wget获取数据库的公钥:
$ wget -qO- https://download.rethinkdb.com/apt/pubkey.gpg | sudo apt-key add -

对于前面的命令行,您应该在终端上收到一条 OK 消息。

  1. 更新您的 Ubuntu 版本并安装 RejectionDB:
$ sudo apt update
$ sudo apt install rethinkdb
  1. 验证数据库:
$ rethinkdb -v

您应该在终端上获得以下输出:

rethinkdb 2.4.0~0eoan (CLANG 9.0.0 (tags/RELEASE_900/final))

RejectionDB 带有一个管理 UI,您可以在localhost:8080的浏览器上管理数据库。这在项目开发过程中非常方便和有用。如果要卸载 RejectionDB 并删除其所有数据库,可以使用以下命令执行此操作:

$ sudo apt purge rethinkdb.
$ sudo rm -r /var/lib/rethinkdb

安装时附带的管理 UI 类似于上一章中用于管理 PHP API 的 MySQL 数据库的 PHP 管理员。您可以使用 RejectDB 管理 UI 通过 UI 上的图形按钮或 RejectDB 查询语言(JavaScript)添加数据库和表,ReQL。我们将在下一节中探讨管理 UI 和 ReQL。

介绍需求

ReQL 是 RejectDB 的查询语言,用于操作 RethinDB 数据库中的 JSON 文档。通过在服务器端调用 RejectDB 的内置可链接函数自动构造查询。这些函数以各种编程语言 JavaScript、Python、Ruby 和 Java 嵌入到驱动程序中。您可以通过以下链接查看 ReQL 命令/功能:

我们将在本书中使用 JavaScript。让我们使用管理 UI 上的 Data Explorer,通过使用相应的 ReQL 命令来执行一些 CRUD 操作。您可以导航到 Data Explorer 所在的页面,或将浏览器指向localhost:8080/#dataexplorer并开始处理查询,如图所示。Data Explorer 上的默认顶级名称空间是r,因此 ReQL 命令必须链接到此名称空间。

但是,我们可以更改这个r名称空间,并在应用中使用驱动程序时调用任何我们喜欢的东西,这将在下一节中进行。现在,对于本练习,让我们使用默认名称空间r

  1. 创建数据库:
r.dbCreate('nuxtdb')

单击“运行”按钮。您应该会在屏幕上得到与以下类似的结果,显示已使用您选择的数据库名称创建了一个数据库,并且数据库生成了一个 ID:

{
  "config_changes": [
    {
      "new_val": {
      "id": "353d11a4-adc8-4958-a4ae-a82c996dcb9f" ,
      "name": "nuxtdb"
    } ,
      "old_val": null
    }
  ] ,
  "dbs_created": 1
}

If you want to find out more information about the dbCreate ReQL command, please visit https://rethinkdb.com/api/javascript/db_create/.

  1. 在现有数据库中创建表;例如,在nuxtdb数据库中创建一个user表:
r.db('nuxtdb').tableCreate('user')

单击“运行”按钮。您应该会在屏幕上得到与以下类似的结果,显示已创建了一个表,其中包含 RejectionDB 为您生成的 ID 以及有关您创建的表的其他信息:

{
  "config_changes": [{
    "new_val": {
      "db": "nuxtdb",
      "durability": "hard",
      "id": "259e0066-1ffe-4064-8b24-d1c82e515a4a",
      "indexes": [],
      "name": "user",
      "primary_key": "id",
      "shards": [{
        "nonvoting_replicas": [],
        "primary_replica": "lau_desktop_opw",
        "replicas": ["lau_desktop_opw"]
      }],
      "write_acks": "majority",
      "write_hook": null
    },
    "old_val": null
  }],
  "tables_created": 1
}

If you want to find out more information about the tableCreate ReQL command, please visit https://rethinkdb.com/api/javascript/table_create/.

  1. user表中插入新文件:
r.db('nuxtdb').table('user').insert([
 { name: "Jane Doe", slug: "jane" },
 { name: "John Doe", slug: "john" }
])

单击“运行”按钮。您应该会在屏幕上得到与以下类似的结果,显示已插入两个文档,其中包含 RejectionDB 为您生成的键:

{
  "deleted": 0,
  "errors": 0,
  "generated_keys": [
    "7f7d768d-0efd-447d-8605-2d460a381944",
    "a144001c-d47e-4e20-a570-a29968980d0f"
  ],
  "inserted": 2,
  "replaced": 0,
  "skipped": 0,
  "unchanged": 0
}

If you want to find out more information about the table and insert ReQL commands, please visit https://rethinkdb.com/api/javascript/table/ and https://rethinkdb.com/api/javascript/insert/, respectively.

  1. user表中检索文档:
r.db('nuxtdb').table('user')

单击“运行”按钮。您应该会在屏幕上得到与以下类似的结果,显示user表中的两个文档:

[{
  "id": "7f7d768d-0efd-447d-8605-2d460a381944",
  "name": "Jane Doe",
  "slug": "jane"
}, {
  "id": "a144001c-d47e-4e20-a570-a29968980d0f",
  "name": "John Doe",
  "slug": "john"
}]

如果要统计表中的文档总数,可以将count方法链接到查询中,如下所示:

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

user您应该在2注入新文档后进入表格。

If you want to find out more information about the count ReQL command, please visit https://rethinkdb.com/api/javascript/count/.

  1. 通过使用slug键过滤user表,更新user表中的文档:
r.db('nuxtdb').table('user')
.filter(
  r.row("slug").eq("john")
)
.update({
  name: "John Wick"
})

单击“运行”按钮。您应该在屏幕上看到以下结果,显示一个文档已被替换:

{
  "deleted": 0,
  "errors": 0,
  "inserted": 0,
  "replaced": 1,
  "skipped": 0,
  "unchanged": 0
}

If you want to find out more information about the filter and update ReQL commands, please visit https://rethinkdb.com/api/javascript/filter/ and https://rethinkdb.com/api/javascript/update/, respectively.

Also, if you want to find out more information about the row and eq ReQL commands, please visit https://rethinkdb.com/api/javascript/row/ and https://rethinkdb.com/api/javascript/eq/, respectively.

  1. 通过使用slug键过滤表格,从user表格中删除文档:
r.db('nuxtdb').table('user')
.filter(
  r.row("slug").eq("john")
)
.delete()

单击“运行”按钮。您应在屏幕上获得以下结果,显示一个文档已被删除:

{
  "deleted": 1,
  "errors": 0,
  "inserted": 0,
  "replaced": 0,
  "skipped": 0,
  "unchanged": 0
}

如果要删除一个表中的所有文档,只需将delete方法链接到该表而不进行过滤,如下所示:

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

If you want to find out more information about the delete ReQL command, please visit https://rethinkdb.com/api/javascript/delete/.

使用 ReQL 命令既有趣又简单,不是吗?您不必通读所有 ReQL 命令并详细研究每一个命令,就可以提高工作效率。您只需知道您想要做什么,并根据您已经了解的编程语言从 ReQL 命令参考/API 页面中找到所需的命令。接下来,您将了解如何将DB 客户端或驱动程序添加到您的应用中。让我们开始吧!

与膝关节炎的整合

在本节中,我们将按照上一章中创建的 PHP API 构建一个简单的 API,以列出、添加、更新和删除用户。在前面的 API 中,我们使用 PHP 和 MySQL,而在本章中,我们将使用 JavaScript 和 DB。我们仍然将使用膝关节炎作为我们的 API 的框架。但这一次,我们将重新构造 API 目录,使其结构与您已经熟悉的 Nuxt 应用和 PHP API 的目录结构保持一致(尽可能多)。那么,让我们开始吧!

重组 API 目录

还记得您在使用 Vue CLI 时在项目中获得的默认目录结构吗?这是您在第 11 章编写路由中间件和服务器中间件中了解的?使用 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

第 12 章创建用户登录和 API 认证以来,我们一直在跨域应用中使用这种标准结构。例如,下面是膝关节炎 API 的目录结构,您以前做过:

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

但这一次,我们将从本章将要制作的 API 中删除/src/目录。随着 T1 的升级,我们将如何重新配置应用目录中的所有内容:

  1. 在项目的根目录中创建以下文件和文件夹:
backend
├── package.json
├── backpack.config.js
├── middlewares.js
├── routes.js
├── configs
│ ├── index.js
│ └── rethinkdb.js
├── core
│ └── ...
├── middlewares
│ └── ...
├── modules
│ └── ...
└── public
    └── index.js

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

  • /configs/目录用于存储应用的基本信息和数据库连接的详细信息。
  • /public/目录用于存储启动应用的文件。
  • /modules/目录用于存储应用的模块,例如'user'模块,我们将在接下来的章节中创建该模块。
  • /core/目录用于存储可在整个应用中使用的常用函数或类。
  • middlewares.js文件是从/middlewares//node_modules/目录导入中间件的核心位置。
  • routes.js文件是从/modules目录导入路由的核心位置。
  • backpack.config.js文件用于自定义我们应用的网页包配置。
  • package.json文件包含我们应用的脚本和依赖项,并且始终位于根级别。

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

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

请记住,背包中的默认条目文件是/src/目录中的index.js文件。因为我们已经将这个索引文件移动到了/public/目录,所以我们必须通过背包配置文件来配置这个入口点。

If you want to know more about the entry points in webpack, please visit https://webpack.js.org/concepts/entry-points/.

  1. /configs/core/modules/middlewares路径的别名添加到网页包配置中的resolve选项,然后返回背包配置文件中的config对象:
// 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'

If you want to find out more about the alias and resolve options in webpack, please visit https://webpack.js.org/configuration/resolve/resolvealias.

一旦准备好前面的结构并对条目文件进行了排序,就可以开始将 CRUD 操作与 RejectDB 一起应用于此 API。但首先,您需要将DB JavaScript 客户端安装到您的项目中。那么,让我们开始吧!

添加和使用 DB JavaScript 客户端

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

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

  1. 通过 npm 安装 DB JavaScript 客户端驱动程序:
$ npm i rethinkdb
  1. 创建一个rethinkdb.js文件,该文件将包含/configs/目录中的数据库服务器连接详细信息,如下所示:
// configs/rethinkdb.js
export default {
  host: 'localhost',
  port: 28015,
  dbname: 'nuxtdb'
}
  1. /core/目录中创建一个connection.js文件,用于使用前面的连接详细信息打开数据库服务器连接,如下所示:
// 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
  1. 此外,在 ORYT1 目录中创建一个带有连接 T0 0 文件的开放式连接中间件,并将其绑定到膝关节炎上下文作为另一个连接到 ReTykDB 的选项,如下:
// 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()
}

It is a good practice, which we learned from PHP's PSR-4, to use the directory path to describe your middleware (or CRUD operations) so that you don't have to use a long name to describe your file. For example, you might want to name this middleware rdb-connection-open.js to describe what it is as clearly as possible if you are not using a descriptive directory path for it. But if you are using the directory path to describe the middleware, then you can just name the file something as simple as open.js.

  1. 创建一个与 ORT T1Y 目录中的 TyrT0AY 文件紧密连接的中间件,并将其绑定到膝关节炎上下文作为最后一个中间件,如下:
// 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()
}
  1. openclose连接中间件导入根middlewares.js文件,并注册到 app 中,如下所示:
// 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)
}

在这里,您可以看到,open连接中间件在所有模块路由之前注册了,并且close连接中间件在最后注册了,因此它们分别被称为 first 和 last。

  1. 在接下来的步骤中,我们将使用下面的模板代码,使用膝关节炎路由和 ReTimkDB 客户端驱动程序来进行 CRUD 操作。例如,下面的代码展示了我们如何应用模板代码从user模块的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 {
    // perform verification on the incoming parameters...
    // perform a CRUD operation:
    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

让我们浏览一下这段代码,了解它的作用。在这里,您可以看到,我们正在为 RejectDB 客户机驱动程序使用一个定制的顶级名称空间rdb,而不是您在localhost:8080上练习的r名称空间。此外,在我们的应用中使用 RejectDB 客户端驱动程序时,我们必须始终使用 RejectDB 服务器连接调用 ReQL 命令末尾的run方法来构造查询并将其传递到服务器上执行。

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

Note that in the upcoming steps, we will skip writing the parameter verification and the try-catch statement from the code to avoid lengthy and repetitive code lines and blocks, but you should have them included in your actual code.

  1. 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()
})

如果插入失败,我们应该抛出错误,并将错误消息传递给膝关节炎的 OTA.T00.EP 方法,用 HTTP 错误代码,这样我们就可以用 AdvT1 的块来捕获它们,并在前端显示它们。

  1. 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异常。

  1. 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命令,以便在运行更新之前,首先通过 ID 获取特定文档。

  1. 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()
})
  1. 最后,重构 CRUD 操作,从步骤 7中刚刚创建的user表中列出所有用户,方法是向index.js文件中的查询添加orderBy命令,该文件保存在/_routes/文件夹中,如下所示:
// 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命令,这样我们就可以按文档的创建日期(最晚的第一个)对文档进行排序。此外,RequiredDB 数据库返回的文档始终包含在游标对象中,作为 CRUD 操作的回调,因此我们必须使用toArray命令迭代游标并将该对象转换为数组。

If you want to find out more about the orderBy and toArray commands, please visit https://rethinkdb.com/api/javascript/order_by/ and https://rethinkdb.com/api/javascript/to_array/, respectively.

这样,您就成功地在 API 中使用 RejectDB 实现了 CRUD 操作。再说一次,这很简单也很有趣,不是吗?但我们仍然可以通过在数据库中强制实施模式来提高存储在数据库中的文档的“质量”。我们将在下一节学习如何做到这一点。

在数据库中实施模式

与 MongoDB 中的 BSON 数据库一样,RejectDB 中的 JSON 数据库也是无模式的。这意味着数据库没有蓝图,也没有公式或完整性约束。关于数据库如何构造的任何有组织的规则都不能在我们的数据库中提出完整性问题。某些文档可能在同一个表(或 MongoDB 中的“集合”)中包含不同的和不需要的键,以及具有正确键的文档。您可能会错误地注入一些键,或者忘记注入所需的键和值。因此,如果您希望将文档中的数据组织起来,那么在 JSON 或 BSON 数据库中实施某种模式是一个好主意。RejectionDB(或 MongoDB)没有用于强制实施模式的内部功能,但我们可以创建自定义函数,以便在 Node.js Lodash 模块中强制实施一些基本模式。让我们探讨如何做到这一点:

  1. 通过 npm 安装 Lodash 模块:
$ npm i lodash
  1. /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 }

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

We are using the following methods from Lodash. For more information about each of them, please visit the following links: https://lodash.com/docs/4.17.15#keys for the keys method https://lodash.com/docs/4.17.15#defaults for the defaults method https://lodash.com/docs/4.17.15#pick for the pick method

  1. user模块中创建一个user模式,其中包含以下只有您想要接受的键:
// modules/user/schema.js
export default {
  slug: null,
  name: null,
  createdAt: null,
  updatedAt: null
}
  1. 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)

在【插入】之前,在【示例 T0】中的【插入】和【插入】字段中不会插入数据。

您可以看到这个sanitise函数只执行一个简单的验证。如果需要更复杂、更高级的数据验证,可以使用 hapi web 框架中的 Node.js joi 模块。

If you want to find out more about this module, please visit https://hapi.dev/module/joi/.

接下来您必须探索的是 RejectDB 中的changefeeds。这是本章的主要目的–向您展示如何利用 RejectDB 的实时功能创建实时应用。所以,让我们在 RejectDB 中探索并使用 ChangeFeed!

在数据库中引入 changefeeds

在使用 RejectDB 客户端驱动程序在我们的应用中应用 ChangeFeed 之前,让我们在localhost:8080/#dataexplorer再次使用管理 UI 中的 Data Explorer 在屏幕上实时查看实时 Feed:

  1. 粘贴以下需求查询并单击运行按钮:
r.db('nuxtdb').table('user').changes()

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

Listening for events...
Waiting for more results
  1. 在浏览器上打开另一个选项卡并将其指向localhost:8080/#dataexplorer。现在,您有两个数据探索器。从“浏览器”选项卡中拖出一个,以便可以并排放置。然后,从一个 data Explorer 将新文档插入到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
}

同时,您应该看到另一个 Data Explorer 实时显示以下提要:

{
  "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
}

好极了您刚刚用 RejectDB 轻松地制作了实时提要!请注意,在每个实时提要中,您总是会得到这两个键,new_valold_val。它们具有以下含义:

  • 如果您在new_val中得到数据,但在old_val中得到null,则表示新文档被注入数据库。
  • 如果您同时获得了new_valold_val中的数据,则表示数据库中的现有文档已更新。
  • 如果您在old_val中得到数据,但在new_val中得到的是null,则表示现有文档已从数据库中删除。

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

介绍 Socket.IO

与 HTTP 一样,WebSocket 是一种通信协议,但它在客户端和服务器之间提供全双工(双向)通信。与 HTTP 不同,WebSocket 连接始终保持打开状态以进行实时数据传输。因此,在 WebSocket 应用中,服务器可以向客户端发送数据,而无需客户端发起请求。

此外,与超文本传输协议安全以 HTTP 或 HTTPS 开头的 HTTP 模式不同,WebSocket 协议模式以wswss开头,用于 WebSocket 安全;例如:

ws://example.com:4000

IO 是一个 JavaScript 库,它使用 WebSocket 协议和轮询作为创建实时 web 应用的后备选项。它支持任何平台、浏览器或设备,并处理服务器和客户端的所有降级,以实现实时全双工通信。如今,大多数浏览器都支持 WebSocket 协议,包括 Google Chrome、Microsoft Edge、Firefox、Safari 和 Opera。但是在使用 Socket.IO 时,我们必须同时使用它的客户端库和服务器端库。客户端库在浏览器中运行,而服务器端库在服务器端 Node.js 应用上运行。那么,让我们让这两个库在我们的应用中工作。

If you want to find out more about Socket.IO, please visit https://socket.io/.

添加和使用 Socket.IO 服务器和客户端

我们将把 Socket.IO 服务器添加到我们在前几节中构建的 API 中,然后最终将 Socket.IO 客户端添加到 Nuxt 应用中。但是在将它添加到 Nuxt 应用之前,我们将把它添加到一个简单的 HTML 页面,这样我们就可以鸟瞰 Socket.IO 服务器和 Socket.IO 客户端是如何协同工作的。让我们学习如何做到这一点:

  1. 通过 npm 安装 Socket.IO 服务器:
$ npm i socket.io
  1. 如果您尚未在/configs/目录中创建index.js文件来存储服务器设置,请执行以下操作:
// configs/index.js
export default {
  server: {
    port: 4000
  },
}

通过这个简单的设置,我们将在端口 4000 提供 API。

  1. 导入 Outt0,并将其绑定到 No.js HTTP 对象,并使用膝关节炎的新实例创建 SoCKET.IO 的新实例,如下:
// backend/koa/public/index.js
import Koa from 'koa'
import socket from 'socket.io'
import http from 'http'
import config from 'Configs'
import middlewares from '../middlewares'

const app = new Koa()
const host = process.env.HOST || '127.0.0.1'
const port = process.env.PORT || config.server.port
middlewares(app)

const server = http.createServer(app.callback())
const io = socket(server)

io.sockets.on('connection', socket => {
  console.log('a user connected: ' + socket.id)
  socket.on('disconnect', () => {
    console.log('user disconnected: ' + socket.id)
  })
})
server.listen(port, host)

创建 Socket.IO 的新实例后,我们可以开始监听来自socket回调的传入套接字的 Socket.IOconnection事件。我们使用其 ID 将传入套接字记录到控制台。我们还记录传入套接字断开连接时的disconnect事件。最后,注意我们使用原生 NoDE.js HTTP 启动并服务于 Apple T3A.上的应用,而不是使用膝关节炎中的 HTTP,我们过去使用它。

app.listen(4000)
  1. 创建socket-client.html页面,通过 CDN 导入 Socket.IO 客户端。通过将localhost:4000作为特定 URL 传递,创建一个新的实例,如下所示:
// frontend/html/socket-client.html
<script src="https://cdn.jsdelivr.net/npm/socket.io-
 client@2/dist/socket.io.js"></script>

<script>
  var socket = io('http://localhost:4000/')
</script>

现在,如果您在浏览器上浏览此 HTML 页面,或者刷新页面时,应该会看到控制台打印带有套接字 ID 的日志,如下所示:

a user connected: abeGnarBnELo33vQAAAB

当您关闭 HTML 页面时,还应该看到控制台打印带有套接字 ID 的日志,如下所示:

user disconnected: abeGnarBnELo33vQAAAB

这就是连接 Socket.IO 的服务器端和客户端所需的全部操作。这非常简单,不是吗?但我们在这里所做的只是连接和断开服务器和客户端。我们需要从他们那里得到更多——我们希望同时传输数据。要做到这一点,我们只需要相互发送和接收事件,我们将在接下来的步骤中完成。

If you want to use the local version of the Socket.IO client, you can point the script tag's URL source to /node_modules/socket.io-client/dist/socket.io.js.

  1. 使用 Socket.IO 服务器中的emit方法从服务器创建一个 emit 事件,如下所示:
// backend/koa/public/index.js
io.sockets.on('connection', socket => {
  io.emit('emit.onserver', 'Hi client, what you up to?')
  console.log('Message to client: ' + socket.id)
})

在这里,您可以看到,我们通过名为emit.onserver的定制事件发出带有简单消息的事件,并将活动记录到控制台。请注意,我们只能在建立连接时发出事件。然后,我们可以在客户端侦听此自定义事件,并记录来自服务器的消息,如下所示:

// frontend/html/socket-client.html
socket.on('emit.onserver', function (message) {
  console.log('Message from server: ' + message)
})
  1. 因此,现在,如果您再次在浏览器上刷新页面,您应该会看到控制台打印带有套接字 ID 的日志,如下所示:
Message to client: abeGnarBnELo33vQAAAB // server side
Message from server: Hi client, what you up to? // client side
  1. 使用 Socket.IO 客户端的emit方法从客户端创建一个 emit 事件,如下所示:
// frontend/html/socket-client.html
<script
  src="https://code.jquery.com/jquery-3.4.1.slim.min.js"
  integrity="sha256-pasqAKBDmFT4eHoN2ndd6lN370kFiGUFyTiUHWhU7k8="
  crossorigin="anonymous"></script>

<button class="button-sent">Send</button>

$('.button-sent').click(function(e){
  e.preventDefault()

  var message = 'Hi server, how are you holding up?'
  socket.emit('emit.onclient', message)
  console.log('Message sent to server.')

  return false
})

在这里,您可以看到,首先,我们通过 CDN 安装 jQuery,并使用 jQueryclick事件创建一个<button>。其次,单击按钮时,我们会发出名为emit.onclient的 Socket.IO 自定义事件,并显示一条简单消息。最后,我们将活动记录到控制台。

  1. 之后,我们可以在服务器端侦听 Socket.IO 自定义事件,并记录来自客户端的消息,如下所示:
// backend/koa/public/index.js
socket.on('emit.onclient', (message) => {
  console.log('Message from client, '+ socket.id + ' :' + message);
})
  1. 如果再次在浏览器上刷新页面,您将看到控制台打印日志以及套接字 ID,如下所示:
Message sent to server. // client side
Message from client, abeGnarBnELo33vQAAAB: Hi server, 
how are you holding up? // server side

现在,您知道了如何使用 Socket.IO 实时来回传输数据–只需发送自定义事件并侦听它们即可。接下来,您应该知道如何将 Socket.IO 与数据库中的 changefeeds 集成,以便将实时数据从数据库传输到客户端。所以,继续阅读!

集成 Socket.IO 服务器和数据库 changefeeds

请记住,您之前在localhost:8080/#dataexplorer处再次使用管理 UI 中的数据资源管理器处理了 RejectionDB changefeeds。要订阅 changefeed,只需将 REQULchanges命令链接到查询,如下所示:

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

RejectionDB changefeeds 包含从 RejectionDB 数据库发送到 API 的实时数据,这意味着我们需要在服务器端使用 Socket.IO 服务器捕获这些提要,并将它们发送到客户端。因此,让我们学习如何通过重构我们在本章中一直开发的 API 来捕捉它们:

  1. 通过 npm 将 Socket.IO 服务器安装到 API 中:
$ npm i socket.io
  1. /core/目录的changefeeds.js文件中创建一个异步匿名箭头函数,代码如下:
// core/database/rethinkdb/changefeeds.js
import rdb from 'rethinkdb'
import rdbConnection from './connection'

export default async (io, tableName, eventName) => {
  try {
    const connection = await rdbConnection()
    var cursor = await rdb.table(tableName)
      .changes()
      .run(connection)

    cursor.each(function (err, row) {
      if (err) {
        throw err
      }
      io.emit(eventName, row)
    })
  } catch( err ) {
    console.error(err);
  }
}

在此函数中,我们将rethinkdb导入为rdb,我们的数据库连接导入为rdbConnection,然后使用以下项目作为此函数的参数:

  • Socket.IO 服务器的实例
  • Socket.IO 发出您要使用的自定义事件名称
  • 要订阅其 changefeed 的数据库表名

changefeed 将返回游标对象中的文档作为回调,因此我们迭代游标对象,并使用自定义事件名称发出文档的每一行。

  1. changefeeds函数作为rdbChangeFeeds导入/public/目录下的 app root 中,并与index.js文件中现有的其余代码集成,如下所示:
// public/index.js
import Koa from 'koa'
import socket from 'socket.io'
import http from 'http'
import config from 'Configs'
import middlewares from '../middlewares'
import rdbChangeFeeds from 'Core/database/rethinkdb/changefeeds'

const app = new Koa()
const host = process.env.HOST || '127.0.0.1'
const port = process.env.PORT || config.server.port
middlewares(app)

const server = http.createServer(app.callback())
const io = socket(server)
io.sockets.on('connection', socket => {
  //...
})

rdbChangeFeeds(io, 'user', 'user.changefeeds')
server.listen(port, host)

在前面的代码中,我们想要订阅的表名是user,我们想要调用的 emit 事件名是user.changefeeds。因此,我们通过socket.io实例将它们传递到rdbChangeFeeds函数中。这就是只需一次全局集成 Socket.IO 和 referencedb 所需要做的一切。

做得好!您已经成功地在服务器端集成了 Koa、ReTimkDB 和 SokKe.IO,并创建了一个实时 API。但是客户端呢,我们如何监听 API 发出的事件呢?我们将在下一节中找到答案。

将 Socket.IO 与 Nuxt 集成

我们将要构建的 Nuxt 应用与上一章中的应用非常相似,我们在/pages/目录中有一个/users/目录,其中包含以下 CRUD 页面,用于添加、更新、列出和删除用户:

users
├── index.vue
├── _slug.vue
├── add
│ └── index.vue
├── update
│ └── _slug.vue
└── delete
    └── _slug.vue

您可以从上一章复制这些文件。这个应用唯一的主要变化和区别是<script>块,我们将通过监听 Socket.IO 服务器发出的事件来实时列出用户。要做到这一点,我们需要使用 Socket.IO 客户端,这是您在添加和使用 Socket.IO 服务器和客户端一节中通过简单 HTML 页面了解到的。那么,让我们来了解一下如何将我们已经知道的实现到 Nuxt 应用中:

  1. 通过 npm 将 Socket.IO 客户端安装到 Nuxt 项目中:
$ npm i socket.io-client
  1. 在 Nuxt 配置文件中为应用的协议、主机名和跨域端口创建以下变量,以便我们以后可以重用它们:
// nuxt.config.js
const protocol = 'http'
const host = process.env.NODE_ENV === 'production' ? 'a-cool-domain-name.com' : 'localhost'

const ports = {
  local: '8000',
  remote: '4000'
}

const remoteUrl = protocol + '://' + host + ':' + ports.remote + '/'

这些变量适用于以下情况:

  • host变量用于在 Nuxt app 生产时取a-cool-domain-name.com的值;也就是说,当您使用npm run start运行应用时。否则只取localhost作为默认值。
  • ports变量中的local键用于设置 Nuxt app 的服务器端口,并设置为8000。请记住,Nuxt 为应用提供服务的默认端口是3000
  • ports变量中的remote键用于告知 Nuxt 应用 API 所在的服务器端口,即4000
  • remoteUrl变量用于将 API 与前面的变量连接起来。

  • 将上述变量应用于 Nuxt 配置文件中的envserver选项,如下所示:

// nuxt.config.js
export default {
  env: {
    remoteUrl
  },
  server: {
    port: ports.local,
    host: host
  }
}

因此,通过此配置,我们可以通过以下方法在服务应用时再次访问remoteUrl变量:

  • process.env.remoteUrl
  • context.env.remoteUrl

此外,在此配置中,我们在server选项中将 Nuxt 应用的默认服务器端口更改为8000。默认端口为3000,默认主机为localhost。但出于某种原因,您可能需要使用不同的端口。这就是为什么我们在这里研究如何改变它们。

If you want to find out more about the server configuration and other options such as timing and https, please visit https://nuxtjs.org/api/configuration-server.

If you want to find out more about the env configuration, please visit https://nuxtjs.org/api/configuration-envthe-env-property.

  1. 安装 Nuxt Axios 和代理模块,并在 Nuxt 配置文件中进行配置,如下所示:
// nuxt.config.js
export default {
  modules: [
    '@nuxtjs/axios'
  ],

  axios: {
    proxy: true
  },

  proxy: {
    '/api/': {
      target: remoteUrl,
      pathRewrite: {'^/api/': ''}
    }
  }
}

请注意,我们在proxy选项中重用了remoteUrl变量。因此,我们发出的每个以/api/开头的 API 请求都将转换为http://localhost:4000/api/。但是,由于 API 中的路由中没有/api/,因此在发送到 API 之前,我们使用pathRewrite选项从请求 URL 中删除了这个/api/部分。

  1. /plugin/目录中创建一个插件,用于抽象 Socket.IO 客户端的实例,以便我们可以在任何地方重用它:
// plugins/socket.io.js
import io from 'socket.io-client'

const remoteUrl = process.env.remoteUrl
const socket = io(remoteUrl)

export default socket

注意,我们在 Socket.IO 客户端实例中通过process.env.remoteUrl重用了remoteUrl变量。这意味着 Socket.IO 客户端将调用位于localhost:4000的 Socket.IO 服务器。

  1. socket.io客户端插件导入<script>块,获取index文件中@nuxtjs/axios模块的用户列表。该索引文件保存在/users/目录下的pages下:
// pages/users/index.vue
import socket from '~/plugins/socket.io'

export default {
  async asyncData ({ error, $axios }) {
    try {
      let { data } = await $axios.get('/api/users')
      return { users: data.data }
    } catch (err) {
      // Handle the error.
    }
  }
}
  1. 在使用asyncData方法获取并设置用户后,使用 Socket.IO 插件来监听mounted方法中的user.changefeeds事件,以获取来自服务器的任何新的实时提要,如下所示:
// pages/users/index.vue
export default {
  async asyncData ({ error, $axios }) {
    //...
  },
  mounted () {
    socket.on('user.changefeeds', data => {
      if (data.new_val === undefined && data.old_val === undefined) {
        return
      }
      //...
    })
  }
}

在这里,您可以看到我们总是检查data回调,以确保在传入提要中定义了new_valold_val。换句话说,我们希望确保在继续执行以下行之前,这两个键始终存在于提要中。

  1. 检查此项后,如果我们在new_val键中接收到数据,但old_val键为空,则表示已向服务器添加了新用户。如果我们从服务器端获得一个新的提要,我们将使用 JavaScriptunshift函数将新的用户数据预先发送到user数组的顶部,如下所示:
// pages/users/index.vue
mounted () {
  //...
  if(data.old_val === null && data.new_val !== null) {
    this.users.unshift(data.new_val)
  }
}

然后,如果我们在old_val键中接收到数据,但new_val键为空,这意味着现有用户已从服务器中删除。因此,要通过索引(其在数组中的位置)从数组中弹出现有用户,我们可以使用 JavaScriptsplice函数。但首先,我们必须使用 JavaScriptmap函数通过 ID 找到用户的索引,如下所示:

// pages/users/index.vue
mounted () {
  //...
  if(data.new_val === null && data.old_val !== null) {
    var id = data.old_val.id
    var index = this.users.map(el => {
      return el.id
    }).indexOf(id)
    this.users.splice(index, 1)
  }
}

最后,如果我们同时收到new_valold_val键中的数据,这意味着当前用户已被更新。因此,如果用户已经更新,我们必须首先在数组中找到用户的索引,然后用 JavaScriptsplice函数替换它,如下所示:

// pages/users/index.vue
mounted () {
  //...
  if(data.new_val !== null && data.old_val !== null) {
    var id = data.new_val.id
    var index = this.users.findIndex(item => item.id === id)
    this.users.splice(index, 1, data.new_val)
  }
}

请注意,我们使用 JavaScriptfindIndex函数代替map函数。

If you want to find out more information about the JavaScript standard built-in functions we have used here for manipulating the JavaScript arrays, please visit the following links:

// pages/users/index.vue
<div>
  <h1>Users</h1>
  <ul>
    <li v-for="user in users" v-bind:key="user.uuid">
      <nuxt-link :to="'/users/' + user.slug">
        {{ user.name }}
      </nuxt-link>
    </li>
  </ul>
  <nuxt-link to="/users/add">
    Add New
  </nuxt-link>
</div>

在这个模板中,您可以看到我们只是将从asyncData方法获得的用户数据与v-for循环,并将用户uuid绑定到每个循环元素。之后,mounted方法中出现的任何实时提要都会反应性地更新用户数据和模板。

  1. 使用npm run dev运行 Nuxt 应用。您应该在终端上输入以下信息:
Listening on: http://localhost:8000/
  1. 并排打开浏览器上的两个选项卡,或并排打开两个不同的浏览器,并将它们指向localhost:8000/users。从localhost:8000/users/add的某个选项卡(或浏览器)添加新用户。您应该看到,新添加的用户即时、并发地显示在所有选项卡(或浏览器)上,而无需刷新它们。

You can find all the code and apps in this chapter in /chapter-17/frontend/ and /chapter-17/backend/ in this book's GitHub repository.

干得好——你成功了!我们希望您觉得这个应用有趣且简单,并且它能激励您进一步尝试迄今为止所学的知识。让我们总结一下我们在本章学到的知识。

总结

在本章中,您成功地安装并使用 RejectDB 和 Socket.IO 将普通后端 API 和前端 Nuxt 应用转换为实时应用。通过使用 ReTimkDB 管理 UI 创建、读取、更新和删除服务器端的 JSON 数据,然后使用 ReTimkDB 客户端驱动程序与 Koa 进行操作,您学会了如何操纵 JSON 数据。最重要的是,您还学会了如何通过 ReTimkDB 管理 UI 操作 RethinkDB 中的实时 feed,也就是称为 CyeFfeed,然后将它们与 SoCKET.IO 服务器和膝关节炎(KOA)集成在服务器端。此外,您还使用 Socket.IO 服务器发送带有自定义事件的数据,使用 Socket.IO 客户端侦听事件,并使用 Nuxt 应用在客户端实时捕获数据。这不是一次有趣的旅行吗?

在下一章中,我们将使用第三方 API、内容管理系统CMS)和 GraphQL 进一步介绍 Nuxt。我们将向您介绍 WordPressAPI、Keystone 和 GraphQL。然后,您将学习如何创建自定义内容类型和自定义路由,以扩展 WordPress API,从而可以将其与 Nuxt 集成,并从 WordPress 项目流式传输远程图像。您将使用 Keystone 开发定制 CMS,为 Keystone 应用开发安装并保护 PostgreSQL,以及保护 MongoDB,您在第 9 章中学习了如何安装 MongoDB,添加了服务器端数据库。最重要和令人兴奋的是,您将了解 RESTAPI 和 GraphQLAPI 之间的区别;使用 GraphQL.js、Express 和 Apollo 服务器构建 GraphQL API;了解 GraphQL 模式及其解析程序;使用 Keystone GraphQL API;然后将它们与 Nuxt 集成。这肯定会是另一个有趣的旅程,所以系好安全带,做好准备!