将 MongoDB 与 Koa 集成

我们已经学习了一些用于通过 MongoDB Shell 执行 CRUD 操作的 MongoDB 查询。现在我们只需要 MongoDB 驱动程序来帮助我们连接到 MongoDB 服务器,并执行与我们在 MongoDB Shell 中执行的相同的 CRUD 操作。我们将在我们的服务器端框架 Koa 应用程序中将此驱动程序安装为一个依赖项。

安装 MongoDB 驱动程序

Node.js 应用程序的官方 MongoDB 驱动程序是 mongodb。它是一个高级 API,构建在低级 API mongodb-coreMongoDB 核心驱动程序)之上。前者是为最终用户设计的,而后者是为 MongoDB 库开发人员设计的。mongodb 包含使 MongoDB 连接、CRUD 操作和身份验证变得简单的抽象和助手,而 mongodb-core 仅包含 MongoDB 拓扑连接的基本管理、核心 CRUD 操作和身份验证。

有关这两个软件包的更多信息,请访问以下站点:

  • MongoDB 驱动程序:https://www.npmjs.com/package/mongodb

  • MongoDB 核心驱动程序:https://www.npmjs.com/package/mongodb-core

  • MongoDB 驱动程序 API:http://mongodb.github.io/node-mongodb-native/3.0/api/

我们可以使用 npm 安装 MongoDB 驱动程序:

$ npm i mongodb

接下来,我们将在下一节中通过一个简单的示例来了解如何使用它。

使用 MongoDB 驱动程序创建一个简单的应用

让我们使用 MongoDB 驱动程序设置一个简单的应用程序,以执行简单的连接检查。在这个测试中,我们将使用我们在上一章中介绍的 Backpack 构建系统来运行我们的测试。那么,让我们按照以下步骤开始:

  1. 如上一节所示,安装 MongoDB 驱动程序,然后安装 Backpackcross-env

    $ npm i backpack-core
    $ npm i cross-env
  2. 创建 /src/ 文件夹作为默认入口目录,并在其中创建一个 index.js 文件,然后导入 MongoDB 驱动程序和 Node.jsAssert 模块,如下所示:

    // src/index.js
    import { MongoClient } from 'mongodb'
    import assert from 'assert'
    const url = 'mongodb://localhost:27017'
    const dbName = 'nuxt-app'

    在这一步中,我们还应该提供 MongoDB 连接详细信息:MongoDB 服务器的默认地址 mongodb://localhost:27017,以及我们要连接的数据库 nuxt-app

    请注意,AssertNode.js 的内置模块,它提供了一组用于单元测试代码的断言函数,因此我们不必安装此模块。如果你想了解更多关于此模块的信息,请访问 https://nodejs.org/api/assert.html#assert_assert。

  3. 接下来,建立与 MongoDB 服务器中数据库的连接,并使用 Assert 确认连接,如下所示:

    // src/index.js
    MongoClient.connect(url, {
        useUnifiedTopology: true,
        useNewUrlParser: true
    }, (err, client) => {
        assert.equal(null, err)
        console.log('Connected to the MongoDB server')
        const db = client.db(dbName)
        client.close()
    })

    在这个例子中,我们使用了 assert 模块的 equal 方法来确保在通过客户端(client)回调创建数据库实例之前,err 回调为 null。每当我们完成一个任务时,都应该使用 close 方法关闭连接。

  4. 如果你在终端上使用 npm run dev 运行此连接测试,你应该在终端上得到以下输出:

    Connected to the MongoDB server

    你可以在我们的 GitHub 仓库的 /chapter-9/mongo-driver/ 目录下找到这个简单的例子。

请注意,我们正在连接到 MongoDB,而没有任何身份验证,因为我们尚未保护我们的 MongoDB。你将在本书的最后一章(第 18 章 “使用 CMS 和 GraphQL 创建 Nuxt 应用程序”)中学习如何设置新的管理用户来保护你的 MongoDB。为了简化你的学习曲线并加快本章后续部分的开发过程,我们将选择不保护 MongoDB。现在,让我们在下一节中更深入地了解如何配置 MongoDB 驱动程序。

配置 MongoDB 驱动程序

从上一节的代码中可以看出,每当我们执行 MongoDB CRUD 任务时,都应该导入 MongoClient,提供 MongoDB 服务器 URL、数据库名称等等。这可能会很繁琐且效率低下。让我们按照以下步骤将前面的 MongoDB 连接代码抽象成一个类:

  1. 将数据库连接详细信息抽象到一个文件中:

    // server/config/mongodb.js
    const database = {
      host: 'localhost',
      port: 27017,
      dbname: 'nuxt-app'
    };
    export default {
      host: database.host,
      port: database.port,
      dbname: database.dbname,
      url: 'mongodb://' + database.host + ':' + database.port
    };
  2. 创建一个 class 函数来构建数据库连接,这样我们在执行 CRUD 操作时就不必重复这个过程。我们还在类函数中构建了一个 objectId 属性,用于存储 ObjectId 方法,我们将需要使用该方法来解析来自客户端的 ID 数据,以便该 ID 数据可以从字符串转换为对象:

    // server/mongo.js
    import mongodb from 'mongodb';
    import config from './config/mongodb';
    const MongoClient = mongodb.MongoClient;
    export default class Mongo {
      constructor() {
        this.connection = null;
        this.objectId = mongodb.ObjectId;
      }
      async connect() {
        this.connection = await MongoClient.connect(config.url, {
          useUnifiedTopology: true,
          useNewUrlParser: true,
        });
        return this.connection.db(config.dbname);
      }
      close() {
        this.connection.close();
      }
    }
  3. 导入该类并使用 new 语句实例化它,如下所示:

    import Mongo from './mongo'
    const mongo = new Mongo()

    例如,我们可以在需要连接到 MongoDB 数据库以执行 CRUD 操作的 API 路由中导入它,如下所示:

    // server/routes.js
    import Router from 'koa-router'
    import Mongo from './mongo'
    const mongo = new Mongo()
    const router = new Router({ prefix: '/api' })
    
    router.post('/user', async (ctx, next) => {
        //...
    })

在使用 MongoDB 驱动程序和我们的服务器端框架 Koa 创建 CRUD 操作之前,我们应该了解 ObjectIdObjectId 方法。让我们深入了解一下。

理解 ObjectId 和 ObjectId 方法

ObjectIdMongoDB 用作集合中主键的一种快速生成且很可能唯一的值。它由 12 个字节组成;前 4 个字节是时间戳,用于记录 ObjectId 值的创建时间。它存储在集合中每个文档唯一的 _id 字段中。如果在注入文档时未声明 _id 字段,则会自动生成该字段。另一方面,ObjectId(<hexadecimal>) 是一个 MongoDB 方法,我们可以使用它来返回一个新的 ObjectId 值,以及将字符串解析为 ObjectId 对象。以下是一个示例:

// 伪代码
var id = '5d2ba2bf089a7754e9094af5'
console.log(typeof id) // string
console.log(typeof ObjectId(id)) // object

在上面的伪代码中,你可以看到我们使用 ObjectId 方法创建的对象中的 getTimestamp 方法来获取 ObjectId 值中的时间戳。以下是一个示例:

// 伪代码
var object = ObjectId(id)
var timestamp = object.getTimestamp()
console.log(timestamp) // 2019-07-14T21:46:39.000Z

有关 ObjectIdObjectId 方法的更多信息,请查看以下链接:

现在,让我们在接下来的章节中使用 MongoDB 驱动程序编写一些 CRUD 操作。首先,我们将编写注入文档的操作。

注入一个文档

在我们开始之前,我们应该看一下我们将要创建的每个路由所需的代码结构:

// server/routes.js
router.get('/user', async (ctx, next) => {
  let result;
  try {
    const connection = await mongo.connect();
    const collectionUsers = connection.collection('users');
    result = await collectionUsers...
    mongo.close();
  } catch (err) {
    ctx.throw(500, err);
  }
  ctx.type = 'json';
  ctx.body = result;
});

让我们讨论一下这个结构:

  • 捕获和抛出错误:

    当我们使用 async/await 语句而不是 Promise 对象进行异步操作时,我们必须始终将它们包装在 try/catch 块中以处理错误:

    try {
        // async/await 代码
    } catch (err) {
        // 处理错误
    }
  • 连接到 MongoDB 数据库和集合:

    在执行任何 CRUD 操作之前,我们必须建立连接并连接到我们要操作的特定集合。在我们的例子中,集合是 users

    const connection = await mongo.connect()
    const collectionUsers = connection.collection('users')
  • 执行 CRUD 操作:

    在这里,我们使用 MongoDB API 方法来读取、注入、更新和删除用户:

    result = await collectionUsers...
  • 关闭 MongoDB 连接:

    我们必须确保在 CRUD 操作之后关闭连接:

    mongo.close()

现在,让我们使用上述代码结构在以下步骤中注入新用户:

  1. 创建一个使用 post 方法的路由来注入新的用户文档:

    // server/routes.js
    router.post('/user', async (ctx, next) => {
        let result
        //...
    })
  2. post 路由内部,在对 MongoDB 执行 CRUD 操作之前,对我们从客户端接收到的键和值执行检查:

    let body = ctx.request.body || {};
    if (body.name === undefined) {
      ctx.throw(400, 'name is undefined');
    }
    if (body.slug === undefined) {
      ctx.throw(400, 'slug is undefined');
    }
    if (body.name === '') {
      ctx.throw(400, 'name is required');
    }
    if (body.slug === '') {
      ctx.throw(400, 'slug is required');
    }
  3. 在允许将新文档注入 user 集合之前,我们要确保 slug 值尚不存在。为此,我们需要使用 findOne API 方法和 slug 键。如果结果为真,则表示 slug 值已被其他用户文档占用,因此我们向客户端抛出一个错误:

    const found = await collectionUsers.findOne({
      slug: body.slug
    });
    if (found) {
      ctx.throw(404, 'slug has been taken');
    }
  4. 如果 slug 是唯一的,那么我们使用 insertOne API 方法注入一个包含所提供数据的新文档:

result = await collectionUsers.insertOne({
    name: body.name,
    slug: body.slug
})

在注入文档之后,接下来我们需要获取和查看我们注入的文档,我们将在下一节中进行此操作。

获取所有文档

在将用户添加到 users 集合后,我们可以通过我们在第八章 “添加服务器端框架” 中创建的路由检索所有用户或仅检索其中一个用户。现在我们只需要使用与上一节相同的代码结构来重构这些路由,以便从数据库中获取真实数据:

  1. 使用 get 方法重构列出所有用户文档的路由:

    // server/routes.js
    router.get('/users', async (ctx, next) => {
        let result
        //...
    })
  2. get 路由内部,使用 find API 方法从 user 集合中获取所有文档:

    result = await collectionUser.find({
    }, {
        // 排除一些字段
    }).toArray()

    如果你想从查询结果中排除某些字段,可以使用 projection 键,并将你不希望在结果中显示的字段的值设置为 0。例如,如果你不希望结果中每个文档都显示 _id 字段,请执行以下操作:

    projection:{ _id: 0 }
  3. 使用 get 方法重构获取单个用户文档的路由:

    // server/routes.js
    router.get('/users/:id', async (ctx, next) => {
        let result
        //...
    })
  4. 使用 findOne 方法和 _id 获取单个文档。我们必须使用 ObjectId 方法解析 id 字符串,我们在类函数的构造(constructor)函数中有一个 objectId 的副本:

    const id = ctx.params.id
    result = await collectionUsers.findOne({
        _id: mongo.objectId(id)
    }, {
        // 排除一些字段
    })

mongo.objectId(id) 方法将 id 字符串解析为 ObjectID 对象,然后我们可以使用它从集合中查询文档。现在我们可以获取我们创建的文档了,接下来我们需要做的是更新它们。让我们在下一节中进行此操作。

更新一个文档

在向 users 集合添加用户后,我们也可以使用与上一节相同的代码结构在以下步骤中更新它们:

  1. 创建一个使用 put 方法的路由来更新现有的用户文档,如下所示:

    // server/routes.js
    router.put('/user', async (ctx, next) => {
        let result
        //...
    })
  2. 在更新文档之前,我们要确保 slug 值是唯一的。因此,在 put 路由内部,我们使用带有 $nefindOne API 查找匹配项,以排除我们正在更新的文档。如果没有匹配项,那么我们继续使用 updateOne API 方法更新文档:

const found = await collectionUser.findOne({
  slug: body.slug,
  _id: { $ne: mongo.objectId(body.id) }
});
if (found) {
  ctx.throw(404, 'slug has been taken');
}
result = await collectionUser.updateOne({
  _id: mongo.objectId(body.id)
}, {
  $set: { name: body.name, slug: body.slug },
  $currentDate: { lastModified: true }
});

在这个 CRUD 操作中,我们使用了三个操作符:$set 操作符、$currentDate 操作符和 $ne 选择器。这些是你经常用于更新文档的一些更新操作符和查询选择器:

  • 更新操作符:

    $set 操作符用于将字段的值替换为新的指定值,格式如下:

    { $set: { <field1>: <value1>, ... } }

    $currentDate 操作符用于将当前日期设置为指定的字段,可以是 BSON 日期类型(默认)或 BSON 时间戳类型,格式如下:

    { $currentDate: { <field1>: <typeSpecification1>, ... } }

    有关这两个以及其他更新操作符的更多信息,请访问 有关这两个以及其他更新操作符的更多信息,请访问 https://docs.mongodb.com/manual/reference/operator/update/。

  • 查询选择器:

    $ne 选择器用于选择字段值不等于指定值的文档,包括那些不包含该字段的文档。以下是一个示例:

    db.user.find( { age: { $ne: 18 } } )

    此查询将选择 user 集合中 age 字段值不等于 18 的所有文档,包括那些不包含 age 字段的文档。

    有关此选择器和其他查询选择器的更多信息,请访问 https://docs.mongodb.com/manual/reference/operator/query/。

现在,让我们看看如何在下一节中删除我们创建的文档。

删除一个文档

最后,我们将使用与上一节相同的代码结构,通过以下步骤从 users 集合中删除现有用户:

  1. 创建一个使用 del 方法的路由来删除现有的用户文档:

    // server/routes.js
    router.del('/user', async (ctx, next) => {
        let result
        //...
    })
  2. 在使用 deleteOne API 方法删除文档之前,在 del 路由内部,像往常一样,我们使用 findOne API 方法查找该文档,以确保它首先存在于 user 集合中:

    let body = ctx.request.body || {};
    const found = await collectionUser.findOne({
      _id: mongo.objectId(body.id)
    });
    if (!found) {
      ctx.throw(404, 'no user found');
    }
    result = await collectionUser.deleteOne({
      _id: mongo.objectId(body.id)
    });

做得好!你已经成功地完成了 MongoDB CRUD 操作的编写,并将它们集成到了 API (Koa) 中。本章的最后一部分涉及将这些操作与 Nuxt 页面集成。让我们在下一节中进行此操作。