MongoDB-基础知识-全-

MongoDB 基础知识(全)

原文:zh.annas-archive.org/md5/804E58DCB5DC268F1AD8C416CF504A25

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

关于本书

MongoDB 是处理大型数据集的最流行的数据库技术之一。这本书将帮助 MongoDB 初学者开发创建数据库和高效处理数据的知识和技能。

与其他 MongoDB 书籍不同,MongoDB 基础从一开始就深入探讨了云计算——向您展示如何在第一章中开始使用 Atlas。您将发现如何修改现有数据,向数据库添加新数据,并通过创建聚合管道处理复杂查询。随着学习的深入,您将了解 MongoDB 复制架构并配置一个简单的集群。您还将掌握用户身份验证以及数据备份和恢复技术。最后,您将使用 MongoDB Charts 进行数据可视化。

您将在以实际项目为基础的小型练习和活动中挑战自己,以愉快且可实现的方式进行学习。其中许多小型项目都是围绕电影数据库案例研究展开的,而最后一章则作为一个最终项目,您将使用 MongoDB 解决基于共享单车应用的真实世界问题。

通过本书,您将具备处理大量数据和使用 MongoDB 处理自己项目的技能和信心。

关于作者

Amit Phaltankar是一名软件开发人员和博主,拥有超过 13 年的轻量级和高效软件组件构建经验。他擅长编写基于 Web 的应用程序,并使用传统 SQL、NoSQL 和大数据技术处理大规模数据集。他在各种技术堆栈中有工作经验,热衷于学习和适应新的技术趋势。Amit 对提高自己的技能集充满热情,也喜欢指导和培养同行,并为博客做出贡献。在过去的 6 年中,他有效地利用 MongoDB 以各种方式构建更快的系统。

Juned Ahsan是一位拥有超过 14 年经验的软件专业人士。他为 Cisco、Nuamedia、IBM、Nokia、Telstra、Optus、Pizza Hut、AT&T、Hughes、Altran 等公司和客户构建软件产品和服务。Juned 在从零开始构建不同规模平台的软件产品和架构方面拥有丰富的经验。他热衷于帮助和指导他人,并是 Stack Overflow 的前 1%贡献者。Juned 对认知 CX、云计算、人工智能和 NoSQL 数据库充满热情。

Michael Harrison在澳大利亚电信领导者 Telstra 开始了他的职业生涯。他曾在他们的网络、大数据和自动化团队工作。他现在是 Southbank Software 的首席软件开发人员和创始成员,这是一家位于墨尔本的初创公司,致力于构建下一代数据库技术的工具。作为一名全栈工程师,Michael 领导开发了一个面向 MongoDB 的开源、平台无关的 IDE(dbKoda),以及一个基于 MongoDB 的区块链数据库,名为 ProvenDB。这两款产品都在纽约的 MongoDB World 大会上展出。考虑到 Michael 拥有一双 MongoDB 袜子,可以说他是一位狂热爱好者。

Liviu Nedov是一位资深顾问,拥有 20 多年的数据库技术经验。他为澳大利亚和欧洲的客户提供专业和咨询服务。在他的职业生涯中,他为 Wotif Group、Xstrata Copper/Glencore 和纽卡斯尔大学以及昆士兰能源等客户设计和实施了大型企业项目。他目前在 Data Intensity 工作,这是最大的多云服务提供商,为应用程序、数据库和商业智能提供服务。近年来,他积极参与 MongoDB、NoSQL 数据库项目、数据库迁移和云 DBaaS(数据库即服务)项目。

这本书是为谁写的

MongoDB 基础面向具有基本技术背景的读者,他们是第一次接触 MongoDB。任何数据库、JavaScript 或 JSON 经验都会有所帮助,但不是必需的。MongoDB 基础可能会简要涉及这些技术以及更高级的主题,但不需要背景知识即可从本书中获得价值。

关于章节

第一章MongoDB 简介,包含了 MongoDB 的历史和背景、基本概念,以及设置第一个 MongoDB 实例的指南。

第二章文档和数据类型,将教您有关 MongoDB 数据和命令的关键组件。

第三章服务器和客户端,为您提供了管理 MongoDB 访问和连接所需的信息,包括数据库和集合的创建。

第四章查询文档,是我们进入 MongoDB 核心的地方:查询数据库。本章提供了实际操作的练习,让您使用查询语法、操作符和修饰符。

第五章插入、更新和删除文档,扩展了查询,允许您将查询转换为更新,修改现有数据。

第六章使用聚合管道和数组进行更新,涵盖了更复杂的更新操作,使用管道和批量更新。

第七章数据聚合,演示了 MongoDB 最强大的高级功能之一,允许您创建可重用的复杂查询管道,无法通过更直接的查询解决。

第八章在 MongoDB 中编写 JavaScript,将带您从直接数据库交互到更常见于现实世界的方法:应用程序的查询。在本章中,您将创建一个简单的 Node.js 应用程序,可以与 MongoDB 进行编程交互。

第九章性能,为您提供了确保您的查询有效运行的信息和工具,主要是通过使用索引和执行计划。

第十章复制,更详细地研究了您可能在生产环境中遇到的标准 MongoDB 配置,即集群和副本集。

第十一章备份和恢复,涵盖了作为管理数据库冗余和迁移的一部分所需的信息。这对于数据库管理至关重要,但也对加载样本数据和开发生命周期有用。

第十二章数据可视化,解释了如何将原始数据转化为有意义的可视化,有助于发现和传达数据中的见解。

第十三章MongoDB 案例研究,是一个课程结束的案例研究,将在一个真实的例子中整合前几章涵盖的所有技能。

约定

文本形式的代码词、数据库和集合名称、文件和文件夹名称、shell 命令和用户输入使用以下格式:“db.myCollection.findOne()命令将返回myCollection中的第一个文档。”

较小的示例代码块及其输出将以以下格式进行格式化:

use sample_mflix
var pipeline = []
var options  = {}
var cursor   = db.movies.aggregate(pipeline, options);

在大多数情况下,输出是一个单独的块,将以图的形式进行格式化,如下所示:

图 0.1:输出作为一个图

图 0.1:输出作为一个图

通常,在章节开始时,会介绍一些关键的新术语。在这些情况下,将使用以下格式:“aggregate命令在集合上操作,就像其他创建、读取、更新、删除CRUD)命令一样。”

开始之前

如前所述,MongoDB 不仅仅是一个数据库。它是一个庞大而分散的工具和库集。因此,在我们全力投入 MongoDB 之前,最好确保我们已经为冒险做好了充分的准备。

安装 MongoDB

  1. www.mongodb.com/try/download/community下载 MongoDB Community tarball(tgz)。在“可用下载”部分,选择当前(4.4.1)版本,您的平台,并单击“下载”。

  2. 将下载的tgz文件放入您选择的任何文件夹中并进行提取。在基于 Linux 的操作系统(包括 macOS)上,可以使用命令提示符将tgz文件提取到文件夹中。打开终端,导航到您复制tgz文件的目录,并发出以下命令:

     tar -zxvf mongodb-macos-x86_64-4.4.1.tgz

请注意,tgz的名称可能会根据您的操作系统和下载的版本而有所不同。如果您查看提取的文件夹,您将找到所有 MongoDB 二进制文件,包括mongodmongo,都放在bin目录中。

  1. 可执行文件,如mongodmongo,分别是 MongoDB 数据库和 Mongo Shell 的启动器。要能够从任何位置启动它们,您需要将这些命令添加到PATH变量中,或将二进制文件复制到/usr/local/bin目录中。或者,您可以将二进制文件保留在原地,并在/usr/local/bin目录中创建这些二进制文件的符号链接。要创建符号链接,您需要打开终端,导航到 MongoDB 安装目录,并执行此命令:
     sudo ln -s /full_path/bin/* /usr/local/bin/
  1. 要在本地运行 MongoDB,您必须创建一个数据目录。执行下一个命令并在任何您想要的文件夹中创建数据目录:
     mkdir -p ~/mytools/mongodb
  1. 要验证安装是否成功,请在本地运行 MongoDB。为此,您需要使用mongo命令并提供数据目录的路径:
     mongod --dbpath ~/mytools/mongodb

执行此命令后,MongoDB 将在默认端口27017上启动,并且您应该看到 MongoDB 引导日志;最后一行包含msg:“等待连接”,这表明数据库已启动并正在等待客户端(例如 Mongo shell)进行连接。

  1. 最后,您需要通过将其连接到数据库来验证 Mongo shell。下一个命令用于使用默认配置启动 Mongo shell:
mongo

执行此命令后,您应该看到 shell 提示已启动。默认情况下,shell 连接到运行在localhost 27017端口上的数据库。在接下来的章节中,您将学习如何将 shell 连接到 MongoDB Atlas 集群。

  1. 在 MongoDB 的官方安装手册中可以找到有关在 Windows 或任何特定操作系统上安装 MongoDB 的详细说明,该手册位于docs.mongodb.com/manual/installation/

编辑器和 IDE

MongoDB shell 允许您通过简单地在控制台中键入命令来直接与数据库交互。但是,这种方法只能让您走得更远,并且随着执行更高级操作,它最终会变得更加繁琐。因此,我们建议准备一个文本编辑器来编写您的命令,然后可以将这些命令复制到 shell 中。尽管任何文本编辑器都可以使用,但如果您还没有偏好,我们建议使用 Visual Studio Code,因为它具有一些对 MongoDB 有帮助的插件。也就是说,您熟悉的任何工具都足够用于本书。

此外,还有许多 MongoDB 工具可以帮助您顺利进行学习。我们不建议特定工具作为学习的最佳方式,但我们建议在网上搜索一些工具和插件,这些工具和插件可以在学习过程中为您提供额外的价值。

下载和安装 Visual Studio Code

让我们继续使用适当的 JavaScript IDE 进行设置。当然,您可以选择任何您喜欢的,但我们将在最初的章节中坚持使用 Visual Studio Code。这是一个专门针对 Web 技术的易于使用的编辑器,并且适用于所有主要操作系统:

  1. 首先,您需要获取安装包。这可以通过不同的方式完成,取决于您的操作系统,但最直接的方法是访问 Visual Studio Code 网站,网址是code.visualstudio.com/

  2. 该网站应该检测到您的操作系统,并向您呈现一个按钮,允许直接下载稳定版本。当然,您可以通过单击下拉箭头选择不同的版本以获得其他选项:图 0.2:Visual Studio Code 下载提示

图 0.2:Visual Studio Code 下载提示

  1. 下载后,安装将取决于您的操作系统。同样,根据您选择的操作系统,安装将略有不同。

.ZIP存档。您需要解压该包以显示.APP应用程序文件。

.EXE文件已下载到您的本地计算机。

.DEB.RPM包下载到您的本地环境。

  1. 下载了安装程序包后,现在您必须运行一个依赖于我们选择的操作系统的安装例程:

.APPApplications文件夹。这将使其通过 macOS 界面实用程序可用,例如.DEB.RPM包。

  1. 安装 Visual Studio Code 后,您现在只需要将其固定到任务栏Dock或任何其他操作系统机制,以便快速轻松地访问该程序。

就是这样。Visual Studio Code 现在可以使用了。

到目前为止,我们已经看到了当今在使用 JavaScript 时可用的各种集成开发环境。我们还下载并安装了 Visual Studio Code,这是微软的现代 JavaScript 集成开发环境。现在我们将看到,在开始新的 JavaScript 项目时,使用适当的文件系统准备是非常重要的。

下载 Node.js

Node.js 是开源的,您可以从其官方网站下载所有平台的 Node.js。它支持所有三个主要平台:Windows、Linux 和 macOS。

Windows

访问它们的官方网站并下载最新的稳定.msi安装程序。这个过程非常简单。只需执行.msi文件并按照说明在系统上安装它。会有一些关于接受许可协议的提示。您必须接受这些提示,然后点击完成。就是这样。

Mac

Windows 和 Mac 的安装过程非常相似。您需要从官方网站下载.pkg文件并执行它。然后,按照说明进行操作。您可能需要接受许可协议。之后,按照提示完成安装过程。

Linux

要在 Linux 上安装 Node.js,请按照提到的顺序执行以下命令:

  • $ cd /tmp

  • $ wget http://nodejs.org/dist/v8.11.2/node-v8.11.2-linux-x64.tar.gz

  • $ tar xvfz node-v8.11.2-linux-x64.tar.gz

  • $ sudo mkdir -p /usr/local/nodejs

  • $ sudo mv node-v8.11.2-linux-x64/* /usr/local/nodejs

请注意,只有在以管理员身份登录时,您才需要在最后两个命令中使用sudo。在这里,您首先将当前活动目录更改为系统的临时目录(tmp)。其次,您从官方发布目录下载nodetar包。第三,您将tar包解压到tmp目录。该目录包含所有已编译和可执行文件。第四,您在系统中为Node.js创建一个目录。在最后一个命令中,您将包的所有已编译和可执行文件移动到该目录。

验证安装

安装完成后,您可以通过执行以下命令来验证系统上是否正确安装了它:

$ node -v && npm -v

它将输出当前安装的 Node.js 和 npm 的版本:

图 0.3:Node.js 和 npm 的已安装版本

图 0.3:Node.js 和 npm 的已安装版本

这里显示系统上安装了 Node.js 的 8.11.2 版本,以及 npm 的 5.6.0 版本。

安装代码包

从 GitHub 上下载代码文件,网址为github.com/PacktPublishing/MongoDB-Fundamentals。这里的文件包含每章的练习、活动和一些中间代码。当您遇到困难时,这可能是一个有用的参考。

您可以使用“下载 ZIP”选项将完整的代码下载为 ZIP 文件。或者,您可以使用git命令来检出存储库,如下面的代码片段所示:

git clone https://github.com/PacktPublishing/MongoDB-Fundamentals.git

联系我们

我们始终欢迎读者的反馈意见。

customercare@packtpub.com

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误是难免的。如果您在本书中发现了错误,我们将不胜感激地接受您的报告。请访问www.packtpub.com/support/errata 并填写表格。

copyright@packt.com 并附上材料的链接。

如果您有兴趣成为作者:如果您在某个专业领域有专长,并且有兴趣撰写或为一本书作出贡献,请访问authors.packtpub.com

请留下评论

请通过在亚马逊上留下详细、公正的评论来告诉我们您的想法。我们感谢所有的反馈意见 - 它帮助我们继续制作出优秀的产品,并帮助有抱负的开发者提升他们的技能。请花几分钟时间给出您的想法 - 这对我们来说意义重大。

第一章:MongoDB 简介

概述

本章将介绍 MongoDB 的基础知识,首先定义数据及其类型,然后探讨数据库如何解决数据存储挑战。您将了解不同类型的数据库以及如何为您的任务选择合适的数据库。一旦您对这些概念有了清晰的理解,我们将讨论 MongoDB、其特性、架构、许可和部署模型。到本章结束时,您将通过 Atlas(用于管理 MongoDB 的基于云的服务)获得使用 MongoDB 的实际经验,并与其基本元素(如数据库、集合和文档)一起工作。

介绍

数据库是一种安全、可靠且易于获取数据的平台。通常有两种类型的数据库:关系数据库和非关系数据库。非关系数据库通常被称为 NoSQL 数据库。NoSQL 数据库用于存储大量复杂和多样化的数据,如产品目录、日志、用户互动、分析等。MongoDB 是最成熟的 NoSQL 数据库之一,具有数据聚合、ACID(原子性、一致性、隔离性、持久性)事务、水平扩展和图表等功能,我们将在接下来的部分中详细探讨。

数据对于企业至关重要,特别是在存储、分析和可视化数据以做出数据驱动决策时。正因如此,像谷歌、Facebook、Adobe、思科、eBay、SAP、EA 等公司都信任并使用 MongoDB。

MongoDB 有不同的变体,可用于实验和实际应用。由于其直观的查询和命令语法,它比大多数其他数据库更容易设置和管理。MongoDB 可供任何人在自己的机器上安装,也可作为托管服务在云上使用。MongoDB 的云托管服务(名为 Atlas)对所有人免费开放,无论您是已建立的企业还是学生。在我们开始讨论 MongoDB 之前,让我们先了解数据库管理系统。

数据库管理系统

数据库管理系统(DBMS)提供存储和检索数据的能力。它使用查询语言来创建、更新、删除和检索数据。让我们来看看不同类型的 DBMS。

关系数据库管理系统

关系数据库管理系统(RDBMS)用于存储结构化数据。数据以表格形式存储,包括行和列。表格可以与其他表格建立关系,以描述实际的数据关系。例如,在大学关系数据库中,“学生”表可以通过共同的列(如 courseId)与“课程”和“成绩”表相关联。

NoSQL 数据库管理系统

NoSQL 数据库是为解决存储非结构化和半结构化数据的问题而发明的。关系数据库要求在存储数据之前定义数据的结构。这种数据库结构定义通常称为模式,涉及数据实体及其属性和类型。RDBMS 客户端应用程序与模式紧密耦合。很难修改模式而不影响客户端。相比之下,NoSQL 数据库允许您在没有模式的情况下存储数据,并支持动态模式,这使客户端与严格的模式解耦,对于现代和实验性应用程序通常是必要的。

存储在 NoSQL 数据库中的数据因提供者而异,但通常以文档而不是表的形式存储。例如,库存管理数据库可以具有不同的产品属性,因此需要灵活的结构。同样,存储来自不同来源的数据的分析数据库也需要灵活的结构。

比较

让我们根据以下因素比较 NoSQL 数据库和 RDBMS。当您阅读本书时,您将深入了解这些内容。现在,以下表格提供了基本概述:

图 1.1:关系数据库和 NoSQL 之间的区别

图 1.1:关系数据库和 NoSQL 之间的区别

这就结束了我们关于数据库和各种数据库类型之间的差异的讨论。在下一节中,我们将开始探索 MongoDB。

MongoDB 简介

MongoDB 是一种流行的 NoSQL 数据库,可以存储结构化和非结构化数据。该组织于 2007 年由 Kevin P. Ryan、Dwight Merriman 和 Eliot Horowitz 在纽约创立,最初被称为 10gen,后来更名为 MongoDB——这个词受到了“巨大”的启发。

它提供了存储真实世界大数据所需的基本和奢华功能。其基于文档的设计使其易于理解和使用。它旨在用于实验和真实世界应用,并且比大多数其他 NoSQL 数据库更容易设置和管理。其直观的查询和命令语法使其易于学习。

以下列表详细探讨了这些功能:

  • 灵活和动态的模式:MongoDB 允许数据库使用灵活的模式。灵活的模式允许不同文档中的字段变化。简而言之,数据库中的每个记录可能具有相同数量的属性,也可能没有。它解决了存储不断发展的数据的需求,而无需对模式本身进行任何更改。

  • 丰富的查询语言:MongoDB 支持直观且丰富的查询语言,这意味着简单而强大的查询。它配备了丰富的聚合框架,允许您根据需要对数据进行分组和过滤。它还具有内置的通用文本搜索和特定用途,如地理空间搜索的支持。

  • 多文档 ACID 事务原子性、一致性、完整性和耐久性ACID)是允许存储和更新数据以保持其准确性的功能。事务用于组合需要一起执行的操作。MongoDB 支持单个文档和多文档事务中的 ACID。

  • 原子性意味着全部或无,这意味着事务中的所有操作要么全部发生,要么全部不发生。这意味着如果其中一个操作失败,那么所有已执行的操作都将被回滚,以使事务操作受影响的数据保持在事务开始之前的状态。

  • 事务中的一致性意味着根据数据库定义的规则保持数据一致。如果事务违反任何数据库一致性规则,则必须回滚。

  • 隔离性强制在隔离中运行事务,这意味着事务不会部分提交数据,事务外的任何值只有在所有操作执行并完全提交后才会发生变化。

  • 耐久性确保事务提交的更改。因此,如果事务已执行,则数据库将确保即使系统崩溃,更改也会被提交。

  • 高性能:MongoDB 使用嵌入式数据模型提供高性能,以减少磁盘 I/O 使用。此外,对不同类型的数据进行索引的广泛支持使查询更快。索引是一种维护索引中相关数据指针的机制,就像书中的索引一样。

  • 高可用性:MongoDB 支持最少三个节点的分布式集群。集群是指使用多个节点/机器进行数据存储和检索的数据库部署。故障转移是自动的,数据在辅助节点上异步复制。

  • 可扩展性:MongoDB 提供了一种在数百个节点上水平扩展数据库的方法。因此,对于所有的大数据需求,MongoDB 是完美的解决方案。通过这些,我们已经了解了 MongoDB 的一些基本特性。

注意

MongoDB 1.0 于 2009 年 2 月首次正式发布为开源数据库。此后,该软件已经发布了几个稳定版本。有关不同版本和 MongoDB 演变的更多信息,请访问官方 MongoDB 网站(www.mongodb.com/evolved)。

MongoDB 版本

MongoDB 有两个不同的版本,以满足开发人员和企业的需求,如下:

社区版:社区版是为开发人员社区发布的,供那些想要学习和获得 MongoDB 实践经验的人使用。社区版是免费的,可在 Windows、Mac 和不同的 Linux 版本上安装,如 Red Hat、Ubuntu 等。您可以在社区服务器上运行生产工作负载;但是,对于高级企业功能和支持,您必须考虑付费的企业版。

企业版:企业版使用与社区版相同的基础软件,但附带一些额外的功能,包括以下内容:

  • 安全性轻量级目录访问协议LDAP)和 Kerberos 身份验证。LDAP 是一种允许来自外部用户目录的身份验证的协议。这意味着您不需要在数据库中创建用户来进行身份验证,而是可以使用外部目录,如企业用户目录。这样可以节省大量时间,不需要在不同系统中复制用户,如数据库。

  • 内存存储引擎:提供高吞吐量和低延迟。

  • 加密存储引擎:这使您可以加密静态数据。

  • SNMP 监控:集中的数据收集和聚合。

  • 系统事件审计:这使您可以以 JSON 格式记录事件。

将社区版迁移到企业版

MongoDB 允许您将社区版升级为企业版。这对于您最初使用社区版并最终构建了现在适合商业用途的数据库的情况很有用。对于这种情况,您可以简单地将社区版升级为企业版,而不是安装企业版并重新构建数据库,从而节省时间和精力。有关升级的更多信息,请访问此链接:docs.mongodb.com/manual/administration/upgrade-community-to-enterprise/

MongoDB 部署模型

MongoDB 可以在多种平台上运行,包括 Windows、macOS 和不同版本的 Linux。您可以在单台机器或多台机器上安装 MongoDB。多台机器安装提供了高可用性和可扩展性。以下列表详细介绍了每种安装类型:

独立版

独立安装是单机安装,主要用于开发或实验目的。您可以参考前言中有关在系统上安装 MongoDB 的步骤。

副本集

在 MongoDB 中,复制集是一组进程或服务器,它们共同工作以提供数据冗余和高可用性。将 MongoDB 作为独立进程运行并不是非常可靠的,因为由于连接问题和磁盘故障,您可能会丢失对数据的访问。使用复制集可以解决这些问题,因为数据副本存储在多个服务器上。集群中至少需要三台服务器。这些服务器被配置为主服务器、次要服务器或仲裁者。您将在第九章“复制”中了解更多关于复制集及其好处的信息。

分片

分片部署允许您以分布式方式存储数据。它们适用于管理大量数据并期望高吞吐量的应用程序。一个分片包含数据的一个子集,每个分片必须使用复制集来提供其持有的数据的冗余。多个分片共同工作提供了一个分布式和复制的数据集。

管理 MongoDB

MongoDB 为用户提供了两种选择。根据您的需求,您可以在自己的系统上安装并自己管理数据库,或者利用 MongoDB(Atlas)提供的数据库即服务(DBaaS)选项。让我们更多地了解这两种选择。

自管理

MongoDB 可以下载并安装在您的机器上。机器可以是工作站、服务器、数据中心中的虚拟机,或者在云上。您可以将 MongoDB 安装为独立、复制集或分片集群。所有这些部署都适用于社区版和企业版。每种部署都有其优势和相关的复杂性。自管理的数据库可以用于您希望更精细地控制数据库或者只是想学习数据库管理和操作的场景。

托管服务:数据库即服务

托管服务是将一些流程、功能或部署外包给供应商的概念。DBaaS 是一个通常用于外包给外部供应商的数据库的术语。托管服务实施了一个共享责任模型。服务提供商管理基础设施,即安装、部署、故障转移、可伸缩性、磁盘空间、监控等。您可以管理数据和安全性、性能和调整设置。它允许您节省管理数据库的时间,专注于其他事情,比如应用程序开发。

在这一部分,我们了解了 MongoDB 的历史和发展。我们还了解了 MongoDB 的不同版本以及它们之间的区别。我们通过学习 MongoDB 的部署和管理方式来结束了这一部分。

MongoDB Atlas

MongoDB Atlas 是 MongoDB Inc.提供的数据库即服务(DBaaS)产品。它允许您在云上提供数据库作为服务,可以用于您的应用程序。Atlas 使用来自不同云供应商的云基础设施。您可以选择要部署数据库的云供应商。与任何其他托管服务一样,您可以获得高可用性、安全的环境,并且几乎不需要或根本不需要维护。

MongoDB Atlas 的好处

让我们来看看 MongoDB Atlas 的一些好处。

  • 简单设置:在 Atlas 上设置数据库很容易,只需几个步骤即可完成。Atlas 在幕后运行各种自动化任务来设置您的多节点集群。

  • 保证可用性:Atlas 每个复制集至少部署三个数据节点或服务器。每个节点都部署在不同的可用区(Amazon Web Services(AWS))、故障域(Microsoft Azure)或区域(Google Cloud Platform(GCP))。这样可以实现高可用性设置,并在发生故障或例行更新时保持持续的正常运行时间。

  • 全球覆盖:MongoDB Atlas 在 AWS、GCP 和 Microsoft Azure 云中的不同区域都可用。对不同区域的支持允许您选择一个离您更近的区域进行低延迟读写。

  • 最佳性能:MongoDB 的创始人管理 Atlas,并利用他们的专业知识和经验来保持 Atlas 中的数据库运行良好。此外,单击升级可用于升级到最新版本的 MongoDB。

  • 高度安全:默认实施安全最佳实践,例如独立的 VPC(虚拟私有云)、网络加密、访问控制和防火墙以限制访问。

  • 自动备份:您可以配置具有可定制计划和数据保留策略的自动备份。安全备份和恢复可用于在不同版本的数据库之间进行切换。

云提供商

MongoDB Atlas 目前支持三个云提供商,分别是AWSGCPMicrosoft Azure

可用区

可用区AZs)是一组物理数据中心,距离较近,配备有计算、存储或网络资源。

区域

区域是一个地理区域,例如悉尼、孟买、伦敦等。一个区域通常包括两个或两个以上的 AZs。这些 AZs 通常位于彼此相距较远的不同城市/城镇,以提供在发生自然灾害时的容错能力。容错能力是系统在某一部分出现问题时仍然能够继续运行的能力。就 AZs 而言,如果一个 AZ 由于某种原因而宕机,另一个 AZ 仍应能够提供服务。

MongoDB 支持的区域和可用区

MongoDB Atlas 允许您在 AWS、GCP 和 Azure 的多云全球基础设施中部署数据库。它使 MongoDB 能够支持大量的区域和 AZs。此外,随着云提供商不断增加,支持的区域和 AZs 的数量也在不断增加。请参考官方 MongoDB 网站上关于云提供商区域支持的链接:

Atlas 套餐

要在 MongoDB Atlas 中构建数据库集群,您需要选择一个套餐。套餐是您从集群中获得的数据库功率级别。在 Atlas 中配置数据库时,您会得到两个参数:RAM 和存储空间。根据您对这些参数的选择,将配置适当数量的数据库功率。您的集群成本与 RAM 和存储的选择相关联;更高的选择意味着更高的成本,更低的选择意味着更低的成本。

M0 是 MongoDB Atlas 中的免费套餐,提供 512MB 的共享 RAM 和存储空间。这是我们用于学习目的的套餐。免费套餐并非在所有区域都可用,因此如果在您的区域找不到它,请选择最接近的免费套餐区域。数据库的接近程度决定了操作的延迟。

选择套餐需要了解您的数据库使用情况以及您愿意花费多少。配置不足的数据库可能会在高峰使用时耗尽应用程序的容量,并可能导致应用程序错误。配置过多的数据库可以帮助应用程序表现良好,但成本更高。使用云数据库的优势之一是您可以根据需要随时修改集群大小。但您仍然需要找到适合您日常数据库使用的最佳容量。确定最大并发连接数是一个关键决策因素,可以帮助您为您的用例选择适当的 MongoDB Atlas 套餐。让我们看看不同的可用套餐:

图 1.2:MongoDB Atlas 套餐配置

图 1.2:MongoDB Atlas 层配置

MongoDB Atlas 定价

容量规划是必不可少的,但估算数据库集群的成本也很重要。我们了解到 M0 集群是免费的,资源很少,非常适合原型设计和学习目的。对于付费的集群层,Atlas 会按小时收费。总成本包括多个因素,如服务器的类型和数量。让我们看一个示例,了解 Atlas 上 M30 类型副本集(三台服务器)的成本估算。

集群成本估算

让我们尝试了解如何估算您的 MongoDB Atlas 集群的成本。按照以下方式确定集群需求:

  • 机器类型:M30

  • 服务器数量:3(副本集)

  • 运行时间:每天 24 小时

  • 估算时间段:1 个月

一旦我们确定了我们的需求,估算成本可以计算如下:

  • 每小时运行单个 M30 服务器的成本:$0.54

  • 服务器运行的小时数:24(小时)x 30(天)= 720

  • 一个月单台服务器的成本:720 x 0.54 = $388.8

  • 运行三台服务器集群的成本:388.8 x 3 = $1166.4

因此,总成本应该降至$1166.4。

注意

除了集群的运行成本,您还应考虑额外服务的成本,如备份、数据传输和支持合同的成本。

让我们通过以下练习在一个示例场景中实施我们的学习。

练习 1.01:设置 MongoDB Atlas 帐户

MongoDB Atlas 为您提供免费注册以设置免费集群。在这个练习中,您将通过执行以下步骤创建一个帐户:

  1. 转到www.mongodb.com并单击“开始免费”。将出现以下窗口:图 1.3:MongoDB Atlas 主页

图 1.3:MongoDB Atlas 主页

  1. 您可以使用您的 Google 帐户注册,也可以手动提供您的详细信息,如下图所示。在相应的字段中提供您的使用情况、“您的工作电子邮件”、“名字”、“姓氏”和“密码”详细信息,选择同意服务条款的复选框,然后单击“开始免费”。图 1.4:开始页面

图 1.4:开始页面

将出现以下窗口,您可以在其中输入组织和项目详细信息:

图 1.5:输入组织和项目详细信息的页面

图 1.5:输入组织和项目详细信息的页面

接下来,您应该看到以下页面,这意味着您的帐户已成功创建:

图 1.6:确认页面

图 1.6:确认页面

在这个练习中,您成功地创建了您的 MongoDB 帐户。

MongoDB Atlas 组织、项目、用户和集群

MongoDB Atlas 对您的环境强制执行基本结构。这包括组织、项目、用户和集群的概念。MongoDB 提供了一个默认组织和一个项目,以帮助您轻松入门。本节将教您这些实体的含义以及如何设置它们。

组织

MongoDB Atlas 组织是您帐户中的顶级实体,包含其他元素,如项目、集群和用户。在任何其他资源之前,您需要首先设置一个组织。

练习 1.02:设置 MongoDB Atlas 组织

您已成功在 MongoDB Atlas 上创建了一个帐户,在这个练习中,您将根据自己的偏好设置一个组织:

  1. 登录到您在练习 1.01中创建的 MongoDB 帐户,设置 MongoDB Atlas 帐户。要创建一个组织,请从您的帐户菜单中选择“组织”选项,如下图所示:图 1.7:用户选项-组织

图 1.7:用户选项-组织

  1. 您将在组织列表中看到默认组织。要创建一个新组织,请单击右上角的“创建新组织”按钮:图 1.8:组织列表

图 1.8:组织列表

  1. 在“命名您的组织”字段中输入组织名称。将“云服务”默认选择为MongoDB Atlas。单击“下一步”以继续下一步:图 1.9:组织名称

图 1.9:组织名称

您将看到以下屏幕:

图 1.10:创建组织页面

图 1.10:创建组织页面

  1. 您将看到您的登录作为“组织所有者”。将一切保持默认设置,然后单击“创建组织”。

成功创建组织后,将出现以下“项目”屏幕:

图 1.11:项目页面

图 1.11:项目页面

因此,在本练习中,您已成功为您的 MongoDB 应用程序创建了组织。

项目

项目为特定目的提供了集群和用户的分组;例如,您想要将您的实验室、演示和生产环境进行分隔。同样,您可能希望为不同的环境设置不同的网络、区域和用户。项目允许您根据自己的组织需求进行分组。在下一个练习中,您将创建一个项目。

练习 1.03:创建 MongoDB Atlas 项目

在本练习中,您将使用以下步骤在 MongoDB Atlas 上设置一个项目:

  1. 一旦您在练习 1.02中创建了一个组织,下次登录时将会出现“项目”屏幕。单击“新建项目”:图 1.12:项目页面

图 1.12:项目页面

  1. 在“命名您的项目”选项卡上为您的项目提供一个名称。将项目命名为myMongoProject。单击“下一步”:图 1.13:创建项目页面

图 1.13:创建项目页面

  1. 单击“创建项目”。“添加成员和设置权限”页面不是必需的,因此将其保留为默认设置。您的名称应该显示为“项目所有者”:图 1.14:为项目添加成员并设置权限

图 1.14:为项目添加成员并设置权限

您的项目现在已经设置好。设置集群的启动画面如下图所示:

图 1.15:集群页面

图 1.15:集群页面

现在您已经创建了一个项目,您可以创建您的第一个 MongoDB 云部署。

MongoDB 集群

MongoDB 集群是 MongoDB Atlas 中用于数据库副本集或共享部署的术语。集群是用于数据存储和检索的一组分布式服务器。MongoDB 集群在最低级别上是一个由三个节点组成的副本集。在分片环境中,单个集群可能包含数百个节点/服务器,每个节点/服务器包含不同的副本集,每个副本集由至少三个节点/服务器组成。

练习 1.04:在 Atlas 上设置您的第一个免费 MongoDB 集群

在本节中,您将在 Atlas 免费版(M0)上设置您的第一个 MongoDB 副本集。以下是执行此操作的步骤:

  1. 前往www.mongodb.com/cloud/atlas并使用练习 1.01中使用的凭据登录您的账户,设置 MongoDB Atlas 账户。将出现以下屏幕:图 1.16:集群页面

图 1.16:集群页面

  1. 单击“构建集群”以配置您的集群:图 1.17:构建集群页面

图 1.17:构建集群页面

将出现以下集群选项:

图 1.18:可用的集群选项

图 1.18:可用的集群选项

  1. 选择标记为“免费”的“共享集群”选项,如前图所示。

  2. 将呈现一个集群配置屏幕,以选择集群的不同选项。选择您选择的云提供商。在本练习中,您将使用 AWS,如下图所示:图 1.19:选择云提供商和区域

图 1.19:选择云提供商和区域

  1. 选择最靠近您位置且免费的推荐区域。在本例中,您将选择悉尼,如下图所示:图 1.20:选择推荐的区域

图 1.20:选择推荐的区域

在区域选择页面上,您将根据您的选择看到您的集群设置。Cluster Tier将是M0 Sandbox(Shared RAM, 512 MB storage)Additional Settings将是MongoDB 4.2 No BackupCluster Name将是Cluster0

图 1.21:集群的附加设置

图 1.21:集群的附加设置

  1. 确保在前面的步骤中正确进行选择,以便成本显示为“免费”。与前面步骤中推荐的选择不同的任何选择都可能为您的集群增加成本。点击“创建集群”:图 1.22:免费套餐通知

图 1.22:免费套餐通知

屏幕上会出现“正在创建您的集群…”的成功消息。通常需要几分钟来设置集群:

图 1.23:MongoDB 集群正在创建

图 1.23:正在创建 MongoDB 集群

几分钟后,您应该会看到您的新集群,如下图所示:

图 1.24:MongoDB 集群已创建

图 1.24:MongoDB 集群已创建

您已成功创建了一个新的集群。

连接到您的 MongoDB Atlas 集群

以下是连接到在云上运行的 MongoDB Atlas 集群的步骤:

  1. 转到account.mongodb.com/account/login。将出现以下窗口:图 1.25:MongoDB Atlas 登录页面

图 1.25:MongoDB Atlas 登录页面

  1. 提供您的电子邮件地址并点击“下一步”:图 1.26:MongoDB Atlas 登录页面(密码)

图 1.26:MongoDB Atlas 登录页面(密码)

  1. 现在输入您的密码并点击登录Clusters窗口将如下图所示出现:图 1.27:MongoDB Atlas 集群屏幕

图 1.27:MongoDB Atlas 集群屏幕

  1. 点击Cluster0下的CONNECT按钮。它将打开一个模态屏幕,如下所示:图 1.28:MongoDB Atlas 模态屏幕

图 1.28:MongoDB Atlas 模态屏幕

在连接到集群之前的第一步是将您的 IP 地址加入白名单。MongoDB Atlas 具有默认启用的内置安全功能,它会阻止从任何地方连接到数据库。因此,需要将客户端 IP 加入白名单才能连接到数据库。

  1. 点击“添加您当前的 IP 地址”以将您的 IP 地址加入白名单,如下图所示:图 1.29:添加您当前的 IP 地址

图 1.29:添加您当前的 IP 地址

  1. 屏幕上将显示您当前的 IP 地址;只需点击“添加 IP 地址”按钮。如果您希望将更多 IP 地址添加到白名单中,可以通过点击“添加不同的 IP 地址”选项手动添加(见上图):图 1.30:添加您当前的 IP 地址

图 1.30:添加您当前的 IP 地址

一旦 IP 被加入白名单,将出现以下消息:

图 1.31:IP 已加入白名单的消息

图 1.31:IP 已加入白名单的消息

  1. 要创建一个新的 MongoDB 用户,请为新用户提供用户名密码,然后点击“创建数据库用户”按钮以创建用户,如下图所示:图 1.32:创建 MongoDB 用户

图 1.32:创建 MongoDB 用户

一旦详细信息成功更新,将出现以下屏幕:

图 1.33:MongoDB 用户创建屏幕

图 1.33:MongoDB 用户创建屏幕

  1. 要选择连接方法,请单击“选择连接方法”按钮。选择如下所示的使用 mongo shell 连接的选项:图 1.34:选择连接类型

图 1.34:选择连接类型

  1. 通过选择工作站/客户端机器的选项来下载和安装 mongo shell,如下截图所示:图 1.35:安装 mongo shell

图 1.35:安装 mongo shell

mongo shell 是连接到 Mongo 服务器的命令行客户端。您将在整本书中使用这个客户端,因此安装它是至关重要的。

  1. 安装了 mongo shell 后,运行您在上一步中获取的连接字符串以连接到您的数据库。提示时,请输入您在上一步中为 MongoDB 用户使用的密码:图 1.36:安装 mongo shell

图 1.36:安装 mongo shell

如果一切顺利,您应该看到 mongo shell 已连接到您的 Atlas 集群。以下是连接字符串执行的示例输出:

图 1.37:连接字符串执行的输出

图 1.37:连接字符串执行的输出

忽略图 1.37中看到的警告。最后,您应该看到您的集群名称和命令提示符。您可以运行show databases命令来列出现有的数据库。您应该看到 MongoDB 用于管理目的的两个数据库。以下是show databases命令的一些示例输出:

MongoDB Enterprise Cluster0-shard-0:PRIMARY> show databases
admin  0.000GB
local  4.215GB

您已成功连接到 MongoDB Atlas 实例。

MongoDB 元素

让我们深入了解 MongoDB 的一些非常基本的元素,如数据库、集合和文档。数据库基本上是集合的聚合,而集合又由文档组成。文档是 MongoDB 中的基本构建块,包含以键值格式存储的各种字段的信息。

文档

MongoDB 将数据记录存储在文档中。文档是一组字段名称和值,以JavaScript 对象表示法JSON)类似的格式结构化。JSON 是一种易于理解的键值对格式,用于描述数据。MongoDB 中的文档存储为 JSON 类型的扩展,称为 BSON(二进制 JSON)。它是 JSON 样式文档的二进制编码序列化。BSON 设计为比标准 JSON 更有效地利用空间。BSON 还包含扩展,允许表示无法在 JSON 中表示的数据类型。我们将在第二章文档和数据类型中详细讨论这些。

文档结构

MongoDB 文档包含字段和值对,并遵循基本结构,如下所示:

{
     "firstFieldName": firstFieldValue,
     "secondFieldName": secondFieldValue,
     …
     "nthFieldName": nthFieldValue
}

以下是一个包含有关个人详细信息的文档示例:

{
    "_id":ObjectId("5da26111139a21bbe11f9e89"),
    "name":"Anita P",
    "placeOfBirth":"Koszalin",
    "profession":"Nursing"
}

以下是另一个包含来自 BSON 的一些字段和日期类型的示例:

{
    "_id" : ObjectId("5da26553fb4ef99de45a6139"),
    "name" : "Roxana",
    "dateOfBirth" : new Date("Dec 25, 2007"),
    "placeOfBirth" : "Brisbane",
    "profession" : "Student"
}

以下文档示例包含一个数组和一个子文档。数组是一组值,当您需要为爱好等键存储多个值时可以使用。子文档允许您将相关属性包装在一个文档中,以对抗一个键,如地址:

{
    "_id" : ObjectId("5da2685bfb4ef99de45a613a"),
    "name" : "Helen",
    "dateOfBirth" : new Date("Dec 25, 2007"),
    "placeOfBirth" : "Brisbane",
    "profession" : "Student",
    "hobbies" : [
     "painting",
     "football",
     "singing",
     "story-writing"],
    "address" : {
     "city" : "Sydney",
    "country" : "Australia",
    "postcode" : 2161
  }
}

在上面片段中显示的_id字段是由 MongoDB 自动生成的,用作文档的唯一标识符。我们将在接下来的章节中了解更多关于这个。

集合

在 MongoDB 中,文档存储在集合中。集合类似于关系数据库中的表。您需要在查询中使用集合名称进行操作,如插入、检索、删除等。

理解 MongoDB 数据库

数据库是一组集合的容器。每个数据库在文件系统上有几个文件,这些文件包含数据库元数据和集合中存储的实际数据。MongoDB 允许您拥有多个数据库,每个数据库可以有各种集合。反过来,每个集合可以有许多文档。这在下图中有所说明,显示了一个包含不同事件相关字段的事件数据库,如PersonLocationEvents;这些又包含各种具体数据的文档:

图 1.38:MongoDB 数据库的图示表示

图 1.38:MongoDB 数据库的图示表示

创建一个数据库

在 MongoDB 中创建数据库非常简单。执行 mongo shell 中的use命令,如下所示,将yourDatabaseName替换为您自己选择的数据库名称:

use yourDatabaseName

如果数据库不存在,Mongo 将创建数据库并将当前数据库切换到新数据库。如果数据库存在,Mongo 将引用现有数据库。以下是最后一个命令的输出:

switched to db yourDatabaseName

注意

命名约定和使用逻辑名称总是有帮助的,即使您正在进行一个学习项目。项目名称应该被更有意义的内容替换,以便以后使用时更容易理解。这个规则适用于我们创建的任何资产的名称,所以尽量使用逻辑名称。

创建一个集合

您可以使用createCollection命令来创建一个集合。这个命令允许您为您的集合使用不同的选项,比如固定大小的集合、验证、排序等。创建集合的另一种方法是通过在不存在的集合中插入文档。在这种情况下,MongoDB 会检查集合是否存在,如果不存在,它将在插入传递的文档之前创建集合。我们将尝试利用这两种方法来创建一个集合。

要显式创建集合,请使用以下语法中的createCollection操作:

db.createCollection( '<collectionName>',
{
     capped: <boolean>,
     autoIndexId: <boolean>,
     size: <number>,
     max: <number>,
     storageEngine: <document>,
     validator: <document>,
     validationLevel: <string>,
     validationAction: <string>,
     indexOptionDefaults: <document>,
     viewOn: <string>,
     pipeline: <pipeline>,
     collation: <document>,
     writeConcern: <document>
})

在下面的代码片段中,我们创建了一个最多包含 5 个文档的固定大小集合,每个文档的大小限制为 256 字节。固定大小集合的工作原理类似于循环队列,这意味着当达到最大大小时,旧文档将被删除以为最新插入腾出空间。

db.createCollection('myCappedCollection',
{
     capped: true,
     size: 256,
     max: 5
})

以下是createCollection命令的输出:

{
        «ok» : 1,
        «$clusterTime» : {
                «clusterTime» : Timestamp(1592064731, 1),
                «signature» : {
                        «hash» : BinData(0,»XJ2DOzjAagUkftFkLQIT                           9W2rKjc="),
                        «keyId» : NumberLong(«6834058563036381187»)
                }
        },
        «operationTime» : Timestamp(1592064731, 1)
}

不要太担心前面的选项,因为它们都不是必需的。如果您不需要设置其中任何一个,那么您的createCollection命令可以简化如下:

db.createCollection('myFirstCollection')

这个命令的输出应该如下所示:

{
        «ok» : 1,
        «$clusterTime» : {
                «clusterTime» : Timestamp(1597230876, 1),
                «signature» : {
                        «hash» : BinData(0,»YO8Flg5AglrxCV3XqEuZG                           aaLzZc="),
                        «keyId» : NumberLong(«6853300587753111555»)
                }
        },
        «operationTime» : Timestamp(1597230876, 1)
}

使用文档插入创建集合

在插入文档之前不需要创建集合。如果在第一次插入文档时集合不存在,MongoDB 会创建一个集合。您可以按照以下方法使用这种方法:

use yourDatabaseName;
db.myCollectionName.insert(
{
    "name" : "Yahya A",  "company" :  "Sony"}
);

您的命令输出应该如下所示:

WriteResult({ "nInserted" : 1 })

前面的输出返回插入到集合中的文档数量。由于您在不存在的集合中插入了一个文档,MongoDB 必须在插入此文档之前为我们创建集合。为了确认这一点,使用以下命令显示您的集合列表:

show collections;

您的命令输出应该显示数据库中集合的列表,类似于这样:

myCollectionName

创建文档

正如您在前一节中注意到的,我们使用insert命令将文档放入集合中。让我们看一下insert命令的几种变体。

插入单个文档

insertOne命令用于一次插入一个文档,如下所示:

db.blogs.insertOne(
  { username: "Zakariya", noOfBlogs: 100, tags: ["science",    "fiction"]
})

insertOne操作返回新插入文档的_id值。以下是insertOne命令的输出:

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

注意

insertedId是插入的文档的唯一 ID,它不会与输出中提到的相同。

插入多个文档

insertMany命令一次插入多个文档。您可以将文档数组传递给命令,如下面的代码段中所述:

db.blogs.insertMany(
[
      { username: "Thaha", noOfBlogs: 200, tags: ["science",       "robotics"]},
      { username: "Thayebbah", noOfBlogs: 500, tags: ["cooking",     "general knowledge"]},
      { username: "Thaherah", noOfBlogs: 50, tags: ["beauty",        "arts"]}
]
)

输出返回所有新插入文档的_id值:

{
  «acknowledged» : true,
  «insertedIds» : [
    ObjectId(«5f33cf74592962df72246ae8»),
    ObjectId(«5f33cf74592962df72246ae9»),
    ObjectId(«5f33cf74592962df72246aea»)
  ]
}

从 MongoDB 获取文档

MongoDB 提供find命令从集合中获取文档。此命令对于检查插入的文档是否实际保存在集合中非常有用。以下是find命令的语法:

db.collection.find(query, projection)

该命令接受两个可选参数:queryprojectionquery参数允许您传递一个文档以在find操作期间应用过滤器。projection参数允许您从返回的文档中选择所需的属性,而不是所有属性。当在find命令中不传递参数时,将返回所有文档。

使用pretty()方法格式化查找输出

find命令返回多个记录时,有时很难阅读它们,因为它们没有适当的格式。MongoDB 提供了pretty()方法,可以在find命令的末尾以格式化的方式获取返回的记录。要查看它的操作,请在名为records的集合中插入一些记录:

db.records.insertMany(
[
  { Name: "Aaliya A", City: "Sydney"},
  { Name: "Naseem A", City: "New Delhi"}
]
)

它应该生成以下输出:

{
  "acknowledged" : true,
  "insertedIds" : [
    ObjectId("5f33cfac592962df72246aeb"),
    ObjectId("5f33cfac592962df72246aec")
  ]
}

首先,使用find命令而不使用pretty方法获取这些记录:

db.records.find()

它应该返回如下所示的输出:

{ "_id" : ObjectId("5f33cfac592962df72246aeb"), "Name" : "Aaliya A",   "City" : "Sydney" }
{ "_id" : ObjectId("5f33cfac592962df72246aec"), "Name" : "Naseem A",   "City" : "New Delhi" }

现在,使用pretty方法运行相同的find命令:

db.records.find().pretty()

它应该以如下所示的美观格式返回相同的记录:

{
  "_id" : ObjectId("5f33cfac592962df72246aeb"),
  "Name" : "Aaliya A",
  "City" : "Sydney"
}
{
  "_id" : ObjectId("5f33cfac592962df72246aec"),
  "Name" : "Naseem A",
  "City" : "New Delhi"
}

显然,当您查看多个或嵌套文档时,pretty()方法可能非常有用,因为输出更容易阅读。

活动 1.01:设置电影数据库

您是一家公司的创始人,该公司制作来自世界各地的电影软件。您的团队没有太多的数据库管理技能,也没有预算来雇佣数据库管理员。您的任务是提供部署策略和基本的数据库架构/结构,并设置电影数据库。

以下步骤将帮助您完成此活动:

  1. 连接到您的数据库。

  2. 创建名为moviesDB的电影数据库。

  3. 创建一个电影集合并插入以下示例数据:packt.live/3lJXKuE

[
    {
        "title": "Rocky",
        "releaseDate": new Date("Dec 3, 1976"),
        "genre": "Action",
        "about": "A small-time boxer gets a supremely rare chance           to fight a heavy-  weight champion in a bout in           which he strives to go the distance for his self-respect.",
        "countries": ["USA"],
        "cast" : ["Sylvester Stallone","Talia Shire",          "Burt Young"],
        "writers" : ["Sylvester Stallone"],
        "directors" : ["John G. Avildsen"]
    },
    {
        "title": "Rambo 4",
        "releaseDate ": new Date("Jan 25, 2008"),
        "genre": "Action",
        "about": "In Thailand, John Rambo joins a group of           mercenaries to venture into war-torn Burma, and rescue           a group of Christian aid workers who were kidnapped           by the ruthless local infantry unit.",
        "countries": ["USA"],
        "cast" : [" Sylvester Stallone", "Julie Benz",           "Matthew Marsden"],
        "writers" : ["Art Monterastelli",          "Sylvester Stallone"],
        "directors" : ["Sylvester Stallone"]
    }
]
  1. 通过获取文档来检查文档是否已插入。

  2. 使用以下数据创建一个awards集合中的一些记录:

{
    "title": "Oscars",
    "year": "1976",
    "category": "Best Film",
    "nominees": ["Rocky","All The President's Men","Bound For       Glory","Network","Taxi Driver"],
    "winners" :
    [
        {
            "movie" : "Rocky"
        }
    ]
}
{
    "title": "Oscars",
    "year": "1976",
    "category": "Actor In A Leading Role",
    "nominees": ["PETER FINCH","ROBERT DE NIRO",      "GIANCARLO GIANNINI","WILLIAM  HOLDEN","SYLVESTER STALLONE"],
    "winners" :
    [
        {
            "actor" : "PETER FINCH",
            "movie" : "Network"
        }
    ]
}
  1. 通过获取文档来检查您的插入是否按预期保存在集合中。

注意

此活动的解决方案可以通过此链接找到。

摘要

我们开始本章时,介绍了数据、数据库、RDBMS 和 NoSQL 数据库的基础知识。您了解了 RDBMS 和 NoSQL 数据库之间的区别,以及如何决定哪种数据库适合特定的场景。您了解到 MongoDB 可以用作自管理或作为 DbaaS,设置了 MongoDB Atlas 中的帐户,并审查了不同云平台上的 MongoDB 部署以及如何估算其成本。我们通过 MongoDB 结构及其基本组件(如数据库、集合和文档)结束了本章。在下一章中,您将利用这些概念来探索 MongoDB 组件及其数据模型。

第二章:文档和数据类型

概述

本章介绍了 MongoDB 文档、它们的结构和数据类型。对于那些对 JSON 模型不熟悉的人来说,本章也将作为 JSON 的简要介绍。您将识别 JSON 文档的基本概念和数据类型,并将 MongoDB 的基于文档的存储与关系数据库的表格存储进行比较。您将学习如何在 MongoDB 中使用嵌入对象和数组表示复杂的数据结构。通过本章的学习,您将了解对 MongoDB 文档的预防性限制和限制的需求。

介绍

在上一章中,我们了解了作为 NoSQL 数据库的 MongoDB 与传统关系数据库的不同之处。我们涵盖了 MongoDB 的基本特性,包括其架构、不同版本和 MongoDB Atlas。

MongoDB 是为现代应用程序设计的。我们生活在一个需求迅速变化的世界。我们希望构建轻量灵活的应用程序,能够快速适应这些新需求,并尽快将其部署到生产环境中。我们希望我们的数据库变得敏捷,以便能够适应应用程序不断变化的需求,减少停机时间,轻松扩展,并且性能高效。MongoDB 完全符合所有这些需求。

使 MongoDB 成为一种敏捷数据库的主要因素之一是其基于文档的数据模型。文档被广泛接受为传输信息的灵活方式。您可能已经遇到许多以 JSON 文档形式交换数据的应用程序。MongoDB 以二进制 JSON(BSON)格式存储数据,并以人类可读的 JSON 表示数据。这意味着当我们使用 MongoDB 时,我们看到的数据是以 JSON 格式呈现的。本章以 JSON 和 BSON 格式的概述开始,然后介绍 MongoDB 文档和数据类型的详细信息。

JSON 介绍

JSON 是一种用于数据表示和传输的全文本、轻量级格式。JavaScript 对对象的简单表示形式催生了 JSON。道格拉斯·克罗克福德(Douglas Crockford)是 JavaScript 语言的开发人员之一,他提出了 JSON 规范的建议,定义了 JSON 语法的语法和数据类型。

JSON 规范于 2013 年成为标准。如果您已经开发了一段时间的应用程序,您可能已经看到应用程序从 XML 转换为 JSON 的过渡。JSON 提供了一种人类可读的纯文本表示数据的方式。与 XML 相比,其中信息被包裹在标签内,而且大量标签使其看起来笨重,JSON 提供了一种紧凑和自然的格式,您可以轻松地专注于信息。

为了以 JSON 或 XML 格式读取或写入信息,编程语言使用它们各自的解析器。由于 XML 文档受模式定义和标签库定义的约束,解析器需要做大量工作来读取和验证 XML 模式定义(XSD)和标签库描述符(TLD)。

另一方面,JSON 没有任何模式定义,JSON 解析器只需要处理开放和关闭括号以及冒号。不同的编程语言有不同的表示语言构造的方式,例如对象、列表、数组、变量等。当两个用不同编程语言编写的系统想要交换数据时,它们需要有一个共同约定的标准来表示信息。JSON 以其轻量级格式提供了这样的标准。任何编程语言的对象、集合和变量都可以自然地适应 JSON 结构。大多数编程语言都有解析器,可以将它们自己的对象转换为 JSON 文档,反之亦然。

注意

JSON 不会将 JavaScript 语言内部规定强加给其他语言。JSON 是语言无关数据表示的语法。定义 JSON 格式的语法是从 JavaScript 的语法派生出来的。然而,为了使用 JSON,程序员不需要了解 JavaScript 的内部。

JSON 语法

JSON 文档或对象是一组零个或多个键值对的纯文本。键值对形成一个对象,如果值是零个或多个值的集合,它们形成一个数组。JSON 具有非常简单的结构,只需使用一组大括号{}、方括号[]、冒号:和逗号,,就可以以紧凑的形式表示任何复杂的信息。

在 JSON 对象中,键值对被包含在大括号{}中。在对象内,键始终是一个字符串。然而,值可以是 JSON 指定的任何类型。JSON 语法规范没有为 JSON 字段定义任何顺序,可以表示如下:

{
  key : value
}

前面的文件代表一个有效的 JSON 对象,其中有一个键值对。接下来是 JSON 数组,数组是一组零个或多个值,这些值被包含在方括号[]中,并用逗号分隔。虽然大多数编程语言支持有序数组,但 JSON 的规范并未指定数组元素的顺序。让我们看一个例子,其中有三个字段,用逗号分隔:

[
  value1,
  value2,
  value3
]

现在我们已经看过了 JSON 的语法,让我们考虑一个包含公司基本信息的示例 JSON 文档。这个例子展示了信息如何以文档格式自然地呈现,使其易于阅读:

{
  "company_name" : "Sparter",
  "founded_year" : 2007,
  "twitter_username" : null,
  "address" : "15 East Street",
  "no_of_employees" : 7890,
  "revenue" : 879423000
}

从前面的文件中,我们可以看到以下内容:

  • 公司名称和地址,都是字符串字段

  • 成立年份、员工人数和收入作为数字字段

  • 公司的 Twitter 用户名为空或没有信息

JSON 数据类型

与许多编程语言不同,JSON 支持一组有限和基本的数据类型,如下:

  • 字符串:指纯文本

  • 数字:包括所有数字字段

  • TrueFalse

  • 对象:其他嵌入的 JSON 对象

  • 数组:字段的集合

  • Null:特殊值,表示没有任何值的字段

JSON 被广泛接受的一个主要原因是它的独立于语言的格式。不同的语言有不同的数据类型。一些语言支持静态类型变量,而一些支持动态类型变量。如果 JSON 有许多数据类型,它将更符合许多语言,尽管不是所有语言。

JSON 是一种数据交换格式。当应用程序通过网络传输一条信息时,该信息被序列化为纯字符串。接收应用程序然后将信息反序列化为其对象,以便可以使用。JSON 提供的基本数据类型的存在减少了这个过程中的复杂性。

因此,JSON 在数据类型方面保持简单和最小化。特定于编程语言的 JSON 解析器可以将基本数据类型轻松地关联到语言提供的最具体的类型。

JSON 和数字

根据 JSON 规范,数字只是一系列数字。它不区分诸如整数浮点数长整数之类的数字。此外,它限制了数字的范围限制。这导致在数据传输或表示时具有更大的灵活性。

然而,也存在一些挑战。大多数编程语言以整数浮点数长整数的形式表示数字。当一条信息以 JSON 格式呈现时,解析器无法预期整个文档中数值字段的确切格式或范围。为了避免数字格式损坏或数值字段精度丢失,交换数据的双方应事先达成一定的协议并遵循。

例如,假设您正在阅读以 JSON 文档形式呈现的电影记录集。当您查看第一条记录时,您发现audience_rating字段是一个整数。然而,当您到达下一条记录时,您意识到它是一个浮点数

{audience_rating: 6}
{audience_rating: 7.6}

我们将在即将到来的BSON部分中看看如何克服这个问题。

JSON 和日期

您可能已经注意到,JSON 文档不支持Date数据类型,所有日期都表示为普通字符串。让我们看一个例子,其中有几个 JSON 文档,每个文档都有一个有效的日期表示:

{"title": "A Swedish Love Story", released: "1970-04-24"}
{"title": "A Swedish Love Story", released: "24-04-1970"}
{"title": "A Swedish Love Story", released: "24th April 1970"}
{"title": "A Swedish Love Story", released: "Fri, 24 Apr 1970"}

尽管所有文档表示相同的日期,但它们以不同的格式编写。根据其本地标准,不同的系统使用不同的格式来编写相同的日期和时间实例。

与 JSON 数字的示例一样,交换信息的各方需要在传输过程中标准化Date格式。

注意

请记住,JSON 规范定义了数据表示的语法和语法。然而,您如何读取数据取决于语言的解释器和它们的数据交换协议。

练习 2.01:创建您自己的 JSON 文档

现在您已经学会了 JSON 语法的基础知识,是时候将这些知识付诸实践了。假设您的组织想要构建一个电影和系列节目的数据集,并且他们想要使用 MongoDB 来存储记录。作为概念验证,他们要求您选择一部随机电影,并以 JSON 格式表示它。

在这个练习中,您将从头开始编写您的第一个基本 JSON 文档,并验证它是否是一个语法上有效的文档。对于这个练习,您将考虑一部样本电影,美女与野兽,并参考电影 ID电影标题发行年份语言IMDb 评分类型导演时长字段,其中包含以下信息:

Movie Id = 14253
Movie Title = Beauty and the Beast
Release Year = 2016
Language = English
IMDb Rating = 6.4
Genre = Romance
Director = Christophe Gans
Runtime = 112

要成功地为上述列出的字段创建一个 JSON 文档,首先将每个字段区分为键值对。执行以下步骤以实现所需的结果:

  1. 打开一个 JSON 验证器,例如jsonlint.com/

  2. 将上述信息以 JSON 格式输入,如下所示:

{
  "id" : 14253,
  "title" : "Beauty and the Beast",
  "year" : 2016,
  "language" : "English",
  "imdb_rating" : 6.4,
  "genre" : "Romance",
  "director" : "Christophe Gans",
  "runtime" : 112
}

请记住,JSON 文档总是以{开头,以}结尾。每个元素由冒号(:)分隔,键值对由逗号(,)分隔。

  1. 单击验证 JSON以验证代码。以下屏幕截图显示了 JSON 文档的预期输出和有效性:图 2.1:JSON 文档及其有效性检查

图 2.1:JSON 文档及其有效性检查

在这个练习中,您将把一部电影记录建模成文档格式,并创建一个语法上有效的 JSON 对象。要更多地练习它,您可以考虑任何一般项目,比如您最近购买的产品或您阅读的一本书,并将其建模为一个有效的 JSON 文档。在下一节中,我们将简要概述 MongoDB 的 BSON。

BSON

当您使用数据库客户端(如 mongo shell、MongoDB Compass 或 Mongo Atlas 中的 Collections Browser)与 MongoDB 一起工作时,您总是以人类可读的 JSON 格式看到文档。然而,在内部,MongoDB 文档以一种称为 BSON 的二进制格式存储。BSON 文档不是人类可读的,您永远不需要直接处理它们。在我们详细探讨 MongoDB 文档之前,让我们快速概述一下 BSON 的特性,这些特性有益于 MongoDB 文档结构。

与 JSON 一样,BSON 是由 MongoDB 在 2009 年引入的。尽管它是由 MongoDB 发明的,但许多其他系统也将其用作数据存储或传输的格式。BSON 规范主要基于 JSON,因为它继承了 JSON 的所有优点,如语法和灵活性。它还提供了一些额外的功能,专门设计用于提高存储效率,便于遍历,并避免类型冲突的一些数据类型增强,这些冲突是我们在JSON 简介部分中看到的。

由于我们已经详细介绍了 JSON 的特性,让我们专注于 BSON 提供的增强功能:

  • BSON 文档的设计旨在比 JSON 更高效,因为它们占用更少的空间并提供更快的遍历速度。

  • 对于每个文档,BSON 存储一些元信息,例如字段的长度或子文档的长度。元信息使文档解析和遍历更快。

  • BSON 文档具有有序数组。数组中的每个元素都以其索引位置为前缀,并可以使用其索引号进行访问。

  • BSON 提供了许多额外的数据类型,如日期、整数、双精度、字节数组等。我们将在下一节中详细介绍 BSON 数据类型。

注意

由于二进制格式,BSON 文档在性质上是紧凑的。但是,一些较小的文档最终占用的空间比具有相同信息的 JSON 文档更多。这是因为每个文档都添加了元信息。但是,对于大型文档,BSON 更节省空间。

现在我们已经完成了对 JSON 和 BSON 增强功能的详细介绍,让我们现在学习一下 MongoDB 文档。

MongoDB 文档

MongoDB 数据库由集合和文档组成。一个数据库可以有一个或多个集合,每个集合可以存储一个或多个相关的 BSON 文档。与关系型数据库相比,集合类似于表,文档类似于表中的行。但是,与表中的行相比,文档更加灵活。

关系型数据库由行和列组成的表格数据模型。但是,您的应用程序可能需要支持更复杂的数据结构,例如嵌套对象或对象集合。表格数据库限制了这种复杂数据结构的存储。在这种情况下,您将不得不将数据拆分成多个表,并相应地更改应用程序的对象结构。另一方面,MongoDB 的基于文档的数据模型允许您的应用程序存储和检索更复杂的对象结构,因为文档具有灵活的类似 JSON 的格式。

以下列表详细介绍了 MongoDB 基于文档的数据模型的一些主要特性:

  1. 文档提供了一种灵活和自然的表示数据的方式。数据可以按原样存储,而无需将其转换为数据库结构。

  2. 文档中的对象、嵌套对象和数组与您编程语言的对象结构容易相关联。

  3. 具有灵活模式的能力使文档在实践中更加灵活。它们可以持续集成应用程序的变化和新功能,而无需进行任何重大的模式更改或停机。

  4. 文档是自包含的数据片段。它们避免了阅读多个关系表和表连接以理解完整信息单元的需要。

  5. 文档是可扩展的。您可以使用文档来存储整个对象结构,将其用作映射或字典,作为快速查找的键值对,或者具有类似关系表的扁平结构。

文档和灵活性

正如前面所述,MongoDB 文档是一种灵活的存储数据的方式。考虑以下示例。想象一下,您正在开发一个电影服务,需要创建一个电影数据库。一个简单的 MongoDB 文档中的电影记录将如下所示:

{"title" : "A Swedish Love Story"}

然而,仅存储标题是不够的。您需要更多的字段。现在,让我们考虑一些更基本的字段。在 MongoDB 数据库中有一系列电影,文档将如下所示:

{
  "id" : 1122,
  "title" : "A Swedish Love Story",
  "release_date" : ISODate("1970-04-24T00:00:00Z"),
  "user_rating" : 6.7
}
{
  "id" : 1123,
  "title" : "The Stunt Man",
  "release_date" : ISODate("1980-06-26T00:00:00Z"),
  "user_rating" : 7.8
}

假设您正在使用 RDBMS 表。在 RDBMS 平台上,您需要在开始时定义您的模式,为此,首先您必须考虑列和数据类型。然后,您可能会提出一个CREATE TABLE查询,如下所示:

CREATE TABLE movies(
  id INT,
  title VARCHAR(250),
  release_date DATE,
  user_ratings FLOAT
);

这个查询清楚地表明,关系表受到一个叫做id字段的定义的限制,而user_ratings永远不能是一个字符串。

插入了一些记录后,表将显示为图 2.2。这个表和一个 MongoDB 文档一样好:

图 2.2:电影表

图 2.2:电影表

现在,假设您想要在表中列出的每部电影中包括 IMDb 评分,并且今后,所有电影都将在表中包括imdb_ratings。对于现有的电影列表,imdb_ratings可以设置为null

为了满足这个要求,您将在您的语法中包含一个ALTER TABLE查询:

ALTER TABLE movies
ADD COLUMN imdb_ratings FLOAT default null;

查询是正确的,但是在某些情况下,表的更改可能会阻塞表一段时间,特别是对于大型数据集。当表被阻塞时,其他读写操作将不得不等待表被更改,这可能导致停机。现在,让我们看看如何在 MongoDB 中解决同样的情况。

MongoDB 支持灵活的模式,并且没有特定的模式定义。在不改变数据库或集合上的任何内容的情况下,您可以简单地插入一个带有额外字段的新电影。集合的行为将与修改后的电影表完全相同,最新插入的将具有imdb_ratings,而之前的将返回null值。在 MongoDB 文档中,不存在的字段始终被视为null

现在,整个集合将看起来类似于以下的屏幕截图。您会注意到最后一个电影有一个新字段,imdb_ratings

图 2.3:电影集合的 imdb_ratings 结果

图 2.3:电影集合的 imdb_ratings 结果

前面的例子清楚地表明,与表格数据库相比,文档非常灵活。文档可以在不停机的情况下进行更改。

MongoDB 数据类型

您已经学会了 MongoDB 如何存储类似 JSON 的文档。您还看到了各种文档,并读取了其中存储的信息,并看到了这些文档在存储不同类型的数据结构时有多灵活,无论您的数据有多复杂。

在本节中,您将了解 MongoDB 的 BSON 文档支持的各种数据类型。在文档中使用正确的数据类型非常重要,因为正确的数据类型可以帮助您更有效地使用数据库功能,避免数据损坏,并提高数据的可用性。MongoDB 支持 JSON 和 BSON 中的所有数据类型。让我们详细看看每种类型,以及示例。

字符串

字符串是用来表示文本字段的基本数据类型。它是一系列普通字符。在 MongoDB 中,字符串字段是 UTF-8 编码的,因此它们支持大多数国际字符。各种编程语言的 MongoDB 驱动程序在从集合中读取或写入数据时将字符串字段转换为 UTF-8。

一个包含纯文本字符的字符串如下所示:

{
  "name" : "Tom Walter"
}

一个包含随机字符和空格的字符串将显示如下:

{
  "random_txt" : "a ! *& ) ( f s f @#$ s"
}

在 JSON 中,用双引号括起来的值被视为字符串。考虑以下示例,其中一个有效的数字和日期被双引号括起来,都形成一个字符串:

{
  "number_txt" : "112.1"
}
{
  "date_txt" : "1929-12-31"
}

有关 MongoDB 字符串字段的一个有趣事实是,它们支持使用正则表达式进行搜索。这意味着您可以通过提供文本字段的完整值或仅提供部分字符串值来使用正则表达式搜索文档。

数字

数字是 JSON 的基本数据类型。 JSON 文档不指定数字是整数,浮点数还是

{
  "number_of_employees": 50342
}
{
  "pi": 3.14159265359
}

但是,MongoDB 支持以下类型的数字:

  • double:64 位浮点

  • int:32 位有符号整数

  • long:64 位无符号整数

  • decimal:128 位浮点 - 符合 IEE 754 标准

当您使用编程语言时,您不必担心这些数据类型。您可以简单地使用语言的本机数据类型进行编程。各种语言的 MongoDB 驱动程序负责将语言特定的数字编码为先前列出的数据类型之一。

如果您在 mongo shell 上工作,您将获得三个包装器来处理:integerlongdecimal。 Mongo shell 基于 JavaScript,因此所有文档都以 JSON 格式表示。默认情况下,它将任何数字视为 64 位浮点数。但是,如果要明确使用其他类型,可以使用以下包装器。

NumberInt:如果要将数字保存为 32 位整数而不是 64 位浮点数,则可以使用NumberInt构造函数:

> var plainNum = 1299
> var explicitInt = NumberInt("1299")
> var explicitInt_double = NumberInt(1299)
  • 在上面的片段中,第一个数字plainNum是使用未提及任何显式数据类型的数字序列初始化的。因此,默认情况下,它将被视为64 位浮点数(也称为double)。

  • 但是,explicitInt是使用整数类型构造函数和数字的字符串表示初始化的,因此 MongoDB 将参数中的数字读取为32 位整数

  • 但是,在explicitInt_double初始化中,构造函数参数中提供的数字没有双引号。因此,它将被视为64 位浮点数 - 也就是double - 并用于形成32 位整数。但是,由于提供的数字适合整数范围,因此不会看到任何更改。

  • 当您打印上述数字时,它们看起来如下:

图 2.4:plainNum,explicitInt 和 explicitInt_double 的输出

](https://gitee.com/OpenDocCN/freelearn-bigdata-zh/raw/master/docs/mongo-fund/img/B15507_02_04.jpg)

图 2.4:plainNum,explicitInt 和 explicitInt_double 的输出

NumberLongNumberLong包装器类似于NumberInt。唯一的区别是它们存储为 64 位整数。让我们在 shell 上尝试一下:

> var explicitLong = NumberLong("777888222116643")
> var explicitLong_double = NumberLong(444333222111242)

让我们在 shell 中打印文档:

图 2.5:MongoDB shell 输出

图 2.5:MongoDB shell 输出

NumberDecimal:此包装器将给定数字存储为 128 位 IEEE 754 十进制格式。NumberDecimal构造函数接受数字的字符串和双精度表示:

> var explicitDecimal = NumberDecimal("142.42")
> var explicitDecimal_double = NumberDecimal(142.42)

我们将一个十进制数的字符串表示传递给explicitDecimal。但是,explicitDecimal_double是使用double创建的。当我们打印结果时,它们看起来略有不同:

图 2.6:explicitDecimal 和 explicitDecimal_double 的输出

图 2.6:explicitDecimal 和 explicitDecimal_double 的输出

第二个数字已附加尾随零。这是由于数字的内部解析。当我们将双精度值传递给NumberDecimal时,参数被解析为 BSON 的双精度,然后转换为具有 15 位数字精度的 128 位小数。

在此转换过程中,十进制数将四舍五入并可能失去精度。让我们看下面的例子:

> var dec = NumberDecimal("5999999999.99999999")
> var decDbl = NumberDecimal(5999999999.99999999)

让我们打印数字并检查输出:

图 2.7:dec 和 decDbl 的输出

](https://gitee.com/OpenDocCN/freelearn-bigdata-zh/raw/master/docs/mongo-fund/img/B15507_02_07.jpg)

图 2.7:dec 和 decDbl 的输出

很明显,当双精度值传递给NumberDecimal时,存在失去精度的可能。因此,在使用NumberDecimal时始终使用基于字符串的构造函数非常重要。

布尔值

布尔数据类型用于表示某事是真还是假。因此,有效布尔字段的值要么是true,要么是false

{
  "isMongoDBHard": false
}
{
  "amIEnjoying": true
}

值没有双引号。如果您用双引号括起来,它们将被视为字符串。

对象

对象字段用于表示嵌套或嵌入文档,即其值是另一个有效的 JSON 文档。

让我们看一下来自 airbnb 数据集的以下示例:

{
  "listing_url": "https://www.airbnb.com/rooms/1001265",
  "name": "Ocean View Waikiki Marina w/prkg",
  "summary": "A great location that work perfectly for business,     education, or simple visit.",
  "host":{
    "host_id": "5448114",
    "host_name": "David",
    "host_location": "Honolulu, Hawaii, United States"
  }
}

主机字段的值是另一个有效的 JSON。MongoDB 使用点表示法(.)来访问嵌入对象。要访问嵌入文档,我们将在 mongo shell 上创建一个列表的变量:

> var listing = {
  "listing_url": "https://www.airbnb.com/rooms/1001265",
  "name": "Ocean View Waikiki Marina w/prkg",
  "summary": "A great location that work perfectly for business,     education, or simple visit.",
  "host": {
    "host_id": "5448114",
    "host_name": "David",
    "host_location": "Honolulu, Hawaii, United States"
  }
}

要仅打印主机详细信息,请使用点表示法(.)获取嵌入对象,如下所示:

图 2.8:嵌入对象的输出

图 2.8:嵌入对象的输出

使用类似的表示法,您还可以访问嵌入文档的特定字段,如下所示:

> listing.host.host_name
David

嵌入文档可以包含其中的更多文档。具有嵌入文档使 MongoDB 文档成为一个自包含的信息片段。要在 RDBMS 数据库中记录相同的信息,您将不得不创建列表和主机作为两个单独的表,并在两者之间创建一个外键引用,并从两个表中获取信息。

除了嵌入文档之外,MongoDB 还支持两个不同集合的文档之间的链接,这类似于具有外键引用。

练习 2.02:创建嵌套对象

到目前为止,您的组织对电影表示感到满意。现在他们提出了一个要求,要包括 IMDb 评分和导致评分的投票数。他们还希望包含番茄表评分,其中包括用户评分和评论家评分以及新鲜和烂的分数。您的任务是修改文档,更新imdb字段以包括投票数,并添加一个名为tomatoes的新字段,其中包含烂番茄评分。

回想一下您在练习 2.01中创建的样本电影记录的 JSON 文档,创建您自己的 JSON 文档

{
  "id": 14253,
  "title": "Beauty and the Beast",
  "year": 2016,
  "language": "English",
  "imdb_rating": 6.4,
  "genre": "Romance",
  "director": "Christophe Gans",
  "runtime": 112
}

以下步骤将帮助修改 IMDb 评分:

  1. 现有的imdb_rating字段表示 IMDb 评分,因此添加一个额外的字段来表示投票数。然而,这两个字段彼此密切相关,并且将始终一起使用。因此,将它们组合在一个单独的文档中:
{
  "rating": 6.4, 
  "votes": "17762"
}
  1. 前面的文档具有两个字段,表示完整的 IMDb 评分。用您刚创建的字段替换当前的imdb_rating字段:
{
  "id" : 14253,
  "Title" : "Beauty and the Beast",
  "year" : 2016,
  "language" : "English",
  "genre" : "Romance",
  "director" : "Christophe Gans",
  "runtime" : 112,
  "imdb" :
  {
    "rating": 6.4,
    "votes": "17762"
  }
}

这个带有嵌入对象值的imdb字段表示 IMDb 评分。现在,添加番茄表评分。

  1. 如前所述,番茄表评分包括观众评分和评论家评分,以及新鲜分数和烂分数。与 IMDb 评分一样,观众评分评论家评分都将有一个评分字段和一个投票字段。分别编写这两个文档:
// Viewer Ratings
{
  "rating" : 3.9,
  "votes" : 238
}
// Critic Ratings
{
  "rating" : 4.2,
  "votes" : 8
}
  1. 由于两个评分相关,将它们组合在一个单独的文档中:
{
  "viewer" : {
    "rating" : 3.9,
    "votes" : 238
  },
  "critic" : {
    "rating" : 4.2,
    "votes" : 8
  }
}
  1. 根据描述添加freshrotten分数:
{
  "viewer" : {
    "rating" : 3.9,
    "votes" : 238
  },
  "critic" : {
    "rating" : 4.2,
    "votes" : 8
  },
  "fresh" : 96,
  "rotten" : 7
}

以下输出表示了我们电影记录中新的tomatoes字段的番茄表评分:

{
    "id" : 14253,
    "Title" : "Beauty and the Beast",
    "year" : 2016,
    "language" : "English",
    "genre" : "Romance",
    "director" : "Christophe Gans",
    "runtime" : 112,
    "imdb" : {
        "rating": 6.4,
        "votes": "17762"
    },
    "tomatoes" : {
        "viewer" : {
            "rating" : 3.9,
            "votes" : 238
        },
        "critic" : {
            "rating" : 4.2,
            "votes" : 8
        },
       "fresh" : 96,
       "rotten" : 7
    }
}
  1. 最后,使用任何在线 JSON 验证器(在我们的案例中,jsonlint.com/)验证您的文档。单击“验证 JSON”以验证代码:图 2.9:验证 JSON 文档

图 2.9:验证 JSON 文档

您的电影记录现在已更新为详细的 IMBb 评分和新的tomatoes评分。在这个练习中,您练习了创建两个嵌套文档来表示 IMDb 评分和番茄表评分。现在我们已经涵盖了嵌套或嵌入对象,让我们了解一下数组。

数组

具有数组类型的字段具有零个或多个值的集合。在 MongoDB 中,数组可以包含的元素数量或文档可以拥有的数组数量没有限制。但是,整个文档大小不应超过 16 MB。考虑以下包含四个数字的示例数组:

> var doc = {
  first_array: [
    4,
    3,
    2,
    1
  ]
}

可以使用其索引位置访问数组中的每个元素。在访问特定索引位置上的元素时,索引号用方括号括起来。让我们打印数组中的第三个元素:

> doc.first_array[3]
1

注意

索引始终从零开始。索引位置3表示数组中的第四个元素。

使用索引位置,您还可以向现有数组添加新元素,如下例所示:

> doc.first_array[4] = 99

打印数组后,您将看到第五个元素已正确添加,其中包含索引位置4

> doc.first_array
[ 4, 3, 2, 1, 99 ]

就像对象具有嵌入对象一样,数组也可以具有嵌入数组。以下语法将嵌入数组添加到第六个元素中:

> doc.first_array[5] = [11, 12]
[ 11, 12 ]

如果打印数组,您将看到嵌入数组如下所示:

> doc.first_array
[ 4, 3, 2, 1, 99, [11, 12]]
>

现在,您可以使用方括号[]来访问嵌入数组中特定索引的元素,如下所示:

> doc.first_array[5][1]
12

数组可以包含任何 MongoDB 有效的数据类型字段。这可以在以下代码片段中看到:

// array of strings
[ "this", "is", "a", "text" ] 
// array of doubles
[ 1.1, 3.2, 553.54 ]
// array of Json objects
[ { "a" : 1 }, { "a" : 2, "b" : 3 }, { "c" : 1 } ] 
// array of mixed elements
[ 12, "text", 4.35, [ 3, 2 ], { "type" : "object" } ]

练习 2.03:使用数组字段

为了为每部电影添加评论详细信息,您的组织希望您包括评论的全文以及用户详细信息,如姓名、电子邮件和日期。您的任务是准备两条虚拟评论并将它们添加到现有的电影记录中。在练习 2.02中,创建嵌套对象,您以文档格式开发了一条电影记录,如下所示:

{
  "id" : 14253,
  "Title" : "Beauty and the Beast",
  "year" : 2016,
  "language" : "English",
  "genre" : "Romance",
  "director" : "Christophe Gans",
  "runtime" : 112,
  "imdb" : {
    "rating": 6.4,
    "votes": "17762"
  },
  "tomatoes" : {
    "viewer" : {
      "rating" : 3.9,
      "votes" : 238
    },
    "critic" : {
      "rating" : 4.2,
      "votes" : 8
    },
    "fresh" : 96,
    "rotten" : 7
  }
}

通过执行以下步骤构建此文档以添加附加信息:

  1. 创建两条评论并列出详细信息:
// Comment #1
Name = Talisa Maegyr
Email = oona_chaplin@gameofthron.es
Text = Rem itaque ad sit rem voluptatibus. Ad fugiat...
Date = 1998-08-22T11:45:03.000+00:00
// Comment #2
Name = Melisandre
Email = carice_van_houten@gameofthron.es
Text = Perspiciatis non debitis magnam. Voluptate...
Date = 1974-06-22T07:31:47.000+00:00
  1. 将两个注释拆分为单独的文档,如下所示:
// Comment #1
{
  "name" : "Talisa Maegyr",
  "email" : "oona_chaplin@gameofthron.es",
  "text" : "Rem itaque ad sit rem voluptatibus. Ad fugiat...",
  "date" : "1998-08-22T11:45:03.000+00:00"
}
// Comment #2
{
  "name" : "Melisandre",
  "email" : "carice_van_houten@gameofthron.es",
  "text" : "Perspiciatis non debitis magnam. Voluptate...",
  "date" : "1974-06-22T07:31:47.000+00:00"
}

有两条评论在两个单独的文档中,您可以轻松地将它们放入电影记录中作为comment_1comment_2。但是,随着评论数量的增加,将很难计算它们的数量。为了克服这一点,我们将使用一个数组,它隐式地为每个元素分配索引位置。

  1. 将两条评论添加到数组中,如下所示:
[
  {
    "name": "Talisa Maegyr",
    "email": "oona_chaplin@gameofthron.es",
    "text": "Rem itaque ad sit rem voluptatibus. Ad fugiat...",
    "date": "1998-08-22T11:45:03.000+00:00"
  },
  {
    "name": "Melisandre",
    "email": "carice_van_houten@gameofthron.es",
    "text": "Perspiciatis non debitis magnam. Voluptate...",
    "date": "1974-06-22T07:31:47.000+00:00"
  }
]

数组为您提供了添加尽可能多的评论的机会。此外,由于隐式索引,您可以自由地通过其专用索引位置访问任何评论。一旦将此数组添加到电影记录中,输出将如下所示:

{
  "id": 14253,
  "Title": "Beauty and the Beast",
  "year": 2016,
  "language": "English",
  "genre": "Romance",
  "director": "Christophe Gans",
  "runtime": 112,
  "imdb": {
    "rating": 6.4,
    "votes": "17762"
  },
  "tomatoes": {
    "viewer": {
      "rating": 3.9,
      "votes": 238
    },
    "critic": {
      "rating": 4.2,
      "votes": 8
    },
    "fresh": 96,
    "rotten": 7
  },
  "comments": [{
    "name": "Talisa Maegyr",
    "email": "oona_chaplin@gameofthron.es",
    "text": "Rem itaque ad sit rem voluptatibus. Ad fugiat...",
    "date": "1998-08-22T11:45:03.000+00:00"
  }, {
    "name": "Melisandre",
    "email": "carice_van_houten@gameofthron.es",
    "text": "Perspiciatis non debitis magnam. Voluptate...",
    "date": "1974-06-22T07:31:47.000+00:00"
  }]
}
  1. 现在,使用在线验证器(例如,jsonlint.com/)验证 JSON 文档。单击“验证 JSON”以验证代码:图 2.10:验证 JSON 文档

图 2.10:验证 JSON 文档

我们可以看到我们的电影记录现在有用户评论。在这个练习中,我们修改了我们的电影记录以练习创建数组字段。现在是时候转到下一个数据类型,null

Null

Null 是文档中的一种特殊数据类型,表示不包含值的字段。null字段只能有null作为值。在下面的示例中,您将打印对象,这将导致null值:

> var obj = null
>
> obj
Null

数组部分创建的数组上进行构建:

> doc.first_array
[ 4, 3, 2, 1, 99, [11, 12]]

现在,创建一个新变量并将其初始化为null,通过将变量插入到下一个索引位置:

> var nullField = null
> doc.first_array[6] = nullField

现在,打印此数组以查看null字段:

> doc.first_array
[ 4, 3, 2, 1, 99, [11, 12], null]

ObjectId

集合中的每个文档都必须有一个包含唯一值的_id。这个字段充当这些文档的主键。主键用于唯一标识文档,并且它们总是被索引的。_id字段的值在集合中必须是唯一的。当您使用任何数据集时,每个数据集代表不同的上下文,并且根据上下文,您可以确定您的数据是否有主键。例如,如果您处理用户数据,用户的电子邮件地址将始终是唯一的,并且可以被视为最合适的_id字段。然而,对于一些没有唯一键的数据集,您可以简单地省略_id字段。

如果您插入一个没有_id字段的文档,MongoDB 驱动程序将自动生成一个唯一 ID 并将其添加到文档中。因此,当您检索插入的文档时,您会发现_id是用随机文本的唯一值生成的。当驱动程序自动添加_id字段时,该值是使用ObjectId生成的。

ObjectId值旨在生成跨不同机器唯一的轻量级代码。它生成一个唯一值的 12 个字节,其中前 4 个字节表示时间戳,第 5 到 9 个字节表示随机值,最后 3 个字节是递增计数器。创建并打印ObjectId值如下:

> var uniqueID = new ObjectId()

在下一行打印uniqueID

> uniqueID
ObjectId("5dv.8ff48dd98e621357bd50")

MongoDB 支持一种称为分片的技术,其中数据集被分布并存储在不同的机器上。当一个集合被分片时,它的文档被物理地位于不同的机器上。即使如此,ObjectId也可以确保在不同机器上的集合中的值是唯一的。如果使用ObjectId字段对集合进行排序,顺序将基于文档创建时间。然而,ObjectId中的时间戳是基于秒数到纪元时间。因此,在同一秒内插入的文档可能以随机顺序出现。ObjectId上的getTimestamp()方法告诉我们文档插入时间。

日期

JSON 规范不支持日期类型。JSON 文档中的所有日期都表示为纯字符串。日期的字符串表示形式很难解析、比较和操作。然而,MongoDB 的 BSON 格式明确支持日期类型。

MongoDB 日期以自 Unix 纪元以来的毫秒形式存储,即 1970 年 1 月 1 日。为了存储日期的毫秒表示,MongoDB 使用 64 位整数(long)。由于这个原因,日期字段的范围大约为自 Unix 纪元以来的+-290 百万年。需要注意的一点是所有日期都以UTC存储,并且没有与它们相关联的时区

在 mongo shell 上工作时,您可以使用Date()new Date()new ISODate()创建Date实例。

注意

使用新的Date()构造函数或新的ISODate()构造函数创建的日期始终是 UTC 时间,而使用Date()创建的日期将是本地时区的时间。下面给出一个例子。

var date = Date()// Sample output
Sat Sept 03 1989 07:28:46 GMT-0500 (CDT)

当使用Date()类型来构造日期时,它使用 JavaScript 的日期表示,这是以纯字符串形式的。这些日期表示基于您当前的时区的日期和时间。然而,作为字符串格式,它们对于比较或操作是没有用的。

如果将new关键字添加到Date构造函数中,您将得到包装在ISODate()中的 BSON 日期,如下所示:

> var date = new Date()
// Sample output
ISODate("1989-09-03T10:11:23.357Z")

您还可以直接使用ISODate()构造函数创建date对象,如下所示:

> var isoDate = new ISODate()
// Sample output
ISODate("1989-09-03T11:13:26.442Z")

这些日期可以被操作、比较和搜索。

注意

根据 MongoDB 文档,不是所有的驱动程序都支持 64 位日期编码。然而,所有的驱动程序都支持编码年份范围从 0 到 9999 的日期。

时间戳

时间戳是日期和时间的 64 位表示。在这 64 位中,前 32 位存储自 Unix 纪元时间以来的秒数,即 1970 年 1 月 1 日。另外 32 位表示一个递增的计数器。时间戳类型是 MongoDB 专门用于内部操作的。

二进制数据

二进制数据,也称为BinData,是一种用于存储以二进制格式存在的数据的 BSON 数据类型。这种数据类型使您能够在数据库中存储几乎任何东西,包括文本、视频、音乐等文件。BinData可以与编程语言中的二进制数组进行映射,如下所示:

图 2.11:二进制数组

图 2.11:二进制数组

BinData的第一个参数是一个二进制子类型,用于指示存储的信息类型。零值代表普通二进制数据,可以与文本或媒体文件一起使用。BinData的第二个参数是base64编码的文本文件。您可以在文档中使用二进制数据字段,如下所示:

{
  "name" : "my_txt",
  "extension" : "txt",
  "content" : BinData(0,/
    "VGhpcyBpcyBhIHNpbXBsZSB0ZXh0IGZpbGUu")
}

我们将在接下来的部分介绍 MongoDB 的文档大小限制。

文档的限制和限制

到目前为止,我们已经讨论了使用文档的重要性和好处。文档在构建高效应用程序中起着重要作用,并且它们提高了整体数据的可用性。我们知道文档以最自然的形式提供了一种灵活的表示数据的方式。它们通常是自包含的,可以容纳完整的信息单元。自包含性来自嵌套对象和数组。

要有效地使用任何数据库,正确的数据结构是很重要的。您今天构建的不正确的数据结构可能会在未来带来很多痛苦。从长远来看,随着应用程序的使用量增加,数据量也会增加,最初似乎很小的问题变得更加明显。然后显而易见的问题就来了:您如何知道您的数据结构是否正确?

您的应用程序会告诉您答案。如果要访问某个信息,您的应用程序必须执行多个查询到数据库,并组合所有结果以获取最终信息,那么它将减慢整体吞吐量。相反,如果数据库上的单个查询返回了太多信息,您的应用程序将不得不扫描整个结果集并获取所需的信息。这将导致更高的内存消耗,过时的对象,最终导致性能下降。

因此,MongoDB 对文档进行了一些限制和限制。需要注意的一点是,这些限制并不是因为数据库的限制或缺陷。这些限制是为了使整体数据库平台能够高效运行。我们已经介绍了 MongoDB 文档提供的灵活性;现在重要的是要了解这些限制。

文档大小限制

包含过多信息的文档在许多方面都是不好的。因此,MongoDB 对集合中每个文档的大小限制为 16 MB。16 MB 的限制足以存储正确的信息。一个集合可以有任意多的文档。集合的大小没有限制。即使集合超出了底层系统的空间,您也可以使用垂直或水平扩展来增加集合的容量。

文档的灵活性和自包含性可能会诱使开发人员放入过多的信息并创建臃肿的文档。超大型文档通常是糟糕设计的表现。大多数情况下,您的应用程序并不需要所有的信息。良好的数据库设计考虑了应用程序的需求。

想象一下,你的应用程序是一个提供来自各种商店的销售信息的界面,用户可以按商品类型或商店位置搜索和找到已售出的商品。大部分时间,是你的应用程序会频繁访问数据库,并且使用类似的查询。因此,你的应用程序的需求在数据库设计中起着重要作用,特别是当用户基数增长,你的应用程序开始在短时间内获得成千上万的请求。你所希望的是更快的查询,更少的处理和更少的资源消耗。

超大的文档在资源使用方面也很昂贵。当文档从系统中读取时,它们会被保存在内存中,然后通过网络传输。网络传输总是比较慢的。然后,你的驱动程序会将接收到的信息映射到你编程语言的对象中。更大的文档会导致太多的庞大对象。考虑一个来自虚拟销售记录的样本文档,如下所示:

{
     «_id" : ObjectId("5bd761dcae323e45a93ccff4"),
     «saleDate" : ISODate("2014-08-18T10:42:13.935Z"),
     «items" : [
          {
               «name" : "backpack",
               «tags" : [
                    «school»,
                    «travel»,
                    «kids»
               ],
               «price" : NumberDecimal("187.16"),
               «quantity" : 2
          },
          {
               «name" : "printer paper",
               «tags" : [
                    «office»,
                    «stationary»
               ],
               «price" : NumberDecimal("20.61"),
               «quantity" : 10
          },
          {
               «name" : "notepad",
               «tags" : [
                    «office»,
                    «writing»,
                    «school»
               ],
               «price" : NumberDecimal("23.75"),
               «quantity" : 5
          },
          {
               «name" : "envelopes",
               «tags" : [
                    «stationary»,
                    «office»,
                    «general»
               ],
               «price" : NumberDecimal("9.44"),
               «quantity" : 5
          }
     ],
     «storeLocation" : "San Diego",
     «customer" : {
          «gender" : "F",
          «age" : 59,
          «email" : "la@cevam.tj",
          «satisfaction" : 4
     },
     «couponUsed" : false,
     «purchaseMethod" : "In store"
}

虽然这个文档很好,但也有一些限制。items字段是items对象的数组。如果一个订单有太多的items,数组的大小会增加,这将导致整个文档的大小增加。如果你的应用程序允许每个订单有多个项目,并且你的商店有成千上万个独特的项目,这个文档很容易变得过大。处理这种复杂文档的最佳方法是将集合拆分为两个,并在其中嵌入文档链接。

嵌套深度限制

MongoDB BSON 文档支持嵌套达到 100 级,这已经足够了。嵌套文档是提供可读数据的好方法。它们一次性提供完整的信息,避免多次查询来收集一部分信息。

然而,随着嵌套级别的增加,性能和内存消耗问题会出现。例如,考虑一个将文档解析为对象结构的驱动程序。在扫描过程中,每当发现一个新的子文档时,扫描器会递归进入嵌套对象,同时保持一个已读信息的堆栈。这会导致内存利用率高和性能慢。

通过设置 100 级的嵌套限制,MongoDB 避免了这些问题。然而,如果无法避免这种深层嵌套,可以考虑将集合拆分为两个或更多,并引用文档。

字段名称规则

MongoDB 有一些关于文档字段名称的规则,列举如下:

  1. 字段名称不能包含字符。

  2. 只有数组或嵌入文档中的字段才能以美元符号($)开头。对于顶级字段,名称不能以美元($)符号开头。

  3. 不支持具有重复字段名称的文档。根据 MongoDB 文档,当插入具有重复字段名称的文档时,不会抛出错误,但文档也不会被插入。甚至驱动程序会悄悄地丢弃这些文档。然而,在 mongo shell 中,如果插入这样的文档,它会被正确插入。然而,结果文档只会有第二个字段。这意味着第二次出现的字段会覆盖第一个字段的值。

注意

MongoDB(截至版本 4.2.8)不建议字段名称以美元($)符号或点(.)开头。MongoDB 查询语言可能无法正确处理这些字段。此外,驱动程序也不支持它们。

练习 2.04:将数据加载到 Atlas 集群中

现在您已经了解了文档及其结构,可以在业务用例上实施您的学习,并观察 MongoDB 文档。在第一章MongoDB 简介中,您创建了一个 MongoDB Atlas 账户,并在云上初始化了一个集群。您将在这个集群中加载示例数据集。MongoDB Atlas 提供了可以通过执行几个简单步骤加载到集群中的示例数据集。这些示例数据库是大型的、真实的数据集,供练习使用。MongoDB Atlas 中的示例数据集包括以下数据库,每个数据库都有多个集合:

  • sample_mflix

  • sample_airbnb

  • sample_geospatial

  • sample_supplies

  • sample_training

  • sample_weatherdata

在所有这些数据集中,您将在本书中处理sample_mflix数据集。这是一个庞大的数据库,包括超过 23,000 部电影和系列记录,以及它们的评分、评论和其他详细信息。在了解数据库之前,将数据库导入到我们的集群中,并熟悉其结构和组件。

以下是要执行的步骤,以实现所需的结果:

  1. 访问cloud.mongodb.com/,并点击登录到您的账户:图 2.12:Atlas 登录页面

图 2.12:Atlas 登录页面

由于您已经在云上创建了一个集群,登录后将显示以下显示集群详细信息的屏幕:

图 2.13:集群视图

图 2.13:集群视图

  1. 点击COLLECTIONS旁边的()选项。将出现一个下拉列表,显示以下选项。点击“加载示例数据集”:图 2.14:加载示例数据集选项

图 2.14:加载示例数据集选项

这将打开一个确认对话框,显示将加载到您的集群中的示例数据集的总大小:

图 2.15:加载示例数据集确认

图 2.15:加载示例数据集确认

  1. 点击“加载示例数据集”。您将在屏幕上看到一条消息,显示“正在加载您的示例数据集…”:图 2.16:加载您的示例数据集…窗口

图 2.16:加载您的示例数据集…窗口

加载数据并重新部署集群实例可能需要几分钟时间。

  1. 数据集成功加载后,您将看到一个成功消息,显示“示例数据集成功加载”:图 2.17:示例数据集成功加载

图 2.17:示例数据集成功加载

数据集加载完成后,您还可以看到图表,显示有关数据集上执行的读取和写入操作数量、总连接数以及数据集的总大小的信息。

  1. 现在,点击COLLECTIONS。在下一个屏幕上,您将看到以下可用数据库的列表:图 2.18:示例数据库列表

图 2.18:示例数据库列表

  1. 点击sample_mflix旁边的向下箭头。

  2. 选择movies集合。

您的前 20 个文档的结果将显示如下:

图 2.19:集群上的电影集合

图 2.19:集群上的电影集合

在这个练习中,我们成功将sample_mflix数据库加载到了我们的集群中。现在,让我们进行一个简单的活动,帮助我们将本章学到的所有内容付诸实践。

活动 2.01:将推文建模为 JSON 文档

现在您已经了解了 JSON 文档、MongoDB 支持的数据类型以及基于文档的存储模型,是时候练习将现实生活中的实体建模为有效的 JSON 文档格式了。

您的任务是准备一个有效的 JSON 文档来表示推文的数据。为此,请使用图 2.20中显示的虚拟推文,从这条推文中识别出所有各种信息,确定字段名称和它们可以表示的数据类型,准备一个包含所有字段的 JSON 文档,并验证您的文档:

图 2.20:示例推文

图 2.20:示例推文

以下步骤将帮助您实现期望的结果:

  1. 列出您在推文中看到的所有对象,例如用户 ID、名称、个人资料图片、推文文本、标签和提及。

  2. 识别可以分组在一起的一组相关字段。这些字段组可以作为嵌入对象或数组放置。

  3. 创建 JSON 文档后,使用在线可用的任何 JSON 验证器对其进行验证(例如,jsonlint.com/)。

以下代码表示最终的 JSON 文档,只显示了一些字段:

{
  "id": 1,
  "created_at": "Sun Apr 17 16:29:24 +0000 2011",
  "text": "Tweeps in the #north. The long nights are upon us..",
  ...,
  ...,
  ...
}

注意

此活动的解决方案可以通过此链接找到。

摘要

在本章中,我们已经涵盖了 MongoDB 文档和基于文档的模型的详细结构,在我们深入研究即将到来的更高级概念之前,这是很重要的。我们从以 JSON 样式的文档形式传输和存储信息开始讨论,这提供了一种灵活的、与语言无关的格式。我们研究了 JSON 文档的概述、文档结构和基本数据类型,接着是 BSON 文档规范,以及在各种参数上区分 BSON 和 JSON。

然后,我们涵盖了 MongoDB 文档,考虑到它们的灵活性、自包含性、关联性和灵活性,以及 BSON 提供的各种数据类型。最后,我们注意到了 MongoDB 文档的限制和限制,并学习了为什么会施加这些限制以及它们为什么重要。

在下一章中,我们将使用 mongo shell 和 Mongo Compass 连接到实际的 MongoDB 服务器,并管理用户身份验证和授权。

第三章:服务器和客户端

概述

本章介绍了 MongoDB Atlas Cloud 服务的网络和数据库访问安全性。您将了解 MongoDB 客户端以及如何连接客户端到云数据库以运行 MongoDB 命令。您将使用 Atlas Cloud 安全配置创建和管理用户身份验证和授权,并为 MongoDB 数据库创建用户帐户。连接到 MongoDB 数据库后,您将探索用于 MongoDB 服务器命令的 Compass GUI 客户端。

介绍

我们已经在云中探索了 MongoDB 数据库的基础知识,并了解了 MongoDB 与其他数据库的不同之处。第二章文档和数据类型解释了 MongoDB 中使用的数据结构。到目前为止,您已经知道如何连接到您的 MongoDB Atlas 控制台,以及如何使用数据浏览器浏览数据库。在本章中,您将继续探索 MongoDB 的世界,并连接和访问新的 MongoDB 数据库,发现其内部架构和命令。

在当今世界,互联网和云计算是现有和未来应用程序制定规则的主要驱动力。到目前为止,我们已经了解到 MongoDB Atlas 是 MongoDB 的强大云版本,为客户提供性能、安全性和灵活性。虽然云基础设施为用户提供了许多好处,但也增加了与存储在云中的数据相关的安全风险。网络安全事件经常在新闻中出现。2013 年,塔吉特公司成为大规模网络攻击的受害者,超过 1 亿客户的个人数据被盗。

MongoDB Atlas 服务的一个优势是许多安全功能默认启用,从而防止互联网攻击。因此,了解配置 Atlas 安全的基础知识非常重要。

考虑这样一个场景,您正在基于 MongoDB 的项目上工作。IT 部门的同事已经在 Atlas Cloud 中部署了一个新的 MongoDB 数据库,并向您发送了连接详细信息。然而,经过查看后,您发现由于网络和用户访问的安全规则,您无法连接到新的数据库。首先要配置的是为自己提供对新数据库的访问权限。您还需要确保未经授权的互联网访问将继续被禁用。

要配置对项目数据库的访问,有两个关键方面需要牢记:

  • 网络访问:配置 IP 网络访问

  • 数据库访问:配置用户和数据库角色

网络访问

在安装和运行数据库之后,第一步是能够成功连接到我们的数据库。网络访问是 Atlas Cloud 中部署的数据库可用的低级安全配置。

对于安装在笔记本电脑上的数据库,通常不需要配置任何网络安全性。连接是指向本地安装的数据库。然而,对于部署在云基础设施上的数据库,默认情况下启用了安全性并且需要进行配置。非常重要的是保护对数据库的访问,以防止未经授权的互联网访问。在学习如何在 MongoDB 中配置网络访问之前,让我们先了解一些其核心概念。

网络协议

互联网协议IP)是一个有几十年历史的标准,传输控制协议/互联网协议TCP/IP)是所有应用程序用来可靠地在互联网上传输数据包的传输协议。互联网上的每台计算机或设备都有其独特的 IP 地址或主机名。设备之间的通信是通过在网络数据包头中包括源 IP 地址和目标 IP 地址来实现的。

注意

网络数据包头是数据包开头的附加数据,包含有关数据包携带的数据的信息。这些信息包括源 IP、目标 IP、协议和其他信息。

MongoDB 在使用 TCP/IP 作为其传输数据的网络协议方面并没有例外。此外,目前有两个版本的 IP:IPv4 和 IPv6。Atlas Cloud 平台支持这两个版本。IPv4 定义了标准的 4 字节(32 位)地址,而 IPv6 定义了标准的 16 字节(128 位)地址。

IPv4 和 IPv6 都用于指定互联网上设备的完整地址。最新的标准 IPv6 旨在克服 IPv4 协议的限制。IP 地址有两部分:IP 网络和 IP 主机地址。子网掩码是一系列位(掩码),用于指示 IP 地址的网络和主机部分。网络地址是 IP 地址的前缀,而主机的地址是剩余部分(IP 地址的后缀):

图 3.1:IP 地址的图解表示

图 3.1:IP 地址的图解表示

图 3.1中,子网掩码 255.255.0.0(或二进制格式中的(1111 1111)。(1111 1111)。(0000 0000)(0000 0000))充当掩码,指示 IP 地址的网络和 IP 主机部分。IP 地址的网络部分(前缀)由 IPv4 地址的前 16 位 100.100 组成,而主机地址是地址的其余部分-20.50。

MongoDB Atlas 使用无类别域间路由CIDR)表示法来指定 IP 地址,而不是 IP 子网掩码。CIDR 格式是一种更短的格式,用于描述 IP 网络和主机格式。此外,CIDR 比旧的 IP 子网掩码表示法更灵活。

以下是一个子网掩码及其等效 CIDR 表示法的示例:

图 3.2:子网掩码及其 CIDR 表示法

图 3.2:子网掩码及其 CIDR 表示法

它们都描述了相同的 IP 网络- 54.175.147.0(从左边的 24 位,或 3 个字节),和主机号-155。在这个网络中可能有 254 个主机(从 1 到 254)。

注意

本课程的目标不是提供互联网网络标准的全面指南。有关更多详细信息,请参阅理解 TCP/IP (www.packtpub.com/networking-and-servers/understanding-tcpip),这是 TCP/IP 协议的清晰和全面指南。

公共 IP 地址与私有 IP 地址

如前所述,连接到互联网的任何设备都需要一个唯一的 IP 地址,以便与其他服务器通信。这些类型的 IP 地址称为公共IP 地址。除了公共 IP 地址,互联网标准还定义了一些保留供私人使用的 IP 地址,称为私有IP 地址。这些在企业环境中更常用,需要限制员工访问私人网络(内部网络)而不是让他们访问公共互联网。

以下表格描述了 IP 版本 4 可用的私有 IP 地址。

图 3.3:IP4 的私有 IP 地址

图 3.3:IP4 的私有 IP 地址

另一方面,公共 IP 地址在互联网上是唯一的,可以有与图 3.3中不同的任何值。

域名服务器

让我们考虑一个例子,IP 地址52.206.222.245是 MongoDB 网站的公共 IP 地址:

C:\>ping mongodb.com
Pinging mongodb.com [52.206.222.245] with 32 bytes of data:
Reply from 52.206.222.245: bytes=32 time=241ms TTL=48
Reply from 52.206.222.245: bytes=32 time=242ms TTL=48
Reply from 52.206.222.245: bytes=32 time=243ms TTL=48
Ping statistics for 52.206.222.245:
    Packets: Sent = 3, Received = 3, Lost = 0 (0% loss),
Approximate round trip times in milli-seconds:
    Minimum = 241ms, Maximum = 250ms, Average = 244ms

正如你所看到的,我们使用名称mongodb.com来运行 ping 命令,而不是直接使用 MongoDB 网站的 IP 地址。mongodb.com,DNS 服务器会响应该主机和域名注册的公共 IP 地址:IP54.175.147.155

传输控制协议

传输控制协议TCP)是 IP 地址的一部分,定义了可以用于不同类型网络连接的套接字或端口。每个需要通过互联网通信的进程都使用 TCP 端口建立连接。

MongoDB 服务器的默认 TCP 端口是 27017。在 MongoDB Atlas 免费版中,无法更改默认 TCP 端口。这是 Atlas 免费版 M0 服务器的限制之一。但是,在本地安装时,可以在启动服务器时配置 TCP 监听器端口。

MongoDB Atlas Cloud 始终使用专门的网络加密协议 TLS(传输层安全)加密服务器和应用程序之间的网络通信。数据受到保护。

有几个重要的 TCP/IP 通信方面需要记住:

  • 服务器始终在 TCP 端口 27017 上监听来自客户端的新连接。

  • 客户端始终通过发送特殊的 TCP 数据包来初始化与服务器的连接。

  • 如果配置了网络访问,客户端可以与数据库服务器建立 TCP 连接。

  • 只有客户端通过了安全检查,服务器才会接受连接。

  • Atlas Cloud 中的数据库的网络通信始终是加密的。

  • 一旦连接建立,客户端通过发送数据库命令和接收数据与服务器通信。

Wire 协议

在内部,MongoDB 以一种称为二进制 JSONBSON)的特殊二进制格式存储文档。我们在第二章文档和数据类型中了解了 JSON 文档的结构。BSON 比 JSON 更有效地存储数据。因此,MongoDB 使用 BSON 来在文件中存储数据和在网络上传输数据。

Wire 协议是 MongoDB 将 BSON 数据封装为可以通过互联网发送的网络数据包的解决方案。Wire 协议定义了标准数据报文或数据包的格式,可以被 MongoDB 服务器和客户端理解。数据报文的结构由头部和主体组成,由 MongoDB 定义的简单但严格的格式。Wire 协议数据报文也封装在 TCP/IP 数据包中,如下图所示:

图 3.4:封装的 Wire 协议数据报

图 3.4:封装的 Wire 协议数据报

网络访问配置

Atlas 项目所有者或集群管理器可以从 Atlas Web 管理控制台修改网络访问。登录 Atlas 控制台后,您可以从 Atlas Web 控制台的SECURITY菜单中访问Network Access选项卡:

图 3.5:MongoDB Atlas 控制台

图 3.5:MongoDB Atlas 控制台

Network Access配置页面显示在页面的右侧。MongoDB Atlas 包括三种管理网络访问的方法,可以使用以下选项卡访问:

  • IP 访问列表

  • 对等

  • 私有端点

IP 访问列表

IP 访问列表帮助 Atlas 管理员指定允许连接到 MongoDB 数据库的有效 IP 地址列表。要添加您的第一个 IP 地址,您可以单击页面中间的绿色按钮ADD IP ADDRESS

注意

如果您已经添加了一个 IP 地址(或几个 IP 地址),则+ ADD IP ADDRESS按钮将显示在网络访问 IP 列表的右侧,如图 3.6所示。

图 3.6:添加 IP 地址列表

图 3.6:添加 IP 地址列表

当您单击ADD IP ADDRESS按钮(或+ ADD IP ADDRESS)时,将弹出一个窗口:

图 3.7:添加新的 IP 访问列表条目

图 3.7:添加新的 IP 访问列表条目

添加 IP 访问列表表单中提供以下选项:

  • 添加当前 IP 地址:这是用于简单部署的最常见方法。它允许您将自己的 IP 地址添加到 IP 访问列表中,如图 3.7所示。Atlas 会自动从 Web 管理控制台的当前会话中检测 IP 源地址,因此您无需记住 IP 地址。您的计算机很可能具有来自私有 IP 类的内部 IP 地址,例如 192.168.0.xx,这与 Atlas 检测到的地址有很大不同。这是因为 Atlas 始终检测网络网关的外部 IP 地址,而不是内部网络私有 IP 地址。私有 IP 地址在互联网上不可见。您可以通过在 Google 中搜索“我的 IP 是多少?”来验证您的外部 IP 地址。Google 搜索的结果应与 Atlas 中的地址匹配。

  • 允许从任何地方访问:顾名思义,此选项通过禁用数据库的网络保护来启用从任何位置访问数据库,如图 3.7所示。特殊的 IP 类 0.0.0.0/0 被添加到 IP 访问列表中。

注意

不建议选择允许从任何地方访问的选项,因为这将禁用网络安全保护,并将我们的云数据库暴露给可能的攻击。

在向IP 列表条目字段添加自定义 IP 地址时,IP 地址需要以 CIDR 表示法表示,如本章介绍中所述。还可以在“注释”字段中输入简短的描述,如图 3.8所示。

图 3.8:填写 IP 访问列表条目中的注释字段

图 3.8:填写 IP 访问列表条目中的注释字段

注意

在当前版本的 Atlas 控制台中,无法将主机名或完全限定域名FQDN)添加到 IP 访问列表中。只有 IP 地址被接受为有效条目。MongoDB Atlas 支持 IPv4 和 IPv6。例如,无法添加主机名,如server01prdserver01prd.mongodb.com(包括域),而应添加主机的公共 IP 地址。IP 地址可以通过 DNS 查找或只是 ping 主机名来获取。

临时访问

访问列表中的条目可以是永久的,也可以具有过期时间。临时条目在过期时会自动从列表中删除。如果要添加临时 IP 地址,请在“添加 IP 访问列表条目”表单中勾选“此条目是临时的,将在...被删除”选项,如图 3.9所示。您可以使用下拉菜单指定过期时间:

图 3.9:添加临时 IP 访问列表条目

图 3.9:添加临时 IP 访问列表条目

单击“确认”后,IP/主机地址将保存在访问列表中,并且网络配置将被激活。该过程通常在一分钟内完成,在此期间,条目状态将在几秒钟内显示为“待处理”,如图 3.10所示:

图 3.10:显示待处理状态的网络访问窗口

图 3.10:显示待处理状态的网络访问窗口

网络配置激活后,“状态”将显示为“活动”,如图 3.11所示:

图 3.11:网络访问窗口

图 3.11:网络访问窗口

注意

屏幕上会出现一条消息,提示用户可用 IP 地址列表,如图 3.11所示。

在 IP 保存在 IP 访问列表后,管理员可以修改条目。可以从“操作”选项卡中访问以下操作的权限,如图 3.11所示:

  • 通过单击“删除”来删除 IP 访问列表中的现有条目。

  • 通过单击“编辑”来编辑 IP 访问列表中的现有条目。

注意

您可以将多个 IP 地址添加到访问列表中。例如,如果您需要从办公室和家中访问云数据库,可以将两个 IP 地址都添加到访问列表中。然而,请注意,最多只能添加 200 个地址到列表中。

网络对等连接

网络对等连接是 Atlas Cloud 基础设施上控制网络访问的另一种方法,与 IP 访问列表不同。它使公司能够在本地公司网络和 Atlas 网络基础设施之间建立虚拟专用云VPC)连接,如下所示:

  • 私人 IP 网络用于配置客户私人网络和 MongoDB Atlas 服务器之间的 VPC。任何类型的私人 IP 都支持 VPC 网络对等连接。

  • 所有云提供商都支持网络对等连接,例如 AWS 的、微软的或谷歌的云基础设施。

  • 网络对等连接仅适用于大型实施(M10+),因此不适用于 Atlas 免费用户。

注意

网络对等连接和私有端点的详细信息超出了本入门课程的范围。

练习 3.01:启用网络访问

在本练习中,您将使用 Atlas Web 管理控制台为云中的新数据库启用网络访问。这是允许通过互联网进行网络连接所必需的。

该练习将指导您完成将您自己的 IP 地址添加到访问列表的步骤。结果,将允许从您的位置进行网络访问,并且您将能够使用在本地计算机上运行的客户端连接到 MongoDB 数据库。按照以下步骤完成此练习:

  1. 转到cloud.mongodb.com连接到 Atlas 控制台。

  2. 使用您在注册 Atlas Cloud 时创建的用户名和密码登录到新的 MongoDB Atlas Web 界面:图 3.12:MongoDB Atlas 登录页面

图 3.12:MongoDB Atlas 登录页面

  1. 从“安全”菜单中,点击“网络访问”选项卡:图 3.13:网络访问窗口

图 3.13:网络访问窗口

  1. 在“IP 访问列表”选项卡中单击“添加 IP 地址”。

  2. 在出现的“添加 IP 访问列表条目”窗口中,单击“添加当前 IP 地址”按钮:图 3.14:IP 访问列表窗口

图 3.14:IP 访问列表窗口

MongoDB Web 界面将自动检测您的外部 IP 地址,并将其反映在“IP 访问列表条目”字段中。

  1. 在“注释”字段中键入This is my IP Address(这是可选的):图 3.15:在添加 IP 访问列表条目窗口中输入注释

图 3.15:在添加 IP 访问列表条目窗口中输入注释

  1. 单击“确认”按钮以保存新条目。Atlas 正在将新的 IP 访问列表规则部署到云系统

  2. IP 地址将出现在访问列表表中(作为活动状态):图 3.16:网络访问窗口

图 3.16:网络访问窗口

注意

IP100.100.10.10/32是一个示例虚拟 IP 地址。在您的实际情况中,IP 地址将是您自己的公共 IP 地址,而且您的 ISP(互联网服务提供商)可能会分配给您一个动态 IP 地址,这是不固定的,并且可能在一段时间后更改。

我们已成功将当前公共 IP 地址列入 Atlas Cloud 控制台的白名单,以便允许来自我们公共 IP 地址的 TCP/IP 连接。如果您有多个位置,比如家里和办公室,可以在 Atlas 控制台的访问列表中添加多个 IP 地址。

数据库访问

部署在 Atlas 云上的 MongoDB 数据库默认启用了几个安全功能,例如用户访问控制。数据库访问控制验证用户的身份验证凭据,例如用户名和密码。因此,即使可以从任何地方访问网络,您仍需要进行身份验证才能成功连接到云中的 MongoDB 数据库。这是为了保护部署在云中的数据库免受未经授权的互联网访问。更重要的是,与其他安全功能相比,访问控制无法在云数据库中禁用,并且始终保持启用状态。

数据库访问涵盖数据库安全的以下方面:

  • 数据库用户

  • 数据库角色

与其他 MongoDB 安装相比,在 Atlas 云中管理用户帐户是在项目级别进行配置的。在一个 Atlas 项目中创建的用户将在该项目中创建的所有 MongoDB 数据库集群中共享。本章节涵盖了配置 Atlas 数据库安全(用户和角色)的基本方法。

注意

数据库访问仅涉及对部署在 Atlas 中的数据库服务的访问,而不涉及 Atlas 控制台本身。作为 Atlas 项目所有者,您始终可以连接到 Atlas Web 控制台以管理您的云数据库访问。如果需要向 Atlas 项目添加更多项目团队成员,则可以从 Atlas Web 应用程序的“项目”选项卡中进行操作。在本课程范围内,这些示例在连接为 Atlas 项目所有者时是相关的。

用户身份验证

验证用户身份是数据库安全的一个重要方面,也是为了保护数据完整性和机密性而必要的。这正是为什么所有部署在 Atlas 云中的 MongoDB 数据库在创建新的数据库会话之前都需要对用户进行身份验证的原因。因此,只有受信任的数据库用户才被授予对云数据库的访问权限。

数据库身份验证过程包括在连接之前验证用户身份的过程。

用户身份必须符合以下两个参数:

  • 连接时必须提供有效的用户名。

  • 用户的身份必须通过验证确认。

声明有效的用户名很简单。唯一的先决条件是用户名必须存在,这意味着用户名必须先前已创建,并且其帐户必须已激活。

用户名存储

用户需要在 Atlas 中声明后才能使用。用户名和密码可以存储在内部(数据库内部)或外部(数据库外部),如下所示:

  • 内部:用户名存储在 MongoDB 数据库中的特殊集合中,位于 admin 数据库中。有一些限制。admin 数据库仅对系统管理员可访问。当用户尝试连接时,用户名必须存在于 admin 数据库中现有用户名列表中。

  • 外部:用户名存储在外部系统中,例如轻量目录访问协议(LDAP)。例如,Microsoft Active Directory 是一个可以配置为 MongoDB 用户名身份验证的 LDAP 目录实现。

注意

LDAP 身份验证仅适用于更大的 Atlas 集群(M10+),并允许对许多数据库用户帐户进行企业特定配置。此配置不在本入门课程中涵盖。

用户名身份验证

身份验证是验证用户身份的过程。如果用户身份验证成功,则用户将被确认并信任访问数据库。否则,用户将被拒绝,并且将不被允许建立数据库连接。以下是一些身份验证机制,每种机制都具有不同的技术和安全级别。

密码身份验证

  • 简单密码身份验证。用户需要提供正确的密码。数据库系统会根据声明的用户名验证密码。在互联网上安全验证用户密码的过程称为握手挑战-响应

  • 密码由 MongoDB 数据库验证。在 LDAP 身份验证的情况下,密码会在外部进行验证。自 4.0 版本以来,MongoDB 有一种新的挑战-响应方法来验证密码,称为Salted Challenge Response Authentication MechanismSCRAM)。SCRAM 保证用户密码可以在互联网上安全验证,而无需传输或存储明文密码。这是因为在互联网的公共基础设施上传输明文密码被认为是极其不安全的。

  • 在较旧版本的 MongoDB 中,使用了不同的挑战-响应方法。如果您将应用程序从 MongoDB 2.0 或 3.0 升级到最新版本,请验证 MongoDB 客户端与 MongoDB 4.0 或更高版本的兼容性。在撰写本文时,MongoDB 服务器的本地当前版本是 4.4 版本。

X.509 证书认证

  • 这指的是使用加密证书进行用户身份验证,而不是简单密码。证书比密码更长,更安全。

  • X.509 证书是使用密码学标准公钥基础设施PKI)创建的数字加密密钥。证书是以一对密钥(公钥-私钥)创建的。

  • 这种方法还允许用户进行无密码身份验证,允许用户和应用程序使用私钥 X.509 证书进行连接。

在 Atlas 中配置身份验证

建议仅使用 Atlas Web 应用程序来创建和配置数据库用户。

Atlas 项目所有者可以通过 Atlas Web 界面向 Atlas 项目添加用户并配置用户的身份验证。Atlas 用户可以添加到相应 Atlas 项目中的所有数据库集群。单击 Atlas 应用程序中的Database Access即可提供身份验证设置。

这是 Atlas Web 应用程序的屏幕截图(cloud.mongodb.com):

图 3.17:数据库访问窗口

图 3.17:数据库访问窗口

图 3.17中,您会注意到两个选项卡,数据库用户自定义角色。让我们首先关注数据库用户的可用选项。当您单击ADD NEW DATABASE USER选项创建新用户时,将出现以下窗口:

图 3.18:添加新数据库用户窗口

图 3.18:添加新数据库用户窗口

注意

密码 SCRAM 身份验证是 Atlas M0 免费集群的唯一选项,该集群用于本课程的示例。其他身份验证方法选项,如证书和 AWS IAM,适用于更大的 Atlas M10+集群。

窗口中有两个字段,如图 3.19所示:

图 3.19:添加新数据库用户窗口中的用户名和密码字段

图 3.19:添加新数据库用户窗口中的用户名和密码字段

在第一个字段中,您可以输入新的数据库用户名。用户名不应包含空格或特殊字符。只允许 ASCII 字母、数字、连字符和下划线。

第二个字段是用户密码。管理员可以手动输入密码,也可以由 Atlas 应用程序生成。自动生成安全密码按钮会自动生成一个安全、复杂的密码。SHOWHIDE选项将在屏幕上显示或隐藏密码输入。还有一个选项,可以通过单击COPY按钮将密码复制到剪贴板,如图 3.19所示。

临时用户

Atlas 管理员可以决定添加临时用户账户。临时用户账户是仅在有限期限内有效的账户。账户将在到期时间后由 Atlas 自动删除:

图 3.20:在添加新用户窗口中的临时用户选项

图 3.20:在添加新用户窗口中的临时用户选项

在上面的示例中,用户账户my_user被设置为在 1 天(24 小时)后自动过期。选择了“保存为临时用户”,并设置了规定的时间。

注意

从“内置角色或特权”下拉菜单中,管理员可以在创建新用户时分配数据库特权。默认情况下,分配的特权是“读写任何数据库”。数据库特权选项将在下一节中详细解释。

添加用户按钮完成了添加新用户的过程。一旦用户账户创建完成,它将出现在 MongoDB 用户列表中,如图 3.21所示。如果需要,可以更改或删除用户账户。用户账户的详细信息可以使用编辑删除选项在操作选项卡中进行更改或删除:

图 3.21:数据库访问窗口

图 3.21:数据库访问窗口

注意

正如您在图 3.21中所看到的那样,my_user账户被设置为在 24 小时后自动过期(23:57)。用户账户将在到期时间后自动删除。

数据库特权和角色

数据库授权是数据库安全的一部分,涵盖了 MongoDB 数据库的特权和角色。一旦您成功验证用户并创建新的数据库会话,数据库特权和角色将分配给用户。数据库集合和对象的可访问性将根据分配给用户的数据库特权进行验证。

特权(或操作)是在 MongoDB 数据库中对特定数据库资源执行特定操作或操作的权利。例如,读取特权授予对特定数据库集合或视图进行查询的权利。

多个数据库特权可以被分组在一个角色中。MongoDB 中有一长串的数据库特权,每个特权都用于 MongoDB 中的不同功能。特权不是直接分配给用户,而是分配给角色,然后这些角色再分配给用户。因此,数据库中特权和角色的管理更容易理解:

图 3.22:数据库特权的图示表示

图 3.22:数据库特权的图示表示

角色可以具有全局或本地范围:

  • GLOBAL:此角色适用于所有 MongoDB 数据库和集合。

  • Database:此角色仅适用于特定数据库名称。

  • Collection:此角色仅适用于数据库中特定集合名称。它具有最严格的范围。

预定义角色

有一些预定义的数据库角色,对于每个角色,都有一系列特定的特权。例如,管理员角色包含了管理 MongoDB 数据库所需的所有特权。分配预定义角色是管理 MongoDB 数据库的最常见方式。

如果预定义的角色都不符合应用程序的安全要求,可以在 MongoDB 中定义自定义角色。以下角色在 Atlas 应用程序中预定义,并且可以在创建新数据库用户时分配:

  • dbAdminAnyDatabasereadWriteAnyDatabaseclusterMonitor

注意

Atlas admin角色与 MongoDB 数据库dbAdmin角色不同。Atlas admin角色包括dbAdmin以及其他角色,并且仅在 Atlas Cloud 平台上可用。

  • 读写任何数据库:此 Atlas 角色具有对任何数据库的读写权限,并适用于在一个 Atlas 项目账户中创建的所有数据库集群。

  • 仅读取任何数据库:这是一个只读的 Atlas 角色,适用于在一个 Atlas 项目帐户中创建的所有数据库集群。

在 Atlas 中配置内置角色

在创建新用户时,分配内置角色的最简单方法是在创建新用户时。Atlas 提供了一个非常简单直观的界面来添加新的数据库用户。在创建新用户时会分配默认的内置角色或权限。然而,管理员可以为新用户分配不同的角色,或者可以编辑现有用户的权限。

注意

强烈建议仅使用 Atlas Web 界面来管理数据库角色和权限。Atlas 将自动禁用并回滚通过 Atlas Web 界面之外进行的任何更改数据库角色的更改。

Atlas 中的用户角色可以在“+添加新用户”窗口或“编辑”用户窗口中进行管理,如前一节所述:

图 3.23:添加新数据库用户窗口

图 3.23:添加新数据库用户窗口

默认情况下,在窗口中自动选择了内置的读取和写入任何数据库角色,如图 3.23所示。然而,管理员可以通过单击下拉菜单来分配不同的角色(例如Atlas 管理员),如图 3.24所示:

图 3.24:在“添加新用户”窗口中选择角色

图 3.24:在“添加新用户”窗口中选择角色

高级权限

有时,内置的 Atlas 数据库角色都不适合我们对数据库的访问需求。有时候,预期的数据库设计需要特殊的用户访问,或者应用程序需要实施特定的安全策略。

注意

稍后在本章中介绍的自定义角色比高级权限提供了���好的功能。始终建议创建自定义角色,并为角色分配单独的权限,而不是直接为用户分配特定的权限。

如果从下拉列表中选择授予特定权限,界面会发生变化:

图 3.25:在“添加新用户”窗口中授予特定权限

图 3.25:在“添加新用户”窗口中授予特定权限

图 3.25所示,管理员可以快速为用户分配特定的 MongoDB 权限。这种高级功能将在本章后面的自定义角色中介绍。目前,让我们在以下练习中配置数据库访问。

练习 3.02:配置数据库访问

此练习的目标是为您的新 MongoDB 数据库启用数据库访问。您的数据库现在允许连接,并且正在请求用户名和密码验证。为了启用访问,您需要创建一个新用户,并为其授予适当的数据库权限。

创建一个用户名为admindb的管理员用户。

按照以下步骤完成此练习:

  1. 重复步骤 123练习 3.01启用网络访问,以登录到您的新 MongoDB Atlas Web 界面并选择project 0

  2. 安全菜单中,选择数据库访问选项:图 3.26:选择数据库访问选项

图 3.26:选择数据库访问选项

  1. 数据库用户选项卡中单击添加新数据库用户以添加新的数据库用户。将打开“添加新用户”窗口。

  2. 保持默认的身份验证方法,密码

  3. 提供用户名或输入admindb作为用户名。

  4. 提供密码或单击自动生成安全密码以生成密码。单击显示以查看自动生成的密码:图 3.27:添加新数据库用户窗口

图 3.27:添加新数据库用户窗口

  1. 单击数据库用户权限下拉菜单,并选择Atlas 管理员角色。

  2. 单击添加用户。系统将对数据库应用更改:图 3.28:新管理员用户详细信息

图 3.28:新管理员用户详细信息

图 3.28中,您可以看到已创建了一个名为admindb的新用户,其认证方法SCRAMMongoDB 角色(全局)设置为项目中所有数据库的atlasAdmin@admin

新的数据库用户现在已在 Atlas 中配置和部署。

配置自定义角色

顾名思义,自定义角色是选定的数据库权限集合,不包括在任何内置的 Atlas 数据库角色中。例如,如果需要读取和更新权限,但没有删除和插入新文档的权限,则需要创建自定义角色,因为这种权限组合不是任何内置角色的一部分。

从“数据库访问”窗口中,单击应用程序中的第二个选项卡“自定义角色”。此选项用于创建和修改自定义 Atlas 角色。

注意

在分配给用户之前,自定义角色需要在 Atlas 中定义。

可以通过单击“添加新自定义角色”按钮来创建新的自定义角色。将出现新的自定义角色窗口:

图 3.29:MongoDB 自定义角色

图 3.29:MongoDB 自定义角色

可以根据以下类别选择操作:

  • 集合操作:适用于集合数据库对象的操作

  • 数据库操作:适用于数据库的操作

  • 全局操作:适用于所有 Atlas 项目的全局操作

例如,数据库管理员只允许用户更新数据库集合。用户不能删除或插入新文档到集合中。这种特定的操作组合不包含在任何 Atlas 预定义角色中。

在一个复杂角色下可能定义许多集合/数据库/全局操作的组合。定义完成后,单击“添加自定义角色”按钮在 Atlas 中创建新角色。新角色将在列表中可见,如图 3.30所示:

图 3.30:自定义角色列表

图 3.30:自定义角色列表

注意

创建自定义角色后,它们将在 Atlas 中可见,并可以分配给数据库用户。可以从“添加/编辑”用户窗口中的“数据库权限”下拉列表中的“选择预定义的自定义角色”中分配新的自定义角色。

数据库客户端

在我们介绍 MongoDB 数据库不同类型的客户端之前,让我们先看一下简短的介绍,以澄清数据库客户端的基础知识。数据库客户端是一个旨在执行以下操作的软件应用程序:

  • 连接到 MongoDB 数据库服务器

  • 从数据库服务器请求信息

  • 通过发送 MongoDB CRUD 请求修改数据

  • 向数据库服务器发送其他数据库命令

与 MongoDB 数据库服务器的交互和兼容性至关重要。例如,客户端和服务器之间的兼容性差异(例如,不同版本)可能会产生意外结果或生成数据库或应用程序错误。这就是为什么客户端通���会经过特定版本的 MongoDB 数据库的兼容性测试和认证的原因。

让我们根据创建目的对 MongoDB 客户端进行分类:

  • 基本:这是客户端的最简版本。通常随数据库软件一起提供,基本客户端提供一个交互式应用程序来与数据库服务器一起工作。

  • 数据导向:这种类型的客户端旨在处理数据。通常提供图形用户界面GUI)和辅助您高效查询、聚合和修改数据的工具。

  • 驱动程序:这些驱动程序旨在提供 MongoDB 数据库与另一个软件系统(如通用编程语言)之间的接口。驱动程序的主要用途是在软件开发和应用部署中。

您现在已经完成了在 Atlas Cloud 中部署的新 MongoDB 数据库的所有配置更改。在之前的章节中已经介绍了在本地计算机上安装 MongoDB 客户端。如果需要,可以查看第一章 MongoDB 简介,了解基本的 MongoDB 安装。下一步是使用本地 MongoDB 客户端连接到云中的新数据库。其次,将使用一组自定义的 Python 脚本进行数据迁移,因此您需要知道如何从 Python 连接到 Atlas 中的 MongoDB 数据库。下一节将讨论 MongoDB 中客户端连接的所有方面。

连接字符串

连接字符串到底是什么,为什么它很重要?连接字符串只不过是一种标识数据库服务地址及其参数的方法,以便客户端可以通过网络连接到服务器。它很重要,因为没有连接字符串,客户端将不知道如何连接到数据库服务。

数据库客户端,如用户和应用程序,需要形成一个有效的连接字符串,以便能够连接到数据库服务。此外,MongoDB 连接字符串遵循统一资源标识符URI)格式,以将所有连接详细信息传递给数据库客户端。

以下是 MongoDB 连接字符串的一般格式:mongodb+srv://user:pass@hostname:port/database_name?options

连接字符串的各个元素在下表中描述:

图 3.31:连接字符串的各个元素

图 3.31:连接字符串的各个元素

注意

关于新前缀mongodb+srv以及如何使用 DNS SRV 记录来识别 MongoDB 服务的更多细节将在第十章 复制中介绍。

现在让我们看一些连接字符串的例子,如下所示:

mongodb+srv://guest:passwd123@atlas1-u7xxx.mongodb.net:27017/data1

此连接字符串适合尝试使用以下参数进行数据库连接:

  • 服务器在 Atlas Cloud 上运行(主机名为mongodb.net)。

  • 数据库集群名称为atlas1

  • 尝试使用用户名guest和密码passwd123进行连接。

  • 数据库服务在标准 TCP 端口27017上提供。

  • 服务器上的默认数据库名称是data1

虽然前面的连接字符串对 Atlas 数据库连接是有效的,但通常不建议在连接字符串中显示密码。以下是一个在连接时请求密码的例子:

mongodb+srv://guest@atlas1-u7xxx.mongodb.net:27017/data1

另一个例子如下:

mongodb+srv://atlas1-u7xxx.mongodb.net:27017/data1 --username guest

在这种情况下,使用guest 用户名尝试连接。但是,密码不是连接字符串的一部分,它将在连接时由服务器请求。

如果省略了数据库名称(或者无效),则会尝试连接到默认数据库,即 admin 数据库。此外,如果省略了 TCP 端口,它将尝试连接到默认的 TCP 端口 27017,如下例所示:

mongodb+srv://guest@atlas1-u7xxx.mongodb.net

对于非云数据库连接或传统的 MongoDB 连接,应使用简单的mongodb前缀。以下是一些非云连接字符串的例子:

mongodb://localhost/data1

在这个例子中,主机名是localhost,这意味着数据库服务器在与应用程序相同的计算机上运行,并尝试连接到数据库data1。以下是另一个在非默认 TCP 端口5500上进行远程网络连接的例子:

mongodb://devsrv01.dev-domain-example.com:5500/data1

如果连接字符串中未指定用户名,则尝试在没有用户名的情况下进行连接。这种类型的连接适用于没有授权模式(未配置用户安全)的数据库。授权模式始终针对云数据库进行配置。

注意

如果数据库服务配置为复制或分片集群,则 MongoDB 连接字符串可能会有所不同。有关 MongoDB 集群的连接字符串示例将在第十章 复制中提供。

Mongo Shell

连接到 MongoDB 数据库最简单的方法可能是使用 mongo shell。mongo shell 为 MongoDB 数据库提供了一个简单的终端模式客户端:

  • mongo shell 包含在所有 MongoDB 安装中。

  • 它可以用于在终端模式下运行服务器交互命令。

  • 它可以用于运行 JavaScript。

  • mongo shell 有自己的命令。

要启动 mongo shell,请在命令提示符中运行mongo命令,如下所示:

C:\>mongo --help
MongoDB shell version v4.4.0
usage: mongo [options] [db address] [file names (ending in .js)]
db address can be:
  foo                   foo database on local machine
  192.168.0.5/foo       foo database on 192.168.0.5 machine
  192.168.0.5:9999/foo  foo database on 192.168.0.5 machine on port 9999
  mongodb://192.168.0.5:9999/foo  connection string URI can also be used
Options:
  --ipv6                               enable IPv6 support (disabled by
....

练习 3.03:使用 Mongo Shell 连接到云数据库

这个简单的练习将向您展示使用 mongo shell 连接到 Atlas 的步骤。在这个练习中,使用连接字符串中的mongodb+srv前缀。第一步是获取 Atlas Cloud 数据库的集群名称(DNS SRV 记录):

  1. 登录到您的新 MongoDB Atlas web 界面,使用您在注册 Atlas Cloud 时创建的用户名和密码:图 3.32:MongoDB Atlas 登录页面

图 3.32:MongoDB Atlas 登录页面

  1. 点击Atlas项目菜单中的Clusters选项卡,如图 3.33所示。

  2. Clusters菜单中点击CONNECT按钮。在 M0 免费版中,只有一个名为Cluster0的集群:图 3.33:集群窗口

图 3.33:集群窗口

  1. 连接到 Cluster0窗口出现:图 3.34:连接到 Cluster0 窗口

图 3.34:连接到 Cluster0 窗口

  1. 单击使用 mongo shell 连接。将出现以下窗口:图 3.35:连接到 Cluster0 页面

图 3.35:连接到 Cluster0 页面

  1. 选择我已安装 mongo shell选项,并选择正确的 mongo shell 版本(在撰写本文时,最新的 mongo shell 版本是 4.4)。或者,如果您尚未安装 mongo shell,可以选择我尚未安装 mongo shell并安装 mongo shell。

  2. 单击复制以将连接字符串复制到剪贴板。

  3. 在您的操作系统中启动命令提示符窗口或终端。

  4. 使用新的连接字符串命令行启动 mongo shell:

C:\>mongo "mongodb+srv://cluster0.u7n6b.mongodb.net/test" --username admindb

以下详细信息将出现:

MongoDB shell version v4.4.0
Enter password:
connecting to: mongodb://cluster0-shard-00-00.u7n6b.mongodb.net:27017,cluster0-
Implicit session: session { "id" : UUID("7407ce65-d9b6-4d92-87b2-754a844ae0e7") }
MongoDB server version: 4.2.8
WARNING: shell and server versions do not match
MongoDB Enterprise atlas-rzhbg7-shard-0:PRIMARY>

要作为练习 3.02中创建的admindb数据库用户连接到 Atlas 数据库时,当提示时提供admindb用户的密码并完成连接。

成功建立连接后,shell 提示将显示以下详细信息:

MongoDB Enterprise atlas-rzhbg7-shard-0:PRIMARY>

具体细节如下:

  • 企业:这指的是 MongoDB 企业版。

  • atlas1-#####-shard-0:这指的是 MongoDB 副本集名称。我们将在后面更详细地了解这个。

  • PRIMARY>:这指的是 MongoDB 实例的状态,即PRIMARY

注意

您可能会看到一条消息,上面写着警告:shell 和服务器版本不匹配。这是因为 mongo shell 的最新版本是 4.4,而 M0 Atlas 云数据库的版本是 4.2.8。可以忽略此警告。

  1. 输入exit退出 mongo shell。

在这个练习中,您使用 mongo shell 客户端连接到了一个云数据库。为了方便起见,您使用 Atlas 界面复制了我们 Atlas 集群的连接字符串。在实践中,开发人员已经提前准备好了数据库连接字符串,因此他们不需要每次连接数据库时都从 Atlas 应用程序中复制它。

MongoDB Compass

MongoDB Compass 是 MongoDB 中数据可视化的图形工具。它与 MongoDB 服务器安装一起安装,因为 MongoDB Compass 是标准发行版的一部分。另外,MongoDB Compass 也可以单独下载和安装,而无需 MongoDB 服务器软件。

MongoDB Compass 的简单而强大的图形用户界面帮助您轻松查询和分析数据库中的数据。MongoDB Compass 具有一个查询构建器图形界面,大大简化了创建复杂的 JSON 数据库查询的工作。

MongoDB Compass 版本 1.23 如下截图所示:

图 3.36:MongoDB Compass 连接到 Atlas 云

图 3.36:MongoDB Compass 连接到 Atlas 云

以下是标准版本中最重要的 MongoDB Compass 功能:

  • 轻松管理数据库连接

  • 与数据、查询和 CRUD 的交互

  • 高效的图形查询构建器

  • 查询执行计划的管理

  • 聚合构建器

  • 集合索引的管理

  • 模式分析

  • 实时服务器统计信息

除了标准的 MongoDB Compass 版本外,在撰写本章时,还有其他两个版本的 MongoDB Compass 可供下载:

  • Compass 隔离:用于高度安全的环境。Compass 的隔离版本仅向连接的 MongoDB 服务器发起网络请求。

  • Compass 只读:顾名思义,Compass 的只读版本不会更改数据库中的任何数据,仅用于查询。

注意:

MongoDB Compass 社区版本现已停用。您可以使用免费的完整版本 MongoDB Compass,其中包括 MongoDB 模式分析等企业版功能。

MongoDB 驱动程序

有一种误解,即 MongoDB 只是 JavaScript 堆栈的数据库。将 MongoDB 的能力减小并仅将其用于 JavaScript 应用程序是不恰当的。

MongoDB 是一个多平台数据库,具有灵活的数据模型,可用于任何类型的应用程序。此外,几乎每种编程语言都对 MongoDB 有很好的支持。

目前,最有用和最受欢迎的 MongoDB 客户端版本是驱动程序。MongoDB 驱动程序是数据库与软件开发世界之间的粘合剂。目前,对于最流行的编程语言,如 C/C++、C#、Java、Node 和 Python,都有许多驱动程序。

驱动程序 API 是软件库接口,它使得可以直接在编程语言结构中使用 MongoDB 数据库功能。例如,来自 MongoDB 的特定 BSON 数据类型被转换为可以在诸如 Python 之类的编程语言中使用的数据格式。

练习 3.04:使用 Python 驱动程序连接到 MongoDB 云数据库

商业决策通常基于数据分析。有时,为了获得有用的结果,开发人员使用诸如 Python 之类的编程语言来分析数据。Python 是一种强大的编程语言,但易于学习和实践。在这个练习中,您将从 Python 3 连接到 MongoDB 数据库。在使用 Python 连接到 MongoDB 之前,请注意以下几点:

  • 您无需在计算机上本地安装 MongoDB 即可使用 Python 进行连接。

  • Python 库使用pymongo模块连接到 MongoDB。

  • pymongo模块适用于 Python 2 和 Python 3。但是,由于 Python 2 现在已经停止维护,强烈建议在新软件开发中使用 Python 3。

  • MongoDB 客户端是pymongo Python 库的一部分。

  • 您还需要安装 DNSPython 模块,因为 Atlas 连接字符串是 DNS SRV 记录。因此,需要 DNSPython 模块来执行 DNS 查找。

按照以下步骤完成练习:

  1. 验证 Python 版本是否为 3.6 或更高,方法如下:
# Check Python version – 3.6+
# On Windows
C:\>python --version
Python 3.7.3
# On MacOS or Linux OS
$ python3 --version

注意

对于 macOS 或 Linux,Python shell 可以使用python3而不是python来启动。

  1. 安装pymongo之前,请确保安装了 Python 软件包管理器pip
# Check PIP version
# On Windows
C:\>pip --version
pip 19.2.3 from C:\Python\Python37\site-packages\pip (python 3.7)
# On MacOS and Linux
$ pip3 --version
  1. 安装pymongo client,如下:
# Install PyMongo client on Windows
C:\>pip install pymongo
# Install PyMongo client on MacOS and Linux
$ pip3 install pymongo
# Example output (Windows OS)
C:\>pip install pymongo
Collecting pymongo
  Downloading https://files.pythonhosted.org/packages/c9/36/715c4ccace03a20cf7e8f15a670f651615744987af62fad8b48bea8f65f9/pymongo-3.9.0-cp37-cp37m-win_amd64.whl (351kB)
     358kB 133kB/s
Installing collected packages: pymongo
Successfully installed pymongo-3.9.0
  1. 安装dnspython模块,如下:
# Install dnspython on Windows OS
C:\> pip install dnspython
# Install dnspython on MacOS and Linux
$ pip3 install dnspython
# Example output (Windows OS)
C:\> pip install dnspython
Collecting dnspython
  Using cached https://files.pythonhosted.org/packages/ec/d3/3aa0e7213ef72b8585747aa0e271a9523e713813b9a20177ebe1e939deb0/dnspython-1.16.0-py2.py3-none-any.whl
Installing collected packages: dnspython
Successfully installed dnspython-1.16.0

现在你已经准备好了 Python 环境,下一步是获取你的云数据库的正确连接字符串。测试 MongoDB 连接以确认这一点。

  1. 编辑连接字符串并添加你的数据库名称和密码。使用在Exercise 3.02Configuring Database Access中创建的admindb用户名尝试连接:
mongodb+srv://admindb:<password>@<server_link>/<database_name>
  1. 用你的服务器链接替换<server_link>

注意

例如,考虑以下情况,连接字符串如下:

"mongodb+srv://admindb:xxxxxx@cluster0-u7xxx.mongodb.net/test?retryWrites=true&w=majority"

这里,服务器链接可以快速识别为:cluster0-u7xxx.mongodb.net

  1. 用你的数据库名称替换<database_name>,在这种情况下是sample_mflix

  2. admindb用户密码替换<password>

注意

如果你想用不同的用户连接,而不是admindb,请用你的用户名替换admindb,用你的密码替换<password>

  1. 编辑一个 Python 测试脚本来测试你的连接并执行 Python 脚本。在 Windows 中,打开记事本文本编辑器,输入以下 Python 代码:
# Python 3 script to test MongoDB connection
# MongoDB Atlas connection string needs to be edited with your connection
from pymongo import MongoClient
uri="mongodb+srv://admindb:xxxxxx@cluster0-u7xxx.mongodb.net/test?retryWrites=true&w=majority"
client = MongoClient(uri)
# switch to mflix database
mflix = client['sample_mflix']
# list collection names
print('Mflix Collections: ')
for name in mflix.list_collection_names(): 
  print(name)

注意:

不要忘记使用你的 Atlas 连接详细信息更新 URI。如果你使用本例中提供的 URI,那么你将收到连接错误。

  1. 将文本脚本保存为mongo4_atlas.py,例如在C:\Temp\mongo4_atlas.py中。

  2. 运行测试脚本。

在 Windows 的命令提示符中,键入:

"python C:\Temp\mongo4_atlas.py"

在 macOS/Linux shell 提示符中,键入:

"$ python3 ./mongo4_atlas.py " 

脚本的输出将显示数据库中的集合,如下所示:

C:\>python C:\Temp\mongo4_atlas.py 
Mflix Collections: 
comments
users
theaters
sessions
movies
>>>

在这个练习中,你通过使用 Python 等编程语言在云中实际操作 MongoDB。在使用扩展的 Python 库方面,可能性是无限的;你可以创建 Web 应用程序,进行数据分析等。

服务器命令

MongoDB 是一个数据库服务器,它有客户端通过网络连接到服务器。数据库服务器管理数据库,而客户端被应用程序或用户用来从数据库查询数据。如果你想知道是否只有数据库(没有服务器),那么是的,有的。例如,Microsoft Access 就是一个没有数据库服务器的关系型数据库的例子。客户端-服务器架构的主要优势在于服务器整合了控制数据管理、用户安全和并行访问的并发性。

还有物理和逻辑结构的分离。数据库服务器管理数据库的物理结构,如存储和内存。另一方面,数据库客户端通常只能访问逻辑数据库结构,如集合、索引和视图。

本节将简要解释 MongoDB 4.4 中的物理和逻辑结构。

物理结构

数据库的物理结构由为 MongoDB 服务器分配的计算资源组成,例如处理器线程、内存分配和数据库文件存储。计算需求和调整是数据库管理的重要部分,特别是对于本地部署的数据库服务器。然而,在部署在 MongoDB Atlas 云上的数据库的情况下,数据库的物理结构对用户不可见。数据库由 MongoDB 在内部管理。因此,云用户可以专注于数据库利用和应用开发,而不是花时间在物理资源的数据库管理上,比如存储和内存。

如介绍所述,MongoDB Atlas 根据集群层大小分配物理资源。资源管理完全通过云 Atlas 应用程序进行。如果需要更多资源,集群可以扩展到更大的大小。

免费的 M0 集群没有专用资源(只有共享的 CPU 和内存)。但是,免费的 M0 集群是一个很好的数据库集群,因为它始终可用于学习和测试 MongoDB。

数据库文件

MongoDB 会在磁盘上自动创建许多类型的文件,如数据文件和日志文件。在 Atlas 云数据库的情况下,所有数据库文件都由 MongoDB 内部管理:

  • 数据文件: 这些文件用于数据库集合和其他数据库对象。MongoDB 有一个可配置的数据文件存储引擎,WiredTiger 是一个高性能的存储引擎,自 MongoDB 3.0 版本以来就被引入。

  • Oplog: 这些文件用于集群成员之间的事务复制。我们将在第十章中详细学习这些。

  • 其他文件: 这些文件包括配置文件、数据库日志和审计文件。

数据库指标

虽然云部署的数据库不涉及数据文件和内存管理,但有必要监视分配的云资源的利用情况。Atlas 资源监控提供了一个图形界面,显示性能指标。在 Atlas 中有许多可用的指标,如逻辑数据库指标、物理数据库指标和网络带宽。

此主题的内容超出了本书的范围。有关更多详细信息,您可以参考 MongoDB Atlas 文档,监控和警报 (docs.atlas.mongodb.com/monitoring-alerts/)。

逻辑结构

数据库的逻辑结构包括数据库、集合和其他数据库对象。以下图表示了 MongoDB 的主要逻辑结构:

图 3.37:MongoDB 的逻辑结构

图 3.37:MongoDB 的逻辑结构

MongoDB 服务器: 运行 MongoDB 服务器实例的物理或虚拟计算机。对于 MongoDB 集群,当客户端连接到 MongoDB 时,会有一组少量的 MongoDB 实例

数据库: MongoDB 集群包含许多数据库。每个数据库是 MongoDB 中的逻辑存储容器,用于数据库对象。在部署数据库时会创建一些系统数据库。系统数据库由 MongoDB 服务器内部用于数据库配置和安全,不能用于用户数据。

对象: 一个数据库包含以下对象:

  • JSON 文档的集合

  • 索引

  • 视图

MongoDB 中的基本逻辑实体是 JSON 文档。多个文档被分组在一个集合中,多个集合被分组在一个数据库中。在 MongoDB 版本 4 中,引入了更多的对象,如数据库视图,这为数据库增加了更多功能。我们将在练习 3.05中使用一个合适的示例来学习数据库视图对象的内容,创建数据库视图对象

服务器命令

在客户端-服务器数据库服务器架构中,例如 MongoDB 服务器,客户端向数据库服务器发送请求,MongoDB 服务器在服务器端执行请求。因此,当服务器执行客户端请求时,不涉及客户端处理。一旦请求完成,服务器将执行结果或消息发送回客户端。

MongoDB 服务器有许多功能,但有几个不同的类别:

  • CRUD 操作:数据库创建、读取、更新、删除CRUD)操作是修改数据文档的命令。

  • 数据库命令:这些命令与数据查询和 CRUD 操作不同。数据库命令有其他功能,如数据库管理、安全和复制。

大多数数据库命令都是由 Atlas 在用户更改数据库配置时在后台执行的。例如,当 Atlas 项目所有者添加新用户时,Atlas 应用程序会在后台运行数据库命令,以在数据库中创建用户。然而,也可以从 MongoDB Shell 或 MongoDB Driver 中执行服务器命令。

一般来说,运行数据库命令的语法如下:

>>> db.runCommand( { <db_command> } )

db_command是数据库命令。

例如,如果我们想要检索在 MongoDB 中正在执行的当前操作,我们可以使用以下语法运行命令:

>>> db.runCommand( {currentOp: 1} )

服务器将返回一个 JSON 格式的文档,其中包含正在进行的操作。

一些数据库命令有自己的更短的语法,并且可以在没有一般db.runCommand语法的情况下运行。这是为了方便记住更常用的命令的语法。例如,列出当前数据库中所有集合的命令的语法是:

>>> db.getCollectionNames()

对于部署在 Atlas Cloud 中的数据库,有一些数据库管理命令无法直接从 mongo shell 中执行。完整的命令列表可在 MongoDB Atlas 文档中找到,M0/M2/M5 集群中不支持的命令docs.atlas.mongodb.com/reference/unsupported-commands/)。

练习 3.05:创建数据库视图对象

在这个练习中,您将练习数据库命令。练习的目标是从 mongo shell 终端创建一个新的数据库对象。您将创建一个数据库视图对象,仅显示三列:电影名称,发行年份和集合信息。您将使用 MongoDB 控制台执行所有数据库命令。

以下是执行此练习的步骤:

  1. 使用 MongoDB 控制台的连接字符串连接到 Atlas 数据库。重复练习 3.03中的步骤 1 到 9使用 Mongo Shell 连接到云数据库,使用 mongo shell 客户端进行连接。如果您已经为 Atlas 数据库准备好了连接字符串,请启动 mongo shell 并按照练习 3.03中的步骤 8描述的方式进行连接,使用 Mongo Shell 连接到云数据库

  2. 使用use数据库命令选择mflix电影数据库:

>>> use sample_mflix
  1. 使用getCollectionNames数据库命令列出现有的集合,以返回当前数据库中所有集合的列表:
>>> db.getCollectionNames()
  1. 从电影集合创建一个short_movie_info视图:
db.createView(
   "short_movie_info",
   "movies",
   [ { $project: { "year": 1, "title":1, "plot":1}}]
)

注意

$project运算符用于从电影集合中选择仅三个字段(yeartitleplot)。

  1. 执行createView代码:
MongoDB Enterprise Cluster0-shard-0:PRIMARY> db.createView(
...    "short_movie_info",
...    "movies",
...    [ { $project: { "year": 1, "title":1, "plot":1}}]
... )

响应"ok" : 1表示成功执行创建和查看数据库的命令,没有错误,如下代码输出所示:

# Command Output
{
        "ok" : 1,
        "operationTime" : Timestamp(1569982200, 1),
        "$clusterTime" : {
                "clusterTime" : Timestamp(1569982200, 1),
                "signature" : {
                        "hash" : BinData(0,"brozBUoH099xryq5l439woGcL3o="),
                        "keyId" : NumberLong("6728292437866840066")
                }
        }
}

注意

输出的详细信息可能会根据服务器运行时的值而有所不同。

  1. 验证视图是否已创建。视图只显示为一个集合:
>>> db.getCollectionNames()

此命令返回一个包含集合列表中视图名称的数组。

  1. 查询视图,如下:
>>> db.short_movie_info.findOne()

视图数据库对象的行为与普通集合完全相同。您可以以与查询数据库集合相同的方式查询视图。您将运行一个简短的查询,只返回一个文档。

此查询的输出将只显示文档idplotyeartitle。完整的会话输出如下:

图 3.38:会话输出

图 3.38:会话输出

这是一个创建新数据库对象的示例,比如一个简单的视图。视图对于用户和开发人员来说非常有用,可以连接多个集合,并且可以限制 JSON 文档中的某些字段的可见性。一旦我们学习了更多关于 MongoDB 查询和聚合的知识,我们就可以应用所有这些技术来在数据库中创建更复杂的视图,从多个集合到使用聚合管道。

活动 3.01:管理您的数据库用户

假设您负责管理公司的 MongoDB 数据库,该数据库位于亚马逊网络服务AWS)的 MongoDB Atlas 云基础设施中。最近,您收到通知,新开发人员 Mark 已加入团队。作为新团队成员,Mark 需要访问 MongoDB 电影数据库,用于一个新项目。

执行以下高级步骤以完成此活动:

  1. 创建一个名为dev_mflix的新数据库,该数据库将用于开发。

  2. 为开发人员创建一个名为developers的新自定义角色。

  3. developers角色授予对dev_mflix数据库的读写权限。

  4. developers角色授予对sample_mflix电影数据库的只读权限。

  5. 为 Mark 创建一个新的数据库帐户。

  6. developers自定义角色授予 Mark。

  7. 通过使用 Mark 作为用户连接到数据库来验证帐户,并验证访问权限。

Mark 不应能够修改生产电影数据库,也不应该能够看到服务器上除sample_mflixdev_mflix之外的任何其他数据库。

一旦 Mark 成功添加到 Atlas 项目中,您应该能够使用该帐户测试连接。使用以下命令使用 mongo shell 进行连接:

C:\> mongo "mongodb+srv://cluster0.u7n##.mongodb.net/admin" --username Mark

注意

您的实际连接字符串不同,需要从 Atlas 连接窗口中复制,如本章所述。

这是输出终端的一个示例(来自 mongo shell):

图 3.39:连接 MongoDB Shell

图 3.39:连接 MongoDB Shell

注意

此活动的解决方案可以通过此链接找到

摘要

在本章中,您学习了 Atlas 服务管理的基础知识。由于安全性是云计算的一个非常重要的方面,控制网络访问和数据库访问对于 Atlas 平台至关重要,您现在应该能够设置新用户并授予对数据库资源的权限。还详细探讨了数据库连接和 MongoDB 数据库命令。下一章将向您介绍 MongoDB 查询语法的世界。MongoDB NoSQL 语言是一种功能丰富且强大的数据库语言,与所有编程语言都非常好地集成在一起。

第四章:查询文档

概述

本章讨论了如何在 MongoDB 中准备和执行查询。你将学习如何从集合中查找文档并限制输出中显示的字段。你将在查询中使用各种条件和逻辑运算符,以及它们的组合,并使用正则表达式在集合中查找文档。通过本章结束时,你将能够在数组和嵌套对象上运行查询,以及限制、跳过和对结果集中的记录进行排序。

介绍

在前几章中,我们介绍了 MongoDB 的基础知识,它的基于文档的数据模型、数据类型、客户端和 MongoDB 服务器。我们在云上创建了一个 Atlas 集群,加载了示例数据集,并使用不同的客户端进行了连接。现在我们有了数据,可以开始编写查询以从集合中检索文档。查询用于从数据库中检索有意义的数据。我们将首先学习查询语法,如何使用运算符以及我们可以使用的技术来格式化结果集。练习和掌握查询语言将帮助你快速高效地找到任何所需的文档。

对于任何数据库管理系统来说,拥有强大的查询语言和存储模型或可扩展性一样重要。考虑一下,你正在使用一个数据库平台,它提供了优秀的存储模型或极高性能的数据库引擎。然而,它的查询语言支持非常差,因此你无法轻松地检索所需的信息。显然,这样的数据库将毫无用处。在数据库中存储信息的主要目的之一是能够在需要时检索它。MongoDB 提供了一种轻量级的查询语言,与关系数据库中使用的 SQL 查询完全不同。让我们首先来看一下它的查询结构。

MongoDB 查询结构

MongoDB 查询基于 JSON 文档,你可以在其中以有效文档的形式编写你的条件。随着数据以类似 JSON 的文档形式存储,查询看起来更加自然和可读。下图是一个简单的 MongoDB 查询示例,它查找所有name字段包含值David的文档:

图 4.1:MongoDB 查询语法

图 4.1:MongoDB 查询语法

为了与 SQL 进行比较,让我们用 SQL 格式重写相同的查询。该查询查找USERS表中包含name列值为David的所有行,如下所示:

SELECT * FROM USERS WHERE name = 'David';

前述查询之间最显著的区别是,MongoDB 查询没有诸如SELECTFROMWHERE之类的关键字。因此,你不需要记住很多关键字及其用法。

关键字的缺失使得查询更加简洁,因此更加专注,也更少出错。当你阅读或编写 MongoDB 查询时,你可以更容易地专注于查询的最重要部分,即条件和逻辑。此外,由于关键字更少,引入语法错误的机会更小。

由于查询以文档格式表示,它们可以很容易地与相应编程语言的对象结构进行映射。当你在应用程序中编写查询时,MongoDB 驱动程序将应用程序编程语言提供的对象映射到 MongoDB 查询中。因此,要构建一个 MongoDB 查询,你只需要准备一个表示查询条件的对象。

相比之下,SQL 查询是以普通字符串的形式编写的。要构建 SQL 查询,您将不得不将关键字、字段和表名以及变量连接成一个字符串。这种字��串连接容易出错。即使在两个连接关键字之间缺少空格也可能引入语法错误。现在我们已经探讨了 MongoDB 查询结构的基本优势,让我们开始编写并执行针对集合的基本查询。

基本的 MongoDB 查询

本节中的所有查询都是顶级查询;也就是说,它们是基于文档中的顶级(也称为根级)字段的。我们将通过针对根字段编写查询来学习基本的查询运算符。

查找文档

在 MongoDB 中最基本的查询是在集合上使用find()函数执行的。当此函数在没有任何参数的情况下执行时,它会返回集合中的所有文档。例如,考虑以下查询:

db.comments.find()

此查询在名为comments的集合上调用find()函数。在 mongo shell 上执行时,它将返回集合中的所有文档。要仅返回特定文档,可以向find()函数提供条件。这样做时,find()函数会对集合中的每个文档进行评估,并返回与条件匹配的文档。

例如,假设我们不是检索所有评论,而是只想找到由特定用户Lauren Carr添加的评论。简而言之,我们想要找到所有name字段的值为Lauren Carr的文档。我们将连接到 MongoDB Atlas 集群并使用sample_mflix数据库。查询应该写成如下形式:

db.comments.find({"name" : "Lauren Carr"})

这将导致以下输出:

图 4.2:使用 find()函数后的评论结果

图 4.2:使用 find()函数后的评论结果

该查询返回了由Lauren Carr添加的三条评论。然而,输出格式不规范,这使得阅读和解释变得困难。为了克服这一点,可以使用pretty()函数打印格式良好的结果,如下所示:

db.comments.find({"name" : "Lauren Carr"}).pretty()

当此查询在 mongo shell 上执行时,输出将如下所示:

图 4.3:使用 pretty()后的结构化结果

图 4.3:使用 pretty()后的结构化结果

如您所见,输出与前面的示例相同,但文档格式良好且易于阅读。

使用 findOne()

MongoDB 提供了另一个函数,称为findOne(),它只返回一个匹配的记录。当您想要隔离特定记录时,这个函数非常有用。该函数的语法与find()函数的语法类似,如下所示:

db.comments.findOne()

此查询在没有任何条件的情况下执行,并匹配comments集合中的所有文档,仅返回第一个:

图 4.4:使用 findOne()函数找到单个文档

图 4.4:使用 findOne()函数找到单个文档

如您所见,findOne()的输出始终格式良好,因为它返回一个文档。将其与旨在返回多个文档的find()函数进行比较。find()的结果被封装在一个集合中,并且从函数返回该集合的游标。游标是用于迭代或遍历集合元素的集合迭代器。

注意

当您在 mongo shell 上执行find()查询时,shell 会自动迭代游标并显示前 20 条记录。当您从编程语言使用find()时,您将始终需要自己迭代结果集。

在 mongo shell 上,您可以将find()函数返回的光标捕获在一个变量中。通过使用该变量,我们可以遍历元素。在下面的代码段中,我们正在执行一个find()查询,并将结果光标捕获在一个名为comments的变量中:

var comments = db.comments.find({"name" : "Lauren Carr"})

您可以在光标上使用next()函数,它将光标移动到下一个索引位置并从那里返回文档。默认情况下,光标设置在集合的开头。第一次调用next()函数时,光标将移动到集合中的第一个文档,并返回该文档。再次调用时,光标将移动到第二个位置,并返回第二个文档。以下是在我们的评论光标上调用next()函数的语法:

comments.next()

当光标到达集合中的最后一个文档时,调用next()将导致错误。为了避免这种情况,在调用next()之前可以使用hasNext()函数。hasNext()函数在下一个索引位置有文档时返回true,否则返回false。以下代码段显示了在光标上调用hasNext()函数的语法:

comments.hasNext()

以下屏幕截图显示了在 mongo shell 上使用此函数的结果:

图 4.5:遍历光标

图 4.5:遍历光标

正如我们所看到的,首先,我们将光标捕获在一个变量中。然后,我们验证光标在下一个位置是否有文档,结果为true。最后,我们使用next()函数打印第一个文档。

练习 4.01:在没有条件的情况下使用 find()和 findOne()

在这个练习中,您将在 MongoDB Atlas 上连接到sample_mflix数据库,并在 mongo shell 上使用find()findOne()而不带任何条件。按照以下步骤进行:

  1. 首先,使用没有条件的find()。因此,在这里,不要传递任何文档或传递一个空文档给find()函数。我们还将执行find()函数来查询我们的文档中不存在的字段。这里显示的所有查询都具有相同的行为:
// All of the queries have the same behavior
db.comments.find()
db.comments.find({})
db.comments.find({"a_non_existent_field" : null})

在执行任何这些查询时,所有文档都将匹配并在光标中返回。以下屏幕截图显示了从 mongo shell 中打印的前 20 个文档,最后还有一条键入"it"以获取更多的消息。每次键入it都将返回下一组 20 个文档,直到集合包含更多元素为止:

图 4.6:mongo shell 中的前 20 个文档

图 4.6:mongo shell 中的前 20 个文档

注意

你是否想知道为什么{"a_non_existent_field" : null}匹配所有文档?

这是因为在 MongoDB 中,一个不存在的字段总是被认为具有空值。"a_non_existent_field"字段在我们的集合中不存在。因此,该字段的空值检查对所有文档都成立,并且它们都被返回。

  1. 接下来,使用没有任何文档的findOne()函数,使用一个空文档,以及使用一个查询不存在字段的文档:
// All of the queries have same behaviour
db.comments.findOne()
db.comments.findOne({})
db.comments.findOne({"a_non_existent_field" : null})

与前面的步骤类似,所有先前的查询都将产生相同的效果,只是findOne()将仅输出集合中的第一个文档。

在下一节中,我们将探讨如何仅在输出中投影一些字段。

选择输出的字段

到目前为止,我们观察了许多查询及其输出。您可能已经注意到,每次返回一个文档时,默认情况下它包含所有字段。然而,在大多数实际应用程序中,您可能只希望在结果文档中包含一些字段。在 MongoDB 查询中,您可以从结果中包含或排除特定字段。这种技术称为find()findOne()函数。在投影表达式中,您可以通过将其设置为0来显式排除一个字段,或者通过将其设置为1来包含一个字段。

例如,用户Lauren Carr可能只想知道她发布评论的日期,而不关心评论文本。以下查询找到用户发布的所有评论,并仅返回namedate字段:

db.comments.find(
    {"name" : "Lauren Carr"},
    {"name" : 1, "date": 1}
) 

执行查询后,可以看到以下结果:

图 4.7:仅显示名称和日期字段的输出

图 4.7:仅显示名称和日期字段的输出

在这里,结果中只有特定字段。但是,即使没有指定,_id 字段仍然可见。这是因为 _id 字段默认包括在结果文档中。如果不希望它出现在结果中,必须明确排除它:

db.comments.find(
    {"name" : "Lauren Carr"}, 
    {"name" : 1, "date": 1, "_id" : 0}
)

上述查询指定应从结果中排除 _id 字段。在 mongo shell 上执行时,我们得到以下输出,显示所有文档中都没有 _id 字段:

图 4.8:输出中排除了 _id 字段

图 4.8:输出中排除了 _id 字段

需要注意字段投影的三种行为,如下所列:

  • _id 字段将始终包括在内,除非明确排除

  • 当明确包括一个或多个字段时,其他字段(除了 _id)将自动排除

  • 明确排除一个或多个字段将自动包括其余字段,以及 _id

注意

投影有助于压缩结果集并专注于特定字段。我们将查询的sample_mflix集合中的文档非常庞大。因此,对于我们大部分的示例输出,我们将使用投影来仅包括文档的特定字段,以展示查询的行为。

查找不同的字段

distinct()函数用于获取字段的不同或唯一值,带有或不带有查询条件。在本例中,我们将使用movies集合。每部电影都被分配了一个基于内容和观众年龄的观众适宜性评级。让我们通过以下查询找到我们集合中存在的唯一评级:

db.movies.distinct("rated")

执行上述查询会给我们返回movies集合中的所有唯一评级:

图 4.9:所有电影评分列表

图 4.9:所有电影评分列表

distinct()函数也可以与查询条件一起使用。以下示例查找了 1994 年发布的电影所获得的所有唯一评分:

db.movies.distinct("rated", {"year" : 1994})

函数的第一个参数是所需字段的名称,而第二个参数是以文档格式表示的查询。执行查询后,我们得到以下输出:

db.movies.distinct("rated", {"year" : 1994}) 
> [ "R", "G", "PG", "UNRATED", "PG-13", "TV-14", "TV-PG", "NOT RATED" ]

需要注意distinct的结果始终以数组形式返回。

统计文档

在某些情况下,我们可能对实际文档不感兴趣,而只对集合中的文档数量或匹配��些查询条件的文档感兴趣。MongoDB 集合有三个返回集合中文档数量的函数。让我们依次看一下它们。

count()

此函数用于返回集合中文档的数量,或返回与给定查询匹配的文档的数量。在没有任何查询参数的情况下执行时,它返回集合中文档的总数,如下所示:

// Count of all movies
db.movies.count()
> 23539

没有查询时,此函数不会实际计算文档的数量。相反,它将通过集合的元数据进行读取并返回计数。MongoDB 规范不能保证元数据计数始终准确。例如,数据库突然关闭或分片集合中不完整的块迁移等情况可能导致不准确性。MongoDB 中的分片集合被分区并分布在数据库的不同节点上。我们不会在这里详细介绍,因为这超出了本书的范围。

当函数提供查询时,返回与给定查询匹配的文档数量。例如,以下查询将返回具有确切六条评论的电影的数量:

// Counting movies that have 6 comments
> db.movies.count({"num_mflix_comments" : 6})
17

执行此查询时,实际文档数量是通过执行具有相同查询的聚合管道来内部计算的。您将在第七章 聚合中了解有关聚合管道的更多信息。

在 MongoDB v4.0 中,这两种行为被分成不同的函数:countDocuments()estimatedDocumentCount()

countDocuments()

此函数返回满足给定条件的文档的数量。以下是一个返回 1999 年上映的电影数量的示例查询:

> db.movies.countDocuments({"year": 1999})
542

count()函数不同,countDocuments()需要查询参数。因此,以下查询是无效的,它将失败:

db.movies.countDocuments()

要计算集合中的所有文档,我们可以将一个空查询传递给函数,如下所示:

> db.movies.countDocuments({})
23539

关于countDocuments()的一个重要事项是,它从不使用集合元数据来查找计数。它在集合上执行给定的查询并计算匹配文档的数量。这提供了准确的结果,但可能比基于元数据的计数需要更长的时间。即使提供了空查询,它也会与所有文档匹配。

estimatedDocumentCount()

此函数返回集合中文档的近似或估计数量。它不接受任何查询,并始终返回集合中所有文档的数量。计数始终基于集合的元数据。其语法如下:

> db.movies.estimatedDocumentCount()
23539

由于计数是基于元数据的,结果不太准确,但性能更好。当性能比准确性更重要时,应使用该函数。

条件运算符

现在您已经学会了如何查询 MongoDB 集合,以及如何使用投影仅返回输出中的特定字段,是时候学习更高级的查询方式了。到目前为止,您已经尝试使用字段值查询comments集合。但是,还有更多查询文档的方法。MongoDB 提供了条件运算符,可用于表示各种条件,例如相等性,以及值是否小于或大于某个指定值。在本节中,我们将探索这些运算符,并学习如何在查询中使用它们。

等于($eq)

在前面的部分中,您看到了相等检查的示例,其中查询使用了键值对。但是,查询也可以使用专用运算符($eq)来查找具有与给定值匹配的字段的文档。例如,以下查询查找并返回具有5条评论的电影。这两个查询具有相同的效果:

db.movies.find({"num_mflix_comments" : 5})
db.movies.find({ "num_mflix_comments" : {$eq : 5 }})

不等于($ne)

此运算符代表不等于,与使用相等检查的效果相反。它选择所有字段值与给定值不匹配的文档。例如,以下查询可用于返回评论计数不等于 5 的电影:

db.movies.find(
    { "num_mflix_comments" : 
        {$ne : 5 }
    }
)

大于(\(gt)和大于或等于(\)gte)

$gt关键字可用于查找字段值大于查询中的值的文档。类似地,$gte关键字用于查找字段值与或大于给定值的文档。让我们找出 2015 年后发布的电影数量:

> db.movies.find(
    {year : {$gt : 2015}}
).count()
1

要查找在 2015 年或之后发布的电影,可以使用以下代码行:

> db.movies.find(
    {year : {$gte : 2015}}
).count()
485

使用这些运算符,我们还可以计算 21 世纪发布的电影数量。对于此查询,我们还希望包括自 2000 年 1 月 1 日以来发布的电影,因此我们将使用$gte,如下所示:

// On or After 2000-01-01
> db.movies.find(
    {"released" : 
        {$gte: new Date('2000-01-01')}
    }
).count()
13767

小于(\(lt)和小于或等于(\)lte)

$lt运算符匹配字段值小于给定值的文档。同样地,$lte运算符选择字段值与给定值相同或小于给定值的文档。

要找出有少于两条评论的电影数量,输入以下查询:

> db.movies.find(
    {"num_mflix_comments" : 
        {$lt : 2}
    }
).count()
8514

同样地,要找出最多有两条评论的电影数量,输入以下查询:

> db.movies.find(
    {"num_mflix_comments" : 
        {$lte : 2}
    }
).count()
13185

同样,要计算上个世纪发行的电影数量,只需使用$lt

// Before 2000-01-01
> db.movies.find(
    {"released" : 
        {$lt : new Date('2000-01-01')}
    }
).count()
9268

在(\(in)和不在(\)nin)

如果用户想要列出所有被评为 G、PG 或 PG-13 的电影,该怎么办?在这种情况下,我们可以使用$in运算符,以及以数组形式给出的多个值。这样的查询可以找到所有字段值至少与给定值中的一个匹配的文档。通过输入以下内容准备一个查询,返回被评为 G、PG 或 PG-13 的电影:

db.movies.find(
    {"rated" : 
        {$in : ["G", "PG", "PG-13"]}
    }
)

$nin运算符代表不在,匹配所有字段值与数组元素都不匹配的文档:

db.movies.find(
    {"rated" : 
        {$nin : ["G", "PG", "PG-13"]}
    }
)

前述查询返回的是未被评为GPGPG-13的电影,包括那些没有rated字段的电影。

首先,找到你拥有的总文档数量,看看当你使用$nin与一个不存在的字段时会发生什么:

> db.movies.countDocuments({})
23539

现在,使用$nin与一些值(除了 null)在一个不存在的对象上。这意味着所有文档都匹配,如下片段所示:

> db.movies.countDocuments(
    {"nef" : 
        {$nin : ["a value", "another value"]} 
    }
)
23539

在以下示例中,将null值添加到$nin数组中:

> db.movies.countDocuments( 
    {"nef" : 
        {$nin : ["a value", "another value", null ]} 
    }
)
0

这一次,没有匹配到任何文档。这是因为在 MongoDB 中,不存在的字段总是具有 null 值,因此$nin条件对任何文档都不成立。

练习 4.02:查询演员的电影

假设你在一家知名娱乐杂志工作,他们即将出版一期专门介绍莱昂纳多·迪卡普里奥的杂志。这期杂志将包含一篇特别文章,你迫切需要一些数据,比如他出演的电影数量、每部电影的类型等。在这个练习中,你将编写查询,按给定条件计算文档数量,找到不同的文档,并投影文档中的不同字段。在sample_mflix电影集合上进行以下查询:

  • 演员出演的电影数量

  • 这些电影的类型

  • 电影标题及其相应的发行年份

  • 他执导的电影数量

  1. 通过使用cast字段找到莱昂纳多·迪卡普里奥出演的电影。输入以下查询来执行:
db.movies.countDocuments({"cast" : "Leonardo DiCaprio"})

以下输出表明,莱昂纳多出演了 25 部电影:

> db.movies.countDocuments({"cast" : "Leonardo DiCaprio"})
25
  1. 集合中电影的类型由genres字段表示。使用distinct()函数找到唯一的类型:
db.movies.distinct("genres", {"cast" : "Leonardo DiCaprio"})

执行上述代码后,将收到以下输出。正如我们所看到的,他出演了 14 种不同类型的电影:

图 4.10:莱昂纳多·迪卡普里奥主演的电影类型

图 4.10:莱昂纳多·迪卡普里奥主演的电影类型

  1. 现在,可以使用电影标题找到演员每部电影的发行年份。由于只对他的电影标题和发行年份感兴趣,因此在查询中添加一个投影子句:
db.movies.find(
    {"cast" : "Leonardo DiCaprio"},
    {"title":1, "year":1, "_id":0}
)

输出将如下生成:

图 4.11:莱昂纳多·迪卡普里奥的电影标题和发行年份

图 4.11:莱昂纳多·迪卡普里奥的电影标题和发行年份

  1. 接下来,你需要找到莱昂纳多执导的电影数量。为了收集这些信息,再次计算他执导的电影数量,这次使用导演字段而不是演员字段。这个问题的查询文档应该如下所示:
{"directors": "Leonardo DiCaprio"}
  1. 编写一个查询,计算与前述查询匹配的电影数量:
db.movies.countDocuments({"directors" : "Leonardo DiCaprio"})

执行查询。结果显示,莱昂纳多·迪卡普里奥执导了0部电影:

> db.movies.countDocuments({"directors" : "Leonardo DiCaprio"})
0

在这个练习中,您根据一些条件找到并计算了文档,找到了字段的不同值,并在输出中投影了特定字段。在下一节中,我们将学习逻辑运算符。

逻辑运算符

到目前为止,我们已经了解了用于编写基于比较的查询的各种运算符。到目前为止,我们编写的查询一次只有一个标准。但在实际场景中,您可能需要编写更复杂的查询。MongoDB 提供了四个逻辑运算符,以帮助您在同一查询中构建多个条件的逻辑组合。让我们来看看它们。

$and 运算符

使用$and运算符,您可以将任意数量的条件包装在数组中,该运算符将仅返回满足所有条件的文档。当文档未通过条件检查时,将跳过下一个条件。这就是为什么该运算符被称为短路运算符的原因。例如,假设您想确定 2008 年发布的未评级电影的数量。此查询必须具有两个条件:

  • 字段 rated 应该有一个值为UNRATED

  • 字段 year 必须等于2008

在文档格式中,这两个查询可以写为{"rated" : "UNRATED"}{"year" : 2008}。使用$and运算符将它们放在一个数组中:

> db.movies.countDocuments (
    {$and : 
        [{"rated" : "UNRATED"}, {"year" : 2008}]
    }
)
37

前面的输出显示,2008 年有 37 部未评级的电影。在 MongoDB 查询中,如果查询文档具有多个条件,则$and运算符是隐式的并且默认包含在内。例如,以下查询可以在不使用$and运算符的情况下重写,并且给出相同的结果:

> db.movies.countDocuments (
    {"rated": "UNRATED", "year" : 2008}
)
37

输出完全相同,因此您不必显式使用$and运算符,除非您想使您的代码更易读。

$or 运算符

使用$or运算符,您��以将多个条件包装在一个数组中,并返回满足任一条件的文档。当我们有多个条件并且希望找到至少一个条件匹配的文档时,就会使用此运算符。

In (\(in) and Not In (\)nin)部分中使用的示例中,您编写了一个查询,用于计算评级为 G、PG 或 PG-13 的电影的数量。使用$or运算符,重写相同的查询,如下所示:

db.movies.find(
    { $or : [
        {"rated" : "G"}, 
        {"rated" : "PG"}, 
        {"rated" : "PG-13"}
    ]}
)

这两个运算符是不同的,并且用于不同的场景。$in运算符用于确定给定字段是否至少具有数组中提供的一个值,而$or运算符不限于任何特定字段,并接受多个表达式。为了更好地理解这一点,请编写一个查询,找到评级为G、发布于2005年或至少有5条评论的电影。此查询中有三个条件,如下所示:

  • {"rated" : "G"}

  • {"year" : 2005}

  • {"num_mflix_comments" : {$gte : 5}}

要在$or查询中使用这些表达式,请将这些表达式组合在一个数组中:

db.movies.find(
    {$or:[
        {"rated" : "G"},
        {"year" : 2005},
        {"num_mflix_comments" : {$gte : 5}}
   ]}
)

$nor 运算符

$nor运算符在语法上类似于$or,但行为方式相反。$nor运算符接受数组形式的多个条件表达式,并返回不满足任何给定条件的文档。

以下是您在上一节中编写的相同查询,只是将$or运算符替换为$nor

db.movies.find(
    {$nor:[
        {"rated" : "G"},
        {"year" : 2005},
        {"num_mflix_comments" : {$gte : 5}}
    ]}
)

此查询将匹配并返回所有未评级为G、未发布于2005年且没有超过5条评论的电影。

$not 运算符

$not运算符表示逻辑 NOT 操作,否定给定条件。简而言之,$not运算符接受一个条件表达式,并匹配所有不满足该条件的文档。

以下查询找到了具有5条或更多评论的电影:

db.movies.find(
    {"num_mflix_comments" : 
        {$gte : 5}
    }
)

在相同的查询中使用$not运算符并否定给定条件:

db.movies.find(
    {"num_mflix_comments" : 
        {$not : {$gte : 5} }
    }
)

此查询将返回所有没有 5 条或更多评论以及不包含num_mflix_comments字段的电影。现在,您将在一个简单的练习中使用到目前为止学到的运算符。

练习 4.03:组合多个查询

即将出版的杂志专注于莱昂纳多与导演马丁·斯科塞斯的合作。您的任务是找到戏剧或犯罪电影的标题和发行年,这些电影是莱昂纳多·迪卡普里奥和马丁·斯科塞斯合作制作的。要完成此练习,您需要使用多个查询的组合,如下所述:

  1. 第一个条件是莱昂纳多·迪卡普里奥必须是其中一位演员,马丁·斯科塞斯必须是导演。因此,您有两个条件需要具有AND关系。正如您之前所见,当两个查询组合时,AND关系是默认关系。输入以下查询:
db.movies.find(
    {
      "cast": "Leonardo DiCaprio",
      "directors" : "Martin Scorsese"
    }
)
  1. 现在,还有一个AND条件需要添加,即电影应该是戏剧或犯罪类型。您可以轻松地为 genre 字段准备两个过滤器:{"genres" : "Drama"}{"genres" : "Crime"}。将它们组合在OR关系中,如下所示:
"$or" : [{"genres" : "Drama"}, {"genres": "Crime"}]
  1. 将 genre 过滤器添加到主查询中:
db.movies.find(
    {
      "cast": "Leonardo DiCaprio", 
      "directors" : "Martin Scorsese",
      "$or" : [{"genres" : "Drama"}, {"genres": "Crime"}]
    }
)
  1. 前述查询包含所有预期条件,但您只对标题和发行年感兴趣。为此,添加投影部分:
db.movies.find(
    {
      "cast": "Leonardo DiCaprio",
      "directors" : "Martin Scorsese",
      "$or" : [{"genres" : "Drama"}, {"genres": "Crime"}]
    },
    {
      "title" : 1, "year" : 1, "_id" : 0
    }
)
  1. 在 mongo shell 上执行查询。输出应如下所示:图 4.12:莱昂纳多·迪卡普里奥和马丁·斯科塞斯合作的电影

图 4.12:莱昂纳多·迪卡普里奥和马丁·斯科塞斯合作的电影

此输出提供了所需的信息;有四部符合我们条件的电影。演员和导演最后一次合作是在 2013 年的电影《华尔街之狼》上。通过这样,您已经练习了如何使用不同的逻辑关系一起使用多个查询条件。在下一节中,您将学习如何使用正则表达式查询文本字段。

正则表达式

在现实世界的电影服务中,您会希望提供自动完成搜索框,当用户输入电影标题的几个字符时,搜索框会建议所有标题与输入的字符序列匹配的电影。这是使用正则表达式实现的。正则表达式是一个特殊的字符串,定义了一个字符模式。当这样的正则表达式用于查找字符串字段时,找到并返回所有具有匹配模式的字符串。

在 MongoDB 查询中,正则表达式可以与$regex运算符一起使用。想象一下,你在搜索框中输入了单词Opera,想要找到所有标题中包含这个字符模式的电影。这个正则表达式查询将如下所示:

db.movies.find(
    {"title" : {$regex :"Opera"}}
)

执行此查询并使用投影仅打印标题时,结果将如下所示:

图 4.13:标题中包含单词“Opera”的电影

图 4.13:标题中包含单词“Opera”的电影

来自 mongo shell 的输出表明,正则表达式正确返回了标题中包含单词Opera的电影。

使用插入符(^)运算符

在前面的正则表达式示例中,输出的标题中包含给定单词Opera的任何位置。要仅查找以给定正则表达式开头的字符串,可以使用插入符运算符(^)。在下面的示例中,您将使用它来仅查找那些标题以单词Opera开头的电影:

db.movies.find(
    {"title" : {$regex :"^Opera"}}
)

当执行前述查询并投影title字段时,将得到以下输出:

图 4.14:仅投影出前述查询的标题字段

图 4.14:仅投影出前述查询的标题字段

来自 Mongo shell 的前述输出显示,仅返回了以单词"Opera"开头的电影标题。

使用美元符号($)运算符

类似于插入符运算符,您还可以匹配以给定正则表达式结尾的字符串。为此,使用美元运算符($)。在以下示例中,您正在尝试查找以单词“Opera”结尾的电影标题:

db.movies.find(
    {"title" : {$regex :"Opera$"}}
)

上述查询在正则表达式文本之后使用了美元($)运算符。当您执行并投影标题字段时,您将收到以下输出:

图 4.15:标题以“Opera”结尾的电影

图 4.15:标题以“Opera”结尾的电影

因此,通过使用美元($)运算符,我们已经找到了所有以单词Opera结尾的电影标题。

不区分大小写搜索

默认情况下,使用正则表达式进行搜索是区分大小写的。提供的搜索模式中的字符大小写会被精确匹配。然而,通常情况下,您希望提供一个单词或模式给正则表达式,并且不考虑它们的大小写来查找文档。MongoDB 为此提供了$options运算符,可用于不区分大小写的正则表达式搜索。例如,假设您想要找到所有标题中包含单词“the”的电影,首先是区分大小写的方式,然后是不区分大小写的方式。

以下查询检索包含小写单词the的标题:

db.movies.find(
    {"title" : {"$regex" : "the"}}
)

在 mongo shell 中的以下输出显示,此查询返回包含小写单词the的标题:

图 4.16:包含小写单词“the”的标题

图 4.16:包含小写单词“the”的标题

现在,尝试使用不区分大小写的搜索进行相同的查询。为此,使用值为i$options参数,其中i代表不区分大小写:

db.movies.find(
    {"title" : 
        {"$regex" : "the", $options: "i"}
    }
)

上述查询使用相同的正则表达式模式(the),但带有额外的参数;也就是$options。在title字段上执行查询并投影:

图 4.17:查询不区分大小写的结果

图 4.17:查询不区分大小写的结果

执行查询并打印标题显示,正则表达式匹配,不考虑大小写。到目前为止,我们已经了解了在基本对象上进行查询。在下一节中,我们将学习如何查询数组和嵌套文档。

查询数组和嵌套文档

第二章文档和数据类型中,我们了解到 MongoDB 文档支持复杂的对象结构,如数组,嵌套对象,对象数组等。数组和嵌套文档有助于存储独立的信息。非常重要的是要有一种机制来轻松搜索和检索存储在这些复杂结构中的信息。MongoDB 查询语言允许我们以最直观的方式查询这些复杂结构。首先,我们将学习如何在数组元素上运行查询,然后我们将学习如何在嵌套对象字段上运行查询。

通过元素查找数组

在数组上进行查询类似于查询任何其他字段。在movies集合中,有几个数组,cast字段是其中之一。考虑到,在您的电影服务中,用户想要查找由演员查理卓别林主演的电影。为此搜索创建查询,使用字段上的相等检查,如下所示:

db.movies.find({"cast" : "Charles Chaplin"})

当您执行此查询并仅投影cast字段时,您将获得以下输出:

图 4.18:查找查理卓别林主演的电影

图 4.18:查找查理卓别林主演的电影

现在,假设��户想要搜索由演员查理卓别林埃德娜·普尔维亚斯一起出演的电影。对于此查询,您将使用$and运算符:

db.movies.find(
    {$and :[
        {"cast" : "Charles Chaplin"},
        {"cast": "Edna Purviance"}
    ]}
)

执行并仅投影数组字段会产生以下输出:

图 4.19:查找查理卓别林和埃德娜·普尔维亚斯主演的电影

图 4.19:查找查理卓别林和埃德娜·普尔维亚斯主演的电影

我们可以得出结论,当使用值查询数组字段时,只要数组字段包含至少一个满足查询条件的元素,就会返回所有这些文档。

通过数组查找数组

在之前的例子中,我们使用元素的值搜索数组。同样,也可以使用数组值搜索数组字段。但是,当您使用数组值搜索数组字段时,元素及其顺序必须匹配。让我们尝试一些例子来证明这一点。

movies 集合中的文档有一个数组,表示电影可用的语言数量。假设您的用户想要查找可用于英语德语的电影。准备一个包含这两个值的数组,并查询languages字段:

db.movies.find(
    {"languages" : ["English", "German"]}
)

在投影languages_id字段的同时打印结果:

图 4.20:可用英语和德语的电影

图 4.20:可用英语和德语的电影

前面的输出显示,当我们使用数组进行搜索时,值会被精确匹配。

现在,让我们改变数组元素的顺序并再次搜索:

db.movies.find(
    {"languages" : ["German", "English"]}
)

请注意,这个查询与之前的查询相同,只是数组元素的顺序不同。您应该看到以下输出:

图 4.21:演示数组元素顺序影响的查询

图 4.21:演示数组元素顺序影响的查询

前面的输出显示,通过改变数组中元素的顺序,不同的记录已经被匹配。

这是因为当使用数组值搜索数组字段时,该值会使用相等检查进行匹配。只有当两个数组具有相同顺序的相同元素时,它们才能通过相等检查。因此,以下两个查询不同,并且将返回不同的结果:

// Find movies languages by [ "English", "French", "Cantonese", "German"]
db.movies.find(
    {"languages": [ "English", "French", "Cantonese", "German"]}
)
// Find movies languages by ["English", "French", "Cantonese"]
db.movies.find(
    {"languages": ["English", "French", "Cantonese"]}
)

这两个查询之间唯一的区别是第二个查询不包含最后一个元素,即德语。现在,在 mongo shell 中执行这两个查询并查看输出:

图 4.22:不同的查询,演示数组值的精确匹配

图 4.22:不同的查询,演示数组值的精确匹配

前面的输出显示,这两个查询依次执行,并证明了数组值的精确匹配。

使用$all 运算符搜索数组

$all运算符找到所有那些字段值包含所有元素的文档,无论它们的顺序或大小如何:

db.movies.find(
    {"languages":{ 
        "$all" :[ "English", "French", "Cantonese"]
    }}
)

前面的查询使用$all来查找所有可用英语法语粤语的电影。您将执行此查询,并进行投影,仅显示languages字段:

图 4.23:在字段上使用$all 运算符的查询

图 4.23:在languages字段上使用$all 运算符的查询

前面的输出表明,$all运算符已经匹配了数组,无论元素的顺序和大小如何。

投影数组元素

到目前为止,我们已经看到每当搜索数组字段时,输出总是包含完整的数组。有几种方法可以限制查询输出中返回的数组元素数量。我们已经练习了在结果文档中投影字段。与此类似,数组中的元素也可以被投影。在本节中,我们将学习如何在搜索数组字段时限制结果集。之后,我们将学习如何根据它们的索引位置从数组中返回特定元素。

使用($)投影匹配的元素

您可以通过元素值搜索数组,并使用$运算符排除数组的除第一个匹配元素之外的所有元素。为此,首先执行一个不带$运算符的查询,然后再执行带有此运算符的查询。准备一个简单的元素搜索查询,如下所示:

db.movies.find(
    {"languages" : "Syriac"}, 
    {"languages" :1}
)

该查询在languages数组上使用元素搜索,并投影字段以产生以下输出:

图 4.24:以叙利亚语提供的电影

图 4.24:以叙利亚语提供的电影

尽管查询旨在查找叙利亚语电影,但输出数组中还包含其他语言。现在,看看当您使用$运算符时会发生什么:

db.movies.find(
    {"languages" : "Syriac"}, 
    {"languages.$" :1}
)

您已经修改了查询,以在投影部分添加$运算符。现在,执行查询,如下所示:

图 4.25:仅以叙利亚语提供的电影

图 4.25:仅以叙利亚语提供的电影

输出中的数组字段仅包含匹配的元素;其余元素被跳过。因此,输出中的languages数组仅包含Syriac元素。最重要的是要记住,如果匹配了多个元素,$运算符只投影第一个匹配的元素。

通过它们的索引位置投影匹配的元素($slice)

$slice运算符用于基于其索引位置限制数组元素。该运算符可以与任何数组字段一起使用,无论是否正在查询该字段。这意味着您可以查询不同的字段,仍然可以使用该运算符来限制数组字段的元素。

为了看到这一点,我们将以电影《青春无敌》为例,该电影的languages数组中有 11 个元素。来自 mongo shell 的以下输出显示了电影记录中的数组字段的样子:

图 4.26:电影《青春无敌》的语言列表

图 4.26:电影《青春无敌》的语言列表

在下面的查询中,使用$slice仅打印数组的前三个元素:

db.movies.find(
    {"title" : "Youth Without Youth"}, 
    {"languages" : {$slice : 3}}
).pretty()

前面查询的输出显示,languages字段仅包含前三个元素。

    "languages" : [
            "English",
            "Sanskrit",
            "German"
    ]
    "released" : ISODate("2007-10-26T00:00:00Z"),
    "directors" : [

$slice运算符可以以更多方式使用。以下投影表达式将返回数组的最后两个元素:

{"languages" : {$slice : -2}}

以下输出显示数组已被切片为仅包含最后两个元素:

    "languages" : [
            "Armenian",
            "Egyptian (Ancient)",
    ]
    "released" : ISODate("2007-10-26T00:00:00Z"),

$slice运算符也可以传递两个参数,其中第一个参数表示要跳过的元素数,第二个参数表示要返回的元素数。例如,以下投影表达式将跳过数组的前两个元素,并返回其后的四个元素:

{"languages" : {$slice : [2, 4]}}

当执行此查询时,我们得到以下输出:

    "languages" : [
            "German",
            "French",
            "Italian"
            "Russian"
    ]
    "released" : ISODate("2007-10-26T00:00:00Z"),
    "directors" : [

两参数切片也可以使用负值进行跳过。例如,在以下投影表达式中,第一个数字是负数。如果跳过的值是负数,则计数从末尾开始。因此,在以下表达式中,将跳过从最后一个索引开始的五个元素,并返回从该索引开始的四个元素:

{"languages" : {$slice : [-5, 4]}}

请注意,由于负的跳过值,跳过索引将从最后一个索引计算。从最后一个索引跳过五个元素得到Romanian,并且从该索引位置开始,将返回接下来的四个元素,如下所示:

    "languages" : [
            "Romanian",
            "Mandarin",
            "Latin"
            "Armenian"
    ]
    "released" : ISODate("2007-10-26T00:00:00Z"),

在本节中,我们已经介绍了如何查询数组字段以及如何以各种方式投影结果。在下一节中,我们将学习如何查询嵌套对象。

查询嵌套对象

与数组类似,嵌套或嵌入式对象也可以表示为字段的值。因此,具有其他对象作为其值的字段可以使用完整对象作为值进行搜索。在movies集合中,有一个名为awards的字段,其值是一个嵌套对象。以下片段显示了集合中某个随机电影的awards对象:

    "rated" : "TV-G",
    "awards"  :  {
             "wins" : 1,
             "nominations" : 0,
             "text" : "1 win."
    }

以下查询通过提供完整对象作为其值来查找awards对象:

db.movies.find(
    {"awards": 
        {"wins": 1, "nominations": 0, "text": "1 win."}
    }
)

以下输出显示,有几部电影的awards字段的确切值为{"wins": 1, "nominations": 0, "text": "1 win."}

图 4.27:没有提名和一项奖项的电影列表

图 4.27:没有提名和一项奖项的电影列表

当使用对象值搜索嵌套对象字段时,必须有精确匹配。这意味着所有字段-值对以及字段的顺序必须完全匹配。例如,请考虑以下查询:

db.movies.find(
    {"awards": 
        {"nominations": 0, "wins": 1, "text": "1 win."}
    }
)

此查询在查询对象方面有一个顺序变化;因此,它将返回一个空结果。

查询嵌套对象字段

第二章文档和数据类型中,我们看到可以使用点(.)表示法访问嵌套对象的字段。类似地,可以使用点表示法通过提供其字段的值来搜索嵌套对象。例如,要查找获得四项奖项的电影,可以使用点表示法如下:

db.movies.find(
    {"awards.wins" : 4}
)

上述查询在awards字段上使用点(.)表示法,并引用名为wins的嵌套字段。当您执行查询并仅投影awards字段时,您将获得以下输出:

图 4.28:仅为上述片段投影奖项字段

图 4.28:仅为上述片段投影奖项字段

上述输出表明筛选已正确应用于wins,并返回了所有获得四项奖项的电影。

嵌套字段搜索是独立执行的,不考虑元素的顺序。您可以通过多个字段进行搜索,并使用任何条件或逻辑查询运算符。例如,请参考以下查询:

db.movies.find(
    {
        "awards.wins" : {$gte : 5}, 
        "awards.nominations" : 6
    }
)

此查询在两个不同的嵌套字段上组合了两个条件。在执行查询时排除其他字段,您应该看到以下输出:

图 4.29:获得六项提名和至少五项奖项的电影

图 4.29:获得六项提名和至少五项奖项的电影

此查询使用条件运算符在两个字段上进行搜索,并返回了获得六项提名并至少获得五项奖项的电影。与数组元素或文档中的任何字段一样,嵌套对象字段也可以按我们的要求进行投影。我们将在下一个练习中详细探讨这一点。

练习 4.04:投影嵌套对象字段

在这个练习中,您将学习如何仅从嵌套对象中投影特定字段。以下步骤将帮助您实施这个练习:

  1. 打开 mongo shell 并连接到 Mongo Atlas 上的sample_mflix数据库。输入以下查询以返回所有记录并仅投影awards字段,这是一个嵌入对象:
db.movies.find(
    {}, 
    {
        "awards" :1, 
        "_id":0
    }
)

以下输出显示,结果中仅包括awards字段,而其他字段(包括_id)已被排除:

图 4.30:仅为查询投影奖项字段

图 4.30:仅为查询投影奖项字段

  1. 要仅从嵌入对象中投影特定字段,可以使用点表示法引用嵌入对象的字段。输入以下查询:
db.movies.find(
    {}, 
    {
        "awards.wins" :1, 
        "awards.nominations" : 1,  
        "_id":0
    }
)

当您在 mongo shell 上执行此查询时,输出将如下所示:

图 4.31:仅投影奖项对象,不包括文本字段

图 4.31:仅投影奖项对象,不包括文本字段

上述输出显示响应中仅包括两个嵌套字段。输出中的awards对象仍然是一个嵌套对象,但已排除了text字段。

到目前为止,我们已经看到了如何在输��中限制嵌套对象及其字段。这结束了我们对在 MongoDB 中查询数组和嵌套对象的讨论。在下一节中,我们将学习如何跳过、限制和排序文档。

限制、跳过和排序文档

到目前为止,我们已经学会了如何编写基本和复杂的查询,并在结果文档中投影字段。在本节中,您将学习如何控制查询返回的文档数量和顺序。

让我们谈谈为什么需要控制查询返回的数据量。在大多数实际情况下,您不会使用查询匹配的所有文档。想象一下,我们电影服务的用户计划今晚观看一部戏剧电影。他们将访问电影商店,搜索戏剧电影,并发现收藏中有超过 13,000 部这样的电影。有了如此庞大的搜索结果,他们可能会花费整个晚上浏览各种电影,并决定要观看哪一部。

为了提供更好的用户体验,您可能希望一次显示戏剧类别中最受欢迎的 10 部电影,然后是序列中的下一个 10 部电影,依此类推。这种提供数据的技术称为分页。这是将大量结果分成小块(也称为页面),并一次只提供一页的技术。分页不仅提高了用户体验,还提高了系统的整体性能,并减少了对数据库、网络或用户的浏览器或移动应用程序的开销。要实现分页,您必须能够限制结果的大小,跳过已提供的记录,并以明确的顺序提供它们。在本节中,我们将练习这三种技术。

限制结果

为了限制查询返回的记录数量,结果游标提供了一个名为limit()的函数。如果可用,此函数接受一个整数并返回相同数量的记录。MongoDB 建议使用此函数,因为它减少了游标产生的记录数量,并提高了速度。

要打印出主演查理·卓别林的电影的标题,请输入以下查询,在cast字段中查找演员的姓名:

db.movies.find(
    {"cast" : "Charles Chaplin"}, 
    {"title": 1, "_id" :0}
)

该查询还向title字段添加了投影。当您执行查询时,将看到以下输出:

图 4.32:显示查理·卓别林主演电影的输出

图 4.32:显示查理·卓别林主演电影的输出

如图所示,查理·卓别林一共出演了八部电影。接下来,您将使用 limit 函数将结果大小限制为3,如下所示:

db.movies.find(
    {"cast" : "Charles Chaplin"}, 
    {"title": 1, "_id" :0}
).limit(3)

当执行此查询时,只返回三条记录:

图 4.33:使用 limit()仅显示查理·卓别林主演的三部电影

图 4.33:使用 limit()仅显示查理·卓别林主演的三部电影

让我们看看当与不同值一起使用时,limit()函数的行为。

当限制大小大于游标内实际记录时,将返回所有记录,而不管设置的限制如何。例如,以下查询将返回8条记录,即使将限制设置为14,因为游标中只有8条记录:

db.movies.find(
    {"cast" : "Charles Chaplin"}, 
    {"title": 1, "_id" :0}
).limit(14)

上述查询的结果如下,显示查询已返回所有八条记录:

图 4.34:当限制设置为 14 时的输出

图 4.34:当限制设置为 14 时的输出

请注意,将限制设置为零相当于根本不设置任何限制。因此,以下查询将返回符合条件的所有八条记录:

db.movies.find(
    {"cast" : "Charles Chaplin"}, 
    {"title": 1, "_id" :0}
).limit(0)

上述查询的输出如下:

图 4.35:当限制设置为 0 时的输出

图 4.35:当限制设置为 0 时的输出

现在,您是否想知道如果将限制大小设置为负数会发生什么?对于返回较小记录的查询,如我们的情况,负大小限制被视为等同于正数限制。以下查询演示了这一点:

db.movies.find(
    {"cast" : "Charles Chaplin"}, 
    {"title": 1, "_id" :0}
).limit(-2)

当您执行此查询(在 mongo shell 上具有负限制-2),您应该获得以下输出:

图 4.36:当限制设置为-2 时的输出

图 4.36:限制为-2 时的输出

输出显示,查询返回了两个文档,行为等同于使用大小为2limit。然而,结果集的批处理大小可能会影响这种行为。下一节将详细探讨这一点。

限制和批处理大小

在 MongoDB 中执行查询时,结果以一个或多个批次的形式进行处理和返回。批次在内部分配,结果将一次性显示。批处理的主要目的之一是避免在处理大量记录集时发生高资源利用。

此外,它保持了客户端和服务器之间的连接活动,因此避免了超时错误。对于大型查询,当数据库需要更长时间来查找和返回结果时,客户端会一直等待。当等待的阈值达到一定值时,客户端和服务器之间的连接会断开,并且查询将因超时异常而失败。使用批处理可以避免这种超时,因为服务器会持续返回单个批次。

不同的 MongoDB 驱动程序可以有不同的批处理大小。然而,对于单个查询,可以设置批处理大小,如下面的代码片段所示:

db.movies.find(
    {"cast" : "Charles Chaplin"}, 
    {"title": 1, "_id" :0}
).batchSize(5)

此查询在游标上使用了batchSize()函数,提供了批处理大小为5。执行此查询的输出如下:

图 4.37:批处理大小为 5 时的输出

图 4.37:批处理大小为 5 时的输出

上述输出中的查询添加了批处理大小为5,但对输出没有影响。然而,结果的内部准备方式有所不同。

批处理大小的正限制

当执行上述查询时,指定了批处理大小为5,数据库开始查找符合给定条件的文档。一旦找到前五个文档,它们作为第一个批次返回给客户端。接下来,剩下的三条记录被找到并作为下一个批次返回。然而,对于用户来说,结果一次性打印出来,变化是不可察觉的。

当使用大于批处理大小的正限制执行查询时,记录在内部被分批获取时也会发生同样的情况:

db.movies.find(
    {"cast" : "Charles Chaplin"}, 
    {"title": 1, "_id" :0}
).limit(7).batchSize(5)

此查询使用了大于提供的批处理大小5的限制7。当执行查询时,我们得到了预期的7条记录,没有任何显著变化。以下截图显示了输出:

图 4.38:当限制为 7 且批处理大小为 5 时的输出

图 4.38:当限制为 7 且批处理大小为 5 时的输出

到目前为止,我们已经学会了如何在不指定限制的情况下执行批处理,然后指定正限制值。现在,我们将看看当使用负限制值时会发生什么,其正等效值大于给定的批处理大小。

负限制和批处理大小

正如我们在前面的例子中学到的,如果结果中的记录总数超过批处理大小,MongoDB 会使用批处理。然而,当我们使用负数来指定限制大小时,只有第一个批次会被返回,即使需要下一个批次也不会被处理。

我们将通过以下查询来演示这一点:

db.movies.find(
    {"cast" : "Charles Chaplin"}, 
    {"title": 1, "_id" :0}
).limit(-7).batchSize(5)

此查询使用了负数7的限制和5的批处理大小,这意味着返回结果需要两个批次。为了观察这种行为,在 mongo shell 上执行此查询:

图 4.39:当限制为-7 且批处理大小为 5 时的输出

图 4.39:当限制为-7 且批处理大小为 5 时的输出

输出表明,查询只返回了前五条记录,而不是预期的七条记录。这是因为数据库只返回了第一个批次,而下一个批次没有被处理。

这证明了负限制并不完全等同于以正数形式提供数字。如果查询返回的记录数小于指定的批量大小,结果将是相同的。一般来说,应避免使用负限制,但如果使用了负限制,确保使用适当的批量大小,以避免这种情况。

跳过文档

跳过用于排除结果集中的一些文档并返回其余文档。MongoDB 游标提供了skip()函数,它接受一个整数,并从游标中跳过指定数量的文档,然后返回其余文档。在前面的示例中,您准备了查询,以查找查尔斯·卓别林主演的电影的标题。以下示例使用相同的查询和skip()函数:

db.movies.find(
    {"cast" : "Charles Chaplin"}, 
    {"title": 1, "_id" :0}
).skip(2)

由于skip()函数已经提供了值2,所以前两个文档将被排除在输出之外,如下面的屏幕截图所示:

图 4.40:带有跳过值 2 的输出

图 4.40:带有跳过值 2 的输出

limit()类似,将零传递给skip()等同于根本不调用该函数,并且返回整个结果集。但是,skip()对于负数有不同的行为;它不允许使用负数。因此,以下查询是无效的:

db.movies.find(
    {"cast" : "Charles Chaplin"}, 
    {"title": 1, "_id" :0}
).skip(-3)

当执行此查询时,将会收到错误提示,如下图所示:

图 4.41:带有跳过值-3 的输出

图 4.41:带有跳过值-3 的输出

skip()操作不使用任何索引,因此在较小的集合上表现良好,但在较大的集合上可能明显滞后。我们将在第九章 性能中详细介绍索引的主题。

排序文档

排序用于按指定顺序返回文档。如果不使用显式排序,MongoDB 不保证以何种顺序返回文档,即使执行相同的查询两次,结果也可能不同。具有特定排序顺序在分页期间尤为重要。在分页期间,我们执行带有指定限制和服务的查询。对于下一个查询,跳过之前的记录,并返回下一个限制。在此过程中,如果记录的顺序发生变化,一些电影可能会出现在多个页面上,而一些电影可能根本不会出现。

MongoDB 游标提供了一个sort()函数,接受一个文档类型的参数,其中文档定义了特定字段的排序顺序。请参见以下查询,它打印出了查尔斯·卓别林的电影标题和排序选项:

db.movies.find(
    {"cast" : "Charles Chaplin"}, 
    {"title" : 1, "_id" :0}
).sort({"title" : 1})

在上述查询中,您正在对结果游标调用sort()函数。函数的参数是一个文档,其中title字段的值为1。这指定给定字段应按升序排序。当查询在排序后执行时,结果如下所示:

图 4.42:按升序排序

图 4.42:按升序排序

现在,将-1传递给sort参数,表示按降序排序:

db.movies.find(
    {"cast" : "Charles Chaplin"}, 
    {"title" : 1, "_id" :0}
).sort({"title" : -1})

其输出如下:

图 4.43:按降序排序

图 4.43:按降序排序

排序可以在多个字段上执行,并且每个字段可以有不同的排序顺序。让我们看一个例子,按照降序对电影的 IMDb 评分进行排序,按照升序对年份进行排序。查询应该返回 50 部电影,其中 IMDb 评分最高的电影出现在顶部。如果两部电影的评分相同,那么年份较早的电影应该优先。可以使用以下查询来实现这一点:

db.movies.find()
    .limit(50)
    .sort({"imdb.rating": -1, "year" : 1})

在我们结束本节之前,值得注意的是,在 MongoDB 中,除了正整数或负整数之外的任何数字,包括零,都被视为无效的排序值。如果使用这样的值,查询将失败,我们会看到消息"bad sort specification error",如下所示:

Error: error: {
        "ok" : 0,
        "errmsg" : "bad sort specification",
        "code" : 2,
        "codeName" : "BadValue"
}

在下一个活动中,我们将运用本章学到的一切知识来实现基于流派的电影搜索的分页。

活动 4.01:按流派查找电影并分页显示结果

您的组织计划为用户提供一个新功能,他们将能够在他们喜爱的流派中找到电影。由于电影数据库庞大,每个流派都有大量电影,返回所有匹配的电影标题并不是非常有用。要求是以小块的方式提供结果。

您在此活动中的任务是在 mongo shell 上创建一个 JavaScript 函数。该函数应接受用户选择的流派,并打印所有匹配的标题,其中具有最高 IMDb 评分的标题应出现在顶部。除了流派,该函数还将接受另外两个参数,用于页面大小和页面编号。页面大小定义了一页上需要显示多少条记录,而页面编号表示用户当前所在的页面。以下步骤将帮助您完成此活动:

  1. 编写一个findMoviesByGenre函数,接受三个参数:genrepageNumberpageSize
   var findMoviesByGenre = function(genre, pageNumber, pageSize){
      …
   }
  1. 编写一个查询,根据genre过滤结果并返回标题。

  2. 对结果进行排序,以显示评分最高的电影。

  3. 使用pageNumberpageSize参数跳过和限制结果的逻辑。

  4. 使用toArray()方法将结果游标转换为数组。

  5. 遍历结果数组并打印所有标题。

  6. 通过将其复制粘贴到 shell 并执行,可以在 mongo shell 中创建该函数。

考虑用户提供的流派是动作。在这里,如下所示,执行函数并显示结果的第一页,显示前五部动作电影:

图 4.44:显示前五部动作电影的第一页

图 4.44:显示前五部动作电影的第一页

同样,下面的输出显示函数返回了第二页的五部动作电影:

图 4.45:动作电影的第二页

图 4.45:动作电影的第二页

注意

此活动的解决方案可以通过此链接找到。

摘要

我们从详细研究了 MongoDB 查询的结构以及它们与 SQL 查询的不同之处开始了本章。然后,我们实现了这些查询来查找和计算文档,并使用各种示例限制返回结果中的字段数量。我们还学习了各种条件和逻辑运算符,并练习将它们结合使用以注意结果的差异。

然后,我们学习了如何使用正则表达式提供文本模式来过滤我们的搜索结果,并介绍了如何查询数组和嵌套对象,并在结果中包含它们的特定字段。最后,我们学习了如何通过在结果中对文档进行限制、排序和跳过来分页大型结果集。

在下一章中,我们将学习如何向 MongoDB 集合中插入、更新和删除文档。

第五章:插入、更新和删除文档

概述

本章介绍了 MongoDB 中的核心操作,即在集合中插入、更新和删除文档。您将学习如何将单个文档或一批多个文档插入到 MongoDB 集合中。您将添加或自动生成一个_id字段,替换现有文档,并更新现有集合中文档的特定字段。最后,您将学习如何删除集合中的所有文档或特定文档。

介绍

在之前的章节中,我们涵盖了各种数据库命令和查询。我们学会了准备查询条件并使用它们来查找或计算匹配的文档。我们还学会了在字段、嵌套字段和数组上使用各种条件运算符、逻辑运算符和正则表达式。除此之外,我们还学会了如何格式化、跳过、限制和对结果集中的文档进行排序。

现在您已经知道如何正确地从集合中找到并表示所需的文档,下一步是学习如何修改集合中的文档。在任何数据库管理系统上工作时,您都将需要修改底层数据。想象一下:您正在管理我们的电影数据集,并经常需要在电影上映时向集合中添加新电影。您还需要永久删除一些电影或从数据库中删除错误插入的电影。随着时间的推移,一些电影可能会获得新的奖项、评论和评分。在这种情况下,您将需要修改现有电影的详细信息。

在本章中,您将学习如何在集合中创建、删除和更新文档。我们将首先创建新的集合,向集合中添加一个或多个文档,并考虑唯一主键的重要性。然后,我们将介绍如何从集合中删除所有或删除特定文档,以及 MongoDB 提供的各种删除函数及其特性。接下来,您将学习如何替换集合中的现有文档,并了解 MongoDB 如何保持主键不变。您还将了解如何使用替换操作执行更新或插入,也称为 upsert。最后,您将学习如何修改文档。MongoDB 提供了各种更新函数和广泛的更新运算符,可用于特定需求。我们将深入研究所有这些函数,并练习使用这些运算符。

插入文档

在本节中,您将学习如何向 MongoDB 集合中插入新文档。MongoDB 集合提供了一个名为insert()的函数,用于在集合中创建新文档。该函数在集合上执行,并将要插入的文档作为参数。该函数的语法如下命令所示:

db.collection.insert( <Document To Be Inserted>)

要在示例中查看此内容,请打开 mongo Shell,连接到数据库集群,并使用use CH05命令创建一个新数据库。您可以根据自己的喜好给数据库取一个不同的名字。如果之前不存在该数据库,则该命令中提到的数据库将被创建。在以下操作中,我们正在插入一个带有title字段和_id的电影,并且输出将打印在下一行上:

> db.new_movies.insert({"_id" : 1, "title" : "Dunkirk"})
WriteResult({ "nInserted" : 1 })

注意

在本章中,我们将在集合中插入、更新和删除大量文档,并且我们不希望损坏现有的sample_mflix数据集。因此,我们正在创建一个不同的数据库,并在整个章节中使用它。练习和活动侧重于真实场景,并因此将使用sample_mflix数据集。

这个 mongo shell 片段显示了insert命令的执行和下一行的结果。结果(WriteResult)显示成功插入了一条记录。首先执行一个find()查询,确认记录是否按我们的要求创建:

> db.new_movies.find({"_id" : 1})
{ "_id" : 1, "title" : "Dunkirk" }

前面的查询及其输出验证了我们文档的正确插入。但是,请注意,new_movies集合从未存在,也没有我们创建它。文档去哪了呢?

为了找到它,您在 shell 上执行show collections命令。此命令打印当前数据库中所有集合的名称:

> show collections
new_movies

前面的片段显示了一个名为new_movies的新集合被添加到数据库中。这证明了当执行insert命令时,如果该集合不存在,MongoDB 也会创建给定的集合。

注意

当插入新文档时,MongoDB 不会验证集合的名称。集合名称中的拼写错误将导致文档被添加到一个全新的集合中。此外,默认情况下,MongoDB 没有与集合关联的任何模式。因此,通过给出不正确的集合名称,您可能会意外地将您的文档添加到任何其他现有集合中,而 MongoDB 不会抱怨。这就是为什么您应该始终小心您的insert命令中的集合名称。

插入多个文档

当需要将多个文档插入到集合中时,可以多次调用在前一节中看到的insert()函数,如下所示:

db.new_movies.insert({"_id": 2, "title": "Baby Driver"})
db.new_movies.insert({"_id": 3, "title": "title" : "Logan"})
db.new_movies.insert({"_id": 4, "title": "John Wick: Chapter 2"})
db.new_movies.insert({"_id": 5, "title": "A Ghost Story"})

MongoDB 集合还提供了insertMany()函数,这是一个专门用于向集合中插入多个文档的函数。如下面的语法所示,此函数接受一个包含一个或多个要插入的文档的数组作为参数:

db.movies.insertMany(< Array of One or More Documents>)

要使用此函数,创建要插入的所有文档的数组,然后将此数组传递给函数。相同的四部电影的数组将如下所示:

[
    {"_id" : 2, "title": "Baby Driver"},
    {"_id" : 3, "title": "Logan"},
    {"_id" : 4, "title": "John Wick: Chapter 2"},
    {"_id" : 5, "title": "A Ghost Story"}
]

现在,您将这四部新电影插入到集合中:

db.new_movies.insertMany([
    {"_id" : 2, "title": "Baby Driver"},
    {"_id" : 3, "title": "Logan"},
    {"_id" : 4, "title": "John Wick: Chapter 2"},
    {"_id" : 5, "title": "A Ghost Story"}
])

前面的命令使用了insertMany()并将一个包含四部电影的数组传递给它。您可以在下图中看到结果:

图 5.1:使用 insertMany()传递一个包含四部电影的数组

图 5.1:使用 insertMany()传递一个包含四部电影的数组

前面操作的结果包含两个内容。第一个字段是acknowledged,其值为true。这确认了写操作已成功执行。结果的第二个字段列出了所有插入文档的IDs。要插入多个文档,最好使用insertMany()函数,因为插入作为单个操作进行。另一方面,单独插入每个文档将作为多个不同的数据库命令执行,并且会使过程变慢。

注意

您可以使用insertMany()函数插入尽可能多的文档。但是,批处理大小不应超过 100,000。在 mongo shell 上,如果尝试在单个批处理中插入超过 100,000 个文档,查询将失败。如果使用编程语言做同样的事情,MongoDB 驱动程序将在内部将单个操作拆分为多个允许大小的批次,并执行批量插入。

插入重复的键

在任何数据库系统中,主键在表中始终是唯一的。同样,在 MongoDB 集合中,由_id字段表示的值是主键,因此必须是唯一的。如果尝试插入已经存在于集合中的键的文档,将会收到重复键错误

在前面的示例中,我们已经插入了一个_id2的电影。现在我们将尝试在另一个insert操作中复制主键:

db.new_movies.insert({"_id" : 2, "title" : "Some other movie"})

insert操作将一个虚拟电影插入到集合中,并明确将_id字段指定为2。当执行该命令时,我们会收到一个详细的重复键错误消息,如下图所示:

图 5.2:重复的 _id 字段的���误消息

图 5.2:重复的 _id 字段的错误消息

同样,当给定数组中的一个或多个文档具有重复的_id时,批量插入操作将失败。例如,考虑以下代码片段:

db.new_movies.insertMany([
    {"_id" : 6, "title" : "some movie 1"}, 
    {"_id" : 7, "title" : "some movie 2"},
    {"_id" : 2, "title" : "Movie with duplicate _id"},
    {"_id" : 8, "title" : "some movie 3"},
])

在这里,使用insertMany()操作,您将向集合中插入四部不同的电影。然而,第三部电影的_id2,我们知道已经存在另一部具有相同_id的电影。这导致错误,如下图所示:

图 5.3:重复 _id 字段的错误消息

图 5.3:重复 _id 字段的错误消息

当您执行该命令时,它将失败并显示详细的错误消息。错误消息清楚地指出_id字段中的值2是重复的。然而,nInserted的值表明已成功插入了两个文档。为了确认这一点,您将查询数据库并观察输出:

> db.new_movies.find({"_id" : {$in : [6, 7, 2, 8]}})
{ "_id" : 2, "title" : "Baby Driver" }
{ "_id" : 6, "title" : "some movie 1" }
{ "_id" : 7, "title" : "some movie 2" } 

从前面的find()命令及其输出中,我们可以得出结论,该命令在插入第三个文档时失败。然而,在第三个文档之前插入的文档将保留在数据库中。

没有 _id 的插入

到目前为止,我们已经学习了在集合中创建新文档的基础知识。在我们到目前为止展示的所有示例中,我们都明确添加了主键(_id字段)。然而,在第二章文档和数据类型中,我们学到,在创建新文档时,MongoDB 会验证给定主键的存在和唯一性,如果主键尚不存在,数据库会自动生成它并将其添加到文档中。

以下是 mongo shell 中执行insert命令的代码片段。insert命令试图将新电影推送到集合中,但文档没有_id字段。下一行的结果显示,文档已成功创建在集合中:

> db.new_movies.insert({"title": "Thelma"})
WriteResult({ "nInserted" : 1 })

现在,您查询新插入的文档,并查看它是否具有_id字段。为此,使用title字段的值查询集合:

> db.new_movies.find({"title" : "Thelma"})
{ "_id" : ObjectId("5df6a0e1b32aea114de21834"), "title" : "Thelma" }

在前面的代码片段中,结果显示文档存在于集合中,并且自动生成的_id字段已添加到文档中。正如我们在第二章文档和数据类型中学到的,自动生成的主键来自ObjectId构造函数,它是全局唯一的。对于批量插入也是如此。例如,考虑以下代码片段:

db.new_movies.insertMany([
    {"_id" : 9, "title" : "movie_1"},
    {"_id" : 10, "title" : "movie_2"},
    {"title" : "movie_3"},
    {"_id" : 8, "title" : "movie_4"},
])

在这里,insertMany()命令将四部电影推送到集合中。在这四个新文档中,第三个文档没有主键;然而,其余的文档都有各自的主键。其结果如下所示:

图 5.4:插入没有 _id 的电影

图 5.4:插入没有 _id 的电影

查询的输出表明查询成功,并且insertedIds字段显示除第三个文档外,所有文档都已使用给定的键插入,第三个文档获得了自动生成的主键。

在处理数据集时,我们的文档将具有可用作主键的唯一字段。主键是可以唯一标识记录的字段。MongoDB 自动生成的键在唯一性方面很有用,但在表示相应文档的数据方面是无意义的。此外,这些自动生成的键很长,因此输入或记住它们很麻烦。因此,我们应该始终尝试使用数据集中已经存在的主键。例如,在用户数据集中,email_address字段是主键的最佳示例。然而,在电影的情况下,没有可以是唯一的字段。因此,对于电影,我们可以使用自动生成的主键。

在本节中,我们介绍了如何在集合中创建单个文档和多个文档。在此过程中,我们了解到在 MongoDB 中,insert命令还会在不存在时创建底层集合。我们还了解到主键在集合中需要是唯一的,如果新文档没有主键,MongoDB 会自动生成并添加它。

删除文档

在本节中,我们将看到如何从集合中删除文档。要从集合中删除一个或多个文档,我们必须使用 MongoDB 提供的各种删除函数之一。每个函数都有不同的行为和目的。要从集合中删除文档,我们必须使用其中一个删除函数,并提供一个查询条件来指定应删除哪些文档。让我们详细看一下。

使用 deleteOne()删除

正如其名称所示,deleteOne()函数用于从集合中删除单个文档。它接受一个表示查询条件的文档。成功执行后,它返回一个包含删除的文档总数(由字段deletedCount表示)以及操作是否被确认(由字段acknowledged给出)的文档。然而,由于该方法只删除一个文档,deletedCount的值始终为 1。如果给定的查询条件在集合中匹配多个文档,只有第一个文档将被删除。

要查看这一点,请使用deleteOne()编写一个删除命令并查看结果:

> db.new_movies.deleteOne({"_id": 2})
{ "acknowledged" : true, "deletedCount" : 1 }

在前面的代码片段中,您执行了deleteOne()命令,并传递了一个查询条件{_id: 2}。这意味着您要删除_id值为2的文档。下一行的输出表明删除成功删除了。

练习 5.01:删除多个匹配的文档中的一个

在这个练习中,您将使用一个匹配多个文档的查询,并验证当您这样做时只有第一个文档被删除。执行以下步骤完成这个练习:

  1. 在查询中使用正则表达式,匹配所有title字段以单词movie开头的电影,如下所示:
({"title" : {"$regex": "^movie"}}

mongo shell 中的以下片段显示,当您在find()查询中使用前面的查询条件时,您会得到四部电影:

> db.new_movies.find({"title" : {"$regex": "^movie"}})
{ "_id" : 9, "title" : "movie_1" }
{ "_id" : 10, "title" : "movie_2" }
{ "_id" : ObjectId("5ef2666a6c3f28e14fddc816"), "title" : "movie_3" }
{ "_id" : 8, "title" : "movie_4" }
  1. 使用相同的查询条件和deleteOne()来匹配所有标题以单词movie开头的电影:
> db.new_movies.deleteOne({"title" : {"$regex": "^movie"}})
{ "acknowledged" : true, "deletedCount" : 1 }

这里的第二行输出确认只有一个文档成功删除。

  1. 要找出哪个文档被删除了,请在您的集合上执行相同的find()查询:
> db.new_movies.find({"title" : {"$regex": "^movie"}})
{ "_id" : 10, "title" : "movie_2" }
{ "_id" : ObjectId("5ef2666a6c3f28e14fddc816"), "title" : "movie_3" }
{ "_id" : 8, "title" : "movie_4" }

前面的片段证实,尽管所有四个文档都匹配了查询条件,但只有第一个文档被删除了。

使用 deleteMany()删除多个文档

为了删除符合给定条件的多个文档,您可以多次执行deleteOne()函数。然而,在这种情况下,每个文档将在单独的数据库命令中被删除,这可能会降低性能。MongoDB 集合提供了deleteMany()函数,可以在单个命令中删除多个文档。

deleteMany()函数必须提供一个查询条件,所有匹配给定查询的文档将被删除:

> db.new_movies.deleteMany({"title" : {"$regex": "^movie"}})
{ "acknowledged" : true, "deletedCount" : 3 }

在上一个片段中的deleteMany()命令使用了前面示例中使用的相同的正则表达式。下一行的输出表明,所有标题以单词"movie"开头的三部电影都被删除了。

在匹配给定查询表达式的文档方面,这两个删除函数的行为与我们在上一章中看到的查找文档的行为类似。传递一个空的查询文档等同于不传递任何过滤器,因此所有文档都被匹配。

在以下示例中,这两个命令都被给予了一个空的查询文档:

db.new_movies.deleteOne({})
db.new_movies.deleteMany({})

deleteOne()函数将删除找到的第一个文档。但是,deleteMany()函数将删除集合中的所有文档。同样,以下查询对不存在的字段执行null检查。在 MongoDB 中,不存在的字段被视为null,因此给定条件将匹配集合中的所有文档:

db.new_movies.deleteOne({"non_existent_field" : null})
db.new_movies.deleteMany({"non_existent_field" : null})

注意

与查找文档不同,删除操作是write操作,并且会永久改变集合的状态。因此,在编写查询条件时,包括空值检查,您应该始终确保字段名称没有拼写错误。不正确的字段名称可能导致从集合中删除所有文档。

使用 findOneAndDelete()进行删除

除了我们之前看到的两种删除方法之外,还有另一个名为findOneAndDelete()的函数,正如其名称所示,它从集合中查找并删除一个文档。虽然它的行为类似于deleteOne()函数,但它提供了一些更多的选项:

  • 它找到一个文档并将其删除。

  • 如果找到多个文档,只有第一个会被删除。

  • 一旦删除,它会将被删除的文档作为响应返回。

  • 在多个文档匹配的情况下,可以使用sort选项来影响哪个文档被删除。

  • 投影可以用来在响应中包含或排除文档中的字段。

在这里,使用findOneAndDelete()来删除一条记录,并将删除的文档作为响应获取:

> db.new_movies.findOneAndDelete({"_id": 3})
{ "_id" : 3, "title" : "Logan" }

在上面的片段中,删除命令通过其_id找到一个文档。下一行的响应显示了被删除的文档。这是一个非常有用的功能。首先,因为它清楚地指示了匹配和删除的记录。其次,它允许您进一步处理已删除的记录。在某些情况下,您可能希望将记录存储在归档集合中,或者您可能希望通知其他系统进行此删除。如果查询匹配多个文档,只有第一个文档会被删除。但是,您可以使用选项对匹配的文档进行排序并控制哪个文档被删除,如下面的片段所示:

db.new_movies.insertMany([
  { "_id" : 11, "title" : "movie_11" },
  { "_id" : 12, "title" : "movie_12" },
  { "_id" : 13, "title" : "movie_13" },
  { "_id" : 14, "title" : "movie_14" },
  { "_id" : 15, "title" : "series_15" }
])

使用上面的insert命令,您已经将五个新文档插入到您的集合中。在下面的片段中,您使用findOneAndDelete()命令,该命令使用正则表达式在集合中查找以单词movie开头的标题。查询将匹配四个文档;但是,您将按照降序排序_id字段,以便删除_id为 14 的文档:

> db.new_movies.findOneAndDelete(
      {"title" : {"$regex" : "^movie"}},
      {sort : {"_id" : -1}}
  )
{ "_id" : 14, "title" : "movie_14" }

这个操作演示了排序选项如何影响被删除的文档。如果不提供排序选项,将会删除_id为 11 的文档。

正如我们所见,这个删除函数总是在响应中返回被删除的文档。我们还可以使用投影选项来控制响应中包含或排除的字段:

> db.new_movies.findOneAndDelete(
      {"title" : {"$regex" : "^movie"}},
      {sort : {"_id" : -1}, projection : {"_id" : 0, "title" : 1}}
  )
{ "title" : "movie_13" }

在这个删除命令中,我们使用了投影选项,只在响应中包含title字段。下一行的输出确认了成功的删除,并且响应中的文档只显示了title字段。

练习 5.02:删除评分低的电影

您组织中的电影档案团队负责确保数据库中存在大多数评分最高的电影。为了改善用户体验,他们希望经常对数据库进行质量检查,并删除评分最低的电影。为了衡量质量,他们希望考虑 IMDb 评分和总投票数,因为投票数越高意味着评分更可靠。

基于此,他们要求您从低评分电影列表中删除一部 IMDb 投票数较高、平均评分较低且获奖最少的电影。您在本练习中的任务是连接到sample_mflix集群并执行删除命令,以便删除获奖最少、IMDb 评分低于 2 且投票超过 50,000 的电影。然后,记录已删除电影的title_id。以下步骤将帮助您完成此练习:

  1. 由于您需要删除一部电影,您可以使用deleteOne()findOneAndDelete()函数,并使用 IMDb 评分和投票准备查询过滤器。但是,为了确保删除获奖最少的电影,您需要按获奖次数升序对电影进行排序,并让结果列表中的第一部电影被删除。这意味着您需要使用findOneAndDelete()。首先,打开任何文本编辑器并开始编写查询。首先编写查询过滤器。第一个条件是查找 IMDb 评分低于两分的电影:
("imdb.rating" : {$lt : 2}}

IMDb 评分是一个嵌套字段;因此,您将使用点表示法访问该字段,然后使用$lt运算符编写条件。

  1. 接下来,第二个条件表示 IMDb 投票的总数应超过 50,000。将此条件添加到您的查询中:
("imdb.rating" : {$lt : 2}, "imdb.votes" : {$gt : 50000}}

第二个条件使用$gt运算符表示。

  1. 现在,编写一个findOneAndDelete()函数,并将前面的查询添加到其中:
db.movies.findOneAndDelete(
  {"imdb.rating" : {$lt : 2}, "imdb.votes" : {$gt : 50000}}
)

前面的命令将查找评分低于 2 星且投票超过 50,000 的电影,并删除第一个。但是,您还希望确保删除获奖最少的电影。

  1. 要删除获奖最少的电影,请添加一个sort选项:
db.movies.findOneAndDelete(
  {"imdb.rating" : {$lt : 2}, "imdb.votes" : {$gt : 50000}},
  {"sort" : {"awards.won" : 1}}
)

此命令将按获奖次数升序对筛选后的电影进行排序。

  1. 现在,添加一个投影选项,仅返回已删除电影的_idtitle字段:
db.movies.findOneAndDelete(
  {"imdb.rating" : {$lt : 2},"imdb.votes" : {$gt : 50000}},
  {
    "sort" : {"awards.won":1},
    "projection" : {"title" : 1}
  }
)

前面的命令具有一个投影选项,其中明确包含了title字段。这意味着所有其他字段将被排除,而_id默认包含。

  1. 最后,打开 mongo shell 并连接到 Atlas 集群。使用sample_mflix数据库并执行前面的命令。您应该看到以下输出:图 5.5:删除低评分电影

图 5.5:删除低评分电影

如前面的输出所示,命令已成功执行。响应中返回的文档正确包括已删除电影的_idtitle

在这个练习中,您使用了一个删除函数,正确地从电影的真实集合中删除了一个特定的记录。

替换文档

在本节中,您将学习如何完全替换集合中的文档。

有时,您可能希望替换集合中错误插入的文档。或者考虑到,通常,文档中存储的数据会随时间而改变。或者,为了支持产品的新要求,您可能希望更改文档的结构或更改文档中的字段。在所有这些情况下,您都需要替换文档。

在前一节中,我们使用了一个名为CH05的新数据库,我们将在本节中继续使用。在同一个数据库中,创建一个名为users的集合,并插入一些用户,如下所示:

> db.users.insertMany([
  {"_id": 2, "name": "Jon Snow", "email": "Jon.Snow@got.es"},
  {"_id": 3, "name": "Joffrey Baratheon", "email":     "Joffrey.Baratheon@got.es"},
  {"_id": 5, "name": "Margaery Tyrell", "email":     "Margaery.Tyrell@got.es"},
  {"_id": 6, "name": "Khal Drogo", "email": "Khal.Drogo@got.es"}
])
{ "acknowledged" : true, "insertedIds" : [ 2, 3, 5, 6 ] }

您可以看到命令执行成功,并添加了四个用户。在继续之前,快速使用find()命令确保集合中除了新插入的文档之外没有其他文档:

> db.users.find()
{ "_id" : 2, "name" : "Jon Snow", "email" : "Jon.Snow@got.es" }
{ "_id" : 3, "name" : "Joffrey Baratheon", "email" :   "Joffrey.Baratheon@got.es" }
{ "_id" : 5, "name" : "Margaery Tyrell", "email" :   "Margaery.Tyrell@got.es" }
{ "_id" : 6, "name" : "Khal Drogo", "email" : "Khal.Drogo@got.es" }

在前面片段的文档中,每个用户都有一个唯一的 ID、姓名和电子邮件地址。现在,假设用户Margaery TyrellJoffrey Baratheon结婚,并且她希望将姓氏改为丈夫的姓氏。为了实现这一点,您需要更改她的姓名以及她的电子邮件。

根据要求,Margaery Tyrell的新记录应如下所示:

{"_id": 5, "name": "Margaery Baratheon", "email": "Margaery.Baratheon@got.es"}

要替换集合中的单个文档,MongoDB 提供了replaceOne()方法,该方法接受查询过滤器和替换文档。该函数找到与条件匹配的文档,并用提供的文档替换它。以下示例演示了这一点:

> db.users.replaceOne(
  {"_id" : 5},
  {"name": "Margaery Baratheon", "email": "Margaery.Baratheon@got.es"}
)
{ "acknowledged" : true, "matchedCount" : 1, "modifiedCount" : 1 }

在这里,第一个参数是查询过滤器,用于识别要替换的文档,第二个参数是新文档。输出清楚地表明给定的查询匹配了一个文档,并且更新了一个文档。查询过滤器不一定总是_id字段。它可以是使用任何字段或多个字段和运算符进行过滤的任何查询。例如,以下替换命令将产生与前一个相同的效果,只要只有一个名为Margaery Tyrell的用户。如果有多个文档与查询匹配,则只有第一个文档将被替换:

db.users.replaceOne(
  {"name": "Margaery Tyrell },
  {"name": "Margaery Baratheon", "email": "Margaery.Baratheon@got.es"}
)

_id 字段是不可变的

在上一个例子中,您会注意到替换文档中没有_id字段。在这种情况下,您认为 MongoDB 一定会添加并自动生成一个主键字段吗?查询文档并找出:

> db.users.find({"name" : "Margaery Baratheon"})
{"_id": 5, "name": "Margaery Baratheon", "email":   "Margaery.Baratheon@got.es" }

前面的输出表明原始文档的_id在新文档中保留了下来。

这是因为 MongoDB 中的_id字段是不可变的。不可变字段就像普通字段一样;但是,一旦赋予了一个值,它们的值就不能再次更改。_id字段用作文档的唯一标识符,因此只要文档存在,就不应更改它。

这类似于您在各种在线门户网站上创建的用户帐户,其中您的用户名是您的唯一标识符。您可以更改密码,或者在您的个人资料中更改任何其他信息,但是大多数门户网站不允许您更改用户名。即使他们允许您修改用户名,旧用户名也不能分配给任何人,因为可能还有人知道您的旧用户名。

这就是为什么 MongoDB 中的_id字段是不可变的理论。然而,尝试修改该字段并观察会发生什么:

db.users.replaceOne(
  {"name" : "Margaery Baratheon"},
  {"_id": 9, "name": "Margaery Baratheon", "email":     "Margaery.Baratheon@got.es"}
)

在这里,替换命令找到了一个名为Margaery Baratheon的文档。在替换文档中,它还为_id字段提供了一个新值:

图 5.6:修改 _id 时出错

图 5.6:修改 _id 时出错

在这个例子中,您执行了一个替换命令,如前面片段所示,替换文档现在有一个显式的_id字段。命令失败,并显示了一个非常详细的错误消息。前面的快照突出了错误消息的最重要部分,表明该字段是不可变的。因此,更新被回滚,记录没有发生任何变化。

使用替换进行 Upsert

在前面的章节中,我们学到了可以在集合中找到现有文档并用新文档替换它。然而,有时您希望用新文档替换现有文档,并且如果文档尚不存在,则插入新文档。这个操作称为更新(如果找到)或插入(如果找不到),进一步缩短为 upsert。Upsert 是许多数据库提供的功能,MongoDB 也支持它。

为什么使用 Upsert?

对于我们上面看到的简单场景,upsert 听起来有点不必要,尤其是当可以使用两个不同的命令轻松执行相同的操作时。例如,我们可以先执行一个替换命令并检查结果。匹配计数的值将告诉我们文档是否在集合中找到。如果没有找到文档,我们可以执行一个insert命令。

然而,在现实场景中,您大多数情况下会执行大量的这些操作。考虑到您的系统每天从用户服务器接收到更新,服务器会发送当天修改的所有文档。这些每日更新可能包括新用户在服务器上注册的记录,以及对现有用户配置文件的更改。在大规模系统上,为每条记录执行两步更新或插入操作将非常耗时且容易出错。然而,有了专门的命令,您可以简单地准备并执行每条记录的 upsert 命令,让 MongoDB 执行更新或插入。

考虑users集合中的以下记录:

> db.users.find()
{"_id": 2, "name": "Jon Snow", "email": "Jon.Snow@got.es"}
{"_id": 3, "name": "Joffrey Baratheon", "email":   "Joffrey.Baratheon@got.es"}
{"_id": 5, "name": "Margaery Baratheon", "email":   "Margaery.Baratheon@got.es"}
{"_id": 6, "name": "Khal Drogo", "email": "Khal.Drogo@got.es"}

在一集的结尾,乔佛里国王被杀害。因此,Margaery想要恢复她的旧姓,而Tommen Baratheon成为新国王。您从用户服务器接收到的更新包含了Margaery的更新记录和Tommen的新记录,如下所示:

{"name": "Margaery Tyrell", "email": "Margaery.Tyrell@got.es"}
{"name": "Tommen Baratheon", "email": "Tommen.Baratheon@got.es"}

在以下命令中,您传递了一个额外的参数{upsert: true},这使这些命令成为 upsert 命令:

db.users.replaceOne(
  {"name" : "Margaery Baratheon"},
  {"name": "Margaery Tyrell", "email": "Margaery.Tyrell@got.es"},
  { upsert: true }
)
db.users.replaceOne(
  {"name" : "Tommen Baratheon"},
  {"name": "Tommen Baratheon", "email": "Tommen.Baratheon@got.es"},
  { upsert: true }
)

当您在 mongo shell 上依次执行这些命令时,您会看到以下输出:

图 5.7:upsert 操作的输出

图 5.7:upsert 操作的输出

第一个 upsert 的结果表明找到了匹配项,并且文档已被更新。然而,第二个表明未找到匹配项,并且使用自动生成的主键进行了新文档的 upsert。

使用 findOneAndReplace()进行替换

我们已经看到了replaceOne()函数,成功执行后返回匹配和更新的文档计数。MongoDB 提供了另一个操作findOneAndReplace()来执行相同的操作。但是,它提供了更多选项。其主要特点如下:

  • 顾名思义,它找到一个文档并替换它。

  • 如果找到多个与查询匹配的文档,第一个将被替换。

  • 可以使用排序选项来影响匹配多个文档时哪个文档被替换。

  • 默认情况下,它返回原始文档。

  • 如果设置了{returnNewDocument: true}选项,新添加的文档将被返回。

  • 字段投影可用于在响应中只包含特定字段的文档。

要查看findOneAndReplace()函数的操作,请向电影集合添加五个文档:

db.movies.insertMany([
    { "_id": 1011, "title" : "Macbeth" },
    { "_id": 1513, "title" : "Macbeth" },
    { "_id": 1651, "title" : "Macbeth" },
    { "_id": 1819, "title" : "Macbeth" },
    { "_id": 2117, "title" : "Macbeth" }
])

现在,假设这五部电影都具有相同的title,并且在不同的日历年份发布和插入。当这些记录最初插入时,发布年份的字段尚未添加。因此,要找到具有此title的最新电影,您需要使用递增的_id字段,其中具有最大_id值的电影是最新的。为了使未来的查找查询更简单,您已被指示找到具有此title的最新电影的文档,并向该文档添加latest: true标志。因此,当有人尝试查找该电影时,他们可以传递此附加过滤器以获取响应中的最新电影,如下所示:

db.movies.findOneAndReplace(
    {"title" : "Macbeth"},
    {"title" : "Macbeth", "latest" : true},
    {
        sort : {"_id" : -1},
        projection : {"_id" : 0}
    }
)

在上面的片段中,您通过title找到了一部电影的文档,并用包含额外字段latest: true的另一个文档替换了它。除此��外,该命令使用了sort选项,以便具有最大值_id的记录出现在顶部。该命令还使用了投影选项,只在响应中包含title字段。输出如下:

图 5.8:findOneAndReplace 命令的输出

图 5.8:findOneAndReplace 命令的输出

上述快照确认了操作成功,并且旧文档的title包含在响应中。或者,如果需要在响应中获取更新后的文档,可以使用命令中的returnNewDocument标志。将此标志设置为 true 将从集合中返回替换后的文档,如下所示:

db.movies.findOneAndReplace(
    {"title" : "Macbeth"},
    {"title" : "Macbeth", "latest" : true},
    {
        sort : {"_id" : -1},
        projection : {"_id" : 0},
        returnNewDocument : true
    }
)

这个替换命令与之前的命令类似,唯一的区别是它使用了一个额外的returnNewDocument选项,该选项设置为true

图 5.9:设置 returnNewDocument 为 true 后的输出

图 5.9:设置 returnNewDocument 为 true 后的输出

该输出显示,将returnNewDocument标志设置为true会返回新文档。现在,快速查询数据库,看看替换命令是否真的起作用:

> db.movies.find({"title" : "Macbeth"})
{ "_id" : 1011, "title" : "Macbeth" }
{ "_id" : 1513, "title" : "Macbeth" }
{ "_id" : 1651, "title" : "Macbeth" }
{ "_id" : 1819, "title" : "Macbeth" }
{ "_id" : 2117, "title" : "Macbeth", "latest" : true }

上述输出显示,最新的记录现在具有所需的标志。

替换与删除和重新插入

正如我们在前面的部分中所看到的,有专门的函数来查找和替换集合中的文档。可以使用删除和插入的组合来替换文档,其中您删除一个现有文档并插入一个新文档。删除和insert组合的这两步操作会给您相同的结果;让我们看看如何操作。

要使用删除和insert进行两步替换操作,请使用在findOneAndReplace()部分中看到的相同示例。

首先,从集合中删除所有先前插入或修改的文档:

> db.movies.deleteMany({})
{ "acknowledged" : true, "deletedCount" : 5 }

现在,再次插入这五个文档:

db.movies.insertMany([
    { "_id": 1011, "title" : "Macbeth" },
    { "_id": 1513, "title" : "Macbeth" },
    { "_id": 1651, "title" : "Macbeth" },
    { "_id": 1819, "title" : "Macbeth" },
    { "_id": 2117, "title" : "Macbeth" }
])

现在,找到标题为Macbeth的最新电影的文档,并为其添加标志"latest" : true

var deletedDocument = db.movies.findOneAndDelete(
                          {"title" : "Macbeth"},
                          {sort : {"_id" : -1}}
    )
db.movies.insert(
  {"_id" : deletedDocument._id, "title" : "Macbeth", "latest" : true}
)

这个片段显示了两个不同的命令。第一个是findOneAndDelete()命令,它通过title找到一部电影,并使用排序选项,以便只删除具有最大_id的电影。删除操作的结果,即已删除的文档,存储在deletedDocument的变量中。

上述片段中的下一个命令是一个插入操作,它重新插入了相同的电影,并使用了latest : true标志。在这样做的同时,它使用了已删除文档的_id值,以便新记录使用相同的主键进行插入:

图 5.10:删除后插入的输出

图 5.10:删除后插入的输出

上述输出表明您已经按顺序执行了两个命令,响应显示成功插入了一个文档,可以使用find操作进行验证:

> db.movies.find()
{ "_id" : 1011, "title" : "Macbeth" }
{ "_id" : 1513, "title" : "Macbeth" }
{ "_id" : 1651, "title" : "Macbeth" }
{ "_id" : 1819, "title" : "Macbeth" }
{ "_id" : 2117, "title" : "Macbeth", "latest" : true }

对集合进行find操作的结果确认了两步替换操作完美地起作用。

尽管结果完全相同,两步操作更容易出错。两步操作执行两个完全不同的命令,一个接着一个。在第一个命令中,您的 MongoDB 客户端或编程语言的驱动程序将delete命令发送到服务器。然后服务器验证和处理命令以删除文档。然后已删除的文档通过网络发送回客户端。客户端或驱动程序然后将返回的结果解析为特定于语言的对象。在我们的情况下,我们正在从 mongo shell 执行命令,因此结果被解析为 JSON 格式并存储在变量deleteDocument中。

接下来,您的 MongoDB 客户端或驱动程序发送另一个命令来插入新文档。新文档,在我们的情况下是 JSON 格式,被转换为 BSON 并通过网络发送到服务器。对于 MongoDB 服务器,这个insert命令就像任何其他新的insert命令一样。服务器对文档进行初始验证,检查_id字段是否存在,并验证集合中值的唯一性。如果文档被发现有效,插入将会发生。

现在您已经熟悉了两步替换操作的细节,请考虑在使用它时可能存在的以下潜在缺陷:

  1. 首先,在删除和插入方法中,数据会多次通过网络传输。这需要驱动程序或客户端在多个阶段解析数据。这将减慢整体性能。

  2. 当多个客户端不断读取和写入您的集合时,可能会出现并发问题。例如,假设您已成功删除了一条记录,在插入新记录之前,某个其他客户端意外地插入了一个具有相同_id的不同记录。

  3. 您的数据库客户端或驱动程序可能在两个操作之间失去与数据库的连接。例如,删除操作成功,但插入无法进行。为了避免这种问题,您将不得不在事务中运行您的命令,以便一个操作的失败可以撤销同一事务中先前成功的操作。

另一方面,专用替换函数实际上是原子的,因此在并发环境中使用是安全的。原子操作是不能进一步分割的最小操作单位。因此,当执行原子操作时,它将作为单个单元一次性执行。因此,与删除和插入组合相比,专用替换函数更安全。

专用函数首先找到要替换的文档并锁定它。锁定只有在操作完成后才会释放。因此,在锁定文档时,没有其他客户端或进程能够修改该特定文档。此外,替换操作仅替换文档中其余的字段,保持_id不变。其他进程不可能能够推送具有相同_id值的不同文档。

因此,最好始终使用 MongoDB 提供的专用函数。

修改字段

在前面的部分,我们学到了一旦插入,就可以替换 MongoDB 集合中的任何文档。在替换操作期间,数据库中的文档将被完全新的文档替换,同时保留相同的主键。替换操作在纠正错误和合并数据更改或更新时非常有用。然而,在大多数情况下,更新只会影响文档的一个或几个字段。想象一下sample_mflix数据集中的任何电影记录,其中大多数字段(如标题、演员、导演、时长等)可能永远不会改变。然而,随着时间的推移,电影可能会收到新的评论、新的评价和评分。

查找和替换操作在所有或大多数文档字段被修改时非常有用。但是,使用它来更新文档中的特定字段将不容易。为此,您提供的替换文档将需要具有所有未更改字段及其现有值以及更改字段及其新值。对于较小的文档,这听起来不像是问题,但对于大型文档,比如我们的电影记录,命令将变得臃肿且容易出错。我们将通过一个我们不会在数据库上执行的命令的示例来看到这一点。

假设数据库中添加了一部电影的记录,但字段year的值不正确。以下是使用替换操作来更正该值的命令的示例。在第一条语句中,我们找到电影文档并将其分配给一个变量。接下来是实际的替换命令,其中需要提供具有所有字段的替换文档。我们使用在第一行中分配的变量movie,并引用其所有未更改的字段。替换文档中的最后一个字段是year字段,其具有新值:

// Find the movie and assign it to a variable
var movie = db.movies.findOne({"title" : "The Italian"})
// A replace function that keeps all the fields same except "year"
db.movies.replaceOne(
  {"title" : "The Italian"},
  {
    "plot" : movie.plot,
    "genres" : movie.genres,
    "runtime" : movie.runtime,
    "rated" : movie.rated,
    "cast" : movie.cast,
    "title" : movie.title,
    "fullplot" : movie.fullplot,
    "language" : movie.language,
    "released" : movie.released,
    "directors" : movie.directors,
    "writers" : movie.writers,
    "awards" : movie.awards,
    "imdb" : movie.imdb,
    "countries" : movie.countries,
    "type" : movie.type,
    "tomatoes" : movie.tomatoes,
    "year" : 1915
  }
)

该命令的问题在于它太庞大了,特别是因为我们只想要更新一个字段。它重新输入了所有字段,即使它们没有改变,而且在重新分配未更改的字段值时很可能会引入拼写错误。此外,这是一个两步操作,引入了难以调试的并发问题。

要理解并发问题,想象一下第一条语句中的查找操作成功,下一条语句是一个替换命令,引用了现有文档中所有未更改的字段;但在第二条语句执行之前,数据库中的实际文档被其他客户端或线程修改了。一旦您的语句执行,其他客户端添加的更新将永远丢失。

这就是为什么替换操作应该仅在修改所有或大部分字段时使用。要修改文档的一个或只有几个字段,MongoDB 提供了update命令。让我们在下一节中探讨这个问题。

使用updateOne()更新文档

要更新集合中单个文档的字段,我们可以使用updateOne()函数。这个函数由 MongoDB 集合提供,接受一个查询条件来找到要更新的记录,以及一个指定字段级更新表达式的文档。函数的第三个参数是提供杂项选项的,是可选的。这个函数的语法如下:

db.collection.updateOne(<query condition>,   <update expression>, <options>)

与替换命令一样,updateOne()不能用于更新文档的_id字段,因为它是不可变的。更新执行后,它以文档的形式返回详细结果,指示匹配了多少条记录以及更新了多少条记录。

在使用此功能之前,首先从集合中删除所有先前插入和修改的记录:

> db.movies.deleteMany({})
{ "acknowledged" : true, "deletedCount" : 5 }

现在,使用以下insert命令向集合添加四条新记录:

> db.movies.insertMany([
  {"_id": 1, "title": "Macbeth", "year": 2014, "type": "series"}, 
  {"_id": 2, "title": "Inside Out", "year": 2015,     "type": "movie", "num_mflix_comments": 1},
  {"_id": 3, "title": "The Martian", "year": 2015,     "type": "movie", "num_mflix_comments": 1},
  {"_id": 4, "title": "Everest", "year": 2015,     "type": "movie", "num_mflix_comments": 1}
])
{ "acknowledged" : true, "insertedIds" : [ 1, 2, 3, 4 ] }

编写并执行第一个更新命令以更改电影Macbethyear字段:

db.movies.updateOne(
    {"title" : "Macbeth"},
    {$set : {"year" : 2015}}
)

在上述命令中,updateOne()函数的第一个参数是查询条件,其中您指定电影的名称应为Macbeth。第二个参数是一个指定year字段及其值的文档。在这里,我们使用了一个新的运算符$set,来为文档中提供的字段赋值。在接下来的章节中,我们将学习更多关于$set运算符以及所有更新函数支持的其他运算符。

当在 mongo shell 上执行该命令时,输出如下:

> db.movies.updateOne(
  {"title" : "Macbeth"},
  {$set : {"year" : 2015}}
)
{ "acknowledged" : true, "matchedCount" : 1, "modifiedCount" : 1 }

输出是一个表示以下内容的文档:

  • "acknowledged" : true 表示更新已执行并已确认。

  • "matchedCount" : 1 显示找到并选择进行更新的文档数量(在这种情况下为 1)。

  • "modifiedCount" : 1 指的是修改的文档数量(在这种情况下为 1)。

以下查询和随后的输出确认了更新命令的正确执行:

> db.movies.find({"title" : "Macbeth"})
{ "_id" : 1, "title" : "Macbeth", "year" : 2015, "type" : "series" }

在上述记录中,字段year正确设置为2015,之前是2014。如果我们再次执行相同的命令,由于值已经是2015,不会执行更新:

> db.movies.updateOne(
    {"title" : "Macbeth"},
    {$set : {"year" : 2015}}
)
{ "acknowledged" : true, "matchedCount" : 1, "modifiedCount" : 0 }

图 5.12显示了再次执行相同更新命令的输出。结果文档表明有一个文档符合更新的条件,但是没有文档被更新。

修改多个字段

我们用来更新文档字段的$set运算符也可以用来修改文档的多个字段。如前面的例子所示,$set后面跟着包含更新表达式的文档。同样,要修改多个字段,更新表达式可以包含多个字段和值对。例如,考虑以下代码片段:

db.movies.updateOne(
  {"title" : "Macbeth"},
  {$set : {"type" : "movie", "num_mflix_comments" : 1}}
)

在前面的操作中,更新表达式{"type": "movie", "num_mflix_comments": 1}}指定了两个字段及其值。其中,num_mflix_comment字段在相应的电影中不存在。在我们的电影集合上执行该命令并查看输出:

> db.movies.updateOne(
  {"title" : "Macbeth"},
  {$set : {"type" : "movie", "num_mflix_comments" : 1}}
)
{ "acknowledged" : true, "matchedCount" : 1, "modifiedCount" : 1 }

前面的图表显示该操作成功,并且一个记录按预期被修改。现在查询文档并查看字段是否被正确修改:

> db.movies.find({"title" : "Macbeth"}).pretty()
{
  "_id" : 1,
  "title" : "Macbeth",
  "year" : 2015,
  "type" : "movie",
  "num_mflix_comments" : 1
}

集合中的文档表明电影类型已经正确修改,并且添加了一个名为num_mflix_comments的新字段,其给定值。因此,您已经看到$set可以用于在同一命令中更新多个字段,如果字段是新的,它将被添加到文档中并指定值。

在我们继续下一节之前,重要的是要知道,在更新操作中,多次更新相同字段是有效的,无论字段的值如何。如前面的输出所示,电影Macbethyear字段设置为 2015。在同一命令中多次修改相同字段:

db.movies.updateOne(
  {"title" : "Macbeth"},
  {$set : {"year" : 2015, "year" : 2015, "year" : 2016, "year" : 2017}}
)

前面的更新命令使用了$set运算符,多次设置了年份。前两个表达式将字段设置为其当前值;然而,最后两个表达式具有不同的值。执行该命令并观察行为:

db.movies.updateOne(
  {"title" : "Macbeth"},
  {$set : {"year" : 2015, "year" : 2015, "year" : 2016, "year" : 2017}}
)
{ "acknowledged" : true, "matchedCount" : 1, "modifiedCount" : 1 }

正如预期的那样,该操作是有效的,并且一个文档被修改。查询集合中的文档并查看year字段的值:

> db.movies.find({"title" : "Macbeth"}).pretty()
{
  "_id" : 1,
  "title" : "Macbeth",
  "year" : 2017,
  "type" : "movie",
  "num_mflix_comments" : 1
}

在前面的输出中,我们证明了当同一字段多次提供时,更新是从左到右进行的。首先,year字段(已经是 2015)被设置为 2015 两次;然后,通过第三个表达式,年份被设置为 2016;最后,通过最右边的表达式,它被设置为 2017。

在任何有效的情况下,您几乎不会在更新操作中两次更新字段。但是,即使您这样做,也许是意外的,现在您已经了解了行为,这将帮助您进行调试。

匹配条件的多个文档

正如updateOne()函数的名称所示,它总是只更新集合中的一个文档。如果给定的查询条件匹配多个文档,只有第一个文档将被修改:

db.movies.updateOne(
  {"type" : "movie"},
  {$set : {"flag" : "modified"}}
)

前面的操作找到typemovie的文档,并将flag的值设置为modified。请记住,我们的电影集合中共有三个movie类型的文档。当该命令在我们的集合上执行时,结果将如下所示:

db.movies.updateOne(
  {"type" : "movie"},
  {$set : {"flag" : "modified"}}
)
{ "acknowledged" : true, "matchedCount" : 1, "modifiedCount" : 1 }

执行结果表明有一个文档匹配并被选择进行更新,实际上有一个文档被修改。因此,这证明即使有多个文档匹配给定的查询条件,也只选择并更新一个文档。

使用 updateOne()进行 upsert

在前面的部分中,我们详细了解了 upsert 操作。当执行基于 upsert 的更新时,如果找到文档,将对其进行更新;但是,如果未找到文档,则在集合中创建一个新文档。与替换操作类似,updateOne()还支持在命令中使用附加标志进行 upsert。考虑以下代码片段:

db.movies.updateOne(
  {"title" : "Sicario"},
  {$set : {"year" : 2015}}
)

前面的操作在电影Sicario上执行了更新命令,该电影在我们的集合中不存在。当没有任何upsert标志的情况下执行该命令时,不会进行更新:

> db.movies.updateOne(
  {"title" : "Sicario"},
  {$set : {"year" : 2015}}
)
{ "acknowledged" : true, "matchedCount" : 0, "modifiedCount" : 0 }

输出表明没有匹配的文档,也没有更新的文档。现在,我们将使用upsert标志执行相同的命令:

db.movies.updateOne(
  {"title" : "Sicario"},
  {$set : {"year" : 2015}},
  {"upsert" : true}
)

前面的操作使用了第三个参数,其中包含一个将upsert标志设置为true的文档,默认为 false。输出如下所示:

图 5.11:使用 upsert 标志更新不存在的电影

图 5.11:使用 upsert 标志更新不存在的电影

因此,执行命令的输出这次略有不同。它指示没有匹配的文档,也没有更新的文档。然而,"upsertedId" : ObjectId("5e…")表明插入了一个带有自动生成的主键的文档。

以下查询使用自动生成的主键找到文档。当您在 shell 上执行此查询时,您将需要使用在上一个命令中生成的ObjectId

> db.movies.find({"_id" : ObjectId("5ef5484b76db1f20a60917d2")}).pretty()
{
  "_id" : ObjectId("5ef5484b76db1f20a60917d2"),
  "title" : "Sicario",
  "year" : 2015
}

当我们使用新创建的主键值查询集合时,我们得到了新插入的记录。

这里需要注意的一点是,新文档有两个字段,其中字段year是更新表达式的一部分;然而,title是查询条件的一部分。当 MongoDB 创建新文档作为upsert操作的一部分时,它会合并更新表达式和查询条件中的字段。

使用 findOneAndUpdate()更新文档

我们已经看到了updateOne()函数,它修改了集合中的一个文档。MongoDB 还提供了findOneAndUpdate()函数,它能够做到updateOne()所做的一切,并且还有一些额外的功能,我们现在将探讨。这个函数的语法与updateOne()相同:

db.collection.findOneAndUpdate (
  <query condition>, 
  <update expression>, 
  <options>
)

findOneAndUpdate()至少需要两个参数,第一个是用于找到要修改的文档的查询条件,第二个是更新表达式。默认情况下,它在响应中返回旧文档。在某些情况下,获取旧文档真的很有用,特别是当需要将其存档时。然而,通过传递一个标志作为参数,函数的行为可以更改为在响应中返回新文档。考虑以下示例。

我们集合中电影Macbeth的记录只有一个评论,由字段num_mflix_comments给出。使用以下更新命令修改这些评论的计数:

db.movies.findOneAndUpdate(
  {"title" : "Macbeth"},
  {$set : {"num_mflix_comments" : 10}}
)

上述命令通过title找到电影,并将num_mflix_comments设置为 10 的值。我们可以看到它看起来与updateOne()命令非常相似,对集合的影响也完全相同。然而,我们在这里看到的唯一区别是响应,如下图所示:

图 5.12:使用 fineOneAndUpdate()进行更新

图 5.12:使用 fineOneAndUpdate()进行更新

输出显示findOneAndUpdate()函数没有返回查询统计信息,比如匹配了多少记录,修改了多少记录。相反,它返回了文档的旧状态。现在查询并验证更新是否成功:

> db.movies.find({"title" : "Macbeth"}).pretty()
{
  "_id" : 1,
  "title" : "Macbeth",
  "year" : 2017,
  "type" : "movie",
  "num_mflix_comments" : 10,
  "flag" : "modified"
}

这里的查询及其输出确认了评论数量已经修改为新值。

返回响应中的新文档

到目前为止,我们使用了两个参数的函数,第一个是查询条件,第二个是更新表达式。然而,该函数还支持一个可选的第三个参数,用于向命令提供杂项选项。在这些选项中,returnNewDocument可以用于控制响应中应返回哪个文档。默认情况下,此标志的值设置为 false,因此我们在不传递选项的情况下得到了旧文档。然而,将此标志设置为 true,我们将在响应中得到修改后的或新文档。例如,考虑以下代码片段:

db.movies.findOneAndUpdate(
  {"title" : "Macbeth"},
  {$set : {"num_mflix_comments" : 15}},
  {"returnNewDocument" : true}
)

上述操作将评论计数设置为 15,并将returnNewDocument标志设置为 true。输出如下所示:

图 5.13:带有 returnNewDocument 标志的 findOneAndUpdate()

图 5.13:带有 returnNewDocument 标志的 findOneAndUpdate()

输出显示,通过将标志returnNewDocument设置为true,响应显示了修改后的文档,这也确认了评论数量已经正确修改。

通过函数的可选第三个参数,我们还可以提供一个表达式来限制文档中返回的字段数量(也称为投影表达式)。投影表达式可以用于返回旧文档或新文档作为响应的情况:

db.movies.findOneAndUpdate(
  {"title" : "Macbeth"},
  {$set : {"num_mflix_comments" : 20}},
  {
    "projection" : {"_id" : 0, "num_mflix_comments" : 1},
    "returnNewDocument" : true
  }
)

上述更新命令通过title找到电影,并将评论数设置为 20。作为第三个参数,它将两个选项传递给命令。第一个选项是投影表达式,它只在响应中包含num_mflix_comments,并明确排除了_id。通过使用第二个操作,函数将返回修改后的文档。输出可以在这里看到:

图 5.14:带有投影的 findOneAndUpdate()

图 5.14:带有投影的 findOneAndUpdate()

我们可以看到,投影表达式已排除了_id,并且只包含了num_mflix_comments字段,正如预期的那样。

排序以查找文档

到目前为止,我们已经介绍了两个更新函数,它们都能够一次更新一个文档。如果给定的查询条件���配了多个文档,那么将选择第一个文档进行修改。这种行为在两个函数之间是共同的。但是,findOneAndUpdate()函数提供了一个额外的选项,可以按特定顺序对匹配的文档进行排序。使用排序选项,您可以影响选择哪个文档进行修改。

排序选项被指定为findOneAndUpdate()函数的可选第三个参数下的一个字段。排序字段的值必须是包含有效排序表达式的文档。现在我们将看到一个在更新命令中使用排序选项的示例。

图 5.15显示了我们的集合有四条记录,都是电影类型。每个记录都有一个顺序的_id字段,最后插入的记录在序列中具有最大的值:

图 5.15:一个包含四条记录的集合

图 5.15:一个包含四条记录的集合

编写一个命令,将使用{"type" : "movie"}的相同过滤器,并将标志"latest" : true放到最后插入的记录中:

db.movies.findOneAndUpdate(
  {"type" : "movie"},
  {$set : {"latest" : true}},
  {
    "returnNewDocument" : true,
    "sort" : {"_id" : -1}
  }
)

上述片段中的更新命令将latest标志设置为 true。查询条件找到了一个typemovie的文档。选项参数设置一个标志,以在响应中返回修改后的文档,并且还指定了一个排序表达式,以按照主键的降序对文档进行排序:

图 5.16:通过排序匹配的文档更新一条记录

图 5.16:通过排序匹配的文档更新一条记录

更新命令的响应,如图 5.16所示,指示具有_id : 4的记录具有最新标志。这是由于指定的排序选项,它对匹配的记录进行排序,以便最大的 ID 将首先出现。函数选择了第一条记录并对其进行了修改。

练习 5.03:更新 IMDb 和 Tomatometer 评分

您的电影数据库记录了大量全球电影及其详细信息。您的产品所有者希望您保持数据库与最新变化的更新。人们仍然喜欢观看一些永恒的经典电影并对其进行评分或发表评论,因此一些几十年前发布的流行电影的评分每天都在变化。您的组织已决定无论电影的发布日期如何,都要纳入所有电影的评分更新。作为概念验证,他们选择了《教父》,这部有史以来最伟大的电影之一,并要求您使用最新的 IMDb 和 Tomatometer 评分对其进行更新。如果您的产品团队对此更新满意,他们将同意从这些平台接收定期更新。您的任务是编写并执行更新操作以更新这些评分。

这是电影的最新 IMDb 和 Tomatometer 观众评分:

IMDb 评分

评分:9.2,投票数:1,565,120

番茄表观众评分

评分:4.76,评论数量:733,777,米特 98

查看数据库以找到这些评分的当前值:

db.movies.find(
  {"title" : "The Godfather"},
  {"imdb" : 1, "tomatoes.viewer" : 1, "_id" : 0}
).pretty()

此查询查找并打印电影教父的 IMDb 和番茄表观众评分:

图 5.17:电影教父的评分

图 5.17:电影教父的评分

输出显示了sample_mflix数据库中当前的评分。

  1. 打开任何文本编辑器,并编写一个带有查询参数的findOneAndUpdate()命令:
db.movies.findOneAndUpdate(
  {"title" : "The Godfather"}
)
  1. 现在,使用$set运算符设置 IMDb 字段。由于 IMDb 评分仍然相同,因此只需更新votes字段。要引用votes的嵌套字段,使用点表示法:
db.movies.findOneAndUpdate(
  {"title" : "The Godfather"},
  {
    $set: {"imdb.votes" : 1565120}
  }
)
  1. 接下来,为番茄表观众评分添加另一个更新表达式。对于番茄表观众评分,您只需要更新ratingnumReviews字段。由于这是两个单独的字段,因此将两个单独的更新表达式添加到$set运算符中。由于这些字段嵌套在嵌套对象中,因此使用点表示法两次:
db.movies.findOneAndUpdate(
  {"title" : "The Godfather"},
  {
    $set: {
      "imdb.votes" : 1565120,
      "tomatoes.viewer.rating": 4.76,
      "tomatoes.viewer.numReviews": 733777
    }
  }
)
  1. 现在您的更新查询已经完成,添加标志以返回响应中修改后的文档以及特定字段的投影:
db.movies.findOneAndUpdate(
  {"title" : "The Godfather"},
  {
    $set: {
      "imdb.votes" : 1565120,
      "tomatoes.viewer.rating": 4.76,
      "tomatoes.viewer.numReviews": 733777
    }
  },
  {
    "projection" : {"imdb" : 1, "tomatoes.viewer" : 1, "_id" : 0},
    "returnNewDocument" : true
  }
)
  1. 打开 mongo shell 并连接到 Atlassample_mflix数据库。复制上一个命令并执行它:图 5.18:更新评分

图 5.18:更新后的评分

前面的输出显示相应的字段已经被正确更新。

在这个练习中,您已经练习了使用findOneAndUpdate()$set来更新嵌套字段的值。接下来,我们将学习使用updateMany()来更新多个文档。

使用 updateMany()更新多个文档

在前面的章节中,我们学习了如何找到一个文档并修改或更新其字段。然而,很多时候,您可能希望对集合中的多个文档执行相同的更新操作。MongoDB 提供了updateMany()函数,可以一次更新多个文档。与updateOne()类似,updateMany()函数需要两个必需的参数。第一个参数是查询条件,第二个是更新表达式。第三个参数是可选的,用于提供杂项选项。执行后,此函数将更新所有匹配给定查询条件的文档。函数的语法如下:

db.collection.updateMany(<query condition>,   <update expression>, <options>)

我们将在我们的电影集合上编写并执行更新操作。假设我们的电影集合有四部于 2015 年发布的电影。为这些电影添加一个名为languages的字段,如下所示:

db.movies.updateMany(
  {"year" : 2015},
  {$set : {"languages" : ["English"]}}
)

此更新操作使用两个参数。第一个是查找所有在 2015 年发布的电影。第二个参数是一个更新表达式,它使用$set运算符添加一个名为languages的新字段。languages字段的值是一个包含英语作为唯一语言的数组。输出如下:

db.movies.updateMany(
  {"year" : 2015},
  {$set : {"languages" : ["English"]}}
)
{ "acknowledged" : true, "matchedCount" : 4, "modifiedCount" : 4 }

输出表明操作成功,并且与updateOne()函数一样,响应中返回了类似的文档。响应表明查询条件匹配了总共四个文档,并且所有文档都已被修改。

在本节中,我们学习了如何修改 MongoDB 集合中一个或多个文档的字段。我们已经介绍了三个更新函数,其中updateOne()findOneAndUpdate()用于更新集合中的一个文档,而updateMany()用于更新集合中的多个文档。以下是关于更新操作的一些重要点,适用于所有三个函数:

  • 没有任何更新函数允许更改_id字段。

  • 文档中字段的顺序始终保持不变,除非更新包括重命名字段。但是,_id字段将始终首先出现。(我们将在下一节中介绍重命名字段)。

  • 更新操作在单个文档上是原子的。在另一个进程完成更新之前,文档不能被修改。

  • 所有的更新函数都支持 upsert。要执行 upsert 命令,需要将upsert : true作为选项传递。

在下一节中,我们将详细介绍各种更新运算符及其用法。

更新运算符

为了方便不同类型的更新命令,MongoDB 提供了各种更新运算符或更新修饰符,如 set、multiply、increment 等。在前面的部分中,我们使用了操作符$set,这是 MongoDB 提供的更新运算符之一。在本节中,我们将学习一些最常用的运算符和示例。在我们讨论运算符之前,我们将讨论它们的语法。以下代码片段显示了使用更新运算符的更新表达式的基本语法:

{
  <update operator>: {<field1> : <value1>, ... }
}

根据前面的语法,可以将运算符分配给包含一个或多个字段和值对的文档。然后,运算符将应用于每个字段,使用相应的值。像前面的更新表达式对于所有给定字段需要使用相同的运算符时是有用的。您可能还希望使用不同的运算符更新文档的不同字段。对于这种情况,更新表达式可以包含多个更新运算符,每个运算符之间用逗号分隔。

{
  <update operator 1>: {<field11> : <value11>, ... },
  <update operator 2>: {<field21> : <value21>, ... },
  ...,
}

前面的代码片段显示了在同一更新表达式中使用多个运算符的语法。在更新操作中,这些运算符中的每一个都将按顺序执行。

现在让我们详细了解每个更新运算符。

设置($set)

正如我们已经看到的,$set运算符用于设置文档中字段的值。它是最常用的运算符,因为它可以轻松地用于设置任何类型的字段的值或在文档中添加新字段。该运算符接受一个包含字段名和它们的新值对的文档。如果给定的字段尚不存在,它将被创建。

增量($inc)

增量运算符($inc)用于将数值字段的值增加特定数字。该运算符接受包含字段名和数字对的文档。给定正数,字段的值将增加;如果提供负数,值将减少。显而易见但值得一提的是,$inc运算符只能用于数值字段;如果尝试用于非数值字段,操作将失败并出现错误。

目前,在我们的集合中,Macbeth电影的文档如下所示:

> db.movies.find({"title" : "Macbeth"}).pretty()
{
  "_id" : 1,
  "title" : "Macbeth",
  "year" : 2017,
  "type" : "movie",
  "num_mflix_comments" : 20,
  "flag" : "modified"
}

现在,使用$inc运算符对两个字段进行更新,其中一个存在于文档中,另一个不存在:

db.movies.findOneAndUpdate(
  {"title" : "Macbeth"},
  {$inc : {"num_mflix_comments" : 3, "rating" : 1.5}},
  {returnNewDocument : true}
)

前面的更新操作通过title找到一部电影,将num_mflix_comments字段增加 3,将一个不存在的名为rating的字段增加1.5。它还将returnNewDocument设置为true,以便在响应中返回更新后的记录。您可以在以下截图中看到输出:

图 5.19:增加评论数量和评分

图 5.19:增加评论数量和评分

因此,更新命令成功。num_mflix_comments字段正确增加了 3,rating(原本不存在的字段)现在已添加到文档中,并具有指定的值。我们将看到减少字段值的示例:

db.movies.findOneAndUpdate(
  {"title" : "Macbeth"},
  {$inc : {"num_mflix_comments" : -2, "rating" : -0.2}},
  {returnNewDocument : true}
)

前面的命令在两个字段上使用了$inc运算符,并提供了负数:

图 5.20:减少评论数量和评分

图 5.20:减少评论数量和评分

图 5.20所示,负增量导致响应。rating,原为 1.5,现在减少了 0.2,num_mflix_comments减少到 21。

乘法($mul)

乘法($mul)操作符用于将数字字段的值乘以给定的数字。该操作符接受包含字段名称和数字对的文档,并且只能用于数字字段。例如,考虑以下代码片段:

db.movies.findOneAndUpdate(
  {"title" : "Macbeth"},
  {$mul : {"rating" : 2}},
  {returnNewDocument : true}
)

前面的更新操作通过title找到电影,使用$mulrating字段的值乘以 2,并添加一个选项在响应中返回修改后的文档。结果如下所示:

图 5.21:将评分翻倍

图 5.21:将评分翻倍

输出显示字段rating的值乘以 2。在使用$mul时,应始终记住,无论我们提供什么乘数,该字段都将被创建并始终设置为零。这是因为,在乘法操作中,假定不存在的数字字段的值为零。因此,在零上使用任何乘数都会得到零:

db.movies.findOneAndUpdate(
  {"title" : "Macbeth"},
  {$mul : {"box_office_collection" : 16.3}},
  {returnNewDocument : true}
)

此更新操作将不存在的字段box_office_collection乘以给定值:

图 5.22:将不存在的字段的值乘以 2

图 5.22:将不存在的字段的值乘以 2

图 5.22中的输出证明,不管提供的值如何,box_office_collection的不存在字段都已添加了值为零。

重命名($rename)

如其名称所示,$rename操作符用于重命名字段。该操作符接受包含字段名称和它们的新名称对的文档。如果字段尚未存在于文档中,则操作符会忽略它并不执行任何操作。提供的字段及其新名称必须不同。如果它们相同,则操作将因错误而失败。如果文档已包含具有提供的新名称的字段,则现有字段将被移除。

为了尝试$rename操作符的各种场景,首先为Macbeth插入名为imdb_rating的字段。以下更新操作设置了新字段,输出显示字段已正确添加:

> db.movies.findOneAndUpdate(
  {"title" : "Macbeth"},
  {$set : {"imdb_rating" : 6.6}},
  {returnNewDocument : true}
)
{
  "_id" : 1,
  "title" : "Macbeth",
  "year" : 2017,
  "type" : "movie",
  "num_mflix_comments" : 21,
  "flag" : "modified",
  "rating" : 2.6,
  "box_office_collection" : 0,
  "imdb_rating" : 6.6
}

现在,将字段num_mflix_comments重命名为comments,并将字段imdb_rating重命名为rating,如下所示:

db.movies.findOneAndUpdate(
  {"title" : "Macbeth"},
  {$rename : {"num_mflix_comments" : "comments",     "imdb_rating" : "rating"}},
  {returnNewDocument : true}
)

更新操作使用$rename操作符,并传递包含两对字段名称和新名称的文档。请注意,第二个字段名称和新名称组合试图将imdb_rating字段重命名为rating;然而,记录已经具有名称为rating的字段。输出如下所示:

图 5.23:重命名字段

图 5.23:重命名字段

输出显示重命名操作成功。如上所述,原始字段rating已被移除,imdb_rating字段现在已重命名为rating。使用此操作符,字段也可以移至嵌套文档和从嵌套文档中移出。要这样做,必须使用点表示法,如下所示:

db.movies.findOneAndUpdate(
  {"title" : "Macbeth"},
  {$rename : {"rating" : "imdb.rating"}},
  {returnNewDocument : true}
)

在这里,更新操作正在重命名rating字段。但是,新名称包含点表示法:

图 5.24:重命名嵌套字段

图 5.24:重命名嵌套字段

由于点表示法,字段rating已移至嵌套文档imdb下。同样,字段可以从嵌套文档移至根文档或任何其他嵌套文档中。

当前日期($currentDate)

操作符$currentDate用于将给定字段的值设置为当前日期或时间戳。如果该字段尚不存在,则将使用当前日期或时间戳值创建它。将字段名称与true值一起提供将当前日期插入为Date。或者,可以使用$type操作符显式指定值为datetimestamp

db.movies.findOneAndUpdate(
  {"title" : "Macbeth"},
  {$currentDate : {
    "created_date" : true,
       "last_updated.date" : {$type : "date"},
       "last_updated.timestamp" : {$type : "timestamp"},
  }},
  {returnNewDocument : true}
)

前面的findOneAndUpdate操作使用$currentDate运算符设置了三个字段。字段created_date的值为 true,默认为Date类型。另外两个字段使用了点表示法和显式的$type声明。输出可以在下图中看到:

图 5.25:设置当前日期和时间戳

图 5.25:设置当前日期和时间戳

我们可以看到created_date字段的值是Date类型。添加了一个新字段last_updated,并且有一个嵌套文档。在嵌套文档下,另一个字段被初始化为Date类型,另一个字段被初始化为Timestamp类型。

移除字段($unset)

$unset运算符从文档中移除给定的字段。该运算符接受一个包含字段名和值的文档,并从匹配的文档中移除所有给定的字段。由于提供的字段正在被移除,它们指定的值没有影响。例如,考虑以下代码片段:

> db.movies.find({"title" : "Macbeth"}).pretty()
{
  "_id" : 1,
  "title" : "Macbeth",
  "year" : 2017,
  "type" : "movie",
  "flag" : "modified",
  "box_office_collection" : 0,
  "comments" : 21,
  "imdb" : {
    "rating" : 6.6
  },
  "created_date" : ISODate("2020-06-26T01:22:35.457Z"),
  "last_updated" : {
    "date" : ISODate("2020-06-26T01:22:35.457Z"),
  "timestamp" : Timestamp(1593134555, 1)
  }
}

使用$unset运算符执行更新操作以移除不需要的字段:

db.movies.findOneAndUpdate(
  {"title" : "Macbeth"},
  {$unset : {
    "created_date" : "", 
    "last_updated" : "dummy_value",
    "box_office_collection": 142.2,
    "imdb" : null,
    "flag" : ""
  }},
  {returnNewDocument : true}
)

前面的更新操作从文档中移除了四个字段。如前所述,无论提供给字段什么值,以及是否提供给字段什么值,都没有影响。在这里,您正在尝试移除多个字段并为它们提供不同的值,您将观察到它们的值没有影响。第一个字段created_date提供了一个空字符串的值。接下来的两个字段有一些虚拟值,字段imdb有一个空值。最后一个字段flag也提供了一个空字符串的值。在这五个字段中,imdblast_updated是嵌套字段。现在执行操作并观察输出,如下所示:

图 5.26:移除多个字段

图 5.26:移除多个字段

输出表明,所有五个字段都已从文档中正确移除。操作和响应证明了为字段指定的值对字段移除没有影响。此外,指定一个值为嵌套对象的字段会移除相应的对象和包含的字段。

插入时设置($setOnInsert)

$setOnInsert运算符类似于$set,但是它只在upsert操作期间插入时设置给定的字段。当upsert操作导致更新现有文档时,它没有影响。为了更好地理解这一点,请考虑以下代码片段:

db.movies.findOneAndUpdate(
  {"title":"Macbeth"},
  {
    $rename:{"comments":"num_mflix_comments"},
    $setOnInsert:{"created_time":new Date()}
  },
  {
    upsert : true,
    returnNewDocument:true
  }
)

在这里,upsert 操作找到并更新了Macbeth电影记录。它将一个字段重命名为一个新名称,并且还在字段created_time上使用了$setOnInsert,该字段被初始化为当前的Date。由于电影已经存在于集合中,这个操作将导致更新:

图 5.27:在现有文档上使用$setOnInsert 进行 upsert

图 5.27:在现有文档上使用$setOnInsert 进行 upsert

输出显示$setOnInsert没有改变文档,但是字段comment现在被重命名为num_mflix_comments。另外,字段created_time没有被添加,因为 upsert 操作被用来更新现有文档。现在尝试使用 upsert 操作来插入一个示例:

db.movies.findOneAndUpdate(
  {"title":"Spy"},
  {
    $rename:{"comments":"num_mflix_comments"},
    $setOnInsert:{"created_time":new Date()}
  },
  {
    upsert : true,
    returnNewDocument:true
  }
)

这段代码与前面的代码唯一的区别是,这个操作找到了一个名为Spy的电影,而这个电影在我们的集合中不存在。由于 upsert 的存在,这个操作将导致向集合中添加一个文档。输出可以在下图中看到:

图 5.28:在新文档上使用$setOnInsert 进行 upsert

图 5.28:在新文档上使用$setOnInsert 进行 upsert

如我们所见,一个新的电影记录已经创建,并且带有created_time字段。通过前面的示例和输出,我们已经看到$setOnInsert操作符仅在作为 upsert 操作的一部分插入记录时设置字段。

活动 5.01:更新电影评论

您数据库的一些用户抱怨说他们在网站上找不到对电影的评论。您的客服团队进行了一些调查,发现实际上有三条评论错误地发布在一部电影上,而这些评论实际上属于另一部电影。错误评论的 ID 如下:

ObjectId("5a9427658b0beebeb6975eaa")
ObjectId("5a9427658b0beebeb6975eb3")
ObjectId("5a9427658b0beebeb6975eb4")

以下find查询返回这三条评论:

db.comments.find(
  {"_id" : 
    {$in : [
      ObjectId("5a9427658b0beebeb6975eaa"), 
      ObjectId("5a9427658b0beebeb6975eb3"), 
      ObjectId("5a9427658b0beebeb6975eb4")
      ]
    }
  }
).pretty()

在 MongoDB Atlas 的sample_mflix数据库上执行上述查询,输出应如下所示:

图 5.29:不正确的评论

图 5.29:不正确的评论

上述三条评论都是针对一部 2009 年的电影“神探夏洛克”(ObjectId("573a13bcf29313caabd57db6"))发布的,但实际上它们属于一部 2014 年的电影“初恋 50 次约”(ObjectId("573a13abf29313caabd25582"))。

您在此活动中的任务是纠正所有三条评论中的movie_id,并分别更新这些电影的num_mflix_comments字段。以下步骤将帮助您完成此活动:

  1. 更新所有三个文档中的movie_id字段。

  2. 找到电影“神探夏洛克”的 ID,并将评论数量减少 3 条。

  3. 在 mongo shell 上执行您在步骤 2中使用的命令,并确认结果。

  4. 找到电影“初恋 50 次”,并将评论数量增加 3 条。

  5. 在 mongo shell 上执行您在步骤 3中使用的命令,并确认结果。

注意

此活动的解决方案可以通过此链接找到。

摘要

我们从在集合中创建文档开始了本章。我们看到,在插入操作期间,如果文档不存在,MongoDB 会创建底层集合,并且如果文档还没有_id字段,则会自动生成一个。然后,我们介绍了 MongoDB 提供的各种函数,用于删除和替换集合中的一个或多个文档,以及 upsert 的概念、其好处、在 MongoDB 中的支持,以及 upsert 操作与删除和插入的区别。然后我们学习了如何使用各种函数和操作符在 MongoDB 文档中添加、更新、重命名或删除字段。

在下一章中,我们将使用 MongoDB 4.2 中添加的聚合管道支持执行一些复杂的更新命令,并学习如何修改数组字段中的元素。

第六章:使用聚合管道和数组进行更新

概述

本章向您介绍了 MongoDB 中更新操作的另外两个特性。您将首先学习如何使用管道支持执行一些复杂的更新操作。使用管道支持,您将能够编写多步更新表达式,并引用其他字段的值。接下来,本章涵盖了文档中数组字段的更新,其中包括向数组添加元素、更新或删除所有或特定元素、创建数组作为集合以及对数组元素进行排序。您将练习将唯一元素推送到数组中,并对其元素进行排序作为最终活动的一部分。通过本章结束时,您将能够根据其他字段的值推导出更新表达式,并操作集合中文档的数组字段。

介绍

到目前为止,我们已经涵盖了使用各种运算符来准备查询表达式的查询。我们还学习了如何在集合中创建、删除和修改文档,使用了各种删除和更新函数,并考虑了它们的差异和可用性。我们还涵盖了如何替换文档以及如何使用多个更新运算符执行 upsert 操作。现在是时候通过使用聚合管道支持来练习更复杂的更新操作,并学习如何修改文档中的数组。

我们将从 MongoDB 管道支持开始这一章,我们将简要介绍聚合管道以及它如何帮助您执行更复杂的更新操作。然后我们将介绍如何更新数组字段,如何添加和排序现有数组的元素,并使用数组作为唯一元素集。接下来,您将学习如何从数组中删除第一个、最后一个或另一个特定元素。最后,您将学习如何准备一个带有查询条件的数组过滤器,并使用它来仅修改数组中的特定元素。

使用聚合管道进行更新(MongoDB 4.2)

在上一章中,我们涵盖了用于修改一个或多个文档中字段的更新函数。我们还使用了各种运算符编写了许多更新操作。正如您在示例中所看到的,我们在分配新值给字段时,要么使用硬编码的值(例如,在更新num_mflix_comments时),要么使用诸如$inc之类的运算符动态派生的值。然而,在更复杂的更新操作中,您可能需要使用基于其他字段的值动态派生的字段。或者,更新操作可能涉及多步更新表达式。

在以前的 MongoDB 版本中,引用其他字段的值或编写多步更新操作是不可能的,但是,随着 MongoDB 4.2 的发布,它的所有更新函数都开始支持聚合管道。聚合管道和各种聚合运算符将在第七章“聚合管道”中详细介绍。现在,我们将限制讨论使用管道支持编写更新表达式。

管道由多个称为阶段的更新表达式组成。当执行包含多个阶段的更新表达式的更新操作时,每个匹配的文档都会按顺序经过每个阶段进行处理和转换。第一个阶段的输出是下一个阶段的输入,直到管道中的最后一个阶段产生最终输出。除了编写多阶段更新表达式外,管道支持还允许在更新表达式中使用字段引用。

在以前的更新表达式中,我们要么在字段上设置硬编码的值,要么对数字字段使用各种运算符来操作其现有值。然而,使用管道支持,我们可以在更新表达式中读取并使用其他字段的值。

以下代码片段显示了在updateMany()中使用聚合管道的语法。对于所有其他更新函数,它是相同的:

db.collection.updateMany(
    <query condition>, 
    [<update expression 1>, <update expression 2>, ...],
    <options>
)

您可能已经注意到,函数的第二个参数,指定更新表达式,现在是多个更新表达式或阶段的数组。如前所述,该语法仅在您的 MongoDB 版本为 4.2 或更高版本时有效。如果传递的是一个包含单个更新表达式的文档,而不是数组,则它将作为普通的更新命令执行。

让我们考虑聚合管道如何允许我们编写复杂的更新查询,并使用字段表达式和聚合操作。我们在上一章的示例中一直在使用CH05数据库,并将在这里继续使用它。如果您已经有users集合,请在我们插入两条记录之前删除其所有元素。

让我们向集合中添加以下记录:

db.users.insertMany([
    {_id: 1, full_name : "Arya Stark"}, 
    {_id: 2, full_name : "Khal Drogo"}
])

两个文档都有一个_idfull_name字段,由名字和姓氏以空格分隔组成。我们将编写一个更新命令,将全名拆分为相应的名字和姓氏字段,并更新full_name字段,以便只有名字以大写形式出现:

db.users.updateMany(
    {},
    [
        {
            $set : {"name_array" : {$split : ["$full_name", " "]}},
        },
        {
            $set: {
                "first_name" : {"$arrayElemAt" : ["$name_array", 0]},
                "last_name" : {"$arrayElemAt" : ["$name_array", 1]}
            }
        },
        {
            $project : {
                "first_name" : 1,
                "last_name" : 1,
                "full_name" : {
                    $concat : [{$toUpper : "$first_name"}, " ", "$last_name"]
                }
            }
        }
    ]
)

在这里,updateMany()操作正在更新users集合中的所有文档。函数的第二个参数是一个包含三个阶段($set$set$project)的数组。现在,我们将逐个介绍这些阶段并探索管道。

注意

诸如$project$arrayElemAt$concat之类的运算符是聚合运算符。这些运算符不能在早于 MongoDB 4.2 的版本中使用,也不能作为聚合管道的一部分的更新表达式中使用。

第 1 阶段($set)

在这个阶段,我们使用$split运算符以空格分割全名。这给我们一个包含名字和姓氏的两个元素数组。我们还使用$set运算符创建一个新的name_array字段,并将新创建的数组分配给它。name_array对我们来说是一个临时字段。

第 2 阶段($set)

在这个阶段,我们引用存储在name_array中的数组,并为名字和姓氏创建新字段。为此,我们使用$arrayElemAt从特定索引位置获取其元素。使用零位置元素创建一个名为first_name的新字段,并使用第一个索引位置元素创建一个名为last_name的字段。在这个阶段结束时,每个用户的文档将具有first_namelast_namename_array和原始的full_name字段。

第 3 阶段($project)

在最后一个阶段,我们对字段进行投影。我们明确包括first_namelast_name字段,并通过连接大写的first_namelast_name来重写full_name;请注意,我们不会改变last_name的大小写。

$toUpper运算符指的是first_name的值,并返回大写的相同字符串。$concat运算符接受一个字符串数组,并通过按照相同顺序连接所有元素来返回单个字符串。在这里,我们将大写的first_name、一个空格和last_name连接起来。

$project运算符用于投影字段并对其进行赋值。在这个阶段,我们投影first_namelast_namefull_name,这意味着name_array将自动被省略:

图 6.1:使用管道支持更新

图 6.1:使用管道支持更新

前面的输出显示操作成功。它匹配了两个文档,并且它们都被修改了。现在我们将查询文档,看它们是否已正确更新:

> db.users.find({}, {_id : 0})
{ "first_name" : "Arya", "last_name" : "Stark", "full_name" : "ARYA Stark" }
{ "first_name" : "Khal", "last_name" : "Drogo", "full_name" : "KHAL Drogo" }

在这里,find查询和输出显示文档已正确修改。原始全名已正确拆分为名字和姓氏。此外,full_name字段中的名字是大写的。

在本节中,我们学习了如何使用 MongoDB 4.2 提供的管道阶段和聚合运算符支持编写复杂的更新命令。我们还了解到阶段是按顺序执行的,一个阶段的输出成为下一个阶段的输入。

更新数组字段

在之前的章节中,我们学习了如何更新一个或多个 MongoDB 文档中的字段。我们还学习了如何使用各种运算符编写更新表达式以及如何使用 MongoDB 管道支持。在本节中,我们将学习如何从文档中更新数组字段。

为了尝试一些基本的数组字段更新操作,我们将向movies集合中插入以下文档:

db.movies.insert({"_id" : 111, "title" : "Macbeth"})

文档只有一个title字段,不包含数组,所以让我们尝试创建一个:

db.movies.findOneAndUpdate(
    {_id : 111},
    {$set : {"genre" : ["Unknown"]}},
    {"returnNewDocument" : true}
)

前面的操作在genre字段中使用了$setgenre的值是一个单元素数组—["unknown"]。输出如下所示:

图 6.2:更新数组字段的值

图 6.2:更新数组字段的值

输出显示genre字段已创建并分配了给定数组的值。接下来,我们将从文档中删除字段,如下所示:

db.movies.findOneAndUpdate(
    {_id : 111},
    {$unset : {"genre" : ""}},
    {"returnNewDocument" : true}
)

前面的更新命令使用$unset来删除genre字段。您可以在这里看到输出:

图 6.3:删除数组字段

图 6.3:删除数组字段

输出表明该字段已从文档中正确删除。从这两个示例中可以清楚地看出,当使用数组作为值更新数组字段时,它会像其他字段一样对待。接下来,我们将看到如何操作数组元素。

我们已经看到如何使用数组值更新字段。当我们想要完全替换数组值时,这是有用的。但是,要向数组添加更多元素,可以使用一个叫做$push的运算符。该运算符将给定元素推送到数组的末尾,如果给定字段不存在,则创建它。让我们在下一个练习中使用它。

练习 6.01:向数组添加元素

在这个练习中,您将使用以下步骤向数组添加元素:

  1. 要插入单个文档,请添加以下命令:
db.movies.findOneAndUpdate(
    {_id : 111},
    {$push : {"genre" : "unknown"}},
    {"returnNewDocument" : true}
)

前面片段中的更新操作通过其_id值找到文档,并将一个元素推送到genre数组中。该字段目前不存在于文档中。您应该看到以下输出:

图 6.4:向数组添加一个元素

图 6.4:向数组添加一个元素

  1. 如图所示,genre数组字段成功创建,并且给定元素已添加到数组中。现在再添加一个流派,如下所示:
db.movies.findOneAndUpdate(
    {_id : 111},
    {$push : {"genre" : "Drama"}},
    {"returnNewDocument" : true}
)

前面的命令插入了另一个流派,Drama。您可以在这里看到输出,显示Drama元素已添加到现有数组的末尾:

图 6.5:向数组添加另一个元素

图 6.5:向数组添加另一个元素

在这个练习中,我们处理了添加单个元素。在下一节中,我们将一次添加多个元素。

添加多个元素

正如我们所见,$push可以一次添加一个元素。要在单个更新命令中向数组添加多个元素,我们必须使用$push$each。以下是此操作的语法:

$push : {<field_name> : {$each : [<element 1>, <element2>, ..]}}

需要附加到数组的元素以数组的形式提供给$each运算符。当执行这样的更新表达式时,$each会迭代每个元素,并将元素推送到数组中:

db.movies.findOneAndUpdate(
    {_id : 111},
    {$push : {
        "genre" : {
            $each : ["History", "Action"]
        }}
    },
    {"returnNewDocument" : true}
)

前面的更新操作通过其_id字段找到并更新文档,并使用$pushgenre字段添加元素。我们通过将这两个元素提供给$each来向数组添加两个元素:

图 6.6:向数组推送多个元素

图 6.6:向数组推送多个元素

响应中的文档(参见上述截图)表明两个元素都正确地追加到数组的末尾,并按相同的顺序添加。

排序数组

在 MongoDB 中,以及一般情况下,数组是一个有序但未排序的元素集合。换句话说,数组的元素将始终保持插入的顺序。然而,在使用 $push 执行更新命令时,我们也可以对数组进行排序。为此,我们必须使用 $sort 操作符和 $each。在之前的例子中,我们向 genre 数组添加了四个元素。现在,我们将尝试对数组进行字母顺序排序:

db.movies.findOneAndUpdate(
    {_id : 111},
    {$push : {
        "genre" : {
            $each : [],
            $sort : 1
        }}
    },
    {"returnNewDocument" : true}
)

在上述命令中,我们在 genre 字段中使用了 $push。需要注意的是,这个查询没有向数组推送任何元素,因为没有元素提供给 $each 操作符。新的 $sort 操作符被赋予值 1,表示升序:

图 6.7:对数组进行排序

图 6.7:对数组进行排序

如图所示,genre 数组现在按照元素的升序字母顺序排序。在之前的例子中,我们对数组进行了排序,但没有添加元素,但我们也可以在向数组插入一个或多个元素时进行排序。在这种情况下,新元素将被添加到数组中,并根据给定的排序顺序对数组进行排序。考虑以下代码片段:

db.movies.findOneAndUpdate(
    {_id : 111},
    {$push : {
        "genre" : {
            $each : ["Crime"],
            $sort : -1
        }}
    },
    {"returnNewDocument" : true}
)

在这个更新命令中,我们向 genre 传递了一个新元素 Crime。请注意,$sort 操作符的值为 -1。当我们执行此命令时,新元素将被添加到数组中,并且数组将按照字母的降序进行排序。这将导致以下输出:

图 6.8:对数组进行排序并将元素推送到数组中

图 6.8:对数组进行排序并将元素推送到数组中

从响应中可以看出,数组按降序排序,新元素 Crimegenre 数组的一部分。如果没有提供 $sort 操作符,新元素将被追加到数组的末尾。在之前的两个例子中,genre 数组包含普通的字符串元素。然而,如果我们有一个包含多个字段的对象数组,可以根据嵌套对象的字段进行排序。考虑 items 集合中的以下记录:

> db.items.insert({_id : 11, items: [
    {"name" : "backpack", "price" : 127.59, "quantity" : 3},
    {"name" : "notepad", "price" : 17.6, "quantity" : 4},
    {"name" : "binder", "price" : 18.17, "quantity" : 2},
    {"name" : "pens", "price" : 60.56, "quantity" : 3},

]})
WriteResult({ "nInserted" : 1 })

items 字段是一个包含三个字段的四个对象的数组。现在我们将按价格对数组进行排序:

db.items.findOneAndUpdate(
    {_id : 11},
    {$push : {
        "items" : {
            $each : [],
            $sort : {"price" : -1}
        }}
    },
    {"returnNewDocument" : true}
)

更新命令找到一个文档并对数组字段进行排序。与之前的例子不同,这次我们要根据它们的嵌套字段对元素进行排序:

图 6.9:根据嵌套字段的值对数组进行排序

图 6.9:根据嵌套字段的值对数组进行排序

注意修改后的文档中的数组字段。所有元素现在按价格的降序排序。在下一节中,我们将学习在 MongoDB 中使用数组作为集合。

数组作为集合

数组是一个有序的元素集合,可以通过迭代或使用特定的索引位置进行访问。集合是一组唯一元素的集合,其顺序不被保证。MongoDB 只支持普通数组,不支持其他类型的集合。然而,您可能希望您的数组只包含唯一的元素。MongoDB 通过使用 $addToSet 操作符提供了一种方法来实现这一点。

$addToSet 操作符类似于 $push,唯一的区别在于只有当元素不存在时才会被推送。该操作符不会改变底层数组,但它确保只有唯一的元素被推送进去。目前,我们 movies 集合中电影 Macbeth 的文档如下所示:

> db.movies.find({"_id" : 111}).pretty()
{
    "_id" : 111,
    "title" : "Macbeth",
    "genre" : [
        "unknown",
        "History",
        "Drama",
        "Crime",
        "Action"
    ]
}

genre 数组是一个很好的例子,您希望您的数组具有唯一的元素,因为电影的重复流派是没有意义的。考虑以下代码片段:

db.movies.findOneAndUpdate(
    {_id : 111},
    {$addToSet : {"genre" : "Action"}},
    {"returnNewDocument" : true    }
)

在这里,更新操作使用$addToSetAction的元素推送到genres数组中。请注意,该元素已经是数组的一部分:

图 6.10:将元素添加到数组中作为集合

图 6.10:将元素添加到数组中作为集合

如前面的屏幕截图所示,Action元素没有被推送到数组中,因为数组已经包含它。即使在我们使用$each将多个元素推送到数组中时,也会出现相同的行为。例如,考虑以下代码片段:

db.movies.findOneAndUpdate(
    {_id : 111},
    {$addToSet : {
        "genre" : {
            $each : ["History", "Thriller", "Drama"]
        }}
    },
    {"returnNewDocument" : true}
)

在这里,我们使用$each将三个流派添加到数组中,其中只有中间的是新的:

图 6.11:将多个元素添加到数组中作为集合

图 6.11:将多个元素添加到数组中作为集合

修改后的文档确认只有新的流派Thriller已经添加到数组中。

练习 6.02:经典电影的新类别

最近,由于《卡萨布兰卡》的重新发行,对经典电影的需求出现了相当大的增长。贵公司的分析部门发现,毫不奇怪,经典电影是唯一被评论家和观众评分都超过 95 分的电影。因此,贵公司希望将数据库中的所有这些电影都归为一个新的流派,名为“经典”。在您的电影文档中,样本番茄评分如下:

"tomatoes" : {
"viewer" : {
        "rating" : 3.7,
        "numReviews" : 2559,
        "meter" : 75
    },
"fresh" : 6,
    "critic" : {
        "rating" : 7.6,
        "numReviews" : 6,
        "meter" : 100
    },
    "rotten" : 0,
    "lastUpdated" : ISODate("2015-08-08T19:16:10Z")
}

您的任务是在viewercritic子对象的meter字段上添加一个过滤器,以找到经典电影并将它们归为新流派。以下步骤将帮助您完成此练习:

  1. 打开文本编辑器并开始编写查询。您将需要准备一个更新命令来更新多个文档,因此使用updateMany()
db.movies.updateMany()
  1. 在找到电影的第一个标准是观众的番茄评分需要超过 95。输入以下命令:
db.movies.updateMany(
    {"tomatoes.viewer.meter" : {$gt : 95}}
)

在这里,您已经为观众评分添加了一个过滤器。由于该字段嵌套在一个嵌套字段中,因此您相应地使用了点符号。

  1. 根据第二个标准,您需要在critic评分上放置相同的过滤器。将第二个标准添加到查询中,如下所示:
db.movies.updateMany(
    {
        "tomatoes.viewer.meter" : {$gt : 95}, 
        "tomatoes.critic.meter" : {$gt : 95}
    }
)

在上述命令中,您已经将相同的过滤器添加到了评论家评分。该命令现在具有所有必需的过滤器。

  1. 现在,创建一个更新表达式,将名为Classic的新流派添加到所有匹配的电影中:
db.movies.updateMany(
    {
        "tomatoes.viewer.meter" : {$gt : 95}, 
        "tomatoes.critic.meter" : {$gt : 95}
    },
    {
        $addToSet : {"genres" : "Classic"}
    }
)

您现在已经添加了更新表达式。请注意,数组中的流派应始终是唯一的,因此您将使用$addToSet而不是$pushClassic元素添加到genres数组中。

  1. 现在,打开一个 MongoDB shell 并连接到 Mongo Atlas 集群,然后转到sample_mflix数据库。在数据库上执行上述命令。输出应该如下所示:图 6.12:添加新流派

图 6.12:添加新流派

您可以看到所有 30 条记录都已成功更新。

  1. 为了验证这一点,使用相同的条件编写一个find查询,并使用以下命令投影必要的字段:
db.movies.find(
    {
        "tomatoes.viewer.meter" : {$gt : 95},
        "tomatoes.critic.meter" : {$gt : 95}
    },
    {
        "_id" : 0,
        "title" : 1,
        "genres" : 1
    }
)

这里的find查询使用相同的过滤器,并且仅显示titlegenres字段。您可以看到以下输出:

图 6.13:显示属于经典流派的电影的输出

图 6.13:显示属于经典流派的电影的输出

输出表明,所有电影现在都有了新的流派“经典”。在这个练习中,您使用了集合的概念来解决业务问题。在下一节中,让我们来看看如何删除数组元素。

删除数组元素

到目前为止,我们已经学习了各种方法来向数组添加元素,并使用各种运算符对数组进行排序。MongoDB 还提供了从数组中删除元素的方法。在本节中,我们将介绍不同的运算符,允许您从数组中删除所有或特定的元素。

删除第一个或最后一个元素($pop)

$pop运算符在更新命令中使用时,允许您删除数组中的第一个或最后一个元素。它一次删除一个元素,只能与值1(最后一个元素)或-1(第一个元素)一起使用:

> db.movies.find({"_id" : 111}).pretty()
{
    "_id" : 111,
    "title" : "Macbeth",
    "genre" : [
        "unknown",
        "History",
        "Drama",
        "Crime",
        "Action",
        "Thriller"
    ]
}

在上面的片段中,输出显示电影记录的genre数组中有六个元素:

db.movies.findOneAndUpdate(
    {_id : 111},
    {$pop : {"genre" : 1}},
    {"returnNewDocument" : true    }
)

上述的findOneAndUpdate操作在genre字段上使用了$pop,值为1,这将从数组中删除最后一个元素。命令的所有其他方面与我们在先前的示例中看到的一样:

图 6.14:从数组中删除最后一个元素

图 6.14:从数组中删除最后一个元素

修改后的文档表明最后一个元素(“惊悚”)已成功从数组中删除。现在,使用以下命令,将$pop的值设为-1

db.movies.findOneAndUpdate(
    {_id : 111},
    {$pop : {"genre" : -1}},
    {"returnNewDocument" : true    }
)

让我们看看执行此命令会发生什么:

图 6.15:从数组中删除第一个元素

图 6.15:从数组中删除第一个元素

输出显示,数组的第一个元素('未知')现在已被删除。请记住,$pop只允许1-1作为值,提供任何其他数字,包括零,都会导致错误。

删除所有元素

当您只需要从数组中删除某些元素时,可以使用$pullAll运算符。为此,您向运算符提供一个或多个元素,然后它将从数组中删除所有这些元素的出现。例如,考虑以下命令:

db.movies.findOneAndUpdate(
    {_id : 111},
    {$pullAll : {"genre" : ["Action", "Crime"]}},
    {"returnNewDocument" : true    }
)

在此更新操作中,我们在genre字段中使用了$pullAll。我们以数组的形式提供了两个元素动作犯罪。这个的输出如下:

图 6.16:删除数组的所有元素

图 6.16:删除数组的所有元素

我们可以看到,指定的流派动作犯罪现在已从底层数组中删除。

删除匹配的元素

在前面的示例中,我们看到了如何使用$pullAll从数组中删除特定元素。在这个例子中,我们将使用另一个名为$pull的运算符,编写一个查询条件,使用各种逻辑和条件运算符,并且与查询匹配的数组元素将被删除。例如,考虑以下片段,其中名为items的数组包含四个对象:

> db.items.find({"_id" : 11}).pretty()
{
    "_id" : 11,
    "items" : [
        {
            "name" : "backpack",
            "price" : 127.59,
            "quantity" : 3
        },
        {
            "name" : "pens",
            "price" : 60.56,
            "quantity" : 3
        },
        {
            "name" : "binder",
            "price" : 18.17,
            "quantity" : 2
        },
        {
            "name" : "notepad",
            "price" : 17.6,
            "quantity" : 4
        }
    ]
}

现在,我们将编写一个使用$pull更新数组的查询。请记住,它允许我们使用逻辑和条件运算符的组合来准备查询条件,就像任何find查询一样:

db.items.findOneAndUpdate(
    {_id : 11},
    {$pull : {
        "items" : {
            "quantity" : 3,
            "name" : {$regex: "ck$"}
        }
    }},
    {"returnNewDocument" : true    }
)

在此更新命令中,$pull运算符在数组字段items中提供了一个查询条件。条件过滤了数组元素,其中quantity3name以'ck'结尾。输出应该如下所示:

图 6.17:删除与给定正则表达式匹配的元素

图 6.17:删除与给定正则表达式匹配的元素

响应中的文档显示,已删除了一个数量为3且名称以'ck'结尾的元素,正如预期的那样。现在让我们来看看如何更新数组元素。

更新数组元素

在数组中,每个元素都绑定到特定的索引位置。这些索引位置从零开始,我们可以使用一对方括号([])和相应的索引位置来引用数组中的元素。使用带有$的这样一对方括号可以更新数组的元素。考虑以��片段,它显示了当前genres数组的外观:

> db.movies.find({"_id" : 111})
{ "_id" : 111, "title" : "Macbeth", "genre" : [ "History", "Drama" ] }

genres数组有两个元素,我们将使用以下命令更新它们:

db.movies.findOneAndUpdate(
    {_id : 111},
    {$set : {"genre.$[]" : "Action"}},
    {"returnNewDocument" : true}
)

在这个操作中,我们在genres字段中使用了$set。通过使用表达式"genre.$[]"来引用该字段,并提供值Action$[]操作符引用给定数组中包含的所有元素,并且更新表达式将应用于所有这些元素:

图 6.18:替换数组中的所有元素

图 6.18:替换数组中的所有元素

文档中指出genre仍然是一个包含两个元素的数组。但是,这两个元素现在都已更改为Action。因此,我们可以使用$[]来更新数组的所有元素为相同的值。

同样,我们也可以更新数组中的特定元素。为此,我们首先需要找到这样的元素并对其进行标识。为了得到一个元素标识符,我们可以使用arrayFilters的更新选项来提供一个查询条件,并将其分配给匹配的元素的变量(称为标识符)。然后,我们使用标识符以及$[]来更新这些特定元素的值。为了看一个例子,我们将使用我们items集合中的文档并向其数组中添加一个元素,如下所示:

> db.items.findOneAndUpdate(
    {"_id" : 11},
    {$push : {"items" : {"name" : "it"}}},
    {"returnNewDocument": true}
)
{
    "_id" : 11,
    "items" : [
        {
            "name" : "pens",
            "price" : 60.56,
            "quantity" : 3
        },
        {
            "name" : "binder",
            "price" : 18.17,
            "quantity" : 2
        },
        {
            "name" : "notepad",
            "price" : 17.6,
            "quantity" : 4
        },
        {
            "name" : "it"
        }
    ]
}

使用前面的update命令,我们已经向数组中添加了一个新元素。请注意,新添加的元素没有pricequantity字段。在接下来的更新命令中,我们将找到并更新这个数组中的元素:

db.items.findOneAndUpdate(
    {_id : 11},
    {$set : {
        "items.$[myElements]" : {
            "quantity" : 7,
            "price" : 4.5,
            "name" : "marker"
        }
    }},
    {
        "returnNewDocument" : true,
        "arrayFilters" : [{"myElements.quantity" : null}]
    }
)

在前面的更新操作中,我们使用$set来更新items数组的元素。要更新的数组元素由$[myElements]的表达式引用,并分配一个新值,这是一个嵌套对象。myElements的标识符是使用arrayFilters基于查询条件定义的。所有匹配给定条件的元素都由myElements标识,然后使用$set进行更新:

图 6.19:替换匹配给定过滤器的元素

图 6.19:替换匹配给定过滤器的元素

查询条件{quantity: null}与数组的最后一个元素匹配,并已使用新文档进行更新。

练习 6.03:更新导演的姓名

在您的电影网站上,人们可以通过电影标题或演员或导演的名字找到电影。您在这个练习中的任务是连接到数据库,将其中一个导演的名字从H. C. Potter更改为H. C. Potter (Henry Codman Potter),以便用户不会将他与另一个名字相似的导演混淆。请记住,一部电影或系列可能由多个人执导。数据库中的directors字段是一个数组,导演团队中的人可以出现在任何索引位置:

db.movies.find(
    {"directors" : "H.C. Potter"}, 
    {_id : 0, title: 1, directors :1}
).pretty()

这个find命令通过导演的缩写名找到所有电影,并打印电影标题,然后是导演的名字:

  1. 打开 mongo shell 并连接到 Mongo Atlas 集群上的sample_mflix数据库。

  2. 由于所有六部电影都需要更新,使用updateMany()更新函数。打开任何文本编辑器,写入以下命令:

db.movies.updateMany()
  1. 接下来,在查询条件中使用导演的缩写名,如下所示:
db.movies.updateMany(
    {"directors" : "H.C. Potter"}
)

这个命令仍然是不完整的,语法无效。到目前为止,您只在命令中添加了查询条件。

  1. 接下来,添加更新表达式。因为您在这里更改了一个字段,所以在数组字段中使用$set操作符。此外,为了仅更改数组中的特定元素,请使用一个元素标识符:
db.movies.updateMany(
    {"directors" : "H.C. Potter"},
    {$set : {
        "directors.$[hcPotter]" : "H.C. Potter (Henry Codman Potter)"
    }}
)

在前面(仍然不完整)的命令中,您已经在数组上添加了一个使用$set操作符的更新表达式。请注意,hcPotter标识符引用的数组元素正在被分配新值,即Henry Codman Potter

  1. 现在,您已经在更新表达式中使用了一个元素标识符,可以使用arrayFilters来定义标识符,如下所示:
db.movies.updateMany(
    {"directors" : "H.C. Potter"},
    {$set : {
        "directors.$[hcPotter]" : "H.C. Potter (Henry Codman Potter)"
    }},
    {
        "arrayFilters" : [{hcPotter : "H.C. Potter"}]
    }
)

从前面的片段中可以看出,您已经添加了arrayFilters的选项。hcPotter的标识符被赋予了H.C. Potter的值,这个值目前存在于数组中。

  1. 现在,打开 mongo shell 并连接到 MongoDB Atlas 集群。使用sample_mflix数据库并执行上述命令。图 6.20:更新导演的名字

图 6.20:更新导演的名字

输出表明所有六条记录都被正确找到和更新了。

  1. 现在,使用正则表达式在directors字段中找到导演的全名电影:
db.movies.find(
    {"directors" : {$regex : "Henry Codman Potter"}}, 
    {_id : 0, title: 1, directors :1}
).pretty()

查询使用正则表达式根据导演的全名找到电影:

图 6.21:显示导演的正确姓名

图 6.21:显示导演的正确姓名

输出表明您已正确更新了所有记录中的导演姓名。在这个练习中,您练习了使用数组过滤器来修改数组中的匹配元素。

在本节中,我们学习了如何更新文档中的数组字段。我们学会了添加新元素,从数组中删除元素,并更新数组中的特定元素。我们还学会了如何将数组视为集合,并对数组中的现有或新元素进行排序。

活动 6.01:向演员阵容中添加演员的名字

最近,您注意到了数据库中的一个错误。演员 Nick Robinson 在 2015 年的电影《侏罗纪世界》中扮演了Zach的角色。然而,电影记录中的cast字段没有将这位演员归属于这部电影:

图 6.22:仅显示电影的演员阵容

图 6.22:仅显示电影的演员阵容

如前面的屏幕截图所示,输出确认了演员的名字丢失了。您在这个活动中的任务是将Nick Robinson添加到这部电影的演员阵容中,并按演员名字对这个数组进行排序。作为最佳实践,您还应确保cast数组具有唯一值。以下步骤将帮助您完成这个活动:

  1. 根据电影标题准备一个查询表达式,并向其添加一个更新表达式。由于您必须避免重复插入,因此应使用$addToSet运算符将数组视为集合。

  2. 接下来,您需要对数组进行排序。由于集合被认为是唯一且无序元素的集合,因此在使用$addToSet时无法对元素进行排序。因此,首先将唯一元素的元素推入数组。

  3. 最后,创建另一个更新命令并对所有数组进行排序。

在这个活动中,您向数组添加了唯一元素并对它们进行了排序。您还验证了不可能同时将元素添加到数组中并对其进行排序。

注意

此活动的解决方案可以通过此链接找到。

总结

我们通过学习如何使用聚合管道支持来更新文档来开始本章。聚合管道支持是在 MongoDB 4.2 版本中引入的,它帮助我们执行一些复杂的更新。使用聚合管道支持,我们可以编写多阶段的更新表达式,其中一个阶段的输出作为下一个阶段的输入。它还允许我们使用字段引用和聚合运算符。我们还学会了如何操作数组字段中的元素,如何向数组中添加、删除和更新元素,如何对数组进行排序,以及如何向数组中添加唯一元素。

在下一章中,我们将详细学习 MongoDB 聚合框架和管道。

第七章:数据聚合

概述

本章向您介绍了聚合的概念及其在 MongoDB 中的实现。您将学习如何识别聚合命令的参数和结构,使用主要聚合阶段组合和操作数据,使用高级聚合阶段处理大型数据集,并优化和配置聚合以获得查询的最佳性能。

介绍

在之前的章节中,我们学习了与 MongoDB 交互的基础知识。通过这些基本操作(insertupdatedelete),我们现在可以开始探索和操作我们的数据,就像操作任何其他数据库一样。我们还观察到,通过充分利用find命令选项,我们可以使用操作符来回答关于我们数据的更具体的问题。我们还可以在查询中进行排序、限制、跳过和投影,以创建有用的结果集。

在更简单的情况下,这些结果集可能足以回答您所需的业务问题或满足用例。然而,更复杂的问题需要更复杂的查询来解决。仅使用find命令解决这些问题将是非常具有挑战性的,并且可能需要多个查询或在客户端进行一些处理来组织或链接数据。

基本限制是当您的数据包含在两个单独的集合中。要找到正确的数据,您将不得不运行两个查询,而不是一个,将数据连接在客户端或应用程序级别。这可能看起来不是一个大问题,但随着应用程序或数据集的规模增加,性能和复杂性也会增加。在可能的情况下,最好让服务器来处理所有繁重的工作,只返回我们在单个查询中寻找的数据。这就是聚合管道的作用。

find命令。除此之外,聚合的管道结构允许开发人员和数据库分析师轻松、迭代地快速构建查询,处理不断变化和增长的数据集。如果您想在 MongoDB 中以规模完成任何重要的工作,您将需要编写复杂的多阶段聚合管道。在本章中,我们将学习如何做到这一点。

注意

在本章的整个过程中,包括的练习和活动都是针对一个场景的迭代。数据和示例都基于名为sample_mflix的 MongoDB Atlas 示例数据库。

考虑一个情景,一个电影公司正在举办年度经典电影马拉松,并试图决定他们应该播放什么电影。他们需要各种符合特定标准的热门电影来满足他们的客户群。公司已经要求你进行研究,确定他们应该展示哪些电影。在本章中,我们将使用聚合来检索给定一组复杂约束条件的数据,然后转换和操作数据,以创建新的结果,并用单个查询回答整个数据集的业务问题。这将帮助电影公司决定他们应该展示哪些电影来满足他们的客户。

值得注意的是,聚合管道足够强大,有许多方法可以完成相同的任务。本章涵盖的练习和活动只是解决所提出情景的一个解决方案,并且可以使用不同的模式来解决。掌握聚合管道的最佳方法是考虑多种方法来解决同一个问题。

聚合是新的查找

MongoDB 中的aggregate命令类似于find命令。您可以以 JSON 文档的形式提供查询的条件,并输出包含搜索结果的cursor。听起来很简单,对吧?那是因为它确实如此。尽管聚合可能变得非常庞大和复杂,但在其核心,它们是相对简单的。

聚合中的关键元素称为管道。我们将很快详细介绍它,但在高层次上,管道是一系列指令,其中每个指令的输入是前一个指令的输出。简而言之,聚合是一种以程序方式从集合中获取数据,并进行过滤、转换和连接其他集合的方法,以创建新的有意义的数据集。

聚合语法

aggregate命令与其他创建、读取、更新、删除(CRUD)命令一样,操作在集合上,如下所示:

use sample_mflix;
var pipeline = [] // The pipeline is an array of stages.
var options  = {} // We will explore the options later in the   chapter.
var cursor   = db.movies.aggregate(pipeline, options);

聚合使用了两个参数。pipeline参数包含了查找、排序、投影、限制、转换和聚合数据的所有逻辑。pipeline参数本身作为 JSON 文档数组传递。您可以将其视为要发送到数据库的一系列指令,然后在最终阶段之后产生的数据存储在cursor中返回给您。管道中的每个阶段都是独立完成的,依次进行,直到没有剩余的阶段。第一个阶段的输入是集合(在上面的示例中是movies),每个后续阶段的输入是前一个阶段的输出。

第二个参数是options参数。这是可选的,允许您指定配置的细节,比如聚合应该如何执行或者在调试和构建管道过程中需要的一些标志。

aggregate命令中的参数比find命令中的参数少。我们将在本章的最后一个主题中介绍options,所以现在我们可以通过完全排除options来简化我们的命令,如下所示:

var cursor = db.movies.aggregate(pipeline);

在上面的示例中,我们首先将管道保存为变量,而不是直接将管道写入命令中。聚合管道可能会变得非常庞大,在开发过程中难以解析。将管道(甚至管道的大部分)分开为单独的变量以提高代码清晰度有时可能会有所帮助。虽然建议这样做,但这种模式完全是可选的,类似于以下内容:

var cursor = db.movies.aggregate([])

建议您在代码或文本编辑器中跟随这些示例,保存您的脚本,然后将其复制粘贴到 MongoDB shell 中。例如,假设我们创建了一个名为aggregation.js的文件,内容如下:

var MyAggregation_A = function() {
    print("Running Aggregation Script Ch7.1");
    var pipeline = [];
      // This next line stores our result in a cursor.
    var cursor = db.movies.aggregate(pipeline);
      // This line will print the next iteration of our cursor.
    printjson(cursor.next())
};
MyAggregation_A();

然后,将此代码直接复制到 MongoDB shell 中,将返回以下输出:

图 7.1:聚合结果(为简洁起见输出被截断)

图 7.1:聚合结果(为简洁起见输出被截断)

我们可以看到,在定义了MyAggregation_A.js函数之后,我们只需要再次调用该函数即可查看我们聚合的结果(在本例中是电影列表)。您可以一遍又一遍地调用这个函数,而无需每次都写整个管道。

通过以这种方式构建聚合,您将不会丢失任何聚合。它还有一个额外的好处,可以让您将所有聚合作为函数交互地加载到 shell 中。但是,如果您愿意,也可以将整个函数复制粘贴到 MongoDB shell 中,或者直接交互输入。在本章中,我们将两种方法混合使用。

聚合管道

如前所述,聚合中的关键元素是管道,它是对初始集合执行的一系列指令。您可以将数据视为流经此管道的水,在每个阶段进行转换和过滤,直到最终作为结果倒出管道的末端。

在下图中,橙色块代表聚合管道。管道中的每个块都被称为聚合阶段:

图 7.2:聚合管道

图 7.2:聚合管道

关于聚合的一点需要注意的是,虽然管道始终以一个集合开始,但使用某些阶段,我们可以在管道中进一步添加集合。我们将在本章后面讨论加入集合。

大型多阶段管道可能看起来令人生畏,但是如果您了解命令的结构以及可以在给定阶段执行的各个操作,那么您可以轻松地将管道分解为较小的部分。在本主题中,我们将探讨聚合管道的构建,比较使用find实现的查询与使用aggregate创建的查询,并识别一些基本操作符。

管道语法

聚合管道的语法非常简单,就像aggregate命令本身一样。管道是一个数组,数组中的每个项都是一个对象:

var pipeline = [
        { . . . },
        { . . . },
        { . . . },
];

数组中的每个对象代表整个管道中的单个阶段,阶段按其数组顺序(从上到下)执行。每个阶段对象采用以下形式:

{$stage : parameters}

该阶段代表我们要对数据执行的操作(如limitsort),参数可以是单个值或另一个对象,具体取决于阶段。

管道可以通过两种方式传递,可以作为保存的变量,也可以直接作为命令。以下示例演示了如何将管道作为变量传递:

var pipeline = [
        { $match:   { "location.address.state": "MN"} },
        { $project: { "location.address.city": 1    } },
        { $sort:    { "location.address.city": 1    } },
        { $limit: 3 }
     ];

然后,在 MongoDB shell 中键入db.theaters.aggregate(pipeline)命令将提供以下输出:

MongoDB Enterprise atlas-nb3biv-shard-0:PRIMARY> var pipeline = [
...         { $match:   { "location.address.state": "MN"} },
...         { $project: { "location.address.city": 1    } },
...         { $sort:    { "location.address.city": 1    } },
...         { $limit: 3 }
...      ];
MongoDB Enterprise atlas-nb3biv-shard-0:PRIMARY> 
MongoDB Enterprise atlas-nb3biv-shard-0:PRIMARY> db.theaters.aggregate(pipeline)
{ "_id" : ObjectId("59a47287cfa9a3a73e51e94f"), "location" :   { "address" : { "city" : "Apple Valley" } } }
{ "_id" : ObjectId("59a47287cfa9a3a73e51eb8f"), "location" :   { "address" : { "city" : "Baxter" } } }
{ "_id" : ObjectId("59a47286cfa9a3a73e51e833"), "location" :   { "address" : { "city" : "Blaine" } } }
MongoDB Enterprise atlas-nb3biv-shard-0:PRIMARY>

直接将其传递到命令中,输出如下:

MongoDB Enterprise atlas-nb3biv-shard-0:PRIMARY> db   .theaters.aggregate([
... ...         { $match:   { "location.address.state": "MN"} },
... ...         { $project: { "location.address.city": 1    } },
... ...         { $sort:    { "location.address.city": 1    } },
... ...         { $limit: 3 }
... ...      ]
... );
{ "_id" : ObjectId("59a47287cfa9a3a73e51e94f"), "location" :   { "address" : { "city" : "Apple Valley" } } }
{ "_id" : ObjectId("59a47287cfa9a3a73e51eb8f"), "location" :   { "address" : { "city" : "Baxter" } } }
{ "_id" : ObjectId("59a47286cfa9a3a73e51e833"), "location" :   { "address" : { "city" : "Blaine" } } }
MongoDB Enterprise atlas-nb3biv-shard-0:PRIMARY> 

如您所见,使用任一方法都会得到相同的输出。

创建聚合

让我们开始探索管道本身。将以下代码粘贴到 MongoDB shell 中,将帮助我们获取明尼苏达州(MN)所有剧院的列表:

var simpleFind = function() {
    // Find command using filter, project, sort and limit.
    print("Find Result:")
    db.theaters.find(
        {"location.address.state" : "MN"}, 
        {"location.address.city" : 1})
    .sort({"location.address.city": 1})
    .limit(3)
    .forEach(printjson);
}
simpleFind();

这将给我们以下输出:

MongoDB Enterprise atlas-nb3biv-shard-0:PRIMARY> simpleFind();
Find Result:
{
        "_id" : ObjectId("59a47287cfa9a3a73e51e94f"),
        "location" : {
                "address" : {
                        "city" : "Apple Valley"
                }
        }
}
{
        "_id" : ObjectId("59a47287cfa9a3a73e51eb8f"),
        "location" : {
                "address" : {
                        "city" : "Baxter"
                }
        }
}
{
        "_id" : ObjectId("59a47286cfa9a3a73e51e7e2"),
        "location" : {
                "address" : {
                        "city" : "Blaine"
                }
        }
}

这个语法现在应该看起来非常熟悉。这是一个非常简单的命令,让我们看看涉及的步骤:

  1. 匹配剧院收集以获取MN(明尼苏达州)州内所有剧院的列表。

  2. 只投影剧院所在的城市。

  3. city名称对列表进行排序。

  4. 将结果限制为前个剧院。

让我们将此命令重建为聚合。如果一开始看起来有点令人生畏,不要担心。我们将逐步进行解释:

var simpleFindAsAggregate = function() {
    // Aggregation using match, project, sort and limit.
    print ("Aggregation Result:")
    var pipeline = [
        { $match:   { "location.address.state": "MN"} },
        { $project: { "location.address.city": 1    } },
        { $sort:    { "location.address.city": 1    } },
        { $limit: 3 }
    ];
    db.theaters.aggregate(pipeline).forEach(printjson);
};
simpleFindAsAggregate();

您应该看到以下输出:

图 7.3:聚合结果(为简洁起见输出被截断)

图 7.3:聚合结果(为简洁起见输出被截断)

如果您运行这两个函数,将会得到相同的结果。请记住,findaggregate命令都返回一个游标,但我们在最后使用.forEach(printjson);将它们打印到控制台以便理解。

如果您观察前面的示例,应该能够从find中匹配出大部分相同的功能。projectsortlimit都以 JSON 文档的形式存在,就像在find命令中一样。这些的唯一显着差异是它们现在是数组中的文档,而不是函数。我们管道开头的$match阶段相当于我们的过滤文档。因此,让我们逐步分解它:

  1. 首先,搜索剧院收集,以查找与州MN匹配的文档:
{ $match:   { "location.address.state": "MN"} },
  1. 将此剧院列表传递到第二阶段,该阶段仅投影所选州内剧院所在的城市:
{ $project: { "location.address.city": 1    } },
  1. 然后将这个城市(和 ID)列表传递到sort阶段,按城市名称按字母顺序排序数据:
{ $sort:    { "location.address.city": 1    } },
  1. 最后,列表传递到limit阶段,仅输出前三个条目:
{ $limit: 3 }

相当简单,对吧?您可以想象这个管道在生产中可能会变得多么庞大和复杂,但它的一个优点是能够将大型管道分解为较小的子部分或单个阶段。通过逐个和顺序地查看阶段,看似难以理解的查询可以变得相当简单。同样重要的是要注意,步骤的顺序与阶段本身一样重要,不仅在逻辑上,而且在性能上也是如此。$match$project阶段首先执行,因为这些将在每个阶段减少结果集的大小。虽然不适用于每种类型的查询,但通常最好的做法是尽早尝试减少您正在处理的文档数量,忽略任何会给服务器增加过大负载的文档。

尽管管道结构本身很简单,但是需要更复杂的阶段和运算符来完成高级聚合,并对其进行优化。在接下来的几个主题中,我们将看到许多这样的内容。

练习 7.01:执行简单的聚合

在开始这个练习之前,让我们回顾一下介绍中概述的电影公司,该公司每年都会举办经典电影马拉松。在以前的几年里,他们在最终手工合并所有数据之前,对几个子类别使用了手动流程。作为这项任务的初始研究的一部分,您将尝试将他们的一个较小的手动流程重新创建为 MongoDB 聚合。这个任务将使您更熟悉数据集,并为更复杂的查询打下基础。

您决定重新创建的流程如下:

返回按 IMDb 评分排序的前三部爱情类型电影,并且只返回 2001 年之前发布的电影

可以通过执行以下步骤来完成:

  1. 将您的查询转换为顺序阶段,这样您就可以将其映射到聚合阶段:限制为三部电影,仅匹配爱情电影,按 IMDb 评分排序,并且仅匹配 2001 年之前发布的电影。

  2. 尽可能简化您的阶段,通过合并重复的阶段来简化。在这种情况下,您可以合并两个匹配阶段:限制为三部电影,按 IMDb 评分排序,并匹配 2001 年之前发布的爱情电影。

重要的是要记住,阶段的顺序是至关重要的,除非我们重新排列它们,否则将产生错误的结果。为了演示这一点,我们将暂时保留它们的错误顺序。

  1. 快速查看电影文档的结构,以帮助编写阶段:
db.movies.findOne();

文档如下所示:

图 7.4:查看文档结构(输出被截短以保持简洁)

图 7.4:查看文档结构(输出被截短以保持简洁)

对于这个特定的用例,您将需要imdb.ratingreleasedgenres字段。现在您知道您要搜索什么,可以开始编写您的管道了。

  1. 创建一个名为Ch7_Activity1.js的文件,并添加以下基本阶段:limit以将输出限制为三部电影,sort以按其评分对其进行排序,并且match以确保您只找到 2001 年之前发布的爱情电影:
// Ch7_Exercise1.js   
var findTopRomanceMovies = function() {
       print("Finding top Classic Romance Movies...");
       var pipeline = [
           { $limit: 3 }, // Limit to 3 results.
            { $sort: {"imdb.rating": -1}}, // Sort by IMDB rating.
      { $match: {. . .}}
        ];
        db.movies.aggregate(pipeline).forEach(printjson);
    }
    findTopRomanceMovies();

$match运算符的功能与find命令中的过滤参数非常相似。您可以简单地传入两个条件而不是一个。

  1. 对于早于 2001 年的条件,使用$lte运算符:
// Ch7_Exercise1.js
    var findTopRomanceMovies = function() {
        print("Finding top Classic Romance Movies...");
        var pipeline = [
            { $limit: 3 },         // Limit to 3 results.
            { $sort: {"imdb.rating": -1}}, // Sort by IMDB rating.
            { $match: {
                genres: {$in: ["Romance"]}, // Romance movies only.
                released: {$lte: new ISODate("2001-01-01T00:00:                  00Z") }}},
        ];
        db.movies.aggregate(pipeline).forEach(printjson);
}
findTopRomanceMovies();

因为genres字段是一个数组(电影可以属于多种类型),您必须使用$in运算符来查找包含您所需值的数组。

  1. 现在运行这个管道;您可能会注意到它不返回任何文档:
MongoDB Enterprise atlas-nb3biv-shard-0:PRIMARY>   findTopRomanceMovies();
Finding top Classic Romance Movies...
MongoDB Enterprise atlas-nb3biv-shard-0:PRIMARY>

是否可能没有文档满足这个查询?当然,可能没有电影满足所有这些要求。然而,正如你可能已经猜到的那样,在这里并非如此。正如前面所述,导致产生误导结果的是管道的顺序。因为你的限制阶段是管道中的第一个阶段,你只能查看三个文档,后续阶段没有足够的数据来找到匹配。因此,记住这一点总是很重要:

在编写聚合管道时,操作的顺序很重要。

因此,重新排列它们,确保你只在管道的末尾限制你的文档。由于命令的类似数组结构,这是相当容易的:只需剪切限制阶段,然后粘贴到管道的末尾。

  1. 安排阶段,使限制发生在最后,不会产生不正确的结果:
// Our new pipeline.
var pipeline = [
            { $sort: {"imdb.rating": -1}}, // Sort by IMDB rating.
            { $match: {
                genres: {$in: ["Romance"]}, // Romance movies only.
                released: {$lte: new ISODate("2001-01-01T00:00:                  00Z") }}},
            { $limit: 3 },  // Limit to 3 results (last stage)
        ];
  1. 在更改后重新运行这个查询。这次,文档被返回:图 7.5:有效文档返回的输出(为简洁起见,输出被截断)

图 7.5:有效文档返回的输出(为简洁起见,输出被截断)

这是编写聚合管道的挑战之一:这是一个迭代过程,当处理大量复杂文档时可能会很麻烦。

缓解这一痛点的一种方法是在开发过程中添加简化数据的阶段,然后在最终查询中删除这些阶段。在这种情况下,你将添加一个阶段,只投影你正在查询的数据。这将使你更容易判断你是否捕捉到了正确的条件。在这样做时,你必须小心,不要影响查询的结果。我们将在本章后面更详细地讨论这个问题。现在,你可以简单地在最后添加投影阶段,以确保它不会干扰你的查询。

  1. 在管道的末尾添加一个投影阶段来帮助调试你的查询:
var pipeline = [
    { $sort:  {"imdb.rating": -1}}, // Sort by IMDB rating.
    { $match: {
    genres: {$in: ["Romance"]}, // Romance movies only.
    released: {$lte: new ISODate("2001-01-01T00:00:00Z") }}},
    { $limit: 3 },     // Limit to 3 results.
    { $project: { genres: 1, released: 1, "imdb.rating": 1}}
];
  1. 再次运行这个查询,你会看到一个更短、更容易理解的输出,如下面的代码块所示:图 7.6:前面片段的输出

图 7.6:前面片段的输出

如果你是从桌面上的文件运行代码,请记住,你可以直接将整个代码片段(如下所示)复制并粘贴到你的 shell 中:

// Ch7_Exercise1.js
var findTopRomanceMovies = function() {
    print("Finding top Classic Romance Movies...");
       var pipeline = [
        { $sort: {"imdb.rating": -1}}, // Sort by IMDB rating.
        { $match: {
            genres: {$in: ["Romance"]}, // Romance movies only.
            released: {$lte: new ISODate("2001-01-01T00:00:              00Z") }}},
        { $limit: 3 },          // Limit to 3 results.
        { $project: { genres: 1, released: 1, "imdb.rating": 1}}
];
    db.movies.aggregate(pipeline).forEach(printjson);
}
findTopRomanceMovies();

输出应该如下:

图 7.7:2001 年之前发布的经典浪漫电影排行榜

图 7.7:2001 年之前发布的经典浪漫电影排行榜

你还可以看到返回的每部电影都是浪漫类别的,2001 年之前发布的,并且具有较高的 IMDb 评分。因此,在这个练习中,你成功地创建了你的第一个聚合管道。现在,让我们拿刚刚完成的管道,努力改进一下。当你相信你已经完成了一个管道时,询问自己通常是有帮助的:

“我能减少通过管道传递的文档数量吗?”

在下一个练习中,我们将尝试回答这个问题。

练习 7.02:聚合结构

把管道想象成一个多层漏斗。它从顶部开始变宽,向底部变窄。当你把文档倒入漏斗顶部时,有很多文档,但随着你向下移动,这个数字在每个阶段都在减少,直到只有你想要的文档在底部输出。通常,实现这一点的最简单方法是先进行匹配(过滤)。

在这个管道中,你将对集合中的所有文档进行排序,并丢弃不匹配的文档。你目前正在对不需要的文档进行排序。交换这些阶段:

  1. 交换matchsort阶段以提高管道的效率:
        var pipeline = [
            { $match: {
                genres: {$in: ["Romance"]}, // Romance movies only.
                released: {$lte: new ISODate("2001-01-01T00:00:                  00Z") }}},
            { $sort: {"imdb.rating": -1}}, // Sort by IMDB rating.
            { $limit: 3 },            // Limit to 3 results.
            { $project: { genres: 1, released: 1,               "imdb.rating": 1}}
];

另一个需要考虑的事情是,虽然你有一个符合条件的电影列表,但你希望你的结果对你的用例有意义。在这种情况下,你希望你的结果对查看这些数据的电影公司有意义和用处。他们可能最关心电影的标题和评分。他们可能还希望看到电影是否符合他们的要求,所以最后让我们将这些投影出来,丢弃所有其他属性。

  1. 在投影阶段添加电影 title 字段。你的最终聚合应该是这样的:
// Ch7_Exercise2.js
var findTopRomanceMovies = function() {
    print("Finding top Classic Romance Movies...");
    var pipeline = [
        { $match: {
            genres: {$in: ["Romance"]}, // Romance movies only.
            released: {$lte: new ISODate("2001-01-01T00:00:              00Z") }}},
        { $sort: {"imdb.rating": -1}}, // Sort by IMDB rating.
        { $limit: 3 },     // Limit to 3 results.
        { $project: { title: 1, genres: 1, released: 1,           "imdb.rating": 1}}
    ];
    db.movies.aggregate(pipeline).forEach(printjson);
}
findTopRomanceMovies();
  1. 通过复制并粘贴 步骤 2 中的代码重新运行你的管道。你应该看到排名前两的电影是 傲慢与偏见阿甘正传图 7.8:前面片段的输出

图 7.8:前面片段的输出

如果你看到这些结果,你刚刚优化了你的第一个聚合管道。

正如你所看到的,聚合管道是灵活、强大且易于操作的,但你可能会认为对于这种用例来说似乎有点过于复杂,可能大多数情况下一个简单的 find 命令就能解决问题。的确,聚合管道并不是每个简单查询都需要的,但你只是刚刚开始。在接下来的几节中,你将看到 aggregate 命令提供了 find 命令所不具备的功能。

数据操作

我们大部分的活动和示例都可以归结为以下几点:在一个集合中有一个或多个文档应该以一种简单易懂的格式返回一些或所有文档。在本质上,find 命令和聚合管道只是关于识别和获取正确的文档。然而,聚合管道的能力要比 find 命令更加强大和广泛。

使用管道中一些更高级的阶段和技术可以让我们转换我们的数据,衍生新的数据,并在更广泛的范围内生成见解。聚合命令的这种更广泛的实现比仅仅将一个 find 命令重写为一个管道更为常见。如果你想要回答复杂的问题或从你的数据中提取最大可能的价值,你需要知道如何实现聚合管道的聚合部分。

毕竟,我们甚至还没有开始聚合任何数据。在这个主题中,我们将探讨如何开始转换和聚合你的数据的基础知识。

分组阶段

正如你从名称中期望的那样,$group 阶段允许你根据特定条件对文档进行分组(或聚合)。虽然有许多其他阶段和方法可以使用 aggregate 命令来完成各种任务,但是 $group 阶段是最强大查询的基石。以前,我们能够返回的最重要的数据单元是一个文档。我们可以对这些文档进行排序,通过直接比较文档来获得洞察力。然而,一旦我们掌握了 $group 阶段,我们就能够通过将文档聚合成大的逻辑单元来增加我们查询的范围到整个集合。一旦我们有了更大的分组,我们可以像在每个文档基础上一样应用我们的过滤、排序、限制和投影。

$group 阶段的最基本实现只接受一个 _id 键,其值为一个表达式。这个表达式定义了管道将文档分组在一起的条件。这个值成为了新生成的文档的 _id,每个唯一的 _id 会生成一个文档。例如,以下代码将按照电影的评分对其进行分组,为每个评分类别输出一个记录:

    var pipeline = [
     {$group: {
         _id: "$rated"
     }}
    ];
    db.movies.aggregate(pipeline).forEach(printjson);

结果输出将如下所示:

图 7.9:前面片段的结果输出

图 7.9:前面片段的结果输出

在我们的 $group 阶段中,你可能会注意到的第一件事是 rated 字段之前的 $ 符号。如前所述,我们的 _id 键的值是一个表达式。在聚合术语中,表达式可以是文字,表达式对象,运算符或字段路径。在这种情况下,我们传递了一个字段路径,它告诉管道应该访问输入文档中的哪个字段。在 MongoDB 中,你可能已经遇到过字段路径,也可能没有。

你可能会想为什么我们不能像在 find 命令中那样传递字段名。这是因为在聚合时,我们需要告诉管道我们想要访问当前正在聚合的文档的字段。$group 阶段将 _id: "$rated" 解释为等同于 _id: "$$CURRENT.rated"。这可能看起来很复杂,但它表明对于每个文档,它将适合与具有相同(当前)文档的 "rated" 键的组匹配。在下一节的实践中,这将变得更清晰。

到目前为止,按单个字段分组已经很有用,可以得到唯一值的列表。然而,这并没有告诉我们更多关于我们的数据。我们想要了解这些不同的组更多信息;例如,每个组中有多少个标题?这就是我们的累加表达式会派上用场的地方。

累加器表达式

$group 命令可以接受不止一个参数。它还可以接受任意数量的其他参数,格式如下:

field: { accumulator: expression},

让我们将这个分解成它的三个组件:

  • field 将为每个组定义我们新计算的字段的键。

  • accumulator 必须是一个受支持的累加器运算符。这些是一组运算符,就像你可能已经使用过的其他运算符一样 - 例如 $lte - 除了,正如名称所示,它们将在属于同一组的多个文档之间累积它们的值。

  • 在这种情况下,expression 将作为输入传递给 accumulator 运算符,告诉它应该累积每个文档中的哪个字段。

在前面的示例基础上,让我们确定每个组中电影的总数:

    var pipeline = [
     {$group: {
         _id: "$rated",
         "numTitles": { $sum: 1},
     }}
    ];
    db.movies.aggregate(pipeline).forEach(printjson);

从中可以看出,我们可以创建一个名为 numTitles 的新字段,该字段的值是每个组的文档总和。这些新创建的字段通常被称为累积结果迄今为止的 1。在 MongoDB shell 中运行这个命令将给我们以下结果:

图 7.10:前面片段的输出

图 7.10:前面片段的输出

同样,我们可以累积给定字段的值,而不仅仅是在每个文档上累积 1。例如,假设我们想要找到每部电影在一个评级中的总运行时间。我们按 rating 字段分组,并累积每部电影的运行时间:

    var pipeline = [
     {$group: {
         _id: "$rated",
         "sumRuntime": { $sum: "$runtime"},
     }}
    ];
    db.movies.aggregate(pipeline).forEach(printjson);

记住,我们必须在运行时间字段前加上 $ 符号,告诉 MongoDB 我们正在引用我们正在累积的每个文档的运行时间值。我们的新结果如下:

图 7.11:前面片段的输出

图 7.11:前面片段的输出

虽然这是一个简单的例子,但你可以看到,只需一个聚合阶段和两个参数,我们就可以开始以令人兴奋的方式转换我们的数据。几个累加器运算符可以组合和层叠,以生成关于组的更复杂和有见地的信息。我们将在接下来的示例中看到其中一些运算符。

重要的是要注意,我们不仅可以使用累加器运算符作为我们的表达式。我们还可以使用其他几个有用的运算符,在累积数据之后对数据进行转换。假设我们想要得到每个组的标题的平均运行时间。我们可以将我们的 $sum 累加器更改为 $avg,这将返回每个组的平均运行时间,因此我们的管道变为如下:

    var pipeline = [
     {$group: {
        _id: "$rated",
        "avgRuntime": { $avg: "$runtime"},
     }}
    ];
    db.movies.aggregate(pipeline).forEach(printjson);

然后我们的输出变为:

图 7.12:基于评级的平均运行时间值

图 7.12:基于评分的平均运行时间值

在这种情况下,这些平均运行时间值并不特别有用。让我们添加另一个阶段来投影运行时间,使用$trunc阶段,给我们一个整数值:

    var pipeline = [
     {$group: {
         _id: "$rated",
         "avgRuntime": { $avg: "$runtime"},
     }},
     {$project: {
         "roundedAvgRuntime": { $trunc: "$avgRuntime"}
     }}
    ];
    db.movies.aggregate(pipeline).forEach(printjson);

这将为我们提供一个更加格式良好的结果,就像这样:

{ "_id" : "PG-13", "avgRuntime" : 108 }

本节演示了如何将分组阶段与运算符、累加器和其他阶段结合起来,以帮助操纵我们的数据来回答更广泛的业务问题。现在,让我们开始聚合并将这个新阶段付诸实践。

练习 7.03:操纵数据

在之前的情景中,你已��习惯了数据的形状,并将客户的一个手动流程重新创建为一个聚合管道。作为经典电影马拉松的前奏,电影公司决定尝试为每种流派运行一部电影(直到马拉松结束每周一部),他们希望最受欢迎的流派最后播放,以此来烘托活动气氛。然而,他们有一个问题。这些周的时间表已经被规定,这意味着经典电影将不得不适应时间表中的空档。因此,为了实现这一目标,他们必须知道每种流派中最长电影的长度,包括每部电影的预告片时间。

注意

在这种情况下,热门程度是由IMDb 评分定义的,而预告片在任何电影之前都会播放 12 分钟。

目标可以总结如下:

“仅针对 2001 年之前的电影,找到每种流派的平均热门程度和最大热门程度,按热门程度对流派进行排序,并找到每种流派中最长电影的调整(包括预告片)运行时间。”

将查询转换为顺序阶段,以便你可以映射到你的聚合阶段:

  • 匹配 2001 年之前发布的电影。

  • 找到每种流派的平均热门程度。

  • 按热门程度对流派进行排序。

  • 输出每部电影的调整后的运行时间。

由于你对分组阶段有了更多了解,利用你的新知识详细说明这一步骤:

  • 匹配 2001 年之前发布的电影。

  • 按照它们的第一个流派对所有电影进行分组,并累积平均和最大的 IMDb 评分。

  • 按每种流派的平均热门程度进行排序。

  • 将调整后的运行时间投影为total_runtime

以下步骤将帮助你完成这个练习。

  1. 首先创建你的聚合大纲。创建一个名为Ch7_Exercise3.js的新文件:
// Ch7_Exercise3.js
var findGenrePopularity = function() {
  print("Finding popularity of each genre");
  var pipeline = [
            { $match: {}},
            { $group: {}},
            { $sort: {}},
            { $project: {}}
        ];
        db.movies.aggregate(pipeline).forEach(printjson);
    }
    findGenrePopularity();
  1. 一次填写一个步骤,从$match开始:
            { $match: {
                released: {$lte: new ISODate("2001-01-01T00:00:                  00Z") }}},

这类似于练习 7.01执行简单的聚合,在那里你匹配了 2001 年之前发布的所有文档。

  1. 对于$group阶段,首先为每个输出文档确定你的新id
{ $group: {
    _id: {"$arrayElemAt": ["$genres", 0]},
}},

$arrayElemAt从数组中取出指定索引处的元素(在这种情况下是 0)。对于这种情况,假设数组中的第一个流派是电影的主要流派。

接下来,在结果中指定你需要的新计算字段。记住使用累加器运算符,包括$avg平均)和$max最大)。记住,在accumulator中,因为你在引用一个变量,你必须在字段前加上$符号:

{ $group: {
    _id: {"$arrayElemAt": ["$genres", 0]},
    "popularity": {  $avg: "$imdb.rating"},
    "top_movie": { $max: "$imdb.rating"},
    "longest_runtime": { $max: "$runtime"}
}},
  1. 填写sort字段。现在你已经定义了你的计算字段,这很简单:
{ $sort: { popularity: -1}},
  1. 要获得调整后的运行时间,使用$add运算符并添加12(分钟)。你添加 12 分钟是因为客户(电影公司)已经告诉你这是每部电影播放前预告片的长度。一旦你有了调整后的运行时间,你将不再需要longest_runtime
{ $project: {
    _id: 1,
    popularity: 1, 
    top_movie: 1, 
    adjusted_runtime: { $add: [ "$longest_runtime", 12 ] } } }
  1. 还要添加一个$。你最终的聚合管道应该是这样的:
var findGenrePopularity = function() {
    print("Finding popularity of each genre");
    var pipeline = [
        { $match: {
        released: {$lte: new ISODate("2001-01-01T00:00:00Z") }}},
        { $group: {
            _id: {"$arrayElemAt": ["$genres", 0]},
            "popularity": {  $avg: "$imdb.rating"},
            "top_movie": { $max: "$imdb.rating"},
            "longest_runtime": { $max: "$runtime"}
        }},
            { $sort: { popularity: -1}},
            { $project: {
                _id: 1,
                popularity: 1, 
                top_movie: 1, 
                adjusted_runtime: { $add: [ "$longest_runtime",                   12 ] } } }
        ];
        db.movies.aggregate(pipeline).forEach(printjson);
    }
    findGenrePopularity();

如果你的结果是正确的,你的前几个文档应该如下:

图 7.13:返回的前几个文档

图 7.13:返回的前几个文档

输出显示,黑色电影、纪录片和短片是最受欢迎的,我们还可以看到每个类别的平均运行时间。在下一个练习中,我们将根据特定要求从每个类别中选择一个标题。

练习 7.04:从每个电影类别中选择标题

您现在已经回答了客户提出的问题。但是,这个结果对于他们来说并没有帮助选择特定的电影。他们必须执行不同的查询,以获取每个流派的电影列表,并从中选择要展示的最佳电影。此外,您还了解到最大的时间段可用为 230 分钟。您将修改此查询,以为电影公司提供每个类别的推荐标题。以下步骤将帮助您完成此练习:

  1. 首先,增加第一个匹配以过滤掉不适用的电影。过滤掉超过 218 分钟(230 加上预告片)的电影。还要过滤掉评分较低的电影。首先,您将获得评分超过 7.0 的电影:
{ $match: {
  released: {$lte: new ISODate("2001-01-01T00:00:00Z") },
  runtime:  {$lte: 218},
  "imdb.rating": {$gte: 7.0}
  }
},
  1. 为了获得每个类别的推荐标题,使用我们组阶段中的$first累加器来获取每个流派的顶级文档(电影)。为此,您首先需要按评分降序排序,确保第一个文档也是评分最高的。在初始的\(match 阶段之后添加一个新的\)sort 阶段:
{ $sort: {"imdb.rating": -1}},
  1. 现在,在组阶段中添加$first累加器,添加您的新字段。还添加recommended_ratingrecommended_raw_runtime字段以便使用:
{ $group: {
  _id: {"$arrayElemAt": ["$genres", 0]},
  "recommended_title": {$first: "$title"},
  "recommended_rating": {$first: "$imdb.rating"},
  "recommended_raw_runtime": {$first: "$runtime"},
  "popularity": {  $avg: "$imdb.rating"},
  "top_movie": { $max: "$imdb.rating"},
  "longest_runtime": { $max: "$runtime"}
}},
  1. 确保将此新字段添加到最终的投影中:
{ $project: {
     _id: 1,
      popularity: 1, 
      top_movie: 1, 
      recommended_title: 1,
      recommended_rating: 1,
      recommended_raw_runtime: 1,
      adjusted_runtime: { $add: [ "$longest_runtime", 12 ] } } }

您的新最终查询应该如下所示:

// Ch7_Exercise4js
var findGenrePopularity = function() {
    print("Finding popularity of each genre");
    var pipeline = [
       { $match: {
        released: {$lte: new ISODate("2001-01-01T00:00:00Z") },
            runtime:  {$lte: 218},
            "imdb.rating": {$gte: 7.0}
            }
           },
           { $sort: {"imdb.rating": -1}},
           { $group: {
             _id: {"$arrayElemAt": ["$genres", 0]},
             "recommended_title": {$first: "$title"},
             "recommended_rating": {$first: "$imdb.rating"},
             "recommended_raw_runtime": {$first: "$runtime"},
             "popularity": {  $avg: "$imdb.rating"},
             "top_movie": { $max: "$imdb.rating"},
             "longest_runtime": { $max: "$runtime"}
           }},
           { $sort: { popularity: -1}},
           { $project: {
                _id: 1,
                 popularity: 1, 
                 top_movie: 1, 
                 recommended_title: 1,
                 recommended_rating: 1,
                 recommended_raw_runtime: 1,
                 adjusted_runtime: { $add: [ "$longest_runtime",                    12 ] } } }
        ];
        db.movies.aggregate(pipeline).forEach(printjson);
    }
    findGenrePopularity();
  1. 执行此操作,您的前两个结果文档应该看起来像下面这样:图 7.14:前两个结果文档

图 7.14:前两个结果文档

您可以看到,通过对管道进行一些添加,您已经提取出了评分最高和最长的电影,为客户创造了额外的价值。

在本主题中,我们看到了如何查询数据,然后对结果进行排序、限制和投影。我们还看到,通过使用更高级的聚合阶段,我们可以完成更复杂的任务。数据被操纵和转换以创建新的、有意义的文档。这些新的阶段使用户能够回答更广泛、更困难的业务问题,并获得有价值的数据洞察。

处理大型数据集

到目前为止,我们一直在处理相对较少的文档。movies集合中大约有 23,500 个文档。这对于人类来说可能是一个相当大的数字,但对于大型生产系统来说,您可能会处理数百万而不是数千的规模。到目前为止,我们也一直严格专注于一次处理单个集合,但如果我们的聚合范围扩大到包括多个集合呢?

在第一个主题中,我们简要讨论了在开发管道时如何使用投影阶段来创建更易读的输出,并简化调试结果。但是,我们没有涵盖在处理更大规模的数据集时如何提高性能,无论是在开发过程中还是在最终的生产就绪查询中。在本主题中,我们将讨论在处理大型多集合数据集时需要掌握的一些聚合阶段。

使用$sample 进行抽样

学习如何处理大型数据集的第一步是了解$sample。这个阶段简单而有用。$sample的唯一参数是您的样本期望大小。这个阶段会随机选择文档(最多达到您指定的大小)并将它们传递到下一个阶段:

{ $sample: {size: 100}}, // This will reduce the scope to   100 random docs.

通过这样做,您可以显著减少通过管道的文档数量。主要是有两个原因。第一个原因是在处理庞大数据集时加快执行时间,尤其是在微调或构建聚合时。第二个原因是对于可以容忍结果中缺少文档的查询用例。例如,如果您想返回某个流派的任意五部电影,您可以使用$sample

var findWithSample = function() { 
    print("Finding all documents WITH sampling") 
    var now = Date.now(); 
    var pipeline = [ 
        { $sample: {size: 100}}, 
        { $match: { 
            "plot": { $regex: /around/} 
        }} 
    ]; 
    db.movies.aggregate(pipeline) 
    var duration = Date.now() - now; 
    print("Finished WITH sampling in " + duration+"ms"); 
}
findWithSample();

执行新的findWithSample()函数后,将获得以下结果:

Finding all documents WITH sampling
Finished WITH sampling in 194ms

你可能会想为什么不直接使用$limit命令来实现在管道的某个阶段减少文档数量的相同结果。主要原因是$limit始终遵守文档的顺序,因此每次返回相同的文档。然而,重要的是要注意,在某些情况下,当你不需要$sample的伪随机选择时,最好使用$limit

让我们看看$sample的实际应用。这是一个查询,用于在plot字段中搜索特定关键字的所有电影,分别使用和不使用$sample实现:

var findWithoutSample = function() {
    print("Finding all documents WITHOUT sampling")
    var now = Date.now();
    var pipeline =[
        { $match: {
            "plot": { $regex: /around/}
        }},
    ]
    db.movies.aggregate(pipeline)
    var duration = Date.now() - now;
    print("Finished WITHOUT sampling in " + duration+ "ms");
}
findWithoutSample();

前面的例子并不是衡量性能的最佳方式,有更好的方法来分析管道的性能,比如Explain。然而,由于我们将在本书的后面部分涵盖这些内容,这将作为一个简单的例子。如果你运行这个小脚本,你将始终得到以下结果:

Finding all documents WITHOUT sampling
Finished WITHOUT sampling in 862ms

这两个命令的输出的简单比较如下:

Finding all documents WITH sampling 
Finished WITH sampling in 194ms 
Finding all documents WITHOUT sampling
Finished WITHOUT sampling in 862ms

通过抽样,性能得到了显著改善。然而,这是因为我们只查看了 100 个文档。更可能的是,在这种情况下,我们希望在match语句之后对结果进行抽样,以确保我们不会在第一个阶段排除所有结果。在大多数情况下,在处理执行时间显著的大型数据集时,你可能希望在构建管道时从开始进行抽样,并在查询最终确定后移除抽样。

使用$lookup 连接集合

抽样可能在针对大型集合开发查询时对你有所帮助,但在生产查询中,你有时需要编写跨多个集合操作的查询。在 MongoDB 中,使用$lookup聚合步骤进行这些集合连接。

这些连接可以通过以下聚合轻松理解:

var lookupExample = function() {
    var pipeline = [
        { $match:  { $or: [{"name": "Catelyn Stark"},           {"name": "Ned Stark"}]}},
        { $lookup: { 
            from: "comments",
            localField: "name",
            foreignField: "name",
            as: "comments"
        }},
  { $limit: 2},
    ];
    db.users.aggregate(pipeline).forEach(printjson);
}
lookupExample();

在我们尝试运行之前,让我们先分析一下。首先,我们对users集合运行了$match,只获取了两个名为Ned StarkCatelyn Stark的用户。一旦我们有了这两条记录,我们执行我们的查找。$lookup的四个参数如下:

  • from:我们要连接到当前聚合的集合。在这种情况下,我们将comments连接到users

  • localField:我们将用来连接本地集合中文档的字段名称(我们正在对其进行聚合的集合)。在这种情况下,是我们用户的名称。

  • foreignField:链接到from集合中的localField的字段。它们可能有不同的名称,但在这种情况下,它是相同的字段:name

  • as:这是我们新连接的数据将被标记的方式。

在这个例子中,查找使用我们用户的名称,搜索comments集合,并将具有相同名称的任何评论添加到原始用户文档的新数组字段中。这个新数组被称为comments。通过这种方式,我们可以获取另一个集合中所有相关文档的数组,并将它们嵌入到我们原始文档中,以便在聚合的其余部分中使用。

如果我们按照现有的管道运行,输出的开头将看起来像这样:

图 7.15:运行管道后的输出(为简洁起见截断)

图 7.15:运行管道后的输出(为简洁起见截断)

由于输出非常大,前面的截图只显示了comments数组的开头部分。

在这个例子中,用户发表了许多评论,因此嵌入的数组变得相当庞大且难以查看。这个问题是引入$unwind运算符的一个很好的地方,因为这些连接通常会导致大量相关文档的数组。$unwind是一个相对简单的阶段。它会从输入文档中解构一个数组字段,以输出数组中每个元素的新文档。例如,如果你展开这个文档:

{a: 1, b: 2, c: [1, 2, 3, 4]}

输出将是以下文档:

{"a" : 1, "b" : 2, "c" : 1 }
{"a" : 1, "b" : 2, "c" : 2 }
{"a" : 1, "b" : 2, "c" : 3 }
{"a" : 1, "b" : 2, "c" : 4 }

我们可以添加这个新的阶段到我们的连接中,然后尝试运行它:

var lookupExample = function() {
    var pipeline = [
        { $match:  { $or: [{"name": "Catelyn Stark"},           {"name": "Ned Stark"}]}},
        { $lookup: { 
            from: "comments",
            localField: "name",
            foreignField: "name",
            as: "comments"
        }},
        { $unwind: "$comments"},
        { $limit: 3},
    ];
    db.users.aggregate(pipeline).forEach(printjson);
}
lookupExample();

我们将看到如下输出:

图 7.16:上述片段的输出(为简洁起见而截断)

图 7.16:上述片段的输出(为简洁起见而截断)

我们可以看到每个用户有多个文档,每个评论都有一个单独的文档,而不是一个嵌入式数组。有了这种新格式,我们可以添加更多阶段来操作我们的新文档集。例如,我们可能希望过滤掉对特定电影的任何评论,或者按日期对评论进行排序。$lookup$unwind的组合对于在单个聚合中跨多个集合回答复杂问题是一个强大的组合。

使用$out$merge输出您的结果

假设在过去的一周里,我们一直在一个大型的多阶段聚合管道上工作。我们一直在调试、抽样、过滤和测试我们的管道,以解决一个具有挑战性和复杂业务问题的巨大数据集。最后,我们对我们的管道感到满意,我们想要执行它,然后保存结果以供后续分析和展示。

我们可以运行查询并将结果导出到新的格式。然而,这意味着如果我们想对结果集进行后续分析,就需要重新导入结果。

我们可以将输出保存在一个数组中,然后重新插入到 MongoDB 中,但这意味着需要将所有数据从服务器传输到客户端,然后再从客户端传输回服务器。

幸运的是,从 MongoDB 4.2 版本开始,我们提供了两个聚合阶段来解决这个问题:$out$merge。这两个阶段都允许我们将管道的输出写入一个集合以供以后使用。重要的是,整个过程都在服务器上进行,这意味着所有数据都不需要通过网络传输到客户端。可以想象,在创建了一个复杂的聚合查询之后,您可能希望每周运行一次,并通过将数据写入集合来创建结果的快照。

让我们看看这两个阶段的语法,以及它们的最基本形式,然后我们可以比较它们的功能:

// Available from v2.6
{ $out: "myOutputCollection"}
// Available from version 4.2
{ $merge: {
    // This can also accept {db: <db>, coll: <coll>} to       merge into a different db
    into: "myOutputCollection", 
}}

正如您所看到的,没有任何可选参数的语法几乎是相同的。然而,在其他方面,这两个命令是不同的。$out非常简单;唯一需要指定的参数是期望的输出集合。它要么创建一个新的集合,要么完全替换现有的集合。$out还有一些约束条件,而$merge没有。例如,$out必须输出到与聚合目标相同的数据库。

在 MongoDB 4.2 服务器上运行时,$merge可能是更好的选择。然而,在本书的范围内,我们将使用 MongoDB 的免费版,它运行的是 MongoDB 4.0。因此,在这些示例中,我们将更多地关注$out阶段。

$out的语法非常简单。唯一的参数是我们想要输出结果的集合。以下是一个带有$out的管道的示例:

var findTopRomanceMovies = function() {
    var pipeline = [
        { $sort:  {"imdb.rating": -1}}, // Sort by IMDB rating.
        { $match: {
            genres: {$in: ["Romance"]}, // Romance movies only.
            released: {$lte: new ISODate("2001-01-01T00:00:              00Z") }}},
        { $limit: 5 },                 // Limit to 5 results.
        { $project: { title: 1, genres: 1, released: 1,           "imdb.rating": 1}},
        { $out: "movies_top_romance"}
    ];
    db.movies.aggregate(pipeline).forEach(printjson);
}
findTopRomanceMovies();

通过运行这个管道,您将不会收到任何输出。这是因为输出已经重定向到我们想要的集合中:

MongoDB Enterprise atlas-nb3biv-shard-0:PRIMARY>   findTopRomanceMovies();
MongoDB Enterprise atlas-nb3biv-shard-0:PRIMARY>

我们可以看到,一个新的集合被我们的结果创建了:

MongoDB Enterprise atlas-nb3biv-shard-0:PRIMARY> show collections
comments
movies
movies_top_romance
sessions
theaters
users

如果我们在新的集合上运行一个查找,我们可以看到我们的聚合结果现在存储在其中:

MongoDB Enterprise atlas-nb3biv-shard-0:PRIMARY> db.movies_top_romance.findOne({})
{
        "_id" : ObjectId("573a1399f29313caabceeead"),
        "genres" : [
                "Drama",
                "Romance"
        ],
        "title" : "Pride and Prejudice",
        "released" : ISODate("1996-01-14T00:00:00Z"),
        "imdb" : {
                "rating" : 9.1
        }
}

通过将结果放入一个集合中,我们可以存储、共享和更新新的复杂聚合结果。我们甚至可以对这个新集合运行进一步的查询和聚合。$out是一个简单但强大的聚合阶段。

练习 7.05:列出评论最多的电影

电影公司希望了解哪些电影从用户那里获得了最多的评论。然而,鉴于数据库中有很多评论(以及您倾向于使用您新学到的技能),您决定在开发此管道时,只使用评论的样本。从这个样本中,您将找出最受关注的电影,并将这些文档与movies集合中的文档结合起来,以获取有关电影的更多信息。公司还要求您的最终交付成果是一个包含输出文档的新集合。鉴于您现在已经了解了$merge阶段,这个要求应该很容易满足。

您收集到的一些额外信息是,他们希望结果尽可能简单,并且希望知道电影的标题和评分。此外,他们希望看到评论最多的前五部电影。

在这个练习中,您将帮助电影公司获取用户评论最多的电影列表。执行以下步骤完成这个练习:

  1. 首先,概述管道中的阶段;它们按以下顺序出现:

在构建管道时,对comments集合进行$sample

$group评论按其所针对的电影分组。

$sort结果按总评论数排序。

$limit结果为评论最多的前五部电影。

$lookup与每个文档匹配的电影。

$unwind电影数组,以保持结果文档简单。

$project只有电影标题和评分。

$merge结果到一个新的集合中。

尽管这可能看起来有很多阶段,但每个阶段都相对简单,整个过程可以从头到尾逻辑地跟随。

  1. 创建一个名为Ch7_Exercise5.js的新文件,并编写您的管道框架:
// Ch7_Exercise5.js
var findMostCommentedMovies = function() {
    print("Finding the most commented on movies.");
    var pipeline = [
             { $sample: {}}, 
             { $group: {}},
             { $sort: {}},
             { $limit: 5},
             { $lookup: {}},
             { $unwind: },
             { $project: {}},
             { $out: {}}
    ];
    db.comments.aggregate(pipeline).forEach(printjson);
}
findMostCommentedMovies();
  1. 在决定样本大小之前,您应该了解comments集合有多大。对comments集合运行count
MongoDB Enterprise atlas-nb3biv-shard-0:PRIMARY>   db.comments.count()
50303
  1. 在开发过程中对集合进行大约百分之十的抽样。将本练习的样本大小设置为5000
{ $sample: {size: 5000}}, 
  1. 现在您已经完成了较容易的步骤,填写$group语句,将评论按其关联的电影分组,累积每部电影的评论总数:
{ $group: {
    _id: "$movie_id",
    "sumComments": { $sum: 1}
}},
  1. 接下来,添加sort,使具有最高sumComments值的电影排在第一位:
{ $sort: { "sumComments": -1}},
  1. 在构建管道时,定期运行部分完成的管道非常重要,以确保您看到预期的结果。由于您正在抽样,每次运行管道时结果都不会相同。以下输出只是一个例子:图 7.17:示例输出
{ $lookup: {
    from: "movies",
    localField: "_id",
    foreignField: "_id",
    as: "movie"
}},

重新运行此代码,现在您可以看到一个带有所有电影详细信息的movie数组嵌入其中:

图 7.19:重新运行管道后的输出

{ $unwind: "$movie" },
{ $project: {
    "movie.title": 1,
    "movie.imdb.rating": 1,
    "sumComments": 1,
}}
  1. 您的数据现在已经完整,但您仍然需要将此结果输出到一个集合中。在最后添加$out步骤:
{ $out: "most_commented_movies" }

您最终的代码应该看起来像这样:

// Ch7_Exercise5.js
var findMostCommentedMovies = function() {
    print("Finding the most commented on movies.");
    var pipeline = [
             { $sample: {size: 5000}}, 
             { $group: {
                 _id: "$movie_id",
                 "sumComments": { $sum: 1}
             }},
             { $sort: { "sumComments": -1}},
             { $limit: 5},
             { $lookup: {
                 from: "movies",
                 localField: "_id",
                 foreignField: "_id",
                 as: "movie"
             }},
             { $unwind: "$movie" },
             { $project: {
                 "movie.title": 1,
                 "movie.imdb.rating": 1,
                 "sumComments": 1,
             }},
             { $out: "most_commented_movies" }
    ];
    db.comments.aggregate(pipeline).forEach(printjson);
}
findMostCommentedMovies();

运行此代码。如果一切顺利,您将在 shell 中看不到管道的任何输出,但您应该能够使用find()检查您新创建的集合并查看您的结果。请记住,由于抽样阶段,结果每次都不会相同:

图 7.20:前面片段的结果(为简洁起见截断输出)

图 7.20:前面片段的结果(为简洁起见截断输出)

通过本主题学到的新阶段,我们现在拥有了在更大、更复杂的数据集上执行聚合的良好基础。而且,更重要的是,我们现在能够有效地在多个集合之间进行数据连接。通过这样做,我们可以扩大我们的查询范围,从而满足更广泛的用例。

通过out阶段,我们可以存储聚合的结果。这使用户可以通过常规的 CRUD 操作快速探索结果,并且可以轻松地保持更新结果。unwind 阶段还使我们能够将查找操作中的连接文档分开成单独的文档,以便将其馈送到后续的管道阶段中。

通过结合所有这些阶段,我们现在能够创建跨大型多集合数据集进行操作的广泛新聚合。

从您的聚合中获得最大收益

在过去的三个主题中,我们已经了解了聚合的结构以及构建复杂查询所需的关键阶段。我们可以使用给定的条件搜索大型多集合数据集,操纵数据以创建新的见解,并将结果输出到新的或现有集合中。

这些基础将使您能够解决聚合管道中遇到的大多数问题。然而,还有一些其他阶段和模式可以让您从聚合中获得最大收益。我们不会在本书中涵盖所有这些内容,但在本主题中,我们将讨论一些可以帮助您微调管道的技巧,以及一些我们到目前为止还没有涵盖的其他技巧。我们将使用Explain来分析您的聚合选项。

调整您的管道

在早期的主题中,我们通过输出聚合之前和之后的时间来计算我们的管道的执行时间。这是一种有效的技术,你可能经常在客户端或应用程序端计时你的 MongoDB 查询。然而,这只能给我们一个大致的持续时间,并且只告诉我们响应到达客户端所花费的总时间,而不是服务器执行管道所花费的时间。MongoDB 为我们提供了一个很好的学习方式,可以准确地了解它是如何执行我们请求的查询的。这个功能被称为Explain,是检查和优化我们的 MongoDB 命令的常规方式。

然而,有一个问题。Explain目前不支持聚合的详细执行计划,这意味着在优化管道时其使用受到限制。Explain和执行计划将在本书的后面更详细地介绍。由于我们不能依赖Explain来分析我们的管道,因此更加重要的是仔细构建和规划我们的管道,以提高聚合的性能。虽然没有一种适用于任何情况的单一正确方法,但有一些启发式方法通常会有所帮助。我们将通过一些示例来介绍其中的一些方法。MongoDB 在幕后进行了大量的性能优化,但这些仍然是要遵循的良好模式。

尽早过滤,经常过滤

聚合管道的每个阶段都会对输入进行一些处理。这意味着输入越重要,处理就越大。如果您正确设计了管道,那么这种处理对于您要返回的文档是不可避免的。您所能做的就是确保您只处理您想要返回的文档。

实现这一点的最简单方法是添加或移动过滤文档的管道阶段。我们在之前的情景中已经用$match$limit做过这个操作。确保这一点的常见方法是将管道中的第一个阶段设置为$match,这样可以只匹配后续管道中需要的文档。让我们通过以下管道示例来理解这一点,其中管道没有按预期执行设计:

var badlyOrderedQuery = function() {
  print("Running query in bad order.")
  var pipeline = [
    { $sort: {"imdb.rating": -1}}, // Sort by IMDB rating.
    { $match: {
        genres: {$in: ["Romance"]}, // Romance movies only.
        released: {$lte: new ISODate("2001-01-01T00:00:00Z") }}},
    { $project: { title: 1, genres: 1, released: 1,       "imdb.rating": 1}},
    { $limit: 1 },                 // Limit to 1 result.
  ];
  db.movies.aggregate(pipeline).forEach(printjson);
}
badlyOrderedQuery();

输出将如下所示:

MongoDB Enterprise atlas-nb3biv-shard-0:PRIMARY>   badlyOrderedQuery();
Running query in bad order.
{
        "_id" : ObjectId("573a1399f29313caabceeead"),
        "genres" : [
                "Drama",
                "Romance"
        ],
        "title" : "Pride and Prejudice",
        "released" : ISODate("1996-01-14T00:00:00Z"),
        "imdb" : {
                "rating" : 9.1
        }
}

一旦你正确地排序了管道,它将如下所示:

var wellOrderedQuery = function() {
print("Running query in better order.")
var pipeline = [
    { $match: {
        genres: {$in: ["Romance"]}, // Romance movies only.
        released: {$lte: new ISODate("2001-01-01T00:00:00Z") }}},
    { $sort:  {"imdb.rating": -1}}, // Sort by IMDB rating.
    { $limit: 1 },                 // Limit to 1 result.
    { $project: { title: 1, genres: 1, released: 1,       "imdb.rating": 1}},
];
db.movies.aggregate(pipeline).forEach(printjson);
}
wellOrderedQuery();

这将导致以下输出:

图 7.21:前面片段的输出(为简洁起见而截断)

图 7.21:前面片段的输出(为简洁起见而截断)

从逻辑上讲,这个改变意味着我们首先要做的是在对它们进行排序之前获取所有符合条件的文档列表,然后我们取前五个并且只投影这五个文档。

这两个管道都输出相同的结果,但第二个更加健壮且易于理解。你可能不会总是看到这种改变带来显著的性能提升,特别是在较小的数据集上。然而,这是一个很好的实践,因为它将帮助你创建逻辑、高效和简单的管道,可以更容易地进行修改或扩展。

使用你的索引

索引是 MongoDB 查询性能的另一个关键因素。本书在第九章“性能”中更深入地介绍了索引及其创建。在创建聚合时,你需要记住的是,在使用$sort$match等阶段时,你要确保你正在操作的是正确索引的字段。使用索引的概念将会变得更加明显。

考虑期望的输出

改进你的管道最重要的方法之一是计划和评估它们,以确保你得到了解决业务问题的期望输出。如果你在创建一个精心调整的管道时遇到困难,可以问自己以下问题:

  • 我是否输出了所有数据来解决我的问题?

  • 我是否只输出了解决问题所需的数据?

  • 我是否能够合并或删除任何中间步骤?

如果你已经评估了你的管道,调整了它,但仍然觉得它过于复杂或低效,你可能需要对数据本身提出一些问题。聚合是否困难是因为设计了错误的查询,甚至是问了错误的问题?或者,也许这是数据形状需要重新评估的一个迹象。

聚合选项

修改管道是你在处理聚合时可能会花费大部分时间的地方,对于初学者来说,你可能只需编写管道就能实现大部分目标。正如本章前面提到的,可以传递多个选项到aggregate命令中以配置其操作。我们不会深入探讨这些选项,但了解它们是有帮助的。以下是包含一些选项的聚合示例:

    var options = { 
        maxTimeMS: 30000,
        allowDiskUse: true
        }
    db.movies.aggregate(pipeline, options);

要指定这些选项,需要在管道数组之后传递第二个参数给命令。在这种情况下,我们称之为options。一些需要注意的选项包括以下内容:

  • maxTimeMS:MongoDB 在终止操作之前可以处理的时间量。本质上是聚合的超时时间。默认值为0,这意味着操作不会超时。

  • allowDiskUse:聚合管道中的阶段可能只使用最大数量的内存,这使得处理大型数据集变得具有挑战性。通过将此选项设置为true,MongoDB 可以写临时文件以处理更多的数据。

  • bypassDocumentValidation:这个选项专门用于将使用$out$merge写入集合的管道。如果将此选项设置为true,则不会对从该管道写入集合的文档进行文档验证。

  • comment:这个选项只是用于调试,允许指定一个字符串来帮助在解析数据库日志时识别这个聚合。

  • 现在让我们进行一个练习,将我们到目前为止学到的概念付诸实践。

练习 7.06:查找获奖纪录片

在看到前几个练习中实现的聚合管道的结果以及它们为电影公司带来的价值后,公司的一些内部工程师尝试自己编写了一些新的聚合。电影公司要求您审查这些管道,以协助他们内部工程师的学习过程。您将使用前面的一些技术和您对最后三个主题中聚合的理解来修复一个管道。这个简单管道的目标是获取一份评分很高的纪录片清单。

对于这种情况,您还将在假设集合中有大量数据的情况下进行工作。给您要审查的管道如下。此练习的目的是找到一些获奖纪录片,并列出获奖最多的电影:

var findAwardWinningDocumentaries = function() {
    print("Finding award winning documentary Movies...");
    var pipeline = [
        { $sort: {"awards.wins": -1}}, // Sort by award wins.
        { $match: {"awards.wins": { $gte: 1}}},
        { $limit: 20}, // Get the top 20 movies with more than           one award
        { $match: {
            genres: {$in: ["Documentary"]}, // Documentary               movies only.
        }},
        { $project: { title: 1, genres: 1, awards: 1}},
        { $limit: 3}, 
    ];
    var options = { }
    db.movies.aggregate(pipeline, options).forEach(printjson);
}
findAwardWinningDocumentaries();

可以通过以下步骤实现结果:

  1. 首先,合并两个$match语句,并将match移到管道的顶部:
var pipeline = [
    { $match: {
        "awards.wins": { $gte: 1},
        genres: {$in: ["Documentary"]},
    }},
    { $sort: {"awards.wins": -1}}, // Sort by award wins.
    { $limit: 20}, // Get the top 20 movies. 
    { $project: { title: 1, genres: 1, awards: 1}},
    { $limit: 3},
];
  1. 不再需要在开头使用sort,因此可以将其移动到倒数第二步:
var pipeline = [
    { $match: {
        "awards.wins": { $gte: 1},
        genres: {$in: ["Documentary"]},
    }},
    { $limit: 20}, // Get the top 20 movies. 
    { $project: { title: 1, genres: 1, awards: 1}},
    { $sort: {"awards.wins": -1}}, // Sort by award wins.
    { $limit: 3},
];
  1. 不再需要两个限制。删除第一个:
var pipeline = [
    { $match: {
        "awards.wins": { $gte: 1},
        genres: {$in: ["Documentary"]},
    }},
    { $project: { itle: 1, genres: 1, awards: 1}},
    { $sort: {"awards.wins": -1}}, // Sort by award wins.
    { $limit: 3},
];
  1. 最后,将投影移到最后三个文档:
var pipeline = [
    { $match: {
        "awards.wins": { $gte: 1},
        genres: {$in: ["Documentary"]},
    }},
    { $sort: {"awards.wins": -1}}, // Sort by award wins.
    { $limit: 3},
    { $project: { title: 1, genres: 1, awards: 1}},
];
  1. 这已经看起来好多了。您被告知集合非常庞大,因此还要为聚合添加一些选项:
var options ={
        maxTimeMS: 30000,
        allowDiskUse: true,
        comment: "Find Award Winning Documentary Films"
    }
    db.movies.aggregate(pipeline, options).forEach(printjson);
  1. 运行完整查询:
var findAwardWinningDocumentaries = function() {
    print("Finding award winning documentary Movies...");
    var pipeline = [
        { $match: {
            "awards.wins": { $gte: 1},
            genres: {$in: ["Documentary"]},
        }},
        { $sort:  {"awards.wins": -1}}, // Sort by award wins.
        { $limit: 3},
        { $project: { title: 1, genres: 1, awards: 1}},
    ];

    var options ={
        maxTimeMS: 30000,
        allowDiskUse: true,
        comment: "Find Award Winning Documentary Films"
    }
    db.movies.aggregate(pipeline, options).forEach(printjson);
}
findAwardWinningDocumentaries();

因此,您的结果应如下所示:

图 7.22:获奖纪录片清单(为简洁起见截断)

图 7.22:获奖纪录片清单(为简洁起见截断)

有了这个,您已根据您的电影公司的要求检索了获奖纪录片清单。我们在本主题中看到,为了从聚合中获得最大价值,您需要设计、测试和不断重新评估您的管道。然而,先前列出的启发式只是设计有用的聚合的一小部分模式,因此建议您进行其他模式和程序的研究。

我们还看到了如何向aggregate命令传递一些选项,以帮助我们处理特定用例或处理可能需要更长时间的大型数据集。

活动 7.01:将聚合实践应用到实践中

在前几个练习中,电影公司对您使用聚合管道从数据中提取的见解印象深刻。然而,公司在管理不同的查询和将数据组合成有意义的结果方面遇到了麻烦。他们决定他们想要一个单一的、统一的聚合,总结他们即将举办的电影马拉松活动的基本信息。

您的目标是设计、测试和运行一个聚合管道,以创建这个统一视图。您应确保聚合的最终输出回答以下业务问题:

  • 对于每种流派,哪部电影获得了最多的奖项提名,假设它们至少赢得了其中一项提名?

  • 对于这些电影中的每一部电影,在每部电影之前都有 12 分钟的预告片,它们的附加运行时间是多少?

  • 关于这部电影的用户评论的例子。

  • 因为这是一场经典的电影马拉松,只有在 2001 年之前发布的电影才有资格。

  • 在所有流派中,列出获奖次数最多的所有流派。

您可以以任何方式完成此活动,但请尽量专注于创建一个简单而高效的聚合管道,以便将来进行调整或修改。有时最好尝试并决定输出文档可能是什么样子,然后从那里开始向后工作。

请记住,在测试时,您也可以选择使用$sample阶段来加快查询速度,但在最终解决方案中必须删除这些步骤。

为了保持所需的输出简单,将结果限制为此场景的三个文档。

以下步骤将帮助您完成此任务:

  1. 过滤掉在 2001 年之前未发布的任何文件。

  2. 筛选掉没有至少一次获奖的文件。

  3. 按奖项提名对文件进行排序。

  4. 将文档分组成流派。

  5. 获取每个组的第一部电影。

  6. 获取每个组的获奖总数。

  7. comments集合连接,获取每部电影的评论列表。

  8. 使用投影将每部电影的评论数量减少到一个。(提示:使用$slice运算符来减少数组长度。)

  9. 将每部电影的播放时间追加 12 分钟。

  10. 按获奖总数对结果进行排序。

  11. 限制三个文件。

期望的输出如下:

图 7.23:执行活动步骤后的最终输出

图 7.23:执行活动步骤后的最终输出

注意

可以通过此链接找到此活动的解决方案。

摘要

在本章中,我们已经涵盖了您需要了解、编写、理解和改进 MongoDB 聚合的所有基本组件。这种新功能将帮助您回答关于数据的更复杂和困难的问题。通过创建多阶段的管道,连接多个集合,您可以将查询范围扩大到整个数据库,而不仅仅是单个集合。我们还看了如何将结果写入新集合,以便进一步探索或操纵数据。

在最后一节中,我们介绍了确保编写的管道具有可扩展性、可读性和性能的重要性。通过专注于这些方面,您的管道将继续在未来提供价值,并可以作为进一步聚合的基础。

然而,我们在这里所涵盖的只是您可以通过聚合功能实现的开始。重要的是要不断探索、实验和测试您的管道,以真正掌握这项 MongoDB 技能。

在下一章中,我们将介绍如何在 Node.js 中使用 MongoDB 作为后端创建应用程序。即使您不是开发人员,这也将让您深入了解 MongoDB 应用程序的构建方式,以及对构建和执行动态查询的更深入理解。

第八章:在 MongoDB 中编写 JavaScript 代码

概述

在本章中,您将学习如何使用 Node.js 驱动程序阅读、理解和创建简单的 MongoDB 应用程序。这些应用程序将帮助您以编程方式获取、更新和创建 MongoDB 集合中的数据,以及处理错误和用户输入。在本章结束时,您将能够创建一个简单的基于 MongoDB 的应用程序。

介绍

到目前为止,我们直接使用 mongo shell 与 MongoDB 数据库进行了交互。这些直接的交互快速、简单,是学习或实验 MongoDB 功能的绝佳方式。然而,在许多生产情况下,将是软件代替用户连接到数据库。MongoDB 是一个很好的存储和查询数据的地方,但通常,它最重要的用途是作为大规模应用程序的后端。这些应用程序通常在某些条件或用户界面触发后以编程方式写入、读取和更新数据。

要将您的软件与数据库连接,通常会使用一个库(通常由数据库创建者提供)称为驱动程序。这个驱动程序将帮助您连接、分析、读取和写入数据库,而无需为简单操作编写多行代码。它提供了常见用例的函数和抽象,以及用于处理从数据库中提取的数据的框架。MongoDB 为不同的编程语言提供了几种不同的驱动程序,其中最流行的(也是我们将在本章中探讨的)是 Node.js 驱动程序(有时称为 Node)。

要将这与现实生活联系起来,想想您的在线购物体验。第一次从网站购买产品时,您必须输入所有的账单和送货细节。然而,如果您已经注册了一个账户,第二次去结账时,所有的细节都已经保存在网站上。这是一个很好的体验,而且在许多网站上,这是通过 Web 应用程序查询后端数据库来实现的。MongoDB 是可以支持这些应用程序的一个这样的数据库。

MongoDB 取得如此出色的增长和采用的主要原因之一是其成功说服软件开发人员选择它作为其应用程序的数据库。其中很大一部分说服力来自于 MongoDB 与 Node 的良好集成。

Node.js 已经成为基于 Web 的应用程序的主要语言之一,我们将在本章后面学习。然而,现在知道 Node 和 MongoDB 集成的便利性对两种技术都非常有益就足够了。这种共生关系还导致了大量成功的 Node/MongoDB 实现,从小型移动应用到大规模 Web 应用。在展示 MongoDB 驱动程序时,选择 Node.js 是首选。

根据您的工作角色,您可能负责编写针对 MongoDB 运行的应用程序,或者期望偶尔编写一行代码。然而,无论您的编程水平或专业责任如何,了解应用程序如何使用驱动程序与 MongoDB 集成将非常有价值。大多数 MongoDB 生产查询是由应用程序而不是人运行的。无论您是数据分析师、前端开发人员还是数据库管理员,您的生产环境很可能会使用 MongoDB 驱动程序之一。

注意

在本章的整个持续时间内,包括的练习和活动都是对一个情景的迭代。数据和示例都基于名为sample_mflix的 MongoDB Atlas 示例数据库。

在本章的整个持续时间内,我们将按照一个基于理论情景的一系列练习。这是我们在第七章“聚合”中涵盖的情景的扩展。

第七章“聚合”中构建场景的基础上,一个电影公司正在举办年度经典电影马拉松,并希望决定他们的放映计划应该是什么,他们需要满足特定标准的各种受欢迎的电影来满足他们的客户群。在探索数据并协助他们做出业务决策后,您为他们提供了新的见解。电影公司对您的建议感到满意,并决定让您参与他们的下一个项目。该项目涉及创建一个简单的 Node.js 应用程序,允许他们的员工查询电影数据库,而无需了解 MongoDB 并对应该在电影院放映哪些电影进行投票。在本章的过程中,您将创建此应用程序。

连接到驱动程序

在高层次上,使用 Node.js 驱动程序与 MongoDB 的过程类似于直接连接 shell。您将指定 MongoDB 服务器 URI、几个连接参数,并且可以对集合执行查询。这应该都很熟悉;主要区别在于这些指令将以 JavaScript 而不是 Bash 或 PowerShell 编写。

Node.js 简介

由于本章的目标不是学习 Node.js 编程,我们将简要介绍基础知识,以确保我们可以创建我们的 MongoDB 应用程序。Node.js 中的js代表JavaScript,因为 JavaScript 是 Node.js 理解的编程语言。JavaScript 通常在浏览器中运行。但是,您可以将 Node.js 视为在计算机上执行 JavaScript 文件的引擎。

在本章的过程中,您将编写 JavaScript(.js)语法,并使用 Node.js 执行它。虽然您可以使用任何文本编辑器编写 JavaScript 文件,但建议使用可以帮助您进行语法高亮和格式化的应用程序,例如Visual Studio CodeSublime

首先,让我们看一些示例代码:

// 1_Hello_World.js
var message = "Hello, Node!";
console.log(message);

让我们详细定义前面语法中的每个术语:

  • var关键字用于声明一个新变量;在本例中,变量名为message

  • =符号将此变量的值设置为一个名为Hello, Node!的字符串。

  • 在每个语句的末尾使用分号(;)。

  • console.log(message)是用于输出message值的函数。

如果您熟悉编程基础知识,您可能已经注意到我们不必将message变量显式声明为string。这是因为 JavaScript 是动态类型的,这意味着您不必显式指定变量类型(数字、字符串、布尔值等)。

如果您对编程基础知识不太熟悉,本章中的一些术语可能会使您感到困惑。因为这不是一本 JavaScript 编程书,这些概念不会被深入讨论。本章的目标是了解驱动程序如何与 MongoDB 交互;Node.js 的具体内容并不重要。尽管本章试图保持编程概念简单,但如果有什么复杂的地方,不要担心。

让我们尝试运行代码示例,将该代码保存到名为1_Hello_World.js的文件中,保存到我们当前的目录中,然后使用以下命令在我们的终端或命令提示符中运行该命令:

> node 1_Hello_World.js

您将看到一个看起来像这样的输出:

Section1> node 1_Hello_World.js
Hello, Node!
Section1>

如您所见,运行 Node.js 脚本非常简单,因为无需构建或编译,您可以编写代码并使用node调用它。

var关键字将信息存储在变量中,并在代码中稍后更改。但是,还有另一个关键字const,用于存储不会更改的信息。因此,在我们的示例中,我们可以用const关键字替换我们的var关键字。作为最佳实践,您可以将任何不会更改的内容声明为const

// 1_Hello_World.js
const message = "Hello, Node!";
console.log(message);

现在,让我们考虑函数和参数的结构。就像在 mongo shell 中的前几章查询的结构一样。首先,让我们考虑定义函数的以下示例:

var printHello = function(parameter) {
    console.log("Hello, " + parameter);
}
printHello("World")

以下是我们将在本章后面遇到的一些代码类型的预览。您可能会注意到,尽管这是一个更复杂的代码片段,但与您在早期章节(第四章查询文档,特别是)学到的 CRUD 操作有一些共同的元素,例如find命令的语法和 MongoDB URI:

// 3_Full_Example.js
const Mongo = require('mongodb').MongoClient;
const server = 'mongodb+srv://username:password@server-  abcdef.gcp.mongodb.net/test?retryWrites=true&w=majority'
const myDB   = 'sample_mflix'
const myColl = 'movies';
const mongo = new Mongo(server);
mongo.connect(function(err) {
    console.log('Our driver has connected to MongoDB!');
    const database = mongo.db(myDB);
    const collection = database.collection(myColl);
    collection.find({title: 'Blacksmith Scene'}).each(function(err, doc) {
        if(doc) {
            console.log('Doc returned: ')
            console.log(doc);
        } else {
            mongo.close();
            return false;
        }
    })
})

开始时可能有点令人生畏,但随着我们深入探讨本章,这将变得更加熟悉。正如我们之前提到的,即使它们看起来有些不同,您应该能够从 mongo shell 中识别出一些元素。代码中映射到 mongo shell 元素的一些元素如下:

  • collection对象,就像 shell 中的db.collection

  • 在我们的collection之后使用find命令,就像在 shell 中一样。

  • 我们find命令中的参数是一个文档过滤器,这正是我们在 shell 中使用的。

在 Node.js 中,函数声明是使用function(parameter){…}函数完成的,它允许我们创建可以多次运行的较小、可重用的代码片段,例如find()insertOne()函数。定义函数很容易;您只需使用function关键字,后跟函数的名称、括号中的参数和大括号来定义此函数的实际逻辑。

这是定义函数的代码。请注意,有两种方法可以做到这一点:您可以将函数声明为变量,也可以将函数作为参数传递给另一个函数。我们将在本章后面详细介绍这一点:

// 4_Define_Function.js
const newFunction = function(parameter1, parameter2) {
    // Function logic goes here.
    console.log(parameter1);
    console.log(parameter2);
}

获取 Node.js 的 MongoDB 驱动程序

安装 Node.js 的 MongoDB 驱动程序最简单的方法是使用npmnpm,或 node 包管理器,是一个用于添加、更新和管理 Node.js 程序中使用的不同包的包管理工具。在这种情况下,您要添加的包是 MongoDB 驱动程序,因此在存储脚本的目录中,在您的终端或命令提示符中运行以下命令:

> npm install mongo --save

安装包后可能会看到一些输出,如下所示:

图 8.1:使用 npm 安装 MongoDB 驱动程序

图 8.1:使用 npm 安装 MongoDB 驱动程序

就这么简单。现在,让我们开始针对 MongoDB 进行编程。

数据库和集合对象

在使用 MongoDB 驱动程序时,您可以使用三个主要组件进行大多数操作。在后面的练习中,我们将看到它们如何组合在一起,但在那之前,让我们简要介绍每个组件及其目的。

MongoClient是您在代码中必须创建的第一个对象。这代表您与 MongoDB 服务器的连接。将其视为 mongo shell 的等价物;您传入数据库的 URL 和连接参数,它将为您创建一个连接供您使用。要使用MongoClient,您必须在脚本顶部导入模块:

// First load the Driver module.
const Mongo = require('MongoDB').MongoClient;
// Then define our server.
const server = 'mongodb+srv://username:password@server-  abcdef.gcp.mongodb.net/test?retryWrites=true&w=majority';
// Create a new client.
const mongo = new Mongo(server);
// Connect to our server.
mongo.connect(function(err) {
    // Inside this block we are connected to MongoDB.
mongo.close(); // Close our connection at the end.
})

接下来是database对象。就像 mongo shell 一样,一旦建立连接,您可以针对服务器中的特定数据库运行命令。这个数据库对象还将确定您可以针对哪些集合运行查询:

…
mongo.connect(function(err) {
    // Inside this block we are connected to MongoDB.
    // Create our database object.
    const database = mongo.db(«sample_mflix»);
    mongo.close(); // Close our connection at the end.
})
…

在(几乎)每个基于 MongoDB 的应用程序中使用的第三个基本对象是collection对象。正如在 mongo shell 中一样,大多数常见操作将针对单个集合运行:

…
mongo.connect(function(err) {
    // Inside this block we are connected to MongoDB.
    // Create our database object.
    const database = mongo.db("sample_mflix");
    // Create our collection object
    const collection = database.collection("movies");
    mongo.close(); // Close our connection at the end.
})
…

databasecollection对象表达了与直接连接 mongo shell 相同的概念。在本章中,MongoClient仅用于创建和存储与服务器的连接。

重要的是要注意,这些对象之间的关系是MongoClient对象可以创建多个database对象,而database对象可以创建多个用于运行查询的collection对象:

图 8.2:驱动程序实体关系

图 8.2:驱动程序实体关系

上图是对前面段落中描述的实体关系的可视化表示。这里有一个MongoClient对象对应多个database对象,每个database对象可能有多个用于运行查询的collection对象。

连接参数

在编写代码之前,了解如何建立到MongoClient的连接是很重要的。创建新客户端时只有两个参数:服务器的 URL 和任何额外的连接选项。如果需要创建客户端,连接选项是可选的,如下所示:

const serverURL = 'mongodb+srv://username:password@server-  abcdef.gcp.mongodb.net/test';
const mongo = new Mongo(serverURL);
mongo.connect(function(err) {
    // Inside this block we are connected to MongoDB.
mongo.close(); // Close our connection at the end.
})

注意

callback. We will cover these in detail later in this chapter. For now, it is enough to use this pattern without having a more in-depth understanding.

与 mongo shell 一样,serverURL支持所有 MongoDB URI 选项,这意味着您可以在连接字符串本身中指定配置,而不是在第二个可选参数中;例如:

const serverURL = 'mongodb+srv://username:password@server-  abcdef.gcp.mongodb.net/test?retryWrites=true&w=majority';

为了简化这个字符串,可以在创建客户端时在第二个参数中指定许多这些 URI 选项(以及其他选项,例如 SSL 设置);例如:

const mongo = new Mongo(serverURL, {
     sslValidate: false
});
mongo.connect(function(err) {
     // Inside this block we are connected to MongoDB.
mongo.close(); // Close our connection at the end.
})

与 mongo shell 一样,有许多配置选项,包括 SSL、身份验证和写入关注选项。然而,大部分超出了本章的范围。

注意

请记住,您可以在 cloud.mongodb.com 的用户界面中找到 Atlas 的完整连接字符串。您可能希望复制此连接字符串,并在所有脚本中使用它作为serverURL

让我们通过练习学习如何与 Node.js 驱动程序建立连接。

练习 8.01:使用 Node.js 驱动程序创建连接

在开始这个练习之前,回顾一下介绍部分中概述的电影公司。您可能还记得电影公司希望有一个 Node.js 应用程序,允许用户查询和更新电影数据库中的记录。为了实现这一点,您的应用程序首先需要建立与服务器的连接。可以通过执行以下步骤来完成:

  1. 首先,在您当前的工作目录中,创建一个名为Exercise8.01.js的新 JavaScript 文件,并在您选择的文本编辑器(Visual Studio Code、Sublime 等)中打开它:
    > node Exercise8.01.js
    ```

1.  通过将以下行添加到文件顶部,将 MongoDB 驱动程序库(如本章前面所述)导入到您的脚本文件中:

```js
    const MongoClient = require('mongodb').MongoClient;
    ```

注意

如果您在本章早期没有安装 npm MongoDB 库,现在应该运行`npm install mongo --save`在命令提示符或终端中进行安装。在与您的脚本相同的目录中运行此命令。

1.  创建一个包含您的 MongoDB 服务器的 URL 的新变量:

```js
    const url = 'mongodb+srv://username:password@server-  abcdef.gcp.mongodb.net/test';
    ```

1.  创建一个名为`client`的新`MongoClient`对象,使用`url`变量:

```js
    const client = new MongoClient(url);
    ```

1.  使用以下方式打开到 MongoDB 的连接`connect`函数:

```js
    client.connect(function(err) {
         …
    })
    ```

1.  在连接块中添加一个`console.log()`消息,以确认连接已打开:

```js
    console.log('Connected to MongoDB with NodeJS!');
    ```

1.  最后,在连接块的末尾,使用以下语法关闭连接:

```js
    client.close(); // Close our connection at the end.
    ```

您的完整脚本应如下所示:

```js
    // Import MongoDB Driver module.
    const MongoClient = require('mongodb').MongoClient;
    // Create a new url variable.
    const url = 'mongodb+srv://username:password@server-  abcdef.gcp.mongodb.net/test';
    // Create a new MongoClient.
    const client = new MongoClient(url);
    // Open the connection using the .connect function.
    client.connect(function(err) {
        // Within the connection block, add a console.log to confirm the       connection
        console.log('Connected to MongoDB with NodeJS!');
        client.close(); // Close our connection at the end.
    })
    ```

使用`node Exercise8.01.js`执行代码后,将生成以下输出:

```js
    Chapter8> node Excercise8.01.js
    Connected to MongoDB with NodeJS!
    Chapter8>
    ```

在这个练习中,您使用 Node.js 驱动程序建立了与服务器的连接。

# 执行简单查询

现在我们已经连接到 MongoDB,可以对数据库运行一些简单的查询。在 Node.js 驱动程序中运行查询与在 shell 中运行查询非常相似。到目前为止,您应该熟悉 shell 中的`find`命令:

```js
db.movies.findOne({})

以下是驱动程序中find命令的语法:

collection.find({title: 'Blacksmith Scene'}).each(function(err, doc) { … }

正如您所看到的,一般结构与您在 mongo shell 中执行的find命令相同。在这里,我们从数据库对象中获取一个集合,然后针对该集合运行带有查询文档的find命令。这个过程本身很简单。主要的区别在于我们如何构造我们的命令以及如何处理驱动程序返回的结果。

在编写 Node.js 应用程序时,一个关键的问题是确保您的代码以一种易于修改、扩展或理解的方式编写,无论是将来您自己还是其他专业人士可能需要在应用程序上工作。

创建和执行 find 查询

Exercise 8.01中的代码,使用 Node.js 驱动程序创建连接,作为参考,因为它已经包含了连接:

const MongoClient = require('mongodb').MongoClient;
// Replace this variable with the connection string for your server, provided by   MongoDB Atlas.
const url = 'mongodb+srv://username:password@server-abcdef.gcp.mongodb.net/test';
const client = new MongoClient(url);
client.connect(function(err) {
    console.log('Connected to MongoDB with NodeJS!');
    // OUR CODE GOES BELOW HERE
    // AND ABOVE HERE
    client.close();
})

我们的查询逻辑将在这里添加:

    // OUR CODE GOES BELOW HERE
    // AND ABOVE HERE

现在,我们已经连接到了 MongoDB 服务器。但是,还有两个重要的对象——dbcollection。让我们创建我们的数据库对象(用于sample_mflix数据库),如下所示:

    // OUR CODE GOES BELOW HERE
    const database = client.db("sample_mflix")
    // AND ABOVE HERE

现在我们有了我们的database对象。在 mongo shell 中发送查询时,您必须将文档作为命令的过滤器传递给您的文档。这在 Node.js 驱动程序中也是一样的。您可以直接传递文档。但是,建议将过滤器单独定义为变量,然后再分配一个值。您可以在以下代码片段中看到差异:

// Defining filter first.
var filter = { title: 'Blacksmith Scene'};
database.collection("movies").find(filter).toArray(function(err, docs) { });
// Doing everything in a single line.
database.collection("movies").find({title: 'Blacksmith   Scene'}).toArray(function(err, docs) {});

与 mongo shell 一样,您可以将空文档作为参数传递以查找所有文档。您可能还注意到我们的find命令末尾有toArray。这是因为,默认情况下,find命令将返回一个游标。我们将在下一节中介绍游标,但与此同时,让我们看看这个完整脚本会是什么样子:

const MongoClient = require('mongodb').MongoClient;
// Replace this variable with the connection string for your server, provided by   MongoDB Atlas.
const url = 'mongodb+srv://mike:password@myAtlas-  fawxo.gcp.mongodb.net/test?retryWrites=true&w=majority'
const client = new MongoClient(url);
client.connect(function(err) {
    console.log('Connected to MongoDB with NodeJS!');
    const database = client.db("sample_mflix");
    var filter = { title: 'Blacksmith Scene'};
    database.collection("movies").find(filter).toArray(function(err, docs) {
        console.log('Docs results:');
        console.log(docs);
     });
    client.close();
})

如果您将此修改后的脚本保存为2_Simple_Find.js并使用命令node 2_Simple_Find.js运行它,将会得到以下输出:

图 8.3:上述片段的输出(为简洁起见而截断)

图 8.3:上述片段的输出(为简洁起见而截断)

上述输出与通过 mongo shell 而不是驱动程序执行的 MongoDB 查询的输出非常相似。在通过驱动程序执行查询时,我们已经了解到,尽管语法可能与 mongo shell 不同,但查询及其输出中的基本元素是相同的。

使用游标和查询结果

在前面的示例中,我们使用toArray函数将我们的查询输出转换为一个可以用console.log输出的数组。当处理少量数据时,这是一种简单的处理结果的方法;然而,对于较大的结果集,您应该使用游标。您应该对游标有一定的了解,这是从第五章插入、更新和删除文档中的 mongo shell 查询中得到的。在 mongo shell 中,您可以使用it命令来遍历游标。在 Node.js 中,有许多访问游标的方式,其中三种更常见的模式如下:

  • toArray:这将获取查询的所有结果并将它们放入一个单一的数组中。这很容易使用,但当您期望从查询中获得大量结果时,效率不是很高。在以下代码中,我们针对电影集合运行find命令,然后使用toArray将数组中的第一个元素记录到控制台中:
    database.collection("movies").find(filter).toArray(function(err, docsArray) {
        console.log('Docs results as an array:');
        console.log(docsArray[0]); // Print the first entry in the array.
     });
    ```

+   `each`:这将逐个遍历结果集中的每个文档。如果您想要检查或使用结果中的每个文档,这是一个很好的模式。在以下代码片段中,我们针对电影集合运行`find`命令,使用`each`记录返回的每个文档,直到没有文档为止:

```js
    database.collection("movies").find(filter).each(function(err, doc) {
        if(doc) {
            console.log('Current doc');
            console.log(doc);
        } else {
            client.close(); // Close our connection.
            return false;   // End the each loop.
        }
     });
    ```

当没有更多文档返回时,文档将等于`null`。因此,每次检查新文档时,检查文档是否存在(使用`if(doc)`)是很重要的。

+   `next`:这将允许你访问结果集中的下一个文档。如果你只想要一个单独的文档或结果的子集,而不必遍历整个结果,这是最好的模式。在下面的代码片段中,我们对电影集合运行了一个`find`命令,使用`next`获取返回的第一个文档,然后将该文档输出到控制台:

```js
    database.collection("movies").find(filter).next(function(err, doc) {
        console.log("First doc in the cursor");
        console.log(doc);
     });
    ```

因为`next`一次只返回一个文档,在这个例子中,我们运行了三次来检查前三个文档。

在本章的示例、练习和活动中,我们将学习这三种方法是如何被使用的。然而,需要注意的是还有其他更高级的模式。

你也可以通过在`find(…)`之后放置这些命令来实现相同的`sort`和`limit`功能,这应该是你在 shell 中以前查询时熟悉的:

```js
database.collection("movies").find(filter).limit(5).sort([['title', 1]]).next   (function(err, doc) {…}

练习 8.02:构建一个 Node.js Driver 查询

在这个练习中,你将在练习 8.01的场景上进行构建,使用 Node.js Driver 创建连接,这允许你连接到 mongo 服务器。如果你要交付一个 Node.js 应用程序,允许电影院员工查询和对电影投票,你的脚本将需要根据给定的条件查询数据库,并以易于阅读的格式返回结果。对于这种情况,你必须获取以下查询的结果:

查找两部浪漫类电影,只投影每部电影的标题。

你可以通过执行以下步骤在 Node.js 中实现这一点:

  1. 创建一个名为Exercise8.02.js的新的 JavaScript 文件。

  2. 为了不必从头开始重写所有内容,将Exercise8.01.js的内容复制到你的新脚本中。否则,在你的新文件中重写连接代码。

  3. 为了保持代码整洁,创建新的变量来存储databaseNamecollectionName。记住,由于这些在整个脚本中不会改变,你必须使用const关键字将它们声明为常量:

    const databaseName = "sample_mflix";
    const collectionName = "movies";
    ```

1.  现在,创建一个新的`const`来存储我们的查询文档;你应该熟悉从之前的章节中创建这些:

```js
    const query = { genres: { $all: ["Romance"]} };
    ```

1.  定义好所有的变量后,创建我们的数据库对象:

```js
    const database = client.db(databaseName);
    ```

现在,你可以使用以下语法发送你的查询。使用`each`模式,传递一个回调函数来处理每个文档。如果这看起来奇怪,不要担心;你将在接下来的部分中详细了解这个。记得使用`limit`只返回两个文档和`project`只输出`title`,因为它们是我们场景的要求:

```js
    database.collection(collectionName).find(query).limit(2).project({title:   1}).each(function(err, doc) {
        if(doc) {

        } else {
            client.close(); // Close our connection.
            return false;   // End the each loop.
        }
     });
    ```

1.  在你的回调函数中,使用`console.log`输出我们的查询返回的每个文档:

```js
    if(doc){
               console.log('Current doc');
               console.log(doc);
    }  
    ```

你的最终代码应该像这样:

```js
    const MongoClient = require('mongodb').MongoClient;
    const url = 'mongodb+srv://username:password@server-  abcdef.gcp.mongodb.net/test';
    const client = new MongoClient(url);
    const databaseName = "sample_mflix";
    const collectionName = "movies";
    const query = { genres: { $all: ["Romance"]} };
    // Open the connection using the .connect function.
    client.connect(function(err) {
        // Within the connection block, add a console.log to confirm the       connection
        console.log('Connected to MongoDB with NodeJS!');
        const database = client.db(databaseName);
        database.collection(collectionName).find(query).limit(2).project({title:      1}).each(function(err, doc) {
            if(doc) {
                console.log('Current doc');
                console.log(doc);
            } else {
                client.close(); // Close our connection.
                return false;   // End the each loop.
            }
         });
    })
    ```

1.  现在,使用`node Exercise8.02.js`运行脚本。你应该得到以下输出:

```js
    Connected to MongoDB with NodeJS!
    Our database connected alright!
    Current doc
    { _id: 573a1390f29313caabcd548c, title: 'The Birth of a Nation' }
    Current doc
    { _id: 573a1390f29313caabcd5b9a, title: "Hell's Hinges" }
    ```

在这个练习中,你构建了一个 Node.js 程序,对 MongoDB 执行查询,并将结果返回到控制台。虽然这是一个小步骤,我们可以很容易地在 mongo shell 中完成,但这个脚本将作为更高级和交互式的 Node.js 应用程序的基础。

# 在 Node.js 中的回调和错误处理

所以,我们已经成功打开了与 MongoDB 的连接并运行了一些简单的查询,但可能有一些代码元素看起来不太熟悉;例如,这里的语法:

```js
.each(function(err, doc) {
        if(doc) {
            console.log('Current doc');
            console.log(doc);
        } else {
            client.close(); // Close our connection.
            return false;   // End the each loop.
        }
     });

这就是所谓的MongoClient,一旦它完成了自己的内部逻辑,它应该执行我们作为第二个参数传递的函数中的代码。第二个参数被称为回调。回调是额外的函数(代码块),作为参数传递给另一个首先执行的函数。

回调允许您指定仅在函数完成后执行的逻辑。我们必须在 Node.js 中使用回调的原因是 Node.js 是异步的,这意味着当我们调用诸如connect之类的函数时,它不会阻塞执行。脚本中的下一个内容将被执行。这就是为什么我们使用回调的原因:确保我们的下一步等待连接完成。除了回调之外,还有其他现代模式可以用来替代回调,例如promisesawait/async。但是,考虑到本书的范围,我们将只在本章中涵盖回调,并学习如何处理驱动程序返回的错误。

Node.js 中的回调

回调通常在视觉上令人困惑且难以概念化;但是,从根本上讲,它们非常简单。回调是作为第二个函数的参数提供的函数,这允许两个函数按顺序运行。

不使用回调函数(或任何其他同步模式),两个函数将在彼此之后立即开始执行。使用驱动程序时,这会创建错误,因为第二个函数可能依赖于第一个函数在开始之前完成。例如,在连接建立之前,您无法查询数据。让我们来看一下回调的分解:

图 8.4:回调的分解

图 8.4:回调的分解

现在,将此与我们的find查询代码进行比较:

图 8.5:MongoDB 回调的分解

图 8.5:MongoDB 回调的分解

您可以看到,相同的结构存在,只是回调函数的参数不同。您可能想知道我们如何知道在特定回调中使用哪些参数。答案是,我们传递给回调函数的参数由我们提供回调函数的第一个函数确定。也许这是一个令人困惑的句子,但它的意思是:当将函数 fA 作为参数传递给第二个函数 fB 时,fA 的参数由 fB 提供。让我们再次检查我们的实际示例,以确保我们理解这一点:

database.collection(collectionName).find(query).limit(2).project({title: 1}).each   (function(err, doc) {
        if(doc) {
            console.log('Current doc');
            console.log(doc);
        } else {
            client.close(); // Close our connection.
            return false;   // End the each loop.
        }
     });

因此,我们的回调函数function(err, doc) { … }作为参数提供给驱动程序函数each。这意味着each将为结果集中的每个文档运行我们的回调函数,为每次执行传递err(错误)和doc(文档)参数。以下是相同的代码,但添加了一些日志以演示执行顺序:

console.log('This will execute first.')
database.collection(collectionName).find(query).limit(2).project({title: 1}).each   (function(err, doc) {
console.log('This will execute last, once for each document in the result.')
        if(doc) {
        } else {
            client.close(); // Close our connection.
            return false;   // End the each loop.
        }
     });
console.log('This will execute second.');

如果我们使用node 3_Callbacks.js运行此代码,我们可以在输出中看到执行顺序:

Connected to MongoDB with NodeJS!
This will execute first.
This will execute second.
This will execute last, once for each doc.
This will execute last, once for each doc.
This will execute last, once for each doc.

回调有时是复杂的模式,需要熟悉,并且越来越多地被更高级的 Node.js 模式(例如promisesasync/await)所取代。熟悉这些模式的最佳方法是使用它们,因此如果您对它们还不太熟悉,不用担心。

Node.js 中的基本错误处理

当我们检查回调时,您可能已经注意到我们尚未描述的参数:err。在 MongoDB 驱动程序中,大多数在 mongo shell 中可能返回错误的命令也可以在驱动程序中返回错误。在回调的情况下,err参数将始终存在;但是,如果没有错误,则err的值为null。在 NodeJS 中捕获异步代码中的错误的“错误优先”模式是标准做法。

例如,假设您创建了一个应用程序,将用户的电话号码输入客户数据库,两个不同的用户输入相同的电话号码。当您尝试运行插入时,MongoDB 将返回重复键错误。此时,作为 Node.js 应用程序的创建者,您有责任正确处理该错误。要检查查询中的任何错误,我们可以检查err是否不为null。您可以使用以下语法轻松检查:

database.collection(collectionName).find(query).limit(2).project({title: 1}).each   (function(err, doc) {
        if(err) {
            console.log('Error in query.');
            console.log(err);
            client.close();
            return false;
        }
        else if(doc) {
            console.log('Current doc');
            console.log(doc);
        } else {
            client.close(); // Close our connection.
            return false;   // End the each loop.
        }
     });

您可能会注意到,这与我们在使用each时检查是否有更多文档时使用的语法相同。类似于我们检查查询的错误,我们的客户端中的connect函数也会向我们的callback函数提供一个错误,这在运行任何进一步的逻辑之前应该进行检查:

// Open the connection using the .connect function.
client.connect(function(err) {
    if(err) {
        console.log('Error connecting!');
        console.log(err);
        client.close();
    } else {
        // Within the connection block, add a console.log to confirm the           connection
        console.log('Connected to MongoDB with NodeJS!');
        client.close(); // Close our connection at the end.
    }
})

注意

在尝试使用参数之前,最好使用回调来检查传递的参数。在find命令的情况下,这意味着检查是否有错误并检查是否返回了文档。在针对 MongoDB 编写代码时,最好验证从数据库返回的所有内容,并记录错误以进行调试。

但我们不仅可以在回调中验证代码的准确性。我们还可以检查非回调函数,以确保一切顺利,例如当我们创建我们的database对象时:

    const database = client.db(databaseName);
    if(database) {
        console.log('Our database connected alright!');
    }

根据您尝试使用 MongoDB 实现的目标,您的错误处理可能像前面的示例那样简单,或者您可能需要更复杂的逻辑。但是,在本章的范围内,我们只会看一下基本的错误处理。

练习 8.03:使用 Node.js 驱动程序进行错误处理和回调

Exercise 8.02中,构建 Node.js 驱动程序查询,您创建了一个成功连接到 MongoDB 服务器并生成查询结果的脚本。在这个练习中,您将向您的代码添加错误处理——这意味着如果出现任何问题,它可以帮助您识别或修复问题。您将通过修改查询来测试此处理,以使其失败。您可以通过以下步骤在 Node.js 中实现这一点:

  1. 创建一个名为Exercise8.03.js的新 JavaScript 文件。

  2. 为了不必从头开始重写所有内容,将Exercise8.02.js的内容复制到您的新脚本中。否则,在新文件中重写连接和查询代码。

  3. 在连接回调中,检查err参数。如果您有错误,请确保使用console.log输出它:

    client.connect(function(err) {
        if(err) {
            console.log('Failed to connect.');
            console.log(err);
            return false;
        }
        // Within the connection block, add a console.log to confirm the       connection
        console.log('Connected to MongoDB with NodeJS!');
    ```

1.  在运行查询之前添加一些错误检查,以确保数据库对象已成功创建。如果您有错误,请使用`console.log`输出它。使用`!`语法来检查某些东西是否不存在:

```js
        const database = client.db(databaseName);
        if(!database) {
            console.log('Database object doesn't exist!');
            return false;
        }
    ```

1.  在`each`回调中,检查`err`参数,确保每个文档都没有错误地返回:

```js
        database.collection(collectionName).find(query).limit(2).project({title: 1}).each(function(err, doc) {
            if(err) {
                console.log('Query error.');
                console.log(err);
                client.close();
                return false;
            }
            if(doc) {
                console.log('Current doc');
                console.log(doc);
            } else {
                client.close(); // Close our connection.
                return false;   // End the each loop.
            }
         });
    ```

此时,您的整个代码应该如下所示:

```js
    const MongoClient = require('mongodb').MongoClient;
    const url = 'mongodb+srv://username:password@server-  fawxo.gcp.mongodb.net/test?retryWrites=true&w=majority';
    const client = new MongoClient(url);
    const databaseName = "sample_mflix";
    const collectionName = "movies";
    const query = { genres: { $all: ["Romance"]} };
    // Open the connection using the .connect function.
    client.connect(function(err) {
        if(err) {
            console.log('Failed to connect.');
            console.log(err);
            return false;
        }
        // Within the connection block, add a console.log to confirm the       connection
        console.log('Connected to MongoDB with NodeJS!');
        const database = client.db(databaseName);
        if(!database) {
            console.log('Database object doesn't exist!');
            return false;
        }
        database.collection(collectionName).find(query).limit(2).project({title:      1}).each(function(err, doc) {
            if(err) {
                console.log('Query error.');
                console.log(err);
                client.close();
                return false;
            }
            if(doc) {
                console.log('Current doc');
                console.log(doc);
            } else {
                client.close(); // Close our connection.
                return false;   // End the each loop.
            }
         });
    })
    ```

1.  在添加错误之前,使用 node `Exercise8.03.js`运行脚本。您应该会得到以下输出:

```js
    Connected to MongoDB with NodeJS!
    Current doc
    { _id: 573a1390f29313caabcd548c, title: 'The Birth of a Nation' }
    Current doc
    { _id: 573a1390f29313caabcd5b9a, title: "Hell's Hinges" }
    ```

1.  修改查询以确保产生错误:

```js
    const query = { genres: { $thisIsNotAnOperator: ["Romance"]} };
    ```

1.  使用 node `Exercise8.03.js`运行脚本。您应该会得到以下输出:![图 8.6:脚本运行后的输出(为简洁起见进行了截断)](https://gitee.com/OpenDocCN/freelearn-bigdata-zh/raw/master/docs/mongo-fund/img/B15507_08_06.jpg)

图 8.6:脚本运行后的输出(为简洁起见进行了截断)

在这个练习中,您扩展了您的 Node.js 应用程序,以便在 Node.js 环境中运行 MongoDB 查询时捕获和处理可能遇到的错误。这将使您能够创建更健壮、容错和可扩展的应用程序。

# 高级查询

在上一节中,我们连接到了 MongoDB 服务器,查询了一些数据,输出了它,并处理了我们遇到的任何错误。但是,如果应用程序或脚本只能执行读取操作,那么它的实用性将受到限制。在本节中,我们将在 MongoDB 驱动程序中应用`write`和`update`操作。此外,我们将研究如何使用函数语法为我们的最终应用程序创建可重用的代码块。

## 使用 Node.js 驱动程序插入数据

与 mongo shell 类似,我们可以使用`insertOne`或`insertMany`函数将数据写入我们的集合。这些函数在集合对象上调用。我们需要将单个文档传递给这些函数,或者在`insertMany`的情况下,需要传递文档数组。以下是一个包含如何使用带有回调的`insertOne`和`insertMany`的代码片段。到目前为止,您应该能够认识到这是一个不完整的代码片段。要执行以下代码,您需要添加我们在本章前面学到的基本连接逻辑。现在这应该看起来非常熟悉:

```js
    database.collection(collectionName).insertOne({Hello:      "World"}, function(err, result) {
        // Handle result.
    })
    database.collection(collectionName).insertMany([{Hello: "World"},       {Hello: "Mongo"}], function(err, result) {
        // Handle result.
    })

find一样,我们将回调传递给这些函数以处理操作的结果。插入操作将返回一个错误(可能为null)和一个结果,其中详细说明了插入操作的执行方式。例如,如果我们要构建在先前练习的结果之上,并记录insertMany操作的结果,那么将产生以下输出:

    database.collection(collectionName).insertOne({Hello: "World"},       function(err, result) {
        console.log(result.result);
   client.close();
    })

我们可能会在输出中看到一个像图 8.7那样的result对象。

注意

我们只输出了整个result对象的一个子集,其中包含有关我们操作的更多信息。例如,我们正在记录result.result,这是整个result对象中的一个子文档。这仅适用于本示例的范围。在其他用例中,您可能需要更多关于操作结果的信息:

图 8.7:显示整个结果对象的子集的输出

图 8.7:显示整个结果对象的子集的输出

使用 Node.js 驱动程序更新和删除数据

使用驱动程序更新和删除文档遵循与insert函数相同的模式,其中collection对象通过回调传递,检查错误,并分析操作的结果。所有这些函数都将返回一个结果文档。但是,在这三个操作之间,结果文档中包含的格式和信息可能会有所不同。让我们看一些示例。

以下是一些示例代码的示例(也建立在我们之前的连接代码之上),用于更新文档。我们可以使用updateOneupdateMany

    database.collection(collectionName).updateOne({Hello: "World"}, {$set: {Hello       : "Earth"}}, function(err, result) {
        console.log(result.modifiedCount);
        client.close();
    })

如果我们运行这段代码,我们的输出结果可能如下所示:

Connected to MongoDB with NodeJS!
1

现在,让我们看一个删除文档的示例。与我们的其他函数一样,我们可以使用deleteOnedeleteMany。请记住,此代码片段作为我们为Exercise 8.03创建的较大代码的一部分存在,Node.js 驱动程序中的错误处理和回调

    database.collection(collectionName).deleteOne({Hello: "Earth"}, function(err, result) {
        console.log(result.deletedCount);
        client.close();
    })

如果我们运行这段代码,我们的输出将如下所示:

Connected to MongoDB with NodeJS!
1

正如您所看到的,所有这些操作都遵循类似的模式,并且在结构上非常接近您将发送到 mongo shell 的相同命令。主要区别在于回调,我们可以在操作结果上运行自定义逻辑。

编写可重用的函数

到目前为止,在我们的示例和练习中,我们总是执行单个操作并输出结果。但是,在更大,更复杂的应用程序中,您将希望在同一程序中运行许多不同的操作,具体取决于上下文。例如,在您的应用程序中,您可能希望多次运行相同的查询并比较各自的结果,或者您可能希望根据第一个查询的输出修改第二个查询。

这就是我们将创建自己的函数的地方。您已经编写了一些函数用作回调,但在这种情况下,我们将编写可以随时调用的函数,无论是用于实用程序还是保持代码清晰和分离。让我们看一个例子。

让我们通过以下代码片段更好地理解这一点,该代码片段运行了三个非常相似的查询。这些查询之间唯一的区别是每个查询中的一个参数(评分):

database.collection(collectionName).find({name: "Matthew"}).each(function(err,   doc) {});
database.collection(collectionName).find({name: "Mark"}).each(function(err, doc)   {});
database.collection(collectionName).find({name: "Luke"}).each(function(err, doc)   {})

让我们尝试用一个函数简化和清理这段代码。我们使用与变量相同的语法声明一个新函数。因为这个函数不会改变,我们可以将它声明为const。对于函数的值,我们可以使用我们在之前的示例中(本章早期的回调部分的示例)已经熟悉的语法:

const findByName = function(name) {

}

现在,让我们在花括号之间为这个函数添加逻辑:

const findByName = function(name) {
    database.collection(collectionName).find({name:       name}).each(function(err, doc) {})
}

但是有些地方不太对。我们在创建数据库对象之前引用了数据库对象。我们将不得不将该对象作为参数传递给这个函数,所以让我们调整我们的函数来做到这一点:

const findByName = function(name, database) {
    database.collection(collectionName).find({name: name}).each(function(err,       doc) {})
}

现在,我们可以用三个函数调用来替换我们的三个查询:

const findByName = function(name, database) {
    database.collection(collectionName).find({name: name}).each(function(err, doc       ) {})
}
findByName("Matthew", database);
findByName("Mark", database);
findByName("Luke", database);

在本章中,为了简单起见,我们不会过多地讨论创建模块化、功能性的代码。但是,如果您想进一步改进这段代码,您可以使用数组和for循环来为每个值运行函数,而不必调用它三次。

练习 8.04:使用 Node.js 驱动程序更新数据

考虑介绍部分的情景,您已经从起点取得了相当大的进展。您的最终应用程序需要能够通过运行更新操作向电影添加投票。但是,您还没有准备好添加这个逻辑。不过,为了证明您能够做到这一点,编写一个脚本,更新数据库中的几个不同文档,并创建一个可重用的函数来实现这一点。在这个练习中,您需要更新chapter8_Exercise4集合中的以下名称。您将使用这个唯一的集合来确保在更新期间不会损坏其他活动的数据:

Ned Stark to Greg Stark, Robb Stark to Bob Stark, and Bran Stark to Brad Stark.

您可以通过执行以下步骤在 Node.js 中实现这一点:

  1. 首先,确保正确的文档存在以进行更新。直接使用 mongo shell 连接到服务器,并执行以下代码片段来检查这些文档:
    db.chapter8_Exercise4.find({ $or: [{name: "Ned Stark"}, {name: "Robb Stark"}, {name: "Bran Stark"}]});
    ```

1.  如果前面查询的结果为空,请使用此片段添加要更新的文档:

```js
    db.chapter8_Exercise4.insert([{name: "Ned Stark"}, {name: "Bran Stark"}, {name: "Robb Stark"}]);
    ```

1.  现在,要创建脚本,请退出 mongo shell 连接,并创建一个名为`Exercise8.04.js`的新 JavaScript 文件。这样您就不必从头开始重写所有内容,只需将`Exercise8.03.js`的内容复制到新脚本中。否则,请在新文件中重写连接代码。如果您从*Exercise 8.03*,*使用 Node.js 驱动程序处理错误和回调*中复制了代码,则删除查找查询的代码。

1.  将集合从电影更改为`chapter8_Exercise4`:

```js
    const collectionName = "chapter8_Exercise4";
    ```

1.  在脚本开始之前,在连接之前,创建一个名为`updateName`的新函数。这个函数将以数据库对象、客户端对象以及`oldName`和`newName`作为参数:

```js
    const updateName = function(client, database, oldName, newName) {
    }
    ```

1.  在`updateName`函数中,添加运行更新命令的代码,该命令将更新包含名为`oldName`的字段的文档,并将该值更新为`newName`:

```js
    const updateName = function(client, database, oldName, newName) {
        database.collection(collectionName).updateOne({name: oldName}, {$set: {name: newName}}, function(err, result) {
            if(err) {
                console.log('Error updating');
                console.log(err);
                client.close();
                return false;
            }
            console.log('Updated documents #:');
            console.log(result.modifiedCount);
            client.close();
        })
    };
    ```

1.  现在,在连接回调中,运行您的新函数三次,分别为要更新的三个名称运行一次:

```js
        updateName(client, database, "Ned Stark", "Greg Stark");
        updateName(client, database, "Robb Stark", "Bob Stark");
        updateName(client, database, "Bran Stark", "Brad Stark");
    ```

1.  此时,您的整个代码应该如下所示:

```js
    const MongoClient = require('mongodb').MongoClient;
    const url = 'mongodb+srv://mike:password@myAtlas-fawxo.gcp.mongodb.net/test?retryWrites=true&w=majority';
    const client = new MongoClient(url);
    const databaseName = "sample_mflix";
    const collectionName = "chapter8_Exercise4";
    const updateName = function(client, database, oldName, newName) {
        database.collection(collectionName).updateOne({name: oldName}, {$set: {name: newName}}, function(err, result) {
            if(err) {
                console.log('Error updating');
                console.log(err);
                client.close();
                return false;
            }
            console.log('Updated documents #:');
            console.log(result.modifiedCount);
            client.close();
        })
    };
    // Open the connection using the .connect function.
    client.connect(function(err) {
        if(err) {
            console.log('Failed to connect.');
            console.log(err);
            return false;
        }
        // Within the connection block, add a console.log to confirm the connection
        console.log('Connected to MongoDB with NodeJS!');
        const database = client.db(databaseName);
        if(!database) {
            console.log('Database object doesn't exist!');
            return false;
        }

        updateName(client, database, "Ned Stark", "Greg Stark");
        updateName(client, database, "Robb Stark", "Bob Stark");
        updateName(client, database, "Bran Stark", "Brad Stark");
    })
    ```

1.  使用`node Exercise8.04.js`运行脚本。您应该会得到以下输出:

```js
    Connected to MongoDB with NodeJS!
    Updated documents #:
    1
    Updated documents #:
    1
    Updated documents #:
    1
    ```

在过去的四个部分中,您已经学会了如何创建一个连接到 MongoDB 的 Node.js 脚本,运行易于使用的函数进行查询,并处理我们可能遇到的任何错误。这为您搭建了一个基础,可以用它来构建许多脚本,以使用您的 MongoDB 数据库执行复杂的逻辑。然而,在我们迄今为止的示例中,我们的查询参数总是硬编码到我们的脚本中,这意味着我们的每个脚本只能满足特定的用例。

这并不理想。像 Node.js 驱动程序这样的强大之处之一是能够拥有一个解决大量问题的单个应用程序。为了扩大我们脚本的范围,我们将接受用户输入来创建动态查询,能够解决用户的问题,而无需重写和分发我们程序的新版本。在本节中,我们将学习如何接受用户输入、处理它,并从中构建动态查询。

注意

在大多数大型、生产就绪的应用程序中,用户输入将以**图形用户界面**(**GUI**)的形式出现。这些 GUI 将简单的用户选择转换为复杂的、相关的查询。然而,构建 GUI 是非常棘手的,超出了本书的范围。

## 从命令行读取输入

在本节中,我们将从命令行获取输入。幸运的是,Node.js 为我们提供了一些简单的方法来从命令行读取输入并在我们的代码中使用它。Node.js 提供了一个名为 `readline` 的模块,它允许我们向用户请求输入、接受输入,然后使用它。您可以通过在文件顶部添加以下行来将 `readline` 加载到您的脚本中。在使用 `readline` 时,您必须始终创建一个接口:

```js
const readline = require('readline');
const interface = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
});

现在,我们可以要求用户输入一些内容。 readline 为我们提供了多种处理输入的方式。然而,现在最简单的方法是使用 question 函数,就像这里的例子一样:

interface.question('Hello, what is your name? ', (input) => {
    console.log(`Hello, ${input}`);
    interface.close();
  });

注意

${input} 语法允许我们在字符串中嵌入一个变量。在使用时,请确保使用反引号,`(如果您不确定在标准 QWERTY 键盘上哪里可以找到它,它与1键左侧的~符号共享一个键。)

如果我们运行这个示例,我们将得到类似这样的输出:

Chapter_8> node example.js 
Hello, what is your name? Michael
Hello, Michael

如果你想创建一个更长的提示,最好使用console.log来输出大部分输出,然后只提供一个较小的readline问题。例如,假设我们在询问用户输入之前发送了一条长消息。我们可以将其定义为变量,并在询问问题之前记录它:

const question = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum?"
interface.question(question, (input) => {
    console.log(`Hello, ${input}`);
    interface.close();
  });

通过这种方式,我们可以轻松地修改和在多个输入中重用我们的消息。

注意

在 Node.js 中处理输入有许多不同的库和模块。然而,为了简化,我们将在本章中使用readline

创建交互式循环

因此,我们有一种简单的方法来询问用户问题并接受他们的输入。然而,如果每次我们想要使用它时都必须从命令行运行我们的应用程序,那么它不会非常有用。如果我们能运行一次程序,并根据不同的输入执行多次运行,那将更加有用。

为此,我们可以创建一个交互式循环,即应用程序将持续请求输入,直到满足退出条件。为了确保循环持续,我们可以将提示语句放置在一个调用自身的函数中,这将使代码块内的代码一直运行,直到所述的退出条件变为true。这将为我们代码的用户提供更好的体验。以下是使用我们之前提到的readline实现交互式循环的示例:

const askName = function() {
    interface.question("Hello, what is your name?", (input) => {
        if(input === "exit") {
            return interface.close(); // Will kill the loop.
        }
        console.log(`Hello, ${input}`);
        askName();

      });
}
askName(); // First Run.

注意这里的退出条件:

        if(input === "exit") {
            return interface.close(); // Will kill the loop.
        }

确保在任何循环中都设置退出条件至关重要,因为这允许用户退出应用程序。否则,他们将永远被困在循环中,这可能会消耗计算机的资源。

注意

在编写代码中的循环时,你可能会不小心创建一个没有退出条件的无限循环。如果确实发生了这种情况,你可能不得不终止你的 shell 或 Terminal。你可以尝试Ctrl+C,或在 macOS 上使用Cmd+C退出。

如果你运行前面的示例,你将能够在退出之前多次回答问题;例如:

Chapter_8> node examples.js 
Hello, what is your name?Mike
Hello, Mike
Hello, what is your name?John
Hello, John
Hello, what is your name?Ed
Hello, Ed
Hello, what is your name?exit

练习 8.05:在 Node.js 中处理输入

对于这个练习,你将创建一个小的 Node.js 应用程序,允许你询问用户的姓名。你可以将此视为一个基本的登录系统。此应用程序应在交互式循环中运行;用户的选择如下:

  • login询问并存储用户的姓名

  • who输出用户的姓名

  • exit结束应用程序

通过执行以下步骤创建此应用程序:

  1. 创建一个名为Exercise8.05.js的新 JavaScript 文件。

  2. 导入readline模块:

    const readline = require('readline');
    const interface = readline.createInterface({
        input: process.stdin,
        output: process.stdout,
    });
    ```

1.  定义选择和用户变量。

1.  现在,定义一个名为`login`的新函数,该函数接受用户作为参数。该函数首先询问用户并将其存储在变量中:

```js
    const login = function() {
        interface.question("Hello, what is your name?", (name) => {
            user = name;
      prompt();
          });
    }
    ```

1.  创建一个名为`who`的新函数,该函数输出`user`:

```js
    const who = function () {
        console.log(`User is ${user}`);
        prompt();
    }
    ```

1.  创建一个输入循环,条件是选择不等于退出:

```js
    const prompt = function() {
        interface.question("login, who OR exit?", (input) => {
            if(input === "exit") {
                return interface.close(); // Will kill the loop.
            }   
            prompt();
          });
    }
    ```

1.  之后,使用 if 关键字检查他们的选择是否匹配 "`login`"。如果找到匹配项,则运行 `login` 函数:

```js
            if(input === "login") {
                login();
            }
    ```

1.  接着,使用 if 关键字检查他们的选择是否匹配 "`who`"。如果找到匹配项,则打印出 `user` 变量:

```js
            if(input === "who") {
                who();
            }
    ```

你的最终代码应该大致如下所示:

```js
    const readline = require('readline');
    const interface = readline.createInterface({
        input: process.stdin,
        output: process.stdout,
    });
    var choice;
    var user;
    var cinema;
    const login = function() {
        interface.question("Hello, what is your name?", (name) => {
            user = name;
            prompt();
          });
    }
    const who = function () {
        console.log(`User is ${user}`)
        prompt();
    }
    const prompt = function() {
        interface.question("login, who OR exit?", (input) => {
            if(input === "exit") {
                return interface.close(); // Will kill the loop.
            }   
            if(input === "login") {
                login();
            }
            if(input === "who") {
                who();
            }
          });
    }
    prompt();
    ```

1.  通过运行 `node Exercise8.05.js` 并输入一些内容来执行代码。现在,你应该能够与应用程序进行交互了。以下是一个示例:

```js
    Chapter_8> node .\Exercise8.06.js
    login, who OR exit?login
    Hello, what is your name?Michael
    login, who OR exit?who
    User is Michael
    login, who OR exit?exit
    ```

在这个练习中,您创建了一个基本的交互式应用程序,使用 Node.js 让用户从三个输入中进行选择,并相应地输出结果。

## 活动 8.01:创建一个简单的 Node.js 应用程序

您已被一家电影公司聘请,创建一个应用程序,允许客户列出所选类别中评分最高的电影。客户应该能够提供一个类别,并在命名的命令行列表中提供响应。他们还需要提供他们最喜欢的电影的详细信息,以便在收藏字段中捕获。最后,完成所有这些后,客户应该能够`退出`应用程序,如下所示:

+   `"列表"`:询问用户一个流派,然后查询该流派中排名前五的电影,输出`ID`、`标题`和`favourite`字段。

+   `"favourite"`:询问用户一个电影 ID,然后更新该电影的收藏字段。

+   `"退出"`:退出交互循环和应用程序。

此活动旨在创建一个小型的 Node.js 应用程序,向用户公开一个交互式输入循环。在此循环中,用户可以通过流派查询数据库中的信息,并通过 ID 更新记录。您还需要确保处理可能出现的用户输入错误。

您可以通过多种方式完成此目标,但请记住我们在本章中学到的内容,并尝试创建简单、易于使用的代码。

以下高级步骤将帮助您完成此任务:

1.  导入`readline`和 MongoDB 库。

1.  创建您的`readline`接口。

1.  声明您将需要的任何变量。

1.  创建一个名为列表的函数,它将为给定流派获取排名前五的最高评分电影,返回`标题`、`收藏`和`ID`字段。

注意

您将需要在此函数中询问类别。查看*练习 8.05*,*在 Node.js 中处理输入*中的登录方法,以获取更多信息。

1.  创建一个名为`favourite`的函数,它将通过标题更新文档,并向文档添加一个名为`favourite`的键,其值为`true`。(提示:在此函数中,您将需要使用与列表函数相同的方法询问标题。)

1.  创建 MongoDB 连接、数据库和集合。

1.  基于用户输入创建一个交互式 while 循环。如果您不确定如何做到这一点,请参考我们在*练习 8.05*,*在 Node.js 中处理输入*中的提示函数。

1.  在交互循环中,使用 if 条件来检查输入。如果找到有效输入,则运行相关函数。

1.  请记住,您需要通过每个函数传递数据库和客户端对象,包括每次调用`prompt()`。要测试您的输出,请运行以下命令:

`列表`

`恐怖`

`favourite`

`列表`

`退出`

预期输出如下:

注意

您可能会注意到输出中标题`Nosferatu`出现了两次。如果查看`_id`值,您会发现这实际上是两部具有相同标题的不同电影。在 MongoDB 中,您可能有许多不同的文档,它们在字段中共享相同的值。

![图 8.8:最终输出(为简洁起见截断)](https://gitee.com/OpenDocCN/freelearn-bigdata-zh/raw/master/docs/mongo-fund/img/B15507_08_08.jpg)

图 8.8:最终输出(为简洁起见截断)

注意

此活动的解决方案可通过此链接找到。

# 总结

在本章中,我们已经介绍了创建使用 Node.js 驱动程序的 MongoDB 应用程序所必需的基本概念。使用这些基础知识,可以创建大量脚本来执行对数据库的查询和操作。我们甚至学会了处理错误和创建交互式应用程序。

尽管您可能不需要在日常工作职责中编写或阅读这些应用程序,但对这些应用程序是如何构建的有深入的理解,可以让您独特地了解 MongoDB 开发以及您的同行可能如何与您的 MongoDB 数据交互。

然而,如果你想增加对于 MongoDB 的 Node.js 驱动的专业知识,这只是个开始。有许多不同的模式、库和最佳实践可以用来开发针对 MongoDB 的 Node.js 应用程序。这只是你 Node.js 之旅的开始。

在下一章中,我们将深入探讨如何提高 MongoDB 交互的性能,并创建高效的索引来加快查询速度。我们还将介绍另一个有用的功能,即使用 `explain` 并且如何最好地解释其输出。


# 第九章:性能

概述

本章介绍了 MongoDB 中查询优化和性能改进的概念。您将首先探索查询执行的内部工作原理,并确定可能影响查询性能的因素,然后转向数据库索引以及索引如何减少查询执行时间。您还将学习如何创建、列出和删除索引,并研究各种类型的索引及其好处。在最后几节中,您将了解各种查询优化技术,帮助您有效地使用索引。通过本章的学习,您将能够分析查询并使用索引和优化技术来提高查询性能。

# 介绍

在之前的章节中,我们学习了 MongoDB 查询语言和各种查询操作符。我们学会了如何编写查询来检索数据。我们还学习了用于添加和删除数据以及更新或修改数据的各种命令。我们确保查询带来了我们期望的输出;然而,我们并没有过多关注它们的执行时间和效率。在本章中,我们将专注于如何分析查询的性能,并在需要时进一步优化其性能。

现实世界的应用程序由多个组件组成,如用户界面、处理组件、数据库等。应用程序的响应性取决于每个组件的效率。数据库组件执行不同的操作,如保存、读取和更新数据。数据库表或集合存储的数据量,或者从数据库中推送或检索的数据量,都可能影响整个系统的性能。因此,重要的是要知道数据库操作的执行效率如何,以及是否可能进一步优化以提高这些操作的速度。

在下一节中,您将学习如何根据数据库提供的详细统计信息来分析查询,并用它们来识别问题。

# 查询分析

为了编写高效的查询,重要的是分析它们,找出可能的性能问题,并加以修复。这种技术称为性能优化。有许多因素可能会对查询的性能产生负面影响,比如不正确的缩放、结构不正确的集合,以及 RAM 和 CPU 等资源不足。然而,最大和最常见的因素是在查询执行过程中扫描的记录数和返回的记录数之间的差异。差异越大,查询就会越慢。幸运的是,在 MongoDB 中,这个因素是最容易解决的,可以使用索引来解决。

在集合上创建和使用索引可以缩小扫描的记录数,并显著提高查询性能。然而,在深入研究索引之前,我们首先需要了解查询执行的细节。

假设您想要查找 2015 年上映的电影列表。以下代码片段显示了此命令:

```js
db.movies.find(
    { 
        "year" : 2015
    },
    {
        "title" : 1, 
        "awards.wins" : 1
    }
).sort(
    {"awards.wins" : -1}
)

该查询根据year字段过滤movies集合,将电影标题和获奖情况投影到输出中,并对结果进行排序,以便获得获奖次数最多的电影出现在顶部。如果我们连接到 MongoDB Atlas 的sample_mflix数据库执行此查询,它将返回484条记录。

为了执行任何这样的查询,MongoDB 查询执行引擎会准备一个或多个查询执行计划。数据库具有内置的查询优化器,选择执行效率最高的计划。计划通常由多个处理阶段组成,按顺序执行以产生最终输出。我们之前创建的查询具有查询条件、投影表达式和排序规范。对于形状相似的查询,典型的执行计划将如图 9.1所示:

图 9.1:查询执行阶段

图 9.1:查询执行阶段

首先,如果给定的查询条件有支持的索引,索引将被扫描以识别匹配的记录。在我们的案例中,year字段没有索引,因此索引扫描阶段将被忽略。在下一个阶段,将扫描整个集合以找到匹配的记录。匹配的记录然后传递到排序阶段,在那里记录在内存中排序。最后,投影应用于排序的记录,并将最终输出传递给客户端。

MongoDB 提供了一个查询分析机制,我们可以从中获取有关查询执行的一些有用统计信息。在下一节中,我们将学习如何使用查询分析和统计信息来识别先前查询中的性能问题。

解释查询

explain()函数非常有用,可以用于探索查询的内部工作原理。该函数可以与查询或命令一起使用,以打印与它们的执行相关的详细统计信息。它可以给我们的最重要的指标如下:

  • 查询执行时间

  • 扫描的文档数量

  • 返回的文档数量

  • 使用的索引

以下代码片段显示了在先前创建的相同查询上使用explain函数的示例:

db.movies.explain().find(
    { 
        "year" : 2015
    },
    {
        "title" : 1, 
        "awards.wins" : 1
    }
).sort(
    {"awards.wins" : -1}
)

请注意,explain函数也可以与以下命令一起使用:

  • remove()

  • update()

  • count()

  • aggregate()

  • distinct()

  • findAndModify()

默认情况下,explain函数打印查询规划器的详细信息,即各种执行阶段的详细信息。可以在以下片段中看到:

       "queryPlanner" : {
          "plannerVersion" : 1,
          "namespace" : "mflix.movies",
          "indexFilterSet" : false,
          "parsedQuery" : {
               "year" : {
                    "$eq" : 2015
               }
          },
          "queryHash" : "9A7F8C29",
          "planCacheKey" : "9A7F8C29",
          "winningPlan" : {
               "stage" : "PROJECTION_DEFAULT",
               "transformBy" : {
                    "title" : 1,
                    "awards.wins" : 1
               },
               "inputStage" : {
                    "stage" : "SORT",
                    "sortPattern" : {
                         "awards.wins" : -1
                    },
                    "inputStage" : {
                         "stage" : "SORT_KEY_GENERATOR",
                         "inputStage" : {
                              "stage" : "COLLSCAN",
                              "filter" : {
                                   "year" : {
                                        "$eq" : 2015
                                   }
                              },
                              "direction" : "forward"
                         }
                    }
               }
          },
          "rejectedPlans" : [ ]
     },

输出显示了获胜计划和一系列被拒绝的计划。在前面的查询中,执行从COLLSCAN开始,因为没有合适的索引。因此,查询没有任何被拒绝的计划,唯一可用的计划是获胜计划。在获胜计划中,有多个嵌套的inputStage对象,清楚地显示了不同阶段的执行顺序。

第一个阶段是COLLSCAN,在这个阶段对year字段应用了过滤器。接下来的阶段SORT,根据awards.wins字段进行排序,即获奖数量。最后,在PROJECTION_DEFAULT阶段,选择并返回了titleawards.wins字段。

explain函数可以接受一个名为详细模式的可选参数,该参数控制函数返回的信息。以下列表详细说明了三种不同的详细模式:

  1. queryPlanner:这是默认选项,打印查询规划器的详细信息,例如被拒绝的计划、获胜计划以及获胜计划的执行阶段。

  2. executionStats:此选项打印queryPlanner提供的所有信息,以及查询执行的详细执行统计信息。此选项对于查找查询中的任何与性能相关的问题非常有用。

  3. allPlansExecution:此选项输出executionStats提供的详细信息,以及被拒绝的执行计划的详细信息。

查看执行统计信息

为了查看执行统计信息,您需要将executionStats作为explain()函数的参数传递。以下片段显示了您的查询的executionStats

       "executionStats" : {
          "executionSuccess" : true,
          "nReturned" : 484,
          "executionTimeMillis" : 85,
          "totalKeysExamined" : 0,
          "totalDocsExamined" : 23539,
          "executionStages" : {
               "stage" : "PROJECTION_DEFAULT",
               "nReturned" : 484,
               "executionTimeMillisEstimate" : 3,
               "works" : 24027,
               "advanced" : 484,
               "needTime" : 23542,
               "needYield" : 0,
               "saveState" : 187,
               "restoreState" : 187,
               "isEOF" : 1,
               "transformBy" : {
                    "title" : 1,
                    "awards.wins" : 1
               },
               "inputStage" : {
                    "stage" : "SORT",
                    "nReturned" : 484,
                    "executionTimeMillisEstimate" : 3,
                    "works" : 24027,
                    "advanced" : 484,
                    "needTime" : 23542,
                    "needYield" : 0,
                    "saveState" : 187,
                    "restoreState" : 187,
                    "isEOF" : 1,
                    "sortPattern" : {
                         "awards.wins" : -1
                    },
                    "memUsage" : 613758,
                    "memLimit" : 33554432,
                    "inputStage" : {
                         "stage" : "SORT_KEY_GENERATOR",
                         "nReturned" : 484,
                         "executionTimeMillisEstimate" : 3,
                         "works" : 23542,
                         "advanced" : 484,
                         "needTime" : 23057,
                         "needYield" : 0,
                         "saveState" : 187,
                         "restoreState" : 187,
                         "isEOF" : 1,
                         "inputStage" : {
                              "stage" : "COLLSCAN",
                              "filter" : {
                                   "year" : {
                                        "$eq" : 2015
                                   }
                              },
                              "nReturned" : 484,
                              "executionTimeMillisEstimate" : 3,
                              "works" : 23541,
                              "advanced" : 484,
                              "needTime" : 23056,
                              "needYield" : 0,
                              "saveState" : 187,
                              "restoreState" : 187,
                              "isEOF" : 1,
                              "direction" : "forward",
                              "docsExamined" : 23539
                         }
                    }
               }
          }
     },

执行统计信息提供了与每个执行阶段相关的有用指标,以及一些顶层字段,其中一些指标在查询的总执行过程中进行了聚合。以下是执行统计信息中一些最重要的指标:

  • executionTimeMillis:这是查询执行所花费的总时间(以毫秒为单位)。

  • totalKeysExamined:这表示扫描的索引键的数量。

  • totalDocsExamined:这表示针对给定查询条件检查的文档数量。

  • nReturned:这是查询输出中返回的记录总数。

现在,让我们在下一节中分析执行统计信息。

识别问题

执行统计数据(如前面片段所示)告诉我们查询过程中存在一些问题。为了返回484条匹配记录,查询检查了23539个文档,这也是集合中的文档总数。扫描大量文档会减慢查询执行速度。看到查询执行时间为85毫秒,似乎很快。然而,查询执行时间可能会根据网络流量、服务器上的 RAM 和 CPU 负载以及扫描的记录数量而变化。扫描文档数量减慢性能的原因将在下一节中解释。

线性搜索

当我们在集合上执行一个带有搜索条件的find查询时,数据库搜索引擎会选择集合中的第一条记录,并检查它是否符合给定的条件。如果没有找到匹配项,搜索引擎会继续查找下一条记录,直到找到匹配项为止。

这种搜索技术称为顺序或线性搜索。线性搜索在应用于少量数据或在最佳情况下,即所需项在第一次搜索中找到时表现更好。因此,在小集合中搜索文档时,搜索性能会很好。然而,如果数据量很大,或者在最坏的情况下,即所需项存在于集合的末尾时,性能将明显较差。

大多数情况下,当新建的系统投入使用时,集合要么是空的,要么包含非常少量的数据。因此,所有数据库操作都是瞬时的。但随着时间的推移,随着集合的增长,相同的操作开始花费更长的时间。缓慢的主要原因是线性搜索,这是大多数数据库(包括 MongoDB)使用的默认搜索算法。可以通过在集合的特定字段上创建索引来避免或至少限制线性搜索。在下一节中,我们将详细探讨这个概念。

索引简介

数据库可以维护和使用索引以使搜索更加高效。在 MongoDB 中,索引可以创建在一个字段或多个字段上。数据库维护一个索引字段的特殊注册表和一些它们的数据。注册表易于搜索,因为它维护了索引字段值和集合中相应文档之间的逻辑链接。在搜索操作期间,数据库首先在注册表中定位值,并相应地识别集合中的匹配文档。注册表中的值总是按值的升序或降序排序,这有助于范围搜索以及对结果进行排序。

为了更好地理解索引注册表在搜索过程中的帮助,想象一下你正在按照其 ID 搜索剧院:

db.theaters.find(
    {"theaterId" : 1009}
)

当在sample_mflix数据库上执行查询时,返回一条记录。请注意,集合中的剧院总数为 1,564。以下图示了带有和不带有索引的文档搜索之间的差异:

图 9.2:带有索引和不带索引的数据搜索

图 9.2:带有索引和不带索引的数据搜索

以下表格代表了在这两种不同情况下扫描的文档数量与返回的文档数量。

图 9.3:扫描的文档和返回的文档的详细信息

图 9.3:扫描的文档和返回的文档的详细信息

从上表可以看出,使用索引进行搜索比不使用索引更可取。在本节中,我们了解到数据库支持索引以更快地检索数据,以及索引注册表如何帮助避免完全扫描集合。现在我们将学习如何创建索引并在集合中查找索引。

创建和列出索引

可以通过在集合上执行createIndex()命令来创建索引,如下所示:

db.collection.createIndex(
keys, 
options
)

命令的第一个参数是一个键值对列表,其中每对由字段名和排序顺序组成,可选的第二个参数是一组控制索引的选项。

在上一节中,您编写了以下查询,以查找所有在 2015 年发布的电影,按获奖数量降序排序,并打印标题和获奖次数:

db.movies.find(
    { 
        "year" : 2015
    },
    {
        "title" : 1, 
        "awards.wins" : 1
    }
).sort(
    {"awards.wins" : -1}
)

由于查询在year字段上使用了过滤器,因此需要在该字段上创建一个索引。下一个命令通过传递1的排序顺序在year字段上创建一个索引,表示升序:

db.movies.createIndex(
    {year: 1}
)

下面的片段显示了在 mongo shell 上执行命令后的输出:

 {
     "createdCollectionAutomatically" : true,
     "numIndexesBefore" : 2,
     "numIndexesAfter" : 3,
     "ok" : 1,
     "$clusterTime" : {
          "clusterTime" : Timestamp(1596352285, 3),
          "signature" : {
               "hash" : BinData(0,"Ce9YztoqHYaBhubyzM3SsujEYFY="),
               "keyId" : NumberLong("6853300587753111555")
          }
     },
     "operationTime" : Timestamp(1596352285, 3)
}

输出表明索引已成功创建。它还提到了在执行此命令之前和之后存在的索引数量(请参阅代码中的突出部分)以及索引创建的时间。

在集合上列出索引

您可以使用getIndexes()命令列出集合的索引。此命令不带任何参数。它只是返回一组带有一些基本详细信息的索引数组。

执行以下命令将列出movies集合中存在的所有索引:

db.movies.getIndexes()

此命令的输出将如下所示:

[
     {
          "v" : 2,
          "key" : {
               "_id" : 1
          },
          "name" : "_id_",
          "ns" : "sample_mflix.movies"
     },
     {
          "v" : 2,
          "key" : {
               "_fts" : "text",
               "_ftsx" : 1
          },
          "name" : "cast_text_fullplot_text_genres_text_title_text",
          "default_language" : "english",
          "language_override" : "language",
          "weights" : {
               "cast" : 1,
               "fullplot" : 1,
               "genres" : 1,
               "title" : 1
          },
          "ns" : "sample_mflix.movies",
          "textIndexVersion" : 3
     },
     {
          "v" : 2,
          "key" : {
               "year" : 1
          },
          "name" : "year_1",
          "ns" : "sample_mflix.movies"
     }
]

输出表明集合中有三个索引,包括您刚刚创建的索引。对于每个索引,它显示了版本、索引字段及其排序顺序、索引名称和由索引名称和数据库名称组成的命名空间。请注意,当在year字段上创建索引时,您没有指定其名称。您将在下一节中了解索引名称是如何派生的。

索引名称

如果未明确提供名称,MongoDB 会为索引分配一个默认名称。索引的默认名称由字段名称和排序顺序以下划线分隔组成。如果索引中有多个键(称为复合索引),则所有键都以相同的方式连接。

以下命令为theaterId字段创建一个索引,而不提供名称:

db.theaters.createIndex(
    {theaterId : 1}
)

此命令将导致创建一个名为theaterId_1的索引。

但是,您也可以使用特定名称创建索引。为此,您可以使用name属性为索引提供自定义名称,如下所示:

db.theaters.createIndex(
    {theaterId : -1},
    {name : "myTheaterIdIndex"}
);

上述命令将创建一个名为myTheaterIdIndex的索引。在下一个练习中,您将使用 MongoDB Atlas 创建一个索引。

练习 9.01:使用 MongoDB Atlas 创建索引

在上一节中,您学习了如何使用 mongo shell 创建索引。在本练习中,您将使用 MongoDB Atlas 门户在sample_analytics数据库中的accounts集合上创建一个索引。执行以下步骤完成此练习:

  1. 登录到您的帐户www.mongodb.com/cloud/atlas

  2. 转到sample_analytics数据库并选择accounts集合。在集合屏幕上,选择Indexes选项卡,您应该看到一个索引。图 9.4:数据库中集合中的索引选项卡

图 9.4:sample_analytics数据库中accounts集合中的索引选项卡

  1. 单击右上角的CREATE INDEX按钮。您应该会看到一个模态框,如下图所示:图 9.5:创建索引页面

图 9.5:创建索引页面

  1. 要在account_id上创建一个索引,从FIELDS部分中删除默认字段和类型条目。将account_id作为字段引入,并将值为1的类型作为升序索引顺序。以下是显示更新后的FIELDS部分的屏幕截图:图 9.6:更新的 FIELDS 部分

图 9.6:更新的 FIELDS 部分

  1. 传递name参数以在OPTIONS部分提供自定义索引名称,如下所示:图 9.7:在 OPTIONS 部分传递 name 参数

图 9.7:在 OPTIONS 部分传递 name 参数

  1. 一旦更新字段部分,Review按钮应该变成绿色。单击它以进行下一步:图 9.8 评论按钮

图 9.8 评论按钮

  1. 将向您呈现确认屏幕。在下一个屏幕上单击“确认”按钮以完成创建索引:图 9.9:确认屏幕

图 9.9:确认屏幕

索引创建完成后,索引列表将更新如下:

图 9.10:更新的索引列表

图 9.10:更新的索引列表

在这个练习中,您已成功使用 MongoDB Atlas 门户创建了索引。

您现在已经学会了如何在集合上创建索引。接下来,您将看到索引字段如何提高查询性能。

索引后的查询分析

查询分析部分,您分析了一个没有合适的索引来支持其查询条件的查询的性能。因此,查询扫描了集合中的所有23539个文档,返回了484个匹配的文档。现在您已经在year字段上添加了一个索引,让我们看看查询执行统计数据如何改变。

以下查询打印了相同查询的执行统计信息:

db.movies.explain("executionStats").find(
    { 
        "year" : 2015
    },
    {
        "title" : 1, 
        "awards.wins" : 1
    }
).sort(
    {"awards.wins" : -1}
)

这次的输出与之前的略有不同,如下所示:

       "executionStats" : {
          "executionSuccess" : true,
          "nReturned" : 484,
          "executionTimeMillis" : 7,
          "totalKeysExamined" : 484,
          "totalDocsExamined" : 484,
          "executionStages" : {
               "stage" : "PROJECTION_DEFAULT",
               "nReturned" : 484,
               "executionTimeMillisEstimate" : 0,
               "works" : 971,
               "advanced" : 484,
               "needTime" : 486,
               "needYield" : 0,
               "saveState" : 7,
               "restoreState" : 7,
               "isEOF" : 1,
               "transformBy" : {
                    "title" : 1,
                    "awards.wins" : 1
               },
               "inputStage" : {
                    "stage" : "SORT",
                    "nReturned" : 484,
                    "executionTimeMillisEstimate" : 0,
                    "works" : 971,
                    "advanced" : 484,
                    "needTime" : 486,
                    "needYield" : 0,
                    "saveState" : 7,
                    "restoreState" : 7,
                    "isEOF" : 1,
                    "sortPattern" : {
                         "awards.wins" : -1
                    },
                    "memUsage" : 613758,
                    "memLimit" : 33554432,
                    "inputStage" : {
                         "stage" : "SORT_KEY_GENERATOR",
                         "nReturned" : 484,
                         "executionTimeMillisEstimate" : 0,
                         "works" : 486,
                         "advanced" : 484,
                         "needTime" : 1,
                         "needYield" : 0,
                         "saveState" : 7,
                         "restoreState" : 7,
                         "isEOF" : 1,
                         "inputStage" : {
                              "stage" : "FETCH",
                              "nReturned" : 484,
                              "executionTimeMillisEstimate" : 0,
                              "works" : 485,
                              "advanced" : 484,
                              "needTime" : 0,
                              "needYield" : 0,
                              "saveState" : 7,
                              "restoreState" : 7,
                              "isEOF" : 1,
                              "docsExamined" : 484,
                              "alreadyHasObj" : 0,
                              "inputStage" : {
                                   "stage" : "IXSCAN",
                                   "nReturned" : 484,
                                   "executionTimeMillisEstimate" : 0,
                                   "works" : 485,
                                   "advanced" : 484,
                                   "needTime" : 0,
                                   "needYield" : 0,
                                   "saveState" : 7,
                                   "restoreState" : 7,
                                   "isEOF" : 1,
                                   "keyPattern" : {
                                        "year" : 1
                                   },
                                   "indexName" : "year_1",
                                   "isMultiKey" : false,
                                   "multiKeyPaths" : {
                                        "year" : [ ]
                                   },
                                   "isUnique" : false,
                                   "isSparse" : false,
                                   "isPartial" : false,
                                   "indexVersion" : 2,
                                   "direction" : "forward",
                                   "indexBounds" : {
                                        "year" : [
                                             "[2015.0, 2015.0]"
                                        ]
                                   },
                                   "keysExamined" : 484,
                                   "seeks" : 1,
                                   "dupsTested" : 0,
                                   "dupsDropped" : 0
                              }
                         }
                    }
               }
          }
     },

第一个不同之处在于第一个阶段(即COLLSCAN)现在被IXSCANFETCH阶段所取代。这意味着首先执行了索引扫描阶段,然后根据检索到的索引引用,从集合中获取了数据。此外,顶层字段表明只检查了484个文档,并返回了相同数量的文档。

因此,我们看到通过减少扫描的文档数量,查询性能得到了极大的改善。正如在这里所表现的那样,查询执行时间现在从85毫秒减少到了7毫秒。即使每年向集合中推入更多的文档,查询的性能也将保持一致。

我们已经看到了如何创建索引,以及如何列出集合中的索引。MongoDB 还提供了一种删除索引的方法。接下来的部分将详细探讨这一点。

隐藏和删除索引

删除索引意味着从索引注册表中删除字段的值。因此,对相关字段的任何搜索都将以线性方式执行,前提是该字段上没有其他索引。

重要的是要注意,MongoDB 不允许更新现有的索引。因此,要修复错误创建的索引,我们需要删除它并正确地重新创建它。

使用dropIndex函数删除索引。它接受一个参数,可以是索引名称或索引规范文档,如下所示:

db.collection.dropIndex(indexNameOrSpecification)

索引规范文档是用于创建索引的索引定义(例如以下代码片段):

db.movies.createIndex(
    {title: 1}
)

考虑以下代码片段:

db.movies.dropIndex(
     {title: 1}
)

此命令删除了movies集合中title字段上的索引:

{
     «nIndexesWas» : 4,
     "ok" : 1,
     "$clusterTime" : {
          "clusterTime" : Timestamp(1596885249, 1),
          "signature" : {
               "hash" : BinData(0,"WNi8vLv+MUP5F7bUg6ZGAbhbT1o="),
               "keyId" : NumberLong("6853300587753111555")
          }
     },
     "operationTime" : Timestamp(1596885249, 1)
}

输出包含nIndexesWas(已突出显示),它指的是在执行命令之前的索引计数。ok字段显示状态为1,表示命令执行成功。

删除多个索引

您还可以使用dropIndexes命令删除多个索引。命令语法如下:

db.collection.dropIndexes()

此命令可用于删除集合上的所有索引,除了默认的_id索引。您可以通过传递索引名称或索引规范文档来使用该命令删除单个索引。您还可以通过传递索引名称数组来使用该命令删除一组索引。以下是dropIndexes命令的示例:

db.theaters.dropIndexes()

上述命令生成以下输出:

{
     "nIndexesWas" : 3,
     «msg» : «non-_id indexes dropped for collection»,
     "ok" : 1,
     "$clusterTime" : {
          "clusterTime" : Timestamp(1596887253, 1),
          "signature" : {
               "hash" : BinData(0,"+OYwY3X1upiuad63SOAYOe0uPXI="),
               "keyId" : NumberLong("6853300587753111555")
          }
     },
     "operationTime" : Timestamp(1596887253, 1)
}

除了默认的_id索引之外,所有索引都已删除,如msg属性(已突出显示)中所确认的那样。

隐藏索引

MongoDB 提供了一种方法来隐藏查询规划器中的索引。创建和删除索引在时间上是昂贵的操作。对于大型集合,这些操作需要更长的时间才能完成。因此,在决定删除索引之前,您可以首先隐藏它以分析性能影响,然后据此决定。

要隐藏索引,可以在集合上使用hideIndex()命令,如下所示:

db.collection.hideIndex(indexNameOrSpecification)

命令的参数与dropIndex()函数类似。它接受索引的名称或索引规范文档。

需要注意的一点是,隐藏的索引只出现在getIndexes()函数调用中。它们在集合上的每次写操作后更新。但是,查询规划器看不到这些索引,因此不能用于执行查询。

一旦索引被隐藏,您可以分析对查询的影响,并在确实不需要时删除索引。但是,如果隐藏索引对性能产生不利影响,您可以使用unhideIndex()函数来恢复或取消隐藏它们,如下所示:

db.collection.unhideIndex(indexNameOrSpecification)

unhideIndex()函数接受一个参数,可以是索引名称或索引规范文档。由于隐藏的索引始终在写操作后更新,因此它们始终处于就绪状态。取消隐藏它们可以立即使它们恢复运行。

练习 9.02:使用 Mongo Atlas 删除索引

在这个练习中,您将使用 Atlas 门户从sample_analytics数据库的accounts集合中删除一个索引。以下步骤将帮助您完成这个练习:

  1. 登录到您的帐户www.mongodb.com/cloud/atlas

  2. 转到sample_ analytics数据库并选择accounts集合。在集合屏幕上,选择Indexes选项卡,您应该看到现有的索引。单击要删除的索引旁边的删除索引按钮:图 9.11:sample_analytics 数据库的 accounts 集合的索引选项卡

图 9.11:sample_analytics 数据库的 accounts 集合的索引选项卡

  1. 应该显示一个确认对话框,如下图所示。输入索引名称,该名称也以粗体显示在对话框消息中:图 9.12:输入要删除的索引名称

图 9.12:输入要删除的索引名称

  1. 如下屏幕所示,索引应该从索引列表中删除。请注意accountIdIndex索引的缺失:图 9.13:索引选项卡指示成功删除了 accountIdIndex

图 9.13:索引选项卡指示成功删除了 accountIdIndex

在这个练习中,您通过使用 MongoDB Atlas 门户删除了集合上的一个索引。在下一节中,我们将看一下 MongoDB 中可用的索引类型。

索引类型

我们已经看到索引如何帮助查询性能,以及我们如何在集合中创建、删除和列出索引。MongoDB 支持不同类型的索引,如单键、多键和复合索引。在决定哪种类型适合您的集合之前,您需要了解每种索引的不同优势。让我们从默认索引的简要概述开始。

默认索引

如前几章所示,集合中的每个文档都有一个主键(即_id字段)并且默认情况下已建立索引。MongoDB 使用此索引来维护_id字段的唯一性,并且它在所有集合上都可用。

单键索引

使用集合中的单个字段创建的索引称为单键索引。在本章的前面部分,您使用了单键索引。语法如下:

db.collection.createIndex({ field1: type}, {options})

复合索引

当使用关键字显着减少要扫描的文档数量时,单键索引是首选的。但是,在某些情况下,单键索引不足以减少集合扫描。当查询基于多个字段时,通常会发生这种情况。

考虑您编写的用于查找 2015 年上映电影的查询。您看到在year字段上添加单键索引可以提高查询性能。现在,您将修改查询并添加基于rated字段的过滤器,如下所示:

db.movies.find(
    { 
        "year" : 2015,
        "rated" : "UNRATED"
    },
    {
        "title" : 1, 
        "awards.wins" : 1
    }
).sort(
    {"awards.wins" : -1}
)

在此查询上使用explain("executionStats")并分析执行统计信息:

"executionStats" : {
          "executionSuccess" : true,
          "nReturned" : 3,
          "executionTimeMillis" : 1,
          "totalKeysExamined" : 484,
          "totalDocsExamined" : 484,
          "executionStages" : {

前面的片段来自查询的执行统计信息。以下是这些统计信息的重要观察结果:

  • 由于索引,只扫描了484个文档。

  • 索引帮助定位了484个文档,并且基于rated字段的第二个过滤器是通过集合扫描应用的。

从这些观点来看,很明显我们再次扩大了要扫描的文档数量和返回的文档数量之间的差异。当使用具有数千条记录的其他年份的相同查询时,这可能会成为潜在的性能问题。对于这种情况,数据库允许您基于多个字段创建索引(称为复合索引)。createIndex命令可用于使用以下语法创建复合索引:

db.collection.createIndex({ field1: type, field2: type, ...}, {options})

此语法与单字段索引的语法类似,只是它接受多对字段及其相应的排序顺序。请注意,复合索引最多可以包含32个字段。

现在,在yearrated字段上创建一个复合索引:

db.movies.createIndex(
    {year : 1, rated : 1}
) 

此命令生成以下输出:

{
     "createdCollectionAutomatically" : false,
     "numIndexesBefore" : 3,
     "numIndexesAfter" : 4,
     "ok" : 1,
     "$clusterTime" : {
          "clusterTime" : Timestamp(1596932004, 4),
          "signature" : {
               "hash" : BinData(0,"y8fxEd0oLD6+OkLmhCjirg2Cm14="),
               "keyId" : NumberLong("6853300587753111555")
          }
     },
     "operationTime" : Timestamp(1596932004, 4)
}

复合索引的默认名称包含字段名称及其排序顺序,用下划线分隔。最后一个索引创建的索引的索引名称将是year_1_rated_1。您也可以为复合索引指定自定义名称。

现在您已在两个字段上创建了额外的索引,请观察查询给出的执行统计信息:

"executionStats" : {
          "executionSuccess" : true,
          "nReturned" : 3,
          "executionTimeMillis" : 2,
          "totalKeysExamined" : 3,
          "totalDocsExamined" : 3,
          "executionStages" : {

前面的片段表明,复合索引用于执行此查询,而不是您之前创建的单键索引。扫描的文档数量和返回的文档数量相同。由于只扫描了3个文档,查询执行时间也减少了。

多键索引

在数组类型字段上创建的索引称为多键索引。当数组字段作为createIndex函数的参数传递时,MongoDB 为数组的每个元素创建一个索引条目。createIndex元素的语法与创建常规(非数组)字段的索引的语法相同:

db.collectionName.createIndex( { arrayFieldName: sortOrder } )

MongoDB 检查输入字段,如果是数组,则将创建多键索引。例如,考虑以下命令:

db.movies.createIndex(
    {"languages" : 1}
)

此查询在languages字段上添加了一个索引,该字段是一个数组。在 MongoDB 中,您可以根据其数组字段的元素查找文档。多键索引有助于加速此类查询:

db.movies.explain("executionStats").count(
    {"languages": "Cantonese"}
)

让我们看看前面的查询的执行情况:

     "executionStats" : {
          "executionSuccess" : true,
          "nReturned" : 361,
          "executionTimeMillis" : 1,
          "totalKeysExamined" : 361,
          "totalDocsExamined" : 361,
          "executionStages" : {

执行统计信息的片段显示返回了361个文档,并且扫描了相同数量的文档。这证明了多键索引被正确创建和使用。

文本索引

在字符串字段或字符串元素数组上定义的索引称为文本索引。文本索引未排序,这意味着它们比普通索引更快。创建文本索引的语法如下:

db.collectionName.createIndex({ fieldName : "text"})

以下是要在users集合的name字段上创建的文本索引的示例:

db.users.createIndex(
    { name : "text"}
)

该命令应生成以下输出:

{
     "createdCollectionAutomatically" : false,
     "numIndexesBefore" : 2,
     "numIndexesAfter" : 3,
     "ok" : 1,
     "$clusterTime" : {
          "clusterTime" : Timestamp(1596889407, 2),
          "signature" : {
               "hash" : BinData(0,"B4Ro1V1WTwkGUMGEImtxvctR9C4="),
               "keyId" : NumberLong("6853300587753111555")
          }
     },
     "operationTime" : Timestamp(1596889407, 2)
}

注意

您不能通过传递索引规范文档来删除文本索引,此类索引只能通过传递dropIndex函数中的索引名称来删除。

嵌套文档上的索引

一个文档可以包含嵌套对象来组合一些属性。例如,在sample_mflix数据库的theaters集合中包含了location字段,其中包含了一个嵌套对象:

{
     "_id" : ObjectId("59a47286cfa9a3a73e51e72c"),
     "theaterId" : 1000,
     "location" : {
          "address" : {
               "street1" : "340 W Market",
               "city" : "Bloomington",
               "state" : "MN",
               "zipcode" : "55425"
          },
          "geo" : {
               "type" : "Point",
               "coordinates" : [
                    -93.24565,
                    44.85466
               ]
          }
     }
}

使用点(.)表示法,您可以在嵌套文档字段上创建索引,就像在集合中的任何其他字段一样,如下面的示例所示:

db.theaters.createIndex(
    { "location.address.zipcode" : 1}
)

您还可以在嵌入式文档上创建索引。例如,您可以在location字段上创建索引,而不是它的属性,如下所示:

db.theaters.createIndex(
    { "location" : 1}
)

当通过传递整个嵌套文档搜索位置时,可以使用此类索引。

通配符索引

MongoDB 支持灵活的模式,不同的文档可以具有不同类型和数量的字段。在不统一的字段上创建和维护索引可能会很困难,因为这些字段并非所有文档都具有。此外,当向文档中引入新字段时,它仍然未被索引。

为了更好地理解,考虑来自假设的products集合的以下文档。下表显示了两个不同的产品文档:

图 9.14:两个不同的产品规格文档

图 9.14:两个不同的产品规格文档

正如您所看到的,specifications 下的字段是动态的。不同的产品可以有不同的规格。在每个字段上定义索引将导致太多的索引定义。随着不断添加具有新字段的新产品,创建索引的想法并不实际。MongoDB 提供通配符索引来解决这个问题。例如,考虑以下查询:

db.products.createIndex(
    { "specifications.$**"  : 1}
)

此查询使用特殊的通配符字符($**)在specifications字段上创建索引。它将在specifications下的所有字段上创建索引。如果将来添加了新的嵌套字段,它们将自动被索引。

同样,通配符索引也可以在集合的顶级字段上创建。

db.products.createIndex(
    { "$**" : 1 } 
)

上述命令在所有文档的所有字段上创建索引。因此,所有添加到文档中的新字段将默认被索引。

您还可以通过传递wildcardProjection选项和一个或多个字段名称来选择或省略通配符索引中的特定字段,如下面的代码片段所示:

db.products.createIndex(
    { "$**" : 1 },
    { 
        "wildcardProjection" : { "name" : 0 }
    }
)

上述查询在集合的所有字段上创建了一个通配符索引,但排除了name字段。要显式包含name字段,排除所有其他字段,您可以将其传递为1的值。

注意

MongoDB 提供了一对索引来支持几何字段:2dsphere2d。本书不涵盖这些索引的范围,但感兴趣的读者可以在docs.mongodb.com/manual/geospatial-queries/#geospatial-indexes找到更多信息。

现在我们已经介绍了索引的类型,接下来我们将在下一节中探讨索引的属性。

索引的属性

在本节中,我们将介绍 MongoDB 中索引的不同属性。索引属性可以影响索引的使用,并且还可以对集合施加一些行为。索引属性作为选项传递给createdIndex函数。我们将研究唯一索引、TTL(生存时间)索引、稀疏索引,最后是部分索引。

唯一索引

唯一索引属性限制了索引键的重复。如果您想要在集合中保持字段的唯一性,这是很有用的。唯一字段对于避免在准确识别文档时产生任何歧义是有用的。例如,在license集合中,像license_number这样的唯一字段可以帮助单独识别每个文档。此属性强制集合拒绝重复条目。唯一索引可以在单个字段或一组字段上创建。以下是在单个文件上创建唯一索引的语法:

db.collection.createIndex(
    { field: type}, 
    { unique: true }
)

{ unique: true }选项用于创建唯一索引。

在某些情况下,您可能希望一些字段的组合是唯一的。对于这种情况,您可以在创建复合索引时传递unique: true标志来定义一个唯一的复合索引,如下所示:

db.collection.createIndex(
    { field1 : type, field2: type2, ...}, 
    { unique: true }
)

练习 9.03:创建唯一索引

在这个练习中,您将强制sample_mflix数据库中theaters集合中theaterId字段的唯一性:

  1. 将您的 shell 连接到 Atlas 集群,并选择sample_mflix数据库。

  2. 确认theaters集合是否强制theaterId字段的唯一性。为此,找到一条记录,并尝试使用与获取的记录中相同的theaterId插入另一条记录。以下是从theaters集合中检索文档的命令:

db.theaters.findOne();

这导致以下输出,尽管您可能会得到不同的记录:

图 9.15:从剧院集合中检索文档的结果

图 9.15:从剧院集合中检索文档的结果

  1. 现在,插入一个具有相同theaterId(即1012)的记录:
db.theaters.insertOne(
    {theaterId : 1012}
);

文档成功插入,证明theaterId不是一个唯一字段。

  1. 现在,使用以下命令在theaterId字段上创建一个唯一索引:
db.theaters.createIndex(
    {theaterId : 1}, 
    {unique : true}
)

上述命令将返回错误响应,因为有一个先决条件,即集合中不应该存在重复的记录。以下是确认此事实的输出:

{
     "operationTime" : Timestamp(1596939398, 1),
     "ok" : 0,
     "errmsg" : "E11000 duplicate key error collection: 5f261717eae2b55842a6aff0_sample_mflix.theaters index: theaterId_1 dup key: { theaterId: 1012.0 }",
     "code" : 11000,
     "codeName" : "DuplicateKey",
     "keyPattern" : {
          "theaterId" : 1
     },
     "keyValue" : {
          "theaterId" : 1012
     },
     "$clusterTime" : {
          "clusterTime" : Timestamp(1596939398, 1),
          "signature" : {
               "hash" : BinData(0,"hzOmtVWMNJkF3fkISbf3kJLLZIA="),
               "keyId" : NumberLong("6853300587753111555")
          }
     }
}
  1. 现在,使用其_id值删除在步骤 3中插入的重复记录:
db.theaters.remove(
    {_id : ObjectId("5dd9c2d9de850e38c5cfc6dd")}
)
  1. 尝试再次创建唯一索引,如下所示:
db.theaters.createIndex(
    {theaterId : 1},
    {unique : true}
)

这次,您应该收到一个成功的响应,如下所示:

{
     "createdCollectionAutomatically" : false,
     "numIndexesBefore" : 1,
     "numIndexesAfter" : 2,
     "ok" : 1,
     "$clusterTime" : {
          "clusterTime" : Timestamp(1596939728, 2),
          "signature" : {
               "hash" : BinData(0,"hdejOvB7dqQojg46DRWRLJVwblM="),
               "keyId" : NumberLong("6853300587753111555")
          }
     },
     "operationTime" : Timestamp(1596939728, 2)
}
  1. 现在字段有了唯一索引,尝试插入一个重复记录,如下所示:
db.theaters.insertOne(
    {theaterId : 1012}
);

由于重复键错误,此命令将失败:

2020-08-09T12:24:11.584+1000 E  QUERY    [js] WriteError({
     "index" : 0,
     "code" : 11000,
     "errmsg" : "E11000 duplicate key error collection: sample_mflix.theaters index: theaterId_1 dup key: { theaterId: 1012.0 }",
     "op" : {
          "_id" : ObjectId("5f2f5e4b78436de2a47da0e4"),
          "theaterId" : 1012
     }
}) :
WriteError({
     "index" : 0,
     "code" : 11000,
     "errmsg" : "E11000 duplicate key error collection: sample_mflix.theaters index: theaterId_1 dup key: { theaterId: 1012.0 }",
     "op" : {
          "_id" : ObjectId("5f2f5e4b78436de2a47da0e4"),
          "theaterId" : 1012
     }
})

在这个练习中,您对索引强制了唯一性属性。

TTL 索引

expireAfterSeconds属性。以下代码显示了创建 TTL 索引的语法:

db.collection.createIndex({ field: type}, { expireAfterSeconds: seconds })

在这里,{ expireAfterSeconds: seconds }选项用于创建 TTL 索引。MongoDB 会删除已经过了expireAfterSeconds值的文档。

练习 9.04:使用 Mongo Shell 创建 TTL 索引

在这个练习中,您将在一个名为reviews的集合上创建一个 TTL 索引。一个名为reviewDate的字段将用于捕获评论的当前日期和时间。您将引入一个 TTL 索引来检查是否删除了已经过去阈值的记录:

  1. 将 mongo shell 连接到 Atlas 集群,并切换到sample_mflix数据库。

  2. 通过插入两个文档来创建reviews集合,如下所示:

db.reviews.insert(
    {"reviewer" : "Eliyana A" , "movie" : "Cast Away","review" : "Interesting plot", "reviewDate" : new Date() }
);
db.reviews.insert(
    {"reviewer" : "Zaid A" , "movie" : "Sully","review" : "Captivating", "reviewDate" : new Date() }
);
  1. reviews集合中获取这些文档,以确认它们存在于集合中:
db.reviews.find().pretty();

这个命令导致以下输出:

{
     "_id" : ObjectId("5f2f65d978436de2a47da0e5"),
     "reviewer" : "Eliyana",
     "movie" : "Cast Away",
     "review" : "Interesting plot",
     "reviewDate" : ISODate("2020-08-09T02:56:25.415Z")
}
{
     "_id" : ObjectId("5f2f65dd78436de2a47da0e6"),
     "reviewer" : "Zaid",
     "movie" : "Sully",
     "review" : "Captivating",
     "reviewDate" : ISODate("2020-08-09T02:56:29.144Z")
}
  1. 使用以下命令引入 TTL 索引,使 60 秒后过期的文档:
db.reviews.createIndex(
    { reviewDate: 1}, 
    { expireAfterSeconds: 60 }
)

这导致以下输出:

 {
     "createdCollectionAutomatically" : false,
     "numIndexesBefore" : 1,
     "numIndexesAfter" : 2,
     "ok" : 1,
     "$clusterTime" : {
          "clusterTime" : Timestamp(1596941915, 2),
          "signature" : {
               "hash" : BinData(0,"s5DU9ZElN+N2cCZ8d27pV5802Uk="),
               "keyId" : NumberLong("6853300587753111555")
          }
     },
     "operationTime" : Timestamp(1596941915, 2)
}
  1. 60 秒后,再次执行find查询:
db.reviews.find().pretty();

查询不会返回任何记录,并且证明两个文档在 60 秒后被删除。

在这个练习中,您在一个集合上创建了一个 TTL 索引,并看到文档在指定时间后过期。

稀疏索引

当在字段上创建索引时,来自所有文档的该字段的所有值都会在索引注册表中维护。如果文档中不存在该字段,则会为该文档注册一个null值。相反,如果索引标记为sparse,则只有那些存在某个值的给定字段的文档会被注册,包括null。稀疏索引不会包含集合中不存在索引字段的条目,这就是为什么这种类型的索引被称为稀疏索引。

复合索引也可以标记为稀疏。对于复合稀疏索引,只有存在字段组合的文档才会被注册。通过向createIndex命令传递{ sparse: true }标志来创建稀疏索引,如下面的片段所示:

db.collection.createIndex({ field1 : type, field2: type2, ...}, { sparse: true })

MongoDB 没有提供任何列出由索引维护的文档的命令。这使得分析稀疏索引的行为变得困难。这就是db.collection.stats()函数可以真正有用的地方,您将在下一个练习中观察到。

练习 9.05:使用 Mongo Shell 创建稀疏索引

在这个练习中,您将在reviews集合的review字段上创建一个稀疏索引。您将验证索引仅维护具有review字段的文档的条目。为此,您将使用db.collection.stats()命令来检查索引的大小,首先插入具有索引字段的文档,然后再次插入不带字段的文档。当插入不带review字段的文档时,索引的大小应保持不变:

  1. 将 mongo shell 连接到 Atlas 集群,并切换到sample_mflix数据库。

  2. review字段上创建一个稀疏索引:

db.reviews.createIndex(
    {review: 1},
    {sparse : true}
)
  1. 检查当前集合上索引的大小:
db.reviews.stats();

此命令的结果如下:

{
     "ns" : "sample_mflix.reviews",
     "size" : 0,
     "count" : 0,
     "storageSize" : 36864,
     "capped" : false,
     "nindexes" : 3,
     "indexBuilds" : [ ],
     "totalIndexSize" : 57344,
     "indexSizes" : {
          "_id_" : 36864,
          "reviewDate_1" : 12288,
          review_1 under the indexSizes section of the preceding output.
  1. 插入一个不包含review字段的文档,如下所示:
db.reviews.insert(
    {"reviewer" : "Jamshed A" , "movie" : "Gladiator"}
);
  1. 使用stats()函数检查索引的大小:
db.reviews.stats()

输出如下:

     "indexSizes" : {
          "_id_" : 36864,
          "reviewDate_1" : 12288,
          review_1 index (highlighted) has not changed. This is because the last document was not registered in the index.
  1. 现在,插入一个包含review字段的文档:
db.reviews.insert(
    {"reviewer" : "Javed A" , "movie" : "The Pursuit of Happyness", "review": "Inspirational"}
);
  1. 再次使用stats()函数检查索引的大小经过几分钟:
db.reviews.stats()

输出中的indexSizes部分如下:

      "indexSizes" : {
          "_id_" : 36864,
          "reviewDate_1" : 36864,
          reviews field, which is part of the sparse index.NoteIndex updates can take some time, depending on the size of the index. So, give it a few moments before you view the updated size of the index.  

在这个练习中,您创建了一个稀疏索引,并证明了没有索引字段的文档不会被索引。

部分索引

可以创建一个索引来维护与给定过滤器表达式匹配的文档。这样的索引称为部分索引。由于根据输入表达式过滤文档,因此索引的大小比普通索引要小。创建部分索引的语法如下:

db.collection.createIndex(
    { field1 : type, field2: type2, ...}, 
    { partialFilterExpression: filterExpression }
) 

在上面的片段中,使用{ partialFilterExpression: filterExpression }选项创建了一个部分索引。partialFilterExpression只能接受包含以下列表中的操作的表达式文档:

  • 相等表达式(即field: value或使用$eq运算符)

  • $exists: true表达式

  • $gt$gte$lt$lte表达式

  • $type表达式

  • 顶层的$and运算符

为了更好地了解部分索引的工作原理,让我们进行一个简单的练习。

练习 9.06:使用 Mongo Shell 创建部分索引

在这个练习中,您将为 1950 年后发布的所有电影的titletype字段引入一个复合索引。然后,您将使用partialFilterExpression验证索引是否包含所需的条目:

  1. 将 mongo shell 连接到 Atlas 集群,并切换到sample_mflix数据库。

  2. movies集合中的titletype字段上使用partialFilterExpression引入一个部分索引,如下所示:

db.movies.createIndex(
    {title: 1, type:1}, 
    {
        partialFilterExpression: { 
            year : { $gt: 1950}
        }
    }
)

上述命令为所有在 1950 年后发布的电影的给定字段创建了一个部分复合索引。以下片段显示了此命令的输出:

{
     "createdCollectionAutomatically" : false,
     "numIndexesBefore" : 2,
     "numIndexesAfter" : 3,
     "ok" : 1,
     "$clusterTime" : {
          "clusterTime" : Timestamp(1596945704, 2),
          "signature" : {
               "hash" : BinData(0,"jaL6CDJrPPntbo5LibWl+Yv74Zo="),
               "keyId" : NumberLong("6853300587753111555")
          }
     },
     "operationTime" : Timestamp(1596945704, 2)
}
  1. 使用stats()函数检查并记录集合上的索引大小:
db.movies.stats();

以下是结果输出的indexSizes部分:

     "indexSizes" : {
          "_id_" : 368640,
          "cast_text_fullplot_text_genres_text_title_text" : 13549568,
          index, title_1_type_1, is 618,496 bytes (highlighted).
  1. 插入一部 1950 年之前发布的电影:
db.movies.insert(
    {title: "In Old California", type: "movie", year: "1910"}
)
  1. 使用stats()函数检查索引大小,并确保它没有变化:
db.movies.stats()

下一段显示了输出的indexSizes部分:

     "indexSizes" : {
          "_id_" : 368640,
          "cast_text_fullplot_text_genres_text_title_text" : 13615104,
          «title_1_type_1» : 618496
     },

输出片段证明了索引大小保持不变,可以从突出显示的部分看出。

  1. 现在,插入一部 1950 年之后发布的电影:
db.movies.insert(
    {title: "The Lost Ground", type: "movie", year: "2019"}
)
  1. 使用stats()函数再次检查索引大小:
db.movies.stats()

以下是前述命令输出的indexSizes部分:

     "indexSizes" : {
          "_id_" : 258048,
          "cast_text_fullplot_text_genres_text_title_text" : 13606912,
          partialFilterExpression. 

在这个练习中,您引入了一个部分索引,并验证了它是否按预期工作。

不区分大小写的索引

不区分大小写的索引允许您以不区分大小写的方式使用索引查找数据。这意味着即使字段的值以与搜索表达式中的值不同的大小写写入,索引也会匹配文档。这是由于 MongoDB 中的排序功能,它允许输入语言特定的规则,比如大小写和重音符号,以匹配文档。要创建不区分大小写的索引,您需要传递字段详细信息和collation参数。

创建不区分大小写索引的语法如下:

db.collection.createIndex( 
    { "field" : 1 }, 
    { 
        collation: { locale : <locale>, strength : <strength> } 
    } 
)

注意,collationlocalestrength参数组成:

要使用指定排序规则的索引,查询和排序规范必须与索引具有相同的排序规则。

练习 9.07:使用 Mongo Shell 创建不区分大小写的索引

在这个练习中,您将通过连接 mongo shell 到 Atlas 集群创建一个不区分大小写的索引。这个功能对于基于 Web 的应用程序非常有用,因为数据库查询在后端是以区分大小写的方式执行的。但在前端,用户不一定会使用与后端相同的大小写进行搜索。因此,确保搜索不区分大小写是很重要的。执行以下步骤来完成这个练习:

  1. 将 mongo shell 连接到 Atlas 集群,并切换到sample_mflix数据库。

  2. 执行一个不区分大小写的搜索,并验证预期的文档没有返回:

db.movies.find(
    {"title" : "goodFEllas"},
    {"title" : 1}
)

前述查询没有返回结果。

  1. 为了解决这个问题,在movies集合的title属性上创建一个不区分大小写的索引,如下所示:
db.movies.createIndex(
    {title: 1}, 
    { 
        collation: { 
            locale: 'en', strength: 2 
        } 
    } 
)

该命令的结果如下:

{
     "createdCollectionAutomatically" : false,
     "numIndexesBefore" : 3,
     "numIndexesAfter" : 4,
     "ok" : 1,
     "$clusterTime" : {
          "clusterTime" : Timestamp(1596961452, 2),
          "signature" : {
               "hash" : BinData(0,"9cdM8c3neW3oRd9A/IFGn5gZiic="),
               "keyId" : NumberLong("6856698413690388483")
          }
     },
     "operationTime" : Timestamp(1596961452, 2)
}
  1. 重新运行步骤 2中的命令,确认返回正确的电影:
db.movies.find(
    {"title" : "goodFEllas"}
).collation({ locale: 'en', strength: 2});

该命令返回了正确的电影,如下一段所示:

{ "_id" : ObjectId("573a1398f29313caabcebf8e"), "title" : "Goodfellas" }

在这个练习中,您创建了一个不区分大小写的索引,并验证它是否按预期工作。

注意

collation选项允许我们在未索引字段上执行不区分大小写的搜索。唯一的区别是这样的查询将进行完整的集合扫描。

在本节中,您回顾了不同的索引属性,并学习了如何使用每个属性创建索引。在下一节中,您将探索一些可以与索引一起使用的查询优化技术。

其他查询优化技术

到目前为止,我们已经看到了查询的内部工作原理以及索引如何帮助限制需要扫描的文档数量。我们还探讨了各种类型的索引及其属性,并学习了如何在特定用例中使用正确的索引和正确的索引属性。创建正确的索引可以提高查询性能,但还有一些技术需要用来微调查询性能。我们将在本节中介绍这些技术。

只获取所需的数据

查询的性能也受到其返回的数据量的影响。数据库服务器和客户端通过网络进行通信。如果一个查询产生大量数据,传输到网络上将需要更长的时间。此外,为了将数据传输到网络上,它需要被服务器转换和序列化,然后由接收客户端进行反序列化。这意味着数据库客户端将不得不等待更长的时间才能获得查询的最终输出。

为了提高整体性能,请考虑以下因素。

正确的查询条件和投影

一个应用程序可能有各种用例,每个用例可能需要不同的数据子集。因此,分析所有这些用例并确保我们有满足每个用例的最佳查询或命令是很重要的。这可以通过使用最佳的查询条件和正确使用投影来返回与用例相关的基本字段来实现。

分页

分页是指在每个后续请求中仅向客户端提供一小部分数据。这是性能优化的最佳方法,特别是在向客户端提供大量数据时。它通过限制返回的数据量并提供更快的结果来改善用户体验。

使用索引进行排序

查询通常需要以某种顺序返回数据。例如,如果用户选择查看最新电影的选项,结果电影可以根据发布日期进行排序。同样,如果用户想要查看热门电影,我们可以根据它们的评分对电影进行排序。

默认情况下,查询的排序操作是在内存中进行的。首先,所有匹配的结果都加载到内存中,然后对它们应用排序规范。对于大型数据集,这样的过程需要大量内存。MongoDB 仅保留allowDiskUse标志,因此当达到内存限制时,记录将被写入磁盘,然后进行排序。然而,将记录写入磁盘并读取它们会减慢查询速度。

为了避免这种情况,您可以使用用于排序的索引,因为索引是根据特定的排序顺序创建和维护的。这意味着对于索引字段,索引注册表始终根据该字段的值进行排序。当排序规范基于这样一个索引字段时,MongoDB 会引用索引来检索已经排序的数据集并返回它。

将索引适配到 RAM 中

当索引适配到内存中时,它们的效率要高得多。如果它们超过了可用的内存,它们将被写入磁盘。正如您已经知道的那样,磁盘操作比内存操作要慢。MongoDB 通过在内存中保留最近添加的记录并将旧记录保存在磁盘上来智能地利用磁盘和内存。这个逻辑假设最近的记录将被查询得更多。为了将索引适配到内存中,您可以在集合上使用totalIndexSize函数,如下所示:

db.collection.totalIndexSize()

如果大小超过服务器上可用的内存,您可以选择增加内存或优化索引。这样,您可以确保所有索引始终保持在内存中。

索引选择性

当索引可以大大缩小实际集合扫描时,索引的效果更好。这取决于isRunning字段是否持有布尔值,这意味着它的值将是truefalse

{_id: ObjectId(..), name: "motor", type: "electrical", isRunning: "true"};
{_id: ObjectId(..), name: "gear", type: "mechanical",  isRunning: "false"};
{_id: ObjectId(..), name: "plug", type: "electrical",  isRunning: "false"};
{_id: ObjectId(..), name: "starter", type: "electrical",  isRunning: "false"};
{_id: ObjectId(..), name: "battery", type: "electrical",  isRunning: "true"};

现在,在isRunning字段上添加一个索引,并执行以下查询以通过其名称找到正在运行的设备:

db.devices.find({
    "name" : "motor",
    "isRunning" : false
})

MongoDB 将首先使用isRunning索引来定位所有正在运行的设备,然后才进行集合扫描以查找具有匹配name值的文档。由于isRunning只能有truefalse值,因此必须扫描集合的大部分内容。

因此,为了使上述查询更有效,我们应该在name字段上放置一个索引,因为相同名称的文档不会太多。对于具有更广泛值或唯一值的字段,索引更有效。

提供提示

MongoDB 查询规划器根据自己的内部逻辑为查询选择索引。当有多个索引可用于执行查询时,查询规划器使用其默认的查询优化技术来选择和使用最合适的索引。但是,我们可以使用hint()函数来指定应该用于执行的索引:

db.users.find().hint(
    { index }
)

这个命令显示了提供索引提示的语法。hint函数的参数可以简单地是一个索引名称或一个索引规范文档。

最佳索引

在了解了索引的好处之后,您可能会想知道我们是否可以在所有字段及其各种组合上创建索引。然而,索引也有一些开销。每个索引都需要一个专用的索引注册表,它在内存或磁盘上存储数据的子集。太多的索引会占用大量空间。因此,在向集合添加索引之前,我们应该首先分析需求,列出应用程序将执行的用例和可能的查询。然后,根据这些信息,应创建最少数量的索引。

尽管索引可以加快查询速度,但它们会减慢集合上的每个写操作。由于索引,集合上的每个写操作都涉及更新相应的索引注册表的开销。每当在集合中添加、删除或更新文档时,都需要更新、重新扫描和重新排序所有相应的索引注册表,这比实际的集合写操作需要更长的时间。因此,在决定使用索引之前,建议检查数据库操作是读密集还是写密集。对于写密集的集合,索引是一种开销,因此应该在经过仔细评估后才创建。

简而言之,索引既有好处又有开销。更多的索引通常意味着更快的读操作和更慢的写操作。因此,我们应该始终以最佳方式使用索引。

活动 9.01:优化查询

想象一下,您的组织在世界各地都有零售店。关于所有出售商品的详细信息都存储在一个 MongoDB 数据库中。数据分析团队使用销售数据来识别不同客户的购买趋势,这些客户的年龄和位置。最近,团队中的一名成员抱怨了他们编写的查询的性能。下面的代码片段显示了查询sales集合,以查找在丹佛商店购买了一个或多个背包的客户的电子邮件地址和年龄。然后,它按客户年龄降序排序结果:

db.sales.find(
    {
        "items.name" : "backpack",
        "storeLocation" : "Denver"
    },
    {
        "_id" : 0,
        "customer.email": 1,
        "customer.age": 1
    }
).sort({
    "customer.age" : -1
})

您在这个活动中的任务是分析给定的查询,识别问题,并创建正确的索引以使其更快。以下步骤将帮助您完成这个活动:

  1. 使用 mongo shell 连接到sample_supplies数据集。

  2. 查找查询执行统计信息并识别问题。

  3. 在集合上创建正确的索引。

  4. 再次分析查询性能,看看问题是否得到解决。

注意

这个活动的解决方案可以通过此链接找到。

总结

在本章中,您练习了改善查询性能。您首先探索了查询执行的内部工作和查询执行阶段。然后,您学习了如何分析查询的性能,并根据执行统计数据识别任何现有问题。接下来,您复习了索引的概念;它们如何解决查询的性能问题;创建、列出和删除索引的各种方法;不同类型的索引;以及它们的属性。在本章的最后部分,您学习了查询优化技术,并简要了解了与索引相关的开销。在下一章中,您将了解复制的概念以及它在 Mongo 中的实现方式。

第十章:复制

概述

本章将介绍 MongoDB 集群的概念和管理。它从讨论高可用性的概念和 MongoDB 数据库的负载共享开始。您将在不同环境中配置和安装 MongoDB 副本集,管理和监控 MongoDB 副本集群,并练习集群切换和故障转移步骤。您将探索 MongoDB 中的高可用性集群,并连接到 MongoDB 集群以执行 MongoDB 集群部署的典型管理任务。

介绍

从 MongoDB 开发人员的角度来看,MongoDB 数据库服务器可能是某种黑匣子,在云端或数据中心的机房中。如果数据库在需要时处于运行状态,细节并不重要。但从商业角度来看,情况略有不同。例如,当生产应用程序需要 24/7 在线为客户提供服务时,这些细节就非常重要。任何中断都可能对客户的服务可用性产生负面影响,最终,如果故障不能迅速恢复,将影响业务的财务结果。

偶尔会发生中断,这可能是由各种原因引起的。这些通常是常见硬件故障的结果,例如磁盘或内存故障,但也可能是由网络故障、软件故障甚至应用程序故障引起的。例如,操作系统错误等软件故障可能导致服务器对用户和应用程序无响应。中断也可能是由洪水和地震等灾难引起的。尽管灾难发生的概率要小得多,但它们仍可能对企业产生毁灭性的影响。

预测故障和灾难是一项不可能的任务,因为无法猜测它们将发生的确切时间。因此,业务策略应该专注于为这些问题提供解决方案,通过分配冗余的硬件和软件资源。在 MongoDB 的情况下,实现高可用性和灾难恢复的解决方案是部署 MongoDB 集群,而不是单服务器数据库。与其他第三方数据库解决方案不同,MongoDB 不需要昂贵的硬件来构建高可用性集群,而且它们相对容易部署。这就是复制派上用场的地方。本章将详细探讨复制的概念。

首先,了解高可用性集群的基础知识非常重要。

高可用性集群

在我们深入了解 MongoDB 集群的技术细节之前,让我们首先澄清基本概念。高可用性集群有许多不同的技术实现,重要的是要了解 MongoDB 集群解决方案与其他第三方集群实现的区别。

计算机集群是一组连接在一起以提供共同服务的计算机。与单个服务器相比,集群旨在提供更好的可用性和性能。集群具有冗余的硬件和软件,允许在发生故障时继续提供服务,因此,从用户的角度来看,集群看起来像是一个统一的系统,而不是一组不同的计算机。

集群节点

集群节点是集群的一部分的服务器计算机系统(或虚拟服务器)。至少需要两个不同的服务器才能组成一个集群,每个集群节点都有自己的主机名和 IP 地址。MongoDB 4.2 集群最多可以有 50 个节点。在实践中,大多数 MongoDB 集群至少有 3 个成员,即使对于非常大的集群,它们也很少超过 10 个节点。

无共享

在其他第三方集群中,集群节点共享公共集群资源,如磁盘存储。相反,MongoDB 采用了“无共享”集群模型,其中节点是独立的计算机。集群节点仅通过 MongoDB 软件连接,并且数据复制是通过互联网执行的。这种模型的优势在于,MongoDB 集群更容易使用廉价的服务器硬件构建。

集群名称

集群名称在 Atlas 控制台中定义,并且用于从 Atlas Web 界面管理集群。如前几章中提到的,在 Atlas 免费版中,只能创建一个集群(M0),其中有三个集群节点。新集群的默认名称为Cluster0。集群的名称在创建后无法更改。

副本集

MongoDB 集群基于集群节点之间的数据复制。数据在所有 MongoDB 数据库实例之间同步复制。

主-从

MongoDB 副本集群中的数据复制是一种主从复制架构。主节点将数据发送到从节点。复制始终是单向的,从主节点到从节点。在 MongoDB 中没有多主复制的选项,因此一次只能有一个主节点。MongoDB 副本集群的所有其他成员必须是从节点。

注意

同一服务器上可以有多个mongod进程。每个mongod进程可以是独立的数据库实例,也可以是副本集群的成员。对于生产服务器,建议每台服务器只部署一个mongod进程。

Oplog

对于 MongoDB 复制而言,一个至关重要的数据库组件是Oplog操作日志)。Oplog 是一个特殊的循环缓冲区,用于保存集群复制的所有数据更改。数据更改是由主数据库上的 CRUD 操作(插入/更新/删除)生成的。然而,数据库查询不会生成任何 Oplog 记录,因为查询不会修改任何数据。

图 10.1:Mongo DB Oplog

图 10.1:Mongo DB Oplog

因此,所有 CRUD 数据库写入都通过更改数据库集合中的 JSON 数据应用到数据文件中(就像在非集群数据库上一样),并保存在 Oplog 缓冲区中进行复制。数据更改操作被转换为一种特殊的幂等格式,可以多次应用并产生相同的结果。

在数据库逻辑级别上,Oplog 显示为本地系统数据库中的一个有上限(循环)的集合。Oplog 集合的大小对于集群操作和维护非常重要。

默认情况下,Oplog 的最大分配大小为服务器空闲磁盘空间的 5%。要检查当前分配的 Oplog 大小(以字节为单位),请使用local数据库查询复制统计信息,如下例所示:

db.oplog.rs.stats().maxSize

以下 JS 脚本将打印 Oplog 的大小(以兆字节为单位):

use local  
var opl = db.oplog.rs.stats().maxSize/1024/1024
print("Oplog size: " + ~~opl + " MB")

这导致以下输出:

图 10.2:运行 JS 脚本后的输出

图 10.2:运行 JS 脚本后的输出

图 10.2所示,此 Atlas 集群的 Oplog 大小为3258 MB

注意

有时,Oplog 被误认为是 WiredTiger 日志记录。日志记录也是数据库更改的日志,但范围不同。虽然 Oplog 是为集群数据复制而设计的,但数据库日志记录是为数据库恢复所需的低级日志。例如,如果 MongoDB 意外崩溃,数据文件可能会损坏,因为最后的更改没有保存。在实例重新启动后,需要日志记录来执行数据库恢复。

复制架构

以下图表描述了一个简单的副本集群架构图,只有三个服务器节点 - 一个主节点和两个从节点:

图 10.3:MongoDB 复制

图 10.3:MongoDB 复制

  • 在上述模型中,PRIMARY 数据库是唯一从数据库客户端接收写操作的活动副本集成员。PRIMARY 数据库保存 Oplog 中的数据更改。在 Oplog 中保存的更改是顺序的,即按照它们接收和执行的顺序保存。

  • SECONDARY 数据库正在查询 PRIMARY 数据库中 Oplog 的新更改。如果有任何更改,那么 Oplog 条目将立即从 PRIMARY 复制到 SECONDARY 上。

  • 然后,SECONDARY 数据库将 Oplog 中的更改应用到自己的数据文件中。Oplog 条目按照它们在日志中插入的顺序应用。因此,SECONDARY 上的数据文件与 PRIMARY 上的更改保持同步。

  • 通常,SECONDARY 数据库直接从 PRIMARY 复制数据更改。有时,SECONDARY 数据库可以从另一个 SECONDARY 复制数据。这种复制类型称为链式复制,因为它是一个两步复制过程。链式复制在某些复制拓扑中很有用,并且在 MongoDB 中默认启用。

注意

重要的是要理解,一旦 MongoDB 实例成为副本集集群的一部分,所有更改都会被复制到 Oplog 以进行数据复制。不可能仅复制一些部分,例如仅复制几个数据库集合。因此,所有用户数据都会被复制并在所有集群成员之间保持同步。

集群成员可以具有不同的状态,例如上图中的 PRIMARY 和 SECONDARY。节点状态可以随着时间的推移而改变,取决于集群活动。例如,一个节点可以在某个时间点处于 PRIMARY 状态,而在另一个时间点处于 SECONDARY 状态。PRIMARY 和 SECONDARY 是集群配置中节点最常见的状态,尽管可能存在其他状态。为了理解它们可能的角色以及它们如何改变,让我们探索集群选举的技术细节。

集群成员

在 Atlas 中,您可以从Clusters页面查看集群成员列表,如下截图所示:

图 10.4:Atlas web 界面

图 10.4:Atlas web 界面

SANDBOX中点击集群名称Cluster0。然后在 Atlas 应用程序中将显示服务器及其角色的列表:

图 10.5:Atlas web 界面

图 10.5:Atlas web 界面

图 10.5所示,此集群有三个集群成员,它们的名称与 Atlas 集群名称具有相同的前缀(在本例中为Cluster0)。对于未使用 Atlas PaaS web 界面(或在本地安装)安装的 MongoDB 集群,可以使用以下 mongo shell 命令检查集群成员:

rs.status().members

将在练习 10.01 检查 Atlas 集群成员中提供使用集群状态命令的示例。

选举过程

所有集群实现的一个特点是在发生故障时能够生存(或故障转移)。MongoDB 副本集受到任何类型的故障的保护,无论是硬件故障、软件故障还是网络中断。负责此过程的 MongoDB 软件称为集群选举,这个名字来源于使用选票进行选举的行为。集群选举的目的是“选举”一个新的主节点。

选举过程是由事件发起的。例如,考虑主节点丢失的情况。类似于政治选举,MongoDB 集群成员参与投票选举新的主节点。只有获得集群中所有选票的多数的选举才被验证。这个公式非常简单:幸存的集群有(N/2 + 1)的多数,其中N是节点的总数。因此,一半加一的选票足以选举出一个新的主节点。这个多数是为了避免分裂脑综合症而必要的。

注意

分裂脑综合症是用来定义同一集群的两个部分被隔离并且它们都“相信”它们是集群中唯一幸存的部分的术语。强制执行“半加一”规则确保只有集群中最大的部分才能选举新的主节点。

图 10.6:MongoDB 选举

图 10.6:MongoDB 选举

考虑前面的图表。在网络分区事件发生后,节点 3 和 5 与集群的其余部分隔离。在这种情况下,左侧(节点 1、2 和 4)形成多数,而节点 3 和 5 形成少数。因此,节点 1、2 和 4 可以选举出一个主节点,因为它们形成了多数集群。然而,也有一些情况,网络分区可能将集群分成两半,节点数量相同。在这种情况下,没有一半具有足够多的节点来选举出一个新的主节点。因此,MongoDB 集群设计的一个关键因素是,集群应始终配置为奇数节点,以避免完美的一半分裂。

并非所有集群成员都可以参与选举。在 MongoDB 集群中,最多可以有七个选票,而成员总数不影响这一规定。这是为了限制选举过程中集群节点之间的网络流量。非投票成员不能参与选举,但它们可以作为辅助节点从主节点复制数据。默认情况下,每个节点可以有一个选票。

练习 10.01:检查 Atlas 集群成员

在这个练习中,您将使用 mongo shell 连接到 Atlas 集群,并识别集群名称和所有集群成员,以及它们当前的状态。使用 JavaScript 列出集群成员:

  1. 连接到 Atlas 数据库使用 mongo shell:
mongo "mongodb+srv://cluster0.u7n6b.mongodb.net/test" --username admindb
  1. 副本集状态函数rs.status()提供了有关集群的详细信息,这些信息在 Atlas Web 界面上是不可见的。列出所有节点及其rs.status成员角色的简单 JS 脚本如下:
var rs_srv = rs.status().members
for (i=0; i<rs_srv.length; i++) {
    print (rs_srv[i].name, '  -  ', rs_srv[i].stateStr)
}

注意

如果您连接到一个辅助节点而不是主节点,则可以从集群的任何节点运行该脚本。

其输出如下:

图 10.7:运行 JS 脚本后的输出

图 10.7:运行 JS 脚本后的输出

我们已经了解了 MongoDB 副本集集群的基本概念。MongoDB 的主从复制技术保护数据库免受任何硬件和软件故障的影响。除了为应用程序和用户提供高可用性和灾难恢复外,MongoDB 集群还易于部署和管理。由于 Atlas 托管数据库服务,用户可以轻松连接到 Atlas 并测试应用程序,而无需在本地安装和配置集群。

客户端连接

MongoDB 连接字符串在第三章服务器和客户端中有介绍。在 Atlas 部署的数据库服务始终是副本集集群,并且连接字符串可以从 Atlas 界面复制。在本节中,我们将探讨客户端与 MongoDB 集群之间的连接。

连接到副本集

一般情况下,MongoDB 连接字符串适用相同的规则。请考虑以下屏幕截图,显示了这样一个连接:

图 10.8:mongo shell 中连接字符串的示例

图 10.8:mongo shell 中连接字符串的示例

图 10.6所示,连接字符串如下所示:

"mongodb+srv://cluster0.<id#>.mongodb.net/<db_name>"

第三章中所述,服务器和客户端,这种类型的字符串需要 DNS 来解析实际的服务器名称或 IP 地址。在本例中,连接字符串包含 Atlas 集群名称cluster0和 ID 号u7n6b

注意

在您的情况下,连接字符串可能会有所不同。这是因为您的 Atlas 集群部署可能具有不同的 ID 号和/或不同的集群名称。您实际的连接字符串可以从 Atlas web 控制台中复制。

仔细检查 shell 中的文本后,我们看到以下细节:

connecting to: mongodb://cluster0-shard-00-00.u7n6b.mongodb.net:27017,cluster0-shard-00-01.u7n6b.mongodb.net:27017,cluster0-shard-00-02.u7n6b.mongodb.net:27017/test?authSource=admin&compressors=disabled&gssapiServiceName=mongodb&replicaSet=atlas-rzhbg7-shard-0&ssl=true

首先要注意的是,第二个字符串比第一个字符串要长得多。这是因为原始连接字符串(成功进行 DNS SRV 查找后)被替换为具有mongodb://URI 前缀的等效字符串。以下表格解释了集群连接字符串的结构:

图 10.9:连接字符串的结构

](https://gitee.com/OpenDocCN/freelearn-bigdata-zh/raw/master/docs/mongo-fund/img/B15507_10_09.jpg)

图 10.9:连接字符串的结构

成功连接和用户认证后,shell 提示将具有以下格式:

MongoDB Enterprise atlas-rzhbg7-shard-0:PRIMARY>
  • MongoDB Enterprise在这里指定了在云中运行的 MongoDB 服务器的版本。

  • atlas-rzhbg7-shard-0表示 MongoDB 副本集名称。请注意,在当前版本的 Atlas 中,MongoDB 副本集名称与集群名称不同,本例中为Cluster0

  • PRIMARY指的是数据库实例的角色。

在 MongoDB 中,集群连接和单个服务器连接之间有明显的区别。连接显示为以下形式的 MongoDB 集群:

replicaset/server1:port1, server2:port2, server3:port3...

要验证从 mongo shell 的当前连接,请使用以下函数:

db.getMongo()

这导致以下输出:

图 10.10:验证 mongo shell 中的连接字符串

](https://gitee.com/OpenDocCN/freelearn-bigdata-zh/raw/master/docs/mongo-fund/img/B15507_10_10.jpg)

图 10.10:验证 mongo shell 中的连接字符串

注意

副本集名称连接参数replicaSet表示连接字符串是用于集群而不是简单的 MongoDB 服务器实例。在这种情况下,shell 将尝试连接到集群的所有服务器成员。从应用程序的角度来看,副本集的行为就像是一个单一的系统,而不是一组独立的服务器。连接到集群时,shell 将始终指示PRIMARY读写实例。

接下来的部分将介绍单服务器连接。

单服务器连接

与连接到非集群 MongoDB 数据库的方式相同,我们有选择地连接到单个集群成员。在这种情况下,目标服务器名称(集群成员)需要包含在连接字符串中。此外,需要删除replicaSet参数。以下是 Atlas 集群的示例:

mongo "mongodb://cluster0-shard-00-00.u7n6b.mongodb.net:27017/test?authSource=admin&ssl=true" --username admindb

注意

另外两个参数authSourcessl需要保留用于 Atlas 服务器连接。如第三章中所述,服务器和客户端,Atlas 已激活授权和 SSL 网络加密以提供云安全保护。

以下截图显示了一个示例:

图 10.11:连接到单个集群成员

](https://gitee.com/OpenDocCN/freelearn-bigdata-zh/raw/master/docs/mongo-fund/img/B15507_10_11.jpg)

图 10.11:连接到单个集群成员

这次,shell 提示显示SECONDARY,表示我们连接到了辅助节点。此外,db.getMongo()函数返回一个简单的服务器和端口号连接。

如前所述,不允许在辅助成员上进行数据更改。这是因为 MongoDB 集群需要在所有集群节点上保持一致的数据副本。因此,只允许在集群的主节点上更改数据。例如,如果我们尝试在连接到辅助成员时修改、插入或更新集合,将会收到not master错误消息,如下截图所示:

图 10.12:在 mongo shell 中获取“not master”错误消息

图 10.12:在 mongo shell 中获取“not master”错误消息

但是,辅助成员允许只读操作,这正是下一个练习的范围。在这个练习中,您将学习如何在连接到辅助集群成员时读取集合。

注意

要在连接到辅助节点时启用读操作,需要运行 shell 命令rs.slaveOk()

练习 10.02:检查集群复制

在这个练习中,您将使用 mongo shell 连接到 Atlas 集群数据库,并观察主要和辅助集群节点之间的数据复制:

  1. 使用 mongo shell 和用户admindb连接到您的 Atlas 集群:
mongo "mongodb+srv://cluster0.u7n6b.mongodb.net/test" --username admindb

注意

在您的情况下,连接字符串可能会有所不同。您可以从 Atlas Web 界面复制连接字符串。

  1. 执行以下脚本在主节点上创建一个新集合,并插入一些具有随机数字的新文档:
use sample_mflix
db.createCollection("new_collection")
for (i=0; i<=100; i++) {
    db.new_collection.insert({_id:i, "value":Math.random()})
}

输出如下:

图 10.13:插入具有随机数字的新文档

图 10.13:插入具有随机数字的新文档

  1. 通过输入以下代码连接到辅助节点:
mongo "mongodb://cluster0-shard-00-00.u7n6b.mongodb.net:27017/test?authSource=admin&ssl=true" --username admindb

注意

在您的情况下,连接字符串可能会有所不同。确保您在连接字符串中编辑正确的服务器节点。连接应指示SECONDARY成员。

  1. 查询集合,查看辅助节点上是否复制了数据。要在辅助节点上读取数据,请运行以下命令:
rs.slaveOk()

输出如下:

图 10.14:在辅助节点上读取数据

图 10.14:在辅助节点上读取数据

在这个练习中,您通过在主节点上插入文档并在辅助节点上查询它们来验证了集群 MongoDB 复制。您可能会注意到,即使 MongoDB 复制是异步的,复制几乎是瞬时的。

读取偏好设置

虽然可以从辅助节点读取数据(如前面的练习所示),但这对应用程序来说并不理想,因为它需要单独的连接。读取偏好设置是 MongoDB 中定义客户端如何自动将读操作重定向到辅助节点的术语,而无需连接到各个节点。客户端可能选择将读操作重定向到辅助节点的原因有几个。例如,在主节点上运行大型查询会减慢所有操作的整体性能。通过在辅助节点上运行查询来卸载主节点是优化插入和更新性能的好方法。

默认情况下,所有操作都在主节点上执行。虽然写操作必须仅在主节点上执行,但读操作可以在任何辅助节点上执行(除了仲裁者节点)。客户端可以在连接到 MongoDB 集群时在会话或语句级别设置读取偏好设置。以下命令可帮助检查当前的读取偏好设置:

db.getMongo().getReadPrefMode()

以下表格显示了 MongoDB 中各种读取偏好设置

图 10.15:MongoDB 中的读取偏好设置

图 10.15:MongoDB 中的读取偏好设置

以下代码显示了设置读取偏好设置的示例(在本例中为secondary):

db.getMongo().setReadPref('secondary')

注意

确保您有当前的集群连接,使用 DNS SRV 或集群/服务器列表。读取偏好设置在单节点连接中无法正常工作。

以下是从 mongo shell 使用读取偏好设置的示例:

图 10.16:从 mongo shell 读取偏好设置

图 10.16:从 mongo shell 读取偏好设置

请注意,一旦读取偏好设置为secondary,shell 客户端会自动将读取操作重定向到次要节点。执行查询后,shell 会返回到primary(shell 提示:PRIMARY)。所有后续查询将被重定向到secondary

注意

读取偏好设置在客户端从副本集断开连接时会丢失。这是因为读取偏好设置是客户端设置(而不是服务器)。在这种情况下,您需要在重新连接到 MongoDB 集群后再次设置读取偏好设置。

读取偏好设置也可以作为连接字符串 URI 的选项进行设置,使用?readPreference参数。例如,考虑以下连接字符串:

"mongodb+srv://atlas1-u7n6b.mongodb.net/?readPreference=secondary"

注意

MongoDB 在集群中为设置读取偏好提供了更复杂的功能。在更高级的配置中,管理员可以为每个集群成员设置标记名称。例如,标记名称可以指示集群成员位于特定的地理区域或数据中心。然后,标记名称可以作为db.setReadPref()函数的参数,将读取重定向到客户端位置附近的特定地理区域。

写入关注

默认情况下,Mongo 客户端在主节点上每次写入操作(插入/更新/删除)都会收到确认。确认返回代码可以在应用程序中使用,以确保数据安全写入数据库。然而,在副本集集群中,情况更为复杂。例如,可能在主实例中插入行,但如果在副本节点应用复制 Oplog 记录之前主节点崩溃,那么存在数据丢失的风险。写入关注通过确保在多个集群节点上确认写入来解决这个问题。因此,在主节点意外崩溃的情况下,插入的数据不会丢失。

默认情况下,写入关注为{w: 1},表示仅从主实例获得确认。{w: 2}将要求每个写入操作的两个节点进行确认。然而,多个节点的确认会带来成本。写入关注的大数字可能导致集群上的写入操作变慢。(w: "majority")表示大多数集群节点。此设置有助于确保在意外故障情况下数据的安全性。

写入关注可以在集群级别或写入语句级别进行设置。在 Atlas 中,我们无法看到或配置写入关注,因为 MongoDB 预设为{w: "majority"}。以下是语句级别的写入关注示例:

db.new_collection.insert({"_id":1, "info": "test writes"},
                             {w:2})

所有 CRUD 操作(除查询外)都有写入关注的选项。可以选择设置第二个参数wtimeout: 1000,以配置最大超时时间(以毫秒为单位)。

以下屏幕截图显示了一个示例:

图 10.17:mongo shell 中的写入关注

图 10.17:mongo shell 中的写入关注

MongoDB 客户端在复制集群中有许多选项。了解集群环境中客户端会话的基础知识对应用程序开发至关重要。如果开发人员忽视了集群配置,可能会导致错误。例如,一个常见的错误是在主节点上运行所有查询,或者假设默认情况下执行次要读取而无需任何配置。设置读取偏好可以显著提高应用程序的性能,同时减少主集群节点的负载。

部署集群

设置新的 MongoDB 副本集集群是一个通常在新开发项目开始时需要的操作任务。根据新环境的复杂程度,部署新的副本集集群可能从相对简单、直接、简单的配置到更复杂和企业级的集群部署。一般来说,部署 MongoDB 集群需要比安装单个服务器数据库更多的技术和操作知识。规划和准备是必不可少的,在部署集群之前绝不能忽视。这是因为用户需要仔细规划集群架构、基础设施和数据库安全性,以提供最佳的数据库性能和可用性。

关于用于 MongoDB 副本集集群部署的方法,有一些工具可以帮助自动化和管理部署。最常见的方法是手动部署。然而,手动方法可能是最费力的选择,尤其是对于复杂的集群。MongoDB 和其他第三方软件提供商提供了自动化工具。接下来的部分将介绍用于 MongoDB 集群部署的最常见方法以及每种方法的优势。

Atlas 部署

在 Atlas 云上部署 MongoDB 集群是开发人员可以选择的最简单选项,因为它节省了精力和金钱。MongoDB 公司管理基础设施,包括服务器硬件、操作系统、网络和mongod实例。因此,用户可以专注于应用程序开发和 DevOps,而不是花时间在基础设施上。在许多情况下,这是快速交付项目的完美解决方案。

在 Atlas 上部署集群只需要在 Atlas Web 应用程序中点击几下即可。您已经熟悉了在 Atlas 中进行数据库部署的方法,这是从第一章《MongoDB 简介》中学到的。免费的 Atlas M0 集群是一个非常适合学习和测试的免费环境。事实上,在 Atlas 中的所有部署都是副本集集群。在当前的 Atlas 版本中,不可能在 Atlas 中部署单服务器集群。

Atlas 为更大规模的部署提供了更多的集群选项,这是收费服务。如果需要,Atlas 集群可以轻松扩展——无论是纵向(增加服务器资源)还是横向(增加更多成员)。在专用的 Atlas 服务器 M10 及更高版本上,可以构建多区域的副本集集群。因此,高可用性可以跨地理区域,覆盖欧洲和北美。这个选项非常适合在远程数据中心分配只读次要节点。

以下截图显示了一个多区域集群配置的示例:

图 10.18:多区域集群配置

图 10.18:多区域集群配置

在前面的例子中,主数据库在伦敦,还有两个次要节点,而在澳大利亚的悉尼,还配置了一个额外的只读次要节点。

手动部署

手动部署是 MongoDB 集群部署最常见的形式。对于许多开发人员来说,手动构建 MongoDB 集群也是首选的数据库安装方法,因为这种方法可以让他们完全控制基础设施和集群配置。然而,与其他方法相比,手动部署更费力,这使得这种方法在大型环境中不太可扩展。

您可以按照以下步骤手动部署 MongoDB 集群:

  1. 选择新集群的服务器成员。无论是物理服务器还是虚拟服务器,它们都必须满足 MongoDB 数据库的最低要求。此外,所有集群成员的硬件和软件规格(CPU、内存、磁盘和操作系统)应该是相同的。

  2. 每台服务器上都必须安装 MongoDB 二进制文件。在所有服务器上使用相同的安装路径。

  3. 在每台服务器上运行一个mongod实例。服务器应该在单独的硬件上,具有单独的电源和网络连接。但是,对于测试,可以将所有集群成员部署在单个物理服务器上。

  4. 使用--bind_ip参数启动 Mongo 服务器。默认情况下,mongod仅绑定到本地 IP 地址(127.0.0.1)。为了与其他集群成员通信,mongod必须绑定到外部私有或公共 IP 地址。

  5. 正确设置网络。每台服务器必须能够自由地与其他成员通信,而无需防火墙。此外,服务器的 IP 和 DNS 名称必须在 DNS 域配置中匹配。

  6. 为数据库文件和数据库实例日志创建目录结构。在所有服务器上使用相同的路径。例如,在 Unix/macOS 系统上使用/data/db用于数据库文件(WiredTiger 存储),使用/var/log/mongodb用于日志文件,在 Windows 操作系统的情况下,使用C:\data\db目录用于数据文件,使用C:\log\mongo用于日志文件。目录必须为空(创建新的数据库集群)。

  7. 在每台服务器上使用副本集参数replSet启动mongod实例。要启动mongod实例,请启动操作系统命令提示符或终端,并对 Linux 和 macOS 执行以下命令:

mongod --replSet cluster0 --port 27017 --bind_ip <server_ip_address> --dbpath /data/db --logpath /var/log/mongodb/cluster0.log --oplogSize 100

对于 Windows 操作系统,命令如下:

mongod --replSet cluster0 --port 27017 --bind_ip <server_ip_address> --dbpath C:\mongo\data --logpath C:\mongo\log\cluster0.log --oplogSize 100

以下表格列出了每个参数及其描述:

图 10.19:命令中参数的描述

图 10.19:命令中参数的描述

  1. 使用 mongo shell 连接到新的集群:
mongo mongodb://hostname1.domain/cluster0
  1. 创建集群配置 JSON 文档并将其保存在 JS 变量(cfg)中:
var cfg = {
    _id : "cluster0",   
    members : [
       { _id : 0, host : "hostname1.domain":27017"},  
       { _id : 1, host : "hostname2.domain":27017"},
       { _id : 2, host : "hostname3.domain":27017"},  
       ]
}

注意

上述配置步骤不是真实的命令。hostname1.domain应替换为与 DNS 记录匹配的真实主机名和域名。

  1. 按以下方式激活集群:
rs.initiate( cfg )

集群激活保存配置并启动集群配置。在集群配置期间,成员节点进行选举过程,决定新的主实例。

配置激活后,shell 提示将显示集群名称(例如,cluster0:PRIMARY>)。此外,您可以使用rs.status()命令检查集群状态,该命令提供有关集群和成员服务器的详细信息。在下一个练习中,您将设置一个 MongoDB 集群。

练习 10.03:构建您自己的 MongoDB 集群

在这个练习中,您将设置一个新的 MongoDB 集群,该集群将有三个成员。所有mongod实例将在本地计算机上启动,并且您需要为每台服务器设置不同的目录,以便实例不会在相同的数据文件上发生冲突。您还需要为每个实例使用不同的 TCP 端口:

  1. 创建文件目录。对于 Windows 操作系统,应该如下所示:

C:\data\inst1:用于实例 1 数据文件

C:\data\inst2:用于实例 2 数据文件

C:\data\inst3:用于实例 3 数据文件

C:\data\log:日志文件目的地

对于 Linux,文件目录如下。请注意,对于 MacOS,您可以使用任何您选择的目录名称,而不是/data

/data/db/inst1:用于实例 1 数据文件

/data/db/inst2:用于实例 2 数据文件

/data/db/inst3:用于实例 3 数据文件

/var/log/mongodb:日志文件目的地

以下屏幕截图显示了 Windows 资源管理器中的示例:

图 10.20:目录结构

图 10.20:目录结构

对于各个实例,使用以下 TCP 端口:

实例 1:27001

实例 2:27002

实例 3:27003

使用副本集名称my_cluster。Oplog 大小应为 50 MB。

  1. 从 Windows 命令提示符启动mongod实例。使用start来运行mongod启动命令。这将为该进程创建一个新窗口。否则,start mongod命令可能会挂起,您将需要使用另一个命令提示符窗口。请注意,对于 MacOS,您需要使用sudo而不是start
start mongod --replSet my_cluster --port 27001 --dbpath C:\data\inst1 -- logpath C:\data\log\inst1.log --logappend --oplogSize 50

start mongod --replSet my_cluster --port 27002 --dbpath C:\data\inst2 -- logpath C:\data\log\inst2.log --logappend --oplogSize 50

start mongod --replSet my_cluster --port 27003 --dbpath C:\data\inst3 -- logpath C:\data\log\inst3.log --logappend --oplogSize 50

注意

--logappend参数会在日志文件末尾添加日志消息。否则,每次启动mongod实例时,日志文件都会被截断。

  1. 检查日志目标文件夹(C:\data\log)中的启动消息。每个实例都有一个单独的日志文件,在日志的末尾应该有一条消息,如下面的代码片段所示:
16.613+1000 I  NETWORK  [initandlisten] waiting for connections on port 27001
  1. 在一个单独的终端(或 Windows 命令提示符)中,使用以下命令连接到集群,使用 mongo shell:
mongo mongodb://localhost:27001/replicaSet=my_cluster

以下截图显示了使用 mongo shell 的示例:

图 10.21:mongo shell 中的输出

图 10.21:mongo shell 中的输出

请注意,尽管您在连接字符串中使用了replicaSet参数,但 shell 命令提示符只是>。这是因为集群尚未配置。

  1. 编辑集群配置 JSON 文档(在 JS 变量cfg中):
var cfg = {
    _id : "my_cluster",     //replica set name
    members : [
      { _id : 0, host : "localhost:27001"},  
      { _id : 1, host : "localhost:27002"},
      { _id : 2, host : "localhost:27003"},  
      ]
}

注意

这段代码可以直接输入到 mongo shell 中。

  1. 激活集群配置如下:
rs.initiate( cfg )

请注意,集群通常需要一些时间来激活配置并选举新的主节点:

图 10.22:mongo shell 中的输出

图 10.22:mongo shell 中的输出

在选举过程完成并成功后,shell 提示应指示集群连接(最初为mycluster: SECONDARY,然后为PRIMARY)。如果您的提示仍然显示SECONDARY,请尝试重新连接或检查服务器日志以查找错误。

  1. 验证集群配置。为此,使用 mongo shell 连接并验证提示符是否为PRIMARY>,然后运行以下命令来检查集群状态:
rs.status()

运行以下命令来验证当前的集群配置:

rs.conf()

两个命令都返回了很多细节的长输出。预期结果如下截图所示(显示了部分输出):

图 10.23:mongo shell 中的输出

图 10.23:mongo shell 中的输出

在这个练习中,您手动部署了副本集群的所有成员到您的本地系统。这个练习仅用于测试目的,不应用于真实应用程序。在现实生活中,MongoDB 集群节点应该部署在单独的服务器上,但这个练习为副本集的初始配置提供了一个很好的内部视图,对于快速测试特别有用。

企业部署

对于大规模企业应用程序,MongoDB 提供了用于管理部署的集成工具。可以想象,为何部署和管理数百个 MongoDB 集群服务器可能是一个非常具有挑战性的任务。因此,在大型企业规模的 MongoDB 环境中,能够在集成界面中管理所有部署是至关重要的。

MongoDB 提供了两种不同的接口:

  • MongoDB OPS Manager是 MongoDB Enterprise Advanced 可用的一个包。通常需要在本地安装。

  • MongoDB Cloud Manager是一个云托管服务,用于管理 MongoDB 企业部署。

注意

Cloud Manager 和 Atlas 都是云应用程序,但它们提供不同的服务。虽然 Atlas 是一个完全托管的数据库服务,Cloud Manager 是一个用于管理数据库部署的服务,包括本地服务器基础设施。

这两个应用程序为企业用户提供了类似的功能,包括部署的集成自动化、高级图形监控和备份管理。使用 Cloud Manager,管理员可以部署所有类型的 MongoDB 服务器(单个和集群),同时保持对基础架构的完全控制。

以下图表显示了 Cloud Manager 的架构:

图 10.24:云管理器架构

图 10.24:云管理器架构

该架构基于中央管理服务器和 MongoDB 代理。在 Cloud Manager 中管理服务器之前,需要在服务器上部署 MongoDB 代理。

注意

MongoDB 代理软件不应与 MongoDB 数据库软件混淆。MongoDB 代理软件用于 Cloud Manager 和 OPS Manager 的集中管理。

关于 Cloud Manager,实际上并不需要用户下载和安装 MongoDB 数据库。一旦代理安装并将服务器添加到 Cloud Manager 配置中,所有 MongoDB 版本都将由部署服务器自动管理。MongoDB 代理将自动下载、分阶段和安装服务器上的 MongoDB 服务器二进制文件。

以下截图显示了 MongoDB Cloud Manager 的一个示例:

图 10.25:云管理器截图

图 10.25:云管理器截图

Cloud Manager 的 Web 界面类似于 Atlas 应用程序。它们之间的一个主要区别是 Cloud Manager 具有更多功能。虽然 Cloud Manager 可以管理 Atlas 部署,但对于 MongoDB 企业部署,它提供了更复杂的选项。

第一步是添加部署(New Replica Set按钮),然后向部署添加服务器并安装 MongoDB 代理。一旦 MongoDB 代理安装在集群成员上,部署将由代理自动执行。

注意

您可以在 MongoDB Cloud 上免费测试 Cloud Manager 30 天。注册过程类似于第一章中展示的步骤,MongoDB 简介

MongoDB Atlas 托管的 DBaaS 云服务是一个快速且易于部署的平台。大多数用户会发现 Atlas 是他们首选的数据库部署选择,因为云环境是完全托管的、安全的,并且始终可用。然而,与 MongoDB 本地部署相比,Atlas 云服务对用户有一些限制。例如,Atlas 不允许用户访问或调整硬件和软件基础设施。如果用户希望对基础设施拥有完全控制权,他们可以选择手动部署 MongoDB 数据库。对于大型企业数据库部署,MongoDB 提供了 Cloud Manager 等软件解决方案,用于管理许多集群部署,同时仍然完全控制基础设施。

集群操作

假设您的运行 MongoDB 数据库的服务器之一报告了内存错误。您有点担心,因为该计算机正在运行您集群的主活动成员。服务器需要维护以更换故障的 DIMM(双列直插式内存模块)。您决定将主实例切换到另一台服务器。维护应该不到一个小时,但您希望确保用户在维护期间可以使用他们的应用程序。

MongoDB 集群操作是指为了集群维护和监控而必要的日常管理任务。这对于手动部署的集群尤为重要,用户必须完全管理和操作副本集集群。在 Atlas DBaaS 托管服务的情况下,唯一的交互是通过 Atlas Web 应用程序进行的,大部分工作都是由 MongoDB 在后台完成的。因此,我们的讨论将局限于手动部署的 MongoDB 集群,无论是在本地基础设施还是在云 IaaS(基础设施即服务)中。

添加和移除成员

可以使用命令rs.add()将新成员添加到副本集。在添加新成员之前,需要准备并使用相同的—replSet集群名称选项启动mongod实例。新集群成员也适用相同的规则。例如,启动新的mongod实例如下所示:

mongod --dbpath C:\data\inst4 --replSet <cluster_name>  --bind_ip <hostname> --  logpath <disk path>

在向现有副本集添加新成员之前,我们需要决定成员的类型。有以下选项可供选择:

图 10.26:成员类型的描述

图 10.26:成员类型的描述

添加成员

在添加新的集群成员时,可以传递一些参数,这取决于成员类型。在最简单的形式中,add命令只有一个参数——包含新实例的主机名和端口的字符串:

rs.add ( "node4.domain.com:27004" )

在添加成员时请记住以下事项:

  • 应向集群添加SECONDARY成员。

  • 优先级可以是01000之间的任何数字。如果此实例被选为主节点,则优先级必须大于0。否则,该实例被视为只读。此外,HIDDENDELAYARBITER实例类型的优先级必须为0。默认值为1

  • 所有节点默认都有一票。在 4.4 版本中,节点可以有 0 票或 1 票。最多可以有 7 个投票成员,每个成员一票。其余节点不参与选举过程,票数为 0。默认值为 1。

以下屏幕截图显示了添加成员的示例:

图 10.27:添加成员示例

图 10.27:添加成员示例

在前面的屏幕截图中,“ok”:1 表示添加成员操作成功。在新实例日志中,新的副本集成员的初始同步(数据库复制)已经开始:

INITSYNC [replication-0] Starting initial sync (attempt 1 of 10)

0添加了不同的成员类型,但add命令可能不同。例如,要添加一个带有投票的隐藏成员,请添加以下内容:

rs.add ( {host: "node4.domain.com:27017", hidden : true,   votes : 1})

如果成功,add命令将执行以下操作:

  • 通过添加新成员节点更改集群配置

  • 执行初始同步——数据库被复制到新的成员实例(除了ARBITER的情况)

在某些情况下,添加新成员可能会改变当前的主节点。

注意

新成员集群在加入副本集集群之前必须具有空数据库(空数据目录)。在同步过程中在主节点上生成的 Oplog 操作也会被复制并应用到新的集群成员上。同步过程可能需要很长时间,特别是如果同步是通过互联网进行的。

移除成员

可以通过连接到集群并运行以下命令来移除集群成员:

rs.remove({ <hostname.com> })

注意

移除集群成员不会移除实例和数据文件。实例可以在单服务器模式下启动(不带—replSet选项),数据文件将包含被移除之前的最新更新。

重新配置集群

如果要对副本集进行更复杂的更改,例如一次添加多个节点或编辑投票和优先级的默认值,则可能需要重新配置集群。可以通过运行以下命令来重新配置集群:

rs.reconfig()

以下是对具有不同优先级的每个节点进行集群重新配置的逐步分解:

  • 将配置保存在 JS 变量中,如下所示:
var new_cfg = rs.config()
  • 编辑new_conf以通过添加以下片段更改默认优先级:
new_conf.members[0].priority=1
new_conf.members[1].priority=0.5
new_conf.members[2].priority=0
  • 启用新配置如下:
rs.reconfig(new_cfg)

以下屏幕截图显示了集群重新配置的示例:

图 10.28:集群重新配置示例

图 10.28:集群重新配置示例

故障转移

在某些情况下,MongoDB 集群可能会启动选举过程。在数据中心运营术语中,这些类型的事件通常称为故障转移切换

  • 故障转移总是由事件引起的。当一个或多个集群成员变得不可用(通常是因为故障或网络中断)时,集群将进行故障转移。副本集检测到一些节点变得不可用,并自动启动副本集选举。

注意

复制集群如何检测故障?成员服务器定期进行通信,每隔几秒发送/接收心跳网络请求。如果一个成员在较长时间内没有回复(默认为 10 秒),则该成员被宣布不可用,并启动新的集群选举。

  • 切换是用户发起的过程(即由服务器命令发起)。切换的目的是对集群进行计划维护。例如,运行主成员的服务器需要重新启动进行操作系统修补,管理员将主切换到另一个集群成员。

无论是故障转移还是切换,选举机制都会启动,集群旨在实现新的多数,并在成功时成为新的主节点。在选举过程中,存在一个过渡期,在此期间数据库上无法进行写操作,客户端会重新连接到新的主成员。应用程序编码应能够透明地处理 MongoDB 故障转移事件。

在 Atlas 中,MongoDB 会自动管理故障转移,因此不需要用户参与。在较大的 Atlas 部署中(例如 M10+),Atlas 应用程序中提供了“测试故障转移”按钮。该按钮将强制应用程序测试进行集群故障转移。如果无法实现新的集群多数,那么所有节点将保持次要状态,不会选举出主节点。在这种情况下,客户端将无法修改数据库中的任何数据。但是,无论集群状态如何,所有次要节点上仍然可以进行只读操作。

故障转移(故障)

在发生故障时,通常可以在实例日志中看到以下代码片段中的消息:

2019-11-25T15:08:05.893+1000  REPL     [replexec-0] Member localhost:27003 is now in state RS_DOWN - Error connecting to localhost:27003 (127.0.0.1:27003) :: caused by :: No connection could be made because the target machine actively refused it.

客户端会自动重新连接到剩余节点,并且活动可以像往常一样继续。一旦缺失的节点重新启动,它将自动重新加入集群。如果集群无法成功完成选举,则故障转移不被视为成功。在日志中,我们可以看到这样的消息:

2019-11-25T15:08:05.893+1000 I  ELECTION [replexec-4] not becoming primary, we received insufficient votes
...Election failed.

在这种情况下,客户端连接会中断,并且用户无法重新连接,除非读取偏好设置为secondary

2019-11-25T15:09:45.928+1000 W  NETWORK  [ReplicaSetMonitor-TaskExecutor] Unable to reach primary for set my_cluster
2019-11-25T15:09:45.929+1000 E  QUERY    [js] Error: Could not find host matching read preference { mode: "primary", tags: [ {} ] } for set my_cluster :

即使选举不成功,用户也可以使用读取偏好secondary设置进行连接,如以下连接字符串所示:

mongo mongodb://localhost:27001/?readPreference=secondary&replicaSet=my_cluster

注意

除非有足够的节点形成集群多数,否则不可能以读写模式(主状态)打开数据库实例。一个典型的错误是同时重新启动多个次要成员。如果集群检测到多数丢失,那么主状态成员将降级为次要成员。

回滚

在某些情况下,故障转移事件可能会导致在以前的主节点上回滚写操作。如果在主节点上使用默认的写关注(w:1)执行写操作,并且以前的主节点在有机会将更改复制到任何次要节点之前崩溃,则可能会发生这种情况。集群形成新的多数,活动将继续进行,并且会有一个新的主节点。以前的主节点恢复后,需要回滚这些(以前未复制的)事务,然后才能与新的主节点同步。

通过将写关注设置为majorityw: 'majority')可以减少回滚的可能性,即通过从大多数集群节点(多数)获得每个数据库写操作的确认。不利的一面是,这可能会减慢应用程序的写入速度。

通常,故障和停机会很快得到解决,并且受影响的节点在恢复时重新加入集群。但是,如果停机时间很长(例如一周),那么辅助实例可能会变得过时。过时的实例在重新启动后将无法与主成员重新同步数据。在这种情况下,该实例应被添加为新成员(空数据目录)或从最近的数据库备份中添加。

切换(Stepdown)

对于维护活动,我们经常需要将主状态从一个实例转移到另一个实例。为此,在主节点上要执行的用户 admin 命令如下:

rs.stepDown()

stepDown命令将强制主节点下台,并导致优先级最高的辅助节点上台成为新的主节点。只有在辅助节点是最新的情况下,主节点才会下台。因此,与故障切换相比,切换是一种更安全的操作。在以前的主成员上没有丢失写入的风险。

以下屏幕截图显示了一个示例:

图 10.29:使用 stepDown 命令

图 10.29:使用 stepDown 命令

您可以通过运行以下命令来验证当前的主节点:

rs.isMaster()

请注意,为了使切换成功,目标集群成员必须配置为具有更高的优先级。具有默认优先级(priority = 0)的成员永远不会成为主要成员。

练习 10.04:执行数据库维护

在这个练习中,您将在主节点上执行集群维护。首先,您将切换到辅助服务器inst2,以便当前的主服务器将变为辅助服务器。然后,您将关闭以前的主服务器进行维护,并重新启动以前的主服务器并进行切换:

注意

在开始这个练习之前,按照练习 10.02中给出的步骤准备好集群脚本和目录。

  1. 启动所有集群成员(如果尚未启动),连接到 mongo shell,并使用rs.isMaster().primary验证配置和当前主节点。

  2. 重新配置集群。为此,将现有的集群配置复制到一个变量sw_over中,并设置只读成员的优先级。对于inst3,优先级应设置为0(只读)。

var sw_over = rs.conf()
sw_over.member[2].priority = 0
rs.reconfig(sw_over)
  1. 切换到inst2。在主节点上,运行以下stepDown命令:
rs.stepDown()
  1. 使用以下命令验证新的主节点是否为inst2
rs.isMaster().primary

现在,inst1可以停止进行硬件维护。

  1. 使用以下命令在本地关闭实例:
db.shutdownServer()

这个输出应该是这样的:

图 10.30:在 mongo shell 中的输出

图 10.30:在 mongo shell 中的输出

在这个练习中,您练习了集群中的切换步骤。命令非常简单。切换是一个很好的实践,可以测试应用程序如何处理 MongoDB 集群事件。

活动 10.01:测试 MongoDB 数据库的灾难恢复程序

您的公司即将上市,因此需要一些证书来证明在灾难发生时已经制定了业务连续性计划。其中一个要求是为 MongoDB 数据库实施并测试灾难恢复程序。集群架构分布在主办公室(主实例)和远程办公室(辅助实例)之间,后者是灾难恢复位置。为了帮助在网络分裂的情况下进行 MongoDB 副本集选举,还在第三个独立位置安装了一个仲裁者节点。每年一次,灾难恢复计划都会通过模拟主办公室中所有集群成员的崩溃来进行测试,而今年,这项任务就落在了您身上。以下步骤将帮助您完成此活动:

注意

如果您有多台计算机,最好尝试使用两台或三台计算机进行此操作,每台计算机模拟一个物理位置。但是,在解决方案中,此操作将通过在同一台本地计算机上启动所有实例来完成。所有辅助数据库(包括 DR)在启动活动时应与主数据库同步。

  1. 使用三个成员配置sale-cluster集群:

sale-prod:主要

sale-dr:次要

sale-ab:仲裁者(第三位置)

  1. 将测试数据记录插入主要集合。

  2. 模拟灾难。重新启动主节点(即,终止当前的mongod主实例)。

  3. 通过插入一些文档在 DR 上执行测试。

  4. 关闭 DR 实例。

  5. 重新启动主办公室的所有节点。

  6. 10 分钟后,启动 DR 实例。

  7. 观察插入的测试记录的回滚并重新与主数据库同步。

重新启动sales_dr后,您应该在日志中看到回滚消息。以下代码片段显示了一个示例:

ROLLBACK [rsBackgroundSync] transition to SECONDARY
2019-11-26T15:48:29.538+1000 I  REPL     [rsBackgroundSync] transition to SECONDARY from ROLLBACK
2019-11-26T15:48:29.538+1000 I  REPL     [rsBackgroundSync] Rollback successful.

注意

可以通过此链接找到此活动的解决方案。

摘要

在本章中,您了解到 MongoDB 副本集对于在 MongoDB 数据库环境中提供高可用性和负载共享至关重要。虽然 Atlas 透明地为基础设施和软件(包括副本集群管理)提供支持,但并非所有 MongoDB 集群都部署在 Atlas 中。在本章中,我们讨论了副本集群的概念和操作。了解有关集群的简单概念,例如读取首选项,可以帮助开发人员在云中构建更可靠、高性能的应用程序。在下一章中,您将了解 MongoDB 中的备份和还原操作。

第十一章:MongoDB 中的备份和恢复

概述

在本章中,我们将详细研究如何将备份、样本和测试数据库加载到目标 MongoDB 实例中,同样重要的是,你将学会如何导出现有数据集以备份和恢复。到本章结束时,你将能够将 MongoDB 数据备份、导出、导入和恢复到现有服务器中。这使你能够从灾难中恢复数据,以及快速将已知信息加载到系统进行测试。

介绍

在之前的章节中,我们主要依赖预加载到 MongoDB Atlas 实例中的样本数据。除非你在进行新项目,否则这通常是数据库首次出现的方式。然而,当你被雇佣或转移到一个包含 MongoDB 数据库的不同项目时,它将包含在你开始之前创建的所有数据。

现在,如果你需要一个本地副本来测试你的应用程序或查询呢?直接对生产数据库运行查询通常是不安全或不可行的,因此将数据集复制到测试环境是非常常见的过程。同样,当创建一个新项目时,你可能希望将一些样本数据或测试数据加载到数据库中。在本章中,我们将研究迁移、导入或导出现有 MongoDB 服务器的程序,并设置一个包含现有数据的新数据库的程序。

注意

在本章中,包括的练习和活动都是对一个场景的迭代。数据和示例都基于名为sample_mflix的 MongoDB Atlas 示例数据库。

在本章期间,我们将按照一个理论场景进行一系列练习。这是对第七章“数据聚合”和第八章“在 MongoDB 中编写 JavaScript”中涵盖的场景的扩展。你可能还记得,一个电影院连锁要求你创建查询和程序,分析他们的数据库,以制作在促销季期间放映的电影列表。

在这些章节中,你已经构建了一些聚合,其输出是包含摘要数据的新集合。你还创建了一个应用程序,使用户可以以编程方式更新电影。公司对你的工作非常满意,他们决定将整个系统迁移到更重要、更好的硬件上。尽管系统管理员们对将现有的 MongoDB 实例迁移到新硬件上感到自信,但你决定最好手动测试该过程,以确保在需要时能够提供帮助。

MongoDB 实用程序

mongo shell 不包括导出、导入、备份或恢复的功能。然而,MongoDB 已经创建了方法来实现这一点,因此不需要脚本工作或复杂的图形用户界面。为此,提供了几个实用程序脚本,可以用于批量将数据进出数据库。这些实用程序脚本包括:

  • mongoimport

  • mongoexport

  • mongodump

  • mongorestore

我们将在接下来的章节中详细介绍这些实用程序。正如它们的名称所示,这四个实用程序对应于导入文档、导出文档、备份数据库和恢复数据库。我们将从导出数据的主题开始。

导出 MongoDB 数据

在批量移动数据进出 MongoDB 时,最常见且通用的实用程序是mongoexport。这个命令很有用,因为它是从 MongoDB 中提取大量数据的主要方式之一。将 MongoDB 数据导出到 JSON 文件中,可以让你将其与其他应用程序或数据库一起使用,并与 MongoDB 之外的利益相关者共享数据。

重要的是要注意,mongoexport 必须在指定的单个数据库和集合上运行。 不能在整个数据库或多个集合上运行 mongoexport。 我们将在本章后面看到如何完成类似这样的更大范围的备份。 以下片段是 mongoexport 的示例:

mongoexport --uri=mongodb+srv://USERNAME:PASSWORD@provendocs-fawxo.gcp.mongodb.net/sample_mflix –quiet --limit=10 --sort="{theaterId:1}" --collection=theaters --out=output.json

这个例子是一个更复杂的命令,其中包括一些可选参数并明确设置其他参数。 但是在实践中,您的导出命令可能会简单得多。 这里使用的结构和参数在下一节中有详细解释。

使用 mongoexport

学习 mongoexport 语法的最佳方法是逐个参数地构建命令。 所以让我们从最简单的导出开始:

mongoexport –-collection=theaters

正如您所看到的,命令的最简单形式只需要一个参数:–-collection。 此参数是我们希望导出文档的集合。

如果执行此命令,可能会遇到一些令人困惑的结果,如下所示:

2020-03-07-T13:16:09.152+1100 error connecting to db server: no reachable servers

我们得到这个结果是因为我们没有指定数据库或 URI。 在这种情况下,mongoexport 默认使用本地 MongoDB 的端口 27017 和默认数据库。 由于在上一章的示例和练习中我们一直在 Atlas 上运行我们的 MongoDB 服务器,让我们更新我们的命令以指定这些参数。

注意

您不能同时指定数据库和 URI;这是因为数据库是 URI 的一部分。 在本章中,我们将使用 URI 进行导出。

更新后的命令如下所示:

mongoexport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlasServer.gcp.mongodb.net/sample_mflix --collection=theaters

现在您有一个有效的命令,可以针对 MongoDB Atlas 数据库运行它。 您将看到以下输出:

2020-08-17T11:07:23.302+1000    connected to: mongodb+srv://[**REDACTED**]@performancetuning.98afc.gcp.mongodb.net/sample_mflix
{"_id":{"$oid":"59a47286cfa9a3a73e51e72c"},"theaterId":1000,"location":  {"address":{"street1":"340 W Market","city":"Bloomington","state":"MN","zipcode":"55425"},"geo":  {"type":"Point","coordinates":[-93.24565,44.85466]}}}
{"_id":{"$oid":"59a47286cfa9a3a73e51e72d"},"theaterId":1003,"location":  {"address":{"street1":"45235 Worth Ave.","city":"California","state":"MD","zipcode":"20619"},"geo":  {"type":"Point","coordinates":[-76.512016,38.29697]}}}
{"_id":{"$oid":"59a47286cfa9a3a73e51e72e"},"theaterId":1008,"location":  {"address":{"street1":"1621 E Monte Vista Ave","city":"Vacaville","state":"CA","zipcode":"95688"},"geo":  {"type":"Point","coordinates":[-121.96328,38.367649]}}}
{"_id":{"$oid":"59a47286cfa9a3a73e51e72f"},"theaterId":1004,"location":  {"address":{"street1":"5072 Pinnacle Sq","city":"Birmingham","state":"AL","zipcode":"35235"},"geo":  {"type":"Point","coordinates":[-86.642662,33.605438]}}}

在输出的末尾,您应该看到导出的记录数:

{"_id":{"$oid":"59a47287cfa9a3a73e51ed46"},"theaterId":952,"location":  {"address":{"street1":"4620 Garth Rd","city":"Baytown","state":"TX","zipcode":"77521"},"geo":  {"type":"Point","coordinates":[-94.97554,29.774206]}}}
{"_id":{"$oid":"59a47287cfa9a3a73e51ed47"},"theaterId":953,"location":  {"address":{"street1":"10 McKenna Rd","city":"Arden","state":"NC","zipcode":"28704"},"geo":  {"type":"Point","coordinates":[-82.536293,35.442486]}}}
2020-08-17T11:07:24.992+1000    [########################]  sample_mflix.theaters  1564/1564  (100.0%)
2020-08-17T11:07:24.992+1000    exported 1564 records

使用指定的 URI,导出操作成功,并且您可以看到从 theatres 集合中的所有文档。 但是,将所有这些文档淹没在输出中并不是很有用。 您可以使用一些 shell 命令将此输出管道或附加到文件中,但是 mongoexport 命令在其语法中提供了另一个参数,用于自动输出到文件。 您可以在以下命令中看到此参数 (--out):

mongoexport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlasServer.gcp.mongodb.net/sample_mflix --collection=theaters --out=output.json

运行此命令后,您将看到以下输出:

2020-08-17T11:11:44.499+1000    connected to: mongodb+srv://[**REDACTED**]@performancetuning.98afc.gcp.mongodb.net/sample_mflix
2020-08-17T11:11:45.634+1000    [........................]  sample_mflix.theaters  0/1564  (0.0%)
2020-08-17T11:11:45.694+1000    [########################]  sample_mflix.theaters  1564/1564  (100.0%)
2020-08-17T11:11:45.694+1000    exported 1564 records

现在,在该目录中创建了一个名为 output.json 的新文件。 如果您查看此文件,您可以看到我们从 theatres 集合中导出的文档。

参数 uricollectionout 可以满足大多数导出用例。 一旦您的数据在磁盘上的文件中,就很容易将其与其他应用程序或脚本集成。

mongoexport 选项

现在我们知道了 mongoexport 的三个最重要的选项。 但是,还有一些其他有用的选项可帮助从 MongoDB 导出数据。 以下是其中一些选项及其效果:

  • --quiet:此选项减少了在导出期间发送到命令行的输出量。

  • --type:这将影响文档在控制台中的打印方式,默认为 JSON。 例如,您可以通过指定 CSV 来以 逗号分隔值 (CSV) 格式导出数据。

  • --pretty:这以良好格式的方式输出文档。

  • --fields:这指定要导出的文档中的键的逗号分隔列表,类似于导出级别的投影。

  • --skip:这类似于查询级别的跳过,跳过导出的文档。

  • --sort:这类似于查询级别的排序,按某些键对文档进行排序。

  • --limit:这类似于查询级别的限制,限制输出的文档数量。

以下是一个示例,其中使用了一些这些选项,本例中将排序的十个 theatre 文档输出到名为 output.json 的文件中。 此外,还使用了 --quiet 参数:

mongoexport --uri=mongodb+srv://USERNAME:PASSWORD@provendocs-fawxo.gcp.mongodb.net/sample_mflix --quiet --limit=10 --sort="{theaterId:1}" --collection=theaters --out=output.json

由于我们使用了 --quiet 选项,因此将不会看到任何输出。

> mongoexport --uri=mongodb+srv://testUser:testPassword@performancet uning.98afc.gcp.mongodb.net/sample_mflix --quiet --limit=10 --sort="{theaterId:1}" --collection=theaters --out=output.json
>

但是,如果我们查看output.json文件的内容,我们可以看到按 ID 排序的十个文档:

图 11.1:output.json 文件的内容(已截断)

图 11.1:output.json 文件的内容(已截断)

还有另一个选项可用于更高级的导出,那就是查询选项。查询选项允许您指定一个查询,使用与标准 MongoDB 查询相同的格式。只有匹配此查询的文档将被导出。将此选项与--fields--skip--limit等其他选项结合使用,可以定义一个完整的查询,并将其格式化输出,然后将其导出到文件中。

以下是使用查询选项返回特定文档子集的导出。在这种情况下,我们正在获取所有theaterId4的电影院。

mongoexport --uri=mongodb+srv://USERNAME:PASSWORD@provendocs-fawxo.gcp.mongodb.net/sample_mflix --query="{theaterId: 4}" --collection=theaters

注意

在 MacOS 上,您可能需要用引号括起theaterId,例如:--query="{\"theaterId\": 4}"

现在我们将看到我们正在寻找的文档如下:

2020-08-17T11:22:48.559+1000    connected to: mongodb+srv://[**REDACTED**]@performancetuning.98afc.gcp.mongodb.net/sample_mflix
{"_id":{"$oid":"59a47287cfa9a3a73e51eb78"},"theaterId":4,"location":  {"address":{"street1":"13513 Ridgedale Dr","city":"Hopkins","state":"MN","zipcode":"55305"},"geo":  {"type":"Point","coordinates":[-93.449539,44.969658]}}}
2020-08-17T11:22:48.893+1000    exported 1 record

让我们在下一个练习中使用这些选项。

练习 11.01:导出 MongoDB 数据

在开始本练习之前,让我们重新审视一下介绍部分中概述的电影公司的情景。假设您的客户(电影公司)将迁移其现有数据,您担心会丢失宝贵的信息。您决定的第一件事是将数据库中的文档导出为 JSON 文件,以防灾难发生,可以将其存储在廉价的云存储中。此外,您将为每个电影类别创建不同的导出。

注意

为了展示对mongoexport的了解,我们将不为每个类别创建一个导出,而只为单个类别创建一个。您还只会导出前三个文档。

在这个练习中,您将使用mongoexport创建一个名为action_movies.json的文件,其中包含按发行年份排序的三部动作电影。以下步骤将帮助您完成任务:

  1. 调整您的导出并保存以备后用。创建一个名为Exercise11.01.txt的新文件,以存储您的导出命令。

  2. 接下来,只需输入标准的mongoexport语法,包括 URI 和movies集合:

mongoexport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/sample_mflix --collection=movies
  1. 添加额外的参数以满足您的条件。首先,将您的导出输出到名为action_movies.json的文件中。使用--out参数如下:
mongoexport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/sample_mflix --collection=movies --out=action_movies.json
  1. 接下来,根据本练习的规范,添加您的排序条件以按发行年份对电影进行排序。您可以使用--sort来实现:
mongoexport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/sample_mflix --collection=movies --out=action_movies.json --sort='{released: 1}'
  1. 如果您在当前的中间阶段运行此命令,您将遇到以下错误:
2020-08-17T11:25:51.911+1000    connected to: mongodb+srv://[**REDACTED**]@performancetuning.98afc.gcp.mongodb.net/sample_mflix
2020-08-17T11:25:52.581+1000    Failed: (OperationFailed) Executor error during find command :: caused by :: Sort operation used more than the maximum 33554432 bytes of RAM. Add an index, or specify a smaller limit.

这是因为 MongoDB 服务器正在尝试为我们排序大量文档。为了提高导出和导入的性能,您可以限制检索的文档数量,这样 MongoDB 就不必为您排序那么多文档。

  1. 添加--limit参数以减少被排序的文档数量,并满足三个文档的条件:
mongoexport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/sample_mflix --collection=movies --out=action_movies.json --sort='{released: 1}' --limit=3

最后,您需要添加查询参数以过滤掉不属于电影类型的任何文档。

mongoexport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/sample_mflix --collection=movies --out=action_movies.json --sort='{released : 1}' --limit=3 --query="{'genres': 'Action'}"

注意

在 MacOS 和 Linux 上,您可能需要更改参数中字符串周围的引号,例如在前面的查询中,您需要使用:--query='{"genres": "Action"}'

  1. 完成命令后,从Exercise11.01.txt文件中将其复制到终端或命令提示符中运行:
2020-08-18T12:35:42.514+1000    connected to: mongodb+srv://[**REDACTED**]@performancetuning.98afc.gcp.mongodb.net/sample_mflix
2020-08-18T12:35:42.906+1000    exported 3 records

到目前为止,输出看起来不错,但您需要检查输出文件以确保已导出正确的文档。在您刚刚执行命令的目录中,您应该看到新文件action_movies.json。打开此文件并查看其中的内容。

注意

为了提高输出的清晰度,已删除了剧情字段。

您应该看到以下文档:

图 11.2:action_movies.json 文件的内容(为简洁起见已截断)

图 11.2:action_movies.json 文件的内容(为简洁起见已截断)

这个练习说明了以强大和灵活的方式从 MongoDB 导出文档所需的基本知识。结合这里学到的参数,大多数基本导出现在都很容易。要掌握 MongoDB 中的数据导出,保持实验和学习是有帮助的。

将数据导入到 MongoDB

现在你知道如何将你的集合数据从 MongoDB 中导出并以易于使用的格式保存到磁盘上。但是假设你在磁盘上有这个文件,并且你想与拥有自己的 MongoDB 数据库的人分享?这种情况下,mongoimport就派上用场了。正如你可能从名称中猜到的那样,这个命令本质上是mongoexport的反向,并且它被设计为将mongoexport的输出作为mongoimport的输入。

然而,不仅可以使用从 MongoDB 导出的数据来使用mongoimport。该命令支持 JSON、CSV 和 TSV 格式,这意味着从其他应用程序提取的数据或手动创建的数据仍然可以轻松地添加到数据库中。通过支持这些广泛使用的文件格式,该命令成为将大量数据加载到 MongoDB 中的通用方式。

mongoexport一样,mongoimport在指定的数据库中操作单个目标集合。这意味着如果你希望将数据导入多个集合,你必须将数据分开成单独的文件。

以下是一个复杂mongoimport的例子。我们将在下一节详细介绍语法。

mongoimport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/imports  --collection=oldData --file=old.csv --type=CSV --headerline --ignoreBlanks --drop

使用 mongoimport

以下是一个具有最少参数的mongoimport命令。这比前面的命令简单得多。

mongoimport --db=imports --collection=contacts --file=contacts.json

这个例子看起来也与我们在前一节中看到的一些片段非常相似。它几乎与我们的mongoexport语法相同,只是不是提供一个使用--out创建新文件的位置,而是输入一个--file参数,指定我们希望加载的数据。我们的数据库和集合参数与mongoexport示例中提供的语法相同。

正如你可能猜到的,mongoimportmongoexport共享的另一个相似之处是,默认情况下,它将针对本地计算机上运行的 MongoDB 数据库运行。我们使用相同的--uri参数来指定我们正在将数据加载到远程 MongoDB 服务器中——在这种情况下是 MongoDB Atlas。

注意

mongoexport一样,dburi参数是互斥的,因为数据库在uri中已经定义了。

当使用--uri参数时,mongoimport命令将如下所示:

mongoimport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlasServer-fawxo.gcp.mongodb.net/imports --collection=contacts --file=contacts.json

在你可以执行这个命令来导入你的 MongoDB 数据库之前,你需要一个包含有效数据的文件。现在让我们创建一个。创建可导入数据的最简单方法之一是运行mongoexport。然而,为了提高你导入文件的知识,我们将从头开始创建一个。

你可以开始创建一个名为contacts.json的文件。在文本编辑器中打开文件并创建一些非常简单的文档。在导入 JSON 文件时,文件中的每一行必须包含一个文档。

contacts.json文件应该如下所示:

//contacts.json
{"name": "Aragorn","location": "New Zealand","job": "Park Ranger"}
{"name": "Frodo","location": "New Zealand","job": "Unemployed"}
{"name": "Ned Kelly","location": "Australia","job": "Outlaw"}

执行以下导入:

mongoimport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlasServer-fawxo.gcp.mongodb.net/imports --collection=contacts --file=contacts.json

这将导致以下输出:

2020-08-17T20:10:38.892+1000    connected to: mongodb+srv://[**REDACTED**]@performancetuning.98afc.g
cp.mongodb.net/imports
2020-08-17T20:10:39.150+1000    3 document(s) imported successfully. 0 document(s) failed to import. 

你还可以使用 JSON 数组格式的文件,这意味着你的导入文件包含许多不同的 JSON 文档的数组。在这种情况下,你必须在命令中指定--jsonArray选项。这个 JSON 数组结构现在应该对你来说非常熟悉,因为它与mongoexport的输出以及你从 MongoDB 查询中收到的结果匹配。例如,如果你的文件包含如下数组:

[
    {
        "name": "Aragorn",
        "location": "New Zealand",
        "job": "Park Ranger"
    },
    {
        "name": "Frodo",
        "location": "New Zealand",
        "job": "Unemployed"
    },
    {
        "name": "Ned Kelly",
        "location": "Australia",
        "job": "Outlaw"
    }
]

你仍然可以使用mongoimport命令导入文件,并使用--jsonArray选项,如下所示:

mongoimport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlasServer-fawxo.gcp.mongodb.net/imports --collection=contacts --file=contacts.json --jsonArray

这将导致以下输出:

2020-08-17T20:10:38.892+1000    connected to: mongodb+srv://[**REDACTED**]@performancetuning.98afc.g
cp.mongodb.net/imports
2020-08-17T20:10:39.150+1000    3 document(s) imported successfully. 0 document(s) failed to import. 

注意

在上面的示例中,您会注意到可以为导入的文档提供_id值。如果没有提供_id,则将为文档生成一个。您必须确保您提供的_id尚未被使用;否则,mongoimport命令将抛出错误。

这两个导入向我们展示了将数据导入 MongoDB 数据库的简单方法,但让我们看看当事情出错时会发生什么。让我们修改文件以为我们的一些文档指定_id

[
    {
        "_id": 1,
        "name": "Aragorn",
        "location": "New Zealand",
        "job": "Park Ranger"
    },
    {
        "name": "Frodo",
        "location": "New Zealand",
        "job": "Unemployed"
    },
    {
        "_id": 2,
        "name": "Ned Kelly",
        "location": "Australia",
        "job": "Outlaw"
    }
]

执行一次,您应该可以得到无错误的输出。

mongoimport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlasServer-fawxo.gcp.mongodb.net/imports --collection=contacts --file=contacts.json --jsonArray

您将看到以下输出:

2020-08-17T20:12:12.164+1000    connected to: mongodb+srv://[**REDACTED**]@performancetuning.98afc.g
cp.mongodb.net/imports
2020-08-17T20:12:12.404+1000    3 document(s) imported successfully. 0 document(s) failed to import.

现在,如果您重新运行相同的命令,您会看到错误,因为该_id值已经存在于您的集合中。

2020-08-17T20:12:29.742+1000    connected to: mongodb+srv://[**REDACTED**]@performancetuning.98afc.g
cp.mongodb.net/imports
2020-08-17T20:12:29.979+1000    continuing through error: E11000 duplicate key error collection: imp
orts.contacts index: _id_ dup key: { _id: 1 }
2020-08-17T20:12:29.979+1000    continuing through error: E11000 duplicate key error collection: imp
orts.contacts index: _id_ dup key: { _id: 2 }
2020-08-17T20:12:29.979+1000    1 document(s) imported successfully. 2 document(s) failed to import.

您可以在输出中看到错误。您可能还注意到,没有问题的文档仍然成功导入。如果您导入了一个包含一万个文档的文件,mongoimport不会因为单个文档而失败。

假设您确实想要更新此文档而不更改其_id。您无法使用此mongoimport命令,因为每次都会收到重复键错误。

您可以使用 mongo shell 登录 MongoDB 并在导入之前手动删除此文档,但这将是一个缓慢的方法。使用mongoimport,我们可以使用--drop选项在导入之前删除集合。这是确保文件中存在的内容也存在于集合中的好方法。

例如,假设在我们导入之前,我们的集合中有以下文档:

MongoDB Enterprise PerformanceTuning-shard-0:PRIMARY> db.contacts.find({})
{ "_id" : ObjectId("5e0c1db3fa8335898940129ca8"), "name": "John Smith"}
{ "_id" : ObjectId("5e0c1db3fa8335898940129ca8"), "name": "Jane Doe"}
{ "_id" : ObjectId("5e0c1db3fa8335898940129ca8"), "name": "May Sue"}

现在,使用--drop运行以下mongoimport命令:

mongoimport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlasServer-fawxo.gcp.mongodb.net/imports --collection=contacts –-file=contacts.json --jsonArray --drop
2020-08-17T20:16:08.280+1000    connected to: mongodb+srv://[**REDACTED**]@performancetuning.98afc.g
cp.mongodb.net/imports
2020-08-17T20:16:08.394+1000    dropping: imports.contacts
2020-08-17T20:16:08.670+1000    3 document(s) imported successfully. 0 document(s) failed to import.  

执行命令后,您将看到集合中有以下文档,可以使用 find 命令查看这些文档。

db.contacts.find({})

您应该看到以下输出:

{ "_id" : ObjectId("5f3a58e8fd0803fc3dec8cbf"), "name" : "Frodo", "location" : "New Zealand", "job" : "Unemployed" }
{ "_id" : 1, "name" : "Aragorn", "location" : "New Zealand", "job" : "Park Ranger" }
{ "_id" : 2, "name" : "Ned Kelly", "location" : "Australia", "job" : "Outlaw" }

在下一节中,我们将看看可以与mongoimport一起使用的选项。

mongoimport 选项

现在我们知道了使用mongoimport的基本选项,包括--uri--collection--file参数。但是,就像我们上一节中的mongoexport一样,运行命令时可能还有几个其他选项。其中许多选项与mongoexport相同。以下列表描述了一些选项及其效果。

  • --quiet:这会减少导入的输出消息量。

  • --drop:在开始导入之前删除集合。

  • --jsonArray:仅适用于 JSON 类型,指定文件是否为 JSON 数组格式。

  • --type:可以是 JSON、CSV 或 TSV,用于指定要导入的文件类型,但默认类型为 JSON。

  • --ignoreBlanks:仅适用于 TSV 和 CSV,这将忽略导入文件中的空字段。

  • --headerline:仅适用于 TSV 和 CSV,这将假定导入文件的第一行是字段名称列表。

  • --fields:仅适用于 TSV 和 CSV,这将为 CSV 和 TSV 格式的文档指定逗号分隔的键列表。只有在没有标题行时才需要。

  • --stopOnError:如果指定,导入将在遇到第一个错误时停止。

以下是使用更多这些选项的示例,特别是带有标题行的 CSV 导入。我们还必须忽略空白,以便文档不会获得空白的_id值。

这是我们的.csv文件,名为contacts.csv

_id,name,location,job
1,Aragorn,New Zealand,Park Ranger
,Frodo,New Zealand,Unemployed
2,Ned Kelly,Australia,Outlaw

我们将使用以下命令导入 CSV:

mongoimport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlasServer-fawxo.gcp.mongodb.net/imports --collection=contacts --file=contacts.csv --drop --type=CSV --headerline --ignoreBlanks
2020-08-17T20:22:39.750+1000    connected to: mongodb+srv://[**REDACTED**]@performancetuning.98afc.gcp.mongodb.net/imports
2020-08-17T20:22:39.863+1000    dropping: imports.contacts
2020-08-17T20:22:40.132+1000    3 document(s) imported successfully. 0 document(s) failed to import.

上述命令导致我们的集合中有以下文档:

MongoDB Enterprise atlas-nb3biv-shard-0:PRIMARY> db.contacts.find({})
{ "_id" : 2, "name" : "Ned Kelly", "location" : "Australia", "job" : "Outlaw" }
{ "_id" : 1, "name" : "Aragorn", "location" : "New Zealand", "job" : "Park Ranger" }
{ "_id" : ObjectId("5f3a5a6fc67ba81a6d4bcf69"), "name" : "Frodo", "location" : "New Zealand", "job" : "Unemployed" }

当然,这些只是您可能遇到的一些常见选项。文档中提供了完整的列表。熟悉这些选项是有用的,以防您需要对不同配置的 MongoDB 服务器运行更高级的导入。

练习 11.02:将数据加载到 MongoDB 中

在这种情况下,您已经成功地在本地机器上创建了客户数据的导出。您已经在不同版本的新服务器上设置了一个新服务器,并希望确保数据正确地导入到新配置中。此外,您还从另一个较旧的数据库中以 CSV 格式获得了一些数据文件,这些数据文件将迁移到新的 MongoDB 服务器。您希望确保这种不同的格式也能正确导入。考虑到这一点,您的目标是将两个文件(如下所示)导入到 Atlas 数据库中,并测试文档是否存在于正确的集合中。

在这个练习中,您将使用mongoimport将两个文件(old.csvnew.json)导入到两个单独的集合(oldDatanewData),并使用 drop 来确保没有剩余的文档存在。

可以通过执行以下步骤来实现这个目标:

  1. 微调您的导入并保存以备后用。创建一个名为Exercise11.02.txt的新文件来存储您的导出命令。

  2. 创建包含要导入的数据的old.csvnew.json文件。可以从 GitHub 上下载文件packt.live/2LsgKS3,或者将以下内容复制到当前目录中的相同文件中。

old.csv文件应如下所示:

_id,title,year,genre
54234,The King of The Bracelets,1999,Fantasy
6521,Knife Runner,1977,Science Fiction
124124,Kingzilla,1543,Horror
64532,Casabianca,1942,Drama
23214,Skyhog Day,1882,Comedy

new.json文件应如下所示:

[
    {"_id": 54234,"title": "The King of The Bracelets","year": 1999,"genre": "Fantasy"},
    {"_id": 6521, "title": "Knife Runner","year": 1977,"genre": "Science Fiction"},
    {"_id": 124124,"title": "Kingzilla","year": 1543,"genre": "Horror"},
    {"_id": 64532,"title": "Casabianca","year": 1942,"genre": "Drama"},
    {"_id": 23214,"title": "Skyhog Day","year": 1882,"genre": "Comedy"}
]
  1. 将标准的mongoimport语法输入到您的Exercise11.02.txt文件中,只包括 URI、集合和文件位置。首先将您的数据导入到"imports"数据库中,先导入旧数据:
mongoimport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/imports --collection=oldData --file=old.csv
  1. 现在,开始添加额外的参数以满足 CSV 文件的条件。指定type=CSV
mongoimport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/ imports  --collection=oldData --file=old.csv --type=CSV
  1. 接下来,因为旧数据中有标题行,所以使用headerline参数。
mongoimport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/imports  --collection=oldData --file=old.csv --type=CSV --headerline
  1. 在本章的一些示例中,当您看到 CSV 导入时,使用了--ignoreBlanks参数来确保空字段不被导入。这是一个很好的做法,所以在这里也要添加它。
mongoimport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/imports  --collection=oldData --file=old.csv --type=CSV --headerline --ignoreBlanks
  1. 最后,在这个练习中,您需要确保不要在现有数据之上进行导入,因为这可能会导致冲突。为了确保您的数据被干净地导入,请使用--drop参数如下:
mongoimport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/imports  --collection=oldData --file=old.csv --type=CSV --headerline --ignoreBlanks --drop
  1. 这应该是你进行 CSV 导入所需要的一切。通过复制现有命令到新行,然后删除 CSV 特定参数来开始编写 JSON 导入。
mongoimport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/imports  --collection=oldData --file=old.csv --drop
  1. 现在,通过以下命令更改filecollection参数,将您的new.json文件导入到newData集合中:
mongoimport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/imports  --drop --collection=newData --file=new.json 
  1. 您可以看到new.json文件中的数据是以 JSON 数组格式,因此添加匹配参数如下:
mongoimport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/imports --collection=newData --file=new.json --drop --jsonArray
  1. 现在,您的Exercise11.02.txt文件中应该有以下两个命令。
mongoimport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/imports  --collection=newData --file=new.json --drop --jsonArray
mongoimport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/imports  --collection=oldData --file=old.csv --type=CSV --headerline --ignoreBlanks --drop
  1. 使用以下命令运行您的newData导入:
mongoimport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/imports  --collection=newData --file=new.json --drop --jsonArray

输出如下:

2020-08-17T20:25:21.622+1000    connected to: mongodb+srv://[**REDACTED**]@performancetuning.98afc.gcp.mongodb.net/imports
2020-08-17T20:25:21.734+1000    dropping: imports.newData
2020-08-17T20:25:22.019+1000    5 document(s) imported successfully. 0 document(s) failed to import.
  1. 现在,按照以下方式执行oldData导入:
mongoimport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/imports  --collection=oldData --file=old.csv --type=CSV --headerline --ignoreBlanks --drop

输出如下:

2020-08-17T20:26:09.588+1000    connected to: mongodb+srv://[**REDACTED**]@performancetuning.98afc.gcp.mongodb.net/imports
2020-08-17T20:26:09.699+1000    dropping: imports.oldData
2020-08-17T20:26:09.958+1000    5 document(s) imported successfully. 0 document(s) failed to import. 
  1. 通过运行以下命令来检查 MongoDB 中的两个新集合:
show collections

输出如下:

图 11.3:显示新集合

图 11.3:显示新集合

首先,我们学会了如何从 MongoDB 服务器中导出数据。现在我们可以使用导入命令将外部数据输入到 MongoDB 中。通过结合这两个简单的命令,我们还可以在 MongoDB 的不同实例之间转移数据,或者在导入到 MongoDB 之前使用外部工具创建数据。

备份整个数据库

使用mongoexport,理论上我们可以获取整个 MongoDB 服务器,并提取每个数据库和集合中的所有数据。然而,我们必须一次处理一个集合,确保文件正确映射到原始数据库和集合。手动完成这个过程是可能的,但很困难。脚本可以可靠地完成整个 MongoDB 服务器的这项工作,即使有数百个集合。

幸运的是,除了mongoimportmongoexport之外,MongoDB 工具包还提供了一个工具,用于导出整个数据库的内容。这个实用程序称为mongodump。这个命令创建了整个 MongoDB 实例的备份。您只需要提供 URI(或主机和端口号),mongodump命令就会完成剩下的工作。这个导出创建了一个二进制文件,可以使用mongorestore来恢复(这是下一节中介绍的命令)。通过结合使用mongodumpmongorestore,您可以可靠地备份、恢复和迁移 MongoDB 数据库,跨不同的硬件和软件配置。

使用 mongodump

以下是一个最简单形式的mongodump命令:

mongodump

有趣的是,您可以运行mongodump而不使用任何参数。这是因为命令需要使用的唯一信息是您的 MongoDB 服务器的位置。如果没有指定 URI 或主机,它将尝试创建运行在您本地系统上的 MongoDB 服务器的备份。

我们可以使用--uri参数来指定 URI,以指定我们的 MongoDB 服务器的位置。

注意

mongoexport一样,--db/--host--uri参数是互斥的。

然而,如果我们确实有一个本地运行的 MongoDB 服务器,这是我们可能会收到的输出:

2020-08-18T12:38:43.091+1000    writing imports.newData to 
2020-08-18T12:38:43.091+1000    writing imports.contacts to 
2020-08-18T12:38:43.091+1000    writing imports.oldData to 
2020-08-18T12:38:43.310+1000    done dumping imports.newData (5 documents)
2020-08-18T12:38:44.120+1000    done dumping imports.contacts (3 documents)
2020-08-18T12:38:44.120+1000    done dumping imports.oldData (5 documents)

在这个命令结束时,我们可以看到我们的目录中有一个新的文件夹,其中包含我们数据库的备份。默认情况下,mongodump会导出 MongoDB 服务器中的所有内容。但是,我们可以更加有选择地进行导出,我们将在下一节中看到一个例子。

mongodump 选项

mongodump命令需要非常少的选项才能运行;在大多数情况下,您可能只使用--uri参数。但是,我们可以使用几个选项来充分利用这个实用程序命令。以下是一些最有用的选项列表。

  • --quiet:这会减少备份时的输出信息量。

  • --out:这允许您指定导出的不同位置,以便将其写入磁盘,默认情况下将在运行命令的相同目录中创建一个名为“dump”的目录。

  • --db:这允许您指定要备份的单个数据库,默认情况下将备份所有数据库。

  • --collection:这允许您指定要备份的单个集合,默认情况下将备份所有集合。

  • --excludeCollection:这允许您指定要从备份中排除的集合。

  • --query:这允许您指定一个查询文档,将备份的文档限制为仅匹配查询的文档。

  • --gzip:如果启用,导出的输出将是一个压缩文件,格式为.gz,而不是一个目录。

我们将看看如何创建一个单个数据库的备份,包括用户和角色,并将其保存到磁盘上的特定位置。因为我们正在进行单个数据库的备份,所以可以使用--uri来指定要使用的数据库。

mongodump --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/imports --out="./backups"
      2020-08-18T12:39:51.457+1000    writing imports.newData to 
2020-08-18T12:39:51.457+1000    writing imports.contacts to 
2020-08-18T12:39:51.457+1000    writing imports.oldData to 
2020-08-18T12:39:51.697+1000    done dumping imports.newData (5 documents)
2020-08-18T12:39:52.472+1000    done dumping imports.contacts (3 documents)
2020-08-18T12:39:52.493+1000    done dumping imports.oldData (5 documents)

正如您在前面的截图中所看到的,只有我们指定的数据库中存在的集合被导出。如果您查看包含我们导出内容的文件夹,甚至可以看到这一点:

╭─ ~/backups
╰─ ls
      imports/
╭─ ~/backups
╰─ ls imports 
      contacts.bson          contacts.metadata.json newData.bson 
      newData.metadata.json  oldData.bson           oldData.metadata.json 

您可以在导入目录中看到,对于转储中的每个集合,都创建了两个文件,一个包含我们数据的.bson文件,一个用于集合元数据的.metadata.json文件。所有mongodump的结果都将匹配这种格式。

接下来,使用您的--query参数来仅转储集合中的特定文档。您可以使用标准查询文档来指定您的集合。例如,在 Windows 上考虑以下命令:

mongodump --uri=mongodb+srv://USERNAME:PASSWORD@myAtlasServer-fawxo.gcp.mongodb.net/sample_mflix --collection="movies" --out="./backups" --query="{genres: 'Action'}"

在 MacOS/Linux 上,您将不得不修改引号如下:

mongodump --uri=mongodb+srv://USERNAME:PASSWORD@myAtlasServer-fawxo.gcp.mongodb.net/sample_mflix --collection="movies" --out="./backups" --query='{"genres": "Action"}'

输出如下:

2020-08-18T12:57:06.533+1000    writing sample_mflix.movies to 
2020-08-18T12:57:07.258+1000    sample_mflix.movies  101
2020-08-18T12:57:09.109+1000    sample_mflix.movies  2539
2020-08-18T12:57:09.110+1000    done dumping sample_mflix.movies (2539 documents)

电影收藏中有超过 20,000 个文档,但我们只导出了2539个匹配的文档。

现在,执行相同的导出,但不使用--query参数:

mongodump --uri=mongodb+srv://USERNAME:PASSWORD@myAtlasServer-fawxo.gcp.mongodb.net/sample_mflix --collection="movies" --out="./backups"

输出如下:

2020-08-18T12:57:45.263+1000    writing sample_mflix.movies to 
2020-08-18T12:57:45.900+1000    [........................]  sample_mflix.movies  101/23531  (0.4%)
2020-08-18T12:57:48.891+1000    [........................]  sample_mflix.movies  101/23531  (0.4%)
2020-08-18T12:57:51.894+1000    [##########..............]  sample_mflix.movies  10564/23531  (44.9%
)
2020-08-18T12:57:54.895+1000    [##########..............]  sample_mflix.movies  10564/23531  (44.9%)
2020-08-18T12:57:57.550+1000    [########################]  sample_mflix.movies  23531/23531  (100.0%)
2020-08-18T12:57:57.550+1000    done dumping sample_mflix.movies (23531 documents)

我们可以在前面的输出中看到,如果没有--query参数,导出的文档数量会显著增加,这意味着我们已经将从我们的集合中导出的文档数量减少到仅与查询匹配的文档。

与我们之前学习的命令一样,这些选项只代表您可以提供给mongodump的参数的一小部分。通过组合和尝试这些选项,您将能够为您的 MongoDB 服务器创建一个强大的备份和快照解决方案。

通过使用mongoimportmongoexport,您已经能够轻松地将特定集合导入和导出数据库。然而,作为 MongoDB 服务器的备份策略的一部分,您可能希望备份整个 MongoDB 数据库的状态。在下一个练习中,我们将仅创建sample_mflix数据库的转储,而不是创建我们的 MongoDB 服务器中可能有的许多不同数据库的更大的转储。

练习 11.03:备份 MongoDB

在这个练习中,您将使用mongodump来创建sample_mflix数据库的备份。将数据导出到名为movies_backup的文件夹中的.gz文件。

完成此练习,执行以下步骤:

  1. 为了调整您的导入并将其保存以备后用,创建一个名为Exercise11.03.txt的新文件来存储您的mongodump命令。

  2. 接下来,输入标准的mongodump语法,只设置--uri参数。记住,--uri包含目标数据库。

mongodump --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/sample_mflix
  1. 接下来,添加指定转储位置的参数。在这种情况下,那就是一个名为movies_backup的文件夹:
mongodump --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/sample_mflix --out=movies_backup
  1. 最后,为了自动将您的转储文件放入.gz文件中,使用--gzip参数并运行命令。
mongodump --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/sample_mflix --out=movies_backup --gzip

注意

因为这个命令将转储整个sample_mflix数据库,所以根据您的互联网连接速度,可能需要一点时间。

一旦命令执行,您应该看到类似以下截图的输出:

图 11.4:执行命令后的输出

图 11.4:执行mongodump命令后的输出

  1. 检查您的转储目录。您可以看到所有的mongodump数据都已经写入了正确的目录。
╰─ ls movies_backup
      sample_mflix/
╰─ ls movies_backup/sample_mflix
      comments.bson.gz                   comments.metadata.json.gz
      most_commented_movies.bson.gz       most_commented_movies.metadata.json.gz 
      movies.bson.gz                      movies.metadata.json.gz
      movies_top_romance.bson.gz          movies_top_romance.metadata.json.gz
      sessions.bson.gz                    sessions.metadata.json.gz
      theaters.bson.gz                    theaters.metadata.json.gz
      users.bson.gz                       users.metadata.json.gz

在本练习过程中,您已经学会了如何编写一个mongodump命令,可以正确地创建数据库的压缩备份。现在,您将能够将这种技术作为数据库迁移或备份策略的一部分。

恢复 MongoDB 数据库

在前一节中,我们学习了如何使用mongodump创建整个 MongoDB 数据库的备份。然而,除非我们拥有一种将它们加载回 MongoDB 服务器的方法,否则这些导出对我们的备份策略没有好处。通过将我们的导出放回数据库的命令是mongorestore

mongoimport允许我们将常用格式导入 MongoDB 不同,mongorestore仅用于导入mongodump的结果。这意味着它最常用于将大部分或全部数据库恢复到特定状态。mongorestore命令非常适合在灾难后恢复转储,或者将整个 MongoDB 实例迁移到新配置。

当与我们的其他命令结合使用时,应该清楚mongorestore完成了导入和导出的生命周期。通过这三个命令(mongoimportmongoexportmongodump),我们已经学会了可以导出集合级别的数据,导入集合级别的数据,导出服务器级别的数据,现在最后,通过mongorestore,我们可以导入服务器级别的信息。

使用mongorestore

与其他命令一样,让我们来看一个mongorestore命令的简单实现。

mongorestore .\dump\

或者在 MacOS/Linux 上,您可以输入以下内容:

mongorestore ./dump/

我们需要传递的唯一必需参数是要还原的转储位置。但是,正如您可能已经从我们的其他命令中猜到的那样,默认情况下,mongorestore会尝试将备份还原到本地系统。

注意

转储位置不需要--parameter格式,而是可以作为命令的最后一个值传递。

在这里,我们可以再次使用--uri参数指定 URI,以指定我们的 MongoDB 服务器的位置。

例如,假设我们确实有一个正在运行的本地 MongoDB 服务器。要完成还原,我们需要之前创建的转储文件。以下是基于练习 11.03,备份 MongoDB的转储命令:

mongodump --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/imports --out=./dump

如果我们现在使用--drop选项对此转储运行mongorestore,您可能会看到类似以下的输出:

图 11.5:使用选项运行后的输出

图 11.5:使用--drop选项运行mongorestore后的输出

正如您所期望的,此输出应该与mongoimport的输出最为相似,告诉我们从转储文件中恢复了多少文档和索引。如果您的用例是作为备份策略的一部分进行还原,那么这个简单的命令和最少的参数就是您所需要的。

默认情况下,mongorestore会还原目标转储中的每个数据库、集合和文档。如果您希望在还原时更加具体,有几个方便的选项可以让您只还原特定的集合,甚至在还原过程中重命名集合。这些选项的示例将在下一节中提供。

mongorestore 选项

mongodump一样,mongorestore命令可以只使用其基本参数(如--uri和转储文件的位置)来满足大多数用例。如果您希望执行更具体类型的还原,可以使用以下一些选项:

  • --quiet:这会减少转储的输出消息量。

  • --drop:类似于mongoimport--drop选项将在还原之前删除要还原的集合,确保命令运行后不会保留旧数据。

  • --dryRun:这允许您查看运行mongorestore的输出,而不实际更改数据库中的信息,这是在执行潜在危险操作之前测试命令的绝佳方式。

  • --stopOnError:如果启用,一旦发生单个错误,进程就会停止。

  • --nsInclude:这个选项允许您定义应从转储文件中导入哪些命名空间(数据库和集合),而不是明确提供数据库和集合。我们将在本章后面看到这个选项的示例。

  • --nsExclude:这是nsInclude的补充选项,允许您提供一个在还原时不导入的命名空间模式。下一节将提供一个示例。

  • --nsFrom:使用与nsIncludensExclude中相同的命名空间模式,此参数可以与--nsTo一起使用,提供导出中的命名空间到还原备份中的新命名空间的映射。这允许您在还原过程中更改集合的名称。

现在,让我们看一些使用这些选项的示例。请注意,对于这些示例,我们使用的是前一节创建的转储文件。作为提醒,这是创建此转储文件所需的命令:

mongodump --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/sample_mflix --out=dump

首先,假设您有一个从sample_mflix数据库创建的完整mongodump。以下是还原我们集合的子集所需的命令示例。您可能会注意到参数的格式是{数据库}.{集合},但是您可以使用通配符(*)运算符来匹配所有值。在以下示例中,我们包括与命名空间"sample_mflix.movies"匹配的任何集合(仅sample_mflix数据库的 movies 集合)。

mongorestore --uri=mongodb+srv://USERNAME:PASSWORD@myAtlasServer-fawxo.gcp.mongodb.net --drop --nsInclude="sample_mflix.movies" dump

当此命令完成运行时,您应该会看到类似以下的输出:

2020-08-18T13:12:28.204+1000    [###################.....]  sample_mflix.movies  7.53MB/9.06MB  (83.2%)
2020-08-18T13:12:31.203+1000    [#######################.]  sample_mflix.movies  9.04MB/9.06MB  (99.7%)
2020-08-18T13:12:33.896+1000    [########################]  sample_mflix.movies  9.06MB/9.06MB  (100.0%)
2020-08-18T13:12:33.896+1000    no indexes to restore
2020-08-18T13:12:33.902+1000    finished restoring sample_mflix.movies (6017 documents, 0 failures)
2020-08-18T13:12:33.902+1000    6017 document(s) restored successfully. 0 document(s) failed to restore.

在输出中,您可以看到只有匹配的命名空间被恢复。现在让我们看一下nsFromnsTo参数如何被用来重命名集合,使用与前面示例相同的格式。我们将在sample_mflix数据库中将集合重命名为相同的集合名称,但在一个名为backup的新数据库中:

mongorestore --uri=mongodb+srv://USERNAME:PASSWORD@myAtlasServer-fawxo.gcp.mongodb.net --drop --nsFrom="sample_mflix.*" --nsTo="backup.*" dump

一旦执行此命令完成,最后几行应该类似于以下内容:

2020-08-18T13:13:54.152+1000    [################........]    backup.movies  6.16MB/9.06MB  (68.0%)
2020-08-18T13:13:54.152+1000
2020-08-18T13:13:56.916+1000    [########################]  backup.comments  4.35MB/4.35MB  (100.0%)
2020-08-18T13:13:56.916+1000    no indexes to restore
2020-08-18T13:13:56.916+1000    finished restoring backup.comments (16017 documents, 0 failures)
2020-08-18T13:13:57.153+1000    [###################.....]  backup.movies  7.53MB/9.06MB  (83.1%)
2020-08-18T13:14:00.152+1000    [#######################.]  backup.movies  9.04MB/9.06MB  (99.7%)
2020-08-18T13:14:02.929+1000    [########################]  backup.movies  9.06MB/9.06MB  (100.0%)
2020-08-18T13:14:02.929+1000    no indexes to restore
2020-08-18T13:14:02.929+1000    finished restoring backup.movies (6017 documents, 0 failures)
2020-08-18T13:14:02.929+1000    23807 document(s) restored successfully. 0 document(s) failed to restore. 

现在,如果我们观察一下我们的 MongoDB 数据库中的集合,我们会发现sample_mflix集合也存在于名为backup的数据库中,例如:

MongoDB Enterprise atlas-nb3biv-shard-0:PRIMARY> use backup
switched to db backup
MongoDB Enterprise atlas-nb3biv-shard-0:PRIMARY> show collections
comments
most_commented_movies
movies
movies_top_romance
sessions
theaters
users

最后,让我们快速看一下dryRun参数的工作原理。看一下以下命令:

mongorestore --uri=mongodb+srv://USERNAME:PASSWORD@myAtlasServer-fawxo.gcp.mongodb.net --drop --nsFrom="imports.*" --nsTo="backup.*" --dryRun .\dump\

您将注意到有一个关于命令准备恢复的输出。但是,它不会加载任何数据。MongoDB 中的基础数据都没有改变。这是确保在执行命令之前,确保命令不会出错的绝佳方法。

mongorestore命令完成了我们的四个命令,即mongoimportmongoexportmongodumpmongorestore。虽然使用mongorestore很简单,但如果您的备份策略设置更复杂,您可能需要使用多个选项并参考文档。

练习 11.04:恢复 MongoDB 数据

在上一个练习中,您使用mongodump创建了sample_mflix数据库的备份。作为 MongoDB 服务器的备份策略的一部分,您现在需要将这些数据放回数据库。在这个练习中,假设您从导出的数据库和导入的数据库是不同的数据库。因此,为了向客户证明备份策略有效,您将使用mongorestore将该转储数据导入到不同的命名空间中。

注意

在完成此练习之前,您需要从练习 11.03备份 MongoDB中创建一个转储。

在这个练习中,您将使用mongorestore从上一个练习中创建的movies_backup转储中恢复sample_mflix数据库,并将每个集合的命名空间更改为backup_mflix

  1. 调整您的导入并保存以备后用。创建一个名为Exercise11.04.txt的新文件来存储您的恢复命令。

  2. 确保练习 11.03备份 MongoDB中的movies_backup转储也在您当前的目录中。否则,您可以使用以下命令创建一个新的备份:

mongodump --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/sample_mflix --out=./movies_backup --gzip
  1. 接下来,只需输入标准的mongorestore语法,只提供 URI 和转储文件的位置。记住,URI 中包括目标数据库:
mongorestore --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net ./movies_backup
  1. 由于转储文件是以gzip格式,您还需要在恢复命令中添加--gzip参数,以便它可以解压数据。
mongorestore --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net --gzip ./movies_backup
  1. 为了确保恢复结果干净,使用您的--drop参数在尝试恢复之前删除相关集合:
mongorestore --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net --gzip --drop ./movies_backup
  1. 现在,添加修改命名空间的参数。因为您正在恢复sample_mflix数据库的转储,所以"sample_mflix"将是您的nsFrom参数的值:
mongorestore --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net --nsFrom="sample_mflix.*" --gzip --drop ./movies_backup
  1. 这种用例规定这些集合将被恢复到一个名为backup_mflix的数据库中。使用nsTo参数提供这个新的命名空间。
mongorestore --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net --nsFrom="sample_mflix.*" --nsTo="backup_mflix.*" --gzip --drop ./movies_backup
  1. 您的命令现在已经完成。将此代码复制并粘贴到您的终端或命令提示符中并运行。将会有大量输出来显示恢复的进度,但最后,您应该会看到如下输出:
2020-08-18T13:18:08.862+1000    [####################....]  backup_mflix.movies  10.2MB/11.7MB  (86.7%)
2020-08-18T13:18:11.862+1000    [#####################...]  backup_mflix.movies  10.7MB/11.7MB  (90.8%)
2020-08-18T13:18:14.865+1000    [######################..]  backup_mflix.movies  11.1MB/11.7MB  (94.9%)
2020-08-18T13:18:17.866+1000    [#######################.]  backup_mflix.movies  11.6MB/11.7MB  (98.5%)
2020-08-18T13:18:20.217+1000    [########################]  backup_mflix.movies  11.7MB/11.7MB  (100.0%)
2020-08-18T13:18:20.217+1000    restoring indexes for collection backup_mflix.movies from metadata
2020-08-18T13:18:26.389+1000    finished restoring backup_mflix.movies (23531 documents, 0 failures)
2020-08-18T13:18:26.389+1000    75594 document(s) restored successfully. 0 document(s) failed to restore.

从输出中可以看出,恢复已完成,将每个现有集合恢复到名为backup_mflix的新数据库中。输出甚至会告诉您恢复的一部分写入了多少个文档。例如,23541个文档被恢复到movies集合中。

现在,如果您使用 mongo shell 登录到服务器,您应该能够看到您新恢复的backup_mflix数据库和相关集合,如下所示:

MongoDB Enterprise atlas-nb3biv-shard-0:PRIMARY> use backup_mflix
switched to db backup_mflix
MongoDB Enterprise atlas-nb3biv-shard-0:PRIMARY> show collections
comments
most_commented_movies
movies
movies_top_romance
sessions
theaters
users

就是这样。你已经成功地将备份还原到了 MongoDB 服务器中。有了对mongorestore的工作知识,你现在可以高效地备份和迁移整个 MongoDB 数据库或服务器。正如本章前面提到的,你也许可以用mongoimport来完成同样的任务,但是能够使用mongodumpmongorestore会让你的任务变得更简单。

通过本章学到的四个关键命令(mongoexportmongoimportmongodumpmongorestore),你现在应该能够完成大部分与 MongoDB 一起工作时遇到的备份、迁移和还原任务。

活动 11.01:MongoDB 中的备份和还原

你的客户(电影公司)已经有了几个每晚运行的脚本,用于导出、导入、备份和还原数据。他们进行备份和导出是为了确保数据有冗余的副本。然而,由于他们对 MongoDB 的经验不足,这些命令并没有正确运行。为了解决这个问题,他们请求你帮助他们优化他们的备份策略。按照以下步骤完成这个活动:

注意

这个活动中的四个命令必须按正确的顺序运行,因为importrestore命令依赖于exportdump命令的输出。

  1. theaterId字段,按theaterId排序,导出到名为theaters.csv的 CSV 文件中:
mongoexport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/sample_mflix --db=sample_mflix --collection=theaters --out="theaters.csv" --type=csv --sort='{theaterId: 1}'
  1. theaters.csv文件导入到名为theaters_import的新集合中:
mongoimport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/imports --collection=theaters_import --file=theaters.csv
  1. theaters集合以gzip格式备份到名为backups的文件夹中:
mongodump --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/sample_mflix --out=./backups –gz --nsExclude=theaters
  1. sample_mflix_backup
mongorestore --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net --from="sample_mflix" --to="backup_mflix_backup" --drop ./backups

你的目标是接受客户提供的脚本,确定这些脚本有什么问题,并解决这些问题。你可以在自己的 MongoDB 服务器上测试这些脚本是否正确运行。

你可以通过几种方式完成这个目标,但要记住我们在整个章节中学到的东西,并尝试创建简单、易于使用的代码。以下步骤将帮助你完成这个任务:

  1. 目标数据库被指定了两次,尝试移除多余的参数。

  2. 重新运行export命令。我们缺少一个特定于 CSV 格式的选项。添加这个参数以确保我们导出theaterId和 location 字段。

现在看看import命令,你应该立即注意到一些缺少的参数,这些参数是 CSV 导入所需的。

  1. 首先对于dump命令,有一个选项是不正确的;运行命令以获取提示。

  2. 其次,dump命令中没有nsInclude选项,因为这是mongorestore的选项。用适用于mongodump的适当选项替换它。

  3. restore命令中,有一些选项的名称不正确。修复这些名称。

  4. 同样在restore命令中,从前一个命令中还原一个gzip格式的 dump。在还原命令中添加一个选项来支持这种格式。

  5. 最后,在restore命令中,查看nsFromnsTo选项的值,并检查它们是否在正确的命名空间格式中。

为了测试你的结果,按顺序运行这四个命令(导出,导入,dump,还原)。

mongoexport命令的输出如下:

2020-08-18T13:21:29.778+1000    connected to: mongodb+srv://[**REDACTED**]@performancetuning.98afc.gcp.mongodb.net/sample_mflix
2020-08-18T13:21:30.891+1000    exported 1564 records

mongoimport命令的输出如下:

2020-08-18T13:22:20.720+1000    connected to: mongodb+srv://[**REDACTED**]@performancetuning.98afc.g
cp.mongodb.net/imports
2020-08-18T13:22:22.817+1000    1564 document(s) imported successfully. 0 document(s) failed to import.

mongodump命令的输出如下:

图 11.6 mongodump 命令的输出

图 11.6 mongodump 命令的输出

mongorestore命令的输出开始如下:

图 11.7:mongorestore 命令的输出开始

图 11.7:mongodump 命令的输出开始

mongorestore命令的输出结束如下:

图 11.8:mongorestore 命令的输出结束

图 11.8:mongorestore 命令的输出结束

注意

此活动的解决方案可通过此链接找到。

摘要

在这一章中,我们涵盖了四个单独的命令。然而,这四个命令都作为 MongoDB 完整备份和恢复生命周期中的元素。通过结合这些基本命令和它们的高级选项,您现在应该能够确保您负责的任何 MongoDB 服务器在数据损坏、丢失或灾难发生时都能得到适当的快照、备份、导出和恢复。

您可能不负责备份 MongoDB 数据,但这些命令也可以用于各种实用程序。例如,将数据导出为 CSV 格式将非常方便,当尝试以电子表格的形式直观地探索信息,甚至向不熟悉文档模型的同事展示时。通过使用 mongoimport,您还可以减少导入非 MongoDB 格式数据所需的手动工作量,以及批量导入来自其他服务器的 MongoDB 数据。

下一章涵盖了数据可视化,这是一个非常重要的概念,可以将 MongoDB 信息转化为易于理解的结果,为业务问题提供洞察和清晰度,并将其整合到演示文稿中,以说服利益相关者对数据中难以解释的趋势产生兴趣。

第十二章:数据可视化

概述

本章将向您介绍 MongoDB Charts,它提供了使用来自 MongoDB 数据库的数据创建可视化的最佳方式。您将首先学习 MongoDB Charts 数据可视化引擎的基础知识,然后创建新的仪表板和图表,以了解各种类型图表之间的区别。您还将集成和定制图表与其他外部应用程序。通过本章结束时,您将熟悉 Charts PaaS 云界面的基本概念,并能够执行构建有用图表所需的步骤。

介绍

数据的可视化呈现对于报告和商业演示非常有用。在科学、统计学和数学中使用图表进行数据可视化的优势不言而喻。图表可以有效地传达业务决策所需的基本信息,就像电影可以通过运动图像来讲述故事一样。

MongoDB 已经开发了一个名为 MongoDB Charts 的新的集成工具,用于数据可视化。这是一个相对较新的功能,首次发布于 2018 年第二季度。MongoDB Charts 允许用户从 MongoDB 数据库中快速表示数据,而无需使用诸如 Java 或 Python 之类的编程语言编写代码。目前,MongoDB Charts 有两种不同的实现方式:

  • MongoDB Charts PaaS平台即服务):这是指 Charts 的云服务。这个版本的 Charts 与 Atlas 云项目和数据库完全集成。它不需要在客户端进行任何安装,并且在 Atlas 云账户中免费使用。

  • MongoDB Charts 服务器:这是指本地安装的 MongoDB Charts 工具。Charts 服务器需要从 MongoDB 下载并安装在一个专用服务器安装中,使用 Docker。本地 Charts 包含在 MongoDB Enterprise Advanced 中,并且本课程不涵盖它。

用户在两个版本的 MongoDB Charts 中可用的功能是相似的。用户只需使用一个简单的浏览器客户端,就可以创建仪表板和各种图表。MongoDB 不断扩展 Charts 工具,每次新发布都会添加新功能和修复应用程序中的错误。

在本章中,我们将考虑这样一个场景:XYZ 组织的员工 John 被指派创建一个仪表板,其中包含来自一个电影集合的数据库的信息。John 是一个经验有限的 MongoDB 初学者。他想知道是否有一种简单的方法可以在不使用编程语言编写代码的情况下构建图形。这就是 MongoDB Charts 发挥作用的地方。首先,我们将学习 MongoDB Charts 中的菜单和选项卡

探索菜单和选项卡

要启动 MongoDB Charts GUI 应用程序,用户需要首先登录 Atlas 云 Web 应用程序。MongoDB Charts(PaaS 版本)绑定到一个 Atlas 项目(“每个项目”选项),因此如果有多个 Atlas 项目,用户需要选择当前活动的 Atlas 项目。如前几章所述,Atlas 项目的名称是在创建项目时选择的。对于本章,Atlas 中的项目名称是 Atlas 中的默认项目名称:Project 0。如下图所示,在 Atlas Web 应用程序中可以看到Charts选项卡:

图 12.1:图表选项卡

图 12.1:图表选项卡

在第一次使用之前,需要激活 MongoDB Charts 选项。为此,您需要点击“立即激活”按钮来激活 Charts 应用程序,如图 12.1所示。激活过程只需要一分钟。在激活过程中,Atlas 应用程序将设置 Charts 并生成创建和运行 Charts 所需的数据库元数据。

图 12.1所示,在 MongoDB Charts 中,每月最大数据传输限制为 1GB,可用于沙盒测试和学习 Charts。一旦达到限制,直到月底 MongoDB Charts 将无法使用。但是,可以通过将免费版服务升级为付费的 Atlas 服务来增加限制。您可以在www.mongodb.com/pricing找到更多详细信息。

请注意,一旦激活,MongoDB Charts 选项将在 Atlas 项目的整个生命周期内保持激活状态。您将被询问是否希望使用示例数据填充或连接现有的 ATLAS 云中的集群。如果您希望删除 Charts 选项,可以通过转到 Atlas 项目设置来执行。如果您想要为现有项目重新激活全新版本的 Charts,这可能会很有用。然而,删除 Charts 应该谨慎进行,因为它将自动删除云中保存的所有图表和仪表板。一旦激活 Atlas Charts,应用程序将启动,并且可以用于创建图表,如下图所示:

图 12.2:Charts 应用程序

图 12.2:Charts 应用程序

选项按钮显示在应用程序的左侧:

  • 仪表板:顾名思义,此选项帮助管理仪表板。仪表板是将不同图表组合成单个页面以用于业务报告目的的集合。

  • 数据源:使用此选项,您可以管理数据源,这只是对 MongoDB 数据库集合的引用,从中处理数据以显示图表。

  • 图表设置:此选项允许用户管理图表认证提供程序,并监视 Charts 应用程序的网络带宽使用情况。

注意

返回主 Atlas web 应用程序,您可以在 Charts 应用程序的顶部工具栏中点击Atlas标签链接。

仪表板

在商业演示中,通常显示与主题相关的信息。主题是一个类别,比如人力资源或房地产。主题显示包含了各自业务领域的所有相关数据指标,但是一个主题领域的数据通常与数据库结构不相关。这就是数据存储在 MongoDB 数据库中的方式。因此,仪表板是一个图表分组功能,当我们需要以集中和有意义的方式为企业呈现数据时使用。

在当前版本的 Charts 中,云应用程序会自动为我们创建一个空白仪表板。默认仪表板的名称为用户的仪表板,如图 12.2所示,其中用户是 Atlas 登录用户名。

您可以删除默认仪表板并为您的业务演示创建其他仪表板。要创建新的仪表板,您可以点击图 12.2中显示的添加仪表板按钮。将打开一个对话框,在其中您需要添加有关新仪表板的详细信息:

图 12.3:添加仪表板对话框

图 12.3:添加仪表板对话框

要访问仪表板属性,请点击仪表板框中的按钮,如下图所示:

图 12.4:仪表板属性下拉菜单

图 12.4:仪表板属性下拉菜单

在仪表板上下文中有一些按钮和选项可用:

  • 编辑标题/描述:此选项用于更改仪表板的当前标题或描述。

  • 复制仪表板:此选项将仪表板复制到一个新的仪表板中,名称不同。

  • 删除仪表板:此选项将从 MongoDB Charts 中删除仪表板。

  • 锁定:此选项为 Atlas 项目用户分配仪表板权限。对于免费版 Atlas Charts 来说,此选项并不实用,因为 MongoDB 不允许您使用免费版来管理项目用户和团队。

要查看仪表板,请点击仪表板名称链接(例如,“用户的仪表板”)。仪表板将打开并显示其中包含的所有图表。如果没有创建图表,则会显示一个空的仪表板,如下图所示:

图 12.5:用户的仪表板

图 12.5:用户的仪表板

在本章的后面,我们将介绍如何向我们的仪表板添加图表的步骤。但在我们添加新图表之前,我们必须确保数据库文档可用于我们的图表。这是下一节的主题。

数据源

数据源代表 MongoDB 数据库结构和 MongoDB Charts 演示引擎之间的接口。数据源是指向特定数据库集合(或集合)的指针,从中处理数据以创建图表。由于 MongoDB Charts 与 Atlas Web 应用程序集成,所有数据源都配置为连接到 Atlas 数据库部署。因此,数据源包含 Atlas 集群部署的描述、将用于 Charts 的数据库和集合。

数据源还可以在 MongoDB 数据库和 MongoDB Charts 应用程序用户之间提供一定程度的隔离。可以保证数据源不会修改 MongoDB 数据库,因为它们以只读模式访问数据库。没有数据源,Charts 无法访问 MongoDB 数据库中的 JSON 文档。

注意

MongoDB Charts(PaaS 版本)允许数据源仅引用来自 Atlas 云集群部署的数据。因此,不可能从本地 MongoDB 数据库安装创建数据源。在生成新数据源之前,必须将数据库集合和文档上传到 Atlas 数据库集群中。

访问数据源,请点击左侧的“数据源”选项卡,如下图所示:

图 12.6:数据源选项卡

图 12.6:数据源选项卡

在中间,您可以看到现有数据源的列表,页面右上角有“添加数据源”按钮。

如您所见,在当前版本的 Charts 中,您的应用程序会自动填充一个示例数据源。这个示例数据源的名称是“示例数据:电影”。MongoDB 试图通过提供示例数据源和示例仪表板/图表来简化对 Charts 的快速介绍,以便用户在不学习如何使用 Charts 界面的情况下查看一些图表。

注意

示例数据源“示例数据:电影”不能被用户更改或删除。这是因为示例数据源指向一个特殊的 Atlas 数据库,该数据库对您的项目外部,并且用户无法访问。由于不能保证这个数据源将存在于将来的版本中,您应该忽略这个数据源,并继续操作。

要创建新的数据源,您必须提供连接到云 MongoDB 数据库的连接详细信息。数据源通常指向单个数据库集合。由于您已经熟悉了 MongoDB 数据库结构,因此在 Charts 中创建新数据源应该相对容易。

但是,数据源可能比单个数据库集合更复杂。Charts 用户可以使用更复杂的选项(称为数据源预处理)。复杂的数据源包括过滤、连接和聚合等功能。有关预处理功能的更多细节将在本章的后面介绍。目前,让我们专注于在 Charts 中创建新数据源。

要创建数据源,请点击“添加数据源”按钮,如图 12.6所示。屏幕上会出现一个带有“添加数据源”向导的新窗口:

图 12.7:添加数据源窗口

图 12.7:添加数据源窗口

您将看到一个用于 Charts 的云数据库列表(图 12.7)。在免费的 Atlas 中,将有一个M0集群可用。正如您所看到的,页脚上写着从 Charts 到您的集群的连接将是只读的。这是为了让您放心,数据源不会改变数据库信息。您可以从集群列表中选择Cluster0,然后点击下一步按钮。

接下来,将显示可用数据库的列表。您可以展开每个数据库,显示其中所有的集合,并从各自的数据库中选择特定的集合,如下截图所示:

图 12.8:选择集合窗口

](https://gitee.com/OpenDocCN/freelearn-bigdata-zh/raw/master/docs/mongo-fund/img/B15507_12_08.jpg)

图 12.8:选择集合窗口

您可以选择整个数据库,或展开数据库部分并从中选择一个或多个集合。如果选择多个集合(或多个数据库),Atlas 将生成多个数据源——每个数据库集合一个数据源。因此,可以创建多个数据源,而不必多次通过此设置助手。在这种情况下的限制是,所有数据源将指向之前选择的单个数据库集群。

一旦数据源配置并保存,它将出现在列表中,如图 12.9 所示:

图 12.9:数据源选项卡显示配置了 sample_supplies 数据库

](https://gitee.com/OpenDocCN/freelearn-bigdata-zh/raw/master/docs/mongo-fund/img/B15507_12_09.jpg)

图 12.9:数据源选项卡显示配置了 sample_supplies 数据库

练习 12.01:使用数据源

在这个练习中,您将为 Charts 创建新的数据源。这些将在本章后面的示例中再次出现,因此请务必仔细遵循这里的步骤:

注意

请确保您已经在M0集群中上传了 Atlas 示例数据,就像本书的前三章中所展示的那样。正如之前解释的,如果没有有效的 MongoDB 数据库集合,就无法定义新的数据源。

  1. 数据源选项卡中,点击添加数据源,如图 12.6 所示。

  2. 选择您自己的集群,如图 12.7 所示。然后,点击下一步:

  3. 从数据库列表中,点击sample_mflix数据库。如果愿意,可以展开数据库部分,查看sample_mflix数据库中所有集合的列表:图 12.10:选择 sample_mflix 数据库

](https://gitee.com/OpenDocCN/freelearn-bigdata-zh/raw/master/docs/mongo-fund/img/B15507_12_10.jpg)

图 12.10:选择 sample_mflix 数据库

  1. 点击完成按钮。您应该能够在界面中看到创建的五个额外数据源(每个集合一个),如下图所示:图 12.11:数据源列表已更新

](https://gitee.com/OpenDocCN/freelearn-bigdata-zh/raw/master/docs/mongo-fund/img/B15507_12_11.jpg)

图 12.11:数据源列表已更新

在这个例子中,您在 MongoDB Charts 中添加了一个新的数据源。

数据源权限

复杂的 MongoDB 项目可能有许多开发人员和业务用户与 Charts 一起工作。在这种情况下,创建新数据源的 Atlas 用户可能需要与其他 Atlas 项目用户共享。正如前几章所解释的,Atlas 应用程序可以管理大型 Atlas 部署的多个用户。但是,这个概念不适用于本书中大部分示例所展示的免费的 Atlas 沙盒项目。

一旦用户创建了新的数据源,他们就成为了该数据源的所有者,并可以通过在数据源窗口的Charts选项卡中点击ACCESS按钮与其他项目成员共享它(见图 12.9)。这是来自M0免费集群的一个截图示例:

图 12.12:数据源权限窗口

](https://gitee.com/OpenDocCN/freelearn-bigdata-zh/raw/master/docs/mongo-fund/img/B15507_12_12.jpg)

图 12.12:数据源权限窗口

从前面的截图中可以看出,所有者可以为Project0中的Everyone启用或禁用VIEWER权限。VIEWER权限允许用户“使用”数据源来构建他们自己的图表。其他用户不允许修改或删除数据源。

对于大型项目,数据源所有者可以授予特定的 Atlas 组或被邀请参与项目的用户权限。这些高级权限是特定于大型 Atlas 项目的,不在本入门课程中涵盖。

构建图表

可以使用图表生成器在 MongoDB Charts 中创建新图表。要启动图表生成器,请打开仪表板。您可以通过单击仪表板选项卡中的用户仪表板链接来打开自己的用户仪表板,如图 12.5所示。然后,单击添加图表按钮。

以下是图表生成器的屏幕截图:

图 12.13:图表生成器

图 12.13:图表生成器

第一步是选择数据源。选择数据源按钮出现在左上角绿色高亮显示。请注意,需要创建并发布有效的数据源,然后才能将其分配给图表。此外,不可能将多个数据源分配给一个图表。默认情况下,图表生成器会检索集合中的所有文档。

有一个选项可以单击示例模式单选按钮。此模式使图表仅从数据库中检索一部分文档。关于应在图表生成器中加载的 JSON 文档的最大数量没有规定。例如,如果目标是显示精确的聚合值,那么可能需要检索所有文档。另一方面,如果目标是显示趋势或相关图表,则可能只需要一部分文档。然而,在图表中加载大量数据(超过 1GB)将对图表的性能产生负面影响,因此不鼓励这样做。

字段

在图表生成器页面的左侧,您可以看到集合字段的列表:

图 12.14:图表生成器中的字段区域

图 12.14:图表生成器中的字段区域

每个字段都有一个名称和一个数据类型,正如您在第二章文档和数据类型中已经看到的。

以下是图表生成器中的数据类型列表:

  • A - 字符串

  • # - 数字(整数或浮点数)

  • 日期

  • [] - 数组

  • {} - 子文档

注意

在示例截图(图 12.14)中,已选择了sample_mflix.movies的电影数据源。

图表类型

有各种类型的图表可供选择。它们都可以表示类似的视图。但是,某些图表类型更适合特定的场景或数据库数据类型。以下表格列出了所有图表类型及其各自的功能:

图 12.15:MongoDB 中的图表类型

图 12.15:MongoDB 中的图表类型

每种图表类型可能有一个或多个子类型,这些子类型是主图表的视觉变化,并且在不同的展示中很有用。由于图表子类型取决于主图表类型,我们将讨论每种图表类型的子类型。

可以从同一菜单中选择图表子类型,就在图表类型下方,如下面的屏幕截图所示,用于条形图类型:

图 12.16:条形图子类型

图 12.16:条形图子类型

请注意,如图 12.16所示,条形图和柱状图有四种不同的子类型。虽然大多数子类型只是相同图表类型的变体,但某些子类型可能有助于专注于数据的不同方面。例如,分组子类型有助于比较不同类别中的值,而堆叠有助于查看所有类别的累积值。确定适合您的正确子类型的最简单方法是快速浏览它们。图表引擎将自动以您选择的子类型形式重新显示图表。

图表类型选择菜单下方,有一个子菜单,其中包含其他选项卡,用于定义图表通道或维度。以下屏幕截图显示了这些内容:

图 12.17:图表通道

图 12.17:图表通道

以下列表简要描述了每个选项卡:

  • 编码:用于定义图表通道。通道描述了数据如何转换为图表可视化项。不同的图表类型具有不同的编码通道。例如,条形图和折线图具有由笛卡尔坐标表示的通道。

  • 筛选:用于定义数据筛选。此选项有助于筛选输入文档,因此只有所需的文档被考虑用于图表绘制。如果我们想要从图表中排除非相关数据,这将非常有用。

  • 自定义:用于定义图表的功能和美学自定义,例如图表颜色和标签。虽然这个选项并非必需,但在图表可读性方面通常会产生很大的差异。

有关通道利用的更详细信息将在本章后面介绍。现在,让我们浏览一些图表类型和实际示例。

条形图和柱状图

条形图和柱状图可能是演示中最常用的图表类型。图表的基本格式由一组具有不同高度和厚度值的条形组成,排列在二维图中。

条形图特别适用于表示分类数据的聚合值。因此,条形图的主要用途是数据分类或分类。虽然这份材料不是关于数据科学的全面理论,但简短的介绍将帮助您了解基础知识。以下是分类数据的定义描述:

  • 数据分类:这涉及可以根据类别或标签进行识别的数据,例如质量(高、平均、低)或颜色(白色、红色、蓝色)。这也可能包括一些不同的数值或用作类别的数字值(而不是值)。

  • 数据分箱:这意味着根据间隔将数据分组到一个类别中。例如,0 到 9.99 之间的数值可以分组到第一个箱中,10 到 19.99 之间的数值可以分组到第二个箱中,依此类推。通过这种方式,我们可以将许多数值分组到相对较少的类别中。分箱是用于表示统计分析图表的方法,称为直方图。

一旦我们定义了数据类别,我们的二维条形图就可以从那里构建。数据类别将填充图表的一个维度,而计算(聚合)值将填充图表的另一个维度。

练习 12.02:创建一个条形图来显示电影

本练习的目标是创建一个条形图,并熟悉 MongoDB Charts 界面菜单和选项:

  1. 首先,选择图表类型,然后将字段拖放到“编码”区域。例如,如果您选择图表类型“条形图”和“分组”,您可以在“编码”区域看到 X 轴和 Y 轴。

注意

为此图表选择“sample_mflix.movies 数据源”(左上角下拉菜单)

  1. 单击名为“标题”(电影标题)的字段,并将其拖放到“Y 轴”:图 12.18:将标题字段拖放到 Y 轴

图 12.18:将标题字段拖放到 Y 轴

  1. 要限制数值的数量,点击“限制结果”,并在“显示”框中输入5

注意

接受“排序方式”的默认选项,即“值”(见图 12.17)。我们将在接下来的章节中解释编码通道的各种选项。

  1. 下一步是为 X 轴定义值。展开“奖项”字段子文档,然后单击并拖放“获奖次数”到“X 轴”。保持“聚合”默认设置为“求和”:图 12.19:将获奖次数字段添加到 X 轴

图 12.19:将获奖次数字段添加到 X 轴

图现在应该会自动出现在图表生成器屏幕的右侧:

图 12.20:按奖项数量排序的前五部电影

图 12.20:按奖项数量排序的前五部电影

  1. 现在,根据数据库字段对条形进行分组。对于此功能,将多个字段添加到X 轴通道,同时保持标题作为唯一的Y 轴值。要在 X 轴上添加第二组值(分组条形),请将提名拖放到X 轴图 12.21:将提名字段拖动到 X 轴

图 12.21:将提名字段拖动到 X 轴

然后图表会自动更新,显示每部电影的提名获奖

图 12.22:条形图显示顶级电影的奖项和提名

图 12.22:条形图显示顶级电影的奖项和提名

如果您想要比较值,这种图表子类型特别有用。在这种情况下,您比较了每部电影的提名和获奖次数。正如您所看到的,这些值是“分组”的。这正是图表类型选择菜单中分组选项的意思。

如果您更喜欢看到它们“堆叠”而不是分组,那么只需单击“堆叠”按钮(图 12.21),图表将自动更新。如果我们想要看到电影奖项提名和获奖的累积总值,这个选项就很有用:

图 12.23:堆叠条形图的结果(而不是分组)

图 12.23:堆叠条形图的结果(而不是分组)

正如您所看到的,在 MongoDB Charts 中从一个子类型切换到另一个子类型只需要点击一次。结果,图表会自动以新格式重新绘制,而无需任何其他用户输入。一旦我们决定我们的初始子类型选择是否适合我们的演示,这个功能就非常有用。

现在,让我们看看 Atlas 中可用的其他类型的图表。

圆形图表

圆形图表是彩色的圆形或半圆形,通常被细分成扇形,以表示值或百分比。圆形图表也是“单维”的,这意味着图表只能表示一组标量值,而不能表示可以在笛卡尔坐标系中表示的值。考虑到这个限制,我们需要意识到使用这种类型的图表可以表示的信息很少。尽管如此,圆形图表通过强调一个扇形与整体之间的比例,提供了数据比例的强大视觉表示。由于其简单性和视觉冲击力,这种类型的图表对于演示也非常有效。

圆形图表有两种子类型:圆环仪表盘

  • 圆环:这代表一个全彩色的圆(饼),被分成代表值或百分比的扇形。可能有很多值或扇形。然而,建议限制值的数量,以便圆环被分成相对较少的扇形。

  • 仪表盘:这代表一个半圆,与总数的比例。这种类型的图表是圆环类型的简化版本,因为它可以表示单个值的比例。

在下一个练习中,您将学习如何创建一个圆环图。

练习 12.03:从电影收藏创建饼图图表

假设您需要根据电影的原产国来表示电影。由于饼状图通常比表格更直观,您决定使用圆环图来表示这些数据。这也将使您强调世界上产出顶级电影的国家:

  1. 图表类型下拉菜单中选择圆环子类型:图 12.24:选择圆环图子类型

图 12.24:选择圆环图子类型

  1. 点击并将国家字段拖动到标签通道,如下图所示:图 12.25:将国家字段拖动到标签通道

图 12.25:将国家字段拖动到标签通道

  1. 单击选择方法下拉菜单,然后选择按索引选择数组元素(索引=0)以选择所有文档中数组的第一个元素。接受排序方式的默认选项——即

注意

因为countries字段是 JSON 数组数据类型,您最好的选择将是数组缩减方法,这样 Charts 将知道如何解释数据。在这个例子中,您将专注于主要国家制片商(索引=0),并忽略联合制片商。

  1. 减少结果数量(使用限制结果选项)到10。这样,您的饼图将只有10个切片,这将对应于前10个电影制片商:图 12.26:将限制结果的值设置为 10

图 12.26:将限制结果的值设置为 10

  1. title字段拖放到Arc通道中,并选择COUNT选项作为聚合下拉菜单的选项。圆形图表应出现在屏幕右侧,如下所示:图 12.27:顶部电影制片国家的环形图表

图 12.27:顶部电影制片国家的环形图表

这个练习带您完成了构建环形图或饼图所需的几个简单步骤。几乎任何演示文稿或仪表板都包含至少一个饼图,因为它们看起来很吸引人。但吸引力并不是环形图如此受欢迎的唯一原因。环形图也是在视觉图表中表示比率和比例的强大工具。接下来的部分将介绍另一种类型的图表,即地理空间图表。

地理空间图表

地理空间图表是一种特殊类别的图表,其中地理数据是构建图表的主要成分。地理(或地理空间)数据的最简单定义是它包含有关地球上特定位置的信息。位置细节被标在地图上以构建地理空间图表。

地理空间信息可以是具体的或更一般的。以下是一些可以使用地图引擎(如 Google Maps)轻松映射的地理空间数据的示例:

  • 精确的经度和纬度坐标

  • 可以使用地图引擎映射的地址

  • 更广泛的位置,如城市、地区或国家

例如,假设我们有一个包含有关汽车信息的数据库。主数据库集合包含数百万个关于汽车的文档,如型号、里程表细节和其他属性。还有一些其他属性将描述车辆注册的物理地址。然后可以使用该信息构建使用城市地图的地理空间图表。

地理空间图表有几种子类型,如下所示:

  • Choropleth图表:此图表显示着色的地理区域,如地区和国家。这种类型的图表不太具体,通常用于高级别的聚合,例如显示每个国家的 COVID-19 病例总数的图表。

  • 散点图表:此图表需要精确的地址或位置。图表在地图上用一个点或一个小圆圈标记位置。如果我们想要显示具有相对较少的精确位置的图表,这个图表是有用的。

  • 热力图图表:热力图在地图上显示不同强度的颜色。更高的强度对应于该位置的数据库实体的更高密度。热力图图表适用于在地图上显示大量对象的情况,用户更关注密度而不是精确位置。

在下一节中,您将完成一个练习,使用包含样本地理空间信息的sample_mflix数据库,以进一步练习在新的地理空间图表中使用地理点信息。

练习 12.04:创建地理空间图表

本练习的目的是创建一个地理空间图表,代表美利坚合众国所有电影院的地图。您将使用theaters集合来映射地理数据:

  1. 对于数据源,选择sample_mflix.theaters图 12.28:选择 sample_mflix.theaters 作为数据源

图 12.28:选择 sample_mflix.theaters 作为数据源

  1. 选择地理空间图表,并从子类型类别中选择热力图图 12.29:从地理空间图表子类型列表中选择热力图

图 12.29:从地理空间图表子类型列表中选择热力图

  1. 点击geo字段,将其拖放到“坐标”编码通道中:图 12.30:将 geo 字段拖放到坐标编码通道中

图 12.30:将 geo 字段拖放到坐标编码通道中

  1. 接下来,点击theatreId字段,将其拖放到“强度”通道中:图 12.31:将 theatreId 字段拖放到强度通道中

图 12.31:将 theatreId 字段拖放到强度通道中

切换到热力图图表类型时,您应该注意到立即出现的彩色区域图表更新,而不是点状图表——在美国大城市周围的红色强度。

美国地图应该出现在窗口的右侧,并将使用不同的颜色渐变显示剧院的密度。颜色编码显示在图表的右侧。电影院的最高密度(大约在纽约市附近)将显示为地图上的红色(见图 12.32):

图 12.32:热力图图表

图 12.32:热力图图表

在这个练习中,您练习了构建美国所有电影院的地理空间图表。您首先进行数据分析,以查看数据库信息是否适合通过地理空间图表进行呈现。一旦数据在 MongoDB 数据库中可用,构建图表就相对容易。

复杂图表

在之前的章节中,您已经看到了在 Atlas 中使用 MongoDB Charts 是多么容易。虽然用户界面非常直观和易于使用,但它也非常强大。MongoDB Charts 中有许多选项,可以对数据库中的数据进行预处理、分组和以各种方式显示。在本节中,我们将看一些更高级的配置主题。

数据预处理和过滤

如前所述,图表通过在 Charts 中定义的数据源访问数据库。默认情况下,会选择数据库集合中的所有文档来构建新的图表。此外,Charts 中的数据字段将继承原始数据库 JSON 文档的数据格式。

还要注意,数据源不能改变或修改数据库。在现实生活中,经常发生数据格式不适合通过图表进行呈现的情况。数据必须经过准备,或者数据格式在使用图表之前需要以某种方式进行修改。这种用于绘图的数据准备类别称为预处理。

数据预处理包括以下内容:

  • 数据过滤:过滤数据,只选择某些文档

  • 数据类型更改:修改数据类型,使其更适合图表生成器

  • 添加新字段:添加在 MongoDB 数据库中不存在的自定义字段

数据过滤

数据过滤允许用户仅选择来自 MongoDB 集合的子集文档。有时,数据库集合太大,这使得图表生成器的操作变得更慢、更不有效。克服这个问题的一种方法是对数据进行抽样。另一种方法是根据某些类别过滤数据,以便仅考虑图表的子集文档。

用户可以通过以下表中列出的几种方式控制图表中处理的文档数量。

图 12.33:用户可以控制文档数量的方式的文档数量

图 12.33:用户可以控制图表中处理的文档数量的方式

注意

建议选择最适合图表要求的一个过滤器方法,并只使用该过滤器。将两种或三种过滤方法混合到同一个图表中可能会导致混乱,应该避免。

除了Filter Tab方法是 UI 的一部分外,所有其他方法都需要使用 JavaScript 代码来定义过滤器。查询语法在第四章查询文档中有详细介绍。在 Charts 中也可以使用相同的查询格式。例如,要为所有在 1999 年后发布的意大利或法国电影定义过滤器,可以编写以下 JSON 查询:

{ countries: { $in: ["Italy", "France"]}, 
  year: { $gt : 1999}}

一旦将此查询输入到Query栏中,应单击Apply按钮,如下截图所示:

图 12.34:查询栏示例截图

图 12.34:查询栏示例截图

注意

过滤文档可能会导致图表响应延迟,特别是在处理大型数据库时。为了提高性能,可以在涉及过滤表达式的集合字段上创建索引,如第九章性能中所示。

添加自定义字段

Charts 允许用户添加自定义字段,用于构建图表。有时,来自 MongoDB 的原始数据并不提供创建新图表所需的正确属性,因此添加自定义字段变得很重要。这些自定义字段大多是使用源数据库值派生或计算出来的。

可以通过单击图表生成器的Fields区域中的+ Add Field按钮来添加自定义字段,如下截图所示:

图 12.35:字段区域中的添加字段按钮

图 12.35:字段区域中的添加字段按钮

可以添加两种类型的字段:

  • MISSED:此选项用于添加在字段列表中缺失的字段。例如,想象一下,应用程序中添加了一个新字段,数据库中只有少数文档有这个新字段。在这种情况下,MongoDB Charts 可以将缺失的字段添加到初始加载中。

  • CALCULATED:用于添加集合中不存在的新字段。例如,共享乘车应用程序的源数据库可以有关于小时数和每小时费率的字段。但是,总值(小时数乘以费率)可能不在数据库中。因此,我们可以添加一个从数据库中的其他值计算出来的新自定义字段。

注意

如果字段在任何集合文档中不存在,则无法添加MISSED字段。在这种情况下,您需要先添加/更新集合文档。

为了更好地理解这个概念,考虑这个实际例子。在这个例子中,您将在 Charts 中添加一个新的计算字段。执行以下步骤:

  1. 单击Add Field按钮,然后单击CALCULATED按钮,如下截图所示:图 12.36:添加新字段

图 12.36:添加新字段

  1. adjusted_rating中输入新字段名称。

  2. 输入计算总值的公式,即tomatoes.viewer.rating * 1.2

  3. 单击Save按钮。现在应该能够看到新计算字段并在图表中使用它,就像任何其他数据类型属性一样。

注意

计算字段不会保存在数据库中。它们的范围仅限于 MongoDB 图表生成器内。此外,可以从Fields列表中删除计算字段。

更改字段

有时,从数据库中获取的数据不是正确的数据类型。在这种情况下,MongoDB 图表允许用户将字段更改为适合图表绘制的数据类型。例如,图表通道可能需要数据以数字格式进行聚合“求和”或“平均”。要更改字段,请将鼠标指针拖动到“字段”列表中的字段名称上(在“图表构建器”窗口的左侧):

图 12.37:从 fullplot 字段中选择转换类型

图 12.37:从 fullplot 字段中选择转换类型

单击...菜单并选择“转换类型”选项(唯一可用的选项),将显示 JSON 数据类型列表。然后,您可以选择所需的数据类型,然后单击“保存”按钮。

例如,如果要将metacritic数字字段(#)更改为字符串字段(A),可以单击metacritic,然后将显示一个新的“转换类型”窗口,如下所示:

图 12.38:转换类型窗口

图 12.38:转换类型窗口

请注意,更改字段的数据类型只会对当前图表产生影响,并不会更改数据库中的数据类型。

注意

在最新版本的图表中,上下文字段菜单[]中还有另一个选项,称为“查找”。 “查找”字段允许我们通过连接同一数据库中的第二个集合来构建图表。有关如何连接集合的详细信息,请参阅第四章查询文档

通道

编码通道是数据可视化中最重要的方面之一。通道决定了数据在图表中的可视化方式。如果选择了错误的通道类型,用户可能会得到混乱的图表或完全意想不到的结果。因此,对编码通道的正确理解对于高效的图表构建和数据可视化至关重要。

如前面的示例所示,编码通道位于图表构建器的“编码”选项卡下方,就在图表子类型选择按钮的下方:

图 12.39:编码通道

图 12.39:编码通道

每个编码通道都有一个名称和类型。通道名称定义了图表中的目标,即通道将用于的终点。例如,“X 轴”通道名称表示该通道提供了图表的水平轴的值。在这种情况下,我们将会得到一个笛卡尔二维图表是很清楚的。通道类型定义了通道输入所期望的数据类型。找到通道输入的正确数据类型很重要。另外,您现在可能已经注意到,并非所有数据类型都可以被接受为通道输入。

MongoDB 图表中有四种通道类型,如下表所示:

图 12.40:MongoDB 图表中的通道类型列表

图 12.40:MongoDB 图表中的通道类型列表

注意

可以从 JSON 文档中的子文档或数组字段中分配通道值。在这种情况下,MongoDB 图表将要求您标识用于通道编码的元素,例如,数组索引[0](指向每个文档中数组的第一个元素)。

聚合和分箱

一个通道中的数据通常与类别数据类型通道结合在一起,以便它可以计算每个类别的聚合值。例如,我们可以对法国电影的所有奖项进行“求和”聚合。在图表构建器中,当将字段拖放到聚合通道中时,假定值将在图表中进行聚合。图表构建器会在不需要您编写聚合管道代码的情况下透明地执行此操作。

聚合类型将取决于我们在通道输入上提供的数据类型。例如,如果通道提供的数据类型是文本,则不可能进行“求和”操作。

有几种聚合类型,如下表所示:

图 12.41:聚合类型

图 12.41:聚合类型

注意

一些通道可以有“系列”类型。这个选项允许用户向图表添加第二个维度,无论是唯一的还是分组的,都可以通过将数据分组在一系列值中来实现。

练习 12.05:为柱状图分组值

在这个练习中,您将构建另一个柱状图,显示在意大利制作的电影。在这个图表中,您需要按电影发行年份对数据进行聚合。此外,图表应该只考虑 1970 年后发布的电影。为了构建这个图表,您需要过滤文档并选择编码字段来表示按年份聚合的电影。以下步骤将帮助您完成这个练习:

  1. 从仪表板窗口中,单击“添加图表”,然后选择“柱状图”类型。

  2. 将“年份”字段拖放到分类通道“Y 轴”上。图表生成器将检测到有太多的分类不同值(年份),并建议对它们进行分组(将它们分组为 10 年期)。现在,切换“分组”并为“分组大小”输入值10(见下图):图 12.42:输入 10 作为分组大小的值

图 12.42:输入 10 作为分组大小的值

  1. 将“标题”字段拖放到分类通道“X 轴”上。然后,选择“聚合”函数选项“计数”并单击“筛选”选项卡。

  2. 将“国家”字段拖放到图表筛选器中。

  3. 如下所示,从图表筛选器中选择“意大利”:图 12.43:从国家列表中选择意大利

图 12.43:从国家列表中选择意大利

  1. 将第二个字段“年份”拖放到图表筛选器中,并将“最小值”设置为1970,如下所示:图 12.44:选择 1970 作为年份字段的最小值

图 12.44:将 1970 年作为年份字段的最小值

  1. 将图表标题编辑为“意大利电影”,如下所示:图 12.45:最终意大利电影柱状图

图 12.45:意大利电影最终柱状图

  1. 保存图表。

在这个练习中,您以简单的方式使用过滤和聚合技术创建了一个图表,而且没有编写任何 JavaScript 代码。新图表已保存在仪表板上,因此可以在以后加载和编辑。MongoDB 图表生成器具有高效的 Web GUI,可帮助用户创建复杂的图表。除了易于使用外,界面还有许多选项和配置项可供选择。

集成

到目前为止,本章的主题集中在描述 MongoDB 图表 PaaS 的功能上。我们已经了解到用户可以轻松地使用来自 Atlas 云数据库的数据源构建仪表板和图表。本章的最后一个主题涉及 MongoDB 图表的最终结果,即仪表板和图表如何用于演示和应用程序。

一种选择是将图表保存为图像并将其集成到 MS PowerPoint 演示文稿中,或将其发布为网页内容。虽然这个选项非常简单,但它有一个主要缺点,即图表图像是静态的。因此,当数据库更新时,图表不会更新。

另一个选择是将 MongoDB 图表用作演示工具。这个选项保证了图表在数据库更新时会刷新和渲染。然而,这个选项可能不是理想的,因为内容仅限于 MongoDB 图表用户界面,无法轻松集成。

幸运的是,MongoDB 图表有一个选项,可以将图表发布为网页和 Web 应用程序的动态内容。它也可以轻松集成到 MS PowerPoint 演示文稿中。这个集成功能称为嵌入式图表,允许图表在预先设定的时间间隔后自动刷新。

嵌入式图表

嵌入图表是一种选项,您可以使用它来通过提供可用于数据演示和应用程序的网页链接来共享 MongoDB Charts 工具之外的图表。

有三种方法可以共享图表:

  • 未经身份验证:使用此方法,用户无需进行身份验证即可访问图表。他们只需要访问链接。这个选项适用于公共数据或不敏感的信息。

  • 已验证:使用此方法,用户需要进行身份验证才能访问图表。这个选项适用于具有非公开数据的图表。

  • 验证签名:使用此方法,用户需要提供签名密钥才能访问图表。这个选项适用于敏感数据,需要额外的配置和代码来验证签名。

选择方法取决于数据安全要求和政策。未经身份验证方法适用于学习或测试非敏感数据。在具有真实或敏感数据的应用程序中,应始终使用验证签名方法与其他应用程序集成。

如此屏幕截图所示,嵌入图表有几个选项:

图 12.46:嵌入图表窗口

图 12.46:嵌入图表窗口

例如,假设您想要为用户配置未经身份验证访问。选择未经身份验证选项后,您可以指定以下详细信息:

  • 用户指定的过滤器(可选):您可以指定在共享时不可见的字段。

  • 自动刷新:您可以指定图表自动刷新的时间间隔。

  • 主题:您可以指定浅色深色的图表主题。

嵌入代码会自动生成,并可以像图 12.46中所示一样复制到应用程序代码中。

练习 12.06:将图表添加到 HTML 页面

在本练习中,您将创建一个包含使用 MongoDB Atlas Charts 创建的嵌入图表的简单 HTML 报告。使用在练习 12.05中创建的保存图表意大利电影为条形图分箱值

  1. 与前面的部分一样,通过转到数据源选项卡并选择数据源sample_mflix.movies来启用对数据源的访问。

  2. 点击菜单右侧的(),选择外部共享选项

  3. 点击未经身份验证或已验证访问,然后点击保存,如下图所示:图 12.47:外部共享选项截图

图 12.47:外部共享选项截图

  1. 转到仪表板选项卡,打开电影仪表板。您应该能够看到创建和保存的图表,包括意大利电影条形图。

  2. 点击图表右侧的(),然后点击嵌入图表,如下图所示:图 12.48:选择嵌入图表选项

图 12.48:选择嵌入图表选项

嵌入图表窗口将如下图所示出现:

图 12.49:嵌入图表页面

图 12.49:嵌入图表页面

  1. 点击未经身份验证选项卡,并按以下设置更改设置:

自动刷新1 分钟

主题浅色

  1. 复制出现在页面底部的嵌入代码内容。

用户可以通过选择过滤器与嵌入的图表进行交互。要激活此可选功能,请点击用户指定的过滤器(可选),并选择可用于确定图表过滤器的字段。JavaScript SDK 允许使用编码库集成 MongoDB 图表。这个选项是由开发人员驱动的,并且在本章中没有介绍。

  1. 使用诸如记事本之类的文本编辑器创建一个简单的 HTML 页面,并将其保存为.html扩展名。
<hr />
<h3 style="text-align: left;">Introduction to MongoDB - Test HTML&nbsp;</h3>
<p align="center">
<! – Paste here the embedded code copied from MongoDB Chart -- >
</p>
<h3 style="text-align: center;">&nbsp;</h3>
<hr />
<p>&nbsp;</p>
  1. 现在,考虑以下代码行:
<!-- Paste here the embedded code copied from MongoDB chart -->
  1. 在其位置上,添加在步骤 7中复制的代码。最终代码结果应如下所示:
<hr />
<h3 style="text-align: left;">Introduction to MongoDB - Test HTML&nbsp;</h3>
<p align="center">
<iframe style="background: #FFFFFF;border: none;border-radius: 2px;box-  shadow: 0 2px 10px 0 rgba(70, 76, 79, .2);" width="640" height="480"     src="img/charts?id=772fcf16-f0ec-467d-b2bf-        d6a49e665511&tenant=e6ffce97-1ff7-4430-9bb2-          8b8fb32917c5&theme=light"></iframe>
</p>
<h3 style="text-align: center;">&nbsp;</h3>
<hr />
<p>&nbsp;</p>
  1. 保存记事本文件。然后,使用互联网浏览器(如 Google Chrome 或 Microsoft Edge)打开该文件。浏览器应该显示具有动态图表内容的页面,如下面的屏幕截图所示:图 12.50:浏览器视图

图 12.50:浏览器视图

这个练习是 MongoDB 图表如何集成到 HTML 网页中的一个很好的例子,这样内容在数据变化时可以动态更新。在这种情况下,如果数据库记录被更新并且图表被改变,网页也将在 1 分钟的间隔后更新,以反映这些变化。

在本节中,我们已经讨论了图表展示和与外部应用集成的选项。在大多数业务用例中,静态图像不适用于动态网页内容和应用程序。MongoDB 的嵌入图表选项允许用户在演示文稿和 Web 应用程序中集成图表。安全和非安全的图表发布选项都是可用的。然而,对于数据敏感的演示文稿,应始终使用安全选项。

活动 12.01:创建销售演示仪表板

在这个活动中,您将从样本数据库中创建一个新的销售统计图表。具体来说,分析必须帮助确定科罗拉多州丹佛市的销售,基于销售项目类型。以下步骤将帮助您完成此活动:

  1. 创建一个甜甜圈图表,以绘制每个销售项目的销售总额。

  2. sample_supplies数据库创建一个新的数据源。

  3. 过滤数据,使报告中只考虑来自丹佛商店的文档。图表应显示一个甜甜圈,显示前 10 个项目(按价值),并应命名为丹佛销售(百万美元)

  4. 使用图表标签格式化以显示以百万为单位的值,并根据生成的图表解释数据。

最终输出应如下所示:

图 12.51:销售图表

图 12.51:销售图表

注意

此活动的解决方案可以通过此链接找到。

总结

这一章与以往的章节不同,它侧重于图表用户界面而不是 MongoDB 编程。使用 Atlas 云图表模块可以实现令人印象深刻的结果,使用户能够专注于数据而不是编程和演示。

有各种图表类型和子类型可供选择,这使得图表更加有效且更易于使用。MongoDB 图表还可以很容易地使用EMBED CODE选项与其他 Web 应用程序集成,这对开发人员来说是一个优势,因为他们不需要处理另一个编程模块来在他们的应用程序中绘制图表。在下一章中,我们将看一个业务用例,其中 MongoDB 将用于管理后端。

第十三章:MongoDB 案例研究

概述

在本章中,您将学习 MongoDB 如何在商业用例中使用。它以一个场景开始,虚构的市政府和当地初创企业共同开发了一个基于移动应用的共享单车平台。然后,它将涵盖一个详细的项目提案和一些挑战,以及如何使用基于 MongoDB Atlas 的数据库即服务解决方案解决这些挑战。最后,您将探索 MongoDB 如何用于一些用例,逐个进行了解,并验证数据库设计是否涵盖了所有需求。

介绍

到目前为止,在本书中,我们已经成功掌握了 MongoDB 的各个方面,从基本介绍到灾难恢复。对于您选择学习的任何工具或技术,了解其如何使用是很重要的,这就是我们在前几章中所取得的成就。因此,最后一章将专注于使用这项技术来解决现实生活中的问题,并使生活更加轻松。

在本章中,我们将研究一个虚构的市政府及其即将推出的共享单车项目的用例。首先,我们将了解项目的细节并看看为什么需要它;然后,我们将涵盖需求并找出 MongoDB 如何解决他们的问题。

Fair Bay 市政厅

Fair Bay 是位于北罗斯兰东海岸的城市,以其宜人的气候和历史意义而闻名。它也是该国的主要商业中心之一。在过去的二十年里,这座城市创造了巨大的就业机会,并吸引了来自全国和全球各地的人才。因此,过去十年间,这座城市的人口急剧增加,进而推动了城市的房地产市场。

城市正在快速扩张,当地市政府正在努力评估和重新开发城市的基础设施和设施,以维持其生活指数的便利性。他们经常对其公共基础设施进行调查和评估,以确定公众提出的一些最常见的问题。

在过去的评估和调查中,当地社区居民反复提出了以下关切:

  • 当地交通总是拥挤。

  • 交通拥堵频繁发生。

  • 燃油和停车价格不断上涨。

  • 城市中心的空气质量很差。

  • 通勤时间正在增加。

为了解决这些投诉,市政府邀请公司、初创企业,甚至公众提出智能和创新的想法和相关项目提案。经过仔细审查和批准,最佳提案被送交州发展和规划委员会审批资金。市政府的倡议迄今为止取得了巨大成功,因为他们有几个受欢迎的想法。今年,提交的项目提案之一引起了大家的注意。当地一家初创企业提出了一个在线共享单车平台 Fair Bay City Bikes 的推出。除了是独特的创新解决方案外,它也是最环保的项目提案之一。他们的提案细节在以下部分中概述。

Fair Bay City Bikes

人口稠密的大都市经常遭受交通拥堵和公共交通拥挤。共享单车计划是一种可持续的出行方式,有几个原因。它提供了比使用汽车、公共交通或私人自行车更健康和更便宜的交通方式。它涉及在城市各地采购和停放自行车。这些自行车可以由公众使用,按先到先得的原则,进入城市。通常,自行车的预订和跟踪是通过在线平台控制的。研究和调查得出结论,一个良好实施的共享单车计划可以:

  • 减少交通拥堵

  • 改善空气质量

  • 减少汽车和公共交通的使用

  • 帮助人们节省在其他交通工具上的花费

  • 鼓励更健康的生活方式

  • 增进社区感

因此,许多城市正在通过提供共享单车平台和城市内的专用自行车道来积极鼓励骑自行车。Fair Bay City Bikes 项目是一个具有独特特点的下一代共享单车平台,如自动锁定和用户友好的移动应用程序。接下来,我们将看一下他们提案的一些主要亮点。

提案亮点

Fair Bay City Bikes 项目的一些亮点如下。

无桩自行车

Fair Bay City Bike 项目是一个无桩共享单车项目。一般来说,自行车需要专用的停车站来锁定。用户需要访问这些停车站来开始和结束他们的骑行。这种系统的主要缺点是在城市各地均匀设置停车站基础设施。建立这样的网络涉及在每个地区找到一个安全和合适的地方,这通常是负担不起的。其次,人们往往很难找到和访问停车站。对用户来说,找不到靠近目的地的空停车站是一个常见的问题,这让他们不愿使用该系统。

另一方面,无桩自行车具有内置的自动锁定和解锁机制。它们可以在任何安全的地方或任何专用停车区取车、停车和留下。用户可以在其周围停放的任何自行车中选择,并将它们留在靠近目的地的任何安全停车位。

易于使用

用户可以在他们的手机上下载并访问 City Bikes 应用程序。在提供一些个人信息,如姓名、电话号码和政府发行的照片 ID(如驾驶执照)后,他们就可以随时使用自行车。

用户可以使用应用程序中的查找功能开始骑行,并根据他们的位置,在地图视图中显示最近可用自行车的列表。用户可以选择任何可用的自行车,并使用应用程序内的导航辅助功能到达自行车。接下来,用户需要扫描自行车上的唯一快速响应(QR)码,然后简单点击解锁。

图 13.1:用户可以扫描以解锁自行车的 QR 码

图 13.1:用户可以扫描以解锁自行车的 QR 码

一旦自行车解锁,它就暂时与用户的账户关联起来。完成旅程后,用户需要将自行车停放在一个安全的地方,打开应用程序,点击锁定自行车,这将释放自行车与用户账户的关联。

实时跟踪

所有自行车都有内置的 GPS 跟踪设备,可以实时跟踪它们的位置。有了这种跟踪能力,用户可以轻松搜索周围地区的可用自行车,并使用导航辅助功能访问自行车。

此外,一旦骑行开始,每辆自行车的位置将每 30 秒记录一次并记录到系统中。这些记录将用于报告、分析和在紧急情况或盗窃时跟踪自行车。用户可以在任何时间将自行车带到城市的任何地方,实时跟踪帮助他们感到安全。

维护和保养

所有自行车都需要定期维护和仔细检查,以确保它们能够有效运行。这种维护每 15 天进行一次,期间自行车会被清洁,移动部件会被润滑,轮胎气压会被检查和调节,刹车会被检查和调整。每天,系统会识别需要维护的自行车,将它们从系统的可用自行车列表中移除,并通知一个技术团队。

技术讨论和决策

议案受到理事会成员的高度赞赏,他们对其尖端功能和低成本实施印象深刻,因为无桩系统比使用停靠式自行车要便宜得多。理事会准备采购自行车,修建自行车道,并在整个城市实施信号系统。他们还将准备使用和安全指南以及处理广告。初创团队负责构建 IT 基础设施和移动应用程序。

理事会坚持要求团队将 IT 基础设施成本降至最低,减少总体推出时间,并构建一个可扩展和灵活的系统以满足未来的需求变化。初创公司的技术团队进行了一些研究,以解决这些条件,详细内容如下。

快速推出

团队时间紧迫,需要找到一种快速构建和快速交付的方法。实现这一目标的关键是减少研究时间,并选择知名和经过验证的技术。技术团队已经准备好了移动应用程序和后端应用程序。他们现在唯一需要做的就是决定一个合适的数据库平台。需要一个数据库来持久化客户详细信息、自行车详细信息、自行车的实时位置和骑行详细信息。这个数据库平台应该快速设置,而不用太担心基础设施、集成、安全性或备份。团队决定选择数据库即服务DBaaS)解决方案,以提供可靠、可扩展的解决方案,并缩短上市时间。

成本效益

由于理事会同时资助了许多项目,预算有点紧张。因此,他们决定先从 200 辆自行车开始,观察效果,并征求公众反馈。根据这些反馈,他们愿意将车队规模增加到 1,000 辆,甚至如果需要的话增加到 2,000 辆。车队规模的增加将进一步导致需要管理的数据量增加。为此,DBaaS平台是一个很好的选择,因为它允许您从最小的设置开始,并根据需要进行扩展。

最初的 200 辆自行车意味着最多会有 200 次骑行。因此,不需要进行大型数据集处理,因此团队决定选择低 RAM 和低 CPU 集群。随着车队规模的增长,他们可以进行横向或纵向扩展,并且成本始终会根据使用需求进行优化。

灵活

在一次理事会会议上,一些成员提出了以下建议:

  • 收费:只有居民可以免费使用,而游客和访客将为每次骑行付费。

  • 使用护照作为有效的身份证明:将护照添加到有效身份证件列表中。没有政府提供的照片身份证的客户可以使用他们的护照来注册系统。

  • 将滑板车加入车队:系统应支持共享自行车和共享滑板车。

这些建议肯定会通过使系统更加用户友好来改进系统。然而,在这些建议纳入系统之前,需要进行一些分析。收费和支持不同类型的身份验证需要与联邦和外部系统集成。这种集成需要遵守相关部门发布的不同规则、法规和安全和隐私政策。

考虑到这些挑战,理事会决定坚持当前的第一阶段计划。建议更改的要求将在项目的第二阶段中得到最终确定并纳入。

技术团队了解到系统需要足够灵活,以便纳入任何尚不明确或不确定的未来更改。根据当前的技术设计,用户具有驾驶执照号码作为 ID,但需要更灵活地存储其他类型的 ID。此外,为了收取费用,模式需要足够灵活,以纳入用户的银行账户或信用卡详细信息。同样,为了引入车队中的滑板车(可能具有不同的维护要求或不同的费用结构),系统需要能够区分自行车和滑板车。

在这种情况下,传统的数据库实体,其受严格的模式定义约束,不是一个好选择。为了纳入一些未来的更改,它们的模式定义需要首先进行更新。使用传统数据库,模式更改很难推出和回滚。经过仔细考虑和比较,团队决定选择 MongoDB Atlas 集群。MongoDB 提供了灵活的模式和水平以及垂直扩展能力。Atlas 集群帮助推出一个生产级系统,只需点击几下即可,并且在成本和时间上节省了大量开支。在下一节中,我们将详细介绍数据库设计。

数据库设计

根据前几节描述的要求,要持久化的三个基本实体是uservehiclerideuservehicle实体将分别存储用户和车辆的属性,而ride实体将在开始新的骑行时创建。

除了基本实体外,还需要一个额外的实体来跟踪自行车骑行日志。对于每次活动骑行,系统会捕获并记录自行车的位置。这些日志将用于报告和分析目的。

由于 MongoDB 提供的基于文档的数据集,所有实体都可以轻松设计为集合。这些集合及其一些示例记录将在下一节中探讨。

用户

users集合保存了所有在系统中注册的用户的数据。以下代码片段显示了代表其中一个注册用户的示例文档:

{
    "_id" : "a6e36e30-41fa-45bf-93c5-83da4efeed37",
    "email_address" : "ethel.112@example.com", 
    "first_name" : "Ethel",
    "last_name" : "Carter",
    "date_of_birth" : ISODate("1993-06-01T00:00:00Z"),
    "address" : {
        "street" : "51 Thornridge Cir",
        "city" : "Fair Bay",
        "state" : "North Roseland",
        "post_code" : 9924,
        "country" : "Roseland"
    },
    "registration_date" : ISODate("2020-11-24T00:00:00Z"),
    "id_documents" : [
    {
        "drivers_license" : {
            "license_number" : 2771556252,
            "issue_date" : ISODate("2011-04-18T00:00:00Z")
        }
    }],
    "payments" : [
    {
        "credit_card" : {
            "name_on_card" : "Ethel Carter",
            "card_number" : 342610644867494,
            "valid_till" : "3/22"
        }
    }]
}

文档中的主键是随机生成的唯一 UUID 字符串。还有其他字段用于保存用户的基本信息,如他们的名字、姓氏、出生日期、地址、电子邮件地址和系统注册日期。id_documents字段是一个数组,目前存储驾驶执照详细信息。将来,当启用其他 ID 类型,如护照时,用户将能够提供多个 ID 详细信息。目前收集付款详细信息是作为预防措施。除非自行车在骑行过程中损坏或被盗,否则客户不会被收费。payments字段是一个数组,目前存储信用卡详细信息。一旦系统与其他支付网关集成,用户将有其他支付方式的选择。

车辆

vehicles集合代表车队中的自行车。城市自行车最初将有 200 辆自行车。下面的车辆文档结构及所有字段和示例值将在以下片段中显示:

{
    "_id" : "227fe7e0-76c7-410b-afe8-6ae5785ac937",
    "vehicle_type" : "bike|scooter",
    "status" : "available",
    "rollout_date" : ISODate("2020-10-20T00:00:00Z"),
    "make" : {
        "Manufacturer" : "Compass Cycles",
        "model_name" : "Unisex - Flatbar Carbon Frame Road Bike",
        "model_code" : "CBUFLATR101",
        "year" : 2020,
        "frame_number" : "FWJ166K23683958E"
    },
    "gears" : 3,
    "has_basket" : true,
    "has_helmet" : true,
    "bike_type" : "unisex|men|women",
    "location" : {
        "type" : "Point",
        "coordinates" : [
            111.189631,
            -72.454577
        ]
    },
    "last_maintenance_date" : ISODate("2020-11-05T00:00:00Z")
}

此文档中的主键是唯一的 UUID 字符串。此 ID 用于唯一地引用车辆,例如在 QR 码或车辆骑行详情中。还有其他静态字段用于表示车辆的投放日期、制造商名称、型号、车架号、齿轮数等。考虑到市政府未来计划推出滑板车,引入了一个名为 vehicle_type 的字段。该字段区分自行车和滑板车。status 字段表示自行车当前是否可用、正在骑行或正在维护(在这种情况下,它是可用的)。该字段可以包含这三个值中的任何一个:availableon_rideunder_maintenance。最后的维护日期有助于确定车辆是否需要维护。location 字段表示车辆的当前地理位置,并且在 MongoDB 的 Point 类型的地理空间索引中表示。其他可选字段,如 has_baskethas_helmetbike_type,对于满足特定要求的客户非常有用。请注意,自行车型号可以分为 男士女士 或中性自行车,而滑板车始终是中性的。因此,只有当 vehicle_typebike 时,才会出现 bike_type 字段。

骑行

rides 集合代表了行程,该集合中的文档总数表示通过系统进行的骑行次数:

{
    "_id" : "ebe89a65-ee02-4fa8-aba7-88c33751d487",
    "user_id" : "a6e36e30-41fa-45bf-93c5-83da4efeed37",
    "vehicle_id" : "227fe7e0-76c7-410b-afe8-6ae5785ac937",
    "start_time" : ISODate("2020-11-25T02:10:00Z"),
    "start_location" : {
        "type" : "Point",
        "coordinates" : [
            111.189631,
            -72.454577
        ]
    },
    "end_time" : ISODate("2020-11-25T03:17:00Z"),
    "end_location" : {
        "type" : "Point",
        "coordinates" : [
            111.045789,
            -72.456144
        ]
    },
    "feedback" : {
        "stars" : 5,
        "comment" : "Navigation helped me locate the bike quickly, enjoyed my           ride. Thank you City Bikes"
    }
}

每次骑行都有一个随机生成的 UUID 字符串作为主键。user_idvehicle_id 字段分别表示当前使用骑行的用户和车辆。当用户解锁自行车时,将创建 ride 文档,并在创建时插入 start_timestart_location 字段。当用户在行程结束时锁定自行车时,将创建 end_timeend_location 字段。还有一个可选字段用于表示反馈,其中记录了星级评分和用户评论。

骑行日志

ride_logs 集合记录了每次活动骑行的进展,每隔 30 秒记录一次。该集合主要用于分析和报告。通过使用该集合中的数据,可以实时追踪任何骑行的完整路径。在骑行过程中,如果自行车发生事故或自行车丢失,自行车的最后记录的条目可以帮助定位它。以下代码片段显示了同一辆自行车骑行的三个连续日志条目:

{
    "_id" : "6b868a75-5c47-4b36-a706-e84b486d4c40",
    "ride_id" : " -ee02-4fa8-aba7-88c33751d487",
    "time" : ISODate("2020-11-25T02:10:00Z"),
    "location":{
        "type":"Point",
        "coordinates":[111.189631, -72.454577]
    }
}
{
    "_id" : "e33f9d94-8787-4b0d-aa52-08795fab2b38",
    "ride_id" : "ebe89a65-ee02-4fa8-aba7-88c33751d487",
    "time" : ISODate("2020-11-25T02:10:30Z"),
    "location":{
        "type":"Point",
        "coordinates":[111.189425 -72.454582]
    }
}
{
    "_id" : "8d39567b-efc5-43d4-9034-f636c97c97b3",
    "ride_id" : "ebe89a65-ee02-4fa8-aba7-88c33751d487",
    "time" : ISODate("2020-11-25T02:11:00Z"),
    "location":{
        "type":"Point",
        "coordinates":[111.189291, -72.454585]
    }
}

每个日志条目都有一个唯一的 UUID 字符串作为主键。文档包含 ride_id,有助于追踪骑行、用户和车辆的详细信息。timelocation 字段有助于跟踪车辆在特定时间的地理坐标。出于分析目的,可以以多种方式使用该集合生成有用的统计信息,以识别和解决现有问题或进行未来改进。例如,该集合有助于找出所有骑行的平均自行车速度、特定区域的平均速度,或特定年龄组骑手的平均速度。通过比较这些统计数据,市政府可以确定骑手倾向于在哪些城市区域骑行速度较慢,并提供充足的自行车道。此外,他们可以通过骑手年龄来检查自行车使用和速度模式,并指定安全速度限制。该集合还有助于找出城市中最受欢迎和最不受欢迎的自行车骑行区域。根据这些信息,市政府可以采取适当措施,在受欢迎的区域提供更多的自行车,在不受欢迎的区域提供更少的自行车。

本节介绍了 MongoDB 数据库结构的细节和集合的解剖结构。在下一节中,我们将通过一些示例场景来运行各种用例。

用例

前面的部分提供了 City Bikes 系统的概述,要求和考虑因素以及数据库结构。现在,我们将列出使用案例和数据库查询的系统使用情况,使用一些示例场景。这将有助于验证设计的正确性,并确保没有遗漏任何要求。

用户查找可用自行车

考虑这样一种情况,用户在其手机上打开应用程序,然后点击查找半径 300 米内的自行车。用户当前的坐标是经度 111.189528 和纬度-72.454567。下一个代码片段显示了相应的数据库查询:

db.vehicles.find({
    "vehicle_type" : "bike", 
    "status" : "available",
    "location" : {
        $near : {
            $geometry : {
                "type" : "Point",
                "coordinates" : [111.189528, -72.454567]
            },
            $maxDistance : 300
        }
    }
})

查询所有当前可用并位于请求的 300 米半径内的自行车。

用户解锁自行车

用户扫描自行车上的 QR 码(227fe7e0-76c7-410b-afe8-6ae5785ac937),然后点击解锁。解锁自行车会开始骑行,并使自行车对其他用户不可用。

使用我们的数据库,此场景可以分为两个步骤实现。首先,应更改自行车的状态,然后应创建新的骑行记录。以下代码片段显示了如何执行此操作:

db.vehicles.findOneAndUpdate(
    {"_id" : "227fe7e0-76c7-410b-afe8-6ae5785ac937"},
    {
        $set : {"status" : "on_ride"}
    }
)

上述命令将自行车的状态设置为on_ride。由于自行车的状态不再设置为available,因此其他用户执行的自行车搜索中将不会出现该自行车。下一个代码片段显示了rides集合上的insert命令:

db.rides.insert({
    "_id" : "ebe89a65-ee02-4fa8-aba7-88c33751d487",
    "user_id" : "a6e36e30-41fa-45bf-93c5-83da4efeed37",
    "vehicle_id" : "227fe7e0-76c7-410b-afe8-6ae5785ac937",
    "start_time" : new Date("2020-11-25T02:10:00Z"),
    "start_location" : {
        "type" : "Point",
        "coordinates" : [
            111.189631,
            -72.454577
        ]
    }
})

insert命令创建了一个新的骑行记录,并将用户、自行车和骑行关联在一起。它还捕获了骑行的开始时间和开始位置。

用户锁定自行车

骑行结束时,用户将自行车停放在安全位置,打开应用程序,然后点击屏幕完成骑行。这也需要两个步骤。首先,需要更新骑行记录的细节。其次,需要更新车辆的状态和新位置:

db.rides.findOneAndUpdate(
    {"_id" : "ebe89a65-ee02-4fa8-aba7-88c33751d487"},
    {
        $set : {
            "end_time" : new Date("2020-11-25T03:17:00Z"),
            "end_location" : {
                "type" : "Point",
                "coordinates" : [
                    111.045789,
                    -72.456144
                ]
            }
        }
    }
)

上述命令设置了骑行的结束时间和坐标。请注意,缺少结束位置和结束时间表示骑行仍在进行中:

db.vehicles.findOneAndUpdate(
    {"_id" : "227fe7e0-76c7-410b-afe8-6ae5785ac937"},
    {
        $set : {
            "status" : "available",
            "location" : {
                "type" : "Point",
                "coordinates" : [
                    111.045789,
                    -72.456144
                ]
            }
        }
    }
)

上述命令将车辆标记为可用,并使用新坐标更新其位置。

系统记录骑行的地理坐标

每 30 秒,一个预定的作业查询所有活动骑行的自行车,通过 GPS 收集它们的最新地理坐标,并为每辆自行车创建骑行记录条目。下一个代码片段显示了logs集合的insert命令:

db.ride_logs.insert({
    "_id" : "8d39567b-efc5-43d4-9034-f636c97c97b3",
    "ride_id" : "ebe89a65-ee02-4fa8-aba7-88c33751d487",
    "time" : new Date(),
    "location":{
        "type":"Point",
        "coordinates":[
            111.189291, 
            -72.454585
        ]
   }
})

上述命令演示了如何创建新的骑行记录。它使用new Date()GMT中记录当前时间戳,并插入给定自行车骑行的最新位置坐标。

系统将自行车送去维护

所有自行车每两周需要定期维护。技术人员定期检查自行车并修复任何已识别的问题。每天午夜都会执行一个预定的作业,并检查所有自行车的最后维护日期。该作业有助于找到所有自行车,其维护在过去 15 天内未完成,并将其标记为需要维护。然后自行车变为不可用。以下命令查找所有最后维护日期早于当前日期 15 天的自行车:

db.vehicles.updateMany(
    {
        "last_maintenance_date" : {
            $lte : new Date(new Date() - 1000 * 60 * 60 * 24 * 15)
        }
    },
    {
        $set : {"status" : "under_maintenance"}
    }
)

1000 * 60 * 60 * 24 * 15表达式表示 15 天的毫秒数。然后从当前日期中减去计算出的毫秒数,以找到 15 天前的日期。如果自行车的last_maintenance_date字段早于 15 天,其状态将标记为under_maintenance

技术人员每两周进行一次维护

技术团队找到所有状态为under_maintenance的自行车,进行维护,并使自行车可用:

db.vehicles.findOneAndUpdate(
    {"_id" : "227fe7e0-76c7-410b-afe8-6ae5785ac937"},
    {
        $set : {
            "status" : "available",
            "last_maintenance_date" : new Date()
        }
    }
)

此命令将自行车状态设置为可用,并将last_maintenance_date设置为当前时间戳。

生成统计数据

分析师的任务是利用应用程序生成的各种统计数据,识别改进和优化的领域,以及评估系统在资金支出方面的好处。他们可以以多种方式使用数据库;然而,我们将使用一个示例用例进行演示。

城市的中央公园(位于108.146337,-78.617716)是一个非常受欢迎和拥挤的地方。为了方便骑车者骑行,议会在公园周围建造了特殊的自行车道。议会想知道有多少 City Bike 骑手在这些车道上骑行。

分析师执行了一个快速查询,以找到在中央公园 200 米半径范围内骑行的自行车行程:

db.ride_logs.distinct(
    "ride_id", 
    {
        "location" : {
            $near : {
                $geometry : {
                    "type" : "Point",
                    "coordinates" : [108.146337, -78.617716]
                },
                $maxDistance : 200
            }
        }
    }
)

这个特殊的查询在ride_logs上过滤所有日志条目,以找出有多少自行车骑行与给定位置地理上接近,并打印它们的骑行 ID。

在本节中,我们讨论了应用程序可以使用的各种场景,并使用 MongoDB 查询和命令满足了它们。

摘要

本章探讨了一个虚构城市议会实施的 City Bikes 项目。首先考虑了议会可能面临的问题,以及项目提案如何解决这些问题。这些考虑包括议会的时间和预算、不确定的需求,以及技术团队决定使用基于 MongoDB Atlas 的 Database-as-a-Service(DBaaS)解决方案来解决所有这些问题。您详细研究了数据库设计,并审查了 MongoDB 查询,以记录、实施和解决本示例系统中的几个示例场景。

在整个课程中,您通过实际示例和应用程序介绍了 MongoDB 的各种功能和优势。您从 MongoDB 的基础知识开始,了解了它的性质和功能,以及它与传统的 RDBMS 数据库的区别。然后,您揭示了其基于 JSON 的数据结构和灵活的模式所提供的优势。接下来,您学习了核心数据库操作和运算符,以从集合中查找、聚合、插入、更新和删除数据,以及更高级的概念,如性能改进、复制、备份和恢复,以及数据可视化。您还在云中使用 MongoDB Atlas 创建了自己的 MongoDB 数据库集群,然后将真实的示例数据集加载到了集群中,并在整本书中使用了这些数据。最后,本章通过演示 MongoDB 解决方案如何解决现实生活中的问题来结束了本课程。

通过本书学到的知识和技能,您将能够在工作场所或自己的个人项目中实施高度可扩展、强大的数据库设计,以满足业务需求。

附录

1. MongoDB 介绍

活动 1.01:设置电影数据库

解决方案:

以下步骤将帮助您完成此活动:

  1. 首先,连接到作为Exercise 1.04的一部分设置的 MongoDB 集群,在 Atlas 上设置您的第一个免费 MongoDB 集群。它应该看起来像这样:
mongo "mongodb+srv://cluster0-zlury.mongodb.net/test" –username   <yourUsername>
  1. 在命令提示符上输入前面的命令,并在提示时提供密码。成功登录后,您应该看到带有您的集群名称的 shell 提示,类似于这样:
MongoDB Enterprise Cluster0-shard-0:PRIMARY>
  1. 现在,创建电影数据库并将其命名为moviesDB。利用use命令:
use moviesDB
  1. 创建一个带有几个相关属性的movies集合。通过将文档插入不存在的集合来创建集合。鼓励您考虑并实现您认为最合适的集合和属性:
db.movies.insertMany(
    [
        {
            "title": "Rocky",
            "releaseDate": new Date("Dec 3, 1976"),
            "genre": "Action",
            "about": "A small-time boxer gets a supremely rare               chance to fight a heavy-weight champion in a bout                 in which he strives to go the distance for his                   self-respect.",
            "countries": ["USA"],
            "cast" : ["Sylvester Stallone","Talia Shire",              "Burt Young"],
            "writers" : ["Sylvester Stallone"],
            "directors" : ["John G. Avildsen"]
        },
        {
            "title": "Rambo 4",
            "releaseDate ": new Date("Jan 25, 2008"),
            "genre": "Action",
            "about": "In Thailand, John Rambo joins a group of               mercenaries to venture into war-torn Burma, and rescue                 a group of Christian aid workers who were kidnapped                   by the ruthless local infantry unit.",
            "countries": ["USA"],
            "cast" : [" Sylvester Stallone", "Julie Benz", "Matthew               Marsden"],
            "writers" : ["Art Monterastelli","Sylvester Stallone"],
            "directors" : ["Sylvester Stallone"]
        }
    ]
)

这应该导致以下输出:

{
  "acknowledged" : true,
  "insertedIds" : [
    ObjectId("5f33d027592962df72246aed"),
    ObjectId("5f33d027592962df72246aee")
  ]
}
  1. 使用find命令获取您在上一步中插入的文档,即db.movies.find().pretty()。它应该返回以下输出:
{
      "_id" : ObjectId("5f33d027592962df72246aed"),
      "title" : "Rocky",
      "releaseDate" : ISODate("1976-12-02T13:00:00Z"),
      "genre" : "Action",
      "about" : "A small-time boxer gets a supremely rare chance to         fight a heavy-weight champion in a bout in which he strives to           go the distance for his self-respect.",
      "countries" : [
            "USA"
      ],
      "cast" : [
            "Sylvester Stallone",
            "Talia Shire",
            "Burt Young"
      ],
      "writers" : [
            "Sylvester Stallone"
      ],
      "directors" : [
            "John G. Avildsen"
      ]
}
{
      "_id" : ObjectId("5f33d027592962df72246aee"),
      "title" : "Rambo 4",
      "releaseDate " : ISODate("2008-01-24T13:00:00Z"),
      "genre" : "Action",
      "about" : "In Thailand, John Rambo joins a group of mercenaries         to venture into war-torn Burma, and rescue a group of           Christian aid workers who were kidnapped by the ruthless             local infantry unit.",
      "countries" : [
            "USA"
      ],
      "cast" : [
            " Sylvester Stallone",
            "Julie Benz",
            "Matthew Marsden"
      ],
      "writers" : [
            "Art Monterastelli",
            "Sylvester Stallone"
      ],
      "directors" : [
            "Sylvester Stallone"
      ]
}
{
      "_id" : ObjectId("5f33d050592962df72246aef"),
      "title" : "Rocky",
      "releaseDate" : ISODate("1976-12-02T13:00:00Z"),
      "genre" : "Action",
      "about" : "A small-time boxer gets a supremely rare chance to         fight a heavy-weight champion in a bout in which he strives to           go the
          distance for his self-respect.",
      "countries" : [
            "USA"
      ],
      "cast" : [
            "Sylvester Stallone",
            "Talia Shire",
            "Burt Young"
      ],
      "writers" : [
            "Sylvester Stallone"
      ],
      "directors" : [
            "John G. Avildsen"
      ]
}
{
      "_id" : ObjectId("5f33d050592962df72246af0"),
      "title" : "Rambo 4",
      "releaseDate " : ISODate("2008-01-24T13:00:00Z"),
      "genre" : "Action",
      "about" : "In Thailand, John Rambo joins a group of mercenaries         to venture into war-torn Burma, and rescue a group of           Christian aid
          workers who were kidnapped by the ruthless local             infantry unit.",
      "countries" : [
            "USA"
      ],
      "cast" : [
            " Sylvester Stallone",
            "Julie Benz",
            "Matthew Marsden"
      ],
      "writers" : [
            "Art Monterastelli",
            "Sylvester Stallone"
      ],
      "directors" : [
            "Sylvester Stallone"
      ]
}
  1. 您可能还想在您的电影数据库中存储奖项信息。创建一个带有几条记录的awards集合。鼓励您考虑并提出自己的集合名称和属性。以下是在awards集合中插入几个示例文档的命令:
db.awards.insertOne(
    {
        "title": "Oscars",
        "year": "1976",
        "category": "Best Film",
        "nominees": ["Rocky","All The President's Men","Bound For           Glory","Network","Taxi Driver"],
        "winners" :
        [
            {
                "movie" : "Rocky"
            }
        ]
    }
)
db.awards.insertOne(
    {
        "title": "Oscars",
        "year": "1976",
        "category": "Actor In A Leading Role",
        "nominees": ["PETER FINCH","ROBERT DE NIRO","GIANCARLO           GIANNINI"," WILLIAM HOLDEN","SYLVESTER STALLONE"],
        "winners" :
        [
            {
                "actor" : "PETER FINCH",
                "movie" : "Network"
            }
        ]
    }
)

这些命令中的每个都应该生成以下类似的输出:

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

这些命令中的每个都应该生成以下类似的输出:

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

注意

插入的 ID 是插入的文档的唯一 ID,因此它与前面的输出中提到的不同。

  1. 运行find命令以从awards集合获取文档。以//(双斜杠)开头的行是注释,仅用于描述目的;数据库不会将其作为命令执行:
// find all the documents from the awards collection
db.awards.find().pretty()

以下是前述命令的输出:

图 1.39:来自奖项集合的文档

图 1.39:来自奖项集合的文档

注意

这个练习是让您添加尽可能多的集合/文档,以便有效和高效地存储电影数据。随时添加任何其他相关的集合和文档。

在这个活动中,您为电影数据库找到了一个相关的数据库解决方案。您还在 MongoDB Atlas 上创建了一个用于存储集合和文档的数据库。

在下一章中,您将获得有关电影的另一个样本数据集的导入步骤。建议您实际考虑电影数据库所需的其他集合或集合中的属性。您还将在下一章中看到您的数据集与提供的样本有何不同。

2. 文档和数据类型

活动 2.01:将推文建模为 JSON 文档

解决方案:

执行以下步骤以完成活动:

  1. 识别并列出可以包含在 JSON 文档中的推文中的以下字段:
creation date and time
user id
user name 
user profile pic
user verification status
hash tags
mentions
tweet text
likes
comments
retweets
  1. 将相关字段分组,以便它们可以作为嵌入对象或数组放置。由于推文可以有多个标签和提及,因此可以表示为数组。修改后的列表如下所示:
creation date and time
user 
  id
  name 
  profile pic
  verification status
hash tags
  [tags]
mentions
  [mentions]
tweet text
likes
comments
retweets
  1. 准备用户对象并从推文中添加值:
{
  "id": "Lord_Of_Winterfell",
  "name": "Office of Ned Stark",
  "profile_pic": "https://user.profile.pic",
  "isVerified": true
}
  1. 将所有标签列为数组:
[
  "north",
  "WinterfellCares",
  "flueshots"
]
  1. 将所有提及包括为数组:
[
  "MaesterLuwin",
  "TheNedStark",
  "CatelynTheCat"
]

一旦将所有文档与其余字段组合,最终输出将如下所示:

{
  "id": 1,
  "created_at": "Sun Apr 17 16:29:24 +0000 2011",
  "user": {
    "id": "Lord_Of_Winterfell",
    "name": "Office of Ned Stark",
    "profile_pic": "https://user.profile.pic",
    "isVerified": true
  },
  "text": "Tweeps in the #north. The long nights are upon us. Do            stock enough warm clothes, meat and mead…",
  "hashtags": [
    "north",
    "WinterfellCares",
    "flueshots"
  ],
  "mentions": [
    "MaesterLuwin",
    "TheNedStark",
    "CatelynTheCat"
  ],
  "likes_count": 14925,
  "retweet_count": 12165,
  "comments_count": 0
}
  1. 单击验证 JSON以验证来自任何文本编辑器的代码:图 2.21:经过验证的 JSON 文档

图 2.21:经过验证的 JSON 文档

在这个活动中,您将数据从推文模型化为有效的 JSON 文档。

3. 服务器和客户端

活动 3.01:管理您的数据库用户

解决方案:

以下是该活动的详细步骤:

  1. 转到 http://cloud.mongodb.com 连接到 Atlas 控制台。

  2. 使用您的用户名和密码登录到新的 MongoDB Atlas 网络界面,该用户名和密码是在注册 Atlas Cloud 时创建的:图 3.40:MongoDB Atlas 登录页面

图 3.40:MongoDB Atlas 登录页面

  1. 创建一个名为dev_mflix的新数据库,在 Atlas 集群页面上,点击COLLECTIONS按钮:图 3.41:MongoDB Atlas 集群页面

图 3.41:MongoDB Atlas 集群页面

一个包含所有集合的窗口将出现,如图 3.42所示:

图 3.42:MongoDB Atlas 数据浏览器

图 3.42:MongoDB Atlas 数据浏览器

  1. 接下来,点击数据库列表顶部的+Create Database按钮。将出现以下窗口:图 3.43:MongoDB 创建数据库窗口

图 3.43:MongoDB 创建数据库窗口

  1. DATABASE NAME设置为dev_mflix,将COLLECTION NAME设置为dev_data01,然后点击CREATE按钮。

  2. 创建一个名为Developers的自定义角色。点击左侧的Database Access。在Database Access页面上,点击Custom Role选项卡。

  3. 点击Add Custom Role按钮。Add Custom Role窗口将出现,如下截图所示:图 3.44:Add Custom Role 窗口

图 3.44:Add Custom Role 窗口

  1. 在新的Developers角色中,在dev_mflix数据库上添加readWrite角色。然后,在sample_mflix数据库上添加read角色,然后点击Add Custom Role按钮。新的Developers角色将出现在列表中:图 3.45:数据库访问-自定义角色

图 3.45:数据库访问-自定义角色

  1. 创建新的 Atlas 用户Mark。在Database Access菜单中,点击+Add New Database User按钮。Add New Database User窗口将如下图所示出现:图 3.46:添加名为 Mark 的新用户

图 3.46:添加名为 Mark 的新用户

  1. 填写如下细节:

用户名:Mark

认证方法:SCRAM

预定义的自定义角色:Developers

现在,在 Atlas 用户列表中应该出现一个名为Mark的新用户:

图 3.47:Atlas 数据库用户

图 3.47:Atlas 数据库用户

  1. Mark用户身份连接到 MongoDB 云数据库,并运行db.getUser() shell 函数。预期的 shell 输出如下截图所示:图 3.48:Shell 输出(示例)

图 3.48:Shell 输出(示例)

这就结束了这个活动。一个名为 Mark 的新开发人员已经被添加到 Atlas 系统,并且已经被授予适当的访问权限。

4. 查询文档

活动 4.01:按类型查找电影并分页显示结果

解决方案:

findMoviesByGenre函数最重要的部分是底层的 MongoDB 查询。您将采用逐步方法来解决问题,首先在 mongo shell 上创建查询。一旦查询准备好,您将把它封装到一个函数中:

  1. 创建一个按genre过滤结果的查询。在这个活动中,我们使用Action类型的genre
      db.movies.find(
          {"genres" : "Action"}
      )
  1. 要求仅返回电影的标题。为此,添加一个投影,仅投影title字段并排除其余部分,包括_id
      db.movies.find(
          {"genres" : "Action"},
          {"_id" : 0, "title" :1}
      )
  1. 现在,按照 IMDb 评分的降序对结果进行排序。在查询中添加一个sort()函数:
      db.movies.find(
          {"genres" : "Action"},
          {"_id" : 0, "title" :1})
      .sort({"imdb.rating" : -1})
  1. 添加skip函数,现在提供任何您想要的值(在本例中为3):
      db.movies.find(
          {"genres" : "Action"}, 
          {"_id" : 0, "title" :1})
     .sort({"imdb.rating" : -1})
     .skip(3)
  1. 接下来,在查询中添加一个limit,如下所示。限制值表示页面大小:
      db.movies.find(
          {"genres" : "Action"}, 
          {"_id" : 0, "title" :1})
     .sort({"imdb.rating" : -1})
     .skip(3)
     .limit(5)
  1. 最后,通过使用toArray()函数将结果游标转换为数组:
     db.movies.find(
         {"genres" : "Action"}, 
         {"_id" : 0, "title" :1})
     .sort({"imdb.rating" : -1})
     .skip(3)
     .limit(5)
     .toArray()
  1. 现在查询已经编写完成,打开文本编辑器并编写一个空函数,接受genre、页码和页大小,如下所示:
      var findMoviesByGenre = function(genre, pageNumber, pageSize){
      }
  1. 复制并粘贴查询到函数中,并将其赋值给一个变量,如下所示:
      var findMoviesByGenre = function(genre, pageNumber, pageSize){
          var movies = db.movies.find(
              {"genres" : "Action"}, 
              {"_id" : 0, "title" :1})
          .sort({"imdb.rating" : -1})
          .skip(3)
          .limit(5)
          .toArray()
      }
  1. 您将得到的结果是一个数组。编写需要迭代元素并打印title字段的逻辑,如下所示:
      var findMoviesByGenre = function(genre, pageNumber, pageSize){
          var movies = db.movies.find(
              {"genres" : "Action"}, 
              {"_id" : 0, "title" :1})
          .sort({"imdb.rating" : -1})
          .skip(3)
          .limit(5)
          .toArray()
          print("************* Page : " + pageNumber)
          for(var i =0; i < movies.length; i++){
              print(movies[i].title)
          }
      }
  1. 查询仍然具有硬编码的值,需要用作函数参数接收的变量替换这些值,因此将genrepageSize变量放在正确的位置:
      var findMoviesByGenre = function(genre, pageNumber, pageSize){

          var movies = db.movies.find(
              {"genres" : genre}, 
              {"_id" : 0, "title" :1})
          .sort({"imdb.rating" : -1})
          .skip(3)
          .limit(pageSize)
          .toArray()
          print("************* Page : " + pageNumber)
          for(var i =0; i < movies.length; i++){
              print(movies[i].title)
          }
      }
  1. 现在,您需要根据页码和页面大小来确定跳过值。当用户在第一页时,跳过值应为零。在第二页时,跳过值应为页面大小。同样,如果用户在第三页,跳过值应为页面大小乘以 2。将此逻辑写成如下:
      var findMoviesByGenre = function(genre, pageNumber, pageSize){
          var toSkip = 0;
          if(pageNumber < 2){
              toSkip = 0;
          } else{
              toSkip = (pageNumber -1) * pageSize;
          }
          var movies = db.movies.find(
              {"genres" : genre}, 
              {"_id" : 0, "title" :1})
          .sort({"imdb.rating" : -1})
          .skip(toSkip)
          .limit(pageSize)
          .toArray()
          print("************* Page : " + pageNumber)
          for(var i =0; i < movies.length; i++){
              print(movies[i].title)
          }
}

现在,在 limit 函数中使用新计算的 skip 值。这使函数完成。

  1. 复制并粘贴该函数到 mongo shell 中并执行。您应该看到以下结果:

图 4.46:最终输出

图 4.46:最终输出

通过使用sort()skip()limit()函数,在此活动中,您为电影服务实现了分页,大大改善了用户体验。

5. 插入、更新和删除文档

活动 5.01:更新电影评论

解决方案:

执行以下步骤完成活动:

  1. 首先,更新所有三条评论中的movie_id字段。由于我们需要对所有三条评论应用相同的更新,因此我们将使用findOneAndUpdate()函数以及$set运算符来更改字段的值:
db.comments.updateMany(
  {
    "_id" : {$in : [
      ObjectId("5a9427658b0beebeb6975eb3"),
      ObjectId("5a9427658b0beebeb6975eb4"),
      ObjectId("5a9427658b0beebeb6975eaa")
    ]}
  },
  {
    $set : {"movie_id" : ObjectId("573a13abf29313caabd25582")}
  }
)

使用更新命令,我们通过其_id找到了三部电影,使用$in运算符提供它们的主键。然后,我们使用$set来更新字段movie_id的值。

  1. 连接到 MongoDB Atlas 集群,使用数据库sample_mflix,然后执行上一步中的命令。输出应该如下:图 5.30:将正确的电影分配给评论

图 5.30:将正确的电影分配给评论

输出确认所有三条评论都已正确更新。

  1. 通过_id找到电影Sherlock Holmes,并将评论数量减少3
db.movies.findOneAndUpdate(
  {"_id" : ObjectId("573a13bcf29313caabd57db6")},
  {$inc : {"num_mflix_comments" : -3}},
  {
    "returnNewDocument" : true,
    "projection" : {"title" : 1, "num_mflix_comments" : 1}
  }
)

此更新命令通过_id找到电影并使用负数的$inc来减少num_mflix_comments计数 3。它返回包含titlenum_mflix_comments字段的修改后的文档。

  1. 在同一个 mongo shell 上执行以下命令:图 5.31:增加 Sherlock Holmes 的评论计数

图 5.31:增加 Sherlock Holmes 的评论计数

输出显示评论数量正确减少了3

  1. 最后,在50 First Dates上准备一个类似的命令,并将评论数量增加3。应使用以下命令:
db.movies.findOneAndUpdate(
  {"_id" : ObjectId("573a13abf29313caabd25582")},
  {$inc : {"num_mflix_comments" : 3}},
  {
    "returnNewDocument" : true,
    "projection" : {"title" : 1, "num_mflix_comments" : 1}
  }
)

在此更新操作中,我们通过_id找到电影,并使用$inc以正值 3 增加评论数量。它还返回更新后的文档,并仅返回titlenum_mflix_comments字段。

  1. 现在,在 mongo shell 上执行以下命令:图 5.32:减少 50 First Dates 的评论计数

图 5.32:减少 50 First Dates 的评论计数

输出显示评论数量已经正确增加。在此活动中,我们练习了修改不同集合的字段,并在更新操作期间增加和减少数值字段的值。

6. 使用聚合管道和数组进行更新

活动 6.01:将演员姓名添加到演员表中

解决方案:

执行以下步骤完成活动:

  1. 由于只有一个电影文档需要更新,因此使用findOneAndUpdate()命令。打开文本编辑器并输入以下命令:
      db.movies.findOneAndUpdate({"title" : "Jurassic World"})

此查询使用基于电影标题的查询表达式。

  1. 准备一个更新表达式来将元素插入数组中。由于演员表必须是唯一的,因此使用$addToSet,如下所示:
    db.movies.findOneAndUpdate(
        {"title" : "Jurassic World"},
        {$addToSet : {"cast" : "Nick Robinson"}}
    )

此查询将Nick Robinson插入cast,并确保不插入重复项。

  1. 接下来,您需要对数组进行排序。由于集合是无序的,您不能在$addToSet表达式中使用$sort。相反,首先将元素添加到集合,然后对其进行排序。打开 mongo shell 并连接到sample_mflix数据库:
    db.movies.findOneAndUpdate(
        {"title" : "Jurassic World"},
        {$addToSet : {"cast" : "Nick Robinson"}},
        {
            "returnNewDocument" : true,
            "projection" : {"_id" : 0, "title" : 1, "cast" : 1}
        }
    )

在此命令中,returnNewDocument标志已设置为true,并且只投影了titlecast字段。在sample_mflix数据库中执行查询:

图 6.23:添加缺失的演员名字

图 6.23:添加缺失的演员名字

截图确认元素Nick Robinson已正确添加到数组的末尾。

  1. 打开文本编辑器,编写基本的更新命令,以及相同的查询表达式:
db.movies.findOneAndUpdate(
    {"title" : "Jurassic World"}
)
  1. 修改命令,向数组添加$push表达式,并提供$sort选项:
    db.movies.findOneAndUpdate(
        {"title" : "Jurassic World"},
        {$push : {
            "cast" : {
                $each : [],
                $sort : 1
            }}
        }
    )

由于不需要推送新元素,因此已将空数组传递给$each运算符。

  1. 添加returnNewDocument标志,将投影添加到titlecast字段,并执行命令,如下所示:
    db.movies.findOneAndUpdate(
        {"title" : "Jurassic World"},
        {$push : {
            "cast" : {
                $each : [],
                $sort : 1
            }}
        },
        {
            "returnNewDocument" : true,
            "projection" : {"_id" : 0, "title" : 1, "cast" : 1}
        }
    )
  1. 打开 mongo shell,连接到sample_mflix数据库,并执行命令:图 6.24:对缺失的演员进行排序

图 6.24:对缺失的演员进行排序

输出确认cast数组现在按元素的升序字母顺序排序。

7. 数据聚合

活动 7.01:将聚合实践

解决方案:

执行以下步骤完成活动:

  1. 首先,创建脚手架代码:
// Chapter_7_Activity.js
var chapter7Activity = function() {
    var pipeline = [];
    db.movies.aggregate(pipeline).forEach(printjson);
}
Chapter7Activity()
  1. 添加对 2001 年之前文档的第一个匹配项:
var pipeline = [
    {$match: {
        released: {$lte: new ISODate("2001-01-01T00:00:00Z")}
    }}
  ];
  1. 为至少获得一项奖项的电影添加第二个匹配条件:
    {$match: {
        released: {$lte: new ISODate("2001-01-01T00:00:00Z")},
        "awards.wins": {$gte: 1},
    }}
  1. 为奖项提名添加一个sort条件。这是为了确保我们$group语句中的$first运算符为每个类型获取了最高提名的电影:
        {$sort: {
            "awards.nominations": -1
        }},
  1. 添加$group阶段。根据第一个类型创建组,并输出每个组中的$first电影,以及该类型的奖项获奖总数:
        { $group: {
            _id: {"$arrayElemAt": ["$genres", 0]},
            "film_id": {$first: "$_id"},
            "film_title": {$first: "$title"},
            "film_awards": {$first: "$awards"},
            "film_runtime": {$first: "$runtime"},
            "genre_award_wins": {$sum: "$awards.wins"},
          }},

comments集合上执行连接,以检索每个组中电影的评论。这将我们计算的film_id字段与movie_id评论字段进行连接。将这个新数组命名为comments

          { $lookup: {
            from: "comments",
            localField: "film_id",
            foreignField: "movie_id",
            as: "comments"
        }},
  1. 仅从新数组中投影第一个评论,以及您想要在最后输出的任何字段。使用$slice运算符仅返回comments数组中的第一个条目。还记得将预告片添加到电影的运行时间中:
        { $project: {
            film_id: 1,
            film_title: 1,
            film_awards: 1,
            film_runtime: { $add: [ "$film_runtime", 12]},
            genre_award_wins: 1,
              "comments": { $slice: ["$comments", 1]}
          }}, 
  1. 最后,按genre_award_wins排序,并限制为三个文档:
          { $sort: {
              "genre_award_wins": -1}},
          { $limit: 3}

您的最终管道现在应该是这样的:

var chapter7Activity = function() {
    var pipeline = [
        {$match: {
            released: {$lte: new ISODate("2001-01-01T00:00:00Z")},
            "awards.wins": {$gte: 1},
        }},
        {$sort: {
            "awards.nominations": -1}},
        { $group: {
            _id: {"$arrayElemAt": ["$genres", 0]},
            "film_id": {$first: "$_id"},
            "film_title": {$first: "$title"},
            "film_awards": {$first: "$awards"},
            "film_runtime": {$first: "$runtime"},
            "genre_award_wins": {$sum: "$awards.wins"},
          }},
          { $lookup: {
            from: "comments",
            localField: "film_id",
            foreignField: "movie_id",
            as: "comments"}},
        { $project: {
            film_id: 1,
            film_title: 1,
            film_awards: 1,
            film_runtime: { $add: [ "$film_runtime", 12]},
            genre_award_wins: 1,
              "comments": { $slice: ["$comments", 1]}
          }}, 
          { $sort: {
              "genre_award_wins": -1
          }},
          { $limit: 3}
    ];
    db.movies.aggregate(pipeline).forEach(printjson);
}
Chapter7Activity();

您的输出将如下所示:

图 7.24:运行管道后的最终输出(为简洁起见截断)

图 7.24:运行管道后的最终输出(为简洁起见截断)

在这个活动中,我们将聚合管道的不同方面组合在一起,以查询、转换和连接集合中的数据。通过结合本章学到的方法,您现在将能够自信地设计和编写高效的聚合管道,解决复杂的业务问题。

8. 在 MongoDB 中编写 JavaScript 代码

活动 8.01:创建一个简单的 Node.js 应用程序

解决方案:

执行以下步骤完成活动:

  1. 导入readline和 MongoDB 库:
const readline = require('readline');
const MongoClient = require('mongodb').MongoClient;
  1. 创建您的readline接口:
const interface = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
});
  1. 声明您需要的任何变量:
const url = 'mongodb+srv://mike:password@myAtlas-  fawxo.gcp.mongodb.net/test?retryWrites=true&w=majority';
const client = new MongoClient(url);
const databaseName = "sample_mflix";
const collectionName = "movies";
  1. 创建一个名为list的函数,该函数将获取给定类型的前五部电影,返回它们的titlefavouriteID字段。您需要在此函数中请求类别。查看第 7.05 练习中的 login 方法,了解更多信息。将此与我们之前练习中的 find 代码结合起来
const list = function(database, client) {
    interface.question("Please enter a category: ", (category) => {
        database.collection(collectionName).find({genres: { $all: [category]          }}).limit(5).project({title: 1, favourite:             1}).toArray(function(err, docs) {
            if(err) {
                console.log('Error in query.');
                console.log(err);
            }
            else if(docs) {
                console.log('Docs Array');
                console.log(docs);
            } else {
            }
            prompt(database, client);
            return;
         });
      });
}
  1. 创建一个名为favourite的函数,它将通过标题更新一个文档,并向文档添加一个名为favourite的键,值为true。在这个函数中,你需要使用与list函数相同的方法来询问标题。将这个与我们之前练习的更新代码结合起来:
const favourite = function(database, client) {
    interface.question("Please enter a movie title: ", (newTitle) => {
        database.collection(collectionName).updateOne({title: newTitle},           {$set: {favourite: true}}, function(err, result) {
            if(err) {
                console.log('Error updating');
                console.log(err);
                return false;
            }
            console.log('Updated documents #:');
            console.log(result.modifiedCount);
            prompt(database, client);
        })
    })
}
  1. 基于用户输入创建一个交互式的while循环。如果你不确定如何做到这一点,请参考Exercise 8.05中的prompt函数,在 Node.js 中处理输入
const prompt = function(database, client) {
    interface.question("list, favourite OR exit: ", (input) => {
        if(input === "exit") {
            client.close();
            return interface.close(); // Will kill the loop.
        }   
        else if(input === "list") {
            list(database, client);
        }
        else if(input === "favourite") {
            favourite(database, client);
        }
        else { // If input matches none of our options.
            prompt(database, client)
        }
      });
}
  1. 创建 MongoDB 连接和数据库,如果数据库创建成功,则调用你的prompt函数:
client.connect(function(err) {
    if(err) {
        console.log('Failed to connect.');
        console.log(err);
        return false;
    }
    // Within the connection block, add a console.log to confirm the       connection
    console.log('Connected to MongoDB with NodeJS!');
    const database = client.db(databaseName);
    if(!database) {
        console.log('Database object doesn't exist!');
        return false;
    } else {
        prompt(database, client);
    }
})

记住,你需要通过每个函数传递databaseclient对象,包括每次调用prompt函数时。

  1. 使用node Activity8.01.js运行你的代码。图 8.9:最终输出(为简洁起见进行了截断)

图 8.9:最终输出(为简洁起见进行了截断)

在这个活动中,你创建了一个带有交互式输入循环的应用程序,并实现了错误处理来处理用户输入的无效类型。

9. 性能

活动 9.01:优化查询

解决方案:

执行以下步骤完成活动:

  1. 打开你的 mongo shell 并连接到 Atlas 集群上的sample_supplies数据库。首先,你需要找出查询返回了多少条记录。以下片段显示了一个count查询,给出了在 Denver 店销售的背包数量:
     db.sales.count(
         {
             "items.name" : "backpack",
             "storeLocation" : "Denver"
         }
     )

  1. 查询返回了711条记录。

  2. 接下来,使用explain()函数分析分析团队给出的查询,并打印执行统计数据,如下所示:

db.sales.find(
         {
             "items.name" : "backpack",
             "storeLocation" : "Denver"
         },
         {
             "_id" : 0,
             "customer.email": 1,
             "customer.age": 1
         }
     ).sort({
         "customer.age" : -1
     }).explain("executionStats")

通过传递executionStats作为参数,查询调用了explain()函数。以下片段显示了输出的executionStats部分:

"executionStats" : {
    "executionSuccess" : true,
    "nReturned" : 711,
    "executionTimeMillis" : 10,
    "totalKeysExamined" : 0,
    "totalDocsExamined" : 5000,
    executionStages" : {
        "stage" : "PROJECTION_DEFAULT",
        "nReturned" : 711,
        "executionTimeMillisEstimate" : 1,
        "works" : 5715,
        "advanced" : 711,
        "needTime" : 5003,
        "needYield" : 0,
        "saveState" : 44,
        "restoreState" : 44,
        "isEOF" : 1,
        "transformBy" : {
            "_id" : 0,
            "customer.email" : 1,
            "customer.age" : 1
        },
        "inputStage" : {
            "stage" : "SORT",
            "nReturned" : 711,
            "executionTimeMillisEstimate" : 1,
            "works" : 5715,
            "advanced" : 711,
            "needTime" : 5003,
            "needYield" : 0,
            "saveState" : 44,
            "restoreState" : 44,
            "isEOF" : 1,
            "sortPattern" : {
                "customer.age" : -1
            },
            "memUsage" : 745392,
            "memLimit" : 33554432,
            "inputStage" : {
                "stage" : "SORT_KEY_GENERATOR",
                "nReturned" : 711,
                    "executionTimeMillisEstimate" : 1,
                    "works" : 5003,
                    "advanced" : 711,
                    "needTime" : 4291,
                    "needYield" : 0,
                    "saveState" : 44,
                    "restoreState" : 44,
                    "isEOF" : 1,
                    "inputStage" : {
                        "stage" : "COLLSCAN",
                        "filter" : {
                            "$and" : [
                                {
                                    "items.name" : {
                                            "$eq" : "backpack"
                                        }
                                },
                                {
                                        "storeLocation" : {
                                            "$eq" : "Denver"
                                        }
                                }
                            ]
                        },
                        "nReturned" : 711,
                        "executionTimeMillisEstimate" : 1,
                        "works" : 5002,
                        "advanced" : 711,
                        "needTime" : 4290,
                        "needYield" : 0,
                        "saveState" : 44,
                        "restoreState" : 44,
                        "isEOF" : 1,
                        "direction" : "forward",
                        "docsExamined" : 5000
                         }
            }
        }
    }
},

输出表明,为了返回711条记录,扫描了所有5000条记录。它还表明执行从COLLSCAN阶段开始,这意味着最初没有索引支持查询中的字段。

为了提高查询性能,可以在集合上创建一个索引。由于查询在筛选条件中使用了两个字段,因此在索引中使用这两个字段。然而,查询还有一个排序规范,根据执行统计,排序是在内存中执行的。为了避免在内存中扫描,将排序字段包含在索引中。

  1. 在集合上创建一个复合索引,并包括items.namestoreLocationcustomer.age字段。以下查询在sales集合上创建了一个复合索引:
db.sales.createIndex(
    {
        "items.name" : 1,
        "storeLocation" : 1,
        "customer.age" : -1
    }
)

输出表明索引已正确创建,如下所示:

{
    "createdCollectionAutomatically" : false,
         "numIndexesBefore" : 1,
         "numIndexesAfter" : 2,
         "ok" : 1,
         "$clusterTime" : {
             "clusterTime" : Timestamp(1603246555, 1),
        "signature" : {
            "hash" : BinData(0,"yLQFK4QAJ0ci0M0PzZTex+K73LU="),
            "keyId" : NumberLong("6827475821280624642")
        }
    },
         "operationTime" : Timestamp(1603246555, 1)
}

再次执行步骤 2中执行的explain()查询。以下片段显示了输出的executionStats部分:

     "executionStats" : {
          "executionSuccess" : true,
          "nReturned" : 711,
          "executionTimeMillis" : 2,
          "totalKeysExamined" : 711,
          "totalDocsExamined" : 711,
          "executionStages" : {
               "stage" : "PROJECTION_DEFAULT",
               "nReturned" : 711,
               "executionTimeMillisEstimate" : 0,
               "works" : 712,
               "advanced" : 711,
               "needTime" : 0,
               "needYield" : 0,
               "saveState" : 5,
               "restoreState" : 5,
               "isEOF" : 1,
               "transformBy" : {
                    "_id" : 0,
                    "customer.email" : 1,
                    "customer.age" : 1
               },
               "inputStage" : {
                    "stage" : "FETCH",
                    "nReturned" : 711,
                    "executionTimeMillisEstimate" : 0,
                    "works" : 712,
                    "advanced" : 711,
                    "needTime" : 0,
                    "needYield" : 0,
                    "saveState" : 5,
                    "restoreState" : 5,
                    "isEOF" : 1,
                    "docsExamined" : 711,
                    "alreadyHasObj" : 0,
                    "inputStage" : {
                         "stage" : "IXSCAN",
                         "nReturned" : 711,
                         "executionTimeMillisEstimate" : 0,
                         "works" : 712,
                         "advanced" : 711,
                         "needTime" : 0,
                         "needYield" : 0,
                         "saveState" : 5,
                         "restoreState" : 5,
                         "isEOF" : 1,
                         "keyPattern" : {
                              "items.name" : 1,
                              "storeLocation" : 1,
                              "customer.age" : -1
                         },
                         "indexName" : "items.name_1_storeLocation_1_customer.age_-1",
                         "isMultiKey" : true,
                         "multiKeyPaths" : {
                              "items.name" : [
                                   "items"
                              ],
                              "storeLocation" : [ ],
                              "customer.age" : [ ]
                         },
                         "isUnique" : false,
                         "isSparse" : false,
                         "isPartial" : false,
                         "indexVersion" : 2,
                         "direction" : "forward",
                         "indexBounds" : {
                              "items.name" : [
                                   "[\"backpack\", \"backpack\"]"
                              ],
                              "storeLocation" : [
                                   "[\"Denver\", \"Denver\"]"
                              ],
                              "customer.age" : [
                                   "[MaxKey, MinKey]"
                              ]
                         },
                         "keysExamined" : 711,
                         "seeks" : 1,
                         "dupsTested" : 711,
                         "dupsDropped" : 0
                    }
               }
          }
     }

从输出结果可以明显看出,执行的第一个阶段是IXSCAN,这意味着使用了正确的索引。还要注意到没有排序阶段。这意味着由于customer.age字段上的正确索引,不需要进一步排序。顶层执行统计数据显示只扫描了711条记录,并且返回了相同数量的记录。这证明了查询被正确优化了。

在这个活动中,你分析了查询的性能统计数据,识别了问题,并创建了正确的索引来解决性能问题。

10. 复制

活动 10.01:测试 MongoDB 数据库的灾难恢复过程

解决方案:

执行以下步骤完成活动:

  1. 创建以下目录:C:\sale\sale-prodC:\sale\sale-drC:\sale\sale-abC:\sale\log

注意

对于 Linux 和 macOS,目录名称将类似于/data/sales/sale-prod/data/sales/sale-dr…

  1. 启动集群节点如下:
start mongod --port 27001 --bind_ip_all --replSet sale-cluster --dbpath C:\sale\sale-prod --logpath C:\sale\log\sale-prod.log --logappend --oplogSize 50
start mongod --port 27002 --bind_ip_all --replSet sale-cluster --dbpath C:\sale\sale-dr --logpath C:\sale\log\sale-dr.log --logappend --oplogSize 50
start mongod --port 27003 --bind_ip_all --replSet sale-cluster --dbpath C:\sale\sale-ab --logpath C:\sale\log\sale-ab.log --logappend --oplogSize 50
  1. 连接 mongo shell:
 mongo mongodb://localhost:27001/?replicaSet=sale-cluster
  1. 创建并激活集群配置:
var cfg = { 
    _id : "sale-cluster",
    members : [
        { _id : 0, host : "localhost:27001"},
        { _id : 1, host : "localhost:27002"},
        { _id : 2, host : "localhost:27003", arbiterOnly:true},
        ] 
}
    rs.initiate(cfg)

注意

在成功的集群选举后,你应该能够在 shell 提示符上看到PRIMARY

  1. sample_mflix数据库中插入100个文档。在主服务器上使用以下脚本创建sales_data集合并插入100个文档:
use sample_mflix
db.createCollection("sales_data")
for (i=0; i<=100; i++) {
    db.new_sales_data.insert({_id:i, "value":Math.random()})
}
  1. 通过添加以下命令关闭主服务器:
use admin
db.shutdownServer()
  1. 通过添加以下命令检查主服务器是否为 DR 实例(首先断开连接,然后重新连接)
rs.isMaster().primary

结果应显示sales_dr

  1. 使用以下脚本在新的主实例(sales_dr)上插入额外的 10 个文档:
use sample_mflix
for (i=101; i<=110; i++) {
    db.new_sales_data.insert({_id:i, "value":Math.random()})
}
  1. 使用以下命令关闭 DR 数据库和仲裁者:
use admin
db.shutdownServer()
  1. 确保两者都关闭后,重新启动以前的主服务器:
start mongod --port 27001 --bind_ip_all --replSet sale-cluster --dbpath C:\sale\sale-prod --logpath C:\sale\log\sale-prod.log --logappend --oplogSize 50
  1. 按照以下步骤重新启动仲裁者:
start mongod --port 27003 --bind_ip_all --replSet sale-cluster --dbpath C:\sale\sale-ab --logpath C:\sale\log\sale-ab.log --logappend --oplogSize 50

连接到集群。您应该能够看到在sales_dr上插入的 10 个文档,并且db.new_sales_data.count()应该只重新运行 100 次。

  1. 5 分钟后,按以下方式重新启动 DR 数据库:
start mongod --port 27002 --bind_ip_all --replSet sale-cluster --dbpath C:\sale\sale-dr --logpath C:\sale\log\sale-dr.log --logappend --oplogSize 50
  1. 在重新启动后,验证sales_dr 日志文件中的步骤。在 DR 日志中,您应该能够看到如下消息:
ROLLBACK [rsBackgroundSync] transition to SECONDARY
2019-11-26T15:48:29.538+1000 I  REPL     [rsBackgroundSync] transition to SECONDARY from ROLLBACK
2019-11-26T15:48:29.538+1000 I  REPL     [rsBackgroundSync] Rollback successful.

11. MongoDB 中的备份和还原

活动 11.01:MongoDB 中的备份和还原

解决方案:

执行以下步骤以完成活动:

  1. mongoexport开始。删除--db选项,因为您在 URI 中提供了它。
mongoexport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/sample_mflix --collection=theaters --out="theaters.csv" --type=csv --sort='{theaterId: 1}'
  1. 将字段选项添加到mongoexport命令
mongoexport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/sample_mflix --fields=theaterId,location --collection=theaters --out="theaters.csv" --type=csv --sort='{theaterId: 1}'
  1. 将必要的 CSV 选项添加到导入命令,即typeignoreBlanksheaderline
mongoimport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/imports --type=CSV --headerline --ignoreBlanks --collection=theaters_import --file=theaters.csv 
  1. 修复dump命令的gzip选项。
mongodump --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/sample_mflix --out=./backups –gzip --nsExclude=theaters
  1. nsExclude更改为excludeCollection
mongodump --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/sample_mflix --out=./backups –gzip --excludeCollection=theaters
  1. mongorestore命令中,修复选项的名称:
mongorestore --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net --nsFrom="sample_mflix" --nsTo="backup_mflix_backup" --drop ./backups
  1. 同样在mongorestore中,添加gzip选项,因为您的转储文件是gzip格式:
mongorestore --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net --nsFrom="sample_mflix" --nsTo="backup_mflix_backup" --gzip --drop ./backups
  1. 最后,请确保您的命名空间使用通配符进行正确的名称迁移:
mongorestore --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net --nsFrom="sample_mflix.*" --nsTo="backup_mflix_backup.*" --gzip --drop ./backups
  1. 最终的mongoexport命令应如下所示:
mongoexport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/sample_mflix --fields=theaterId,location --collection=theaters --out="theaters.csv" --type=csv --sort='{theaterId: 1}'
  1. 最终的mongoimport命令应如下所示:
mongoimport --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/imports --type=CSV –headerline –ignoreBlanks --collection=theaters_import --file=theaters.csv 
  1. 最终的mongodump命令应如下所示:
mongodump --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net/sample_mflix --out=./backups –gzip --excludeCollection=theaters
  1. 最终的mongorestore命令应如下所示:
mongorestore --uri=mongodb+srv://USERNAME:PASSWORD@myAtlas-fawxo.gcp.mongodb.net --nsFrom="sample_mflix.*" --nsTo="backup_mflix_backup.*" --gzip --drop ./backups

注意

重要的是要注意,因为mongoimportmongorestore都将在数据库中创建新文档,所以您必须使用具有写访问权限的凭据执行这些命令。

12.数据可视化

活动 12.01:创建销售演示仪表板

解决方案:

执行以下步骤以完成活动:

  1. 在您可以开始为这个新演示构建图表之前,您必须在应用程序中定义适当的数据来源。按照练习 12.01使用数据来源中的步骤,在sample_supplies数据库的销售集合上创建一个新的销售数据来源,如下图所示:图 12.52:创建新的销售数据来源

图 12.52:创建新的销售数据来源

  1. 单击“完成”以保存。新的数据来源将显示在列表中,如下图所示:图 12.53:销售数据来源

图 12.53:销售数据来源

  1. 从仪表板中,单击“添加图表”按钮,如下截图所示:图 12.54:在用户仪表板上单击“添加图表”

图 12.54:在用户仪表板上单击“添加图表”

在“图表生成器”中,选择在步骤 2中创建的销售数据来源(即sample_supplies.sales),然后选择Circular图表类型和Donut图表子类型,如下截图所示:

图 12.55:选择圆形图表类型和甜甜圈图表子类型

图 12.55:选择圆形图表类型和甜甜圈图表子类型

  1. 展开items数组。这一步很重要,因为销售数据以数组格式存在于 JSON 数据库中。因此,unwind函数将为数组中的每个项目创建一个虚拟文档。为此,请将以下 JSON 代码添加到“查询”栏:
[{$unwind:"$items"}]

然后单击“应用”按钮,如下截图所示:

图 12.56:在查询栏中编写展开函数

图 12.56:在查询栏中编写展开函数

  1. 下一步是添加一个新的计算字段,即items.value。要做到这一点,点击“+添加字段”按钮,并将新字段添加为items.value = items.price * items.quantity,如下所示:图 12.57:添加 items.value 字段

图 12.57:添加 items.value 字段

  1. 添加一个过滤器,以便仅考虑来自“丹佛”的商店的商品。从“过滤器”选项卡中,通过仅勾选“丹佛”位置复选框来定义商店位置的新过滤器:图 12.58:仅从位置列表中选择丹佛

图 12.58:仅从位置列表中选择丹佛

  1. 在“编码”选项卡中添加通道。如下图所示,将字段items.name拖入“标签”通道。从“排序方式”下拉菜单中选择VALUE,并将结果限制为10个。这将把我们的圆环图分成 10 个部分。类似地,将items.value(新计算字段)拖入“弧”通道,并从“聚合”下拉菜单中选择SUM函数:图 12.59:将 items.value 拖入弧道并选择 SUM 函数

图 12.59:将 items.value 拖入弧道并选择 SUM 函数

  1. 图表应该出现在屏幕的右侧,如下所示:图 12.60:最终图表

图 12.60:最终图表

  1. 将图表名称编辑为“丹佛销售(百万美元)”,如下所示:图 12.61:编辑图表标题

图 12.61:编辑图表标题

  1. 编辑图表标签。从“自定义”选项卡,点击启用“数据值标签”,如下所示:图 12.62:自定义数据标签

图 12.62:自定义数据标签

  1. 接下来,从“数字格式”下拉菜单中选择“自定义”,最多保留2位小数,如下所示:图 12.63:自定义图表格式

图 12.63:自定义图表格式

  1. 图表将显示正确的标题和标签格式,如下图所示:图 12.64:最终丹佛销售图表

图 12.64:最终丹佛销售图表

结果相当不言自明。如预期的那样,笔记本电脑的销售价值接近 200 万美元,居销售榜首,并且是销售报告中价值最高的商品。销售额排名第二的商品是背包,价值仅 25 万美元。

活动现在已经完成。在仅 10 个简单的步骤中,您已经能够为科罗拉多州丹佛市商店的商品创建一份销售报告。您的图表构建现在已经完成,可以保存在您的仪表板上。在这里学到的经验可以被学生和专业人士应用,以使用真实数据进行演示。

posted @ 2024-05-21 12:36  绝不原创的飞龙  阅读(143)  评论(0)    收藏  举报