MERN-技术栈高级教程-三-

86 阅读51分钟

MERN 技术栈高级教程(三)

原文:Pro MERN Stack

协议:CC BY-NC-SA 4.0

六、MongoDB

在本章中,我将讨论 MongoDB、数据库层和 MERN 堆栈中的 M。到目前为止,我们在用作数据库的 Express 服务器内存中存在一系列问题。我们将用真正的持久性来代替它,并从 MongoDB 数据库中读取和写入问题列表。

为了实现这一点,我们需要在云上安装或使用 MongoDB,习惯它的 shell 命令,安装 Node.js 驱动程序以从 Node.js 访问它,最后修改服务器代码以替换 API 调用,从而从 MongoDB 数据库而不是内存中的问题数组中读取和写入。

MongoDB 基础知识

这是一个介绍性的部分,在这里我们不会修改应用。我们将在这一节中讨论这些核心概念:MongoDB、文档和集合。然后,我们将设置 MongoDB,并通过使用 mongo shell 读写数据库的示例来探索这些概念。

文档

MongoDB 是一个文档数据库,这意味着记录的等价物是一个文档,或者一个对象。在关系数据库中,您按照行和列来组织数据,而在文档数据库中,整个对象可以写成一个文档。

对于简单的对象,这似乎与关系数据库没有什么不同。但是假设你有嵌套对象(称为嵌入文档)和数组的对象。现在,当使用关系数据库时,通常需要多个表。例如,在一个关系数据库中,一个发票对象可以存储在一个invoice表(用于存储发票细节,如客户地址和交货细节)和一个invoice_items表(用于存储货物中每一项的细节)的组合中。在 MongoDB 中,整个 Invoice 对象将被存储为一个文档。这是因为一个文档可以以嵌套的方式包含数组和其他对象,所包含的对象不必分离到其他文档中。

文档是由字段和值对组成的数据结构。字段的值可能包括对象、数组和对象数组等等,嵌套的深度与您希望的一样。MongoDB 文档类似于 JSON 对象,所以很容易把它们看作 JavaScript 对象。与 JSON 对象相比,MongoDB 文档不仅支持原始数据类型——布尔、数字和字符串——还支持其他常见的数据类型,如日期、时间戳、正则表达式和二进制数据。

发票对象可能如下所示:

{
  "invoiceNumber" : 1234,
  "invoiceDate" : ISODate("2018-10-12T05:17:15.737Z"),
  "billingAddress" : {
    "name" : "Acme Inc.",
    "line1" : "106 High Street",
    "city" : "New York City",
    "zip" : "110001-1234"
  },
  "items" : [
    {
      "description" : "Compact Flourescent Lamp",
      "quantity" : 4,
      "price" : 12.48
    },
    {
      "description" : "Whiteboard",
      "quantity" : 1,
      "price" : 5.44
    }
  ]
}

在这个文档中,有数字、字符串和日期数据类型。此外,还有一个嵌套对象(billingAddress)和一个对象数组(items)。

收集

集合就像关系数据库中的表:它是一组文档。就像在关系数据库中一样,集合可以有主键和索引。但是与关系数据库相比,还是有一些不同之处。

MongoDB 中规定了一个主键,它有一个保留的字段名_id。即使在创建文档时没有提供_id字段,MongoDB 也会创建这个字段,并为每个文档自动生成一个惟一的键。通常情况下,自动生成的 ID 可以按原样使用,因为即使当多个客户端同时向数据库写入数据时,它也很方便,并且可以保证生成惟一的键。MongoDB 使用一种称为ObjectId的特殊数据类型作为主键。

_id字段被自动索引。除此之外,还可以在其他字段上创建索引,这包括嵌入文档中的字段和数组字段。索引用于有效地访问集合中的文档子集。

与关系数据库不同,MongoDB 不要求您为集合定义模式。惟一的要求是集合中的所有文档必须有一个惟一的_id,但是实际的文档可能有完全不同的字段。但是实际上,集合中的所有文档都有相同的字段。尽管在应用的初始阶段,灵活的模式对于模式更改可能非常方便,但是如果没有在应用代码中添加某种模式检查,这可能会导致问题。

从 3.6 版本开始,MongoDB 已经支持模式的概念,尽管它是可选的。你可以在 https://docs.mongodb.com/manual/core/schema-validation/index.html 阅读所有关于 MongoDB 模式的内容。模式可以强制允许和必需的字段及其数据类型,就像 GraphQL 一样。但是它也可以验证其他东西,比如字符串长度和整数的最小和最大值。

但是由于模式冲突而产生的错误并没有给出足够的细节来说明从 3.6 版本开始哪些验证检查失败了。在 MongoDB 的未来版本中,这一点可能会有所改进,到那时就值得考虑添加全面的模式检查了。对于问题跟踪器应用,我们将不使用 MongoDB 的模式验证特性,相反,我们将在后端代码中实现所有必要的验证。

数据库

数据库是许多集合的逻辑分组。因为没有像 SQL 数据库中那样的外键,所以数据库的概念只是一个逻辑分区名称空间。大多数数据库操作从单个集合中读取或写入,但是$lookup(聚合管道中的一个阶段)相当于 SQL 数据库中的一个连接。这个阶段可以合并同一个数据库中的文档。

此外,备份和其他管理任务是作为一个单元在数据库上进行的。一个数据库连接被限制为只能访问一个数据库,因此要访问多个数据库,需要多个连接。因此,将应用的所有集合保存在一个数据库中是很有用的,尽管一个数据库服务器可以托管多个数据库。

查询语言

与关系数据库中通用的类似英语的 SQL 不同,MongoDB 查询语言由方法组成,以实现各种操作。读写操作的主要方法是 CRUD 方法。其他方法包括聚合、文本搜索和地理空间查询。

所有方法都对集合进行操作,并将参数作为指定操作细节的 JavaScript 对象。每种方法都有自己的规范。例如,要插入一个文档,唯一需要的参数就是文档本身。对于查询,参数是查询过滤器和要返回的字段列表(也称为投影)。

查询过滤器是一个 JavaScript 对象,由零个或多个属性组成,其中属性名是要匹配的字段的名称,属性值由另一个带有运算符和值的对象组成。例如,要匹配字段invoiceNumber大于 1,000 的所有文档,可以使用以下查询过滤器:

{ "invoiceNumber": { $gt: 1000 } }

因为没有用于查询或更新的“语言”,所以可以非常容易地以编程方式构造查询过滤器。

与关系数据库不同,MongoDB 鼓励非规范化,也就是说,将文档的相关部分存储为嵌入式子文档,而不是作为关系数据库中的独立集合(表)。以人(姓名、性别等)为例。)及其联系信息(主要地址、次要地址等)。).在关系数据库中,这需要为人员和联系人创建单独的表,然后在需要所有信息时将这两个表连接起来。另一方面,在 MongoDB 中,它可以存储为同一个人文档中的联系人列表。这是因为集合的连接对于 MongoDB 中的大多数方法来说并不自然:最方便的find()方法一次只能操作一个集合。

装置

在您尝试在您的计算机上安装 MongoDB 之前,您可能想要尝试一个能够让您访问 MongoDB 的托管服务。有许多服务,但以下是受欢迎的,并有一个免费版本,您可以使用一个小的测试或沙盒应用。对于我们将作为本书的一部分构建的问题跟踪器应用来说,这些都非常好。

  • MongoDB Atlas ( https://www.mongodb.com/cloud/atlas ):我简称这个为 Atlas。一个小数据库(共享 RAM,512 MB 存储)是免费的。

  • mLab(之前为 MongoLab) ( https://mlab.com/ ): mLab 已经宣布被 MongoDB Inc .收购,最终可能会并入 Atlas 本身。沙盒环境是免费的,限制为 500 MB 的存储空间。

  • Compose ( https://www.compose.com ):在许多其他服务中,Compose 提供 MongoDB 作为服务。有 30 天的试用期,但是没有永久免费的沙盒选项。

在这三个中,我发现 Atlas 是最方便的,因为主机的位置有许多选择。当连接到数据库时,它让我选择一个离我的位置最近的数据库,这样可以最小化延迟。mLab 不提供群集—可以单独创建数据库。Compose 不是永久免费的,您可能需要 30 多天来完成这本书。

任何托管选项的缺点是,除了访问数据库时的小的额外延迟之外,您需要互联网连接。这意味着您可能无法在无法访问互联网的地方测试您的代码,例如在飞机上。相比之下,在您的计算机上安装 MongoDB 可能会更好,但安装比注册一个基于云的选项需要更多的工作。

即使使用云选项之一,您也需要下载并安装 mongo shell 才能远程访问数据库。每项服务都附带了关于这一步骤的说明。注册这些服务时,请选择 MongoDB 3.6 或更高版本。按照服务提供商给出的说明,通过使用 mongo shell 连接到集群或数据库来测试注册。

如果您选择在您的计算机上安装 MongoDB(它可以很容易地安装在 OS X、Windows 和大多数基于 Linux 的发行版上),请查找安装说明,每个操作系统的安装说明都是不同的。您可以按照 mongodb 网站( https://docs.mongodb.com/manual/installation/ 或在您的搜索引擎中搜索“MongoDB 安装”)上的说明来安装 MongoDB。

选择 MongoDB 版本 3.6 或更高版本,最好是最新的,因为一些示例使用了仅在版本 3.6 中引入的特性。大多数本地安装选项都允许您将服务器、shell 和工具安装在一起。检查情况是否如此;如果没有,您可能需要单独安装它们。

在本地安装之后,确保您已经启动了 MongoDB 服务器(守护进程或服务的名称是 mongod ),如果安装过程尚未启动它的话。通过运行 mongo shell 来测试安装,如下所示:

$ mongo

在 Windows 系统上,您可能需要在命令后面追加.exe。根据您的安装方法,该命令可能需要路径。如果 shell 成功启动,它还将连接到本地 MongoDB 服务器实例。如果您在本地安装了 MongoDB 4 . 0 . 2 版,您应该会看到控制台上打印的 MongoDB 版本、它所连接的数据库(默认为 test)以及一个命令提示符,如下所示:

MongoDB shell version v4.0.2
connecting to: mongodb://127.0.0.1:27017
MongoDB server version: 4.0.2
>

您看到的消息可能与此略有不同,尤其是如果您安装了不同版本的 MongoDB。但是您确实需要看到提示>,在那里您可以键入更多的命令。相反,如果您看到一条错误消息,请重新执行安装和服务器启动过程。

蒙哥贝壳

mongo shell 是一个交互式 JavaScript shell,非常像 Node.js shell。在交互式 shell 中,除了 JavaScript 的全部功能之外,还有一些非 JavaScript 的便利。在这一节中,我们将讨论通过 shell 可能实现的基本操作,这些操作是最常用的。关于 shell 所有功能的完整参考,您可以在 https://docs.mongodb.com/manual/mongo/ 查看 mongo shell 文档。

我们将在 mongo shell 中输入的命令已经收集在一个名为mongo_commands.txt的文件中。这些命令已经过测试,可以在 Atlas 或本地安装中正常工作,但是您可能会发现其他选项中有所变化。例如,mLab 只允许您连接到数据库(而不是集群),因此它不允许在 mLab 中的数据库之间切换。

注意

如果您在键入命令时发现某些东西不能按预期工作,请在 GitHub 资源库( https://github.com/vasansr/pro-mern-stack-2 )中交叉检查这些命令。这是因为错别字可能是在书的制作过程中引入的,或者最后一刻的更正可能错过了这本书。另一方面,GitHub 库反映了最新的和经过测试的代码和命令。

要使用 MongoDB,您需要连接到数据库。让我们从查找哪些数据库可用开始。显示当前数据库的命令是:

> show databases

这将列出数据库及其占用的存储。例如,在 MongoDB 的全新本地安装中,您将看到以下内容:

admin         0.000GB
config        0.000GB
local         0.000GB

这些是 MongoDB 用于内部记账等的系统数据库。我们不会使用这些来创建我们的集合,所以我们最好改变当前的数据库。要识别当前数据库,命令是:

> db

mongo shell 连接到的默认数据库称为test,这就是您可能看到的该命令的输出。现在让我们看看这个数据库中存在哪些集合。

> show collections

您会发现这个数据库中没有任何集合,因为它是全新安装的。此外,您还会发现,当我们列出可用数据库时,数据库test并未列出。这是因为数据库和集合实际上只在对它们的第一次写操作时创建。

让我们切换到名为issuetracker的数据库,而不是使用默认数据库:

> use issuetracker

这将导致确认新数据库是issuetracker的输出:

switched to db issuetracker

让我们确认该数据库中也没有集合:

> show collections

该命令不应返回任何内容。现在,让我们创建一个新集合。这是通过在集合中创建一个文档来实现的。集合作为全局对象db的属性被引用,与集合同名。名为employees的集合可以简称为db.employees。让我们使用insertOne()方法在employees集合中插入一个新文档。该方法接受要插入的文档作为参数:

> db.employees.insertOne({ name: { first: 'John', last: 'Doe' }, age: 44 })

该命令的结果将显示操作的结果和创建的新文档的 ID,如下所示:

{
     "acknowledged" : true,
     "insertedId" : ObjectId("5bbc487a69d13abf04edf857")
}

除了insertOne()方法之外,任何集合上都有许多方法可用。输入“db.employees.”后按两下 Tab 键可以看到可用方法的列表(按 Tab 键前需要输入末尾的句点)。您可能会发现如下所示的输出:

db.employees.addIdIfNeeded(              db.employees.getWriteConcern(
db.employees.aggregate(                  db.employees.group(
db.employees.bulkWrite(                  db.employees.groupcmd(
db.employees.constructor                 db.employees.hasOwnProperty
db.employees.convertToCapped(            db.employees.hashAllDocs(
db.employees.convertToSingleObject(      db.employees.help(
db.employees.copyTo(                     db.employees.initializeOrderedBulkOp(
db.employees.count(                      db.employees.initializeUnorderedBulkOp(
db.employees.createIndex(                db.employees.insert(
db.employees.createIndexes(              db.employees.insertMany(
db.employees.dataSize(                   db.employees.insertOne(
...

这就是 mongo shell 的自动完成功能。请注意,您可以让 mongo shell 自动完成任何方法的名称,方法是在输入方法的开头几个字符后按 Tab 字符。

现在让我们检查文档是否已经在集合中创建。为此,我们可以对集合使用find()方法。没有任何参数,该方法只列出集合中的所有文档:

> db.employees.find()

这将显示我们刚刚创建的文档,但是它的格式并不“漂亮”。它将全部打印在一行中,并且可能不方便地换到下一行。为了获得更清晰的输出,我们可以对find()方法的结果使用pretty()方法:

> db.employees.find().pretty()

这应该会显示更清晰的输出,如下所示:

{
     "_id" : ObjectId("5bbc487a69d13abf04edf857"),
     "name" : {
            "first" : "John",
            "last" : "Doe"
     },
     "age" : 44
}

在这个时间点,如果您执行show collectionsshow databases,您会发现employees集合和issuetracker数据库确实已经被创建,并在它们各自命令的输出中列出。让我们在同一个集合中插入另一个文档,并尝试处理集合中的多个文档:

> db.employees.insertOne({ name: { first: 'Jane', last: 'Doe' }, age: 54 })

现在,既然我们已经在 shell 中拥有了 JavaScript 的全部功能,让我们尝试使用它来体验一下。让我们将结果收集到一个 JavaScript 数组变量中,而不是在屏幕上打印结果。find()方法的结果是一个可以迭代的游标。在cursor对象中,还有除了pretty()之外的方法,其中一个就是toArray()。该方法从查询中读取所有文档,并将它们放在一个数组中。所以,让我们使用这个方法,并将其结果赋给一个数组变量。

> let result = db.employees.find().toArray()

现在,变量result应该是一个包含两个元素的数组,每个元素都是一个employee文档。让我们使用 JavaScript 数组方法forEach()遍历它们并打印每个雇员的名字:

> result.forEach((e) => print('First Name:', e.name.first))

这将产生如下输出:

First Name: John
First Name: Jane

在 Node.js 中,console.log方法可用于在控制台上打印对象。另一方面,mongo shell 提供了用于相同目的的print()方法,但是它只打印字符串。在打印之前,需要使用实用函数tojson()将对象转换成字符串。还有另外一种方法,叫做printjson(),把对象打印成 JSON。让我们用它来检查嵌套文档name的内容,而不仅仅是名字:

> result.forEach((e) => printjson(e.name))

现在,您应该看到name对象被扩展成名和姓,如下所示:

{ "first" : "John", "last" : "Doe" }
{ "first" : "Jane", "last" : "Doe" }

除了提供一种访问数据库和集合的方法的机制之外,shell 本身做得很少。它是 JavaScript 引擎,它构成了 shell 的基础,并赋予了 shell 很大的灵活性和强大的功能。

在下一节中,我们将讨论集合的更多方法,比如您刚刚了解到的insertOne()。许多编程语言都可以通过驱动程序访问这些方法。mongo shell 只是另一个可以访问这些方法的工具。您会发现其他编程语言中可用的方法和参数与 mongo shell 中的非常相似。

练习:MongoDB 基础知识

  1. 使用 shell,显示一个在cursor对象上可用的方法列表。提示:在 https://docs.mongodb.com/manual/tutorial/access-mongo-shell-help/ 查找 mongo shell 文档以获得 mongo Shell 帮助。

本章末尾有答案。

MongoDB CRUD 操作

因为 mongo shell 是最容易尝试的,所以让我们使用 shell 本身来探索 MongoDB 中可用的 CRUD 操作。我们将继续使用我们在上一节中创建的issuetracker数据库。但是让我们清空数据库,这样我们可以重新开始。collection 对象提供了一个方便的方法来擦除自身,称为drop():

> db.employees.drop()

这应该会产生如下输出:

true

这不同于删除集合中的所有文档,因为它还会删除集合中的所有索引。

创造

在上一节中,您简要地看到了如何插入文档,作为其中的一部分,您发现了 MongoDB 如何自动创建主键,这是一种称为ObjectID的特殊数据类型。现在让我们使用自己的 ID,而不是让 MongoDB 自动生成一个 ID。

> db.employees.insertOne({
  _id: 1,
  name: { first: 'John', last: 'Doe' },
  age: 44
})

这将导致以下输出:

{ "acknowledged" : true, "insertedId" : 1 }

请注意,insertedId的值反映了我们为_id提供的值。这意味着,我们能够提供自己的价值,而不是一种ObjectID类型的价值。让我们尝试创建一个新的相同的文档(您可以使用向上箭头键在 mongo shell 中重复前面的命令)。它将失败,并出现以下错误:

WriteError({
     "index" : 0,
     "code" : 11000,
     "errmsg" : "E11000 duplicate key error collection: issuetracker.employees index: _id_ dup key: { : 1.0 }",
     "op" : {
            "_id" : 1,
            "name" : {
                  "first" : "John",
                  "last" : "Doe"
            },
            "age" : 44
     }
})

这表明_id字段仍然是主键,并且应该是惟一的,不管它是自动生成的还是在文档中提供的。现在,让我们添加另一个文档,但是使用一个新字段作为名称的一部分,比如中间名:

> db.employees.insertOne({
  name: {first: 'John', middle: 'H', last: 'Doe'},
  age: 22
})

这很好,使用find(),您可以看到集合中存在两个文档,但是它们不一定是相同的模式。这就是灵活模式的优势:只要发现需要存储的新数据元素,就可以增强模式,而不必显式修改模式。

在这种情况下,任何缺少name下的middle字段的员工文档都意味着该员工没有中间名。另一方面,如果添加了一个在缺失时没有隐含意义的字段,则必须在代码中处理它的缺失。或者必须运行一个迁移脚本,将该字段的值默认为某个值。

你还会发现两个文档的_id字段的格式不一样,甚至数据类型也不一样。对于第一个文档,数据类型是整数。对于第二个,它是类型ObjectID(这就是为什么它被显示为ObjectID(...)。因此,不仅仅是同一集合中两个文档之间的字段存在差异,甚至同一字段的数据类型也可能不同。

在大多数情况下,将主键的创建留给 MongoDB 就很好了,因为您不必担心保持它的唯一性:MongoDB 会自动做到这一点。但是,这个标识符不是人类可读的。在 Issue Tracker 应用中,我们希望标识符是一个数字,以便于记忆和谈论。但是我们不使用_id字段来存储人类可读的标识符,而是使用一个名为id的新字段,让 MongoDB 自动生成_id

所以,让我们放弃这个集合,开始用一个名为id的新字段创建新文档。

> db.employees.drop()

> db.employees.insertOne({
  id: 1,
  name: { first: 'John', last: 'Doe' },
  age: 48
})

> db.employees.insertOne({
  id: 2,
  name: { first: 'Jane', last: 'Doe'} ,
  age: 16
})

该集合有一个方法,可以一次接收多个文档。这种方法叫做insertMany()。让我们用它在一个命令中再创建几个文档:

> db.employees.insertMany([
  { id: 3, name: { first: 'Alice', last: 'A' }, age: 32 },
  { id: 4, name: { first: 'Bob', last: 'B' }, age: 64 },
])

对这个问题的回答会显示多个insertedIds被创建,而不是为insertOne()方法创建一个insertedId,如下所示:

{
     "acknowledged" : true,
     "insertedIds" : [
            ObjectId("5bc6d80005fb87b8f2f5cf6f"),
            ObjectId("5bc6d80005fb87b8f2f5cf70")
     ]
}

阅读

现在集合中有多个文档,让我们看看如何检索文档的子集,而不是完整的列表。find()方法接受另外两个参数。第一个是应用于列表的过滤器,第二个是投影,即指定要检索的字段。

过滤器规范是一个对象,其中属性名是要过滤的字段,值是它需要匹配的值。让我们获取一个员工的文档,由等于 1 的id标识。因为我们知道给定的 ID 只能有一个雇员,所以让我们使用findOne()而不是find()。方法findOne()是方法find()的变体,它返回单个对象而不是光标。

> db.employees.findOne({ id: 1 })

这将返回我们创建的第一个员工文档,输出如下所示:

{
     "_id" : ObjectId("5bc6d7e505fb87b8f2f5cf6d"),
     "id" : 1,
     "name" : {
            "first" : "John",
            "last" : "Doe"
     },
     "age" : 48
}

注意,我们在这里没有使用pretty(),但是输出被美化了。这是因为findOne()返回单个对象,mongo shell 默认美化对象。

过滤器实际上是{ id: { $eq: 1 } },的简写,其中$eq是表示字段id的值必须等于到 1 的运算符。一般意义上,过滤器中单个元素的格式是fieldname: { operator: value }。也可以使用其他运算符进行比较,如$gt表示大于等。让我们尝试使用$gte(大于或等于)操作符来获取 30 岁或以上的员工列表:

> db.employees.find({ age: { $gte: 30 } })

该命令应该返回三个文档,因为我们插入了那些年龄超过 30 岁的人。如果指定了多个字段,那么它们都必须匹配,这与用一个操作符将它们组合起来是一样的:

> db.employees.find({ age: { $gte: 30 }, 'name.last': 'Doe'  })

现在返回的文档数量应该减少到只有一个,因为只有一个文档符合这两个标准,姓氏等于'Doe',年龄大于 30。注意,我们使用了点符号来指定嵌套文档中嵌入的字段。这也让我们在字段名两边加上引号,因为它是一个普通的 JavaScript 对象属性。

要匹配同一个字段的多个值,例如,匹配大于 30 的年龄和小于 60 的年龄,不能使用相同的策略。这是因为过滤器是一个普通的 JavaScript 对象,一个文档中不能存在两个同名的属性。因此,像{ age: { $gte: 30 }, age: { $lte: 60 } }这样的过滤器将不起作用(JavaScript 不会抛出错误,而是只为属性age选择一个值)。必须使用一个显式的$and操作符,它接受一个指定多个字段值标准的对象数组。您可以在 https://docs.mongodb.com/manual/reference/operator/query/ 的 MongoDB 参考手册的操作符部分阅读到关于$and操作符和更多操作符的所有内容。

当在一个字段上进行筛选是常见的情况时,在该字段上创建索引通常是一个好主意。集合上的createIndex()方法就是为了这个目的。它接受一个指定构成索引的字段的参数(多个字段将构成一个复合索引)。让我们在年龄字段上创建一个索引:

> db.employees.createIndex({ age: 1 })

有了这个索引,任何使用包含字段age的过滤器的查询都会快得多,因为 MongoDB 将使用这个索引,而不是扫描集合中的所有文档。但这并不是一个唯一的指数,因为许多人可能年龄相同。

年龄字段可能不是常用的过滤器,但是根据标识符获取文档将会非常频繁。MongoDB 自动在_id字段上创建一个索引,但是我们使用了自己的标识符id,这个字段更有可能用于获取单个雇员。所以让我们创建一个关于这个领域的索引。此外,它必须是唯一的,因为它标识了雇员:没有两个雇员应该有相同的id值。createIndex()的第二个参数是一个包含索引各种属性的对象,其中一个属性指定索引是否惟一。让我们用它来创建一个关于id的唯一索引:

> db.employees.createIndex({ id: 1 }, { unique: true })

现在,当提供了一个带有id的过滤器时,不仅find()方法的性能会好得多,而且 MongoDB 还会阻止创建带有重复id的文档。让我们通过为第一个员工重新运行insert命令来尝试一下:

> db.employees.insertOne({
  id: 1,
  name: { first: 'John', last: 'Doe' },
  age: 48
})

现在,您应该会在 mongo shell 中看到这样的错误(对于您来说,ObjectID会有所不同):

WriteError({
     "index" : 0,
     "code" : 11000,
     "errmsg" : "E11000 duplicate key error collection: issuetracker.employees index: id_1 dup key: { : 1.0 }",
     "op" : {
            "_id" : ObjectId("5bc04b8569334c5ff5bb7e8c"),

            "id" : 1
               ...
     }
})

规划

与此同时,我们检索到了与过滤器匹配的整个文档。在上一节中,当我们必须只打印文档字段的子集时,我们使用了一个forEach()循环。但是这意味着即使我们只需要打印文档的一部分,也要从服务器获取整个文档。当文档很大时,这会占用大量网络带宽。为了将获取限制在某些字段,find()方法采用了第二个参数,称为投影。投影指定在结果中包括或排除哪些字段。

该规范的格式是一个对象,其中一个或多个字段名称作为关键字,值为 0 或 1,以指示排除或包含。但是 0 和 1 不能组合。您可以从零开始并包含使用 1 的字段,或者从所有内容开始并排除使用 0 的字段。_id字段是一个例外;除非您指定 0,否则它将始终包含在内。以下将获取所有雇员,但仅获取他们的名字和年龄:

> db.employees.find({}, { 'name.first': 1, age: 1 })

请注意,我们指定了一个空的过滤器,即必须获取所有文档。因为投影是第二个参数,所以必须这样做。前面的请求应该会打印出这样的内容:

{ "_id" : ObjectId("5bbc...797855"), "name" : { "first" : "John" }, "age" : 48 }
{ "_id" : ObjectId("5bbc...797856"), "name" : { "first" : "Jane" }, "age" : 16 }
{ "_id" : ObjectId("5bbc...797857"), "name" : { "first" : "Alice" }, "age" : 32 }
{ "_id" : ObjectId("5bbc...797858"), "name" : { "first" : "Bob" }, "age" : 64 }

即使我们只指定了名字和年龄,字段_id也会自动包含在内。要禁止包含该字段,需要将其显式排除,如下所示:

> db.employees.find({}, { _id: 0, 'name.first': 1, age: 1 })

现在,输出将排除 ID,如下所示:

{ "name" : { "first" : "John" }, "age" : 48 }
{ "name" : { "first" : "Jane" }, "age" : 16 }
{ "name" : { "first" : "Alice" }, "age" : 32 }
{ "name" : { "first" : "Bob" }, "age" : 64 }

更新

有两种方法可以用来修改单据,分别是updateOne()updateMany()。这两种方法的参数是相同的,除了updateOne()在找到并更新第一个匹配的文档后停止。第一个参数是一个查询过滤器,与find()使用的过滤器相同。第二个参数是更新规范,如果只需要更改对象的某些字段。

使用updateOne()时,主键或任何唯一标识符是过滤器中通常使用的,因为过滤器只能匹配一个文档。更新规范是一个具有一系列$set属性的对象,这些属性的值指示另一个对象,该对象指定字段及其新值。让我们更新由id 2 标识的员工的年龄:

> db.employees.updateOne({ id: 2 }, { $set: {age: 23 } })

这应该会产生以下输出:

{ "acknowledged" : true, "matchedCount" : 1, "modifiedCount" : 1 }

matchedCount返回了符合过滤器的文档数量。如果过滤器匹配了不止一个,那么就会返回这个数字。但是因为该方法应该只修改一个文档,所以修改的计数应该总是 1,除非修改无效。如果再次运行该命令,您将发现modifiedCount将为 0,因为 ID 为 2 的雇员的年龄已经是 23 岁。

要一次性修改多个文档,必须使用updateMany()方法。格式与updateOne()方法相同,但效果是所有匹配的文档都将被修改。让我们使用updateMany()方法为所有员工添加一个organization字段:

> db.employees.updateMany({}, { $set: { organization: 'MyCompany' } })

注意,即使字段organization在文档中不存在,新值MyCompany也会应用于所有文档。如果你执行命令find()在投影中单独显示公司,这个事实将被证实。

还有一种用替换的方法叫做replaceOne()。如果完整的已修改文档可用,则无需指定要修改的字段,只需用新文档替换现有文档即可。这里有一个例子:

> db.employees.replaceOne({ id: 4 }, {
  id: 4,
  name : { first : "Bobby" },
  age : 66
});

该命令将用新文档替换 ID 为 4 的现有文档。事实上,organizationname.last字段没有被指定,这将导致这些字段在被替换的文档中不存在*,而不是使用updateOne()改变*。得到被替换的对象应该证明:**

> db.employees.find({ id: 4 })

这应该会产生如下所示的文档:

{ "_id" : ObjectId("5c38ae3da7dc439456c0281b"), "id" : 4, "name" : { "first" : "Bobby" }, "age" : 66 }

您可以看到它不再有字段name.lastorganization,因为在提供给命令replaceOne()的文档中没有指定这些字段。它只是用提供的文档替换文档,除了字段ObjectId。作为主键,该字段不能通过updateOne()replaceOne()改变。

删除

delete操作接受一个过滤器并从集合中删除文档。滤波器格式相同,变量deleteOne()deleteMany()都可用,就像在update操作中一样。

让我们删除最后一个文档,ID 为 4:

> db.employees.deleteOne({ id: 4 })

这将产生以下输出,确认删除仅影响一个文档:

{ "acknowledged" : true, "deletedCount" : 1 }

让我们通过查看集合的大小来交叉检查删除。集合上的count()方法告诉我们它包含多少个文档。现在执行它应该会返回值 3,因为我们最初插入了四个文档。

> db.employees.count()

总计

find()方法用于返回集合中的所有文档或文档子集。很多时候,我们需要的不是文档列表,而是摘要或集合,例如,符合某个标准的文档的数量。

count()方法当然可以带一个过滤器。但是其他的聚合函数呢,比如 sum?这就是aggregate()发挥作用的地方。与支持 SQL 的关系数据库相比,aggregate()方法执行 GROUP BY 子句的功能。但是它还可以执行其他功能,比如连接,甚至展开(基于数组展开文档)等等。

您可以在位于 https://docs.mongodb.com/manual/reference/operator/aggregation-pipeline/ 的 MongoDB 文档中查找aggregate()函数支持的高级特性,但是现在,让我们看看它提供的真正的聚合和分组构造。

aggregate()方法在管道中工作。管道中的每个阶段从前一阶段的结果中获取输入,并根据其规范进行操作,以产生一组新的修改后的文档。当然,管道的初始输入是整个集合。管道规范采用对象数组的形式,每个元素都是一个对象,具有一个标识管道阶段类型的属性和指定管道效果的值。

例如,通过使用$match(滤波器)和$project(投影),可以使用aggregate()来复制find()方法。要执行实际的聚合,需要使用$group阶段。stage 的规范包括由属性_id标识的分组关键字和作为关键字的其他字段,其值是聚合规范和需要执行聚合的字段。_id可以为空,以对整个集合进行分组。

让我们通过获取整个集合中所有雇员的总年龄来尝试一下。管道数组中只有一个元素,一个具有单一属性$group的对象。在值中,_id将被设置为null,因为我们不想按任何字段分组。我们需要(使用聚合函数$sum)将字段age求和成一个新的字段total_age,如下所示:

> db.employees.aggregate([
  { $group: { _id: null, total_age: { $sum: '$age' } } }
])

这应该会产生如下输出:

{ "_id" : null, "total_age" : 103 }

相同的函数$sum可用于通过简单地对值 1 求和来获得记录的计数:

> db.employees.aggregate([
  { $group: { _id: null, count: { $sum: 1 } } }
])

要按字段对聚合进行分组,我们需要指定字段的名称(以$为前缀)作为_id的值。让我们使用organization字段,但是在此之前,让我们插入一个组织不同于其余文档的新文档(它们都被设置为MyCompany):

> db.employees.insertOne({
  id: 4,
  name: { first: 'Bob', last: 'B' },
  age: 64,
  organization: 'OtherCompany'
})

现在,下面是使用 sum 跨不同组织聚合年龄的命令:

> db.employees.aggregate([
  { $group: { _id: '$organization', total_age: { $sum: '$age' } } }
])

这应该会产生如下输出:

{ "_id" : "OtherCompany", "total_age" : 64 }
{ "_id" : "MyCompany", "total_age" : 103 }

让我们也尝试另一个聚合函数,比方说 average,使用$avg:

> db.employees.aggregate([
  { $group: { _id: '$organization', average_age: { $avg: '$age' } } }
])

这应该会产生如下输出:

{ "_id" : "OtherCompany", "average_age" : 64 }
{ "_id" : "MyCompany", "average_age" : 34.333333333333336 }

还有其他聚合函数,包括最小值和最大值。关于完整的设置,请参考 https://docs.mongodb.com/manual/reference/operator/aggregation/group/#accumulator-operator 的文档。

练习:MongoDB Crud 操作

  1. 编写一个简单的语句来检索所有有中间名的雇员。提示:在 https://docs.mongodb.com/manual/reference/operator/query/ 查找查询操作符的 MongoDB 文档。

  2. 过滤器规范是 JSON 吗?提示:考虑字段名称周围的日期对象和引号。

  3. 假设某个员工的中间名设置错误,您需要删除它。为此写一个语句。提示:在 https://docs.mongodb.com/manual/reference/operator/update/ 查找更新操作符的 MongoDB 文档。

  4. 在索引创建过程中,1 表示什么?还允许哪些有效值?提示:在 https://docs.mongodb.com/manual/indexes/ 查找 MongoDB 索引文档。

本章末尾有答案。

MongoDB Node.js 驱动程序

这是 Node.js 驱动程序,允许您连接 MongoDB 服务器并与之交互。它提供的方法与您在 mongo shell 中看到的非常相似,但又不完全相同。我们可以使用一个名为 Mongoose 的对象文档映射器来代替低级别的 MongoDB 驱动程序,它具有更高的抽象级别和更方便的方法。但是学习低级别的 MongoDB 驱动程序可能会让您更好地处理 MongoDB 本身的实际工作,所以我选择为问题跟踪器应用使用低级别的驱动程序。

首先,让我们安装驱动程序:

$ npm install mongodb@3

让我们也启动一个新的 Node.js 程序,尝试一下驱动程序方法的不同使用方式。在下一节中,我们将使用此次试用的一些代码来将驱动程序集成到问题跟踪器应用中。让我们将这个示例 Node.js 程序称为trymongo.js,并将其放在一个名为scripts的新目录中,以区别于应用中的其他文件。

首先要做的是连接到数据库服务器。这可以通过首先从驱动程序导入对象MongoClient,然后使用标识要连接的数据库的 URL 从它创建一个新的客户机对象,最后对它调用connect方法来完成,如下所示:

...
const { MongoClient } = require('mongodb');

const client = new MongoClient(url);
client.connect();
...

URL 应该以mongodb://开头,后跟要连接的服务器的主机名或 IP 地址。可以使用:作为分隔符添加一个可选端口,但是如果 MongoDB 服务器运行在默认端口 27017 上,就不需要这个端口。将连接参数分离到一个配置文件中而不是保存在一个签入的文件中是一个好习惯,但是我们将在下一章中这样做。现在,让我们对此进行硬编码。如果您使用了某个云提供商,可以从相应的连接说明中获得 URL。对于本地安装,URL 将是mongodb://localhost/issuetracker。请注意,MongoDB Node.js 驱动程序接受数据库名称作为 URL 本身的一部分,最好以这种方式指定它,即使云提供商可能不会明确显示这一点。

让我们将本地安装 URL 添加到trymongo.js和云提供商 URL 的注释版本。

...
const url = 'mongodb://localhost/issuetracker';

// Atlas URL - replace UUU with user, PPP with password, XXX with hostname
// const url = 'mongodb+srv://UUU:PPP@cluster0-XXX.mongodb.net/issuetracker?retryWrites=true';

// mLab URL - replace UUU with user, PPP with password, XXX with hostname
// const url = 'mongodb://UUU:PPP@XXX.mlab.com:33533/issuetracker';
...

此外,客户机构造函数接受另一个参数,为客户机提供更多设置,其中之一是是否使用新的样式解析器。让我们更改构造函数来传递它,以避免在最新的 Node.js 驱动程序(3.1 版)中出现警告。

...
const client = new MongoClient(url, { useNewUrlParser: true });
...

connect()方法是一个异步方法,需要一个回调来接收连接结果。回调接受两个参数:错误和结果。结果是客户端对象本身。在回调中,可以通过调用client对象的db方法来获得到数据库的连接(相对于到服务器的连接)。因此,回调和到数据库的连接可以写成这样:

...
client.connect(function(err, client) {
  const db = client.db();
...

到数据库的连接db,类似于我们在 mongo shell 中使用的db变量。特别是,我们可以用它来获得集合及其方法的句柄。让我们使用 mongo shell 来处理上一节中使用的名为employees的集合。

...
  const collection = db.collection('employees');
...

有了这个集合,我们可以做我们在上一节中用 mongo shell 的等价物db.employees所做的事情。这些方法也非常相似,只是它们都是异步的。这意味着这些方法接受常规参数,但也接受操作完成时调用的回调函数。回调函数中的约定是将错误作为第一个参数传递,将操作结果作为第二个参数传递。在前面的连接方法中,您已经看到了这种回调模式。

让我们插入一个文档并回读它,看看这些方法在 Node.js 驱动程序中是如何工作的。可以使用insertOne方法编写插入,传入一个雇员文档和一个回调。在回调中,让我们打印新创建的_id。就像在 mongo shell insertOne命令中一样,创建的 ID 作为结果对象的一部分返回,位于名为insertedId的属性中。

...
  const employee = { id: 1, name: 'A. Callback', age: 23 };
  collection.insertOne(employee, function(err, result) {
    console.log('Result of insert:\n', result.insertedId);
...

注意,访问集合和insert操作只能在连接操作的回调中调用,因为只有这样我们才知道连接成功了。还需要一些错误处理,但是让我们稍后再处理这个问题。

现在,在insert操作的回调中,让我们使用结果的 ID 读回插入的文档。我们可以使用我们提供的 ID(id)或者自动生成的 MongoDB ID ( _id)。让我们使用_id来确保我们能够使用结果值。

...
    collection.find({ _id: result.insertedId})
      .toArray(function(err, docs) {
        console.log('Result of find:\n', docs);
      }
...

现在我们已经完成了文档的插入和回读,我们可以关闭到服务器的连接了。如果我们不这样做,Node.js 程序就不会退出,因为连接对象正在等待被使用,并且正在监听一个套接字。

...
      client.close();
...

让我们将所有这些放在一个名为testWithCallbacks()的函数中。我们很快也将使用一种不同的方法来使用 Node.js 驱动程序。同样,按照惯例,让我们给这个函数传递一个回调函数,一旦所有操作完成,我们将从testWithCallbacks()函数调用这个函数。然后,如果有任何错误,可以将它们传递给回调函数。

让我们首先声明这个函数:

...
function testWithCallbacks(callback) {
  console.log('\n--- testWithCallbacks ---');
  ...
}
...

并且在作为每个操作结果的每个回调中,在出现错误时,我们需要执行以下操作:

  • 关闭与服务器的连接

  • 打电话回电

  • 从调用中返回,以便不再执行任何操作

当所有操作完成时,我们也需要这样做。错误处理的模式如下:

...
    if (err) {
      client.close();
      callback(err);
      return;
    }
...

让我们从主部分引入一个对testWithCallbacks()函数的调用,为它提供一个回调函数来接收任何错误,如果有错误就打印出来。

...
testWithCallbacks(function(err) {
  if (err) {
    console.log(err);
  }
});
...

引入所有错误处理和回调后,trymongo.js文件中的最终代码如清单 6-1 所示。

注意

虽然我们不遗余力地确保所有代码清单的准确性,但在本书付印之前,可能会有一些错别字甚至更正没有出现在书中。所以,总是依赖 GitHub 库( https://github.com/vasansr/pro-mern-stack-2 )作为所有代码清单的经过测试的和最新的源代码,尤其是当某些东西不能按预期工作时。

const { MongoClient } = require('mongodb');

const url = 'mongodb://localhost/issuetracker';

// Atlas URL  - replace UUU with user, PPP with password, XXX with hostname
// const url = 'mongodb+srv://UUU:PPP@cluster0-XXX.mongodb.net/issuetracker?retryWrites=true';

// mLab URL - replace UUU with user, PPP with password, XXX with hostname
// const url = 'mongodb://UUU:PPP@XXX.mlab.com:33533/issuetracker';

function testWithCallbacks(callback) {
  console.log('\n--- testWithCallbacks ---');
  const client = new MongoClient(url, { useNewUrlParser: true });
  client.connect(function(err, client) {
    if (err) {
      callback(err);
      return;
    }
    console.log('Connected to MongoDB');

    const db = client.db();
    const collection = db.collection('employees');

    const employee = { id: 1, name: 'A. Callback', age: 23 };
    collection.insertOne(employee, function(err, result) {
      if (err) {
        client.close();
        callback(err);
        return;
      }
      console.log('Result of insert:\n', result.insertedId);
      collection.find({ _id: result.insertedId})

        .toArray(function(err, docs) {
        if (err) {
          client.close();
          callback(err);
          return;
        }
        console.log('Result of find:\n', docs);
        client.close();
        callback(err);
      });
    });
  });
}

testWithCallbacks(function(err) {
  if (err) {
    console.log(err);
  }
});

Listing 6-1trymongo.js: Using Node.js driver, Using the Callbacks Paradigm

在测试之前,让我们清理一下集合。我们可以打开另一个命令 shell,在其中运行 mongo shell,并执行db.employees.remove({})。但是 mongo shell 有一种命令行方式,可以使用--eval命令行选项执行简单的命令。让我们这样做,并传递要连接的数据库名称;否则,该命令将在默认数据库test上执行。对于本地安装,该命令如下所示:

$ mongo issuetracker --eval "db.employees.remove({})"

如果您使用来自某个主机提供商的远程服务器,而不是数据库名称,请使用主机提供商建议的包含数据库名称的连接字符串。例如,Atlas 命令可能如下所示(用您自己的主机名、用户名和密码替换):

$ mongo "mongodb+srv://cluster0-xxxxx.mongodb.net/issuetracker" --username atlasUser --password atlasPassword --eval "db.employees.remove({})"

现在,我们准备测试我们刚刚创建的试用程序。它可以这样执行:

$ node scripts/trymongo.js

这将产生如下输出(您将看到不同的ObjectID,否则输出应该是相同的):

--- testWithCallbacks ---
Connected to MongoDB
Result of insert:
 5bbef955580a2c313d4052f6
Result of find:
 [ { _id: 5bbef955580a2c313d4052f6,
    id: 1,
    name: 'A. Callback',
    age: 23 } ]

正如你可能感觉到的,回调范式有点笨拙。但好处是它可以在旧版本的 JavaScript)中工作,因此也可以在旧版本的 Node.js 中工作。ES2015 开始支持承诺,Node.js MongoDB 驱动程序也支持承诺,这是对回调的改进。但在 ES2017 和 7.6 版的 Node.js 中,出现了对 async/await 范式的完全支持,这是使用驱动程序的推荐和最方便的方式。

让我们在使用 async/await 范例的trymongo.js中实现另一个名为testWithAsync()的函数。所有带有回调的异步调用现在都可以被对同一方法的调用所取代,但不提供回调。在方法调用之前使用await将模拟一个同步调用,由等待调用完成并返回结果。例如,我们可以不将回调传递给connect()方法,而是像这样等待它完成:

...
    await client.connect();
...

在下一行中,我们可以在操作完成后做任何需要做的事情,在本例中,连接到数据库:

...
    await client.connect();
    const db = client.db();
...

相同的模式可以用于其他异步调用,但有一点不同:调用的结果(最初是回调的第二个参数)可以直接赋给一个变量,比如函数调用的返回值。所以,insertOne()的结果可以这样捕捉:

...
    const result = await collection.insertOne(employee);
...

错误将被抛出并被捕获。我们可以将所有操作放在一个单独的try块中,并在一个地方(catch块)捕获任何错误,而不是在每次调用之后。函数不需要接受回调,因为如果调用者需要等待结果,可以在调用这个函数之前添加一个await,并抛出错误。

在每个操作connect()insertOne(),find()之前使用await的新功能如清单 6-2 所示。

async function testWithAsync() {
  console.log('\n--- testWithAsync ---');
  const client = new MongoClient(url, { useNewUrlParser: true });
  try {
    await client.connect();
    console.log('Connected to MongoDB');
    const db = client.db();
    const collection = db.collection('employees');

    const employee = { id: 2, name: 'B. Async', age: 16 };
    const result = await collection.insertOne(employee);
    console.log('Result of insert:\n', result.insertedId);

    const docs = await collection.find({ _id: result.insertedId })
      .toArray();
    console.log('Result of find:\n', docs);
  } catch(err) {
    console.log(err);
  } finally {
    client.close();
  }
}

Listing 6-2trymongo.js, testWithAsync Function

最后,让我们修改程序的主要部分,在处理来自testWithCallbacks()的返回值的回调中调用testWithAsync():

...
testWithCallbacks(function(err) {
  if (err) {
    console.log(err);
  }
  testWithAsync();
});
...

如果您如前所述使用remove()清除集合并测试这些更改,您将会看到这个结果(您看到的ObjectIDs将与这里显示的不同):

--- testWithCallbacks ---
Connected to MongoDB
Result of insert:
 5bbf25dcf50e97340be0f01f
Result of find:
 [ { _id: 5bbf25dcf50e97340be0f01f,
    id: 1,
    name: 'A. Callback',
    age: 23 } ]

--- testWithAsync ---
Connected to MongoDB
Result of insert:
 5bbf25dcf50e97340be0f020
Result of find:
 [ { _id: 5bbf25dcf50e97340be0f020,
    id: 2,
    name: 'B. Async',
    age: 16 } ]

测试错误是否被捕获和显示的一个好方法是再次运行程序。会有错误,因为我们在字段id上有一个唯一的索引,所以 MongoDB 将抛出一个重复的键冲突。如果在创建索引后删除了集合,可以运行createIndex()命令来恢复这个索引。

正如您所看到的,async/await 范式在代码方面要小得多,也清晰易读得多。事实上,虽然我们在这个函数中捕获了错误,但是我们并不需要这样做。我们也可以让打电话的人来处理。

考虑到 async/await 范例的好处,让我们在与数据库交互时在 Issue Tracker 应用中使用它。

模式初始化

mongo shell 不仅是一个交互式 shell,而且还是一个脚本环境。利用这一点,可以编写脚本来执行各种任务,例如模式初始化和迁移。因为 mongo shell 实际上是构建在 JavaScript 引擎之上的,所以 JavaScript 的强大功能在脚本中是可用的,就像在 shell 本身中一样。

交互式和非交互式工作模式之间的一个区别是,非交互式 shell 不支持非 JavaScript 快捷方式,例如use <db>show collections命令。该脚本必须是遵循正确语法的常规 JavaScript 程序。

让我们在script目录中创建一个名为init.mongo.js的模式初始化脚本。因为 MongoDB 不强制模式,所以实际上不存在像在关系数据库中创建表那样的模式初始化。唯一真正有用的是创建索引,这是一次性任务。同时,让我们用一些样本文档来初始化数据库,以便于测试。我们将使用我们用来测试 mongo shell 的同一个数据库issuetracker,来存储所有与问题跟踪器应用相关的集合。

让我们复制来自server.js的问题数组,并使用同一个数组在名为issues的集合上使用insertMany()来初始化集合。但是在此之前,让我们通过在同一个集合上调用一个带有空过滤器的remove()(它将匹配所有文档)来清除现有的问题。然后,让我们在有用的字段上创建一些索引,我们将使用这些索引来搜索集合。

清单 6-3 显示了初始化脚本init.mongo.js的完整内容。文件开头的注释说明了如何为不同类型的数据库运行这个脚本——local、Atlas 和 mLab。

/*
 * Run using the mongo shell. For remote databases, ensure that the
 * connection string is supplied in the command line. For example:
 * localhost:
 *   mongo issuetracker scripts/init.mongo.js
 * Atlas:
 *   mongo mongodb+srv://user:pwd@xxx.mongodb.net/issuetracker
     scripts/init.mongo.js
 * MLab:
 *   mongo mongodb://user:pwd@xxx.mlab.com:33533/issuetracker
     scripts/init.mongo.js
 */

db.issues.remove({});

const issuesDB = [
  {
    id: 1, status: 'New', owner: 'Ravan', effort: 5,
    created: new Date('2019-01-15'), due: undefined,
    title: 'Error in console when clicking Add',
  },
  {
    id: 2, status: 'Assigned', owner: 'Eddie', effort: 14,
    created: new Date('2019-01-16'), due: new Date('2019-02-01'),
    title: 'Missing bottom border on panel',
  },
];

db.issues.insertMany(issuesDB);
const count = db.issues.count();
print('Inserted', count, 'issues');

db.issues.createIndex({ id: 1 }, { unique: true });
db.issues.createIndex({ status: 1 });
db.issues.createIndex({ owner: 1 });
db.issues.createIndex({ created: 1 });

Listing 6-3init.mongo.js: Schema Initialization

如果您像这样使用 MongoDB 的本地安装,您应该能够使用 mongo shell 运行这个脚本,在命令行中将文件名作为参数:

$ mongo issuetracker scripts/init.mongo.js

对于使用 MongoDB 的其他方法,在脚本的顶部有作为注释的说明。实际上,必须在命令行中指定整个连接字符串,包括用于连接托管服务的用户名和密码。在连接字符串之后,您可以键入脚本的名称scripts/init.mongo.js

您可以在任何希望将数据库重置为原始状态的时候运行该命令。您应该会看到一个输出,表明插入了两个问题,以及 MongoDB 版本和 shell 版本。请注意,在索引已经存在的情况下创建索引没有任何效果,因此多次创建索引是安全的。

练习:模式初始化

  1. 使用 Node.js 脚本和 MongoDB 驱动程序可以完成相同的模式初始化。使用 mongo shell 和 Node.js MongoDB 驱动程序这两种方法各有什么优缺点?

  2. 还有其他可能有用的索引吗?提示:如果我们在应用中需要一个搜索栏呢?在 https://docs.mongodb.com/manual/indexes/#index-types 阅读 MongoDB 索引类型。

本章末尾有答案。

从 MongoDB 读取

在上一节中,您看到了如何使用 Node.js 驱动程序来执行基本的 CRUD 任务。有了这些知识,现在让我们将 List API 改为从 MongoDB 数据库中读取,而不是从服务器中的内存数组中读取。因为我们已经用相同的初始问题集初始化了数据库,所以在测试时,您应该在 UI 中看到相同的问题集。

在我们为驱动程序做的试验中,我们在一系列操作中使用了到数据库的连接,并关闭了它。相反,在应用中,我们将维护连接,以便我们可以在许多操作中重用它,这些操作将从 API 调用中触发。因此,我们需要将与数据库的连接存储在一个全局变量中。除了import语句和其他全局变量声明之外,我们还可以调用全局数据库连接变量db:

...
const url = 'mongodb://localhost/issuetracker';

// Atlas URL  - replace UUU with user, PPP with password, XXX with hostname
// const url = 'mongodb+srv://UUU:PPP@cluster0-XXX.mongodb.net/issuetracker?retryWrites=true';

// mLab URL - replace UUU with user, PPP with password, XXX with hostname
// const url = 'mongodb://UUU:PPP@XXX.mlab.com:33533/issuetracker';

let db;
...

接下来,让我们编写一个连接数据库的函数,它初始化这个全局变量。这是我们在trymongo.js中所做的一个小变化。我们不要在这个函数中捕捉任何错误,而是让调用者来处理它们。

...
async function connectToDb() {
  const client = new MongoClient(url, { useNewUrlParser: true });
  await client.connect();
  console.log('Connected to MongoDB at', url);
  db = client.db();
}
...

现在,我们必须更改服务器的设置,首先连接到数据库,然后启动 Express 应用。由于connectToDb()是一个异步函数,我们可以使用await等待它完成,然后调用app.listen()。但是由于await不能用在程序的主要部分,我们必须将它放在一个async函数中,并立即执行该函数。

...
(async function () {
  await connectToDb();
  app.listen(3000, function () {
    console.log('App started on port 3000');
  });
})();
...

但是我们也必须处理错误。因此,让我们将这个匿名函数的内容包含在一个try块中,并在控制台的catch块中打印任何错误:

...
(async function () {
  try {
    ...
  } catch (err) {
    console.log('ERROR:', err);
  }
})();
...

现在我们已经连接到在名为db的全局变量中设置的数据库,我们可以在列表 API 解析器issueList()中使用它,通过调用issues集合上的find()方法来检索问题列表。我们需要从这个函数返回一系列问题,所以让我们像这样对find()的结果使用toArray()函数:

...
  const issues = await db.collection('issues').find({}).toArray();
...

清单 6-4 中显示了对server.js的更改。

...
const { Kind } = require('graphql/language');

const { MongoClient } = require('mongodb');

const url = 'mongodb://localhost/issuetracker';

// Atlas URL  - replace UUU with user, PPP with password, XXX with hostname

// const url = 'mongodb+srv://UUU:PPP@cluster0-XXX.mongodb.net/issuetracker?retryWrites=true';

// mLab URL - replace UUU with user, PPP with password, XXX with hostname

// const url = 'mongodb://UUU:PPP@XXX.mlab.com:33533/issuetracker';

let db;

let aboutMessage = "Issue Tracker API v1.0";
...

async function issueList() {
  return issuesDB;
  const issues = await db.collection('issues').find({}).toArray();
  return issues;
}
...

async function connectToDb() {

  const client = new MongoClient(url, { useNewUrlParser: true });
  await client.connect();
  console.log('Connected to MongoDB at', url);
  db = client.db();

}

const server = new ApolloServer({
...

(async function () {

  try {
    await connectToDb();
    app.listen(3000, function () {
      console.log('App started on port 3000');
    });
  } catch (err) {
    console.log('ERROR:', err);
  }

})();

Listing 6-4server.js: Changes for Reading the Issue List from MongoDB

注意

我们不需要做任何特殊的事情,因为解析器issueList()现在是一个异步函数,它不会立即返回值。graphql-tools库自动处理这个问题。解析器可以立即返回值或返回承诺(这是异步函数立即返回的内容)。两者都是解析程序可接受的返回值。

由于来自数据库的问题现在除了包含id字段之外还包含一个_id,让我们将它包含在Issue类型的 GraphQL 模式中。否则,调用 API 的客户端将无法访问该字段。让我们使用ID作为它的 GraphQL 数据类型,并使它成为强制的。这一变化如清单 6-5 所示。

...
type Issue {
  _id: ID!
  id: Int!
  ...
}
...

Listing 6-5schema.graphql: Changes to add _id as a Field in Issue

现在,假设服务器仍在运行(或者您已经重新启动了服务器和编译),如果您刷新浏览器,您会发现两个初始问题集列在一个表中,和以前一样。UI 本身不会显示任何变化,但是为了让自己相信数据确实来自数据库,您可以使用 mongo shell 和集合上的updateMany()方法修改集合中的文档。例如,如果您将所有文档的工作量更新为 100 并刷新浏览器,您应该会看到表中所有行的工作量都显示为 100。

练习:从 MongoDB 中读取

  1. 我们将连接保存在一个全局变量中。当连接丢失时会发生什么?停止 MongoDB 服务器,然后再次启动它,看看会发生什么。连接还能用吗?

  2. 关闭 MongoDB 服务器,等待一分钟或更长时间,然后再次启动服务器。现在,刷新浏览器。会发生什么?你能解释这个吗?如果您希望即使在数据库服务器关闭的情况下,连接也能工作更长时间,该怎么办?提示:在 http://mongodb.github.io/node-mongodb-native/3.1/reference/connecting/connection-settings/ 查找连接设置参数。

  3. 我们使用toArray()将问题列表转换成一个数组。如果列表太大,比如说一百万个文档,该怎么办?你会怎么处理这件事?提示:在 http://mongodb.github.io/node-mongodb-native/3.1/api/Cursor.html 查找 MongoDB Node.js 驱动的Cursor的文档。注意,find()方法返回一个Cursor

写入 MongoDB

为了完全替换服务器上的内存数据库,我们还需要更改 Create API 以使用 MongoDB 数据库。正如您在 MongoDB CRUD 操作一节中看到的,创建新文档的方法是对集合使用insertOne()方法。

我们使用内存数组的大小来生成新文档的id字段。我们可以做同样的事情,使用集合的count()方法来获取下一个 ID。但是当有多个用户使用这个应用时,在我们调用count()方法和调用insertOne()方法之间有一个小的机会创建一个新文档。我们真正需要的是一种可靠的方法来生成一个不会重复的数字序列,就像流行的关系数据库中的序列一样。

MongoDB 没有直接提供这样的方法。但是它支持原子更新操作,可以返回更新的结果。这种方法叫做findOneAndUpdate()。使用这种方法,我们可以更新一个计数器并返回更新后的值,但是我们可以使用增加当前值的$inc操作符,而不是使用$set操作符。

让我们首先创建一个包含计数器的集合,该计数器保存最新生成的问题 ID 的值。为了使它更通用,让我们假设我们可能有其他这样的计数器,并使用一个集合,该集合的 ID 设置为计数器的名称,一个名为current的值字段保存计数器的当前值。将来,我们可以在同一个集合中添加更多的计数器,并且这些将转化为每个计数器一个文档。

首先,让我们修改模式初始化脚本,以包含一个名为counters的集合,并用一个针对问题计数器的文档填充它。因为有些插入会产生一些样本问题,所以我们需要将计数器的值初始化为插入文档的计数。变化在init.mongo.js中,清单 6-6 显示了这个文件。

...
print('Inserted', count, 'issues');

db.counters.remove({ _id: 'issues' });

db.counters.insert({ _id: 'issues', current: count });

...

Listing 6-6init.mongo.js: Initialize Counters for Issues

让我们再次运行模式初始化脚本,使更改生效:

$ mongo issuetracker scripts/init.mongo.js

现在,对增加字段currentfindOneAndUpdate()的调用保证返回序列中下一个唯一值。让我们在server.js中创建一个函数来做这件事,但是以一种通用的方式。我们将让它获取计数器的 ID 并返回下一个序列。在这个函数中,我们要做的就是调用findOneAndUpdate()。它使用提供的 ID 标识要使用的计数器,递增名为current的字段,并返回新值。默认情况下,findOneAndUpdate()方法的结果返回原始文档。要使它返回新的、修改过的文档,选项returnOriginal必须设置为false

方法findOneAndUpdate()的参数是(a)过滤器或匹配,我们使用了 _ id,然后是(b)更新操作,我们使用了值为 1 的$inc操作符,最后是(c)操作的选项。下面是完成必要工作的代码:

...
async function getNextSequence(name) {
  const result = await db.collection('counters').findOneAndUpdate(
    { _id: name },
    { $inc: { current: 1 } },
    { returnOriginal: false },
  );
  return result.value.current;
}
...

注意

返回当前值或新值的选项在 Node.js 驱动程序和 mongo shell 中的调用是不同的。在 mongo shell 中,该选项被称为returnNewDocument,默认为false。在 Node.js 驱动中,该选项被称为returnOriginal,默认为true。在这两种情况下,默认行为都是返回原始文档,因此必须指定选项来返回新文档。

现在,我们可以使用这个函数生成一个新的 ID 字段,并在解析器issueAdd()中的提供的issue对象中设置它。然后,我们可以使用insertOne()写入名为issues的集合,然后使用findOne()读回新创建的问题。

...
  issue.id = await getNextSequence('issues');

  const result = await db.collection('issues').insertOne(issue);
  const savedIssue = await db.collection('issues')
    .findOne({ _id: result.insertedId });
  return savedIssue;
...

最后,我们可以消除服务器中的内存问题。包括这一变化,清单 6-7 中显示了server.js中的全部变化。

...

const issuesDB = [

  {
    id: 1, status: 'New', owner: 'Ravan', effort: 5,
    ...
  },
  ...
  },

];

...

async function getNextSequence(name) {

  const result = await db.collection('counters').findOneAndUpdate(
    { _id: name },
    { $inc: { current: 1 } },
    { returnOriginal: false },
  );
  return result.value.current;

}

async function issueAdd(_, { issue }) {
  const errors = [];
  ...
  issue.created = new Date();

  issue.id = issuesDB.length + 1;
  issue.id = await getNextSequence('issues');

  issuesDB.push(issue);
  const result = await db.collection('issues').insertOne(issue);

  return issue;
  const savedIssue = await db.collection('issues')
    .findOne({ _id: result.insertedId });
  return savedIssue;
...

Listing 6-7server.js: Changes for Create API to Use the Database

测试这组更改将显示可以添加新的问题,即使重新启动 Node.js 服务器或数据库服务器,新添加的问题仍然存在。作为交叉检查,您可以使用 mongo shell 在 UI 每次更改后查看集合的内容。

练习:写入 MongoDB

  1. 我们是否可以将_id添加到传入的对象中并返回,而不是对插入的对象执行find()操作?

本章末尾有答案。

摘要

在本章中,您了解了 MongoDB 中数据库实例的安装和其他访问方法。您看到了如何使用 mongo shell 和 Node.js 驱动程序来访问 MongoDB 中的基本操作:CRUD 操作。然后,我们修改了问题跟踪器应用,使用其中的一些方法来读写 MongoDB 数据库,从而使问题列表持久化。

我只讲述了 MongoDB 的基础知识,只讲述了对构建 Issue Tracker 应用有用的功能和特性,这是一个相当简单的 CRUD 应用。实际上,数据库、Node.js 驱动程序和 mongo shell 的功能非常强大,复杂的应用可能需要 MongoDB 的更多功能。我鼓励您看一看 MongoDB 文档( https://docs.mongodb.com/manual/ )和 Node.js 驱动程序文档( http://mongodb.github.io/node-mongodb-native/ ),以熟悉数据库和 Node.js 驱动程序还能做什么。

既然我们已经使用了 MERN 堆栈的基本要素,并且有了一个可以工作的应用,那么让我们暂时停止实现特性,而是稍微组织一下。在应用变得更大、更笨拙之前,让我们将代码模块化,并使用工具来提高我们的生产率。

我们将在下一章通过使用 Webpack 来实现这一点,web pack 是可用于模块化前端和后端代码的最佳工具之一。

练习答案

练习:MongoDB 基础知识

  1. 根据“访问 mongo shell 帮助”下的 mongo shell 文档,您可以发现在许多对象上都有一个名为help()的方法,包括cursor对象。获得帮助的方法是使用db.collection.find().help()

    但由于这也是一个类似 Node.js 的 JavaScript shell,按 Tab 将自动完成,双 Tab 将显示可能完成的列表。因此,如果您将光标指定给一个变量,并在键入变量名和其后的点之后按 Tab 键两次,shell 将列出可能的完成,这是光标上可用方法的列表。

练习:MongoDB CRUD 操作

  1. 这可以使用如下的$exists操作符来完成:

    > db.employees.find({ "name.middle": { $exists: true } })

  2. 过滤器规范不是 JSON 文档,因为它不是字符串。它是一个常规的 JavaScript 对象,这就是为什么您可以跳过属性名两边的引号。与 JSON 字符串不同,您还可以将真正的Date对象作为字段值。

  3. The $unset operator in an update can be used to unset a field (which is actually different from setting it to null). Here is an example:

    > db.employees.update(({_id: ObjectId("57b1caea3475bb1784747ccb")},
    {"name.middle": {$unset: null}})
    
    

    尽管我们提供了null作为$unset的值,但是这个值被忽略了。它可以是任何东西。

  4. 1 表示遍历索引的升序排序。-1用于表示降序排序。这只对复合索引有用,因为一个字段的简单索引可以用来双向遍历集合。

练习:模式初始化

  1. 使用 Node.js 驱动程序的优点是有一种跨应用和脚本的方式,这种熟悉有助于防止错误。但是运行这个程序需要一个合适的 Node.js 环境,包括安装的 npm 模块,而 mongo shell 脚本可以从任何地方运行,只要机器安装了 mongo shell。

  2. 搜索栏在搜索问题时非常有用。在这种情况下,标题字段上的文本索引(基于单词的索引)会很有用。我们将在书的结尾实现一个文本索引。

练习:从 MongoDB 中读取

  1. 连接对象实际上是一个连接池。它会自动确定最佳做法:重用现有的 TCP 连接,在连接断开时重新建立新的连接,等等。建议使用全局变量(至少重用连接对象)。

  2. 如果数据库在短时间内(不到 30 秒)不可用,当数据库再次可用时,驱动程序会重试并重新连接。如果数据库长时间不可用,读取将引发错误。还原数据库时,驱动程序也无法重新建立连接。在这种情况下,需要重启应用服务器。

    使用连接设置reconnectTriesreconnectInterval可以更改默认的 30 秒间隔。

  3. 一种选择是对结果使用limit()来限制返回值的最大记录数。例如,find().limit(100)返回前 100 个文档。如果要对 UI 中的输出进行分页,也可以使用skip()方法来指定列表的起始位置。

    另一方面,如果您认为客户端可以处理大型列表,但是您不希望在服务器上花费那么多内存,那么您可以使用hasNext()next()一次处理一个文档,并将结果返回给客户端。

练习:写入 MongoDB

  1. 添加_id并返回传入的对象本来是可行的,只要您确实知道写操作是成功的,并且对象被原样写入数据库。在大多数情况下,这是正确的,但是从数据库中获得结果是一个好的实践,因为这是最终的真理。**

七、架构和 ESLint

在本章和下一章中,我们将暂停添加特性。相反,当应用变得越来越大时,我们将会变得更有组织性。

在这一章中,我们将再次审视该架构,并使其更加灵活,以便它能够满足具有大量流量的大型应用的需求。我们将使用一个名为 dotenv 的包来帮助我们在不同的环境中运行相同的代码,为每个环境使用不同的配置,比如开发和生产。

最后,我们将添加检查,以验证我们编写的代码遵循标准和良好的实践,并在测试周期的早期捕获可能的错误。为此,我们将使用 ESLint。

UI 服务器

到目前为止,我们并没有太关注应用的架构,并且唯一的服务器处理两个功能。Express 服务器不仅提供静态内容,还提供 API 调用。该架构如图 7-1 所示。

img/426054_2_En_7_Chapter/426054_2_En_7_Fig1_HTML.jpg

图 7-1

单一服务器架构

所有的请求都在同一个物理服务器上,其中有一个也是唯一的 Express 应用。然后,根据请求将请求路由到两个不同的中间件。目录中的任何请求匹配文件都由名为static的中间件进行匹配。这个中间件使用磁盘来读取文件并提供文件内容。其他匹配/graphql路径的请求由 Apollo 服务器的中间件处理。这个中间件使用解析器从 MongoDB 数据库获取数据。

这对于小型应用非常有用,但是随着应用的增长,会出现以下一种或多种情况:

  • API 有其他的消费者,不仅仅是基于浏览器的 UI。例如,API 可能会暴露给第三方或移动应用。

  • 这两部分有不同的扩容要求。通常,随着 API 的消费者越来越多,您可能需要多个 API 服务器和一个负载平衡器。然而,由于大多数静态内容能够并且将会被缓存在浏览器中,所以为静态资产提供许多服务器可能是大材小用。

此外,两种功能都在同一个服务器上完成,都在同一个 Node.js 和 Express 流程中,这使得诊断和调试性能问题变得更加困难。一个更好的选择是将这两个功能分成两个服务器:一个提供静态内容,另一个只托管 API。

在后面的章节中,我将介绍服务器呈现,其中完整的页面将从服务器生成,而不是在浏览器上构建。这有助于搜索引擎正确地索引页面,因为搜索引擎机器人不一定运行 JavaScript。当我们实现服务器渲染时,如果所有的 API 代码和 UI 代码都是分开的,将会有所帮助。

图 7-2 描绘了 UI 和 API 服务器分离的新一代架构。它还展示了当我们实现服务器端渲染时,它最终将适用于何处。

img/426054_2_En_7_Chapter/426054_2_En_7_Fig2_HTML.jpg

图 7-2

独立的 UI 服务器架构

在图 7-2 的图中,可以看到有两个服务器:UI 服务器和 API 服务器。这些可以是物理上不同的计算机,但是出于开发的目的,我们将在同一台计算机上运行它们,但是在不同的端口上运行。这些将使用两个不同的 Node.js 进程运行,每个进程都有自己的 Express 实例。

API 服务器现在将只负责处理API 请求,因此,它将只响应路径中匹配/graphql的 URL。因此,Apollo 服务器中间件及其对 MongoDB 数据库的请求将是 API 服务器中唯一的中间件。

UI 服务器部分现在将只包含静态中间件。在未来,当我们引入服务器渲染时,该服务器将通过调用 API 服务器的 API 来获取必要的数据,从而负责生成 HTML 页面。目前,我们将只使用 UI 服务器来提供所有静态内容,包括index.html和包含所有 React 代码的 JavaScript 包。

浏览器将负责根据请求的类型使用适当的服务器:所有的 API 调用将被定向到 API 服务器,而静态文件将被提交到 UI 服务器。

为了实现这一点,我们要做的第一件事是创建一个新的目录结构,将 UI 和 API 代码清晰地分开。

注意

理想情况下,UI 和 API 代码应该属于两个不同的存储库,因为它们之间没有共享。但是为了方便阅读这本书和参考 GitHub 库中的 Git diffs(https://github.com/vasansr/pro-mern-stack-2),我把代码放在一起,但是放在最顶层的不同目录中。

让我们重命名server目录api,而不是创建一个新的目录。

$ mv server api

注意

显示的命令(也可以在 GitHub 存储库(commands.md文件中的 https://github.com/vasansr/pro-mern-stack-2 )是为了在 MacOS 或基于 Linux 的发行版中的 bash shell 中执行。如果您使用的是 Windows PC,则必须使用 Windows 的等效命令。

因为我们拥有的所有脚本都只适用于 API 服务器,所以让我们将scripts目录也移到新目录api下。

$ mv scripts api

对于 UI 代码,让我们在项目根目录下创建一个名为ui的新目录,并将 UI 相关的目录publicsrc移到这个目录下。

$ mkdir ui
$ mv public ui
$ mv src ui

但是仅仅移动目录是不够的;我们需要在这些目录uiapi中各有一个package.json文件,既用于保存 npm 依赖关系,也用于创建运行服务器的便捷脚本。有了新的package.json文件并安装了所有的依赖项后,新的目录结构将如图 7-3 所示。

img/426054_2_En_7_Chapter/426054_2_En_7_Fig3_HTML.jpg

图 7-3

用于 UI 服务器分离的新目录结构

现在让我们在两个新目录中创建两个新的package.json文件。为了方便起见,您还可以从根项目目录中复制这个文件并进行修改。

在 API 对应的文件中,让我们在名称(例如,pro-mern-stack-2-api)和描述(例如,"Pro MERN Stack (2nd Edition) API")中使用 API 这个词。至于脚本,我们将只有一个脚本来启动服务器。由于文件的位置已经从server更改为当前目录,我们可以在这个脚本中删除nodemon-w选项。

...
    "start": "nodemon -e js,graphql server.js",
...

至于依赖项,我们没有任何devDependencies,但是有运行服务器所需的所有常规依赖项。完整的package.json文件如清单 7-1 所示。

{
  "name": "pro-mern-stack-2-api",
  "version": "1.0.0",
  "description": "Pro MERN Stack (2nd Edition) API",
  "main": "index.js",
  "scripts": {
    "start": "nodemon -e js,graphql server.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/vasansr/pro-mern-stack-2.git"
  },
  "author": "vasan.promern@gmail.com",
  "license": "ISC",
  "homepage": "https://github.com/vasansr/pro-mern-stack-2",
  "dependencies": {
    "apollo-server-express": "².3.1",
    "express": "⁴.16.4",
    "graphql": "⁰.13.2",
    "mongodb": "³.1.10",
    "nodemon": "¹.18.9"
  }
}

Listing 7-1api/package.json: New File

注意

尽管我们不遗余力地确保所有代码清单的准确性,但在本书付印之前,可能会有一些错别字甚至更正没有被收入书中。所以,总是依赖 GitHub 库( https://github.com/vasansr/pro-mern-stack-2 )作为所有代码清单的经过测试的和最新的源代码,尤其是当某些东西不能按预期工作时。

现在,让我们根据api目录中的新package.json文件安装所有的 npm 依赖项。

$ cd api
$ npm install

因为我们将在这个新的api目录中运行服务器,所以我们需要从当前目录加载schema.graphql。因此,让我们修改server.js中的代码,从正在加载的schema.graphql的路径中删除/server/前缀。

...
const server = new ApolloServer({
  typeDefs: fs.readFileSync('./server/schema.graphql', 'utf-8'),
...

我们还可以删除static中间件的加载,并在控制台消息中将新服务器称为 API 服务器,而不是应用服务器。清单 7-2 中显示了api/server.js的全套变更。

...
const server = new ApolloServer({
  typeDefs: fs.readFileSync('./server/schema.graphql', 'utf-8'),
...
const app = express();

app.use(express.static('public'));

server.applyMiddleware({ app, path: '/graphql' });
...
    app.listen(3000, function () {
      console.log('AppAPI server started on port 3000');
    });
...

Listing 7-2api/server.js: Changes for New Location of schema.graphql

此时,您应该能够使用npm start运行 API 服务器。此外,如果您使用 GraphQL Playground 测试 API,您应该会发现 API 像以前一样工作。

UI 服务器的变化有点复杂。我们需要一个新的既有服务器又有转换 npm 包的package.json,比如 Babel。让我们在 UI 目录中创建新的package.json。你可以通过从项目根目录复制或者运行npm init来完成。然后,在依赖项部分,让我们添加 Express 和 nodemon:

...
  "dependencies": {
    "express": "⁴.16.4",
    "nodemon": "¹.18.9"
  },
...

至于devDependencies,让我们从根目录下的package.json开始保留原设置。

...
  "devDependencies": {
    "@babel/cli": "⁷.2.3",
    "@babel/core": "⁷.2.2",
    "@babel/preset-env": "⁷.2.3",
    "@babel/preset-react": "⁷.0.0"
  }
...

让我们安装 UI 服务器所需的所有依赖项。

$ cd ui
$ npm install

现在,让我们创建一个 Express 服务器来服务目录ui中名为uiserver.js的静态文件。这与我们为 Hello World 创建的服务器非常相似。我们所需要的是带有static中间件的 Express 应用。文件内容如清单 7-3 所示。

const express = require('express');

const app = express();

app.use(express.static('public'));

app.listen(8000, function () {
  console.log('UI started on port 8000');
});

Listing 7-3ui/uiserver.js: New Server for Static Content

要运行这个服务器,让我们在package.json中创建一个启动它的脚本。这是您在其他服务器启动脚本中看到的常见的nodemon命令。这一次,我们将只关注uiserver.js文件,因为我们还有其他与服务器本身无关的文件。

...
  "scripts": {
    "start": "nodemon -w uiserver.js uiserver.js",
  },
...

此外,为了生成转换后的 JavaScript 文件,让我们添加compilewatch脚本,就像在原始的package.json文件中一样。该文件的完整内容,包括compilewatch脚本,如清单 7-4 所示。

{
  "name": "pro-mern-stack-2-ui",
  "version": "1.0.0",
  "description": "Pro MERN Stack (2nd Edition) - UI",
  "main": "index.js",
  "scripts": {
    "start": "nodemon -w uiserver.js uiserver.js",
    "compile": "babel src --out-dir public",
    "watch": "babel src --out-dir public --watch --verbose"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/vasansr/pro-mern-stack-2.git"
  },
  "author": "vasan.promern@gmail.com",
  "license": "ISC",
  "homepage": "https://github.com/vasansr/pro-mern-stack-2",
  "dependencies": {
    "express": "⁴.16.3",
    "nodemon": "¹.18.4"
  },
  "devDependencies": {
    "@babel/cli": "⁷.0.0",
    "@babel/core": "⁷.0.0",
    "@babel/preset-env": "⁷.0.0",
    "@babel/preset-react": "⁷.0.0"
  }
}

Listing 7-4ui/package.json: New File for the UI Server

现在,您可以通过在每个对应的目录中使用npm start运行 UI 和 API 服务器来测试应用。至于转换,您可以在ui目录中运行npm run compilenpm run watch。但是 API 调用将会失败,因为端点/graphql在 UI 服务器中没有处理程序。因此,我们需要更改 UI 来调用 API 服务器,而不是对 UI 服务器进行 API 调用。这可以在App.jsx文件中完成,如清单 7-5 所示。

...
async function graphQLFetch(query, variables = {}) {
  try {
    const response = await fetch('http://localhost:3000/graphql', {
      method: 'POST',
...

Listing 7-5ui/src/App.jsx: Point to a Different API Server

现在,如果您测试应用,您会发现它像以前一样工作。我们也可以清理根目录。文件package.json和目录node_modules不再需要,可以删除。完成此操作的 Linux 和 MacOS 命令如下:

$ rm package.json
$ rm -rf node_modules

练习:UI 服务器

  1. 打开一个新的浏览器标签,输入http://localhost:3000。你看到了什么,为什么?我们需要对此做些什么吗?有哪些选择?提示:以类似的方式浏览到 GitHub 的 API 端点主机,在 https://api.github.com

本章末尾有答案。

多重环境

我们推迟了移除硬编码的东西,比如端口号和 MongoDB URL。既然目录结构已经最终确定,现在可能是删除所有硬编码并将它们作为更容易更改的变量的好时机。

通常,有三种部署环境:开发、试运行和生产。每一个的服务器端口和 MongoDB URL 会有很大的不同。例如,API 服务器和 UI 服务器的端口都是 80。我们使用了两个不同的端口,因为两个服务器都运行在同一台主机上,并且两个进程不能在同一个端口上侦听。此外,我们使用 8000 这样的端口,因为使用端口 80 需要管理特权(超级用户权限)。

与其根据可能的部署目标(如开发、登台和生产)预先确定端口和 MongoDB URL,不如让变量保持灵活性,以便它们可以在运行时设置为任何值。提供这些的典型方式是通过环境变量,特别是对于远程目标和生产服务器。但是在开发过程中,最好能够在一些配置文件中包含这些内容,这样开发人员就不需要每次都记住设置这些内容。

让我们使用一个名为 dotenv 的包来帮助我们实现这一点。这个包可以将存储在文件中的变量转换成环境变量。因此,在代码中,我们只处理环境变量,但是环境变量可以通过真实的环境变量或配置文件来提供。

dotenv 包寻找一个名为.env的文件,它可以包含像在 shell 中定义的变量。例如,我们可以在该文件中包含以下行:

...
DB_URL=mongodb://localhost/issuetracker
...

在代码中,我们要做的就是使用process.env.DB_URL查找环境变量DB_URL,并使用其中的值。这个值可以被程序启动前定义的实际环境变量覆盖,所以没有必要有这个文件。事实上,大多数生产部署只从环境变量中获取值。

现在让我们安装软件包,首先在 API 服务器中:

$ cd api
$ npm install dotenv@6

要使用这个包,我们需要做的就是require它并立即调用它的config()

...
require('dotenv').config();
...

现在,我们可以通过使用process.env属性来使用任何环境变量。让我们首先在server.js中为 MongoDB URL 这样做。我们已经有了一个变量url,我们可以将它从process.env设置为DB_URL,如果没有定义的话,就将它默认为原来的本地主机值:

...
const url = process.env.DB_URL || 'mongodb://localhost/issuetracker';
...

同样,对于服务器端口,我们使用一个名为API_SERVER_PORT的环境变量,并在server.js中使用一个名为port的变量,如下所示:

...
const port = process.env.API_SERVER_PORT || 3000;
...

现在我们可以使用可变端口来启动服务器。

...
    app.listen(3000port, function () {
      console.log('API server started on port 3000');
      console.log(`API server started on port ${port}`);
...

请注意引号样式从单引号到反勾号的变化,因为我们使用了字符串插值。清单 7-6 显示了对api/server.js文件的一整套修改。

...
const fs = require('fs');

require('dotenv').config();

const express = require('express');
...
const url = process.env.DB_URL || 'mongodb://localhost/issuetracker';

// Atlas URL  - replace UUU with user, PPP with password, XXX with hostname

// const url = 'mongodb+srv://UUU:PPP@cluster0-XXX.mongodb.net/issuetracker?retryWrites=true';

// mLab URL - replace UUU with user, PPP with password, XXX with hostname

// const url = 'mongodb://UUU:PPP@XXX.mlab.com:33533/issuetracker';

...

const port = process.env.API_SERVER_PORT || 3000;

(async function () {
  try {
    ...
    app.listen(3000port, function () {
      console.log('API server started on port 3000');
      console.log(`API server started on port ${port}`);
    });
   ...
...

Listing 7-6api/server.js: Changes to Use Environment Variables

让我们在api目录中创建一个名为.env的文件。我在 GitHub 资源库中包含了一个名为sample.env的文件,你可以从中复制并修改以适应你的环境,尤其是DB_URL。该文件的内容如清单 7-7 所示。

## DB
# Local
DB_URL=mongodb://localhost/issuetracker

# Atlas - replace UUU: user, PPP: password, XXX: hostname
# DB_URL=mongodb+srv://UUU:PPP@XXX.mongodb.net/issuetracker?retryWrites=true

# mLab - replace UUU: user, PPP: password, XXX: hostname, YYY: port
# DB_URL=mongodb://UUU:PPP@XXX.mlab.com:YYY/issuetracker

## Server Port
API_SERVER_PORT=3000

Listing 7-7api/sample.env: Sample .env File

建议不要将.env文件签入任何存储库。每个开发人员和部署环境都必须根据自己的需要,在环境或该文件中专门设置变量。这是为了使对此文件的更改保留在开发人员的计算机中,而其他人的更改不会覆盖开发人员的设置。

更改nodemon命令行也是一个好主意,这样它可以监视对该文件的更改。由于当前命令行不包含 watch 规范(因为它默认为".",即当前目录),所以让我们也包含它。清单 7-8 显示了package.json中对这个脚本的修改。

...
  "scripts": {
    "start": "nodemon -e js,graphql -w . -w .env server.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
...

Listing 7-8api/package.json: nodemon to Watch for .env

现在,如果您在文件.env中将API_SERVER_PORT指定为4000并重启 API 服务器(因为 nodemon 需要知道新的观察文件),您应该会看到它现在使用端口 4000。您可以撤销这一更改,改为定义一个环境变量(不要忘记在 bash shell 中使用export来使该变量对子进程可用),并查看更改是否已经完成。注意,实际的环境变量优先于(或覆盖)在.env文件中定义的相同变量。

让我们也对api/scripts/trymongo.js做一组类似的更改,以使用环境变量DB_URL。这些变化如清单 7-9 所示。还有一些更改是在连接后打印出 URL,以交叉检查环境变量是否被使用。

require('dotenv').config();
const { MongoClient } = require('mongodb');

const url = process.env.DB_URL || 'mongodb://localhost/issuetracker';

// Atlas URL  - replace UUU with user, PPP with password, XXX with hostname

// const url = 'mongodb+srv://UUU:PPP@cluster0-XXX.mongodb.net/issuetracker?retryWrites=true';

// mLab URL - replace UUU with user, PPP with password, XXX with hostname

// const url = 'mongodb://UUU:PPP@XXX.mlab.com:33533/issuetracker';

...
  client.connect(function(err, client) {
    ...
    console.log('Connected to MongoDB');
    console.log('Connected to MongoDB URL', url);
...
    await client.connect();
    console.log('Connected to MongoDB');
    console.log('Connected to MongoDB URL', url);

Listing 7-9api/scripts/trymongo.js: Read DB_URI from the Environment Using dotenv

现在,您可以像以前一样使用命令行和 Node.js 来测试脚本,您将看到不同环境变量的效果,包括在 shell 和.env文件中。

我们需要对 UI 服务器进行类似的更改。在这种情况下,我们需要使用的变量是:

  • UI 服务器端口

  • 要调用的 API 端点

UI 服务器端口更改类似于 API 服务器端口更改。让我们先把那件事做完。至于 API 服务器,我们来安装 dotenv 包。

$ cd ui
$ npm install dotenv@6

然后,在ui/uiserver.js文件中,让我们要求并配置 dotenv:

...
require('dotenv').config();
...

让我们也将硬编码的端口改为使用环境变量。

...

const port = process.env.UI_SERVER_PORT || 8000;

app.listen(8000 port, function () {
  console.log('UI started on port 8000');
  console.log(`UI started on port ${port}`);
});
...

与这些变化不同,API 端点必须以 JavaScript 代码的形式到达浏览器。它不是可以从环境变量中读取的东西,因为它不会传输到浏览器。

一种方法是在构建和绑定过程中,用变量值替换代码中的预定义字符串。我将在下一节描述这个方法。尽管对许多人来说这是一个有效的首选,但我还是选择将配置设为运行时变量,而不是编译时变量。这是因为在真正的 UI 服务器上,设置服务器端口和 API 端点的方式是统一的。

为此,让生成一个 JavaScript 文件,并将其注入到index.html中。这个 JavaScript 文件将包含一个带有环境内容的全局变量。让我们称这个新的脚本文件为env.js,并将其包含在index.html中。这是本节中对index.html的唯一更改,如清单 7-10 所示。

...
  <div id="contents"></div>

  <script src="/env.js"></script>
  <script src="/App.js"></script>
...

Listing 7-10ui/public/index.html: Include the Script /env.js

现在,在 UI 服务器中,让我们生成这个脚本的内容。这应该会导致设置一个名为ENV的全局变量,其中一个或多个属性被设置为环境变量,如下所示:

...
window.ENV = {
  UI_API_ENDPOINT: "http://localhost:3000"
}
...

当 JavaScript 被执行时,它将初始化对象的全局变量ENV。当任何其他地方需要该变量时,可以从全局变量中引用它。现在,在 UI 服务器代码中,让我们首先为 API 端点初始化一个变量,如果找不到,就使用默认值。然后,我们将构造一个对象,只将这一个变量作为属性。

...
const UI_API_ENDPOINT = process.env.UI_API_ENDPOINT || 'http://localhost:3000';
const env = { UI_API_ENDPOINT };
...

现在,我们可以在服务器中创建一个路由来响应对env.js的 GET 调用。在该路由的处理程序中,让我们使用env对象根据需要构造字符串,并将其作为响应发送:

...
app.get('/env.js', function(req, res) {
  res.send(`window.ENV = ${JSON.stringify(env)}`)
})
...

清单 7-11 中显示了对ui/uiserver.js的完整更改。

require('dotenv').config();

const express = require('express');

const app = express();

app.use(express.static('public'));

const UI_API_ENDPOINT = process.env. UI_API_ENDPOINT || 'http://localhost:3000/graphql';

const env = { UI_API_ENDPOINT };

app.get('/env.js', function(req, res) {

  res.send(`window.ENV = ${JSON.stringify(env)}`)

})

const port = process.env.UI_SERVER_PORT || 8000;

app.listen(8000port, function () {
  console.log('UI started on port 8000');
  console.log(`UI started on port ${port}`);
});

Listing 7-11ui/uiserver.js: Changes for Environment Variable Usage

就像 API 服务器一样,让我们创建一个.env文件来保存两个变量,一个用于服务器的端口,另一个用于 UI 需要访问的 API 端点。您可以使用sample.env文件的副本,其内容如清单 7-12 所示。

UI_SERVER_PORT=8000
UI_API_ENDPOINT=http://localhost:3000/graphql

Listing 7-12ui/sample.env: Sample .env File for the UI Server

最后,在App.jsx中,API 端点是硬编码的,让我们用来自全局ENV变量的属性替换硬编码。这一变化如清单 7-13 所示。

...
async function graphQLFetch(query, variables = {}) {
  try {
    const response = await fetch('http://localhost:3000/graphql', {
    const response = await fetch(window.ENV.UI_API_ENDPOINT, {
    ...
  ...
}
...

Listing 7-13ui/src/App.jsx: Replace Hard-Coding of API Endpoint

让我们也让 nodemon 监视.env文件中的变化。由于我们在 UI 服务器中指定了要监视的单个文件,这要求我们使用-w命令行选项添加另一个要监视的文件。对ui/package.json的更改如清单 7-14 所示。

...
  "scripts": {
    "start": "nodemon -w uiserver.js -w .env uiserver.js",
...

Listing 7-14ui/package.json: nodemon to Watch for Changes in .env

现在,如果您使用默认端口和端点测试应用,应用应该像以前一样工作。如果您一直在控制台中运行npm run watch,对App.jsx的更改将会被自动重新编译。

您还可以通过实际的环境变量和对.env文件(如果有)的更改来确保对任何变量的更改生效。如果您通过一个环境变量来改变一个变量,那么如果您使用的是 bash shell,一定要记住导出它。此外,必须重新启动服务器,因为 nodemon 不会监视对任何环境变量的更改。

练习:多种环境

  1. 在浏览器中,手动键入http://localhost:8000/env.js。你看到了什么?将环境变量UI_API_ENDPOINT设置到不同的位置,并重启 UI 服务器。检查env.js的内容。

本章末尾有答案。

基于代理的体系结构

如果你在测试时在开发者控制台打开了网络标签,你会注意到有两个/graphql调用,而不是一个。第一次调用的 HTTP 方法是OPTIONS。原因是 API 调用是针对不同于应用来源(http://localhost:8000)的主机(http://localhost:3000)。由于同源策略,这样的请求通常会被浏览器阻止,除非服务器特别允许。

同源策略的存在是为了防止恶意网站获得对应用的未授权访问。您可以在 https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy 阅读该政策的详细内容。但其要点是,由于由一个来源设置的 cookie 会自动附加到对该来源的任何请求,因此恶意网站可能会从浏览器调用该来源,并且浏览器会附加该 cookie。

假设您登录了一家银行的网站。在另一个浏览器选项卡中,您正在浏览一些运行恶意 JavaScript 的新闻网站,可能是通过网站上的广告。如果这个恶意的 JavaScript 对银行的网站进行 Ajax 调用,并将 cookies 作为请求的一部分发送出去,那么这个恶意的 JavaScript 最终会冒充您,甚至可能将资金转移到黑客的帐户上!

因此,浏览器通过要求这样的请求被明确允许来防止这种情况。可以允许的请求类型由同源策略以及由服务器控制的参数控制,服务器确定是否可以允许请求。这种机制被称为跨源资源共享或简称 CORS。默认情况下,Apollo GraphQL 服务器允许跨源的未经验证的请求。对OPTIONS请求的响应中的以下标题表明了这一点:

Access-Control-Allow-Headers: content-type
Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE
Access-Control-Allow-Origin: *

让我们禁用 Apollo 服务器的默认行为(当然,使用一个环境变量)并检查 API 服务器的新行为。让我们调用这个环境变量ENABLE_CORS并将api/.env文件设置为false(默认为true,当前行为)。

...
## Enable CORS (default: true)
ENABLE_CORS=false
...

现在,在 API 的server.js中,让我们寻找这个环境变量,并根据这个变量将一个名为cors的选项设置为truefalse。对api/server.js的更改如清单 7-15 所示。

...
const app = express();

const enableCors = (process.env.ENABLE_CORS || 'true') == 'true';

console.log('CORS setting:', enableCors);

server.applyMiddleware({ app, path: '/graphql', cors: enableCors });
...

Listing 7-15api/server.js: Option for Enabling CORS

如果您测试应用,您会发现OPTION请求失败,HTTP 响应为 405。现在,应用不会受到恶意的跨站点攻击。但是这也意味着我们需要一些其他的机制来进行 API 调用。

我将更详细地讨论 CORS,以及为什么在应用的当前阶段启用 CORS 是安全的,因为所有的资源都是公开的,无需认证。但为了安全起见,我们也来看看替代方案。在这一节中,我们将改变 UI,甚至向 UI 服务器发出 API 请求,我们将安装一个代理,这样任何对/graphql的请求都会被路由到 API 服务器。这种新架构如图 7-4 所示。

img/426054_2_En_7_Chapter/426054_2_En_7_Fig4_HTML.jpg

图 7-4

基于代理的体系结构

使用http-proxy-middleware包可以很容易地实现这样的代理。让我们安装这个包:

$ cd ui
$ npm install http-proxy-middleware@0

现在,代理可以用作软件包提供的中间件,安装在路径/graphql上,使用app.use()。创建中间件只需要一个选项:代理的目标,这是请求必须被代理的主机的基本 URL。让我们定义另一个名为API_PROXY_TARGET的环境变量,并使用它的值作为目标。如果这个变量是未定义的,我们可以跳过安装代理,而不是默认它。

清单 7-16 中显示了ui/uiserver.js的变更。

...
require('dotenv').config();
const express = require('express');

const proxy = require('http-proxy-middleware');

...

const apiProxyTarget = process.env.API_PROXY_TARGET;

if (apiProxyTarget) {

  app.use('/graphql', proxy({ target: apiProxyTarget }));

}

const UI_API_ENDPOINT = process.env.UI_API_ENDPOINT ||
...

Listing 7-16ui/uiserver.js: Changes to Install Proxy

现在让我们更改在ui/.env中指定 API 端点的环境变量,将其设置为/graphql,这意味着/graphql在与原点相同的主机上。进一步,让我们定义代理的目标,变量API_PROXY_TARGEThttp://localhost:3000

...
UI_API_ENDPOINT=http://localhost:3000/graphql

API_PROXY_TARGET=http://localhost:3000

...

现在,如果您测试应用并查看浏览器的开发人员控制台中的 Network 选项卡,您会发现对于每个 API 调用,只有一个请求发送到 UI 服务器(端口 8000 ),并成功执行。

您可以使用本节中描述的代理方法,或者让 UI 直接调用 API 服务器并在 API 服务器中启用 CORS。这两个选项都很好,您的实际选择取决于各种因素,例如您的部署环境和应用的安全需求。

出于阅读本书的目的,让我们将本节中对.env文件所做的更改还原,以便使用直接 API 调用机制。您可以将 API 和 UI 目录中的sample.env文件从 GitHub 库复制到您自己的.env文件中,这反映了 API 的直接工作方式。

斯洛文尼亚语

一个棉绒(?? 棉绒的东西)检查可能是错误的可疑代码。它还可以检查您的代码是否符合您希望整个团队遵循的约定和标准,以使代码具有可预测的可读性。

虽然对于什么是好的标准有多种观点和争论(例如,制表符和空格),但是对于是否首先需要一个标准却没有争论。对于一个团队或者一个项目,采用一个标准远比采用正确的标准重要。

ESLint ( https://eslint.org )是一个非常灵活的 linter,可以让你定义你想要遵循的规则。但我们需要一些东西作为起点,最吸引我的规则是 Airbnb 的规则。其吸引力的部分原因是它的受欢迎程度:如果更多的人采用它,它就变得越标准化,所以更多的人最终会跟随它,成为一个良性循环。

Airbnb ESLint 配置有两个部分:基本配置适用于普通 JavaScript,常规配置也包括 JSX 和 React 的规则。在本节中,我们将只对后端代码使用 ESLint,这意味着我们只需要安装基本配置,以及基本配置所需的 ESLint 和其他依赖项:

$ cd api
$ npm install --save-dev eslint@5 eslint-plugin-import@2
$ npm install --save-dev eslint-config-airbnb-base@13

ESLint 在.eslintrc文件中寻找一组规则,这是一个 JSON 规范。这些不是规则的定义,而是需要启用或禁用哪些规则的规范。规则集也可以被继承,这就是我们在配置中使用extends属性所要做的。使用一个.eslintrc文件使规则应用于该目录中的所有文件。对于单个文件中的覆盖,可以在该文件的注释中指定规则,甚至可以只在一行中指定。

配置文件中的规则在属性rules下指定,该属性是一个包含一个或多个规则的对象,由规则名标识,值是错误级别。错误等级为offwarningerror。例如,要指定规则quotes(检查字符串的单引号和双引号)应该显示警告,这就是规则需要被指定的方式:

...
  rules: {
    "quotes": "warning”
  }
...

许多规则都有选项,例如,规则quotes有一个选项,用于选择要执行的报价类型是单个还是两个。指定这些选项时,值需要是一个数组,第一个元素作为错误级别,第二个(或更多,取决于规则)是选项。下面是 quotes 规则如何选择一个选项来检查双引号:

...
    "quotes": ["warning", "double"]
...

先说一个基础配置,只继承了 Airbnb 的基础配置,没有任何规则。让我们使用env属性来具体说明代码将在哪里运行。因为所有的后端代码都只能在 Node.js 上运行(并且只能在 Node.js 上运行),所以这个属性对于值为truenode只有一个条目。下面是.eslintrc文件在这个阶段的样子:

{
  "extends": "airbnb-base",
  "env": {
    "node": "true"
  }
}

现在,让我们在整个api目录上运行 ESLint。执行此操作的命令行如下:

$ cd api
$ npx eslint .

或者,您可以在编辑器中安装一个插件,在编辑器中显示 lint 错误。流行的代码编辑器 Atom 和 Sublime 都有插件来处理这个问题;按照各自网站上的说明安装插件。然后,我们将查看每种类型的错误或警告,并处理它。

对于大多数错误,我们只是要更改代码以符合建议的标准。但在少数情况下,我们会对 Airbnb 规则进行例外处理。这可能是针对整个项目,或者在某些情况下,针对特定文件或文件中的某一行。

让我们看看每种类型的错误并修复它们。请注意,我只是在讨论 ESLint 在我们到目前为止编写的代码中可能会发现的错误。当我们写更多的代码时,我们会修复所有的 lint 错误,所以强烈推荐一个编辑器插件来报告我们输入时的错误。

文体问题

JavaScript 在语法上非常灵活,所以有很多方法可以编写相同的代码。linter 规则会报告一些错误,以便您在整个项目中使用一致的样式。

  • 缩进:始终期望一致的缩进;这不需要辩解。让我们解决所有的违规问题。

  • 关键字间距:关键字之间的空格(ifcatch等)。)并建议使用左括号。让我们更改代码,无论哪里报告了这一点。

  • 缺少分号:关于到处都有分号还是哪儿都没有分号更好,有很多争论。两者都可以工作,除了少数情况下缺少分号会导致行为改变。如果您遵循无分号标准,您必须记住那些特殊情况。还是用 Airbnb 默认的吧,就是要求处处分号。

  • 字符串必须使用单引号 : JavaScript 允许单引号和双引号。为了标准化,最好始终使用一种风格。让我们使用 Airbnb 默认的单引号。

  • 新行上的对象属性:一个对象的所有属性必须在一行中,或者每个属性在新行中。这只是使它更可预测,尤其是当一个新的属性必须被插入的时候。对于是将新属性附加到现有行中的一行还是新行中,没有疑问。

  • objects中 before }之后的空格:这只是为了可读性;让我们在 linter 报告错误的地方更改它。

  • Arrow 函数风格:linter 建议要么在单个参数和函数体之间使用括号,要么在参数和返回表达式之间不使用括号(即不是函数体)。让我们进行建议的修改。

最佳实践

这些规则与更好的做事方式有关,通常有助于避免错误。

  • 函数必须命名为:省略函数名会使调试更加困难,因为堆栈跟踪无法识别函数。但这仅适用于常规函数,不适用于箭头样式的函数,因为箭头样式的函数应该是回调的小段。

  • 一致返回:函数应该总是返回值或者从不返回值,不管条件如何。这提醒开发人员添加返回值或明确返回值,以防他们忘记返回条件之外的值。

  • 变量必须在使用之前定义:虽然 JavaScript 提升了定义,使得它们在整个文件中都可用,但是在使用之前定义它们是个好习惯。否则,当从上到下阅读代码时,它会变得混乱。

  • 控制台:特别是在浏览器中,这些通常是遗留下来的调试信息,因此不适合在客户端显示。但是这些在 Node.js 应用中是没问题的。因此,让我们在 API 代码中关闭这条规则。

  • 返回作业:虽然很简洁,但是返回和作业放在一起可能会让读者感到困惑。还是回避一下吧。

可能的错误

考虑您可能遇到的这些错误:

  • 重新声明变量:当一个变量在更高的范围内遮蔽(覆盖)另一个变量时,很难阅读和理解原编码者的意图。也不可能在更高的范围内访问变量,所以最好给变量取不同的名字。

  • 未声明的变量:最好避免内部作用域中的变量与外部作用域中的同名。这是令人困惑的,它隐藏了对外部作用域变量的访问,以防需要访问它。但是在 mongo 脚本中,我们确实有真正全局的变量:dbprint。让我们在注释中将它们声明为全局变量,这样 ESLint 就知道这些不是错误:

    ...
    /* global db print */
    ...
    
    
  • 更喜欢箭头回调:当使用匿名函数时(比如当传递一个回调给另一个函数时),最好使用箭头函数风格。这具有将变量this设置为当前上下文的额外效果,这在大多数情况下是可取的,并且语法也更简洁。如果函数很大,最好把它分成一个命名的常规函数。

  • 三重等于:三重等于的使用确保了在比较之前值不会被强制。在大多数情况下,这就是我们想要的,它避免了由于强制值而导致的错误。

  • 函数参数的赋值:改变传入的参数可能会导致调用者没有注意到变化,从而导致意外的行为。让我们避免更改函数参数的值,而是制作参数的副本。

  • 受限全局函数 : iNaN被认为是受限全局函数,因为它将非数字强制转换为数字。推荐使用函数Number.isNaN(),但是它只对数字有效,所以在用Number.isNaN()检查之前,让我们对日期对象做一个getTime()。另外,print()是一个受限的全局变量,但是它在 mongo 脚本中的使用是有效的,所以让我们只对 mongo 脚本关闭这个规则,如下所示:

    ...
    /* eslint no-restricted-globals: "off" */
    ...
    
    
  • Wrap 立即调用函数表达式(life):立即调用的函数表达式是一个单独的单元。用括号把它括起来,不仅使它更清楚,而且使它成为一个表达式而不是一个声明。

API 目录下最后一个.eslintrc文件的内容如清单 7-17 所示。

{
  "extends": "airbnb-base",
  "env": {
    "node": "true"
  },
  rules: {
    "no-console": "off"
  }
}

Listing 7-17api/.eslintrc: Settings for ESLint in the API Directory

对 API 目录下 JavaScript 文件的修改如清单 7-18 到 7-20 所示。

/*
 ...
*/

/* global db print */

/* eslint no-restricted-globals: "off" */

db.issues.remove({});

const issuesDB = 
  {
    id: 1, status: 'New', owner: 'Ravan', effort: 5,
    created: new Date('2019-01-15'), due: undefined,
    id: 1,
    status: 'New',
    owner: 'Ravan',
    effort: 5,
    created: new Date('2019-01-15'),
    due: undefined,
    title: 'Error in console when clicking Add',
  },
  {
    id: 2, status: 'Assigned', owner: 'Eddie', effort: 14,
    created: new Date('2019-01-16'), due: new Date('2019-02-01'),
    id: 2,
    status: 'Assigned',
    owner: 'Eddie',
    effort: 14,
    created: new Date('2019-01-16'),
    due: new Date('2019-02-01'),
    title: 'Missing bottom border on panel',
  },
...

Listing 7-18api/scripts/init.mongo.js: Fixes for ESLint Errors

function testWithCallbacks(callback) {
  console.log('\n--- testWithCallbacks ---');
  const client = new MongoClient(url, { useNewUrlParser: true });
  client.connect(function(err, client) {
  client.connect((connErr) => {
    if (err connErr) {
      callback(errconnErr);
      return;
    }
    console.log('Connected to MongoDB URL', url);
...
    const employee = { id: 1, name: 'A. Callback', age: 23 };
    collection.insertOne(employee, function(err, result) {
    collection.insertOne(employee, (insertErr, result) => {
      if (err insertErr) {
        client.close();
        callback(err insertErr);
         return;
      }
      console.log('Result of insert:\n', result.insertedId);
      collection.find({ _id: result.insertedId})
      collection.find({ _id: result.insertedId })
        .toArray(function(err, docs) {
        .toArray((findErr, docs) => {
        if (err) {
          client.close();
          callback(err);
          return;
        }
          if (findErr) {
            client.close();
            callback(findErr);
            return;
          }
        console.log('Result of find:\n', docs);
        client.close();
        callback(err);
     });
          console.log('Result of find:\n', docs);
          client.close();
          callback();
        });
...

async function testWithAsync() {

  ...
  } catch(err) {
  } catch (err) {
  ...
}

testWithCallbacks(function(err) {

testWithCallbacks((err) => {

  ...
}

Listing 7-19api/scripts/trymongo.js: Fixes for ESLint Errors

let db;

let aboutMessage = "Issue Tracker API v1.0";

let aboutMessage = 'Issue Tracker API v1.0';

...

const GraphQLDate = new GraphQLScalarType({
  ...
  parseValue(value) {
     ...
    return isNaN(dateValue) ? undefined : dateValue;
    return Number.isNaN(dateValue.getTime()) ? undefined : dateValue;
  },
  parseLiteral(ast) {
    if (ast.kind === Kind.STRING) {
      const value = new Date(ast.value);
      return isNaN(value) ? undefined : value;
      return Number.isNaN(value.getTime()) ? undefined : value;
    }
    return undefined;
  },
});
...

const resolvers = {

  ...

};

...

function setAboutMessage(_, { message }) {
  return aboutMessage = message;
  aboutMessage = message;
  return aboutMessage;
}
...

async function issueAdd(_, { issue }) {

  ...
    errors.push('Field "title" must be at least 3 characters long.')
    errors.push('Field "title" must be at least 3 characters long.');
  ...
  if (issue.status == 'Assigned' && !issue.owner) {
  if (issue.status === 'Assigned' && !issue.owner) {
  ...
  const newIssue = Object.assign({}, issue);
  issue newIssue.created = new Date();
  issue newIssue.id = await getNextSequence('issues');

  const result = await db.collection('issues').insertOne(issue newIssue);
  ...
}
...

const resolvers = {

  ...

};

...

const server = new ApolloServer({
  ...
  formatError: error => {
  formatError: (error) => {
  ...
});
...

const enableCors = (process.env.ENABLE_CORS || 'true') == 'true';

const enableCors = (process.env.ENABLE_CORS || 'true') === 'true';

...

(async function start() {
  ...
    app.listen(port, function () => {

      ...
  ...

})();

}());

Listing 7-20api/server.js: Fixes for ESLint Errors

最后,让我们添加一个 npm 脚本,它将 lint all 目录中的所有文件。命令行类似于我们之前使用的 lint 整个目录。对此的更改显示在package.json中的清单 [7-21 中。

...
  "scripts": {
    "start": "nodemon -e js,graphql -w . -w .env server.js",
    "lint": "eslint .",
    "test": "echo \"Error: no test specified\" && exit 1"

  },
...

Listing 7-21api/package.json: New Script for lint

前端的 ESLint

在本节中,我们将把 ESLint 检查添加到 UI 目录中。这一次,我们不仅要安装airbnb-base包,还要安装完整的 Airbnb 配置,包括 React 插件。

$ cd ui
$ npm install --save-dev eslint@5 eslint-plugin-import@2
$ npm install --save-dev eslint-plugin-jsx-a11y@6 eslint-plugin-react@7
$ npm install --save-dev eslint-config-airbnb@17

接下来,让我们通过扩展airbnb-base从服务器代码的.eslintrc开始。由于这是 Node.js 代码,我们也将环境设置为node,将规则no-console设置为off,就像在 API 配置中一样。清单 7-22 显示了.eslintrc的全部内容。

{
  "extends": "airbnb-base",
  "env": {
    "node": true
  },
  "rules": {
    "no-console": "off"
  }
}

Listing 7-22ui/.eslintrc: New ESLint Configuration for UI Server Code

要运行 linter,我们可以使用当前目录(.)作为命令行参数来执行命令。但是,在当前目录上执行将导致 ESLint 也在子目录中运行,这包括在public目录下编译的文件。编译后的文件会有很多 lint 错误,因为它不是源代码。因此,让我们通过使用 ESLint 的--ignore-pattern命令行选项来排除public目录,从而将其从 ESLint 范围中排除。

$ npx eslint . --ignore-pattern public

另一种忽略文件模式的方法是将它们作为行添加到名为.eslintignore的文本文件中。当有许多模式要被忽略时,这是很有用的。因为我们只需要忽略一个目录,所以我们将使用命令行选项。

在开发的这个阶段,uiserver.js文件将抛出与 API server.js类似的错误。文件。这些是样式问题,包括缺少分号、对箭头功能的偏好以及长行的换行符样式。修正这些错误后对server.js的更改如清单 7-23 所示。

...

const UI_API_ENDPOINT = process.env.UI_API_ENDPOINT ||

  'http://localhost:3000/graphql';

const UI_API_ENDPOINT = process.env.UI_API_ENDPOINT

  || 'http://localhost:3000/graphql';
...

app.get('/env.js', function(req, res) {

app.get('/env.js', (req, res) => {

...

app.listen(port, function () {

app.listen(port, () => {

...

Listing 7-23ui/uiserver.js: Fixes for ESLint Errors

现在,让我们从简单配置src目录下的 React 代码开始。.eslintrc文件将不得不扩展airbnb而不是airbnb-base。在环境中,我们可以指定对 browser 的支持,而不是 Node.js。起始的.eslintrc文件将如下所示:

...
{
  "extends": "airbnb",
  "env": {
    "browser": true
  }
}
...

现在,我们可以运行 ESLint 来检查 React 代码。在 ESLint 的早期调用中,没有检查App.jsx中的 React 代码,因为默认情况下 ESLint 不匹配扩展名为jsx的文件。为了包含这个扩展,ESLint 需要在命令行选项中包含完整的扩展列表--ext.

$ npx eslint . --ext js,jsx --ignore-pattern public

该命令引发的错误包括一些我们在前面部分已经讨论过的问题。这些是:

  • 新行上的对象属性

  • 缺少分号

  • 字符串必须使用单引号

  • 一致回报

  • 对象定义中花括号周围的间距

  • 对象中{之前和之后}的空格

  • 三倍相等

让我们讨论一下 ESLint 显示的其他问题。

文体问题

  • 隐式箭头换行符:这是一个风格问题,为了保持换行符的一致性。建议将从箭头函数返回的表达式与箭头放在同一行。如果表达式很长,无法放在一行中,可以从同一行开始用括号括起来。让我们来做这个改变。

  • 中缀操作符必须有空格:为了可读性,操作符周围需要空格。让我们按照建议做些改变。

最佳实践

  • “React”必须在范围内:当 ESLint 检测到 JSX 时,它期望 React 被定义。在这个阶段,我们包括来自 CDN 的 React。很快,我们将通过使用 npm 安装这些模块来使用它们。在那之前,让我们禁用这些检查。我们将内联执行这些操作,保持.eslintrc内容没有这种临时的变通办法。让我们在App.jsx文件中添加以下注释:

  • 无状态函数:组件IssueFilter目前只是一个占位符。当我们向它添加功能时,它将成为一个有状态的组件。在此之前,让我们禁用 ESLint 检查,但只针对这个组件。

...
/* eslint "react/react-in-jsx-scope": "off" */
/* globals React ReactDOM */
/* eslint "react/jsx-no-undef": "off" */
...

  • 更喜欢析构,尤其是属性赋值(props assignment):这种从对象中赋值变量的新方式不仅更简洁、可读性更好,还可以为那些被创建的属性保存临时引用。让我们按照建议更改代码。

  • 每个文件一个组件:每个文件只声明一个组件提高了组件的可读性和可重用性。目前,我们还没有讨论如何为 React 代码创建多个文件。我们将在下一章做那件事;在此之前,让我们禁用对文件的检查。

...
// eslint-disable-next-line react/prefer-stateless-function
class IssueFilter extends React.Component {
...

  • 无警告:这条规则的初衷是清除未运行的调试消息。我们将在文档中把警告消息转换成风格优美的消息。在那之前,让我们禁用这种检查,但是只在我们显示错误消息的文件中。

  • 缺少尾随逗号:在多行数组或对象中的最后一项需要逗号,这在插入新项时非常方便。此外,当在比如说 GitHub 中查看两个版本之间的差异时,在最后一行添加逗号的事实表明这一行发生了变化,而实际上并没有。

...
/* eslint "react/no-multi-comp": "off" */
...

可能的错误

  • Props validation :检查传递给组件的属性的类型是一个很好的实践,既可以让组件的用户清楚地知道,又可以避免输入中的错误。虽然我会简单地讨论这个问题,但我不会在 React 代码中添加 props 验证,纯粹是为了避免代码清单中的干扰。让我们为问题跟踪器应用全局关闭此规则,但我鼓励您在自己的应用中保持启用此规则。

  • *按钮类型:*虽然一个按钮的默认类型是submit,但是最好确保明确声明,以防这不是预期的行为,开发者遗漏了添加一个类型。让我们按照建议,将submit添加到按钮的类型中。

  • 函数参数重新赋值:给一个函数参数赋值会使原参数不可访问,导致混淆行为。让我们使用一个新的变量,而不是重用函数参数。

...
  "rules": {
    "react/prop-types": "off",
  }
...

在对.eslintrc文件进行这些更改后,该文件的最终内容如清单 7-24 所示。

{
  "extends": "airbnb",
  "env": {
    "browser": true
  },
  rules: {
    "react/prop-types": "off"
  }
}

Listing 7-24ui/src/.eslintrc: New ESLint Configuration for UI Code

清单 7-25 整合了对uiserver.js的所有修改,以解决 ESLint 错误。

...

/* eslint "react/react-in-jsx-scope": "off" */

/* globals React ReactDOM */

/* eslint "react/jsx-no-undef": "off" */

/* eslint "no-alert": "off" */

...

// eslint-disable-next-line react/prefer-stateless-function

class IssueFilter extends React.Component {
...

function IssueRow(props{ issue }) {
  const issue = props.issue;
...

function IssueTable(props{ issue }) {
  const issueRows = props.issues.map(issue =>
  const issueRows = issues.map(issue => (
     <IssueRow key={issue.id} issue={issue} />
  ));
...

    const issue = {
      owner: form.owner.value, title: form.title.value,
      title: form.title.value,
      due: new Date(new Date().getTime() + 1000*60*60*24*10),
      due: new Date(new Date().getTime() + 1000 * 60 * 60 * 24 * 10),
    }
    this.props.createIssue(issue);
    const { createIssue } = this.props;
    createIssue(issue);
    form.owner.value = ""; form.title.value = "";
    form.owner.value = ''; form.title.value = '';
...

        <button type="submit">Add</button>

...

async function graphQLFetch(query, variables = {}) {
...

      headers: { 'Content-Type': 'application/json'},
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ query, variables })
      body: JSON.stringify({ query, variables }),
...

      if (error.extensions.code == 'BAD_USER_INPUT') {
      if (error.extensions.code === 'BAD_USER_INPUT') {
...

  } catch (e) {
    alert(`Error in sending data to server: ${e.message}`);
    return null;
  }
}

class IssueList extends React.Component {
  ...
  render() {
    const { issues } = this.state;
    return (
      ...
        <IssueTable issues={this.state.issues} />
      ...
    )
...

Listing 7-25ui/src/App.jsx: Fixes for ESLint Errors

最后,为了方便起见,我们给 UI 目录中的package.json添加一个脚本,对所有相关文件执行 lint。命令行与我们之前用来检查整个目录的命令行相同。这显示在清单 7-26 中。

...
  "scripts": {
    "start": "nodemon -w uiserver.js -w .env uiserver.js",
    "lint": "eslint . --ext js,jsx --ignore-pattern public",
    ...
  },
...

Listing 7-26ui/package.json: Command for Running ESLint on the UI Directory

现在,命令npm run lint将检查当前设置,以及将被添加到 UI 目录下的任何其他文件。在这些代码更改之后,该命令应该不会返回任何错误或警告。

React 型态

在像 Java 这样的强类型语言中,参数的类型总是预先确定的,并作为函数声明的一部分来指定。这确保了调用者知道列表和参数类型,并确保传入的参数根据规范进行验证。

类似地,从一个组件传递到另一个组件的属性也可以根据规范进行验证。该规范以类中名为propTypes的静态对象的形式提供,属性的名称作为键,验证器作为值。验证器是由PropTypes导出的众多常量之一,例如PropTypes.string。当属性为必填项时,可以在数据类型后添加.isRequired。对象PropTypes作为一个名为 prop-types 的模块可用,它可以包含在 CDN 的index.html中,就像我们对 React 本身所做的那样。这一变化如清单 7-27 所示。

...
  <script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
  <script src="https://unpkg.com/prop-types@15/prop-types.js"></script>
...

Listing 7-27ui/public/index.html: Changes to Include PropTypes Library

IssueTableIssueRow组件分别需要一个对象和一个对象数组作为属性。虽然PropTypes支持数组和对象等数据类型,但 ESLint 认为这些太模糊了。相反,必须描述对象的实际形状,这意味着必须指定对象的每个字段及其数据类型,以避免 ESLint 警告。

让我们添加一个更简单的检查来确保IssueAdd被传递给一个createIssue函数。我们需要定义一个IssueAdd.propTypes对象,用createIssue作为键,用PropTypes.func.isRequired作为它的类型。此外,由于PropTypes是一个全局对象(由于包含在 CDN 的脚本中),它必须声明为全局对象以避免 ESLint 错误。清单 7-28 中显示了对App.jsx的这些更改。

...
/* globals React ReactDOM PropTypes */
...
class IssueAdd extends React.Component {
  ...
}

IssueAdd.propTypes = {

  createIssue: PropTypes.func.isRequired,

};

Listing 7-28ui/src/App.jsx: Adding PropType Validation for IssueAdd Component

在运行时,仅在开发模式下检查属性验证,当任何验证失败时,控制台中会显示一条警告。如果您在构造IssueAdd组件时移除了createIssue属性的传递,您将在开发人员控制台中发现以下错误:

Warning: Failed prop type: The prop `createIssue` is marked as required in `IssueAdd`, but its value is `undefined`.
    in IssueAdd (created by IssueList)
    in IssueList

尽管为所有组件添加基于PropTypes的验证是一个好主意,但出于本书的目的,我将跳过这一步。唯一的原因是它使代码更加冗长,可能会分散读者对主要变化的注意力。

摘要

虽然在本章中我们没有给应用添加任何特性,但是我们通过分离 UI 和 API 服务器做了一个大的架构改变。我们讨论了 CORS 的含义,并编写了一个使用代理来处理它的选项。

然后,您看到了如何使应用可配置用于不同的部署环境,如试运行和生产环境。我们还通过添加对遵循编码标准、最佳实践和验证的检查来净化代码。

在下一章中,我们将继续通过模块化代码(即,将单个大文件分割成更小的、可重用的部分)和添加对调试的支持以及开发过程中有用的其他工具来提高开发人员的生产力。

练习答案

练习:UI 服务器

  1. 您应该会在浏览器中看到类似于Cannot GET /的消息。这是 Express 服务器返回的消息,因为不存在/的路由。这本身不是问题,因为 API 的唯一消费者是 web UI,并且在我们的控制之下。另一方面,如果 API 被公开给其他 API,比如 GitHub 的 API,那么返回一条有用的消息来指明真正的 API 端点在哪里,确实会更好。

    另一个选择是将 API 托管在根(/)上,而不是在/graphql上。但是将/graphql作为端点名称可以清楚地表明它是一个 GraphQL API。

练习:多种环境

  1. env.js的内容将显示一个带有UI_API_ENDPOINT属性的对象的 JavaScript 赋值window.ENV。在对环境进行更改后重新启动 UI 服务器将导致内容反映新值。