GraphQL-全栈应用-全-
GraphQL 全栈应用(全)
原文:Full Stack GraphQL Applications
译者:飞龙
前置内容
前言
感谢您阅读《全栈 GraphQL 应用》。本书的目标是展示如何将 GraphQL、React、Apollo 和 Neo4j 数据库(所谓的 GRANDstack)结合使用,以构建复杂、数据密集型的全栈应用。您可能想知道我们为什么选择了这种特定的技术组合。随着您阅读本书,我希望您能意识到在整个堆栈中使用图数据模型(从数据库到 API,再到前端客户端的数据获取代码)带来的开发者生产力、性能和直观优势。
这是我作为一家小型初创公司的第一位工程人员时希望存在的书籍,当时我被分配构建我们的全栈 Web 应用程序。我们花费了几个月的时间评估我们的技术堆栈,并探索它们如何结合在一起。最终,我们找到了解决方案,并使用我们满意的技术组合投入了生产,但到达那里需要许多迭代。
在过去几年中,GraphQL 这一技术从根本上改变了开发者对 Web 开发的看法。本书专注于 GraphQL;然而,仅仅了解如何构建 GraphQL 服务器和编写 GraphQL 操作是不够的,要将全栈应用投入生产。我们需要考虑如何在我们的前端应用程序中启用 GraphQL 数据获取和状态管理,如何保护我们的 API,如何部署我们的应用程序,以及许多其他考虑因素。这就是为什么本书不仅仅关于 GraphQL;相反,我们通过展示各个部分如何结合在一起,全面探索使用 GraphQL。如果您负责使用 GraphQL 构建全栈应用,那么这本书就是为您准备的!
致谢
撰写一本书是一个漫长的过程,需要许多人的帮助和支持。如果不遗漏任何人的话,不可能感谢所有帮助本书成书的个人。当然,如果没有参与创建我们所涵盖的令人惊叹的技术的人员,这本书是不可能的。
感谢迈克尔·斯蒂普斯提出撰写关于 GraphQL 的书籍的想法,并帮助构思全栈 GraphQL 的概念,感谢凯伦·米勒对每一章早期版本的宝贵反馈,以及所有参与 Manning 出版社的同事们:道格、亚历山大、安迪、克里斯蒂安、梅洛迪、尼克、戈兰和玛丽亚。感谢我的家人在我撰写本书期间的理解和支持。特别感谢图数据库社区在验证本书中的理念以及为 Neo4j GraphQL 库提供优秀反馈和贡献。
致所有审稿人:Andres Sacco、Brandon Friar、Christopher Haupt、Damian Esteban、Danilo Zekovic、Deniz Vehbi、Ferit Topcu、Frans Oilinki、Gustavo Gomes、Harsh Raval、Ivo Sánchez Checa Crosato、José Antonio Hernández Orozco、José San Leandro、Kevin Ready、Konstantinos Leimonis、Krzysztof Kamyczek、Michele Adduci、Miguel Isidoro、Richard Meinsen、Richard Vaughan、Rob Lacey、Ronald Borman、Ryan Huber、Satej Kumar Sahu、Simeon Leyzerzon、Stefan Turalski、Tanya Wilke、Theofanis Despoudis 和 Vladimir Pasman,你们的建议帮助使这本书变得更好。
关于本书
《全栈 GraphQL 应用》的目标是展示全栈 GraphQL 应用程序的各个部分是如何结合在一起的,以及全栈开发者如何利用在线服务来实现开发和部署。这是通过介绍概念并在构建和部署全栈业务审查应用程序的过程中逐步构建和部署来实现的。
适合阅读本书的人群?
这本书旨在为对 GraphQL 感兴趣的全栈 Web 开发者而写,他们至少对 Node.js API 应用程序和连接到这些 API 的客户端 JavaScript 应用程序有基本的理解。成功的读者将对 Node.js 有一些基本了解,并对客户端 JavaScript 有基本理解,但最重要的是,他们将有一个理解如何利用 GraphQL 构建 GraphQL 服务和应用的动机。
本书组织结构:路线图
本书由九章组成,分为三个部分。每个章节都在构建全栈业务审查应用程序的背景下介绍新的概念和技术。
在第一部分,我们介绍了 GraphQL、Neo4j 图数据库以及图思维的概念:
-
第一章讨论了全栈 GraphQL 应用程序的组成部分,包括对本书中我们将使用到的每种特定技术(GraphQL、React、Apollo 和 Neo4j 数据库)的介绍。
-
第二章介绍了 GraphQL 和构建 GraphQL API 的基础知识(类型定义和解析器函数)。
-
第三章介绍了 Neo4j 图数据库、属性图数据模型和 Cypher 查询语言。
-
第四章展示了如何使用 Neo4j GraphQL 库将 GraphQL 的力量带到 Neo4j 图数据库中。
在第二部分,我们专注于使用 React 开发我们的客户端应用程序:
-
第五章介绍了 React 框架和我们在构建前端应用程序时需要了解的重要概念。
-
第六章展示了如何使用 React 和 GraphQL 启用数据获取和客户端状态管理,正如我们在前几章中构建的 GraphQL API 一样。
在第三部分,我们探索了如何使用云服务来保护我们的应用程序并部署它:
-
第七章展示了我们可以如何使用 GraphQL 和 Auth0 来保护我们的应用程序。
-
第八章介绍了我们将用于部署数据库、GraphQL API 和 React 应用程序的云服务。
-
第九章通过探讨如何在 GraphQL 中利用抽象类型、基于游标的分页以及处理 GraphQL 中的关系属性来结束本书。
本书旨在从头到尾阅读,因为每一章都是基于前一章的工作,所有这些工作都是为了构建一个完整的全栈业务审查应用程序。读者可以选择专注于单个章节,深入探讨特定感兴趣的主题,但务必参考前几章,以了解其他部分的应用程序是如何和为什么被构建的。
关于代码
本书包含许多源代码示例,既有编号列表,也有与普通文本混排。在两种情况下,源代码都以固定宽度字体格式化,如这样,以将其与普通文本区分开来。有时代码也会被加粗,以突出显示章节中从先前步骤更改的代码,例如当新功能添加到现有代码行时。
在许多情况下,原始源代码已被重新格式化;我们添加了换行并重新调整了缩进,以适应书籍中的可用页面空间。在极少数情况下,即使这样也不够,列表中还包括了行续符(➥)。此外,当代码在文本中描述时,源代码中的注释通常也会从列表中删除。许多列表都有代码注释,突出显示重要概念。
您可以从本书的 liveBook(在线)版本中获取可执行的代码片段 livebook.manning.com/book/fullstack-graphql-applications。书中示例的完整代码可以从 Manning 网站 www.manning.com 和 GitHub github.com/johnymontana/fullstack-graphql-book 下载。
软件硬件要求
读者需要安装 Node.js 的最新版本。我使用了最新版本 v16,因此建议使用 nvm 工具来安装和管理 Node.js 版本。nvm 的安装和使用说明可以在 github.com/nvm-sh/nvm 找到。
我们还将使用几个(免费)在线服务进行部署。大多数这些服务都可以使用 GitHub 账户访问,因此请确保创建一个 GitHub 账户,如果您目前还没有的话,可以在 github.com/ 创建。
liveBook 讨论论坛
购买《全栈 GraphQL 应用》包括对 Manning 的在线阅读平台 liveBook 的免费访问。使用 liveBook 的独家讨论功能,您可以在全球范围内或针对特定部分或段落附加评论。为自己做笔记、提出和回答技术问题,以及从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问livebook.manning.com/book/fullstack-graphql-applications/discussion。您还可以在livebook.manning.com/discussion了解更多关于 Manning 的论坛和行为准则。
Manning 对我们读者的承诺是提供一个场所,在那里个人读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量承诺的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议您尝试向作者提出一些挑战性的问题,以免他的兴趣转移!只要书籍在印刷中,论坛和先前讨论的存档将从出版社的网站提供访问。
其他在线资源
请务必查阅 Neo4j GraphQL 库的文档,链接为neo4j.com/docs/graphql-manual/current/。其他可能有所帮助的在线资源包括 GraphAcademy 提供的免费在线课程(graphacademy.neo4j.com/)和 Neo4j 社区网站(community.neo4j.com/)。
关于作者

William Lyon 是 Neo4j 的员工开发者倡导者,在那里他帮助开发者成功构建使用图形的应用程序。加入 Neo4j 之前,他在初创公司担任软件工程师,从事定量金融系统、房地产行业的移动应用程序和预测 API 服务。他拥有蒙大拿大学计算机科学硕士学位,并在lyonwj.com上发布博客。
关于封面插图
《全栈 GraphQL 应用》的封面图题为“Tinne 岛的女神”,或称“Tinne 岛的女郎”,取自 Jacques Grasset de Saint-Sauveur 的收藏,该收藏于 1797 年出版。每一幅插图都是手工精心绘制和着色的。
在那些日子里,人们通过他们的服饰很容易就能识别出他们住在哪里,以及他们的职业或社会地位。Manning 通过基于几个世纪前丰富多样的地区文化的封面设计,以及此类收藏中的图片,来庆祝计算机行业的创新精神和主动性。
第一部分:全栈 GraphQL 入门
在开始我们的全栈 GraphQL 之旅之前,我们将查看我们将使用的技术,并介绍强大的图形化思维概念。本书的这一部分专注于我们全栈应用程序的后端,特别是数据库和 GraphQL API。
在第一章中,我们介绍了全栈 GraphQL 应用程序的组件,并查看本书中将使用的具体技术:GraphQL、React、Apollo 和 Neo4j 数据库。在第二章中,我们深入探讨 GraphQL 和构建 GraphQL API 应用程序的基础知识。在第三章中,我们探索 Neo4j 图数据库、属性图数据模型和 Cypher 查询语言。然后,在第四章中,我们展示如何利用数据库集成来支持 GraphQL,特别是使用 Neo4j GraphQL 库来构建由图数据库支持的 GraphQL API。完成本书的这一部分后,我们将拥有运行中的数据库和初始 GraphQL API 应用程序,并准备好在第二部分开始构建前端。
1 什么是全栈 GraphQL?
本章涵盖
-
构成典型全栈 GraphQL 应用程序的组件
-
本书使用的技术(GraphQL、React、Apollo 和 Neo4j 数据库)以及每个组件在全栈应用程序上下文中的结合方式
-
我们将在本书中构建的应用程序的要求
1.1 全栈 GraphQL 概述
在本章中,我们将简要介绍本书中将要使用的技术。具体来说,我们将查看以下内容:
-
GraphQL—用于构建我们的 API
-
React—用于构建我们的用户界面和 JavaScript 客户端 Web 应用程序
-
Apollo—用于在服务器和客户端处理 GraphQL 的工具
-
Neo4j 数据库—我们将用于存储和操作应用程序数据的数据库
构建全栈 GraphQL 应用程序涉及与多层架构(通常称为三层应用)一起工作,该架构包括前端应用程序、API 层和数据库。在图 1.1 中,我们可以看到全栈 GraphQL 应用程序的各个组件以及它们如何相互交互。

图 1.1 全栈 GraphQL 应用程序的组件:GraphQL、React、Apollo 和 Neo4j 数据库
在本书的整个过程中,我们将使用这些技术构建一个简单的商业评论应用程序,在实现应用程序的上下文中逐一处理每个技术组件。在本章的最后部分,我们将回顾本书中将要构建的应用程序的基本要求。
本书的核心是学习如何使用 GraphQL 构建应用程序,因此在我们介绍 GraphQL 时,我们将将其置于构建全栈应用程序的背景下,并使用其他技术,包括设计我们的模式、与数据库集成、构建可以查询我们的 GraphQL API 的 Web 应用程序、为我们的应用程序添加身份验证等。因此,本书假设读者对如何构建 Web 应用程序有一些基本知识,但并不一定需要每个特定技术的经验。为了成功,读者应该对 JavaScript 有基本的了解,包括客户端和 Node.js,以及诸如 API 和数据库等概念。你应该已经安装了 node,并且应该熟悉 npm 命令行工具(或 yarn)的基本用法以及如何使用它来创建 Node.js 项目和安装依赖项。我们将使用本书撰写时的最新 LTS 版本 Node.js(16.14.2),该版本可在nodejs.org/下载。你可能希望使用 Node.js 版本管理器,如 nvm 来管理 Node 版本。有关更多信息,请参阅github.com/nvm-sh/nvm。
我们对每种技术进行了简要介绍,并在需要时建议读者查阅其他资源以进行更深入的覆盖。同样重要的是要注意,我们将涵盖与 GraphQL 一起使用的特定技术,并且在每个阶段,可以替换为类似的技术(例如,我们可以使用 Vue 而不是 React 来构建我们的前端)。最终,本书的目标是展示这些技术如何相互配合,并为读者提供一个全栈框架来思考和构建使用 GraphQL 的应用程序。
1.2 GraphQL
在其核心,GraphQL 是构建 API 的规范。GraphQL 规范描述了一种 API 查询语言和满足这些请求的方式。当构建 GraphQL API 时,我们使用严格的类型系统描述可用的数据。这些类型定义成为 API 的规范,客户端可以基于这些类型定义请求所需的数据,这些类型定义也定义了 API 的入口点。
GraphQL 通常被视为 REST 的替代品,这是你最可能熟悉的 API 范式。在某些情况下这可能成立;然而,GraphQL 也可以包装现有的 REST API 或其他数据源。这是由于 GraphQL 的数据层无关性带来的好处,这意味着我们可以使用 GraphQL 与任何数据源一起使用。
GraphQL 是一种 API 查询语言,也是满足这些查询的运行时。GraphQL 提供了 API 中数据的完整和可理解的描述,使客户端能够请求他们确切需要的东西,而无需更多,这使得随着时间的推移更容易演变 API,并使强大的开发者工具成为可能。
让我们深入了解 GraphQL 的更多具体方面。
1.2.1 GraphQL 类型定义
与围绕映射到资源的端点组织(如 REST)不同,GraphQL API 是围绕类型定义的中心,这些定义了数据类型、字段以及它们在 API 中的连接方式。这些类型定义成为 API 的模式,并从单个端点提供。
由于 GraphQL 服务可以用任何语言实现,因此使用一种与语言无关的 GraphQL 模式定义语言(SDL)来定义 GraphQL 类型。让我们看看图 1.2 中的示例,这个示例是由考虑一个简单的电影应用程序而激发的。想象一下,你被雇佣来创建一个网站,允许用户搜索电影目录以获取电影详情,例如标题、演员和描述,以及显示用户可能感兴趣的类似电影推荐。

图 1.2 一个简单的电影网络应用程序
让我们从下一个列表开始,创建一些简单的 GraphQL 类型定义,这些定义将定义我们应用程序的数据域。
列表 1.1 为电影 GraphQL API 定义的简单 GraphQL 类型定义
type Movie { ❶
movieId: ID!
title: String ❷
actors: [Actor] ❸
}
type Actor {
actorId: ID! ❹
name: String
movies: [Movie]
}
type Query { ❺
allActors: [Actor]
allMovies: [Movie]
movieSearch(searchString: String!): [Movie] ❻
moviesByTitle(title: String!): [Movie]
}
❶ 电影是一个 GraphQL 对象类型,这意味着包含一个或多个字段的类型。
❷ 标题是 Movie 类型上的一个字段。
❸ 字段可以引用其他类型,例如在这种情况下 Actor 对象的列表。
❹ actorId 是 Actor 类型上的一个必需(或非可空)字段,这由 ! 字符表示。
❺ 查询类型是 GraphQL 中的一个特殊类型,它表示 API 的入口点。
❻ 字段也可以有参数;在这种情况下,movieSearch 字段需要一个必需的字符串参数:searchString。
我们的 GraphQL 类型定义声明了 API 中使用的类型、它们的字段以及它们是如何连接的。当定义一个对象类型(如 Movie)时,对象上所有可用的字段以及每个字段的类型也会被指定(我们也可以稍后添加字段,使用 extend 关键字)。在这种情况下,我们定义标题为一个标量 String 类型——一个解析为单个值的类型,与可以包含多个字段和其他类型引用的对象类型相对。这里 actors 是一个在 Movie 类型上的字段,它解析为一个 Actor 对象数组,表示演员和电影是连接的(GraphQL 中“图形”的基础)。
字段可以是可选的或必需的。Actor 对象类型上的 actorId 字段是必需的(或非可空的)。这意味着每个 Actor 对象都必须有一个 actorId 的值。不包括 ! 的字段是可空的,这意味着这些字段的值是可选的。
查询类型的字段成为查询 GraphQL 服务的入口点。GraphQL 模式还可以包含一个突变类型,它定义了写入 API 的入口点。与入口点相关的第三个特殊类型是订阅类型,它定义了客户端可以订阅的事件。
注意:在这里我们跳过了许多重要的 GraphQL 概念,例如突变操作、接口和联合类型等,但请放心;我们只是刚开始,很快就会涉及到这些内容!
到目前为止,你可能想知道 GraphQL 中的“图形”在哪里。实际上,我们已经使用我们的 GraphQL 类型定义定义了一个图形。图形是由节点(我们数据模型中的实体或对象)和连接节点的边组成的数据结构,这正是我们在类型定义中使用 SDL 定义的。之前显示的 GraphQL 类型定义已经定义了一个具有以下结构的简单图形(见图 1.3)。

图 1.3. 以图形图表示的我们的电影网络应用程序的 GraphQL 类型定义
图形都是关于描述连接数据的,在这里我们定义了我们的电影和演员如何在图形中连接。GraphQL 允许我们将应用程序数据建模为图形,并通过 GraphQL 操作遍历数据图形。
当 GraphQL 服务接收到一个操作(例如,一个 GraphQL 查询)时,它将根据这些类型定义定义的 GraphQL 模式进行验证和执行。让我们看看一个可以针对使用之前显示的类型定义定义的 GraphQL 服务执行的示例查询。
1.2.2 使用 GraphQL 进行查询
GraphQL 查询定义了通过我们的类型定义定义的数据图进行遍历,并请求查询返回的字段子集——这被称为 选择集。在这个查询中,我们从 allMovies 查询字段入口点开始遍历图,以找到与每部电影相关的演员(参见下一列表)。然后,对于这些演员中的每一个,我们遍历到他们与之相连的所有其他电影。
列表 1.2 一个用于查找电影和演员的 GraphQL 查询
query FetchSomeMovies { ❶
allMovies { ❷
title ❸
actors { ❹
name
movies { ❺
title
}
}
}
}
❶ 这是操作的可选命名。query 是默认操作,因此可以省略。命名查询——在这种情况下,FetchSomeMovies——也是可选的,可以省略。
❷ 在这里,我们指定入口点,它是 Query 或 Mutation 类型上的一个字段。在这种情况下,我们的查询入口点是 allMovies 查询字段。
❸ 选择集定义了查询要返回的字段。
❹ 在对象字段的情况下,使用嵌套选择集来指定要返回的字段。
❺ 需要进一步嵌套的选择集来返回电影上的字段。
注意,我们的查询是嵌套的,描述了如何遍历相关对象(在这种情况下,电影和演员)的图。我们可以通过数据图和结果的可视化来表示这种遍历(参见图 1.4)。

图 1.4 通过电影数据图进行 GraphQL 查询遍历
虽然我们可以通过可视化表示数据图的遍历,但 GraphQL 查询的典型结果是下一个列表中显示的 JSON 文档。
列表 1.3 JSON 查询结果
"data": {
"allMovies": [
{
"title": "Toy Story",
"actors": [
{
"name": "Tom Hanks",
"movies": [
{
"title": "Bachelor Party"
}
]
},
{
"name": " Jim Varney",
"movies": [
{
"title": "3 Ninjas: High Noon On Mega Mountain"
}
]
}
]
},
{
"title": "Jumanji",
"actors": [
{
"name": "Robin Williams",
"movies": [
{
"title": "Popeye"
}
]
},
{
"name": "Kirsten Dunst",
"movies": [
{
"title": "Midnight Special"
},
{
"title": "All Good Things"
}
]
}
]
},
{
"title": "Grumpier Old Men",
"actors": [
{
"name": "Walter Matthau",
"movies": [
{
"title": "Cactus Flower"
}
]
},
{
"name": " Ann-Margret",
"movies": [
{
"title": "Bye Bye Birdie"
}
]
}
]
}
]
}
如您从结果中看到的,响应与查询选择集的形状相匹配——查询中请求的恰好是返回的字段。但数据从哪里来呢?GraphQL API 的数据获取逻辑定义在称为 解析函数 的函数中,这些函数包含从数据层解析任意 GraphQL 请求数据的逻辑。GraphQL 是数据层无关的,因此解析器可以查询一个或多个数据库或从另一个 API(甚至是一个 REST API)获取数据。我们将在下一章深入探讨解析器。
1.2.3 GraphQL 的优势
现在我们已经看到了我们的第一个 GraphQL 查询,你可能正在想,“好吧,这很好,但我也可以使用 REST 获取有关电影的数据。GraphQL 有什么了不起的?”让我们回顾一下 GraphQL 的一些好处。
过度获取和不足获取
过度获取 是与 REST 常见相关的一种模式,在这种模式中,不必要的和未使用的数据会在响应 API 请求时通过网络发送。由于 REST 是对资源的建模,当我们发出针对,例如,/movie/tt0105265 的 GET 请求时,我们得到该电影的表示——不多也不少。
列表 1.4 GET /movie/tt0105265 的 REST API 响应
{
"title": "A River Runs Through It",
"year": 1992,
"rated": "PG",
"runtime": "123 min",
"plot": "The story about two sons of a stern minister -- one reserved,
one rebellious -- growing up in rural Montana while devoted to
fly fishing.",
"movieId": "tt0105265",
"actors": ["nm0001729", "nm0000093", "nm0000643", "nm0000950"],
"language": "English",
"country": "USA",
"production": "Sony Pictures Home Entertainment",
"directors": ["nm0000602"],
"writers": ["nm0533805", "nm0295030"],
"genre": "Drama",
"averageReviews": 7.3
}
但如果我们应用程序的视图只需要渲染电影的标题和年份呢?那么我们就无谓地发送了过多的数据。此外,一些电影字段可能计算成本很高。例如,如果我们需要通过聚合每个请求的所有电影评论来计算 averageReviews,但我们甚至没有在应用程序视图中显示这一点,那么这将浪费大量的计算时间,这无谓地影响了我们 API 的性能。(当然,在现实世界中,我们可能会缓存这些数据,但这也会增加额外的复杂性。)同样,不足获取是与 REST 相关的一种模式,其中请求返回的数据不足。
假设我们的应用程序视图需要渲染电影中每位演员的姓名。首先,我们发出针对 /movie/tt0105265 的 GET 请求。如前所述,我们有一个与这部电影相连的演员 ID 数组。现在,为了获取我们应用程序所需的数据,我们需要遍历这个演员 ID 数组,通过为要在视图中渲染的每位演员发出另一个 API 请求来获取每位演员的姓名:
/actor/nm0001729
/actor/nm0000093
/actor/nm0000643
/actor/nm0000950
使用 GraphQL,由于客户端控制所需的数据,我们可以通过在 GraphQL 查询的选择集中指定应用程序视图所需的确切数据,在一个请求中完成此操作,从而解决过度获取和不足获取的问题。这导致服务器端性能得到改善,因为我们花费在数据层的计算资源更少,网络发送的总数据更少,并且通过能够通过向 API 服务发出单个网络请求来渲染我们的应用程序视图,降低了延迟。
GraphQL 规范
GraphQL 是一种客户端-服务器通信规范,它描述了 GraphQL API 查询语言的功能、功能性和能力。拥有这个规范为如何实现你的 GraphQL API 提供了清晰的指南,并明确定义了什么是 GraphQL,什么不是 GraphQL。
REST 没有规范;相反,有许多不同的实现,从可能被认为是仅仅类似于 REST 的到超媒体作为应用程序状态引擎(HATEOAS)。将规范作为 GraphQL 的一部分简化了关于端点、状态码和文档的辩论。所有这些都内置在 GraphQL 中,这为开发人员和 API 设计师带来了生产力的提升。规范为 API 实现者提供了清晰的路径。
使用 GraphQL,一切皆图
REST 模型本身是一个资源层次结构,然而与 API 的交互大多数是以关系为单位的。例如,给定我们之前的电影查询——对于这部电影,显示所有与之相关的演员,以及对于每位演员,显示他们参演的其他所有电影——我们正在查询演员和电影之间的关系。这种关系概念在现实世界应用中更为突出,当我们可能正在处理连接客户和他们在订单中的产品或用户和他们在对话中发送给其他用户的消息之间的关系时。
GraphQL 还可以帮助统一来自不同系统的数据。由于 GraphQL 对数据层是中立的,我们可以构建 GraphQL API,将来自多个服务的数据进行整合,并提供一个清晰的方式来将这些不同系统的数据集成到单个统一的 GraphQL 模式中。
GraphQL 还可以用于在应用程序中以基于组件的数据交互模式对数据获取进行分块。由于每个 GraphQL 查询可以精确描述图遍历和要返回的字段,将这些查询与应用程序组件封装起来可以帮助简化应用程序的开发和测试。我们将在第五章开始构建我们的 React 应用程序时看到如何应用这一点。
自省
自省 是 GraphQL 的一个强大功能,它允许我们向 GraphQL API 询问它支持的类型和查询。自省成为了一种自我文档化的方式。利用自省的工具可以提供可读性强的 API 文档,以及可视化工具,并利用代码生成来创建 API 客户端。
1.2.4 GraphQL 的缺点
当然,GraphQL 并非万能的银弹,我们不应将其视为解决所有 API 相关问题的方案。采用 GraphQL 最大的挑战之一是,当使用 GraphQL 时,一些从 REST 中理解良好的实践并不适用。例如,HTTP 状态码通常用于传达 REST 请求的成功、失败和其他情况;“200 OK”表示我们的请求成功,而“404 Not Authorized”表示我们忘记了一个身份验证令牌或没有请求资源的正确权限。然而,在 GraphQL 中,每个请求都返回 200 OK,无论请求是否完全成功。这使得在 GraphQL 世界中的错误处理略有不同。与描述我们请求结果的单一状态码不同,GraphQL 错误通常在字段级别返回。这意味着我们可能成功检索了我们的 GraphQL 查询的一部分,而其他字段返回了错误,需要适当处理。
缓存 是 REST 中另一个被广泛理解的领域,但在 GraphQL 中处理方式略有不同。在使用 REST 时,缓存 /movie/123 的响应是可能的,因为我们可以为每个 GET 请求返回完全相同的结果。但在 GraphQL 中,由于每个请求可能包含不同的选择集,这意味着我们不能简单地为整个请求返回缓存的响应。这可以通过大多数 GraphQL 客户端在应用级别实现客户端缓存来缓解,实际上,我们的 GraphQL 请求大多数时间都在一个认证环境中,那里不适用缓存。
另一个挑战是向客户端暴露任意复杂性以及相关的性能考虑。如果客户端可以自由地按自己的意愿组合查询,我们如何确保这些查询不会变得过于复杂,从而显著影响性能或耗尽我们后端基础设施的计算资源?幸运的是,GraphQL 工具允许我们限制查询的深度,并进一步限制可以运行的查询,到一个白名单选择查询(称为持久化查询)。另一个相关挑战是实现速率限制。在使用 REST 时,我们可以简单地限制在特定时间段内可以发出的请求数量;然而,在使用 GraphQL 时,这变得更加复杂,因为客户端可能在单个查询中请求多个对象。这导致需要定制查询成本实现来解决速率限制问题。
最后,所谓的 n + 1 查询问题是在 GraphQL 数据获取实现中常见的一个问题,可能导致多次往返数据层,并可能对性能产生负面影响。考虑这种情况,我们请求有关一部电影及其所有演员的信息。在数据库中,我们可能存储与每部电影关联的演员 ID 列表,该列表与我们的电影详情请求一起返回。在简单的 GraphQL 实现中,我们随后需要检索演员详情,并且我们需要为每个演员对象向数据库发出单独的请求,从而导致总共 n(即演员数量)+ 1(即电影)次数据库查询。为了解决 n + 1 查询问题,像 DataLoader 这样的工具允许我们批量缓存对数据库的请求,从而提高性能。解决 n + 1 查询问题的另一种方法是通过使用 GraphQL 数据库集成,例如 Neo4j GraphQL 库和 PostGraphile,这些库允许我们从任意 GraphQL 请求生成单个数据库查询,确保只进行一次数据库往返。
GraphQL 限制
当我们谈论数据库时,重要的是要理解 GraphQL 是一种 API 查询语言,而不是数据库查询语言。GraphQL 缺乏数据库查询语言所需的许多复杂操作的语义,例如聚合、投影和可变长度路径遍历。
1.2.5 GraphQL 工具
在本节中,我们回顾了一些特定的 GraphQL 工具,这些工具将帮助我们构建、测试和查询我们的 GraphQL API。这些工具利用 GraphQL 的 introspection 功能,允许提取已部署的 GraphQL 端点的模式以生成文档、查询验证、自动完成和其他有用的开发功能。
GraphiQL
GraphiQL 是一个浏览器内工具,用于探索和查询 GraphQL API。使用 GraphiQL,我们可以对 GraphQL API 执行 GraphQL 查询并查看结果。多亏了 GraphQL 的 introspection 功能,我们可以查看我们连接的 GraphQL API 支持的类型、字段和查询。此外,由于 GraphQL 类型系统,我们在构建查询时立即进行查询验证。GraphiQL 是一个由 GraphQL 基金会维护的开源软件包。GraphiQL 可以打包成一个独立工具或 React 组件,因此通常嵌入到更大的 Web 应用程序中(见图 1.5)。

图 1.5 GraphiQL 截图
GraphQL Playground
与 GraphiQL 类似,GraphQL Playground 是一个浏览器内工具,用于执行 GraphQL 查询、查看结果和探索 GraphQL API 的模式,它由 GraphQL 的 introspection 功能提供支持(见图 1.6)。GraphQL Playground 有一些额外的功能,例如查看 GraphQL 类型定义、搜索 GraphQL 模式,以及轻松添加请求头(例如,用于身份验证所需的头)。GraphQL Playground 曾经默认包含在服务器实现中,如 Apollo Server;然而,它已被弃用,并且不再积极维护。我们在这里包含 GraphQL Playground,因为它仍然部署在许多 GraphQL 端点中,你可能会在某个时刻遇到它。

图 1.6 GraphQL Playground 截图
Apollo Studio
Apollo Studio 是 Apollo 提供的一个云平台,包括许多用于构建、验证和保障 GraphQL API 的功能(见图 1.7)。Apollo Studio 包含在本节中,因为 Studio 的 探索者 功能与之前提到的 GraphiQL 和 GraphQL Playground 工具类似,用于创建和运行 GraphQL 操作。此外,Apollo Studio 中的探索者现在默认由 Apollo Server(截至 Apollo Server 版本 3)使用,因此我们将在这本书中使用 Apollo Studio 来运行 GraphQL 操作,以开发我们的 GraphQL API。

图 1.7 Apollo Studio 截图
1.3 React
React 是一个用于使用 JavaScript 构建用户界面的声明式、基于组件的库。React 使用虚拟 DOM(实际文档对象模型的副本)来高效地计算渲染视图所需的 DOM 更新,以适应应用程序状态和数据的变化。这意味着用户只需设计映射到应用程序数据的视图,React 就会高效地处理 DOM 更新。组件封装了数据处理和用户界面渲染逻辑,而不暴露其内部结构,因此可以轻松组合在一起以构建更复杂用户界面和应用。
1.3.1 React 组件
让我们在下一个列表中检查一个简单的 React 组件。
列表 1.5 一个简单的 React 组件
import React, { useState } from "react"; ❶
function MovieTitleComponent(props) { ❷
const [movieTitle, setMovieTitle] = useState( ❸
"River Runs Through It, A"
);
return <div>{movieTitle}</div> ❹
}
export default MovieTitleComponent; ❺
❶ 我们导入 React 和 useState 钩子以管理状态变量。
❷ 我们的组件是一个函数,它从 React 组件层次结构中更高的组件接收 props 或值。
❸ 使用 useState 钩子,我们创建一个新的状态变量及其关联的更新函数。
❹ 在这里,我们从组件状态中访问 movieTitle 值,并在 div 标签内渲染它。
❺ 我们导出这个组件,以便它可以在其他 React 组件中组合使用。
组件库
由于组件封装了数据处理和用户界面渲染逻辑,并且易于组合,因此将组件库分发给项目作为依赖项以快速利用复杂的样式和用户界面设计变得实用。使用此类组件库超出了本书的范围;然而,一个很好的例子是 Material UI 组件库,它允许我们导入许多流行的、常见的用户界面组件,例如网格布局、数据表、导航和输入。
1.3.2 JSX
React 通常与一个名为 JSX 的 JavaScript 语言扩展一起使用。JSX 看起来类似于 XML,并且是构建 React 用户界面和组合 React 组件的强大方式。虽然可以使用 React 而不使用 JSX,但大多数用户更喜欢 JSX 提供的可读性和可维护性。我们将在第五章中介绍 JSX 以及其他一些 React 概念,例如单向数据流、props 和 state,以及使用 React 进行数据获取。
1.3.3 React 工具
接下来,我们将回顾一些有用的工具,这些工具将帮助我们构建、开发和调试 React 应用程序。对于使用 React 应用程序进行开发,有一个健康的工具生态系统,所以不要认为这是一个完整的列表。
Create React App
Create React App 是一个命令行工具,可以用来快速创建 React 应用程序的框架,包括配置构建设置、安装依赖项以及模板化一个简单的 React 应用程序以开始开发。我们将在第五章中介绍如何使用 Create React App 来构建应用程序的前端。
React Chrome DevTools
React DevTools 是一个浏览器扩展,允许我们在应用程序运行时检查 React 应用程序,并查看组件层次结构、props 和每个组件的内部状态,从而实现 React 应用程序的调试。在查看我们的组件在不同使用场景下的结构时,它非常有用(见图 1.8)。

图 1.8 React Chrome DevTools
1.4 Apollo
Apollo 是一组工具,使使用 GraphQL 变得更容易,包括在服务器、客户端和云中。我们将使用 Apollo Server,这是一个 Node.js 库,用于构建我们的 GraphQL API,以及 Apollo Client,这是一个客户端 JavaScript 库,用于从我们的应用程序查询 GraphQL API,以及之前介绍的 Apollo Studio 的 Explorer,用于构建和运行查询。
1.4.1 Apollo Server
Apollo Server 允许我们通过定义我们的类型定义和解析函数,轻松启动一个 Node.js 服务器,该服务器提供 GraphQL 端点。Apollo Server 可以与许多不同的 Web 框架一起使用;然而,默认且最受欢迎的是 Express.js。Apollo Server 还可以与无服务器函数一起使用,例如 Amazon Lambda 和 Google Cloud Functions。Apollo Server 可以使用 npm 安装:npm install apollo-server。
1.4.2 Apollo Client
Apollo Client 是一个用于查询 GraphQL API 的 JavaScript 库,并与许多前端框架集成,包括 React 和 Vue.js,以及原生移动版本 iOS 和 Android。我们将使用 React Apollo Client 集成在 React 组件中实现通过 GraphQL 的数据获取。Apollo Client 处理客户端数据缓存,也可以用于管理本地状态数据。React Apollo Client 库可以使用 npm 安装:npm install @apollo/client。
1.5 Neo4j 数据库
Neo4j 是一个开源的原生图数据库。与其他使用表或文档作为数据模型的数据库不同,Neo4j 使用的数据模型是一个图,具体称为属性图数据模型,它允许我们将数据作为图进行建模、存储和查询。像 Neo4j 这样的图数据库针对处理图数据和执行复杂的图遍历进行了优化,例如由 GraphQL 查询定义的遍历。
使用图数据库与 GraphQL 的一个好处是我们可以在整个应用程序堆栈中保持相同的图数据模型,在前端、后端和数据库中处理图。另一个好处与图数据库相对于其他数据库系统(如关系数据库)的性能优化有关。许多 GraphQL 查询最终会嵌套多层——相当于关系数据库中的 JOIN 操作。图数据库针对执行这些图遍历操作进行了优化,因此是 GraphQL API 后端的自然选择。
注意:需要注意的是,我们并不是直接使用 GraphQL 查询数据库。虽然 GraphQL 有数据库集成,但 GraphQL API 是位于我们的应用程序和数据库之间的一层。
1.5.1 属性图数据模型
与许多图数据库一样,Neo4j 使用属性图模型(见图 1.9)。属性图模型的组成部分包括
-
节点—我们数据模型中的实体或对象
-
关系—节点之间的连接
-
标签—节点的分组语义
-
属性—存储在节点和关系上的键值对属性

图 1.9 书籍、出版社、客户和订单的属性图示例
属性图数据模型允许我们以灵活的方式表达复杂、连接的数据。这种数据模型还具有额外的优势,即与我们在处理领域数据时通常考虑数据的方式紧密映射。
1.5.2 Cypher 查询语言
Cypher 是由 Neo4j 和其他图数据库以及图计算引擎使用的声明式图查询语言。你可以将 Cypher 视为类似于 SQL,但 Cypher 是为图数据设计的,而不是处理表。Cypher 的一个主要特性是 模式匹配。在 Cypher 中的图模式匹配中,我们可以使用类似 ASCII 艺术的符号来定义图模式。在下一个列表中,让我们看看一个简单的 Cypher 示例:查询与这些电影相连的电影和演员。
列表 1.6 简单的 Cypher 查询,查询电影和演员
MATCH (m:Movie)<-[r:ACTED_IN]-(a:Actor) ❶
RETURN m,r,a ❷
❶ 描述图模式以在数据库中查找数据
❷ 我们返回与所描述的图模式匹配的数据。
在我们的 Cypher 查询中,MATCH 后跟一个使用类似 ASCII 艺术的符号描述的图模式。在这个模式中,节点定义在括号内——例如,(m:Movie)。:Movie 表示我们应该匹配标签为 Movie 的节点,冒号前的 m 成为一个变量,它绑定到匹配该模式的任何节点。我们可以在整个查询中引用 m。
关系由方括号定义(例如,<-[r:ACTED_IN]-)并遵循类似的约定,其中 :ACTED_IN 声明 ACTED_IN 关系类型,r 成为一个可以在查询中引用的变量,用于表示匹配该模式的任何关系。
在 RETURN 子句中,我们指定查询要返回的数据。在这里,我们指定了变量 m、r 和 a,这些变量是在 MATCH 子句中定义的,并且与数据库中匹配图模式元素的节点和关系绑定。
1.5.3 Neo4j 工具
我们将使用 Neo4j Desktop 来管理本地和 Neo4j Browser 上的 Neo4j 实例,Neo4j Browser 是一个用于查询和与我们的 Neo4j 数据库交互的开发者工具。为了从我们的 GraphQL API 查询 Neo4j,我们将使用 JavaScript Neo4j 客户端驱动程序以及 Neo4j GraphQL 库,这是一个用于 Neo4j 的 Node.js GraphQL 集成。
Neo4j Desktop
Neo4j Desktop 是 Neo4j 的指挥中心(见图 1.10)。从 Neo4j Desktop 我们可以管理 Neo4j 数据库实例,包括编辑配置、安装插件和图形应用(例如,可视化工具),以及访问管理员级别的功能,如导出/导入数据库。Neo4j Desktop 是 Neo4j 的默认下载体验,可在 neo4j.com/download 下载。

图 1.10 Neo4j Desktop
Neo4j AuraDB
Neo4j AuraDB 是 Neo4j 的完全托管云服务,在云中提供托管的 Neo4j 实例。AuraDB 包括免费层,这使得它成为开发和个人项目的绝佳选择。我们将在第八章中更详细地介绍 Neo4j AuraDB,当时我们将探讨使用云服务部署我们的全栈应用程序。您可以在 dev.neo4j.com/neo4j-aura 免费开始使用 Neo4j AuraDB。
Neo4j 浏览器
Neo4j 浏览器是 Neo4j 的浏览器内查询工作台,是开发期间与 Neo4j 交互的主要方式之一(见图 1.11)。使用 Neo4j 浏览器,我们可以使用 Cypher 查询数据库并可视化结果,无论是作为图形可视化还是以表格形式的结果。

图 1.11 Neo4j 浏览器
Neo4j 客户端驱动程序
由于我们的最终目标是构建一个与我们的 Neo4j 数据库通信的应用程序,我们将利用 Neo4j 的语言驱动程序。客户端驱动程序在许多语言中可用(Java、Python、.Net、JavaScript、Go 等),但我们将使用 Neo4j JavaScript 驱动程序。
注意:Neo4j JavaScript 驱动程序既有 Node.js 版本也有浏览器版本(允许从浏览器直接连接到数据库);然而,在这本书中,我们只会使用 Node.js 版本。
Neo4j JavaScript 驱动程序使用 npm 安装:
npm install neo4j-driver
在以下列表中,让我们看看一个示例:使用 Neo4j JavaScript 驱动程序执行 Cypher 查询并记录结果。
列表 1.7 基本 Neo4j JavaScript 驱动程序使用
const neo4j = require("neo4j-driver"); ❶
const driver = neo4j.driver("neo4j://localhost:7687", ❷
neo4j.auth.basic("neo4j", "letmein")); ❸
const session = driver.session(); ❹
session.run("MATCH (n) RETURN COUNT(n) AS num") ❺
.then(result => { ❻
const record = result.records[0]; ❼
console.log(`Your database has ${record['num']} nodes`);
})
.catch(error => {
console.log(error);
})
.finally( () => { ❽
session.close();
)
❶ 导入 neo4j-driver 模块
❷ 创建驱动程序实例并指定数据库连接字符串
❸ 指定数据库用户名和密码
❹ 会话更轻量级,应该为特定的工作块实例化。
❺ 在自动提交事务中运行查询;它返回一个承诺。
❻ 承诺解析为结果集。
❼ 访问结果集的记录并选择第一条记录
❽ 一定要关闭会话。
我们将学习如何在我们的 GraphQL 解析函数中利用 Neo4j JavaScript 驱动程序,作为在我们的 GraphQL API 中实现数据获取的一种方式。
Neo4j GraphQL 库
Neo4j GraphQL 库是 Neo4j 的 GraphQL 到 Cypher 查询执行层。它与任何 JavaScript GraphQL 服务器实现(如 Apollo Server)一起工作。我们将学习如何使用此库来完成以下任务:
-
使用 GraphQL 类型定义驱动 Neo4j 数据库模式
-
从 GraphQL 类型定义生成完整的 CRUD GraphQL API
-
为任意 GraphQL 请求生成单个 Cypher 数据库查询(解决 n + 1 查询问题)
-
使用 Cypher 在我们的 GraphQL API 中添加自定义逻辑
虽然 GraphQL 是数据层无关的——可以使用任何数据源或数据库实现 GraphQL API——但是当与图数据库一起使用时,有一些好处,例如减少数据模型映射和转换,以及针对使用 GraphQL 定义的复杂遍历的性能优化。Neo4j GraphQL 库有助于构建由 Neo4j 图数据库支持的 GraphQL API。从第四章开始介绍使用 Neo4j GraphQL 库,你可以在 dev.neo4j.com/graphql 上了解更多关于该库的信息。
1.6 如何整体结合
现在我们已经查看了我们 GraphQL 栈的每个单独部分,让我们看看在完整栈应用程序的上下文中,所有这些是如何结合在一起的,我们以电影搜索应用程序为例。我们虚构的电影应用程序有三个简单的要求:
-
允许用户通过标题搜索电影。
-
向用户显示任何匹配的结果和电影的详细信息,例如评分或类型。
-
显示与用户喜欢的匹配电影相似的影片列表,这可能是一个不错的推荐。
图 1.12 展示了不同组件如何组合在一起,从客户端应用程序的请求流程开始,通过标题搜索电影,到 GraphQL API,然后从 Neo4j 数据库解析数据,最后返回客户端,在更新的用户界面视图中呈现结果。

图 1.12 随着电影搜索请求通过完整的 GraphQL 应用程序
1.6.1 React 和 Apollo Client:发起请求
我们应用程序的前端是用 React 构建的;具体来说,我们有一个 MovieSearch React 组件,它渲染一个接受用户输入的文本框(用户将提供要搜索的电影字符串)。这个 MovieSearch 组件还包含将用户输入与 GraphQL 查询结合,并通过 Apollo Client React 集成将其发送到 GraphQL 服务器以解析数据的逻辑。以下列表显示了如果用户搜索“河上河”,发送到 API 的 GraphQL 查询可能的样子。
列表 1.8 搜索匹配“河上河”电影的 GraphQL 查询
{
moviesByTitle(title: "River Runs Through It") {
title
poster
imdbRating
genres {
name
}
recommendedMovies {
title
poster
}
}
}
这种数据获取逻辑是通过 Apollo Client 实现的,我们在 MovieSearch 组件中使用它。Apollo Client 实现了一个缓存,所以当用户输入他们的搜索查询时,Apollo Client 首先检查缓存,看是否已经处理过这个搜索字符串的 GraphQL 查询。如果没有,那么查询将以 HTTP POST 请求的形式发送到 GraphQL 服务器上的 /graphql。
1.6.2 Apollo Server 和 GraphQL 后端
我们的电影应用程序的后端是一个 Node.js 应用程序,它使用 Apollo Server 和 Express 网络服务器库通过 HTTP 提供一个 /graphql 端点。一个 GraphQL 服务器由网络层组成,负责处理 HTTP 请求、提取 GraphQL 操作并返回 HTTP 响应,以及 GraphQL 模式,它定义了 API 的入口点和数据结构,并负责通过执行解析函数从数据层解析数据。
当 Apollo 客户端发起请求时,我们的 GraphQL 服务器通过验证查询来处理请求,然后首先调用根级别的解析函数,在这个例子中是 Query.moviesByTitle。这个解析函数接收标题参数——用户在搜索文本框中输入的值。在我们的解析函数内部,我们拥有查询数据库以找到与搜索查询匹配的电影、检索电影详情以及为每部匹配电影找到其他推荐电影的逻辑。
解析函数实现
在这本书中,我们将展示两种实现解析函数的方法:
-
在各个解析函数内部定义数据库查询的 天真 方法
-
使用 GraphQL 引擎 库自动生成解析函数,例如 Neo4j GraphQL 库
这个例子只涵盖了第一种情况。
解析函数以嵌套的方式执行(参见图 1.13)——在这个例子中,从 moviesByTitle 查询字段解析函数开始,这是此操作的根级别解析函数。moviesByTitle 解析函数将返回一个电影列表,然后查询中请求的每个字段的解析函数将被调用,并传递 moviesByTitle 返回的电影列表中的一个项目——本质上是对这个电影列表进行迭代。

图 1.13 GraphQL 解析函数以嵌套方式调用。
每个解析函数都包含解析整体 GraphQL 模式一部分数据的逻辑。例如,当推荐电影解析函数接收到一部电影时,它有找到观众可能也喜欢的类似电影的逻辑。在这种情况下,这是通过查询数据库,使用简单的 Cypher 查询来搜索观看过该电影的用户,并遍历以找到这些用户观看的其他电影来提供协同过滤推荐,如以下列表所示。此查询在 Neo4j 中使用 Node.js JavaScript Neo4j 客户端驱动程序执行。
列表 1.9 一个简单的电影推荐 Cypher 查询
MATCH (m:Movie {movieId: $movieID})<-[:RATED]-(:User)-[:RATED]->(rec:Movie)
WITH rec, COUNT(*) AS score ORDER BY score DESC
RETURN rec LIMIT 3
n + 1 查询问题
这里我们完美地展示了 n + 1 查询问题。我们的根级别解析函数返回一个电影列表。现在,为了解析我们的 GraphQL 查询,我们需要为每部电影调用一次演员解析函数。这会导致对数据库的多次请求,可能会影响性能。
理想情况下,我们向数据库发送单个请求,该请求获取解决 GraphQL 查询所需的所有数据。对此问题有几个解决方案:
-
DataLoader 库允许我们将请求批量处理。
-
GraphQL 引擎库,如 Neo4j GraphQL 库,可以从任意 GraphQL 请求生成单个数据库查询,利用 GraphQL 的图特性,而不会因多次数据库调用而产生负面的性能影响。
1.6.3 React 和 Apollo 客户端:处理响应
一旦我们的数据获取完成并将数据发送回 Apollo 客户端,缓存就会更新,因此如果将来执行相同的搜索查询,数据将从缓存中检索,而不是从 GraphQL 服务器请求数据。
我们的 MovieSearch React 组件将 GraphQL 查询的结果作为 props 传递给 MovieList 组件,该组件随后渲染一系列 Movie 组件,更新视图以显示每个匹配电影的详细信息——在这种情况下,只有一个。我们的用户将看到一个电影搜索结果列表(见图 1.14)!

图 1.14 React 组件组合在一起构建复杂的用户界面。
本例的目标是展示如何将 GraphQL、React、Apollo 和 Neo4j 数据库结合使用来构建一个简单的全栈应用程序。我们省略了许多细节,例如身份验证、授权和优化性能,但请放心,我们将在整本书中详细讲解这些内容!
1.7 本书我们将构建的内容
本章中我们使用的简单电影搜索示例,希望是对我们将在这本书中学到的概念的一个不错的介绍。我们不是构建一个电影搜索应用程序,而是从头开始构建一个新的应用程序,在构建过程中,我们将一起处理需求并设计 GraphQL API,以此构建我们对 GraphQL 的知识。为了展示本书中涵盖的概念,我们将构建一个利用 GraphQL、React、Apollo 和 Neo4j 的 Web 应用程序。这个 Web 应用程序将是一个简单的企业评论应用程序。该应用程序的需求包括
-
列出企业和企业详细信息
-
允许用户对商业机构撰写评论
-
允许用户搜索商业机构并向用户展示个性化推荐
为了实现此应用程序,我们需要设计和实现我们的 GraphQL API、用户界面和数据库。我们需要处理身份验证、授权等问题,并将我们的应用程序部署到云端。
1.8 练习
-
为了熟悉 GraphQL 和编写 GraphQL 查询,请探索公共电影 GraphQL API,网址为
movies.neo4j-graphql.com。在网页浏览器中打开此 URL 以访问 GraphQL Playground,并探索 DOCS 和 SCHEMA 标签以查看类型定义。尝试编写查询以响应以下提示:
-
按标题顺序查找前 10 部电影的标题。
-
谁出演了电影《侏罗纪公园》?
-
《侏罗纪公园》的流派是什么?还有哪些电影属于这些流派?
-
哪部电影具有最高的 imbdRating?
-
-
考虑我们在本章中描述过的业务评论应用程序。看看你是否可以创建此应用程序所需的 GraphQL 类型定义。
-
下载 Neo4j,熟悉 Neo4j Desktop 和 Neo4j Browser。在 neo4j.com/sandbox 上完成 Neo4j Sandbox 示例数据集指南。
你可以在本书的 GitHub 仓库中找到练习的解决方案以及代码示例:github.com/johnymontana/fullstack-graphql-book。
摘要
-
GraphQL 是一种用于满足请求的 API 查询语言和运行时。我们可以使用 GraphQL 与任何数据层结合。要构建 GraphQL API,我们首先定义类型,包括每个类型上可用的字段以及它们是如何连接的,并描述数据图。
-
React 是一个用于构建用户界面的 JavaScript 库。我们使用 JSX 构建封装数据和逻辑的组件。这些组件可以组合在一起,从而允许构建复杂用户界面。
-
Apollo 是一套用于处理 GraphQL 的工具集合,适用于客户端和服务器端。Apollo Server 是一个用于构建 GraphQL API 的 Node.js 库。Apollo Client 是一个 JavaScript GraphQL 客户端,它集成了许多前端框架,包括 React。
-
Neo4j 是一个开源的图数据库,它使用属性图数据模型,该模型由节点、关系、标签和属性组成。我们使用 Cypher 查询语言与 Neo4j 交互。
-
这些技术可以一起使用来构建全栈 GraphQL 应用程序。
2 使用 GraphQL 进行图思维
本章涵盖
-
描述我们业务审查应用的需求
-
将需求转换为 GraphQL 类型定义
-
使用一种简单的方法实现这些类型定义的数据获取解析函数
-
使用 Apollo Server 结合我们的类型定义和解析器来提供 GraphQL 端点
-
使用 Apollo Studio 查询我们的 GraphQL 端点
在本章中,我们将为业务审查应用设计一个 GraphQL API。首先,我们将定义此应用的需求;然后,我们将描述一个遵循 GraphQL-first 开发方法的 GraphQL API,以解决这些需求。然后,我们将探讨如何实现此 API 的数据获取逻辑。最后,我们将探讨如何结合我们的 GraphQL 类型定义和解析函数,使用 Apollo Server 提供 GraphQL API,并使用 Apollo Studio 进行查询。在构建 API 时,了解数据域和常见的访问模式通常很有用——换句话说,API 要解决什么问题?GraphQL-first 开发方法允许我们在定义 GraphQL 类型定义后并行实现后端和前端系统。
GraphQL-first 开发
GraphQL-first 开发范式是一种以 GraphQL API 设计为驱动力的应用程序构建方法。该过程从描述由业务需求综合而成的 GraphQL 类型定义开始。然后,这些类型定义成为 API 实现、数据库数据获取代码和客户端应用程序代码的基础。GraphQL-first 开发是一种强大的方法,因为它允许在定义 GraphQL 类型定义后并行实现后端和前端系统。
2.1 您的应用数据是一个图
图 是由节点(实体或对象)和连接节点的边组成的基本数据结构。图是一个直观的模型,可以用来表示许多不同的领域。通常,当我们通过检查一个领域的业务需求来生成数据模型时,我们最终会绘制一个显示对象及其连接方式的图表。这就是图!
让我们通过我们的业务审查应用的过程。我们应用的需求是
-
作为用户,我想通过类别、位置和名称搜索企业列表。
-
作为用户,我想查看每个企业的详细信息(名称、描述、地址、照片等)。
-
作为用户,我想查看每个企业的评论,包括每个企业的摘要,并按好评企业进行搜索排序。
-
作为用户,我想为一家企业创建评论。
-
作为用户,我想连接我喜欢的口味的朋友和用户,这样我就可以关注我朋友的评论。
-
作为用户,我希望根据我之前写过的评论和我的社交网络收到个性化的推荐。
现在我们已经确定了我们应用程序的需求,让我们考虑这个应用程序的数据需求及其描述的数据模型。
首先,什么是实体?这些将成为我们图中的节点。我可以将用户、企业、评论和照片视为我们需要考虑的实体(见图 2.1)。

图 2.1 实体成为节点。
接下来,这些实体是如何连接的?这些连接被建模为实体之间的关系,而我们所描述的是一个图(见图 2.2)。让我们添加以下关系:
-
用户撰写评论。
-
评论与一个企业相连。
-
用户上传照片。
-
照片被标记到企业上。

图 2.2 添加关系以连接节点
现在我们已经将我们应用程序的数据需求描述为一个图,我们可以开始考虑如何构建一个 GraphQL API,使我们能够与这个数据图一起工作。
2.2 GraphQL 中的图
GraphQL 将我们的业务领域建模为一个图。使用 GraphQL,我们通过创建一个 GraphQL 模式来定义这个图模型,我们通过编写 GraphQL 类型定义来实现这一点。在模式中,我们定义了节点的类型、每个节点上可用的字段以及它们通过关系如何连接。创建 GraphQL 模式最常见的方式是使用 GraphQL 模式定义语言(SDL)。在本节中,我们根据我们应用程序的需求创建一个 GraphQL 模式,使用 GraphQL 类型定义在 GraphQL 中建模我们的业务审查领域。
2.2.1 使用类型定义进行 API 建模:GraphQL-first 开发
在将我们的业务需求翻译成我们应用程序所需的图数据模型之后,我们现在可以正式编写我们的 GraphQL 类型定义,使用 GraphQL 模式定义语言。使用 GraphQL SDL,我们定义类型、每种类型上的字段以及它们是如何连接的。我们数据的 GraphQL SDL 表示只是我们之前章节中描述的图数据模型的另一种表示。我们的 GraphQL 类型定义将成为 API 的规范,并指导我们其余的实现。这个过程被称为GraphQL-first 开发。
表示 GraphQL 类型的其他方式
SDL 不是创建我们的类型定义的唯一方式。每个 GraphQL 实现(例如,graphql.js,大多数 Node.js JavaScript GraphQL 项目使用的参考实现)也提供了一个程序化 API 来表示 GraphQL 类型定义。实际上,当 SDL 被解析时,内部会创建这个对象表示来与 GraphQL 模式一起工作。这种构建 GraphQL 类型的方法也可以由 API 开发者使用,并且在程序化生成 GraphQL 类型时通常是更好的选择,例如从现有类生成类型时。
由于 GraphQL 服务可以用任何语言实现,因此特定于编程语言的语法对于所有 GraphQL 实现并不相关;因此,使用无语言特定的 GraphQL SDL 来定义 GraphQL 类型。在第一章中,我们介绍了 GraphQL 模式定义语言的语法,使用了一个简单的电影和演员 GraphQL 模式。使用该示例中介绍的语法,让我们根据本章前一部分创建的业务审查应用的要求,创建 GraphQL 类型定义,如下所示。
列表 2.1 我们业务审查应用的 GraphQL 类型定义
type Business { ❶
businessId: ID!
name: String
address: String
avgStars: Float
photos: [Photo!]!
reviews: [Review!]!
}
type User {
userId: ID! ❷
name: String
photos: [Photo!]!
reviews: [Review!]! ❸
}
type Photo {
business: Business!
user: User!
photoId: ID!
url: String
}
type Review {
reviewId: ID!
stars: Float
text: String
user: User! ❹
business: Business!
}
❶ 我们图中的每种对象或实体类型都成为 GraphQL 类型。
❷ 每种类型都应该有一些字段可以唯一标识该对象。
❸ 字段可以是其他类型的引用——在这种情况下,一对一关系。
❹ 连接引用也可以表示一对一关系。
注意,我们识别的实体成为 GraphQL 类型,实体的属性成为类型上的字段,连接类型或连接类型的关系定义为引用其他类型的字段。每种类型都包含字段,这些字段可以是标量类型、对象或列表。
每种类型都应该有一些字段可以唯一标识该对象。ID 是用于表示此唯一字段的特殊 GraphQL 标量。内部,我们将 ID 字段视为字符串。感叹号!表示此字段是必需的;在我们的 GraphQL API 中,如果没有 userId 字段的值,则不能有 User 对象。这里的中括号[]表示这是一对多关系;一个 User 可以创建零个或多个评论,而一个 Review 只能由一个 User 编写。要表示一对一关系,我们只需省略中括号,表示这不是数组字段。
内置 GraphQL 类型
GraphQL 模式语言支持以下内置类型:
-
字符串
-
整型
-
浮点数
-
布尔型
-
ID
默认情况下,每个类型都是可空的,这意味着 null 是该字段的有效值。使用感叹号!来表示类型不可空。例如,Int!是不可空的整数。
要表示列表类型,使用方括号[]。例如,[Int]表示整数列表。
方括号和感叹号可以组合使用。例如,[String!] 是一个非空字符串列表:列表中的每个项目都必须有一个字符串值,但列表本身可以是 null,而 [String]! 是一个非空的可空字符串列表。
现在我们已经有了我们的类型定义,我们需要定义我们 API 的入口点。读取操作的入口点定义在一个特殊类型中,称为查询类型。写入操作的入口点定义在另一个特殊类型中,称为突变类型。在本章中,我们只关注查询。突变将在第四章中介绍,我们将更新数据库中的数据。除了查询和突变类型之外,还有一个第三种特殊的 GraphQL 类型,用于定义入口点,称为订阅。订阅是 GraphQL 的事件发布功能,超出了本书的范围。
我们 API 的入口点应该映射到我们应用程序的客户端需求。换句话说,问问自己,“客户端需要完成哪些操作?”这些需求应该指导我们定义哪些查询和突变字段。让我们首先关注下一列表中的只读需求。
列表 2.2 查询字段作为 API 入口点
type Query {
allBusinesses: [Business!]!
businessBySearchTerm(search: String!): [Business!]!
userById(id: ID!): User
}
现在我们已经创建了我们的 GraphQL 类型定义,我们可以构建一些可能被我们的应用程序使用的 GraphQL 查询。考虑以下列表中显示的应用程序可能需要发出以填充搜索结果页面的查询,基于用户提供的搜索字符串。
列表 2.3 搜索商业和评论的 GraphQL 查询
{
businessBySearchTerm(search: "Library") {
name
avgStars
reviews {
stars
text
user {
name
}
}
}
}
使用这个查询,我们可以搜索“图书馆”商业,查看匹配的商业,并查看搜索结果所需的商业详情,以及所有评论及其作者。
这很好,但这个查询有几个问题。如果我们有很多“图书馆”商业的匹配项会发生什么?如果某个特定的商业有数千条评论会发生什么?我们的客户端应用程序将不堪重负,需要渲染大量数据。此外,我们可能不想以任何顺序显示商业结果;我们应该允许搜索结果按名称排序,无论是升序还是降序。
将分页和排序添加到我们的 API
GraphQL 没有内置的过滤、分页或排序语义;相反,API 设计者需要根据应用程序的需求和相关性将这些添加到 GraphQL 模式中。
对于分页,我们将在我们的 API 中添加一个 first(想想限制)参数,允许客户端指定要返回的对象数量。我们在根查询字段和任何关系字段(描述一对一关系的字段)中都这样做。此外,一个 offset 参数(想想跳过),它指定在返回结果之前要跳过的记录数,允许客户端实现分页。
参数与字段
理解参数和字段之间的区别很重要。例如,first 和 offset 是参数,而 name 和 address 是字段。参数出现在字段名后面的括号内,并传递给解析函数。字段出现在对象名后面的花括号内,代表对象的属性。字段可以被视为持有值,而参数更多地用作选择器,并在 GraphQL 操作中传递。
列表 2.4 更新后的查询和业务类型定义,包含 first 和 offset 参数
type Business {
businessId: ID!
name: String
address: String
avgStars: Float
photos(first: Int = 3, offset: Int = 0): [Photo!]!
reviews(first: Int = 3, offset: Int = 0): [Review!]! ❶
}
type Query {
allBusinesses(first: Int = 10, offset: Int = 0): [Business!]! ❷
businessBySearchTerm(
search: String
first: Int = 10
offset: Int = 0
): [Business]
userById(id: ID!): User ❸
}
❶ 在此处,我们将 first 和 offset 参数添加到 Business 类型的 reviews 字段上。这意味着我们可以控制查询中每个返回的业务的嵌套连接 Review 对象的分页。
❷ 在此处,我们将 first 和 offset 参数添加到 allBusinesses 字段中,允许客户端指定查询的跳过和限制值,控制返回的业务数量和偏移量。请注意,我们分配了默认值,如果没有指定,则默认值为 10 和偏移量为 0,确保我们接收前 10 个结果。
❸ 由于用户 ID 字段保证最多返回一个结果,因此无需向用户 ID 字段添加 first 和 offset 参数。
分页选项
在 GraphQL 中实现分页有几种模式。我们在此关注一种相当简单的前/偏移模式。其他选项包括编号页面和基于游标的分页,如 Relay Cursor Connections。使用 Relay Cursor Connection 规范实现的基于游标的分页在第九章中介绍。
这样就解决了分页问题,但排序怎么办?在显示搜索结果时,这是必需的——我们希望以有意义的顺序向用户展示业务。为了实现这一点,我们将添加一个排序枚举,该枚举将列出我们的 GraphQL API 中类型为[Business]的字段排序选项。
列表 2.5 Business 排序枚举
enum BusinessOrdering { ❶
name_asc ❷
name_desc
}
❶ enum 是 GraphQL 的一个内置类型,它限制为一系列允许的值。
❷ 我们为每个我们希望支持排序的字段添加两个枚举选项:一个用于升序排序的字段,以 _asc 结尾,另一个用于降序排序的字段,以 _desc 结尾。
通常,枚举的约定是大写(例如,NAME_ASC);然而,由于在此情况下我们的枚举值描述了字段名称,我们做出例外,并保持枚举的命名与我们的字段名称一致。现在,我们需要将此字段作为可选参数添加到我们的查询字段中,以便搜索业务,如下一列表所示。
列表 2.6 为业务搜索结果添加排序
type Query {
allBusinesses(first: Int = 10, offset: Int = 0): [Business!]!
businessBySearchTerm(
search: String!
first: Int = 10
offset: Int = 0
orderBy: BusinessOrdering = name_asc
): [Business!]! ❶
userById(id: ID!): User
}
❶ 我们已将 orderBy 参数添加到 businessBySearchTerm 字段中,该字段类型为 BusinessOrdering。
现在,我们已经准备好使用我们新的分页和排序参数。在下一列表中,让我们更新我们之前的查询,其中我们正在搜索名称中包含“Library”的业务,以仅返回评分最高的五个业务以及每个业务的两个评论。
列表 2.7 查询商业和评论的 GraphQL 查询
{
businessBySearchTerm(search: "Library", first: 5, orderBy: name_desc) {
name
avgStars
reviews(first: 2) {
stars
text
user {
name
}
}
}
}
通常,当在应用程序查询中使用参数值时,我们希望使用在查询时可以替换的变量值,这样我们就不需要在应用程序中构建查询字符串。相反,我们希望传递参数化的 GraphQL 查询字符串和一个包含变量值的对象。我们可以在 GraphQL 中通过首先声明我们计划使用的变量及其类型,然后在查询中包含它们,并在前面加上$字符来实现这一点。以下列表显示了使用 GraphQL 变量的查询外观。
列表 2.8 使用分页搜索商业和评论的 GraphQL 查询
query businessSearch(
$searchTerm: String!
$businessLimit: Int
$businessSkip: Int
$businessOrder: BusinessOrdering
$reviewLimit: Int
) {
businessBySearchTerm(
search: $searchTerm
first: $businessLimit
offset: $businessSkip
orderBy: $businessOrder
) {
name
avgStars
reviews(first: $reviewLimit) {
stars
text
user {
name
}
}
}
}
注意,这个查询现在包含了一些附加信息,以及我们的 GraphQL 变量声明。我们明确指定了 GraphQL 的操作类型和操作名称。操作类型是查询、突变或订阅。之前,我们使用了一种简写,排除了操作类型,并将查询视为默认操作类型。我们将在本书的后面部分介绍突变类型。除非指定操作名称或变量定义,或使用除查询之外的类型,否则操作类型不是必需的。
在这里提供的另一项附加信息是操作名称——在本例中为 businessSearch。操作名称是对操作的明确命名,有助于调试和日志记录。当有问题或进行故障排除时,使用操作名称查找查询会更容易。除了 GraphQL 查询外,我们还会传递一个包含变量值的对象:
{
searchTerm: "Library",
businessLimit: 5,
businessOrder: "name_desc",
reviewLimit: 2
}
当然,我们目前还没有查询我们不存在的 API 的方法,所以让我们通过实现一些用于数据获取的解析器来解决这个问题!
2.2.2 使用解析器解析数据
按照我们的 GraphQL 优先开发方法,下一步我们需要完成的是实现从数据层实际获取这些数据的代码。我们通过编写称为解析器的函数来完成此操作,这些函数包含从数据层解析数据的逻辑。解析器是具有从 GraphQL 类型单个字段获取数据目的的独立函数,它们可以被视为 GraphQL 服务中的主要执行单元。解析器以嵌套方式调用,从根级解析器(查询、突变或订阅类型上的字段)开始,以深度优先执行,直到所有请求的字段都已解析。先前解析的数据通过 obj 参数传递给嵌套解析器。
你可以将解析器视为与在 SDL 中定义的 GraphQL 类型定义一起使用的函数,并且实际上使 GraphQL 模式可执行。GraphQL 模式必须为所有字段提供解析器函数(对于未明确定义的任何解析器函数,将使用默认解析器),因此解析器函数集合对应于类型定义,并被称为解析器映射。
解析器函数签名
每个解析器函数接收四个参数:
-
obj—之前解析的对象。对于根查询字段解析器不使用。
-
args—在 GraphQL 查询中使用的字段的参数。
-
context—一个可以包含上下文数据(如授权信息或数据库连接)的对象。
-
info—GraphQLResolveInfo 对象包含 GraphQL 查询的版本以及完整的 GraphQL 模式和其他有关查询和模式的元数据。
解析器函数返回的有效结果取决于正在解析的字段的 GraphQL 类型定义:
-
一个标量或对象值
-
一个数组
-
一个承诺
-
undefined 或 null
默认解析器
如果在 GraphQL 查询中请求的字段没有提供解析器,则将调用默认解析器,传入已解析的数据(前面提到的 obj)。此默认解析器将返回 obj 参数的属性。例如,Business 类型上 name 字段的默认解析器可能如下代码所示:
Business: {
name: (obj, args, context, info) => {
return obj.name
}
}
2.2.3 我们的第一个解析器
让我们实现我们创建的类型定义的解析器(参见列表 2.9)。我们首先需要一些要返回的数据,因此让我们创建一些代表我们的数据层的静态数据。我们将简单地创建一些对象字面量,并将这些存储在一个名为 db 的对象中,我们可以将其视为在解析器函数中查询的数据库的模拟。我们将通过将我们的假数据注入到上下文对象中,确保它在每个解析器中可用。
列表 2.9 代表我们的数据层的业务、评论和用户示例数据
const businesses = [
{
businessId: "b1",
name: "Missoula Public Library",
address: "301 E Main St, Missoula, MT 59802",
reviewIds: ["r1", "r2"],
},
{
businessId: "b2",
name: "San Mateo Public Library",
address: "55 W 3rd Ave, San Mateo, CA 94402",
reviewIds: ["r3"],
},
];
const reviews = [
{
reviewId: "r1",
stars: 3,
text: "Friendly staff. Interlibrary loan is super fast",
businessId: "b1",
userId: "u1",
},
{
reviewId: "r2",
stars: 4,
text: "Easy downtown access, lots of free parking",
businessId: "b1",
userId: "u2",
},
{
reviewId: "r3",
stars: 5,
text: "Lots of glass and sunlight for reading.",
businessId: "b1",
userId: "u1",
},
];
const users = [
{
userId: "u1",
name: "Will",
reviewIds: ["r1", "r2"],
},
{
userId: "u2",
name: "Bob",
reviewIds: ["r3"],
},
];
const db = { businesses, reviews, users };
我们假设这些对象会像传递数据库连接对象一样传递到上下文对象中的解析器。
模拟 GraphQL 数据
而不是创建一个静态对象作为示例,我们可以使用 Apollo Server 的模拟功能来创建返回模拟数据的解析器。这种模拟功能对于测试 UI 和前端代码以及使前端和后端团队能够并行工作非常有用。我们可以确信这些数据是相关的,因为它们使用模式内省和 GraphQL 类型系统来确保模拟数据与我们定义在 GraphQL 类型定义中的形式相同。有关使用 Apollo Server 进行数据模拟的更多信息,请参阅文档:mng.bz/Pnlw。
根据我们的 GraphQL 类型定义,我们的初始解析器映射将如下所示。
列表 2.10 解析器映射骨架
const resolvers = {
Query: {
allBusinesses: (obj, args, context, info) => {
// TODO: return all businesses
},
businessBySearchTerm: (obj, args, context, info) => {
// TODO: search businesses for matching search term
}
},
Business: {
reviews: (obj, args, context, info) => {
// TODO: find reviews for a particular business
},
avgStars: (obj, args, context, info) => {
// TODO: calculate average stars aggregation
}
},
Review: {
user: (obj, args, context, info) => {
// TODO: find the user who wrote this review
},
business: (obj, args, context, info) => {
// TODO: find the business for this review
}
},
User: {
reviews: (obj, args, context, info) => {
// TODO: find all reviews written by a user
}
}
};
注意,我们不需要麻烦实现那些将由默认解析器处理的平凡解析器,例如 Business.name。让我们首先实现 allBusinesses 解析器(见列表 2.11)。这个解析器简单地从我们的数据层获取所有企业并返回它们,无需担心分页或排序。记住,在这个例子中,我们的数据层由每个解析器通过上下文对象公开的嵌套对象组成。(我们将在下一节中介绍如何实际注入此对象。)
列表 2.11 根级别解析器:allBusinesses
Query: { ❶
allBusinesses: (obj, args, context, info) => { ❷
return context.db.businesses; ❸
}
}
❶ 我们正在解析 Query 类型上的字段,所以这个解析器是我们解析器映射中 Query 键下的一个函数。
❷ 这里我们看到解析器函数的标准签名。obj 在这里将是空的,因为这是根级别解析器——还没有解析任何数据。args 也将是一个空对象,因为这个字段不接受任何参数。然而,context 将包含我们的静态数据对象。
❸ 我们通过上下文对象返回 db 对象上的 businesses 数组。
现在我们已经实现了第一个解析器函数,让我们看看如何结合我们的 GraphQL 类型定义和解析器来使用 Apollo Server 提供 GraphQL API。
2.3 使用 Apollo Server 结合类型定义和解析器
我们已经创建了我们的 GraphQL 类型定义和第一个解析器函数来查询我们的数据层,所以现在是时候将它们组合起来,并使用 Apollo Server 启动一个 GraphQL 服务器。Apollo Server 作为一个 npm 包可用,所以让我们用 npm 安装它:
npm install apollo-server graphql
2.3.1 使用 Apollo Server
在下一个列表中,我们创建 index.js,它将使用我们之前定义的类型定义和解析器以及 Apollo Server 来根据这些类型定义提供 GraphQL API。
列表 2.12 index.js 使用 Apollo Server 创建的 GraphQL 服务器
const ApolloServer = require('apollo-server'); ❶
const server = new ApolloServer({ ❷
typeDefs, ❸
resolvers, ❹
context: { db } ❺
});
server.listen().then(({ url }) => { ❻
console.log(`Server ready at ${url}`);
});
❶ 从我们刚刚安装的包中导入 ApolloServer。
❷ 创建一个服务器实例。
❸ 我们传递了我们上面定义的类型定义。
❹ 我们之前已经定义了解析器。
❺ db 是我们模拟的数据对象,并注入到上下文中。此对象将在每个解析器中可用。
❻ 这里我们启动服务器并开始监听传入的 GraphQL 请求。
2.3.2 Apollo Studio
默认情况下,Apollo Server 将为 POST 请求提供 GraphQL 端点,但对于来自同一 URL(在我们的例子中是 http://localhost:4000)的 GET 请求,Apollo Server 将重定向到浏览器中的 Apollo Studio 工具(见图 2.3)。

图 2.3 使用 Apollo Studio 进行查询
Apollo Studio 可用于查看 GraphQL API 的类型定义和模式,以及执行查询和突变并查看结果。到目前为止,我们唯一实现的查询字段解析器是 allBusinesses。让我们通过在 Apollo Studio 中运行以下查询来测试它:
{
allBusinesses {
name
}
}
这将导致调用 Query 字段解析器 allBusinesses,它将返回我们模拟数据库中的企业对象。然后,由于我们请求的是 Business 类型上的名称字段,将使用名称的默认解析器来返回每个企业的名称(见图 2.4)。

图 2.4 使用 Apollo Studio 的简单查询
如果你通过在 Apollo Studio 中调整查询进行实验,你很快就会看到我们需要实现剩余的解析器。让我们回到我们的解析器映射骨架,并完成解析器的实现。
2.3.3 实现解析器
我们创建了一些假数据来工作,并编写了我们的第一个解析器,allBusinesses,它简单地返回我们模拟数据库中的所有企业。现在,是时候实现更复杂的解析器了,比如 businessBySearchTerm,这将允许我们根据用户的搜索词过滤结果,以及数组解析器,如 Business.reviews,它将负责解析企业和评论之间的连接。
根级解析器:businessBySearchTerm
根级解析器是与我们的 API 入口点相对应的解析器。回顾我们的 GraphQL 类型定义,我们有以下入口点,如 Query 类型中定义的:
type Query {
allBusinesses: [Business!]!
businessBySearchTerm(
search: String!
first: Int = 10
offset: Int = 0
orderBy: BusinessOrdering = name_asc
): [Business!]!
userById(id: ID!): User
}
enum BusinessOrdering {
name_asc
name_desc
}
我们已经在上一节中实现了 allBusinesses 根级解析器。那个例子相当简单,因为我们不需要处理任何参数。现在让我们实现 businessesBySearchTerm 解析器,它接受一个搜索字符串、排序和分页参数,如下一列表所示。
列表 2.13 根级解析器:businessBySearchTerm
businessBySearchTerm: (obj, args, context, info) => { ❶
const compare = (a, b) => { ❷
const [orderField, order] = args.orderBy.split("_");
const left = a[orderField],
right = b[orderField];
if (left < right) {
return order === "asc" ? -1 : 1;
} else if (left > right) {
return order === "desc" ? -1 : 1;
} else {
return 0;
}
};
return context.db.businesses
.filter(v => {
return v["name"].indexOf(args.search) !== -1; ❸
})
.slice(args.offset, args.first) ❹
.sort(compare); ❺
}
❶ 由于这是一个根级解析器,obj 参数将为空,但我们将利用 args 对象,它将包含 GraphQL 查询参数——在这种情况下,orderBy、search、first 和 offset。由于我们的类型定义使用了 orderBy、first 和 offset 的默认值,并且 search 是一个必填字段,我们可以确信这些值将被定义。
❷ 在这里,我们定义了一个比较函数来用于排序,利用我们的 BusinessOrdering 枚举。我们将 orderBy 值根据下划线分割以识别字段名和排序方向(例如,name_asc 表示我们将按名称字段升序排序)。
❸ 我们根据包含在 GraphQL 查询中传递的搜索词的名称属性过滤企业。
❹ 我们使用 slice 函数来实现 first/offset 分页。
❺ 在这里,我们将我们的比较函数应用于结果,根据 orderBy 参数指定的值进行排序。如果没有指定 orderBy 参数,则将使用 name_asc,因为它在 GraphQL 类型定义中指定为默认值。
数组解析器:Business.reviews
我们之前的根级解析器返回了对象的数组,但我们也可以从非根级解析器返回对象的数组,如果字段是列表字段(例如,Business.reviews,它是 Review 类型,或者 Review 对象的列表)。在非根级解析器中,obj 参数将包括之前解析的数据。例如,如果我们首先执行 Query.businessBySearchTerm 解析器来获取企业,该解析器的结果将被传递给 Business.reviews 解析器。让我们利用这些数据来实现下一个列表中的 Business.reviews 解析器。
列表 2.14 根级解析器
Business: {
reviews: (obj, args, context, info) => {
return obj.reviewIds.map(v => {
return context.db.reviews.find(review => {
return review.reviewId === v;
});
});
},
}
标量解析器:Business.avgStars
我们讨论了默认解析器,它只是返回与 obj 参数中字段同名的对象属性,但有些情况下我们需要实现返回标量值的解析器,而默认解析器没有被使用。聚合就是一个很好的例子。Business.avgStars 字段是一个聚合字段,我们需要找到特定企业的所有评论,然后计算这些评论的星级平均值,返回一个单一的标量值。
列表 2.15 标量字段解析器
avgStars: (obj, args, context, info) => {
const reviews = obj.reviewIds.map(v => {
return context.db.reviews.find(review => {
return review.reviewId === v;
});
});
return (
reviews.reduce((acc, review) => {
return acc + review.stars;
}, 0) / reviews.length
);
}
对象解析器:Review.user
到目前为止,我们已经看到了返回标量和数组的解析器;现在,让我们实现一个返回单个对象的解析器,如下面的列表所示。在我们的类型定义中,一个评论与一个用户相关联,这意味着 Review.user 是一个对象字段,而不是列表字段。
列表 2.16 对象字段解析器解析器
Review: {
user: (obj, args, context, info) => {
return context.db.users.find(user => {
return user.userId === obj.userId;
});
}
}
在最后一个解析器实现之后,我们现在可以使用 Apollo Studio 返回查询我们的 GraphQL API。
2.3.4 使用 Apollo Studio 进行查询
现在我们已经实现了其余的解析器函数,让我们通过在网页浏览器中打开 http://localhost:4000/返回 Apollo Studio。首先,让我们使用搜索词“Library”搜索企业(见图 2.5)。

图 2.5 通过搜索词查询企业
现在让我们检索与我们的搜索结果匹配的每个企业的评论(见图 2.6)。

图 2.6 向查询添加企业评论
你可以在本书的 GitHub 仓库中找到完成示例 GraphQL API 的代码:mng.bz/J2jo。在下一章中,我们将介绍 Neo4j 图形数据库,并学习如何使用 Cypher 查询语言建模、存储和查询数据。
2.4 练习
-
考虑我们业务评论应用中我们没有实现的一些其他要求。你能编写 GraphQL 查询来满足这些要求吗?结果是什么?
-
我们 API 中哪些其他字段应该使用分页和排序?更新类型定义以包括适当的排序和分页字段,并更新解析器以处理这些分页参数。
-
实现 usersById 的根级解析器。
-
我们的示例 GraphQL API 明显缺少业务类别。更新示例数据、GraphQL 类型定义和解析器,以利用业务类别。考虑在 API 中如何建模类别,鉴于按类别搜索已被明确标识为业务需求。
你可以在本书的 GitHub 仓库中找到练习的解决方案以及代码示例:github.com/johnymontana/fullstack-graphql-book。
摘要
-
可以使用应用程序的业务需求来处理 API 数据建模。以这种方式完成时——绘制数据的心理模型——会创建一个图,节点是实体,关系将它们连接起来。
-
GraphQL 类型定义用于定义 GraphQL API 的数据、关系和入口点。类型定义可以使用模式定义语言(Schema Definition Language,SDL)来定义,SDL 是一种与语言无关的表示法,用于指定 GraphQL 类型。除了内置的 GraphQL 类型(ID、String、Int、Float、Bool 等)之外,还可以定义自定义的用户定义标量和类型。
-
解析器是包含 GraphQL API 数据获取逻辑的函数。解析器根据 GraphQL 查询中请求的字段以嵌套方式调用。每个解析器都会传递一个上下文对象,该对象可以包含数据库连接或其他辅助对象,用于访问数据。
-
Apollo Server 用于将 GraphQL 类型定义和解析器组合成一个可执行的 GraphQL 模式,并服务于 GraphQL API。
-
Apollo Studio 可以用于查看 GraphQL API 的模式,以及执行查询并查看结果。
3 数据库中的图
本章涵盖
-
专注于 Neo4j 的图数据库简介
-
属性图数据模型
-
使用 Cypher 查询语言在 Neo4j 中创建和查询数据
-
使用 Neo4j 客户端驱动程序,特别是 JavaScript Node.js 驱动程序
基本上,图数据库是一个允许用户以图的形式建模、存储和查询数据的软件工具。在数据库级别使用图通常对建模复杂连接数据更为直观,并且在处理需要遍历许多连接实体的复杂查询时可能性能更佳。
在本章中,我们开始使用上一章的业务需求创建属性图数据模型的过程,并将其与上一章中创建的 GraphQL 模式进行比较。然后,我们探讨 Cypher 查询语言,重点关注如何编写 Cypher 查询以满足我们应用程序的需求。在这个过程中,我们展示了如何安装 Neo4j,使用 Neo4j Desktop 在本地创建新的 Neo4j 项目,以及如何使用 Neo4j Browser 查询 Neo4j 并可视化结果。最后,我们展示了如何使用 Neo4j JavaScript 客户端驱动程序创建一个简单的 Node.js 应用程序,该应用程序查询 Neo4j。
3.1 Neo4j 概述
Neo4j 是一个使用属性图模型建模数据和使用 Cypher 查询语言与数据库交互的原生图数据库。Neo4j 是一个具有完整 ACID 保证的事务型数据库,这对于操作工作负载是必要的,也可以用于图分析。像 Neo4j 这样的图数据库针对处理高度连接的数据和遍历图(在关系型数据库中相当于多个 JOIN 操作)的查询进行了优化,因此是 GraphQL API 的完美后端,这些 API 描述了连接数据,并且通常会导致复杂、嵌套的查询。Neo4j 是开源的,可以从neo4j.com/download下载。
在本章中,我们将学习如何在 Neo4j 中创建和查询数据时使用 Neo4j Desktop 和 Neo4j Browser,但首先,让我们深入了解 Neo4j 使用的属性图模型,并看看它与我们之前章节中审查的 GraphQL API 模型之间的关系。
3.2 使用 Neo4j 进行图数据建模
与使用表或文档来建模数据的其他数据库不同,像 Neo4j 这样的图数据库将数据建模、存储并允许用户以图的形式查询数据。在图中,节点是实体,关系将它们连接起来。在关系型数据库中,我们使用外键和连接表来表示关系。在文档数据库中,我们使用 ID 引用其他实体,甚至可以在单个文档中非规范化并嵌入其他实体(参见图 3.1)。

图 3.1 比较关系型、文档型和图数据模型
与数据库一起工作时,第一步是确定将要使用的数据模型。在我们的案例中,我们的数据模型将由我们在上一章中定义的业务需求驱动——与商业、用户和评论一起工作。回顾上一章第一部分中列出的需求以进行复习。让我们根据那些需求和我们对该领域的了解来创建一个白板模型。
白板模型
我们将使用术语白板模型来指代在首次对一个领域进行推理时通常创建的图,这通常是在白板上绘制的实体及其关系的图(参见以下图)。

构建属性图模型:白板模型
我们如何将这个思维模型从白板模型转换为数据库使用的物理数据模型?在其他系统中,这可能涉及创建实体-关系(ER)图或定义数据库的模式。据说 Neo4j 是可选模式的。虽然我们可以创建数据库约束来强制约束,例如属性唯一性,但我们也可以在没有这些约束或模式的情况下使用 Neo4j。但第一步是使用属性图数据模型定义一个模型,这是 Neo4j 和其他图数据库使用的模型。让我们将之前展示的简单白板模型转换为可以在数据库中使用的属性图模型。
3.2.1 属性图模型
在第一章中,我们简要概述了属性图数据模型。接下来,我们将介绍将我们的白板模型转换为数据库使用的属性图模型的过程。
属性图数据模型
属性图模型由以下部分组成
-
节点标签——节点是我们数据模型中的实体或对象。节点可以有一个或多个标签,这些标签描述了节点是如何分组的(想想实体类型)。
-
关系——关系连接两个节点,具有单一类型和方向。
-
属性——这些是存储在节点或关系上的任意键值对属性。
节点标签
节点代表白板模型中的对象。每个节点可以有一个或多个标签,这是一种分组节点的方式。向白板模型添加节点标签通常是一个简单的过程,因为在白板过程中已经定义了一些分组。在这里,我们将用于引用我们的节点的描述符正式化为节点标签(稍后,我们将添加节点别名和多个标签,因此我们使用冒号作为分隔符来表示标签;参见图 3.2)。

图 3.2 构建属性图模型:节点标签
图数据模型绘图工具
有许多工具可用于图形化图数据模型。在这本书的整个过程中,我们使用 Arrows 工具,这是一个简单的基于 Web 的应用程序,允许创建图数据模型。Arrows 可在网上找到:arrows.app。
Arrows 用户界面是最简化的,它围绕创建属性图数据模型而设计:
-
使用(+ 节点)按钮或从现有节点拖出创建新节点。
-
将关系从节点的光环中拖出,要么拖到空白空间以创建新节点,要么拖到现有节点上方以连接它们。
-
双击节点和关系以编辑它们,设置名称,并设置属性(使用键:值语法)。
-
您可以导出为 PNG、SVG 和其他格式(包括 GraphQL 类型定义)。
用于节点标签的大小写约定是 PascalCase。有关命名约定的更多示例,请参阅 Cypher 风格指南:neo4j.com/developer/cypher/style-guide/。节点可以有多个标签,并允许我们表示类型层次结构、不同上下文中的角色,甚至多租户。
关系
一旦我们确定了节点标签,下一步就是确定数据模型中的关系。关系有一个单一的类型和方向,但可以以任一方向查询(见下侧栏中的图)。
处理无向关系
虽然每个关系只有一个方向,但我们在查询时可以通过在 Cypher 查询中不指定方向来将关系视为无向的。

构建属性图模型:关系类型
命名关系的良好指南是,从节点沿关系到另一个节点的遍历应读作一个多少有些可理解的句子(例如,“用户撰写评论”或“评论评论业务”)。您可以在Cypher 风格指南中了解更多关于命名和约定的最佳实践:neo4j.com/developer/cypher-style-guide。
属性
属性是存储在节点和关系上的任意键值对。这些是我们数据模型中实体的属性或字段。在这里,我们在用户节点上存储 userId 和 name 作为字符串属性,以及在评论和业务节点上的其他相关属性。
属性类型
Neo4j 支持以下属性类型(见下图):
|
- 字符串
|
- 日期、日期时间和其他时间类型
|
|
- 浮点数
|
- 点
|
|
- 长度
|
- 之前类型的列表
|

构建属性图模型:属性
3.2.2 数据库约束和索引
现在我们已经定义了我们的数据模型,我们如何在数据库中利用它呢?如前所述,与其他数据库不同,这些数据库在插入数据之前要求我们定义完整的模式,Neo4j 据说具有可选模式,并且不需要使用预定义的模式。相反,我们可以定义数据库约束,以确保数据遵循领域规则。我们可以创建唯一性约束,确保属性值在节点标签之间是唯一的(例如,保证没有两个用户有重复的 ID 属性值),属性存在约束(例如,确保在创建或修改节点或关系时存在一组属性),以及节点键约束,这与复合键类似,并使用多个属性创建约束。
数据库约束由索引支持,这些索引也可以单独创建。在图数据库中,索引用于找到遍历的起点,而不是用于遍历图。我们将在下一节中更详细地介绍数据库约束和索引,该节介绍了 Cypher。
3.3 数据建模考虑因素
图数据建模可能是一个迭代过程。一般来说,这是遵循的过程:
-
实体是什么?它们是如何分组的?这些成为节点和节点标签。
-
这些实体是如何连接的?这些成为关系。
-
节点和关系的属性是什么?这些成为属性。
-
你能识别出回答你问题的图遍历吗?这些将成为 Cypher 查询。如果不能,请迭代图模型。
然而,通常有一些细微差别没有被这种通用方法涵盖。我们将在下一节中解决一些常见的图数据建模问题。
3.3.1 节点与属性
有时候,确定一个值应该建模为节点还是节点上的属性可能会有困难。这里的一个好指南是问自己这样的问题,“如果这个值是一个节点,我能否通过遍历这个值发现一些有用的信息?”如果答案是肯定的,那么它应该被建模为节点;如果不是,那么将其视为属性。例如,考虑如果我们向我们的模型添加业务类别。找到具有重叠类别的业务可能是有用的,并且如果类别被建模为节点,那么发现它可能更容易。另一方面,考虑一个商业地址。如果我们将地址建模为节点而不是属性,那么遍历地址节点会有用吗?很可能是没有用,我们应该将地址建模为属性。
3.3.2 节点与关系
在我们有一份数据似乎连接了两个节点的情况下(例如,一个由用户撰写的商业评论),我们应该将此数据建模为一个节点还是一个关系?乍一看,我们可能只想创建一个连接用户和商业的 REVIEWS 关系,将评论信息(如星级和文本)作为关系属性存储。然而,我们可能希望通过某些自然语言处理技术从评论中提取数据,例如提到的关键词,并将提取的数据连接到评论。或者,我们可能希望将评论节点作为遍历查询的起点。这些都是我们可能选择将此数据建模为中间节点而不是关系的原因的两个例子。
3.3.3 索引
索引在图数据库中用于查找遍历的起点,而不是在遍历过程中。这是像 Neo4j 这样的图数据库的一个重要性能特征,称为无索引邻接。只为将用于查找遍历起点的属性创建索引,例如用户名或业务 ID。
3.3.4 关系类型的特定性
关系类型是分组关系的一种方式,应该传达足够的信息,以便清楚地表明两个节点是如何连接的,而不需要过于具体。例如,REVIEWS 是一个很好的关系类型,它连接了 Review 和 Business 节点。REVIEW_WRITTEN_BY_BOB_FOR_PIZZA 是一个过于具体的关系类型;用户和餐厅的名称存储在其他地方,不需要在关系类型中重复。
3.3.5 选择关系方向
属性图模型中的所有关系都有一个单一的方向,但可以双向查询或不考虑方向进行查询。不需要创建重复的关系来模拟双向性。通常,你应该选择允许一致读取数据模型的关系方向。
3.4 工具:Neo4j 桌面
现在我们已经了解了属性图数据模型,并且定义了我们用于业务审查应用的模型的一个简单版本,让我们创建一个 Neo4j 数据库并开始执行一些 Cypher 查询。为此,我们将利用 Neo4j Desktop,它是 Neo4j 的任务控制中心(见图 3.3)。在 Neo4j Desktop 中,我们可以创建项目和 Neo4j 的实例。我们可以在 Neo4j Desktop 中启动、停止和配置 Neo4j 数据库实例,以及安装可选的数据库插件,例如图数据科学和 APOC(Neo4j 的数据库过程标准库)。Neo4j Desktop 还包括安装图应用的功能,这些应用在 Neo4j Desktop 中运行并连接到活动的 Neo4j 实例。默认安装的 Neo4j 浏览器就是这些图应用中的一个例子。有关其他图应用的示例,请参阅install.graphapp.io。

图 3.3 Neo4j Desktop:Neo4j 的任务控制台
如果您尚未下载 Neo4j Desktop,请现在前往 neo4j.com/download 进行下载。Neo4j Desktop 可供 Mac、Windows 和 Linux 系统下载。
下载并安装 Neo4j 后,通过选择 添加图 创建一个新的本地 Neo4j 实例。您将被提示输入数据库名和密码。密码可以是您想要的任何内容;只需确保您能记住它以备后用。创建图后,点击 启动 按钮激活它;然后我们将使用 Neo4j 浏览器来查询我们刚刚创建的数据库。
3.5 工具:Neo4j 浏览器
Neo4j 浏览器是一个 Neo4j 的查询工作台,允许开发者通过编写 Cypher 查询并与结果可视化来与数据库交互(见图 3.4)。通过在 Neo4j Desktop 的 应用程序 部分选择其应用程序图标来启动 Neo4j 浏览器。

图 3.4 Neo4j 浏览器:Cypher 和 Neo4j 的查询工作台
Neo4j 浏览器允许我们针对 Neo4j 运行 Cypher 查询并处理结果。在深入研究 Neo4j 浏览器之前,让我们回顾一下 Cypher 查询语言。
3.6 Cypher
Cypher 是一种声明式图查询语言,具有一些可能来自 SQL 的特性。实际上,将 Cypher 视为 图上的 SQL 是一个很好的思考方式。Cypher 利用模式匹配,使用类似 ASCII 艺术的符号来描述图模式。在本节中,我们将查看一些基本的 Cypher 功能,用于创建和查询数据,包括使用谓词和聚合。我们只涵盖 Cypher 语言的一小部分;有关全面参考,请参阅 Cypher refcard r.neo4j.com/refcard,或查阅 neo4j.com/docs/cypher-manual/current/ 的文档。
3.6.1 模式匹配
作为一种声明式图查询语言,模式匹配是 Cypher 中用于创建和查询数据的基本工具。与告诉数据库我们希望它执行的确切操作(命令式方法)不同,使用 Cypher,我们描述我们正在寻找或想要创建的模式,数据库负责以最有效的方式确定满足该语句的操作序列。使用类似 ASCII 艺术的符号(也称为模式)描述图模式是这种声明式功能的核心。
节点
节点定义在括号 () 内。可选地,我们可以指定节点标签(使用冒号作为分隔符),例如,(:User)。
关系
关系定义在方括号 [] 内。可选地,我们可以指定类型和方向:(:Review)-[:REVIEWS]->(:Business)。
3.6.2 属性
属性被指定为花括号‘{}’内的逗号分隔的名称:值对,例如企业或用户的名称。
别名
图元素可以绑定到别名或变量,这些别名或变量可以在查询的后续部分中引用。例如,给定此模式(r:Review)-[a:REVIEWS]->(b:Business),别名 r 被绑定到图中匹配的评论节点,a 被绑定到 REVIEWS 关系,b 被绑定到业务节点。这些变量仅在它们被使用的 Cypher 查询的范围内有效。随着我们介绍 Cypher 命令来创建和查询数据,这些数据与我们在本章中构建的数据模型相匹配,请跟随以下 Cypher 查询在 Neo4j 浏览器中运行。
3.6.3 CREATE
我们需要做的第一件事是使用 CREATE 命令在我们的数据库中创建一些数据。首先,为了在图中创建一个单独的业务节点,我们以 CREATE 命令开始,后跟一个描述要创建的数据的图模式:
CREATE ❶
(b ❷
:Business ❸
{name: ❹
"Bob's Pizza"
}) ❺
❶ CREATE 命令用于在数据库中创建数据。
❷ b 成为一个别名,可以在查询的后续部分中引用此节点。
❸ 我们指定要创建的节点的标签。
❹ 名称是业务节点的属性,用于指定其值。
❺ 这是我们图模式——在这种情况下,它是一个由括号标识的节点。
在 Neo4j 浏览器中运行的结果显示以下内容:
Added 1 label, created 1 node, set 1 property, completed after 4 ms.
这意味着我们在数据库中创建了一个带有新标签的节点,并设置了一个节点属性值——在这种情况下,是带有 Business 标签的节点的名称属性。或者,我们也可以使用 SET 命令。以下内容是等效的:
CREATE (b:Business)
SET b.name = "Bob's Pizza"
为了可视化正在创建的数据,我们可以在 Cypher 语句中添加一个 RETURN 子句,它将在 Neo4j 浏览器中以图形可视化的形式呈现。运行
CREATE (b:Business)
SET b.name = "Bob's Pizza"
RETURN b
在 Neo4j 浏览器中显示的图形如图 3.5 所示。

图 3.5 使用 Cypher 和 Neo4j 浏览器创建节点
我们可以在 CREATE 语句中指定更复杂的模式,例如关系。注意使用方括号 <-[]- 定义关系的 ASCII 艺术表示法,包括关系的方向(见图 3.6):
CREATE (b:Business)<-[:REVIEWS]-(r:Review)
SET b.name = "Bob's Pizza",
r.stars = 4,
r.text = "Great pizza"
RETURN b, r

图 3.6 创建两个节点和一个关系
我们可以使用 Cypher 创建任意复杂的图模式。在这里,我们还在 CREATE 语句中指定了与评论相连的用户(见图 3.7):
CREATE p=(b:Business)<-[:REVIEWS]-(r:Review)<-[:WROTE]-(u:User)
SET b.name = "Bob's Pizza",
r.stars = 4,
r.text = "Great pizza",
u.name = "Willie"
RETURN p

图 3.7 创建子图
注意,在这个 Cypher 查询中,我们将整个图模式绑定到一个变量 p 并返回该变量。在这种情况下,p 获得了正在创建的整个路径(节点和关系的组合)的值。
到目前为止,我们只返回了每个 Cypher 语句中创建的数据。我们如何查询和可视化数据库中的其余数据?为了做到这一点,我们使用 MATCH 关键字。让我们匹配数据库中的所有节点并将它们返回:
MATCH (a) RETURN a
我们应该看到一个看起来像图 3.8 的图。

图 3.8 创建了重复节点
立刻我们可以看到有问题;我们在图中创建了大量的重复节点!让我们删除数据库中的所有数据:
MATCH (a) DETACH DELETE a
这将匹配所有节点并删除节点及其任何关系。我们应该看到输出告诉我们我们删除了什么:
Deleted 11 nodes, deleted 4 relationships, completed after 23 ms.
现在,让我们重新开始,看看如何在不创建重复数据的情况下在数据库中创建数据。
3.6.4 MERGE
为了避免创建重复数据,我们可以使用 MERGE 命令。MERGE 充当 upsert,仅在数据已存在于数据库中时才创建模式中指定的数据。当使用 MERGE 时,最好在标识唯一性的属性上创建唯一性约束——通常是一个 ID 字段。通过创建唯一性约束,这也会在数据库中创建索引。请参阅下一节中创建唯一性约束的示例。对于简单的示例,在没有这些约束的情况下使用 MERGE 是可以的,所以让我们回顾一下创建业务、评论和用户的 Cypher 语句,但这次我们将使用 MERGE:
MERGE (b:Business {name: "Bob's Pizza"})
MERGE (r:Review {stars: 4, text: "Great pizza!"})
MERGE (u:User {name: "Willie"})
MERGE (b)<-[:REVIEWS]-(r)<-[:WROTE]-(u)
RETURN *
图 3.9 显示了使用我们创建的数据生成的结果图可视化。


这个 Cypher 语句的结果看起来与使用 CREATE 的上一版本相同;然而,有一个重要的区别:这个查询现在是幂等的。无论我们运行查询多少次,我们都不会创建重复的节点,因为我们使用 MERGE 而不是 CREATE。我们将在下一章再次回顾 MERGE,届时我们将展示如何通过我们的 GraphQL API 在数据库中创建数据。
Neo4j 中的索引
理解在像 Neo4j 这样的图数据库中索引的使用方式很重要。我们之前提到 Neo4j 有一个名为 index-free adjacency 的属性,这意味着从一个节点遍历到任何其他连接的节点不需要索引查找。那么在 Neo4j 中索引是如何使用的呢?索引仅用于查找遍历的起点,与关系数据库不同,关系数据库使用索引来计算集合(表)重叠,图数据库只是在文件存储中计算偏移量,本质上是在追逐指针,我们知道计算机在快速执行这项任务时非常出色。
3.6.5 使用 Cypher 定义数据库约束
我们在构建数据模型时,在章节中提到了数据库约束以及它们如何与(可选地)定义 Neo4j 中的模式相关联。接下来,我们将查看创建与我们的数据模型相关的数据库约束的 Cypher 语法。
唯一性约束
CREATE CONSTRAINT ON (b:Business) ASSERT b.businessId IS UNIQUE;
属性存在约束
CREATE CONSTRAINT ON (b:Business) ASSERT b.businessId IS NOT NULL
节点键约束
CREATE CONSTRAINT ON (p:Person) ASSERT (p.firstName, p.lastName) IS NODE KEY;
注意,如果您数据库中仍然有与这些约束冲突的重复数据,那么您将收到一个错误消息,表明无法创建约束。在这种情况下,您可能想要删除数据库中的所有数据,然后再次尝试创建约束。
3.6.6 MATCH
现在我们已经在图中创建了我们的数据,我们可以开始编写查询来处理我们应用程序的一些业务需求。MATCH 子句与 CREATE 类似,因为它接受一个图模式;然而,我们还可以使用 WHERE 子句来指定要在模式中应用的谓词。MATCH 语句用于在数据库中查找与指定图模式匹配的数据。例如,这里我们搜索数据库中所有的用户节点:
MATCH (u:User)
RETURN u
当然,我们可以在 MATCH 子句中使用更复杂的图模式:
MATCH (u:User)-[:WROTE]->(r:Review)-[:REVIEWS]->(b:Business)
RETURN u, r, b
这个查询匹配所有撰写过任何企业评论的用户。如果我们只想查询特定企业的评论呢?在这种情况下,我们需要在我们的查询中引入谓词,使用 WHERE 子句。
WHERE
WHERE 子句可以用于向 MATCH 语句添加谓词。为了搜索名为 Bob’s Pizza 的企业,我们可以编写以下 Cypher 语句:
MATCH (b:Business)
WHERE b.name = "Bob's Pizza"
RETURN b
对于等价比较,有一个等效的简写符号可用:
MATCH (b:Business {name: "Bob's Pizza"})
RETURN b
3.6.7 聚合
我们经常想要对一组结果进行聚合计算。例如,我们可能想要计算 Bob’s Pizza 所有评论的平均评分。为此,我们使用 avg 聚合函数:
MATCH (b:Business {name: "Bob's Pizza"})<-[:REVIEWS]-(r:Review)
RETURN avg(r.stars)
现在,在 Neo4j 浏览器中,我们看到的不是图可视化,而是一个表格,显示了我们的查询结果,因为我们不是返回图数据,而是表格数据:

如果我们想要计算每个企业的平均评分呢?在 SQL 中,我们可能会使用 GROUP BY 运算符按企业名称对评论进行分组,并在每个组中计算聚合,但在 Cypher 中没有 GROUP BY 运算符。相反,在 Cypher 中,当返回聚合函数的结果以及非聚合结果时,会自动应用隐式分组操作。例如,我们执行以下操作来使用 Cypher 计算每个企业的平均评分:
MATCH (b:Business)<-[:REVIEWS]-(r:Review)
RETURN b.name, avg(r.stars)
结果表如下:

当然,这并不令人兴奋,因为我们只有一个企业和一条评论。在本章的练习部分,我们将处理更大的数据集。
3.7 使用 Neo4j 客户端驱动程序
到目前为止,我们一直在使用 Neo4j 浏览器来执行我们的 Cypher 查询,这对于即席分析或原型设计很有用;然而,通常我们希望创建一个以编程方式与数据库交互的应用程序。为此,我们使用 Neo4j 客户端驱动程序。这些客户端驱动程序在许多语言中可用,例如 JavaScript、Java、Python、.NET 和 Go,并且允许开发者使用与编程语言一致的 API 对 Neo4j 实例执行 Cypher 查询。在第一章中,我们看到了使用 Neo4j JavaScript 驱动程序执行 Cypher 查询和处理结果的示例。有关 Neo4j 客户端驱动程序的更多信息,请参阅驱动程序和语言指南:neo4j.com/developer/language-guides/。
在下一章中,我们将通过构建一个使用 Neo4j 作为数据层的 GraphQL API 来结合我们迄今为止讨论的概念和工具(GraphQL 和 Neo4j)。为此,我们将使用 Neo4j GraphQL 库,该库简化并加速了构建由 Neo4j 支持的 GraphQL API 的过程。
3.8 练习
要完成以下练习,首先在 Neo4j 浏览器中运行以下命令以加载一个包含嵌入式 Cypher 查询的浏览器指南::play grandstack。这个浏览器指南将指导您加载更大、更完整的商家和评论样本数据集。在 Neo4j 中运行查询加载数据后,继续以下练习:
-
运行命令 CALL db.schema.visualization()来检查数据模型。使用了哪些节点标签?有哪些关系类型?
-
编写一个 Cypher 查询以找到数据库中的所有用户。有多少用户?他们的名字是什么?
-
找出用户名为 Will 所写的所有评论。这位用户给出的平均评分是多少?
-
找出用户名为 Will 的所有评论过的商家。最常见的类别是什么?
-
编写一个查询,为用户名为 Will 的未评论过的商家推荐业务。
您可以在本书的 GitHub 仓库中找到练习的解决方案以及代码示例:github.com/johnymontana/fullstack-graphql-book。
摘要
-
图数据库允许用户将数据建模、存储和查询为图。
-
图数据库使用属性图数据模型,该模型由节点标签、关系和属性组成。
-
Cypher 查询语言是一种以模式匹配为中心的声明式图查询语言,用于查询图数据库,包括 Neo4j。
-
客户端驱动程序用于构建与 Neo4j 交互的应用程序。这些驱动程序使应用程序能够向数据库发送 Cypher 查询并处理结果。
4 Neo4j GraphQL 库
本章涵盖
-
审查在构建 GraphQL API 应用程序时出现的常见问题
-
介绍旨在解决这些常见问题的 GraphQL 数据库集成,包括 Neo4j GraphQL 库
-
构建一个基于 Neo4j 的 GraphQL 端点,利用 Neo4j GraphQL 库的功能,例如生成的查询和突变类型、过滤以及时间和空间数据类型
-
通过自定义逻辑扩展自动生成的 GraphQL API 的功能
-
从现有的 Neo4j 数据库中检查 GraphQL 模式
GraphQL 后端实现通常会遇到一系列问题,这些问题会负面影响性能和开发者的生产力。我们之前已经确定了一些这些问题(例如,n + 1 查询问题),在本章中,我们将更深入地探讨这些常见问题,并讨论如何通过数据库集成来减轻这些问题,这些集成使得构建基于数据库的高效 GraphQL API 变得更容易。
具体来说,我们探讨使用 Neo4j GraphQL 库,这是一个 Node.js 库,旨在与 JavaScript GraphQL 实现(如 Apollo Server)一起工作,用于构建基于 Neo4j 的 GraphQL API。Neo4j GraphQL 库允许我们从 GraphQL 类型定义生成一个完全功能的 GraphQL API,从 GraphQL 驱动数据库数据模型,并自动生成用于数据获取和突变的解析器,包括复杂的过滤、排序和分页。Neo4j GraphQL 库还使我们能够添加超出生成的创建、读取、更新和删除操作的定制逻辑。
在本章中,我们探讨使用 Neo4j GraphQL 库将我们的业务审查 GraphQL API 与 Neo4j 集成,为我们的 API 添加持久化层。在初步了解 Neo4j GraphQL 库时,我们专注于使用前一章中在 Neo4j 中使用的示例数据集查询现有数据。我们将在未来的章节中探索创建和更新数据(GraphQL 突变)以及更复杂的 GraphQL 查询语义,如接口和片段,这些概念将在构建用户界面的上下文中介绍。图 4.1 显示了 Neo4j GraphQL 库如何融入我们应用程序的更大架构。Neo4j GraphQL 库的目标是使构建基于 Neo4j 的 API 变得容易,而不是直接使用 GraphQL 与数据库交互。

图 4.1 Neo4j GraphQL 库帮助构建客户端和数据库之间的 API 层。
4.1 常见的 GraphQL 问题
在构建 GraphQL API 时,开发者通常面临两种类型的问题:性能不佳和编写大量样板代码,这可能会影响开发者的生产力。
4.1.1 表现不佳和 n + 1 查询问题
我们之前讨论了 n + 1 查询问题,这个问题可能出现在向数据层发送多个请求以解析单个 GraphQL 请求时。由于 GraphQL 解析器函数调用的嵌套方式,通常需要多个数据库请求来从数据层解析 GraphQL 查询。例如,想象一个查询,它通过名称搜索企业以及每个企业的所有评论。一个简单的实现会首先查询数据库以找到所有与搜索短语匹配的企业。然后,对于每个匹配的企业,它会向数据库发送额外的查询以找到该企业的任何评论。对数据库的每次查询都会产生网络和查询延迟,这可能会显著影响性能。
解决这个问题的常见方法是使用一种名为 DataLoader 的缓存和批量模式。这可以缓解一些性能问题;然而,它仍然可能需要多个数据库请求,并且不能在所有情况下使用,例如当不知道对象的 ID 时。
4.1.2 模板代码和开发者生产力
术语 boilerplate 用于描述编写来完成常见任务的重复性代码。在实现 GraphQL API 的情况下,通常需要在解析器中编写模板代码来实现数据获取逻辑,这可能会对开发者生产力产生负面影响,减慢开发速度,因为开发者需要为每种类型和字段编写简单的数据获取逻辑,而不是专注于应用程序的关键组件。在我们的业务审查应用程序的上下文中,这意味着需要手动编写在数据库中按名称查找企业的逻辑,查找与每个企业相关的评论以及与每个评论关联的每个用户,等等,直到我们手动定义了获取我们 GraphQL 模式所有字段的逻辑。
4.2 介绍 GraphQL 数据库集成
数据库的 GraphQL 集成是一类工具,它能够构建与数据库交互的 GraphQL API。这些工具数量有限,具有不同的功能集和集成级别——在这本书中,我们专注于 Neo4j GraphQL 库。然而,总的来说,这些 GraphQL 引擎 的目标是以一致的方式解决之前确定的常见 GraphQL 问题,通过减少模板代码和解决数据获取性能问题。
在本章的其余部分,我们专注于使用 Neo4j GraphQL 库构建一个由 Neo4j 支持的 GraphQL API。需要注意的是,我们的 GraphQL API 作为客户端和数据库之间的一个层——我们不希望客户端直接从数据库中查询。API 层扮演着重要的角色,它允许我们实现一些功能,例如授权和自定义逻辑,这些我们不想暴露给客户端。此外,由于 GraphQL 是一种 API 查询语言(而不是数据库查询语言),它缺乏我们在数据库查询语言中期望的许多语义(例如,投影)。
4.3 Neo4j GraphQL 库
Neo4j GraphQL 库是一个 Node.js 库,它可以与任何 JavaScript GraphQL 实现(如 GraphQL.js 和 Apollo Server)一起工作,旨在使基于 Neo4j 数据库构建 GraphQL API 尽可能简单。Neo4j GraphQL 库的两个主要功能是 GraphQL 模式生成 和 GraphQL 到 Cypher 的翻译。您可能希望参考项目文档,网址为 mng.bz/woNO。
GraphQL 到 Cypher 的翻译可以实现以下功能:
-
在运行时从任意 GraphQL 请求生成单个数据库查询
-
在生成的数据库查询中将 GraphQL 模式中定义的自定义逻辑作为子查询处理
GraphQL 模式生成过程将 GraphQL 类型定义转换为具有创建、读取、更新、删除(CRUD)操作的 GraphQL API。在 GraphQL 语义中,这包括向模式中添加查询和突变类型,并为这些查询和突变生成解析器。生成的 API 包括对过滤、排序、分页和本地数据库类型(如空间和时间类型)的支持,而无需在类型定义中手动定义这些类型。此过程的结果是一个可执行的 GraphQL 模式对象,然后可以将其传递给 GraphQL 服务器实现(如 Apollo Server),以提供 API 并处理网络和 GraphQL 执行过程。模式生成过程消除了编写数据获取和映射 GraphQL 和数据库模式样板代码的需求。
GraphQL 翻译过程在查询时发生。当接收到 GraphQL 请求时,生成一个单一的 Cypher 查询,该查询可以解析请求并发送到数据库。为任何任意的 GraphQL 操作生成单个数据库查询解决了 n + 1 查询问题,确保每个 GraphQL 请求只进行一次数据库往返。您可以在 dev.neo4j.com/graphql 找到 Neo4j GraphQL 库的文档和其他资源。
4.3.1 项目设置
在本章的其余部分,我们将通过为 Neo4j 创建一个新的 GraphQL API 来探索 Neo4j GraphQL 库的功能,使用来自上一章 练习 部分的业务和评论样本数据集。首先,我们将创建一个新的 Node.js 项目,该项目利用 Neo4j GraphQL 库和 Neo4j JavaScript 驱动程序从 Neo4j 获取数据。然后,我们将探索 Neo4j GraphQL 库的各种功能,随着我们的进展,我们将向我们的 GraphQL API 应用程序添加额外的代码。
Neo4j
首先,请确保 Neo4j 实例正在运行(您可以使用 Neo4j Desktop、Neo4j Sandbox 或 Neo4j Aura,但为了本章的目的,我们将假设您正在使用 Neo4j Desktop)。如果您使用 Neo4j Desktop,您需要安装 APOC 标准库插件。如果您使用 Neo4j Sandbox 或 Neo4j Aura,则无需担心此步骤;APOC 默认包含在这些服务中。要在 Neo4j Desktop 中安装 APOC,请点击项目中的插件选项卡,然后在可用插件列表中查找 APOC,并点击安装。接下来,通过运行 Cypher 语句(见列表 4.1)确保您的 Neo4j 数据库为空。
警告 此语句将删除您的 Neo4j 数据库中的所有数据,所以请确保这是您想要使用的实例,而不是您不想删除的数据库。
列表 4.1 清空我们的 Neo4j 数据库
MATCH (a) DETACH DELETE a;
现在,我们准备加载我们的示例数据集,如果您完成了上一章的练习部分,您可能已经完成了这一步。在 Neo4j 浏览器中运行以下命令(见图 4.2):
:play grandstack

图 4.2 将示例数据集加载到 Neo4j 中
这将在 Neo4j 中加载一个示例数据集,我们将以此为基础构建我们的 GraphQL API。在下一个列表中,我们可以通过运行一个命令来探索这些数据,该命令将给我们一个关于示例数据集中包含的数据的视觉概述(见图 4.3)。

图 4.3 我们示例数据集的图模式
列表 4.2 在 Neo4j 中可视化图模式
CALL db.schema.visualization();
我们可以看到我们有四个节点标签——Business、Review、Category 和 User——通过三种关系类型连接:IN_CATEGORY(将企业连接到它们所属的类别)、REVIEWS(将评论连接到企业)和 WROTE(将用户连接到他们所撰写的评论)。我们还可以查看存储在各个节点标签上的节点属性,如下一个列表所示。
列表 4.3 检查存储在 Neo4j 中的节点属性
CALL db.schema.nodeTypeProperties()
此命令将显示一个表格,显示属性名称、它们的类型以及是否在所有该标签的节点上找到:

我们将在构建描述此图的 GraphQL 类型定义时使用此表。
Node.js 应用
现在我们已经将我们的示例数据集加载到 Neo4j 数据库中,让我们为我们的 GraphQL API 设置一个新的 Node.js 项目:
npm init -y
我们还将安装我们的依赖项:
-
@neo4j/graphql——一个使使用 GraphQL 和 Neo4j 更容易的包。Neo4j GraphQL 库将 GraphQL 查询转换为单个 Cypher 查询,消除了在 GraphQL 解析器和批处理查询中编写查询的需要。它还通过@cypher 架构指令通过 GraphQL 公开 Cypher 查询语言。
-
apollo-server—Apollo Server 是一个开源的 GraphQL 服务器,它可以与任何使用 graphql.js 构建的 GraphQL 模式一起工作,包括 Neo4j GraphQL 库。它还提供了与许多不同的 Node.js 网络服务器框架或默认的 Express.js 一起工作的选项。
-
graphql—GraphQL.js 的 JavaScript 引用实现是 @neo4j/graphql 和 apollo-server 的 peer dependency。截至本文写作时,@neo4j/graphql 包与 graphql 15.x 版本兼容;因此,我们将安装最新的 v15.x 版本。
-
neo4j-driver—Neo4j 客户端驱动程序允许通过 Bolt 协议连接到本地或远程的 Neo4j 实例,并执行 Cypher 查询。Neo4j 驱动程序在许多不同的语言中可用,这里我们使用 Neo4j JavaScript 驱动程序:
npm i @neo4j/graphql graphql neo4j-driver apollo-server
现在,创建一个名为 index.js 的新文件,并在下一列表中添加一些初始代码。
列表 4.4 index.js:初始 GraphQL API 代码
const { ApolloServer } = require("apollo-server"); ❶
const neo4j = require("neo4j-driver");
const { Neo4jGraphQL } = require("@neo4j/graphql");
const driver = neo4j.driver( ❷
"bolt://localhost:7687",
neo4j.auth.basic("neo4j", "letmein")
);
const typeDefs = /* GraphQL */ ``; ❸
const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); ❹
neoSchema.getSchema().then((schema) => {
const server = new ApolloServer({ ❺
schema
});
server.listen().then(({url}) => {
console.log(`GraphQL server ready at ${url}`); ❻
});
});
❶ 导入我们的依赖项
❷ 创建到我们的 Neo4j 数据库的连接
❸ 这一行作为我们稍后要填写的 GraphQL 类型定义的占位符。
❹ 在实例化 Neo4jGraphQL 类时传递我们的 GraphQL 类型定义和数据库连接
❺ 我们生成的 GraphQL 模式被传递给 Apollo Server。
❻ 这里我们开始启动 GraphQL 服务器。
这是我们的 GraphQL API 应用程序代码的基本结构。构建 Neo4j 驱动程序实例时使用的凭据将取决于你是否使用 Neo4j Desktop、Neo4j Sandbox 或 Neo4j Aura,以及你最初选择的密码。请确保调整你的特定 Neo4j 实例的连接凭据。
如果我们现在尝试运行我们的 GraphQL API 应用程序,我们会很快看到一条错误消息,抱怨我们没有提供 GraphQL 类型定义。我们必须提供定义 GraphQL API 的 GraphQL 类型定义,因此下一步是填写我们的 GraphQL 类型定义。
4.3.2 从类型定义生成 GraphQL 模式
按照之前描述的 GraphQL 首选方法,我们的 GraphQL 类型定义将驱动 API 规范。在这种情况下,我们知道我们想要公开什么数据(我们加载到 Neo4j 中的示例数据集),因此我们可以参考之前显示的节点属性表,并在创建我们的 GraphQL 类型定义时应用一个简单的规则:节点标签成为类型,承担节点属性作为字段。我们还需要在我们的 GraphQL 类型定义中定义关系字段。让我们首先查看下一列表中的完整类型定义,然后探讨我们如何定义关系字段。
列表 4.5 index.js:GraphQL 类型定义
const typeDefs = /* GraphQL */ `
type Business {
businessId: ID!
name: String!
city: String!
state: String!
address: String!
location: Point!
reviews: [Review!]! @relationship(type: "REVIEWS", direction: IN)
categories: [Category!]!
@relationship(type: "IN_CATEGORY", direction: OUT)
}
type User {
userID: ID!
name: String!
reviews: [Review!]! @relationship(type: "WROTE", direction: OUT)
}
type Review {
reviewId: ID!
stars: Float!
date: Date!
text: String
user: User! @relationship(type: "WROTE", direction: IN)
business: Business! @relationship(type: "REVIEWS", direction: OUT)
}
type Category {
name: String!
businesses: [Business!]!
@relationship(type: "IN_CATEGORY", direction: IN)
}
`;
@relationship GraphQL 模式指令
在 Neo4j 使用的属性图模型中,每个关系都有一个方向和类型。为了在 GraphQL 中表示这一点,我们使用了 GraphQL 模式指令——特别是,@relationship 模式指令。指令类似于我们在 GraphQL 类型定义中的注释。它是一个由 @ 字符前缀的标识符,然后可以(可选地)包含一个命名参数列表。模式指令是 GraphQL 的内置扩展机制,表示 GraphQL 服务器实现的一些自定义逻辑。
当使用 @relationship 指令定义关系字段时,类型参数表示存储在 Neo4j 中的关系类型,方向参数表示关系方向。除了模式指令外,指令还可以在 GraphQL 查询中使用,以指示特定的行为。当我们探索在 React 应用程序中使用 Apollo Client 管理客户端状态时,我们将看到一些查询指令的示例。
现在,让我们运行我们的 API 应用程序:
node index.js
作为输出,我们应该看到我们的 API 应用程序正在监听的地址——在本例中,localhost 上的端口 4000:
➜ node index.js
GraphQL server ready at http://localhost:4000/
在您的网络浏览器中导航到 http://localhost:4000,您应该会看到 Apollo Studio 的登录页面。在 Apollo Studio 的 GraphQL 中,点击左上角的 Schema 图标以查看完全生成的 API(见图 4.4)。花几分钟浏览查询字段描述,您会注意到已为诸如排序、分页和过滤之类的功能添加了参数。您还可以在 Reference 和 SDL 视图之间切换,以查看基于我们初始 GraphQL 类型定义的完整生成的 GraphQL SDL。

图 4.4 显示我们的生成 API 的 Apollo Studio
4.4 基本 GraphQL 查询
现在,我们已经有了由 Apollo Server 和 Neo4j GraphQL 库提供支持的 GraphQL 服务器正在运行,让我们开始使用 Apollo Studio 查询我们的 API。查看 Apollo Studio 的 Schema 选项卡,我们可以看到可用的 API 入口点(在 GraphQL 术语中,每个 Query 类型字段是 API 的入口点):Business、User、Review 和 Category——每个类型定义中有一个。让我们首先查询所有企业,并只返回名称字段,如下一个列表所示。
列表 4.6 查询所有企业的 GraphQL 查询
{
businesses {
name
}
}
如果我们在 Apollo Studio 中运行此查询,我们应该看到企业名称列表:
{
"data": {
"businesses": [
{
"name": "Missoula Public Library"
},
{
"name": "Ninja Mike's"
},
{
"name": "KettleHouse Brewing Co."
},
{
"name": "Imagine Nation Brewing"
},
{
"name": "Market on Front"
},
{
"name": "Hanabi"
},
{
"name": "Zootown Brew"
},
{
"name": "Ducky's Car Wash"
},
{
"name": "Neo4j"
}
]
}
}
真 neat!这些数据已经从我们的 Neo4j 实例中为我们获取,我们甚至不需要编写任何解析器!
让我们打开 Neo4j GraphQL 库的调试日志,以便我们可以看到发送到数据库的生成 Cypher 查询。为此,我们需要设置一个 DEBUG 环境变量。让我们通过在终端中按 Ctrl-C 停止我们的 GraphQL 服务器,然后当我们再次启动 GraphQL API 应用程序时,我们将设置 DEBUG 环境变量:
DEBUG=@neo4j/graphql:* node index.js
如果我们再次运行我们的 GraphQL 查询并检查终端的输出,我们可以在下一列表中看到生成的 Cypher 查询被记录到终端中。
列表 4.7 生成的 Cypher 查询
MATCH (`business`:`Business`)
RETURN `business` { .name } AS `business`
我们可以向 GraphQL 查询添加额外的字段,这些字段将被添加到生成的 Cypher 查询中,只返回所需的数据。例如,GraphQL 查询添加了企业的地址和名称字段,如下一列表所示。
列表 4.8 返回企业名称和地址的 GraphQL 查询
{
businesses {
name
address
}
}
现在的 GraphQL 查询的 Cypher 翻译包括地址字段,如下一列表所示。
列表 4.9 包含地址属性的生成的 Cypher 查询
MATCH (`business`:`Business`)
RETURN `business` { .name , .address } AS `business`
最后,当我们检查 GraphQL 查询的结果时,我们现在看到每个企业都列出了一个地址:
{
"data": {
"businesses": [
{
"name": "Missoula Public Library",
"address": "301 E Main St"
},
{
"name": "Ninja Mike's",
"address": "200 W Pine St"
},
{
"name": "KettleHouse Brewing Co.",
"address": "313 N 1st St W"
},
{
"name": "Imagine Nation Brewing",
"address": "1151 W Broadway St"
},
{
"name": "Market on Front",
"address": "201 E Front St"
},
{
"name": "Hanabi",
"address": "723 California Dr"
},
{
"name": "Zootown Brew",
"address": "121 W Broadway St"
},
{
"name": "Ducky's Car Wash",
"address": "716 N San Mateo Dr"
},
{
"name": "Neo4j",
"address": "111 E 5th Ave"
}
]
}
}
接下来,让我们利用生成的 GraphQL API 的一些功能。
4.5 排序和分页
每个查询字段都包括一个输入对象参数 options。我们可以在此选项参数中指定 limit 和 sort 的值,以方便排序和分页。在这里,我们按名称字段的值搜索前三个企业。
列表 4.10 包含排序和限制的初始 GraphQL API 代码
{
businesses(options: { limit: 3, sort: { name: ASC } }) {
name
}
}
为每种类型生成排序枚举,为每个字段提供升序和降序选项。运行我们的查询现在返回按名称排序的企业,如下一列表所示。
列表 4.11 分页结果
{
"data": {
"businesses": [
{
"name": "Ducky's Car Wash"
},
{
"name": "Hanabi"
},
{
"name": "Imagine Nation Brewing"
}
]
}
}
如果我们切换到终端,我们可以看到从我们的 GraphQL 查询生成的 Cypher 查询,现在包括 ORDER BY 和 LIMIT 子句,它们映射到我们的第一个和 orderBy GraphQL 参数,如下一列表所示。这意味着排序和限制是在数据库中执行的,而不是在客户端,因此只从数据库查询返回必要的数据。
列表 4.12 包含排序和限制的生成的 Cypher 查询
MATCH (`business`:`Business`)
WITH `business`
ORDER BY business.name ASC
RETURN `business` { .name } AS `business`
LIMIT toInteger($first)
注意,此查询包含一个$first 参数,而不是在查询中内联的值 3。在此处参数使用很重要,因为它确保用户无法将可能有害的 Cypher 代码注入到生成的查询中,同时也确保 Neo4j 生成的查询计划可以重用,从而提高性能。要在 Neo4j 浏览器中运行此查询,首先使用:param 命令为第一个参数设置一个值:
:param first => 3
4.6 嵌套查询
Cypher 可以轻松表达我们 GraphQL 查询中的图遍历类型,Neo4j GraphQL 库能够为任意的 GraphQL 请求生成等效的 Cypher 查询,包括嵌套查询。现在,如下一列表所示,我们从企业遍历到它们的类别。
列表 4.13 包含嵌套选择集的 GraphQL 查询
{
businesses(options: { limit: 3, sort: { name: ASC } }) {
name
categories {
name
}
}
}
结果显示每个企业都与一个或多个类别相连:
{
"data": {
"businesses": [
{
"name": "Ducky's Car Wash",
"categories": [
{
"name": "Car Wash"
}
]
},
{
"name": "Hanabi",
"categories": [
{
"name": "Ramen"
},
{
"name": "Restaurant"
}
]
},
{
"name": "Imagine Nation Brewing",
"categories": [
{
"name": "Beer"
},
{
"name": "Brewery"
}
]
}
]
}
}
4.7 过滤
通过添加一个带有基于 GraphQL 类型定义的关联输入的 where 参数,暴露了过滤器功能。您可以在文档中查看完整的过滤标准列表,网址为 neo4j.com/docs/graphql-manual/current/filtering/。
4.7.1 where 参数
在下一个列表中,我们使用 where 参数来搜索名称中包含 Brew 的业务。
列表 4.14 包含 Brew 的业务名称的 GraphQL 查询过滤器
{
businesses(where: { name_CONTAINS: "Brew" }) {
name
address
}
}
我们的结果现在显示了符合过滤标准的业务,并且只返回名称中包含字符串 Brew 的业务:
{
"data": {
"businesses": [
{
"name": "KettleHouse Brewing Co.",
"address": "313 N 1st St W"
},
{
"name": "Imagine Nation Brewing",
"address": "1151 W Broadway St"
},
{
"name": "Zootown Brew",
"address": "121 W Broadway St"
}
]
}
}
4.7.2 嵌套过滤
要根据应用于根的嵌套字段的結果进行过滤,我们可以嵌套我们的过滤器参数。在下一个列表中,我们搜索包含名称 Brew 且至少有一个至少评分为 4.75 的评论的业务。
列表 4.15 使用嵌套过滤器的 GraphQL 查询
{
businesses(
where: { name_CONTAINS: "Brew", reviews_SOME: { stars_GTE: 4.75 } }
) {
name
address
}
}
如果我们检查这个 GraphQL 查询的结果,我们可以看到两个匹配的业务:
{
"data": {
"businesses": [
{
"name": "KettleHouse Brewing Co.",
"address": "313 N 1st St W"
},
{
"name": "Zootown Brew",
"address": "121 W Broadway St"
}
]
}
}
4.7.3 逻辑运算符:AND、OR
过滤器可以用逻辑运算符 OR 和 AND 进行包装。例如,我们可以在过滤器参数中使用 OR 运算符来搜索咖啡或早餐类别的企业,如下一个列表所示。
列表 4.16 使用逻辑运算符的过滤器的 GraphQL 查询
{
businesses(
where: {
OR: [
{ categories_SOME: { name: "Coffee" } }
{ categories_SOME: { name: "Breakfast" } }
]
}
) {
name
address
categories {
name
}
}
}
这个 GraphQL 查询返回与咖啡或早餐类别相关联的业务:
{
"data": {
"businesses": [
{
"name": "Market on Front",
"address": "201 E Front St",
"categories": [
{
"name": "Restaurant"
},
{
"name": "Cafe"
},
{
"name": "Coffee"
},
{
"name": "Deli"
},
{
"name": "Breakfast"
}
]
},
{
"name": "Ninja Mike's",
"address": "200 W Pine St",
"categories": [
{
"name": "Restaurant"
},
{
"name": "Breakfast"
}
]
},
{
"name": "Zootown Brew",
"address": "121 W Broadway St",
"categories": [
{
"name": "Coffee"
}
]
}
]
}
}
4.7.4 选择中的过滤
过滤器也可以在整个选择集中使用,以在选择的级别应用过滤器。例如,假设在下一个列表中,我们想要找到所有咖啡或早餐企业,但只查看包含短语早餐三明治的评论。
列表 4.17 在选择集中使用过滤器参数的 GraphQL 查询
{
businesses(
where: {
OR: [
{ categories_SOME: { name: "Coffee" } }
{ categories_SOME: { name: "Breakfast" } }
]
}
) {
name
address
reviews(where: { text_CONTAINS: "breakfast sandwich" }) {
stars
text
}
}
}
由于过滤器是在评论选择上应用的,因此没有包含短语早餐三明治的评论的业务仍然显示在结果中;然而,只显示包含该短语的评论:
{
"data": {
"businesses": [
{
"name": "Market on Front",
"address": "201 E Front St",
"reviews": []
},
{
"name": "Ninja Mike's",
"address": "200 W Pine St",
"reviews": [
{
"stars": 4,
"text": "Best breakfast sandwich at the Farmer's Market."
}
]
},
{
"name": "Zootown Brew",
"address": "121 W Broadway St",
"reviews": []
}
]
}
}
4.8 使用时间字段
Neo4j 支持作为节点和关系的属性的原生时间类型。这些类型包括 Date、DateTime 和 LocalDateTime。使用 Neo4j GraphQL 库,您可以在您的 GraphQL 模式中使用这些时间类型。
4.8.1 在查询中使用日期类型
我们在 Review 类型上使用日期类型。日期类型由 yyyy-mm-dd 格式的字符串表示,但在数据库中以原生日期类型存储,支持日期操作。让我们在下一个列表中查询最近的三条评论。
列表 4.18 使用日期字段的 GraphQL 查询
{
reviews(options: { limit: 3, sort: { date: DESC } }) {
stars
date
business {
name
}
}
}
由于我们在选择集中指定了日期字段,因此我们在结果中看到:
{
"data": {
"reviews": [
{
"stars": 3,
"date": "2018-09-10",
"business": {
"name": "Imagine Nation Brewing"
}
},
{
"stars": 5,
"date": "2018-08-11",
"business": {
"name": "Zootown Brew"
}
},
{
"stars": 4,
"date": "2018-03-24",
"business": {
"name": "Market on Front"
}
}
]
}
}
4.8.2 日期和 DateTime 过滤器
时间字段也包含在生成的过滤枚举中,允许使用日期和日期范围进行过滤。在下一个列表中,我们将搜索在 2017 年 1 月 1 日之前创建的评论。
列表 4.19 使用日期过滤器进行 GraphQL 查询
{
reviews(
where: { date_LTE: "2017-01-01" }
options: { limit: 3, sort: { date: DESC } }
) {
stars
date
business {
name
}
}
}
我们可以看到,结果现在按日期字段排序:
{
"data": {
"reviews": [
{
"stars": 5,
"date": "2016-11-21",
"business": {
"name": "Hanabi"
}
},
{
"stars": 5,
"date": "2016-07-14",
"business": {
"name": "KettleHouse Brewing Co."
}
},
{
"stars": 5,
"date": "2016-03-04",
"business": {
"name": "Ducky's Car Wash"
}
}
]
}
}
4.9 处理空间数据
Neo4j 目前支持空间点类型,它可以表示二维(例如,纬度和经度)和三维(例如,x,y,z 或纬度,经度,高度)的点,使用地理坐标参考系统(例如,纬度和经度)和笛卡尔坐标参考系统。Neo4j GraphQL 库提供了两种空间类型:Point,用于使用地理坐标参考系统的点,和 CartesianPoint,用于使用笛卡尔坐标参考系统的点。您可以在本文档中了解更多关于在 Neo4j GraphQL 库中处理空间数据的信息:mng.bz/qYKA。
4.9.1 选择中的 Point 类型
Point 类型字段是 GraphQL 模式中的对象字段,因此让我们通过在下一个列表中添加这些字段来检索匹配商业实体的纬度和经度字段。
列表 4.20 使用 Point 字段进行 GraphQL 查询
{
businesses(options: { limit: 3, sort: { name: ASC } }) {
name
location {
latitude
longitude
}
}
}
现在,在 GraphQL 查询结果中,我们看到每个商业实体都包含了经度和纬度:
{
"data": {
"businesses": [
{
"name": "Ducky's Car Wash",
"location": {
"latitude": 37.575968,
"longitude": -122.336041
}
},
{
"name": "Hanabi",
"location": {
"latitude": 37.582598,
"longitude": -122.351519
}
},
{
"name": "Imagine Nation Brewing",
"location": {
"latitude": 46.876672,
"longitude": -114.009628
}
}
]
}
}
4.9.2 距离过滤器
当使用点数据进行查询时,我们经常想找到靠近其他事物的事物。例如,哪些商业实体在我 1.5 公里范围内?我们可以使用生成的 where 参数来完成此操作,如下列所示。
列表 4.21 使用距离过滤器进行 GraphQL 查询
{
businesses(
where: {
location_LT: {
distance: 3500
point: { latitude: 37.563675, longitude: -122.322243 }
}
}
) {
name
address
city
state
}
}
对于使用地理坐标参考系统(纬度和经度)的点,距离以米为单位:
{
"data": {
"businesses": [
{
"name": "Hanabi",
"address": "723 California Dr",
"city": "Burlingame",
"state": "CA"
},
{
"name": "Ducky's Car Wash",
"address": "716 N San Mateo Dr",
"city": "San Mateo",
"state": "CA"
},
{
"name": "Neo4j",
"address": "111 E 5th Ave",
"city": "San Mateo",
"state": "CA"
}
]
}
}
4.10 向我们的 GraphQL API 添加自定义逻辑
到目前为止,我们已经看到了由 Neo4j GraphQL 库创建的基本查询操作。通常,我们想在我们的 API 中添加自定义逻辑。例如,我们可能想计算最受欢迎的商业或向用户推荐商业。使用 Neo4j GraphQL 库向您的 API 添加自定义逻辑有两种选项:@cypher 模式指令或实现自定义解析器。
4.10.1 @cypher GraphQL 模式指令
Neo4j GraphQL 库通过 GraphQL 通过 @cypher GraphQL 模式指令暴露 Cypher。在您的模式中用 @cypher 指令注释一个字段,以将查询的结果映射到注释的 GraphQL 字段。@cypher 指令接受一个参数语句,这是一个 Cypher 语句。参数在运行时传递到这个查询中,包括当前解析的节点,以及在任何 GraphQL 类型定义中定义的字段级参数。
注意:@cypher 指令和 Neo4j GraphQL 库的其他功能需要使用 APOC 标准库插件。请确保您已遵循本章 项目设置 部分的步骤安装 APOC。
计算标量场
我们可以使用 @cypher 指令定义一个自定义标量字段,在我们的模式中创建一个计算字段。在下一个列表中,我们向 Business 类型添加一个 averageStars 字段,该字段使用 this 变量计算业务所有评论的平均星级。
列表 4.22 index.js:添加 averageStars 字段
type Business {
businessId: ID!
averageStars: Float!
@cypher(
statement: "MATCH (this)<-[:REVIEWS]-(r:Review) RETURN avg(r.stars)"
)
name: String!
city: String!
state: String!
address: String!
location: Point!
reviews: [Review!]! @relationship(type: "REVIEWS", direction: IN)
categories: [Category!]!
@relationship(type: "IN_CATEGORY", direction: OUT)
}
由于我们已修改了类型定义,我们需要重新启动我们的 GraphQL 服务器:
DEBUG=@neo4j/graphql:* node index.js
现在,让我们在下一个列表中将 averageStars 字段包含在我们的 GraphQL 查询中。
列表 4.23 包含 averageStars 字段的 GraphQL 查询
{
businesses {
name
averageStars
}
}
我们可以看到在结果中,averageStars 的计算值现在已包含:
{
"data": {
"Business": [
{
"name": "Hanabi",
"averageStars": 5
},
{
"name": "Zootown Brew",
"averageStars": 5
},
{
"name": "Ninja Mike's",
"averageStars": 4.5
}
]
}
}
如果我们检查终端输出以查看生成的 Cypher 查询,我们会注意到生成的 Cypher 查询将我们的 @cypher 指令中的注释 Cypher 查询作为子查询包含在内,保留了单个数据库调用以解析 GraphQL 请求,但仍然包括我们的自定义逻辑!
计算对象和数组字段
我们还可以使用 @cypher 模式指令来解析对象和数组字段。让我们向 Business 类型添加一个推荐的业务字段。我们将使用一个简单的 Cypher 查询来查找其他用户评论过的共同业务。例如,如果一个用户喜欢 Front 上的 Market,我们可以推荐其他也被 Market on Front 的评论者评论过的业务。
列表 4.24 查找推荐业务的 Cypher 查询
MATCH (b:Business)<-[:REVIEWS]-(:Review)<-[:WROTE]-(u:User)
WHERE b.name = "Market on Front"
MATCH (u)-[:WROTE]->(:Review)-[:REVIEWS]->(rec:Business)
WITH rec, COUNT(*) AS score
RETURN rec ORDER BY score DESC
我们可以通过在我们的 Business 类型定义中的推荐字段上包含 @cypher 指令来在我们的 GraphQL 模式中使用此 Cypher 查询。
列表 4.25 index.js:添加推荐字段
type Business {
businessId: ID!
averageStars: Float!
@cypher(
statement: "MATCH (this)<-[:REVIEWS]-(r:Review) RETURN avg(r.stars)"
)
recommended(first: Int = 1): [Business!]!
@cypher(
statement: """
MATCH (this)<-[:REVIEWS]-(:Review)<-[:WROTE]-(u:User)
MATCH (u)-[:WROTE]->(:Review)-[:REVIEWS]->(rec:Business)
WITH rec, COUNT(*) AS score
RETURN rec ORDER BY score DESC LIMIT $first
"""
)
name: String!
city: String!
state: String!
address: String!
location: Point!
reviews: [Review!]! @relationship(type: "REVIEWS", direction: IN)
categories: [Category!]!
@relationship(type: "IN_CATEGORY", direction: OUT)
}
我们还定义了一个第一个字段参数,它作为 Cypher 查询传递给 @cypher 指令的 Cypher 参数,并作为返回推荐业务数量的限制。
自定义顶级查询字段
使用 @cypher 指令的另一种有用方式是作为自定义查询或突变字段。例如,让我们看看我们如何添加全文查询支持以搜索业务。应用程序通常使用全文搜索来纠正用户输入中的拼写错误等问题,使用模糊匹配。在 Neo4j 中,我们可以通过首先创建全文索引来使用全文搜索。
列表 4.26 创建全文索引的 Cypher
CREATE FULLTEXT INDEX businessNameIndex FOR (b:Business) ON EACH [b.name]
然后,为了查询此索引,我们拼写错误为 libary,但包括 ~ 字符启用模糊匹配,确保我们仍然能找到我们想要的东西。
列表 4.27 查询全文索引的 Cypher
CALL db.index.fulltext.queryNodes("businessNameIndex", "libary~")
将这种模糊匹配的全文搜索包含在我们的 GraphQL API 中不是很好吗?为了做到这一点,让我们创建一个名为 fuzzyBusinessByName 的查询字段,它接受一个搜索字符串并搜索业务,如下所示。
列表 4.28 index.js:添加自定义查询字段
type Query {
fuzzyBusinessByName(searchString: String): [Business]
@cypher(
statement: """
CALL
db.index.fulltext.queryNodes('businessNameIndex', $searchString+'~')
YIELD node RETURN node
"""
)
}
由于我们已更新了类型定义,我们必须重新启动 GraphQL API 应用程序:
DEBUG=@neo4j/graphql:* node index.js
如果我们在 Apollo Studio 中检查 Schema 选项卡,我们会看到一个新查询字段 fuzzyBusinessByName,现在我们可以使用这个模糊匹配来搜索企业名称,如下所示。
列表 4.29 使用我们的自定义查询字段进行 GraphQL 查询
{
fuzzyBusinessByName(searchString: "libary") {
name
}
}
由于我们使用全文搜索,即使我们拼写 libary 错误,我们仍然可以找到匹配的结果:
{
"data": {
"fuzzyBusinessByName": [
{
"name": "Missoula Public Library"
}
]
}
}
@cypher 模式指令是一种强大的方式,可以为我们添加自定义逻辑和高级功能到我们的 GraphQL API。我们还可以使用 @cypher 指令来实现授权功能,从请求对象中访问值,例如授权令牌,这一模式将在后续章节中讨论,当我们探索向我们的 API 添加授权的不同选项时。您可以在文档中了解更多关于 @cypher GraphQL 模式指令的信息:mng.bz/7yom。
4.10.2 实现自定义解析器
虽然 @cypher 指令是一种添加自定义逻辑的方法,但在某些情况下,我们可能需要实现无法用 Cypher 表达的自定义解析器。例如,我们可能需要从另一个系统获取数据或应用一些自定义验证规则。在这些情况下,我们可以实现一个自定义解析器并将其附加到 GraphQL 模式上,这样解析器就会被调用以解析我们的自定义字段,而不是依赖于 Neo4j GraphQL 库生成的 Cypher 查询来解析字段。
在我们的示例中,让我们假设有一个外部系统可以用来确定企业当前的等待时间。我们想在我们的模式中为 Business 类型添加一个额外的 waitTime 字段,并实现这个字段的解析逻辑以使用这个外部系统。
要做到这一点,我们首先在我们的模式中添加这个字段,添加 @ignore 指令以确保该字段被排除在生成的 Cypher 查询之外,如下列所示。这是我们告诉 Neo4j GraphQL 库自定义解析器将负责解析这个字段,并且我们不期望它自动从数据库中检索。
列表 4.30 index.js:添加 waitTime 字段
type Business {
businessId: ID!
waitTime: Int! @ignore
averageStars: Float!
@cypher(
statement: "MATCH (this)<-[:REVIEWS]-(r:Review) RETURN avg(r.stars)"
)
name: String!
city: String!
state: String!
address: String!
location: Point!
reviews: [Review!]! @relationship(type: "REVIEWS", direction: IN)
categories: [Category!]! @relationship(type: "IN_CATEGORY", direction: OUT)
}
接下来,我们创建一个包含我们自定义解析器的解析器映射,如下列 4.31 所示。我们之前不需要创建这个映射,因为 Neo4j GraphQL 库为我们生成了解析器。我们的等待时间计算将仅涉及随机选择一个值,但我们可以在这里实现任何自定义逻辑来确定 waitTime 值,例如向第三方 API 发送请求。
列表 4.31 index.js:创建解析器映射
const resolvers = {
Business: {
waitTime: (obj, args, context, info) => {
const options = [0, 5, 10, 15, 30, 45];
return options[Math.floor(Math.random() * options.length)];
}
}
};
然后,我们将这个解析器映射添加到传递给 Neo4jGraphQL 构造函数的参数中,如下列所示。
列表 4.32 index.js:生成 GraphQL 模式
const neoSchema = new Neo4jGraphQL({typeDefs, resolvers, driver})
现在,我们重新启动 GraphQL API 应用程序,因为我们已经更新了代码:
DEBUG=@neo4j/graphql:* node index.js
重启后,在 Apollo Studio 中,如果我们检查业务类型的模式,我们将看到业务类型上的新字段 waitTime。在下一个列表中,让我们通过在选择集中包含 waitTime 字段来搜索餐厅并查看它们的等待时间。
列表 4.33 使用自定义解析器的 GraphQL 查询
{
businesses(where: { categories_SOME: { name: "Restaurant" } }) {
name
waitTime
}
}
在结果中,我们现在可以看到等待时间的一个值。当然,你的结果会有所不同,因为值是随机的:
{
"data": {
"businesses": [
{
"name": "Ninja Mike's",
"waitTime": 30
},
{
"name": "Market on Front",
"waitTime": 5
},
{
"name": "Hanabi",
"waitTime": 45
}
]
}
}
4.11 从现有数据库检查 GraphQL 模式
通常,当我们开始一个新的应用程序时,我们没有现有的数据库,并且遵循 GraphQL-first 开发范式,从类型定义开始。然而,在某些情况下,我们可能有一个用数据填充的现有 Neo4j 数据库。在这些情况下,根据现有数据库生成 GraphQL 类型定义可能很方便,然后可以将这些类型定义输入到 Neo4j GraphQL 库中,为现有数据库生成 GraphQL API。我们可以使用@neo4j/introspector 包来完成这项工作。
首先,我们需要安装@neo4j/introspector 包:
npm i @neo4j/introspector
这个 Node.js 脚本将连接到我们的 Neo4j 数据库,并检查描述此数据的 GraphQL 类型定义,如下所示;然后我们将这些类型定义写入名为 schema.graphql 的文件。
列表 4.34 intropect.js:检查 GraphQL 类型定义
const { toGraphQLTypeDefs } = require("@neo4j/introspector");
const neo4j = require("neo4j-driver");
const fs = require("fs");
const driver = neo4j.driver(
"neo4j://localhost:7687",
neo4j.auth.basic("neo4j", "letmein")
);
const sessionFactory = () =>
driver.session({ defaultAccessMode: neo4j.session.READ });
// We create a async function here so we can use async/await
async function main() {
const typeDefs = await toGraphQLTypeDefs(sessionFactory);
fs.writeFileSync("schema.graphql", typeDefs);
await driver.close();
}
main();
然后,我们可以加载这个 schema.graphql 文件,并将类型定义传递给 Neo4jGraphQL 构造函数,如下所示。
列表 4.35 从 schema.graphql 加载我们的 GraphQL 类型定义
// Load GraphQL type definitions from schema.graphql file
const typeDefs = fs
.readFileSync(path.join(__dirname, "schema.graphql"))
.toString("utf-8");
到目前为止,我们所有的 GraphQL 查询都是使用 Apollo Studio 完成的,这对于测试和开发来说很棒,但通常我们的目标是构建一个查询 GraphQL API 的应用程序。在接下来的几章中,我们将开始构建我们的业务评论应用程序的用户界面,使用 React 和 Apollo Client。在这个过程中,我们将学习更多关于 GraphQL 概念的知识,如 mutations、fragments、interface types 等!
4.12 练习
-
使用 Apollo Studio 查询本章中创建的 GraphQL API
-
哪些用户评论了名为 Hanabi 的企业。
-
查找包含单词“舒适”的任何评论。他们评论的是哪些企业?
-
哪些用户没有给出五星级的评论?
-
-
向 Category 类型添加一个@cypher 指令字段,计算每个类别中的企业数量。咖啡类别中有多少家企业?
-
在
sandbox.neo4j.com创建一个 Neo4j 沙盒实例,从任何预填充的数据集中选择。使用@neo4j/introspector 包,为这个 Neo4j 沙盒实例创建一个 GraphQL API,而无需手动编写 GraphQL 类型定义。你可以使用 GraphQL 查询哪些数据?
请参阅本书的 GitHub 仓库以查看练习解决方案:mng.bz/mOYP。
摘要
-
在构建 GraphQL API 时可能会遇到的一些常见问题包括n + 1 查询问题、模式重复和大量样板数据获取代码。
-
像 Neo4j GraphQL 库这样的 GraphQL 数据库集成可以通过从 GraphQL 请求生成数据库查询、从 GraphQL 类型定义驱动数据库模式以及从 GraphQL 类型定义自动生成 GraphQL API 来帮助缓解这些问题。
-
Neo4j GraphQL 库通过生成用于数据获取的解析器,并添加过滤、排序和分页到生成的 API,使得基于 Neo4j 数据库构建 GraphQL API 变得容易。
-
通过使用@cypher 模式指令来定义字段的自定义逻辑,或者实现自定义解析器并将它们附加到 GraphQL 模式中,我们可以向我们的 GraphQL API 添加自定义逻辑。
-
如果我们有一个现有的 Neo4j 数据库,我们可以使用@neo4j/introspector 包在现有数据库上生成 GraphQL 类型定义和 GraphQL API。
第二部分 构建前端
在第一部分,我们专注于应用的后端,探索了 Neo4j 图数据库并使用 Neo4j GraphQL 库构建我们的 GraphQL API。现在,是时候构建前端 React 应用了。
在第五章中,我们将探讨 React 框架和对于构建前端应用时使用 React 重要的概念。然后,在第六章中,我们将使用 React 和 GraphQL 添加数据获取和客户端状态管理,因为我们将从之前章节中构建的 GraphQL API 中拉取数据。完成第二部分后,我们将拥有一个功能性的初始版本的业务审查应用,并准备好在第三部分探索添加授权和部署。
5 使用 React 构建用户界面
本章涵盖
-
React 基本概念的概述
-
使用 Create React App CLI 工具开始 React
-
在 React 应用中使用 React Hooks 处理状态
到目前为止,本书我们一直专注于应用的后端方面:构建 GraphQL API 和与数据库交互。现在是我们转向前端的时候了。在第一章,我们简要概述了 React 并查看了一个 React 组件的最小代码片段。在本章中,我们回到 React 并开始构建一个将成为我们 GraphQL API 客户端的 React 应用,在浏览器中搜索企业和渲染结果。当然,在一个章节中包含所有你需要了解的 React 介绍内容是不可能的,因此,而不是试图提供一个全面的 React 介绍,本章的目标是解释构建简单应用所需的 React 基本概念。我们提供了一个使用 Create React App 命令行工具的见解方法。对于更深入的 React 覆盖,你可能对在 reactjs.org/ 找到的文档和教程感兴趣。
在本章中,我们将尝试使用 Create React App 来处理构建工具和配置,创建我们 React 应用的骨架。然后,我们将更新模板应用,创建必要的组件以按类别搜索企业并查看结果。最初,我们的数据将直接硬编码在应用中;然后在第六章,我们将向 React 应用添加数据获取逻辑,使用 Apollo Client 连接我们在前几章中创建的 GraphQL API(见图 5.1)。让我们开始吧!

图 5.1 本章重点在于构建将成为我们 GraphQL API 客户端的 React 应用。
5.1 React 概述
React 基本上是一个用于构建用户界面(UI)的 JavaScript 库。React 可以用来构建网页(ReactDOM)、原生移动应用(React Native)和其他界面,如虚拟现实(React VR)。React 使用 组件 的概念来封装模型数据和逻辑。组件可以被重用并组合在一起来构建复杂的 UI,同时提供标准抽象以帮助开发者理解他们的应用。需要理解的重要 React 概念包括 JSX、React 元素、props、state、hooks 和组件层次结构。
5.1.1 JSX 和 React 元素
在 React 中,元素 是最基本的构建块。元素不应与组件混淆;相反,组件是由 React 元素组成的。你可以将元素视为可能在用户界面中直观显示的东西。例如,考虑以下列表中的简单 React 元素。
列表 5.1 使用 JSX 定义的简单 React 元素
const element = <h1>Welcome to GRANDstack</h1>;
初看之下,这似乎是一个 HTML 片段,但带有 JavaScript 的提示。实际上,这是一个 JSX 示例。JSX 用于创建 React 元素。
注意:JSX 不是与 React 一起工作的必需品;然而,使用 JSX 被高度推荐,本书不会涵盖替代方案。
你可以将 JSX 视为 HTML 和 JavaScript 的组合。我们可以在 JSX 中使用 JavaScript 表达式,通过将表达式包裹在花括号中来使用。例如,如果我们想个性化我们的欢迎来到 GRANDstack问候语,我们可以使用 JavaScript 变量来定义用户的姓名。
列表 5.2 在 JSX 中使用 JavaScript 表达式
const name = "Bob Loblaw";
const element = <h1>Welcome to GRANDstack, {name}!</h1>
在构建时,JSX 被编译成 JavaScript,并使用 React.createElement() JavaScript 函数来创建 React 元素,这些元素本质上表示为渲染到 DOM 中的 JavaScript 对象。
React 元素很重要,因为它们帮助 React 维护所谓的虚拟 DOM——DOM 的表示,允许 React 将 DOM 更新应用于所需状态。这意味着当应用发生变化时,React 只需重新渲染必要的部分,而不是重新渲染整个 DOM。
5.1.2 React 组件
React 允许我们使用称为组件的较小、可重用、可组合的片段来构建 UI。组件本质上是一些函数,它们接受输入(props,或属性)并返回构成 UI 的 React 元素,这些元素是 React 应用的构建块。列表 5.3 展示了示例。
注意:我们只会使用功能性的 React 组件。你可能会看到所谓的 React 类组件的引用;然而,随着 React Hooks 类的引入,组件不再需要。
列表 5.3 简单的 React 组件
function Greeting(props) {
return <h1>Welcome to GRANDstack, {props.name}</h1>;
}
组件使用两种类型的数据模型:props和state。Props 是不可变的;如果我们需要更改组件内部应触发重新渲染的值,那么我们需要处理状态数据。
5.1.3 组件层次结构
React 组件可以由其他组件组成。这允许我们在构建 UI 时封装和重用逻辑组件。
列表 5.4 组合 React 组件
function Greeting(props) {
return <h1>Welcome to GRANDstack, {props.name}</h1>;
}
function Popup() {
const name = "Bob Loblaw";
return <Greeting name={name} />
}
5.2 Create React App
Create React App 是一个用于创建 React 应用的命令行工具。它将构建工具捆绑在一起,无需初始配置。这是开始使用 React 的最简单方法,因为它会自动配置 webpack、Babel、ESLint 和其他工具,让开发者能够无需费力设置和配置构建工具即可开始编写 React 应用。你可以在create-react-app.dev/了解更多关于 Create React App 的信息。
5.2.1 使用 Create React App 创建 React 应用
让我们使用 Create React App 创建一个 React 应用程序。我们将在与 api 目录相邻的目录中这样做,在那里我们一直在构建我们的 GraphQL API。我们将首先为我们的业务审查应用程序构建一些初始功能,从业务搜索开始。我们的 React 应用程序的初始版本应该允许用户通过类别搜索业务并显示业务详情。目前,我们将硬编码数据到一个 JavaScript 对象中;然后在下一章中,我们将连接 React 应用程序到我们的 GraphQL API 作为数据源。要开始使用 Create React App,请在终端中运行以下命令,在 API 目录级别,与我们的 GraphQL API 代码相邻的目录中:
npx create-react-app web-react --use-npm
从 npm 版本 5.2.0 开始,npx 命令包含在 npm 中,可用于执行 npm 包和命令。npx 的一个出色功能是,如果我们没有本地安装该包,它将自动为我们下载该包,确保我们始终运行最新版本。
到目前为止,我们一直在使用 npm;默认情况下,Create React App 使用 yarn 包管理器 CLI,因此我们在调用 create-react-app 时将使用 --use-npm 命令标志。运行此命令后,我们应该看到输出告诉我们已创建一个新的 React 项目以及一些我们可以用来开始项目的有用命令:
Success! Created web-react at /Users/lyonwj/business-reviews/web-react
Inside that directory, you can run several commands:
npm start
Starts the development server.
npm run build
Bundles the app into static files for production.
npm test
Starts the test runner.
npm run eject
Removes this tool and copies build dependencies, configuration files
and scripts into the app directory. If you do this, you can’t go back!
We suggest that you begin by typing:
cd web-react
npm start
Happy hacking!
让我们看看 Create React App 为我们创建了什么:
.
├── README.md
├── package-lock.json
├── package.json
├── public
│ ├── favicon.ico
│ ├── index.xhtml
│ ├── logo192.png
│ ├── logo512.png
│ ├── manifest.json
│ └── robots.txt
└── src
├── App.css
├── App.js
├── App.test.js
├── index.css
├── index.js
├── logo.svg
├── serviceWorker.js
└── setupTests.js
└── node_modules
├── ...
README.md 文件包含了与我们所创建的 React 应用程序和 Create React App 一起工作的全面文档。node_modules 目录包含了我们应用程序的所有依赖项,这些依赖项是自动安装的。在 public 目录中,我们可以找到在应用程序启动时从根目录提供的静态内容。在 src 目录中,我们将找到定义骨架 React 应用的 JavaScript 和 CSS 代码。首先,让我们查看下一列表中的 package.json 文件,以查看包含的依赖项和可用的脚本。
列表 5.5 package.json
{
"name": "web-react",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "⁵.15.1",
"@testing-library/react": "¹¹.2.7",
"@testing-library/user-event": "¹².8.3",
"react": "¹⁷.0.2",
"react-dom": "¹⁷.0.2",
"react-scripts": "4.0.3",
"web-vitals": "¹.1.2"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
我们可以看到到目前为止包含在我们的应用程序中的依赖项:React 库以及一个名为 react-scripts 的包。react-scripts 包用于启动、运行、构建和测试我们的应用程序,正如我们在 package.json 文件的“scripts”部分中看到的那样。让我们继续运行我们的应用程序:
cd web-react
npm start
npm start 命令创建应用程序的开发版本,并启动一个本地 web 服务器,该服务器提供我们的 React 应用程序。使用了一个监视器,因此我们对源文件所做的任何更改都会触发应用程序的实时重新加载;这意味着我们通常在更改代码后不需要重新启动 web 服务器,以便在应用程序中看到我们的更改:
Compiled successfully!
You can now view web-react in the browser.
Local: http://localhost:3000
On Your Network: http://192.168.1.3:3000
Note that the development build is not optimized.
To create a production build, use npm run build.
如果运行我们的应用程序成功,我们会看到一个消息,告诉我们如何在网页浏览器中打开我们的应用程序(见图 5.2)。

图 5.2 我们在网页中运行的初始 React 应用程序
让我们打开那个 src/App.js 文件,并在下一条列表中查看我们的初始应用程序。
列表 5.6 src/App.js:初始代码
import logo from './logo.svg';
import './App.css';
function App() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
);
}
export default App;
我们正在导出一个 App 组件,但它被用在何处呢?如果我们打开 src/index.js,我们可以看到 App 组件是如何被使用的(见下一条列表)。它被传递给 ReactDOM.render,告诉 ReactDOM 在具有 ID 为 root 的 HTML 元素中渲染 App 组件。
列表 5.7 src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
让我们在下一条列表中更新 src/App.js 文件。首先,我们将创建一个简单的表单,包含一个下拉选择框,用于按类别搜索业务。
列表 5.8 src/App.js:添加示例数据和简单表单
const businesses = [ ❶
{
businessId: "b1",
name: "San Mateo Public Library",
address: "55 W 3rd Ave",
category: "Library",
},
{
businessId: "b2",
name: "Ducky's Car Wash",
address: "716 N San Mateo Dr",
category: "Car Wash",
},
{
businessId: "b3",
name: "Hanabi",
address: "723 California Dr",
category: "Restaurant",
},
];
function App() { ❷
return (
<div>
<h1>Business Search</h1>
<form>
<label>
Select Business Category:
<select value="All">
<option value="All">All</option>
<option value="Library">Library</option>
<option value="Restaurant">Restaurant</option>
<option value="Car Wash">Car Wash</option>
</select>
</label>
<input type="submit" value="Submit" />
</form>
<h2>Results</h2>
<table>
<thead>
<tr>
<th>Name</th>
<th>Address</th>
<th>Category</th>
</tr>
</thead>
<tbody>
{businesses.map((b, i) => ( ❸
<tr key={i}>
<td>{b.name}</td>
<td>{b.address}</td>
<td>{b.category}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export default App;
❶ 目前,我们的业务数据被定义为 JavaScript 数组。
❷ 我们的 React 组件位于组件层次结构的顶部,并且没有传递任何 props 数据;因此,它不接受任何参数。
❸ 我们遍历我们的业务数组,为每个业务创建一个表格行。
目前,我们只是将业务定义为 JavaScript 数组,但稍后我们需要用来自我们的 GraphQL API 的数据填充我们的应用程序。最初,所有结果都显示在一个简单的 HTML 表格中(见图 5.3)。

图 5.3 更新 src/App.js 后的我们的 React 应用程序
我们渲染了一个表格,但我们的表单实际上不起作用。我们无法选择一个类别,当我们尝试时,表格中没有任何变化。让我们更新我们的应用程序,以便根据我们选择的类别过滤结果。为此,我们需要了解状态,在这个过程中,我们还将了解 props!由于我们只使用功能 React 组件,我们需要使用 React Hooks 来处理状态。
5.3 状态和 React Hooks
React Hooks 是在 React 版本 16.8 中引入的,提供了一种在保持 React 组件为函数而不是类的同时,处理状态(和其他 React 概念)的方法。之前,你可能见过包含对 setState 函数调用、生命周期方法和构造函数的 React 类组件。有了 Hooks,这一切都不再需要;相反,我们可以通过单个函数调用来管理状态。
我们将通过实践的方式介绍 Hooks,更新我们的 React 应用程序以添加过滤功能,允许我们根据类别过滤业务结果表。在这个过程中,我们将看到如何使用 State React Hooks 在组件内部管理状态。
让我们创建一个新的 React 组件,该组件将负责渲染我们的结果表,称为 BusinessResults。为此,首先在 App.js 相同目录下创建一个名为 BusinessResults.js 的新文件,如下一条列表所示。
列表 5.9 src/BusinessResults.js
function BusinessResults(props) { ❶
const { businesses } = props; ❷
return (
<div>
<h2>Results</h2>
<table>
<thead>
<tr>
<th>Name</th>
<th>Address</th>
<th>Category</th>
</tr>
</thead>
<tbody>
{businesses.map((b, i) => (
<tr key={i}>
<td>{b.name}</td>
<td>{b.address}</td>
<td>{b.category}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export default BusinessResults;
❶ 组件通过参数传递 props 数据。
❷ props 参数包含要在结果表中渲染的业务数据。
我们将结果表移动到这个 src/BusinessResults.js 文件中,通过将组件要渲染的企业作为 props 传入。组件不再渲染所有企业到表中,而是渲染通过 props 参数传递的数据。现在,在我们的 App 组件中,我们可以导入这个新的 BusinessResults 组件,并将我们的企业数据数组作为 props 传递给组件,如下所示。
列表 5.10 src/App.js:使用 BusinessResults 组件
import BusinessResults from "./BusinessResults"; ❶
const businesses = [
{
businessId: "b1",
name: "San Mateo Public Library",
address: "55 W 3rd Ave",
category: "Library",
},
{
businessId: "b2",
name: "Ducky's Car Wash",
address: "716 N San Mateo Dr",
category: "Car Wash",
},
{
businessId: "b3",
name: "Hanabi",
address: "723 California Dr",
category: "Restaurant",
},
];
function App() {
return (
<div>
<h1>Business Search</h1>
<form>
<label>
Select Business Category:
<select value="All">
<option value="All">All</option>
<option value="Library">Library</option>
<option value="Restaurant">Restaurant</option>
<option value="Car Wash">Car Wash</option>
</select>
</label>
<input type="submit" value="Submit" />
</form>
<BusinessResults businesses={businesses} /> ❷
</div>
);
}
export default App;
❶ 导入 BusinessResults 组件
❷ 将企业数组作为 props 传递给 BusinessResults 组件
我们导入了一个新的组件 BusinessResults,并将我们的企业数组传递给它,因此 BusinessResults 组件可以负责渲染结果。我们的 App 组件现在只需要关注允许用户选择搜索类别。
在进行此更改后,我们的应用程序在网页浏览器中的外观完全相同,我们的选择表单仍然不起作用。在下一个列表中,让我们让我们的下拉菜单真正做一些事情!
列表 5.11 src/App.js:使用状态变量
import React, { useState } from "react"; ❶
import BusinessResults from "./BusinessResults";
const businesses = [
{
businessId: "b1",
name: "San Mateo Public Library",
address: "55 W 3rd Ave",
category: "Library",
},
{
businessId: "b2",
name: "Ducky's Car Wash",
address: "716 N San Mateo Dr",
category: "Car Wash",
},
{
businessId: "b3",
name: "Hanabi",
address: "723 California Dr",
category: "Restaurant",
},
];
function App() {
const [selectedCategory, setSelectedCategory] = useState("All"); ❷
return (
<div>
<h1>Business Search</h1>
<form>
<label>
Select Business Category:
<select
value={selectedCategory} ❸
onChange={(event) => setSelectedCategory(event.target.value)} ❹
>
<option value="All">All</option>
<option value="Library">Library</option>
<option value="Restaurant">Restaurant</option>
<option value="Car Wash">Car Wash</option>
</select>
</label>
<input type="submit" value="Submit" />
</form>
<BusinessResults
businesses={
selectedCategory === "All"
? businesses
: businesses.filter((b) => {
return b.category === selectedCategory;
})
}
/> ❺
</div>
);
}
export default App;
❶ 导入 useState 钩子。
❷ 调用 useState 钩子创建一个新的状态变量及其更新值的函数。
❸ 将下拉菜单的选中值绑定到我们的新状态变量。
❹ 当用户在表单中选择新的选项时,更新我们的状态变量值。
❺ 根据所选类别过滤传递给 BusinessResults 组件的企业结果。
首先,我们导入 useState 钩子并使用它来创建一个新的状态变量 selectedCategory。useState 的调用还返回一个函数(我们称之为 setSelectedCategory),用于更新 selectedCategory 的值。我们将这个变量绑定到选择输入的选择选项上,通过将 selectedCategory 传递给 select 元素的 value prop,并使用 setSelectedCategory 函数在选中新选项时更新 selectedCategory 的值。现在用户可以在表单中选择一个值,并看到只显示所选类别的企业结果表(见图 5.4)。

图 5.4 添加状态和过滤功能后的我们的 React 应用程序
现在我们已经有一个非常基础的 React 应用程序,我们的下一步将是添加数据获取功能以连接到我们的 GraphQL API。我们将在下一章中这样做,使用 Apollo Client React Hooks,并在过程中探索更多的 React 功能!
5.4 练习
-
将搜索逻辑移动到一个名为 BusinessSearch 的新组件中,并在 App 组件内部渲染该组件。
-
允许企业搜索除了企业类别外还可以按城市进行过滤。您需要将城市添加到样本数据中,并将其包含在表格结果中。
-
你会如何处理多类别搜索?修改示例数据以包含多个类别。更改表单处理以允许选择多个类别。更新过滤逻辑,以便将正确的业务搜索结果传递给 BusinessResults 组件。
摘要
-
React 是一个用于创建 UI 的 JavaScript 库,它使用组件的概念来封装逻辑。组件可以被组合起来创建复杂的 UI。
-
JSX 是一种用于创建 React 元素的语法,允许我们在处理 UI 代码时使用类似 HTML 的语法。
-
React 组件以两种形式使用模型数据:props 和 state。Props(或属性)是作为 React 单向数据流的一部分传递给组件的不可变数据。State 数据是局部且私有的,属于单个组件,当其发生变化时,会触发组件树的重新渲染。
-
Create React App 是一个用于创建 React 应用的命令行工具。它将构建工具捆绑在一起,无需初始配置。
-
React Hooks 允许开发者在一个组件内处理状态,同时仍然保持组件作为函数。
6 使用 React 和 Apollo 客户端进行客户端 GraphQL
本章节涵盖
-
使用 Apollo 客户端连接 React 应用程序到 GraphQL 端点
-
使用 Apollo 客户端在客户端缓存和更新数据
-
使用 GraphQL 变更更新应用中的数据
-
使用 Apollo 客户端管理 React 客户端状态数据
在上一章中,我们使用 Create React App 创建了一个 React 应用程序,允许用户通过类别搜索企业。我们使用一个硬编码到应用程序中的单个 JavaScript 对象作为我们数据源,因此我们的应用程序功能有限。在本章中,我们将探索将我们的 React 应用程序连接到我们在上一章中创建的 GraphQL API,并向我们的 GraphQL 工具箱引入一个新工具:Apollo 客户端。
Apollo 客户端 是一个数据管理 JavaScript 库,使开发者能够使用 GraphQL 管理本地和远程数据。它用于获取、缓存和修改应用数据,并提供了一系列前端框架集成,包括 React,以实现数据变化时更新您的 UI。
我们将使用 Apollo 客户端的 React Hooks API 来用我们的 GraphQL API 中的数据填充我们的 React 应用程序,通过 Apollo 客户端发出数据获取 GraphQL 查询。然后,我们将探索通过我们的 GraphQL API 更新数据的 GraphQL 变更操作,了解如何处理应用程序数据的变化。最后,我们将了解如何使用 Apollo 客户端来管理我们的 React 应用程序的本地状态,称为 客户端状态管理,通过向我们的 GraphQL API 添加仅本地字段来实现。让我们开始吧!
6.1 Apollo 客户端
Apollo 客户端不仅仅是一个发送和接收图数据的库。正如 Apollo 客户端文档所说:
Apollo 客户端是一个全面的 JavaScript 状态管理库,它使您能够使用 GraphQL 管理本地和远程数据。使用它来获取、缓存和修改应用数据,同时自动更新您的 UI。... Apollo 客户端帮助您以经济、可预测和声明式的方式构建代码,这与现代开发实践保持一致。核心 @apollo/client 库提供了与 React 的内置集成,而更大的 Apollo 社区维护了与其他流行视图层的集成。
—www.apollographql.com/docs/react/
我们将在将 Apollo 客户端添加到我们的 React 应用程序时利用这些功能,首先添加数据获取逻辑,然后使用 Apollo 客户端来管理我们的 React 应用程序中的本地状态数据。
6.1.1 将 Apollo 客户端添加到我们的 React 应用程序中
由于我们使用 React,我们将专注于 Apollo 客户端的 React 特定集成。首先,我们将使用 npm 安装 Apollo 客户端,创建一个连接到我们的 GraphQL API 的 Apollo 客户端实例,然后在我们的 React 应用程序中开始发出数据获取查询,使用 Apollo 客户端提供的 useQuery React 钩子。
由于我们将查询我们的 GraphQL API,请确保我们之前章节中的 Neo4j 数据库和 GraphQL API 应用程序都在运行。如果它们没有运行,我们将看到错误信息,表明 Apollo Client 无法连接到 GraphQL 端点。
安装 Apollo Client
到本文写作时,Apollo Client 3.5.5 是 Apollo Client 的最新版本,我们添加 GraphQL 支持所需的大部分工具都包含在一个单独的包中。之前的 Apollo Client 版本将 React Hooks 单独打包;然而,React 集成现在默认包含在内。
打开终端,确保你处于 web-react 目录中,然后运行以下命令来安装 Apollo Client。我们还需要安装 Apollo Client 的 peer dependency graphql.js。我们使用的是本文写作时的最新版本 Apollo Client,即 v3.5.5:
npm install @apollo/client graphql
现在,Apollo Client 已经安装,我们可以创建一个 Apollo Client 实例并开始发出 GraphQL 查询。首先,我们将以通用方式展示如何做到这一点,然后我们将添加此功能到我们的 React 应用程序中。
创建 Apollo Client 实例
要创建一个新的 Apollo Client 实例,我们需要将我们想要连接的 GraphQL API 的 URI 以及我们想要使用的缓存传递给 Apollo Client 构造函数,如下一个列表所示。最常用的缓存类型是 Apollo 的 InMemoryCache。
列表 6.1 创建 Apollo Client 实例
import { ApolloClient, InMemoryCache } from "@apollo/client";
const client = new ApolloClient({
uri: "http://localhost:4000",
cache: new InMemoryCache(),
});
然后,我们可以使用这个客户端实例来执行 GraphQL 操作。
使用 Apollo Client 进行查询
首先,让我们查看列表 6.2,看看如何使用客户端 API 执行 GraphQL 查询。在我们的 React 应用程序中,大多数情况下我们希望利用 Apollo Client 的 React Hooks API,因此这段代码不会成为我们应用程序的一部分。
列表 6.2 使用 Apollo Client 执行查询
import { ApolloClient, InMemoryCache, gql } from "@apollo/client";
const client = new ApolloClient({
uri: "http://localhost:4000",
cache: new InMemoryCache(),
});
client
.query({
query: gql`
{
businesses {
name
}
}
`
})
.then(result => console.log(result));
注意,我们将我们的 GraphQL 查询包裹在 gql 模板字面量标签中。这样做是为了将 GraphQL 查询字符串解析成 GraphQL 客户端理解的标准的 GraphQL 抽象语法树 (AST)。在这里,我们执行一个 GraphQL 查询操作来获取企业信息,只返回每个企业的名称,并将日志记录到控制台。
这个最小的 Apollo Client 示例在图 6.1 中展示。我们的 Apollo Client 实例向 GraphQL 服务器发送 GraphQL 查询操作,服务器响应数据,然后存储在 Apollo Client 缓存中。后续对相同数据的请求将直接从缓存中读取,而不是发送请求到 GraphQL 服务器。在本章的后面部分,我们将介绍如何直接与 Apollo Client 缓存交互。

图 6.1 最小 Apollo Client 示例
现在我们已经了解了 Apollo Client 的基础知识,让我们看看如何在我们的 React 应用程序中实现它们。
将 Apollo Client 注入组件层次结构
我们首先需要做的是将客户端实例注入到 React 组件层次结构中,使其在各个组件中可用。为此,我们将对由 Create React App 生成的 web-react/src/index.js 文件进行一些修改。
列表 6.3 web-react/src/index.js: 创建 Apollo 客户端实例
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import {
ApolloClient,
InMemoryCache,
ApolloProvider,
} from "@apollo/client";
const client = new ApolloClient({ ❶
uri: "http://localhost:4000",
cache: new InMemoryCache(),
});
ReactDOM.render(
<React.StrictMode>
<ApolloProvider client={client}> ❷
<App />
</ApolloProvider>
</React.StrictMode>,
document.getElementById("root")
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
❶ 创建一个 Apollo 客户端实例。
❷ 使用 Apollo Provider 组件将客户端实例注入到 React 组件层次结构中。
一旦我们创建了一个连接到我们的 GraphQL API 的 Apollo 客户端实例,我们就将我们的 App 组件包裹在 Apollo Provider 组件中,将我们的客户端实例作为属性传递给 ApolloProvider 组件。这将允许我们的 React 应用程序中的任何组件访问客户端实例并执行 GraphQL 操作。我们将在需要数据获取逻辑的任何组件中通过 React Hooks API 来做这件事(见图 6.2)。

图 6.2 将我们的 Apollo 客户端实例注入到 React 组件层次结构
6.1.2 Apollo 客户端钩子
Apollo Client React 集成包括用于处理数据的 React 钩子。useQuery React 钩子是执行 GraphQL 查询的主要方法。为了了解如何使用 useQuery 钩子,让我们开始更新我们的 App 组件,以便在 GraphQL API 中搜索数据,而不是使用硬编码的数据数组。
列表 6.4 web-react/src/App.js: 添加 GraphQL 查询
import React, { useState } from "react";
import BusinessResults from "./BusinessResults";
import { gql, useQuery } from "@apollo/client"; ❶
const GET_BUSINESSES_QUERY = gql` ❷
{
businesses {
businessId
name
address
categories {
name
}
}
}
`;
function App() {
const [selectedCategory, setSelectedCategory] = useState("All");
const { loading, error, data } = useQuery(GET_BUSINESSES_QUERY); ❸
if (error) return <p>Error</p>;
if (loading) return <p>Loading...</p>;
return (
<div>
<h1>Business Search</h1>
<form>
<label>
Select Business Category:
<select
value={selectedCategory}
onChange={(event) => setSelectedCategory(event.target.value)}
>
<option value="All">All</option>
<option value="Library">Library</option>
<option value="Restaurant">Restaurant</option>
<option value="Car Wash">Car Wash</option>
</select>
</label>
<input type="submit" value="Submit" />
</form>
<BusinessResults businesses={data.businesses} /> ❹
</div>
);
}
export default App;
❶ 导入 useQuery 钩子。
❷ 定义 GraphQL 查询以搜索企业。
❸ useQuery 钩子暴露了运行 GraphQL 操作的各种生命周期状态。
❹ 我们将 GraphQL 响应传递给 BusinessResults 组件。
首先,我们导入 useQuery 钩子和 gql 模板字面量标签。然后,我们定义一个 GraphQL 查询来搜索企业并返回我们需要在结果表中渲染的数据。接下来,我们将这个 GraphQL 查询传递给 useQuery 钩子,它返回状态对象,让我们检查 GraphQL 操作的各种状态:加载、错误和数据。当查询正在加载时,我们可以向用户显示我们正在获取数据的指示。如果我们的 GraphQL 查询返回了错误,我们可以向用户渲染一些错误结果。最后,一旦数据对象被填充,我们知道我们的 GraphQL 查询已成功完成,我们可以将那些数据作为属性传递给 BusinessResults 组件,该组件负责渲染我们的结果表(见图 6.3)。

图 6.3 使用 Apollo Client 钩子数据在 React 应用程序中的流动。
由于我们现在需要显示每个企业的多个类别,我们还需要对 BusinessResults 组件进行一些微调。
列表 6.5 web-react/src/BusinessResults.js: 显示企业类别
function BusinessResults(props) {
const { businesses } = props;
return (
<div>
<h2>Results</h2>
<table>
<thead>
<tr>
<th>Name</th>
<th>Address</th>
<th>Category</th>
</tr>
</thead>
<tbody>
{businesses.map((b, i) => (
<tr key={i}>
<td>{b.name}</td>
<td>{b.address}</td>
<td>
{b.categories.reduce( ❶
(acc, c, i) => acc + (i === 0 ? " " : ", ") + c.name,
""
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export default BusinessResults;
❶ 我们使用 reduce 函数来创建我们类别的单个字符串表示。
现在,如果我们看一下我们的 React 应用程序,我们应该看到我们的业务结果表已经填充了企业。数据来自 GraphQL API(见图 6.4)。

图 6.4 连接到我们的 GraphQL API 后的我们的 React 应用程序
当然,我们的应用还没有完全功能化,因为我们只是展示了所有企业。相反,我们需要根据用户输入的类别进行过滤。为此,我们将选择的类别作为 GraphQL 变量传递。
6.1.3 GraphQL 变量
GraphQL 变量允许我们将动态参数作为我们的 GraphQL 操作的一部分传递。让我们修改 web-react/src/App.js 以搜索仅匹配用户选择的类别的企业,将选择的类别作为 GraphQL 变量传递。我们将利用第四章中介绍过的过滤功能,使用 where 参数来过滤与用户选择类别相关联的企业。
列表 6.6 web-react/src/App.js:使用 GraphQL 变量
import React, { useState } from "react";
import BusinessResults from "./BusinessResults";
import { gql, useQuery } from "@apollo/client";
const GET_BUSINESSES_QUERY = gql`
query BusinessesByCategory($selectedCategory: String!) {
businesses(
where: { categories_SOME: { name_CONTAINS: $selectedCategory } }
) {
businessId
name
address
categories {
name
}
}
}
`;
function App() {
const [selectedCategory, setSelectedCategory] = useState("");
const { loading, error, data } = useQuery(GET_BUSINESSES_QUERY, {
variables: { selectedCategory },
});
if (error) return <p>Error</p>;
if (loading) return <p>Loading...</p>;
return (
<div>
<h1>Business Search</h1>
<form>
<label>
Select Business Category:
<select
value={selectedCategory}
onChange={(event) => setSelectedCategory(event.target.value)}
>
<option value="">All</option>
<option value="Library">Library</option>
<option value="Restaurant">Restaurant</option>
<option value="Car Wash">Car Wash</option>
</select>
</label>
<input type="submit" value="Submit" />
</form>
<BusinessResults businesses={data.businesses} />
</div>
);
}
export default App;
当与 GraphQL 变量一起工作时,我们首先需要将静态值替换为 $selectedCategory。然后,我们将 $selectedCategory 声明为查询接受的变量之一。然后,我们在调用 useQuery 时传递 $selectedCategory 的值。现在,当我们更改选择的类别时,搜索结果会更新,只显示该类别的结果(见图 6.5)。

图 6.5 使用 GraphQL 启用按类别过滤
6.1.4 GraphQL 片段
到目前为止,在创建选择集时,我们已经列出了我们希望在查询中包含的所有字段和嵌套字段。通常,我们应用中的不同组件会使用相同的(或选择集的子集)在 GraphQL 查询中。GraphQL 片段允许我们在 GraphQL 查询中重用选择集,或选择集的一部分。要在我们的 GraphQL 查询中使用片段,我们首先声明片段,给它一个名称和它有效的类型,如下面的列表所示。
列表 6.7 声明 GraphQL 片段
fragment businessDetails on Business {
businessId
name
address
categories {
name
}
}
在这里,我们定义了一个名为 businessDetails 的片段,它可以用来选择 Business 类型的字段,并包含渲染我们的结果表所需的所有字段。然后,为了在选择集中使用片段,我们在选择集中包含片段名称,前面加上 ....,如下一个列表所示。
列表 6.8 在 GraphQL 查询中使用片段
query BusinessesByCategory($selectedCategory: String!) {
businesses(
where: { categories_SOME: { name_CONTAINS: $selectedCategory } }
) {
...businessDetails
}
}
}
我们查询的结果将保持不变,但现在我们可以将这个 businessDetails 片段重用在其他查询中。
使用 Apollo Client 的片段
要使用 Apollo 客户端的片段,我们可以在单独的变量中声明我们的片段,并在我们的 GraphQL 查询中使用模板字面量中的占位符将它们包含在内。这允许我们存储片段并在组件之间共享它们。如果我们需要更改选择集中的字段,我们只需在声明片段的地方进行更改,然后使用该片段的任何查询都将被更新。
接下来,我们在 BUSINESS_DETAILS_FRAGMENT 变量中声明我们的 businessDetails 片段,然后我们使用模板字面量占位符将其包含在我们的 GraphQL 查询中,如下所示。
列表 6.9 web-react/src/App.js:使用 GraphQL 片段
...
const BUSINESS_DETAILS_FRAGMENT = gql`
fragment businessDetails on Business {
businessId
name
address
categories {
name
}
}
`;
const GET_BUSINESSES_QUERY = gql`
query BusinessesByCategory($selectedCategory: String!) {
businesses(
where: { categories_SOME: { name_CONTAINS: $selectedCategory } }
) {
...businessDetails
}
}
${BUSINESS_DETAILS_FRAGMENT}
`;
...
6.1.5 使用 Apollo 客户端进行缓存
Apollo 客户端将 GraphQL 结果存储在规范化的内存缓存中。这意味着如果再次运行相同的 GraphQL 查询,而不是将数据发送到服务器,将读取缓存的结果,减少不必要的网络请求并提高应用程序的感知性能。我们可以通过打开浏览器开发者工具并检查网络选项卡来验证结果是否已缓存,同时从下拉菜单中选择不同的类别。
更新缓存结果
当我们的应用程序数据变化不频繁时,缓存是提高性能的好方法,但我们如何处理更新已缓存的缓存数据?如果我们不想在我们的应用程序中使用缓存数据,而是想显示来自服务器的最新数据怎么办?幸运的是,Apollo 客户端提供了更新缓存结果的选择。我们将探讨两种更新缓存查询结果的方法:轮询和重新获取。
轮询允许在指定的时间间隔定期同步结果。在 Apollo 客户端中,可以通过指定以毫秒为单位的 pollInterval 值来按查询启用轮询。接下来,我们将查询结果设置为每 500 毫秒更新一次,如下所示。
列表 6.10 web-react/src/App.js:设置轮询间隔
const { loading, error, data } = useQuery(GET_BUSINESSES_QUERY, {
variables: { selectedCategory },
pollInterval: 500
});
与在固定间隔更新结果不同,重新获取允许我们显式地更新查询结果,通常是对用户操作的反应,例如点击按钮或提交表单。要使用 Apollo 客户端的重新获取功能,请调用由 useQuery 钩子返回的重新获取函数,如下所示。
列表 6.11 web-react/src/App.js:使用重新获取函数
import React, { useState } from "react";
import BusinessResults from "./BusinessResults";
import { gql, useQuery } from "@apollo/client";
const GET_BUSINESSES_QUERY = gql`
query BusinessesByCategory($selectedCategory: String!) {
businesses(
where: { categories_SOME: { name_CONTAINS: $selectedCategory } }
) {
businessId
name
address
categories {
name
}
}
}
`;
function App() {
const [selectedCategory, setSelectedCategory] = useState("");
const { loading, error, data, refetch } = useQuery( ❶
GET_BUSINESSES_QUERY,
{
variables: { selectedCategory },
}
);
if (error) return <p>Error</p>;
if (loading) return <p>Loading...</p>;
return (
<div>
<h1>Business Search</h1>
<form>
<label>
Select Business Category:
<select
value={selectedCategory}
onChange={(event) => setSelectedCategory(event.target.value)}
>
<option value="">All</option>
<option value="Library">Library</option>
<option value="Restaurant">Restaurant</option>
<option value="Car Wash">Car Wash</option>
</select>
</label>
<input type="button" value="Refetch" onClick={() => refetch()} /> ❷
</form>
<BusinessResults businesses={data.businesses} />
</div>
);
}
export default App;
❶ 重新获取函数是由 useQuery 钩子返回的。
❷ 点击按钮时调用重新获取函数
现在我们已经准备好处理应用程序中的变化数据,让我们看看如何使用 GraphQL 突变更新我们的 API 数据。
6.2 GraphQL 突变
GraphQL 突变是能够写入或更新数据的 GraphQL 操作。我们在第二章中介绍了突变的概念,但到目前为止,我们实际上还没有使用任何突变。在本节中,我们将探索由 Neo4j GraphQL 库生成的突变,允许我们创建、更新和删除节点和关系。
6.2.1 创建节点
对于我们 GraphQL 类型定义中的每个类型,都会生成一个 create 变异,映射到 Neo4j 中的一个节点标签。要创建节点,我们调用适当的 create 变异,并将新节点的属性值作为参数传入。注意,如果字段使用 ! 定义,这意味着该字段是非空白的,并且必须包含在内才能创建节点。让我们向数据库添加一个新的商业:Philz Coffee。
列表 6.12 创建商业的 GraphQL 变异
mutation {
createBusinesses(
input: {
businessId: "b10"
name: "Philz Coffee"
address: "113\. S B St"
city: "San Mateo"
state: "CA"
location: { latitude: 37.567109, longitude: -122.323680 }
}
) {
businesses {
businessId
name
address
city
}
info {
nodesCreated
}
}
}
在 Apollo Studio 中运行此变异将在数据库中创建一个新的商业节点:
{
"data": {
"createBusinesses": {
"businesses": [
{
"businessId": "b10",
"name": "Philz Coffee",
"address": "113\. S B St",
"city": "San Mateo"
}
],
"info": {
"nodesCreated": 1
}
}
}
}
6.2.2 创建关系
要在数据库中创建关系,我们可以使用由 Neo4j GraphQL 库生成的更新操作。在下一个列表中,让我们将新的 Philz Coffee 节点连接到咖啡类别节点。为此,我们使用 IDbusinessID 在变异的输入中引用业务节点。
列表 6.13 创建关系的 GraphQL 变异
mutation {
updateBusinesses(
where: { businessId: "b10" }
connect: { categories: { where: { node: { name: "Coffee" } } } }
) {
businesses {
name
categories {
name
}
}
info {
relationshipsCreated
}
}
}
注意使用 connect 参数。此参数允许我们在已存在的节点之间创建关系。我们也可以通过使用 create 参数创建一个新的类别节点;然而,在这种情况下,我们的咖啡类别节点已经在数据库中存在。这些 connect 和 create 参数在创建节点时也可用,并构成了 Neo4j GraphQL 库的一个强大功能,称为 嵌套变异。通过嵌套 create 或 connect 操作,我们可以在单个 GraphQL 变异中执行多个写操作:
{
"data": {
"updateBusinesses": {
"businesses": [
{
"name": "Philz Coffee",
"categories": [
{
"name": "Coffee"
}
]
}
],
"info": {
"relationshipsCreated": 1
}
}
}
}
6.2.3 更新和删除
假设 Philz Coffee 店从 B 街搬迁到与 Neo4j 办公室相邻的地址,我们需要更新地址。为此,我们使用 updateBusinesses 变异,使用 businessId 字段引用节点,然后将需要更新的任何值传递给 update 参数,如下所示。
列表 6.14 更新商业地址的 GraphQL 变异
mutation {
updateBusinesses(
where: { businessId: "b10" }
update: { address: "113 E 5th Ave" }
) {
businesses {
name
address
categories {
name
}
}
}
}
{
"data": {
"updateBusinesses": {
"businesses": [
{
"name": "Philz Coffee",
"address": "113 E 5th Ave",
"categories": [
{
"name": "Coffee"
}
]
}
]
}
}
}
或者,如果我们需要从数据库中完全删除节点,我们可以使用 deleteBusinesses 变异,如下所示。
列表 6.15 删除商业节点的 GraphQL 变异
mutation {
deleteBusinesses(where: { businessId: "b10" }) {
nodesDeleted
}
}
{
"data": {
"deleteBusinesses": {
"nodesDeleted": 1
}
}
}
当你在 Apollo Studio 中执行这些变异操作时,尝试上一节中提到的轮询和重新获取技术,以查看 React 应用程序如何响应变异执行时后端数据的更改。
6.3 使用 GraphQL 进行客户端状态管理
我们之前提到 Apollo Client 是一个全面的数据管理库,这包括不仅与我们的 GraphQL 服务器中的数据一起工作,还包括管理本地数据。本地数据可以包括我们的 React 应用程序的状态——例如,我们不希望发送到服务器的用户偏好,因为它们仅与客户端相关。
Apollo Client 允许我们在 GraphQL 查询中添加仅本地字段,然后由 Apollo Client 进行管理和缓存,以帮助管理我们的 React 应用程序的状态。这很有用,因为它允许我们使用与远程数据相同的 API 来处理本地数据:GraphQL!
6.3.1 仅本地字段和响应式变量
在 Apollo Client 中,仅本地字段 可以在我们的 GraphQL 模式中进行定义和包含。这些字段在服务器模式中未定义,而是仅针对客户端应用程序。这些字段的值是使用我们可以定义的逻辑在本地计算的,例如在浏览器中使用 localStorage 进行存储和读取。
响应式变量 使我们能够在 GraphQL 之外读取和写入本地值。当我们需要在不执行 GraphQL 操作(例如,对用户动作做出响应)的情况下更新它们的值,但作为 GraphQL 数据获取操作的一部分读取仅本地字段时,这些变量很有用。此外,修改响应式变量会触发使用其值的任何查询的更新。
让我们将仅本地字段与响应式变量结合起来,为我们的应用程序添加一个 星标业务 功能。我们将在结果列表中的每个业务旁边添加一个 星标 按钮,使用户能够选择他们的星标业务。当用户星标了一个业务时,它将以粗体显示,让用户知道它是他们偏好的业务之一。
如列表 6.16 所示,为此,我们首先向 Apollo Client 中使用的 InMemoryCache 实例添加一个仅本地字段的政策。字段政策指定了如何计算仅本地字段。在这里,我们添加了一个 isStarred 字段,它将是一个仅本地字段。我们还创建了一个新的响应式变量,它将用于存储星标业务列表。在这种情况下,isStarred 字段的字段政策会检查正在解析的业务是否包含在星标业务列表中。
列表 6.16 web-react/src/index.js: 使用响应式变量
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import {
ApolloClient,
InMemoryCache,
ApolloProvider,
makeVar, ❶
} from "@apollo/client";
export const starredVar = makeVar([]); ❷
const client = new ApolloClient({
uri: "http://localhost:4000",
cache: new InMemoryCache({
typePolicies: { ❸
Business: {
fields: {
isStarred: { ❹
read(_, { readField }) {
return starredVar().includes(readField("businessId")); ❺
},
},
},
},
},
}),
});
ReactDOM.render(
<React.StrictMode>
<ApolloProvider client={client}>
<App />
</ApolloProvider>
</React.StrictMode>,
document.getElementById("root")
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
❶ 导入 makeVar 函数以创建一个新的响应式变量。
❷ 创建一个新的响应式变量,将初始值设置为空数组。
❸ 在 InMemoryCache 构造函数参数中包含一个字段政策。
❹ 字段政策定义了在业务类型上名为 isStarred 的仅本地字段值的计算方式。
❺ 如果星标业务列表中包含当前业务,则返回 true。
现在,我们可以将 isStarred 字段包含在我们的 GraphQL 查询中,如下一列表所示。我们需要包含 @client 指令以指示这是一个仅本地字段,并且不会从 GraphQL 服务器获取。
列表 6.17 web-react/src/App.js: 使用仅本地 GraphQL 字段
...
const GET_BUSINESSES_QUERY = gql`
query BusinessesByCategory($selectedCategory: String!) {
businesses(
where: { categories_SOME: { name_CONTAINS: $selectedCategory } }
) {
businessId
name
address
categories {
name
}
isStarred @client ❶
}
}
`;
...
❶ 将 isStarred 字段添加到选择集中,使用 @client 指令表示这是一个仅本地字段。
最后,我们需要一种方法来更新 starredVar 响应式变量。在下一个列表中,我们在每个业务旁边添加一个 星号 按钮。当用户点击此按钮时,starredVar 的值会更新,以包含所选业务的 businessId。
列表 6.18 web-react/src/BusinessResults.js:使用我们的响应式变量
import { starredVar } from "./index";
function BusinessResults(props) {
const { businesses } = props;
const starredItems = starredVar(); ❶
return (
<div>
<h2>Results</h2>
<table>
<thead>
<tr>
<th>Name</th>
<th>Address</th>
<th>Category</th>
</tr>
</thead>
<tbody>
{businesses.map((b, i) => (
<tr key={i}>
<td>
<button
onClick={() =>
starredVar([...starredItems, b.businessId]) ❷
}
>
Star
</button>
</td>
<td style={b.isStarred ? { fontWeight: "bold" } : null}> ❸
{b.name}
</td>
<td>{b.address}</td>
<td>
{b.categories.reduce(
(acc, c, i) => acc + (i === 0 ? " " : ", ") + c.name,
""
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export default BusinessResults;
❶ 我们获取 starredVar 的值以找到所有已标记的业务。
❷ 点击时,将 businessId 添加到已标记业务的列表中。
❸ 如果业务已被标记星号,那么为业务名称使用粗体样式。
由于这是一个响应式变量,任何依赖于 isStarred 本地字段的活跃查询都会在 UI 中自动更新(见图 6.6)!

图 6.6 连接到我们的 GraphQL API 后的 React 应用程序
现在我们已经探讨了变更操作,我们需要考虑如何保护我们的应用程序,以便不是任何人都可以添加数据并更新我们的应用程序。在下一章中,我们将探讨如何添加身份验证来保护我们的应用程序,包括前端和后端。
6.4 练习
-
我们可以在整个应用程序中使用哪些其他的 GraphQL 片段?编写一些片段,并在 Apollo Studio 中的查询中尝试使用它们。在什么情况下,在同一个查询中使用多个片段是有意义的?
-
使用 GraphQL 变更操作,创建连接业务和类别节点的关系,以将业务添加到额外的类别中。例如,将新创建的 Philz Coffee 业务添加到餐厅和早餐类别。将您喜欢的业务和相应的类别添加到图中。
-
将 星号 按钮转换为切换按钮。如果业务已被标记星号,则从已标记列表中删除它。
摘要
-
Apollo 客户端是一个数据管理库,它使开发者能够使用 GraphQL 管理本地和远程数据,并包括对前端框架(如 React)的集成。
-
GraphQL 变更操作是允许创建和更新数据的操作,由 Neo4j GraphQL 库为每个类型生成。
-
Apollo 客户端可以通过向 GraphQL 模式添加本地字段以及定义字段策略来管理本地状态,这些策略指定了如何读取、存储和更新这些本地数据。
第三部分 全栈考虑因素
在构建了我们全栈业务审查应用的初始版本之后,现在是我们将注意力转向保护我们的应用并使用云服务部署它的时刻。在第七章中,我们将向我们的 GraphQL API 添加授权和认证,并探索使用 Auth0 服务。在第八章中,我们将使用 Netlify、AWS Lambda 和 Neo4j AuraDB 来部署我们的全栈应用。最后,在第九章中,我们将以如何利用 GraphQL 中的抽象类型、基于游标的分页以及处理 GraphQL 中的关系属性来结束本书。完成本书的这一部分后,我们将拥有一个安全的全栈 GraphQL 应用,并已部署到云端。
7 添加授权和认证
本章涵盖
-
将身份验证和授权添加到我们的应用程序中,包括 GraphQL API 和我们的前端 React 应用程序
-
使用 JSON Web 令牌 (JWT) 来编码用户身份和权限
-
使用 @auth GraphQL 模式指令在我们的 GraphQL 模式中表达和强制执行授权规则
-
使用 Auth0 作为 JWT 提供者以及 Auth0 React SDK 来为我们的应用程序添加 Auth0 支持
身份验证(验证用户的身份)和 授权(验证用户可以访问的资源)对于任何应用程序的安全性都是必要的——确保用户拥有他们应有的权限,并保护应用程序的数据和操作,包括前端和后端。到目前为止,我们的前端 React 应用程序和 GraphQL API 对任何人开放,任何人都可以访问所有功能和功能,包括修改、创建和删除数据。
GraphQL 本身对授权没有意见,将其留给开发者选择在他们的应用程序中实施的最合适的方法。在本章中,我们展示了如何使用 JWT、GraphQL 模式指令和 Auth0 在我们的应用程序中实现授权和认证功能。首先,我们将看看在解析器中添加授权检查的简单方法来添加授权到我们的 GraphQL API。然后,我们将探讨如何使用 @auth GraphQL 模式指令与 Neo4j GraphQL 库一起保护我们的 GraphQL API,在模式中添加授权规则。然后,我们添加对 Auth0 授权服务的支持,并看看我们如何可以在我们的应用程序中使用 JSON Web 令牌来编码用户身份和权限。
7.1 GraphQL 中的授权:一个简单的方法
让我们首先看看在列表 7.1 中添加授权到 GraphQL API 的一个简单方法,作为起点,仅使用单个静态授权令牌。当 GraphQL 服务器接收到请求时,我们将检查请求授权头中包含的令牌。我们将此令牌传递到 GraphQL 解析器,在那里我们将检查令牌的特定值以确定请求是否正确认证,并且只有在令牌有效时才发送适当的响应。请注意,此示例旨在传达概念,并不代表最佳实践!
列表 7.1 api/naive.js:一个简单的 GraphQL 授权实现
const { ApolloServer } = require("apollo-server");
const peopleArray = [
{
name: "Bob",
},
{
name: "Lindsey",
},
];
const typeDefs = /* GraphQL */ `
type Query {
people: [Person]
}
type Person {
name: String
}
`;
const resolvers = {
Query: {
people: (obj, args, context, info) => {
if (
context &&
context.headers &&
context.headers.authorization === "Bearer authorized123" ❶
) {
return peopleArray;
} else {
throw new Error("You are not authorized.");
}
},
},
};
const server = new ApolloServer({
resolvers,
typeDefs,
context: ({ req }) => {
return { headers: req.headers }; ❷
},
});
server.listen().then(({ url }) => {
console.log(`GraphQL server ready at ${url}`);
});
❶ 检查特定的认证令牌值
❷ 将 HTTP 请求头添加到 GraphQL 上下文对象中
我们的 GraphQL 服务器有一个单一的解析器,Query.people,它包括检查授权令牌值的逻辑,该令牌通过上下文对象传递。此令牌来自请求头,并在查询时传递到上下文对象中(见图 7.1)。

图 7.1 我们简单 GraphQL 授权实现的授权流程
让我们试一试。现在我们可以启动 GraphQL 服务器:
node naive.js
在 Apollo Studio 中,让我们发出一个 GraphQL 查询以查找所有 Person 对象并返回每个对象的名字字段:
{
people {
name
}
}
由于我们没有包括适当的授权令牌,我们的请求被拒绝,并且我们的结果是错误消息:您未授权. 让我们在 GraphQL 请求中添加适当的授权头和我们的授权令牌。我们可以在 Apollo Studio 中通过点击左下角的 Headers 并选择 New header(键为 Authorization,值为 Bearer authorized123)来完成此操作:
{
"Authorization": "Bearer authorized123"
}
现在,当我们执行相同的 GraphQL 操作——这次将授权令牌附加为请求中的头信息——我们看到我们预期的结果:
{
"data": {
"people": [
{
name: "Bob",
},
{
name: "Lindsey",
},
]
}
}
这种天真方法展示了几个重要概念,例如如何从请求中获取授权头并将其传递到 GraphQL 解析器的上下文对象,以及如何在 Apollo Studio 中添加授权头。然而,这种方法有几个问题,我们不会在现实世界的应用程序中实现:
-
我们不验证令牌。 我们如何知道发起请求的用户是他们所说的那个人,以及他们是否真的拥有令牌中声明的权限?我们只是在相信他们的话!
-
我们的授权规则与 GraphQL 解析器中的数据获取逻辑混合在一起。 这可能看起来像是一个简单的例子可以工作,但想象一下当我们添加更多类型和授权规则时会发生什么——这将很难跟踪和维护。
我们将通过使用加密签名的 JWT 来编码和验证授权头中表达的用户身份和权限来解决第一个问题。我们将通过使用 Neo4j GraphQL 库中的 @auth GraphQL 模式指令来解决第二个问题;通过在我们的模式中添加声明性授权规则,我们有一个单一的真相来源,用于我们的授权规则。
7.2 JSON Web Tokens
JSON Web Token,通常简称为 JWT,是一种用于对 JSON 对象进行加密签名的开放标准,可用于各方之间进行可信通信。通过使用公钥/私钥对生成并签名一个紧凑的令牌,以验证该令牌是由持有私钥的一方生成的,因此,可以通过解码它并使用用于签名的私钥的公钥对应物来对令牌中包含的信息的完整性进行加密验证。
JWT(负载)中编码的信息是一系列关于实体的 声明,通常是用户。JWT 中的标准声明包括
-
iss—令牌的发行者
-
exp—令牌的过期日期
-
sub—主题,通常是一些 ID,引用了这些声明适用的用户
-
aud—受众,通常在验证 API 时使用
我们还可以向 JWT 添加额外的声明来表示有关用户的信息,例如他们在应用程序中的角色(例如,用户是管理员还是编辑?)或更细粒度的权限,例如读取、创建、更新或删除应用程序中某些类型数据的权限。
许多身份和访问管理服务支持 JWT 标准。如果您选择提供自己的授权服务,它们甚至可以独立使用。在本章中,我们将使用 Auth0 服务。
首先,让我们创建一个 JWT 来编码关于用户的一些声明,然后我们将修改之前的简单 GraphQL API 以验证令牌并确保用户应该被允许访问 GraphQL API。为此,我们将使用在线 JWT 调试器jwt.io。
我们需要一个随机字符串作为签名密钥。稍后,我们将在我们的 GraphQL 服务器中使用这个密钥来验证传入的 JWT:
Dpwm9XXKqk809WXjCsOmRSZQ5i5fXw8N
在 JWT 调试器的“验证签名”部分输入此值。接下来,我们需要在我们的令牌的有效载荷中添加一些声明(见图 7.2):
{
"sub": "1234567890",
"name": "William Lyon",
"email": "will@grandstack.io",
"iat": 1638331785
}

图 7.2 使用 jwt.io 创建已签名的 JWT
在创建我们的 JWT 后,让我们回到简单的 GraphQL 服务器并添加验证令牌的支持。首先,我们将安装 jsonwebtoken 包:
npm install jsonwebtoken
接下来,我们将更新解析器逻辑,使用我们的随机客户端密钥解码 JWT,如下所示。
列表 7.2 api/naive.js:在 GraphQL 服务器中验证 JWT
const { ApolloServer } = require("apollo-server");
const jwt = require("jsonwebtoken");
const peopleArray = [
{
name: "Bob",
},
{
name: "Lindsey",
},
];
const typeDefs = /* GraphQL */ `
type Query {
people: [Person]
}
type Person {
name: String
}
`;
const resolvers = {
Query: {
people: (obj, args, context, info) => {
if (context.user) {
return peopleArray;
} else {
throw new Error("You are not authorized");
}
},
},
};
const server = new ApolloServer({
resolvers,
typeDefs,
context: ({ req }) => {
let decoded;
if (req && req.headers && req.headers.authorization) {
try {
decoded = jwt.verify( ❶
req.headers.authorization.slice(7),
"Dpwm9XXKqk809WXjCsOmRSZQ5i5fXw8N"
);
} catch (e) {
// token not valid
console.log(e);
}
}
return {
user: decoded,
};
},
});
server.listen().then(({ url }) => {
console.log(`GraphQL server ready at ${url}`);
});
❶ 使用我们的随机客户端密钥验证令牌
如果令牌可以验证,即它是由适当的密钥签名的,那么我们将继续在解析器中获取数据。如果令牌无效,则解析器抛出错误,并且不会获取任何数据(见图 7.3)。

图 7.3 将 JWT 引入我们的授权流程
此示例使用 HS256 算法,这意味着客户端和服务器共享相同的密钥。稍后,当我们切换到 Auth0 作为令牌提供者时,我们将使用更安全的 RS256 算法,其中使用公钥/私钥对代替。
在重启 GraphQL 服务器以应用我们的更改后,我们将打开 Apollo Studio 并将 JWT 令牌添加到授权头中。如果我们尝试不使用令牌或使用无效令牌进行请求,我们会收到以下错误:“您未授权”。这确保了 GraphQL 服务器只执行有效的请求——即包含使用对应公钥的私钥签名的 JWT(见图 7.4)。

图 7.4 在 Apollo Studio 中添加 JWT 作为授权头
之前,我们提到了我们原始授权方法中的两个问题。第一个问题是我们没有一种方式来验证授权令牌。我们通过使用和验证 JWT 解决了这个问题,所以现在,是时候解决我们的混合授权规则了。我们将在 GraphQL 模式中使用指令来声明我们的授权规则,并确保使用 Neo4j GraphQL 库强制执行这些规则。
7.3 The @auth GraphQL 模式指令
让我们放下简单的、原始的 GraphQL 服务器示例,回到我们的业务审查应用中,探索如何将授权规则添加到我们的 GraphQL 模式中。正如我们之前通过@cypher 模式指令所看到的,GraphQL 模式指令允许我们在解析 GraphQL 请求时应用一些自定义的服务端逻辑。
Neo4j GraphQL 库包括@auth GraphQL 模式指令,允许定义授权规则以保护 GraphQL 模式中的字段或类型。在我们能够使用@auth 模式指令之前,我们需要指定用于验证 JWT 的方法以及用于验证令牌的密钥。让我们设置一个环境变量,其值为我们的 JWT 密钥:
export JWT_SECRET=Dpwm9XXKqk809WXjCsOmRSZQ5i5fXw8N
现在,我们需要更新 Neo4j GraphQL 库的配置,指定在验证授权令牌时应使用此令牌,如列表 7.3 所示。为此,我们将读取我们刚刚设置的 JWT_SECRET 环境变量,并将其与我们的类型定义和解析器一起传递到插件对象中。我们还需要安装 graphql-plugin-auth 包以启用与 Neo4j GraphQL 库一起使用授权插件:
npm i @neo4j/graphql-plugin-auth
列表 7.3 api/index.js:配置 Neo4j GraphQL 库的授权
const {
Neo4jGraphQLAuthJWTPlugin,
} = require("@neo4j/graphql-plugin-auth");
const neoSchema = new Neo4jGraphQL({
typeDefs,
resolvers,
driver,
plugins: {
auth: new Neo4jGraphQLAuthJWTPlugin({
secret: process.env.JWT_SECRET, ❶
}),
},
});
❶ 使用密钥验证 JWT。
我们还可以使用 JSON Web Key Set (JWKS) URL 配置 JWT 解码和验证,这是一种比使用共享密钥更安全的方法。当我们使用 Auth0 时,我们将使用此方法配置 Neo4j GraphQL 库中的 JWT 验证,但就目前而言,使用共享密钥配置是可行的,如下一个列表所示。此外,我们还需要传递包含授权头和用户认证令牌的 HTTP 请求对象。
列表 7.4 api/index.js:传递带有认证令牌的请求对象
neoSchema.getSchema().then((schema) => {
const server = new ApolloServer({
schema,
context: ({ req }) => ({ req }), ❶
});
server.listen().then(({ url }) => {
console.log(`GraphQL server ready at ${url}`);
});
});
❶ 将 HTTP 请求对象传递给上下文函数,以便 Neo4j GraphQL 库生成的解析器可以解码 JWT。
7.3.1 规则和操作
当使用@auth GraphQL 模式指令时,我们需要考虑两个方面的内容:规则和操作。这两个都作为@auth 指令的参数指定。可以定义几种授权规则,具体取决于我们如何精确地保护字段和类型。也许我们只想让已登录的用户访问某些字段。或者,也许我们只想让我们的应用中的管理员能够编辑某些类型。或者,也许只有评论的作者才能更新它。这些都是可以使用@auth 指令指定的授权规则。以下是与@auth 模式指令一起可用的规则类型:
-
isAuthenticated 是我们能使用的最基本规则。访问受保护类型或字段的 GraphQL 请求必须包含有效的 JWT。
-
roles 规则指定一个或多个角色,这些角色必须包含在 JWT 负载中。
-
allow 规则将比较 JWT 负载中的值与数据库中的值,确保它们在有效请求中相等。
-
bind 规则用于确保在提交到数据库之前,JWT 负载中的值与 GraphQL 突变操作中的值相等。
-
Where 规则与 allow 规则类似,都使用 JWT 负载中的值;然而,它不是检查相等性,而是在生成的数据库查询中添加了一个谓词来过滤符合规则的数据。
当使用@auth 指令添加规则时,可以可选地指定一个或多个操作,指示规则应用于哪些操作。如果没有指定操作,则规则将应用于所有操作。以下操作可以使用:
-
创建
-
读取
-
更新
-
删除
-
连接
-
断开连接
让我们看看@auth 指令的实际应用,以帮助我们理解这些规则和操作如何在我们的业务审查应用中使用。
7.3.2 isAuthenticated 授权规则
isAuthenticated 规则可以在 GraphQL 类型或字段上使用,表示要访问该类型或字段,GraphQL 请求必须附加有效的 JWT。JWT 的有效性由是否可以使用 JWT 密钥值作为密钥进行验证来决定——这表明令牌是由私钥签署并由适当的权威机构创建的。isAuthenticated 逻辑用于控制应用中需要用户认证但不需要任何特定权限的区域——用户只需要是一个认证用户。
对于我们的业务审查应用,假设我们希望允许任何用户搜索企业,但只向认证用户显示 averageStars 字段,以鼓励用户使用我们的应用进行注册。让我们更新我们的 GraphQL 类型定义以包含此授权规则。
列表 7.5 api/index.js:更新 Business 类型
type Business {
businessId: ID!
waitTime: Int! @computed
averageStars: Float
@auth(rules: [{ isAuthenticated: true }]) ❶
@cypher(
statement: "MATCH (this)<-[:REVIEWS]-(r:Review) RETURN avg(r.stars)"
)
recommended(first: Int = 1): [Business]
@cypher(
statement: """
MATCH (this)<-[:REVIEWS]-(:Review)<-[:WROTE]-(u:User)
MATCH (u)-[:WROTE]->(:Review)-[:REVIEWS]->(rec:Business)
WITH rec, COUNT(*) AS score
RETURN rec ORDER BY score DESC LIMIT $first
"""
)
name: String!
city: String!
state: String!
address: String!
location: Point!
reviews: [Review!]! @relationship(type: "REVIEWS", direction: IN)
categories: [Category!]!
@relationship(type: "IN_CATEGORY", direction: OUT)
}
❶ 我们使用@auth 模式指令添加 isAuthenticated 规则来保护 averageStars 字段。
我们现在已经保护了 averageStars 字段,这意味着我们需要在包含该字段的任何 GraphQL 请求的头部包含一个有效的 JWT,如下一个列表所示。
列表 7.6 在 GraphQL 查询中请求受保护的 averageStars 字段
{
businesses {
name
categories {
name
}
averageStars
}
}
"errors": [
{
"message": "Unauthenticated",
如果我们不将 averageStars 字段包含在选择集中,我们的请求将返回预期的字段。尝试发送无效令牌和包含或不包含 averageStars 字段的请求。在这里,我们在请求的授权头中包含一个有效令牌,以便我们可以查看 averageStars 字段:
{
"Authorization":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxM
jM0NTY3ODkwIiwibmFtZSI6IldpbGxpYW0gTHlvbiIsImVtYWlsIjoid2lsbEBncmFuZHN0YWNr
LmlvIiwiaWF0IjoxNTE2MjM5MDIyfQ.Y37P8OF_qMamIcZldi89Nm0YQdF4v91iHQWrNu0jtBk"
}
7.3.3 角色授权规则
角色规则允许我们为一种或多种操作添加所需权限类型的必要条件。除了拥有一个有效的签名令牌外,要访问受角色规则保护的字段或类型,令牌必须包含在令牌中编码的角色声明中指定的一个角色。让我们在下一个列表中查看一个示例。
列表 7.7 api/index.js:使用角色授权规则保护用户类型
extend type User @auth(rules: [{roles: ["admin"]}])
在这里,我们使用 extend GraphQL 关键字在我们的类型定义中添加额外的指令或字段到已经定义在我们的类型定义中的类型。使用此语法等同于在首次定义类型时包含指令,但使用类型扩展允许我们将我们的类型定义分成多个文件,如果需要的话(见图 7.5)。

图 7.5 使用@auth GraphQL 架构指令的授权流程
现在,任何访问用户类型的 GraphQL 操作都必须具有管理员角色,包括任何遍历到用户的操作,如下一个列表所示。
列表 7.8 查询用户信息的 GraphQL
query {
businesses(where: {name: "Neo4j"}) {
name
categories {
name
}
address
reviews {
text
stars
date
user {
name
}
}
}
}
执行前面的查询将导致以下错误消息,因为我们的令牌不包括管理员角色:
"errors": [
{
"message": "Forbidden"
}
]
我们需要在令牌中的声明中包含角色。返回到在线 JWT 构建器jwt.io,并将角色数组添加到声明中:
{
"sub": "1234567890",
"name": "William Lyon",
"email": "will@grandstack.io",
"iat": 1516239022,
"roles": ["admin"]
}
现在,如果我们使用这个新的 JWT 更新 Apollo Studio 中用于授权头的令牌,并再次运行 GraphQL 查询,我们将能够访问用户信息。
记住,如果我们没有在添加授权规则时指定特定操作(例如,创建、读取和更新),那么该规则将适用于包含所讨论类型或字段的任何操作。如果我们想限制授权规则只应用于某些操作,我们必须在定义规则时明确指定它们,使用@auth 架构指令。
我们之前检查的前两个@auth 规则(isAuthenticated 和 roles)仅使用了 JWT 有效载荷中的值(或者,在 isAuthenticated 的情况下,简单地,有效令牌的存在)。接下来我们将探索的三个规则将使用数据库中的值(我们的应用程序数据)来执行授权规则。
7.3.4 允许授权规则
之前,我们创建了一个规则,通过要求认证用户具有管理员角色来保护用户类型。让我们添加一个额外的授权规则,允许用户读取他们自己的用户信息。
列表 7.9 api/index.js:允许用户访问他们自己的用户信息
extend type User
@auth(
rules: [
{ operations: [READ], allow: { userId: "$jwt.sub" } }
{ roles: ["admin"] }
]
)
注意,我们已经将我们的新允许规则与现有的角色规则结合起来。由于规则参数接受一个规则数组,这些规则充当或逻辑。要访问用户类型,请求的 JWT 中的声明必须至少符合规则数组中定义的授权规则之一。在这种情况下,认证用户必须是管理员或与请求用户的 userId 匹配。为了测试我们的新规则,让我们为用户 Jenny 创建一个新的 JWT,其有效载荷如下:
{
"sub": "u3",
"name": "Jenny",
"email": "jenny@grandstack.io",
"iat": 1516239022,
"roles": [
"user"
]
}
我们可以使用 web 界面在 jwt.io 中创建此 JWT;只需确保在签名令牌时使用相同的 JWT 密钥:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1MyIsIm5hbWUiOiJKZW5ueSIsIm
VtYWlsIjoiamVubnlAZ3JhbmRzdGFjay5pbyIsImlhdCI6MTUxNjIzOTAyMiwicm9sZXMiOlsid
XNlciJdfQ.ctal5qgshR4-hqchxsYxxHVGPsE0JNxydGy3Pga27nA
现在,使用此 JWT 以用户 Jenny 的身份执行 GraphQL 请求,我们可以在以下列表中查询此用户的详细信息。
列表 7.10 查询单个用户的详细信息
query {
users(where: { name: "Jenny" }) {
name
userId
}
}
由于我们的 JWT 中的 sub 声明与请求用户的 userId 匹配,我们看到了结果数据:
{
"data": {
"users": [
{
"name": "Jenny",
"userId": "u3"
}
]
}
}
在这种情况下,我们的 GraphQL 查询正在使用 where 参数过滤用户,以确保我们只查询我们有权访问的数据。如果我们请求我们没有访问权限的用户数据会发生什么?例如,如果我们请求所有用户信息会怎样?
列表 7.11 查询所有用户详细信息
query {
users {
name
userId
}
}
由于我们的用户不是管理员,并且我们请求的用户对象中 userId 将不会与我们的 JWT 中的 sub 声明匹配,我们将看到禁止错误。
让我们看看如何通过自动过滤查询结果,只为认证用户有权访问的数据,来避免这些类型的错误。为了实现这一点,我们将使用一个 where 授权规则。这意味着客户端不必担心添加过滤器以避免请求认证用户无权访问的数据。
7.3.5 Where 授权规则
在上一节中,我们使用了一个允许授权规则来确保用户只能访问他们自己的数据。然而,这种方法存在问题,因为它将负担放在了客户端,需要客户端添加适当的过滤器以确保 GraphQL 请求没有请求用户无权查看的数据。让我们在下一个列表中使用一个 where 规则,这样我们就不必担心请求认证用户无权查看的数据。
列表 7.12 api/index.js:使用 where 授权规则
extend type User
@auth(
rules: [
{ operations: [READ], where: { userId: "$jwt.sub" } }
{ operations: [CREATE, UPDATE, DELETE], roles: ["admin"] }
]
)
我们仍然需要确保只有管理员用户能够创建、更新或删除用户,因此我们将这些操作添加到角色规则中,如列表 7.13 所示。现在,每当执行对用户类型的读取请求时,生成的数据库查询中都会添加一个谓词,以过滤出当前认证的用户,将 JWT 的 sub 声明与数据库中 userId 节点属性值相匹配。
列表 7.13 GraphQL 查询请求用户信息
query {
users {
name
userId
}
}
{
"data": {
"users": [
{
"name": "Jenny",
"userId": "u3"
}
]
}
}
如果我们检查发送到数据库的生成的 Cypher 查询,我们可以看到附加的谓词,确保数据库中节点的 userId 属性值与 JWT 的 sub 值相匹配,如下列所示。
列表 7.14 生成的 Cypher 查询
MATCH (this:User)
WHERE this.userId IS NOT NULL AND this.userId = "u3"
RETURN this { .name, .userId } as this
7.3.6 绑定授权规则
绑定规则用于在创建或更新数据时强制执行授权规则,也可以用于跨关系。在列表 7.15 中,让我们使用绑定规则来确保在创建或更新审查时,它们与当前认证的用户相关联。我们不希望允许用户伪造其他用户的审查!
列表 7.15 api/index.js:使用绑定授权规则
extend type Review
@auth(
rules: [
{
operations: [CREATE, UPDATE]
bind: { user: { userId: "$jwt.sub" } }
}
]
)
让我们在下一个列表中编写一个 GraphQL 突变来创建一个新的商业审查。
列表 7.16 创建新的审查
mutation {
createReviews(
input: {
business: { connect: { where: { node: { businessId: "b10" } } } }
date: "2022-01-22"
stars: 5.0
text: "Love the Philtered Soul!"
user: { connect: { where: { node: { userId: "u3" } } } }
}
) {
reviews {
business {
name
}
text
stars
}
}
}
这没有问题执行,将审查节点添加到数据库中并建立适当的关系:
{
"data": {
"createReviews": {
"reviews": [
{
"business": {
"name": "Philz Coffee"
},
"text": "Love the Philtered Soul!",
"stars": 5
}
]
}
}
}
然而,如果连接审查与当前认证用户(在这种情况下,userId 为 u3 的用户)的操作失败,尝试连接到用户 u1 或没有任何用户,那么突变操作将失败,并返回一个禁止错误。
请务必参考文档以获取更多关于如何使用@auth GraphQL 模式指令将复杂授权规则添加到您的 GraphQL API 的示例:neo4j.com/docs/graphql-manual/current/auth。
到目前为止,我们一直在使用 JWT Builder 网站创建我们的 JWT;这对于开发和测试来说是不错的,但我们还需要为生产环境准备更多。
7.4 Auth0:JWT as a service
Auth0 是一个认证和授权服务,可以使用多种方法认证用户,例如社交登录或电子邮件和密码。它还包括维护用户数据库的功能,我们可以用它来定义用户的规则和权限。我喜欢将 Auth0 视为 JWT-as-a-service 提供商。尽管 Auth0 有很多功能和服务,但最终,我通常只对获取用户的认证令牌(作为 JWT)并使用它来授权我的 API 和应用程序感兴趣。
Auth0 也是一个很好的学习和发展的服务,因为它提供免费层,无需信用卡即可注册。在本节中,我们将配置 Auth0 以保护我们的 API,然后使用 Auth0 React SDK 将 Auth0 支持添加到我们的应用程序中。您可以在auth0.com免费创建 Auth0 账户。
7.4.1 配置 Auth0
一旦我们登录到 Auth0,我们将在我们的 Auth0 租户中创建一个 API 和一个应用程序(见图 7.6)。首先,创建 API 并为其命名。

图 7.6 在 Auth0 中创建 API
我们不会在我们的应用程序中使用此功能,但我们可以选择性地为我们的 API 启用基于角色的访问控制(RBAC)(见图 7.7)。这将允许我们向 Auth0 生成的 JWT 添加细粒度的权限,这些权限可以用于基于角色的访问控制(@auth)模式指令规则。

图 7.7 在 Auth0 中为我们的 API 启用 RBAC
如果我们启用 RBAC,我们还需要定义我们 API 中可以使用的所有可能的权限。我已经为我们的 API 中创建、读取、更新和删除业务添加了必要的权限(见图 7.8)。

图 7.8 在 Auth0 中的 API 中添加权限
您可以在 Neo4j GraphQL 库文档中了解更多关于使用角色授权规则启用 RBAC 的信息:mng.bz/5Q5z。
现在,我们需要在 Auth0 控制台中创建我们的应用程序。选择 创建应用程序。我们需要为我们的应用程序选择一个名称——我使用了 Business Reviews。我们还被要求选择应用程序类型。由于我们正在构建一个 React 应用程序,请选择 单页 Web 应用程序,然后点击 创建 按钮。
我们将保留大多数默认设置,但我们必须更新 允许回调 URL 和 允许注销 URL 的条目。在我们的新应用程序的 设置 选项卡下,将 http://localhost:3000 添加到每个文本框中,然后选择 保存更改。
接下来,我们需要更新我们的 GraphQL API 中的配置,指定用于验证 Auth0 生成的 JWT 的方法,如图 7.17 列表所示。到目前为止,我们一直在使用存储在环境变量(JWT_SECRET)中的简单密钥来验证 JWT。这对于本地开发和测试来说是不错的,但现在我们使用 Auth0 并准备将我们的应用程序部署到网络上,我们希望使用更安全的方法。
导航到 高级设置 然后选择 端点。查找 JWKS URL,并复制此值。然后,在我们的 GraphQL API 代码中,将用于验证 JWT 的方法更改为使用 Auth0 应用程序的 URL 的 jwksEndpoint。这将允许我们的 GraphQL API 从 Auth0 获取公钥以验证令牌,这是一种比使用共享密钥更安全的验证方法。
列表 7.17 api/index.js:使用 Auth0 JSON Web Key Set (JWKS) 端点
const {
Neo4jGraphQLAuthJWKSPlugin, ❶
} = require("@neo4j/graphql-plugin-auth");
...
const neoSchema = new Neo4jGraphQL({
typeDefs,
resolvers,
driver,
plugins: {
auth: new Neo4jGraphQLAuthJWKSPlugin({
jwksEndpoint: "https://grandstack.auth0.com/.well-known/jwks.json", ❷
}),
},
});
❶ 现在我们使用 Neo4jGraphQLAuthJWKSPlugin 类。
❷ 请确保使用在 Auth0 高级设置中找到的端点。
我们现在准备好开始将 Auth0 集成到我们的 React 应用程序中。
7.4.2 Auth0 React
首先,让我们安装 Auth0 SDK for React。这个包包括为添加 Auth0 支持到任何 React 应用程序而设计的特定于 React 的集成。
我们将使用 npm 安装 auth0-react 库。首先,确保你处于 web-react 目录中:
npm install @auth0/auth0-react
现在,让我们在下一个列表中将初始 Auth0 设置添加到我们的 React 应用程序中。
列表 7.18 web-react/src/index.js:添加 Auth0 提供者组件
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import {
ApolloClient,
InMemoryCache,
ApolloProvider,
makeVar,
} from "@apollo/client";
import { Auth0Provider } from "@auth0/auth0-react"; ❶
export const starredVar = makeVar([]);
const client = new ApolloClient({
uri: "http://localhost:4000",
cache: new InMemoryCache({
typePolicies: {
Business: {
fields: {
isStarred: {
read(_, { readField }) {
return starredVar().includes(readField("businessId"));
},
},
},
},
},
}),
});
ReactDOM.render(
<React.StrictMode>
<Auth0Provider ❷
domain="grandstack.auth0.com"
clientId="4xw3K3cjvw0hyT4Mjp4RuOVSxvVYcOFF"
redirectUri={window.location.origin}
audience="https://reviews.grandstack.io"
>
<ApolloProvider client={client}>
<App />
</ApolloProvider>
</Auth0Provider>
</React.StrictMode>,
document.getElementById("root")
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
❶ 导入 Auth0 Provider 组件。
❷ 将我们的 App 组件包裹在 Auth0Provider 组件中。
我们添加 Auth0Provider 组件,通过包裹我们的 ApolloProvider 和 App 组件将其注入到组件层次结构中。我们还包含了我们的 Auth0 租户、应用程序和 API 的域、客户端 ID 和受众信息,这些信息可以在 Auth0 仪表板中找到。
在接下来的列表中,我们将使用 Auth0 将登录和注销按钮添加到我们的应用程序中。点击登录按钮将引导用户通过 Auth0 的认证流程。
列表 7.19 web-react/src/App.js:添加登录和注销按钮
import React, { useState } from "react";
import BusinessResults from "./BusinessResults";
import { gql, useQuery } from "@apollo/client";
import { useAuth0 } from "@auth0/auth0-react"; ❶
const GET_BUSINESSES_QUERY = gql`
query BusinessesByCategory($selectedCategory: String!) {
businesses(
where: { categories_SOME: { name_CONTAINS: $selectedCategory } }
) {
businessId
name
address
categories {
name
}
isStarred @client
}
}
`;
function App() {
const [selectedCategory, setSelectedCategory] = useState("");
const { loginWithRedirect, logout, isAuthenticated } = useAuth0(); ❷
const { loading, error, data, refetch } = useQuery(
GET_BUSINESSES_QUERY,
{
variables: { selectedCategory },
}
);
if (error) return <p>Error</p>;
if (loading) return <p>Loading...</p>;
return (
<div>
{!isAuthenticated && ( ❸
<button onClick={() => loginWithRedirect()}>Log In</button>
)}
{isAuthenticated && (
<button onClick={() => logout()}>Log Out</button>
)}
<h1>Business Search</h1>
<form>
<label>
Select Business Category:
<select
value={selectedCategory}
onChange={(event) => setSelectedCategory(event.target.value)}
>
<option value="">All</option>
<option value="Library">Library</option>
<option value="Restaurant">Restaurant</option>
<option value="Car Wash">Car Wash</option>
</select>
</label>
<input type="button" value="Refetch" onClick={() => refetch()} />
</form>
<BusinessResults businesses={data.businesses} />
</div>
);
}
export default App;
❶ 导入 useAuth0 React 钩子。
❷ 访问函数以处理认证流程和用户数据。
❸ 添加登录和注销按钮。
Auth0 React 包包括一个 useAuth0 钩子,它为我们提供了可以触发认证流程、确定用户是否当前已认证以及访问用户信息的函数。现在,我们有一个带有登录选项的按钮,或者如果我们已经登录,我们就有注销的选项。
点击“登录”,我们会看到一系列登录选项,包括 GitHub、Google、Twitter 或电子邮件和密码认证(见图 7.9)。使用认证服务的优点之一是我们实际上不需要关心认证流程的具体细节,因为这是由 Auth0 处理的。

图 7.9 通过 Auth0 的登录选项
注意使用了由 useAuth0 钩子提供的 isAuthenticated 变量。一旦他们登录,我们也可以访问用户信息。现在,让我们添加一个配置文件组件,在用户登录后显示他们的姓名和头像图片。在 web-react/src 目录中创建一个新文件 Profile.js,如下所示。
列表 7.20 web-react/src/Profile.js:添加用户配置文件组件
import { useAuth0 } from "@auth0/auth0-react";
const Profile = () => {
const { user, isAuthenticated } = useAuth0();
return (
isAuthenticated && (
<div style={{ padding: "10px" }}>
<img
src={user.picture}
alt="User avatar"
style={{ width: "40px" }}
/>
<strong>{user.name}</strong>
</div>
)
);
};
export default Profile;
现在,让我们将这个配置文件组件包含到我们的主 App 组件中,以便在用户登录时显示配置文件。
列表 7.21 web-react/src/App.js:添加配置文件组件
import Profile from "./Profile";
...
{!isAuthenticated && (
<button onClick={() => loginWithRedirect()}>Log In</button>
)}
{isAuthenticated && <button onClick={() => logout()}>Log Out</button>}
<Profile /> ❶
<h1>Business Search</h1>
...
❶ 添加配置文件组件
好的,我们现在可以让用户登录到我们的应用程序并显示他们的配置文件信息,如图 7.10 所示,但我们如何向我们的 GraphQL API 发送认证请求呢?当我们使用 Apollo Studio 时,我们看到了需要在 GraphQL 请求中附加授权令牌作为头部。

图 7.10 我们 React 应用程序的认证视图
为了访问令牌,我们将使用 auth0-react 库中的 getAccessTokenSilently 函数。然后,我们将把这个令牌附加到 Apollo Client 实例中,如下一列表所示。
列表 7.22 web-react/src/index.js:在我们的 GraphQL 请求中添加访问令牌
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import {
ApolloClient,
InMemoryCache,
ApolloProvider,
makeVar,
createHttpLink,
} from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import { Auth0Provider, useAuth0 } from "@auth0/auth0-react";
export const starredVar = makeVar([]);
const AppWithApollo = () => { ❶
const { getAccessTokenSilently, isAuthenticated } = useAuth0();
const httpLink = createHttpLink({
uri: "http://localhost:4000",
});
const authLink = setContext(async (_, { headers }) => { ❷
// Only try to fetch access token if user is authenticated
const accessToken = isAuthenticated
? await getAccessTokenSilently()
: undefined;
if (accessToken) {
return {
headers: {
...headers,
authorization: accessToken ? `Bearer ${accessToken}` : "",
},
};
} else {
return {
headers: {
...headers,
// We could set additional headers here or a "default"
// authorization header if needed
},
};
}
});
const client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache({
typePolicies: {
Business: {
fields: {
isStarred: {
read(_, { readField }) {
return starredVar().includes(readField("businessId"));
},
},
},
},
},
}),
});
return (
<ApolloProvider client={client}>
<App />
</ApolloProvider>
);
};
ReactDOM.render(
<React.StrictMode>
<Auth0Provider
domain="grandstack.auth0.com"
clientId="4xw3K3cjvw0hyT4Mjp4RuOVSxvVYcOFF"
redirectUri={window.location.origin}
audience="https://reviews.grandstack.io"
>
<AppWithApollo /> ❸
</Auth0Provider>
</React.StrictMode>,
document.getElementById("root")
reportWebVitals();
❶ 创建一个包装组件,该组件将负责添加授权令牌。
❷ 使用 Apollo Client 的 setContext 函数将 JWT 添加到 GraphQL 请求中。
❸ 将 AppWithApollo 组件注入到 React 组件层次结构中。
现在,如果用户经过身份验证,每次对 GraphQL API 的请求都会在头部包含授权令牌。我们可以通过打开浏览器开发者工具并检查 GraphQL 网络请求(见图 7.11)来验证这一点。

图 7.11 在浏览器开发者工具窗口中查看附加到 GraphQL 请求的授权头部
我们可以复制这个令牌并使用 jwt.io 来解码其有效载荷。以下是我的解码令牌的样子:
{
"iss": "https://grandstack.auth0.com/",
"sub": "github|1222454",
"aud": [
"https://reviews.grandstack.io",
"https://grandstack.auth0.com/userinfo"
],
"iat": 1599684745,
"exp": 1599771145,
"azp": "4xw3K3cjvw0hyT4Mjp4RuOVSxvVYcOFF",
"scope": "openid profile email"
}
当然,我们的应用程序看起来没有任何不同,因为我们没有在我们的 GraphQL 查询中请求任何受保护的字段。让我们在用户登录时,将受 isAuthenticated 规则保护的 averageStars 字段添加到 GraphQL 查询中。
列表 7.23 web-react/src/App.js:在选择集中包含 averageStars 字段
function App() {
const [selectedCategory, setSelectedCategory] = useState("");
const { loginWithRedirect, logout, isAuthenticated } = useAuth0();
const GET_BUSINESSES_QUERY = gql`
query BusinessesByCategory($selectedCategory: String!) {
businesses(
where: { categories_SOME: { name_CONTAINS: $selectedCategory } }
) {
businessId
name
address
categories {
name
}
${isAuthenticated ? "averageStars" : ""} ❶
isStarred @client
}
}
`;
const { loading, error, data, refetch } = useQuery(
GET_BUSINESSES_QUERY,
{
variables: { selectedCategory },
}
);
if (error) return <p>Error</p>;
if (loading) return <p>Loading...</p>;
❶ 当用户经过身份验证时添加 averageStars 字段。
现在我们将更新 BusinessResults 组件,以便在用户经过身份验证时包含 averageStars。
列表 7.24 web-react/src/BusinessResults.js:显示 averageStars 字段
import React from "react";
import { starredVar } from "./index";
import { useAuth0 } from "@auth0/auth0-react";
function BusinessResults(props) {
const { businesses } = props;
const starredItems = starredVar();
const { isAuthenticated } = useAuth0();
return (
<div>
<h2>Results</h2>
<table>
<thead>
<tr>
<th>Star</th>
<th>Name</th>
<th>Address</th>
<th>Category</th>
{isAuthenticated ? <th>Average Stars</th> : null} ❶
</tr>
</thead>
<tbody>
{businesses.map((b) => (
<tr key={b.businessId}>
<td>
<button
onClick={() =>
starredVar([...starredItems, b.businessId])
}
>
Star
</button>
</td>
<td style={b.isStarred ? { fontWeight: "bold" } : null}>
{b.name}
</td>
<td>{b.address}</td>
<td>
{b.categories.reduce(
(acc, c, i) => acc + (i === 0 ? " " : ", ") + c.name,
""
)}
</td>
{isAuthenticated ? <td>{b.averageStars}</td> : null} ❷
</tr>
))}
</tbody>
</table>
</div>
);
}
export default BusinessResults;
❶ 仅当用户经过身份验证时,才添加“平均星级”标题。
❷ 当用户经过身份验证时显示平均星级值。
现在,我们只有在用户经过身份验证时才会看到每个企业的平均星级。我们已经将身份验证和授权添加到我们的应用程序中,并添加了对 Auth0 的支持。现在,我们相信我们的应用程序是安全的,我们将在下一章中查看如何部署我们的应用程序和数据库。
7.5 练习
-
创建一个新的查询字段 qualityBusinesses,它使用 @cypher 架构指令来返回每个企业至少有两个评论,每个评论至少有四个星级的业务。使用角色规则和 @auth 架构指令来保护此字段,要求分析师角色。创建一个包含此角色的声明的 JWT,并使用 Apollo Studio 来查询这个新的 qualityBusinesses 字段。
-
在本章中,我们使用 GraphQL 变更来创建新的企业评论。更新 React 应用程序以包括一个表单,允许当前经过身份验证的用户创建新的企业评论。
摘要
-
可以使用 @auth GraphQL 架构指令在 GraphQL 模式中以声明方式表达授权规则。
-
JWT 是一种用于编码和传输 JSON 对象的标准,通常用于 Web 应用程序中的授权令牌,如 GraphQL API。
-
Auth0 是一种身份和访问管理服务,可用于处理 JWT 生成和用户认证。Auth0 可以集成到 React 应用程序中,使用 Auth0 React SDK。
8 部署我们的全栈 GraphQL 应用程序
本章涵盖
-
部署我们的全栈 GraphQL 应用程序,使其可供网络上的用户访问
-
使用无服务器部署和云托管服务,如 Netlify、AWS Lambda 和 Neo4j Aura
-
评估各种部署选项的框架,帮助我们解决固有的权衡
在迄今为止的开发过程中,我们一直在本地机器上运行我们的应用程序进行测试。现在,是时候部署我们的应用程序,以便我们可以与世界分享它,并让用户与之交互。部署应用程序的方法有很多种,尤其是在云托管服务的增长和演变中,这些服务提供了改进的开发体验和定价。没有一种单一的最好部署选项适用于任何应用程序,因为每个选择都有权衡;最终,开发者必须决定哪些选项对他们及其用例最有意义。
在本章中,我们探讨了一种有见地的部署我们的全栈 GraphQL 应用程序的方法,利用第三方服务提供商,如 Netlify、AWS Lambda 和 Neo4j Aura。这种利用托管服务、将这些服务的许多运营外包给提供商的方法通常被称为无服务器。我们使用一个关注运营、规模和开发者体验的框架来评估这种部署方法的优缺点。最后,我们回顾了其他部署选项,并讨论了引入的权衡。
8.1 部署我们的全栈 GraphQL 应用程序
无服务器计算是一种描述按需分配计算资源和执行方式的范例;它是开发者无需关心服务器配置和维护即可交付其应用程序的方式。像 AWS Lambda Function as a Service (FaaS) 平台这样的服务被称为无服务器——并不是因为在这个过程中没有服务器参与服务应用程序,而是开发者无需考虑服务器,相反,相关的抽象变成了函数,或代码单元。无服务器这一术语的使用已经扩展,不仅描述了像 AWS Lambda 和 Google App Engine 这样的计算运行时,还包括数据库和其他托管云服务。
我们将要考察的第一个部署范式利用了托管服务。托管服务是将操作软件、基础设施或网络的责任外包给云服务提供商的一种方式。这意味着开发者可以花更少的时间维护和操作数据库、扩展 Web 服务器、安装安全更新以及续订 SSL 证书,相反,他们可以专注于构建他们在其中具有竞争优势的应用程序方面,例如核心业务能力和业务逻辑。我们的方法对全栈开发者特别有吸引力,他们可能不是数据库、服务器管理、处理 SSL 证书、DNS 配置和其他操作全栈 Web 应用程序所需方面的专家,或者他们不愿意承担这些责任。
8.1.1 这种部署方法的优势
采用托管服务相对于其他方法具有优势。在这里,我们强调了开发者生产力、基于使用的定价、可扩展性和维护和运营的优势。
开发者生产力
许多托管服务以提供改进的开发者体验而自豪,这种体验抽象了许多与开发者目标无关的不必要复杂性和关注点:构建和发布他们的应用程序。像 Web 控制台这样的工具可以配置服务,以及可以集成到开发者工作流程中的命令行界面(CLI),使开发者能够更加高效。
使用定价
根据服务的使用情况来产生成本是这个范式的一个核心原则。如果一个应用程序的使用非常少,那么对于开发者来说,产生的成本也会很小。这允许开发者以较低的前期成本构建、部署和测试他们的应用程序,因为他们的成本不是固定的。
可扩展性
服务应根据需求驱动进行扩展。例如,AWS Lambda 这样的函数即服务(FaaS)运行时会在响应事件时执行,例如调用 API 端点。每个函数调用都是无状态的,可以并发运行,这比单个 Web 服务器提供了更大的弹性和按需扩展能力。
维护和运营
通过使用托管服务,确保服务在健康、安全和高性能状态下的责任被外包给了服务提供商。这种好处通常与那些通常负责整个应用程序许多组件的全栈开发者产生共鸣。
8.1.2 我们部署方法的不利因素
当然,托管服务并不是一个万能的解决方案,它不能解决我们所有的问题,而且可能会带来一些不利因素。这些不利因素包括供应商锁定、性能优化和基于使用的定价(一把双刃剑!)。
供应商锁定
将维护和更新服务的责任外包给服务提供商意味着开发者将依赖于服务提供商提供服务的持续运行——并且成本合理。服务有时可能会被停止或弃用,因为许多服务都有特定的 API、库或范式,这可能对开发者适应替代方案来说成本高昂。
性能优化
由于许多服务在多租户架构中运行,性能可能无法始终保证一致,因为资源可能被共享或分配给其他用户。鉴于许多服务的按需性质,可能会有一些开销,因为资源被配置以响应增加的使用量。
使用费用
使用费用可能既有利又有弊。如果成本结构和使用模式没有得到理解,或者如果出现了未预见的用量激增,那么成本的不预期增加可能会非常不受欢迎。
8.1.3 我们对完整堆栈 GraphQL 的方法概述
我们的部署方法将利用三种托管服务(见图 8.1):
-
Neo4j Aura 数据库作为服务—用于在云中部署一个可管理的、可扩展的图数据库。通过使用 Neo4j Aura,我们消除了考虑如何管理我们的数据库实例的需求。操作和维护,如定期备份和更新,都由服务为我们处理。
-
Netlify Build—用于构建、部署和更新我们的 React 应用程序,并通过内容分发网络(CDN)在全球范围内提供服务。使用 Netlify 平台不仅将使我们能够访问全球 CDN,以确保无论用户位于世界何处,网站都能快速加载,而且 Netlify 还提供流畅的开发者体验,并与版本控制系统(如 GitHub)集成。
-
AWS Lambda (via Netlify Functions)—用于将我们的 GraphQL API 部署为一个可扩展的无服务器函数。使用 AWS Lambda 部署我们的 GraphQL API 意味着我们不需要考虑托管和管理 web 服务器,以及随着请求量的增加而上下调整服务器规模。

图 8.1 从用户角度看到的完整堆栈 GraphQL 部署
8.2 Neo4j Aura 数据库作为服务
Neo4j Aura 是 Neo4j 的托管云服务,提供 Neo4j 数据库集群作为云服务。Neo4j Aura 提供可扩展、高可用的 Neo4j 集群,无需处理操作或维护。开发者可以一键部署 Neo4j 集群,并访问 Neo4j 开发者工具,如 Neo4j 浏览器、Neo4j Bloom 和 APOC 标准库。Neo4j Aura 有两种版本:AuraDB 和 AuraDS。AuraDB 是 Neo4j 的标准数据库作为服务提供,适用于支持 Web 应用程序和 API 服务。AuraDS 是 Neo4j 的托管图数据科学平台,包括针对数据科学工作负载的特定功能。对于我们的目的,我们将使用 Neo4j AuraDB。
8.2.1 创建 Neo4j Aura 集群
由于 Neo4j Aura 是一个托管服务,我们首先需要通过登录 Google 账户或使用电子邮件和密码在 neo4j.com/aura 创建账户,然后选择“立即注册”。由于我使用 Gmail,我将选择使用 Google 登录。登录后,我们将看到 Neo4j Aura 仪表板。
Neo4j Aura 仪表板是我们云中 Neo4j 集群的指挥控制中心。我们可以监控我们的数据库,配置新的数据库,导入数据,调整数据库大小,以及访问开发者工具。
然而,由于我们尚未创建任何 Neo4j Aura 集群,我们的仪表板看起来是空的。让我们通过点击“创建数据库”按钮(见图 8.2)来创建一个新的集群。AuraDB 免费层提供无任何成本且无需输入信用卡的 Neo4j 实例,所以我会选择这个选项。对于大型应用程序,我们可以选择 AuraDB 专业层,它提供额外的功能和扩展数据库实例可用资源的可能性。

图 8.2 配置 Neo4j AuraDB 部署
一定要选择“AuraDB 免费版”数据库类型。接下来,我们需要为我们的数据库选择一个名称。我选择了“GRANDstack Business Reviews”。我们可以选择数据库部署的不同区域。我只是保留了默认设置,但您也可以选择离您最近的位置。在“起始数据集”选项中,我们可以选择从预定义的数据集开始,或者加载我们自己的数据。由于我们将使用自己的数据,请选择“在空白数据库中加载或创建自己的数据”。在选择了配置选项后,我们将看到一个随机密码,我们将使用它来访问我们的 Neo4 Aura 实例(见图 8.3)。

图 8.3 Neo4j AuraDB 部署的数据库凭据
一定要将密码保存在安全的地方。我们将更改密码,但稍后使用 Neo4j 浏览器登录时还需要它。
点击“继续”将带我们回到 Neo4j Aura 仪表板,但现在,我们将看到我们刚刚部署的数据库集群的详细信息,包括“探索”、“查询”或“导入”的选项(见图 8.4)。“探索”按钮将启动 Neo4j 浏览器,我们在前面的章节中使用它来执行 Cypher 查询并可视化结果。“查询”按钮将启动 Neo4j Bloom,这是一个可视化的图形探索工具,我们将在稍后探索它。最后,“导入”按钮将启动 Neo4j 数据导入器,这是一个将数据从如 CSV 格式的平面文件加载到 Neo4j 的工具。

图 8.4 Neo4j AuraDB 仪表板,显示我们的新数据库
如果我们点击数据库名称,我们可以看到更多详细信息和针对我们的数据库的特定选项。对于我们的集群,我们可以看到以下详细信息:
-
连接 URI—这是用于使用 Neo4j 客户端驱动程序连接到我们的 Neo4j 集群的连接字符串。
-
层级—这告诉我们数据库的服务层级(免费、专业或企业)。
-
云服务提供商—这是部署此集群的云平台。在这种情况下,它是 Google Cloud Platform。
-
区域—这是集群部署的数据中心的地理区域。
-
内存—这是数据库的当前大小,可以在任何时候进行扩展或缩减。
我们还有一个“打开方式”下拉按钮,用于访问 Neo4j 浏览器或 Neo4j Bloom 开发者工具。
8.2.2 连接到 Neo4j Aura 集群
现在我们已经配置了 Neo4j Aura 集群,我们准备好使用 Neo4j JavaScript 驱动程序连接到它。首先,让我们更改 neo4j 数据库用户的初始密码。为此,我们将通过点击查询按钮启动 Neo4j 浏览器。这将打开我们熟悉的 Neo4j 浏览器。请参考第三章以了解如何使用 Neo4j 浏览器。我们将被提示使用分配给我们的 neo4j 数据库用户和初始密码进行登录。
登录后,让我们更改用户 neo4j 的密码。为此,我们需要在系统数据库上执行 Cypher 命令。任何管理命令,如更改用户密码,都需要针对此系统数据库执行。首先,我们告诉 Neo4j 浏览器切换到系统数据库:
:use system
然后,我们将使用 ALTER CURRENT USER Cypher 命令更改默认 neo4j 用户的密码:
ALTER CURRENT USER SET PASSWORD FROM
"<OUR_RANDOM_INITIAL_PASSWORD_HERE>" TO "<NEW_SECRET_PASSWORD_HERE>"
请确保将<OUR_RANDOM_INITIAL_PASSWORD_HERE>替换为初始密码,将<NEW_SECRET_PASSWORD_HERE>替换为新安全密码。对于剩余的示例,我们将使用密码graphqlapi,但鼓励使用更强的密码。要切换回默认的neo4j数据库,我们可以使用命令:use neo4j。
注意:像:use 这样的命令是针对 Neo4j 浏览器的特定实用命令,不是 Cypher 命令。有关在 Neo4j 浏览器中使用这些命令的更多信息,请运行:help 或:help commands。
现在我们已经更改了数据库用户的密码,让我们测试是否可以使用 Neo4j JavaScript 驱动程序连接到我们的 Neo4j Aura 集群。从 Aura 仪表板,如果我们点击我们的数据库名称,我们可以看到代码示例,展示如何使用不同的语言驱动程序连接到我们的 Neo4j Aura 实例(见图 8.5)。

图 8.5 Neo4j Aura 中的“连接”选项卡,显示各种语言的代码示例
在列表 8.1 中,让我们将 JavaScript 示例调整为简单地计算数据库中的节点数并返回结果。我们将在 API 目录中创建一个新文件,命名为 aura-connect.js,并包含我们的简化 JavaScript 示例。
注意代码示例中使用的 neo4j+s:// URI 方案。之前,我们使用 bolt://,它表示连接到特定的 Neo4j 实例。在 Neo4j Aura 中,我们已经部署了一个集群——一系列相互通信以复制和分发数据的 Neo4j 实例——因此我们使用 neo4j 方案来告诉驱动器将请求路由到集群中的不同机器,而不是单个机器。+s 告诉驱动器我们想要使用安全的加密连接。
列表 8.1 aura-connect.js:查询我们的 Neo4j Aura 实例
(async () => {
const neo4j = require("neo4j-driver");
// be sure to change these connection details for your Neo4j instance
const uri = "neo4j+s://97a0fe69.databases.neo4j.io";
const user = "neo4j";
const password = "graphqlapi";
const driver = neo4j.driver(uri, neo4j.auth.basic(user, password));
const session = driver.session();
try {
const readQuery = `MATCH (n)
RETURN COUNT(n) AS num`;
const readResult = await session.readTransaction((tx) =>
tx.run(readQuery)
);
readResult.records.forEach((record) => {
console.log(`Found ${record.get("num")} nodes in the database`);
});
} catch (error) {
console.error("Something went wrong: ", error);
} finally {
await session.close();
}
await driver.close();
})();
此代码导入 Neo4j JavaScript 驱动程序,使用我们的 Neo4j Aura 凭据创建驱动程序的实例,在读取事务中执行 Cypher 查询,然后将查询结果记录到控制台。如果我们运行此文件,我们应该验证我们能够连接到我们的 Neo4j Aura 数据库,并且数据库目前为空:
$ node aura-connect.js
Found 0 nodes in the database
我们的下一步是从我们用于开发应用程序的本地 Neo4j 实例上传数据到我们的 Neo4j Aura 数据库。
8.2.3 将数据上传到 Neo4j Aura
之前,我们使用 :play grandstack Neo4j Browser 指南来加载一些初始数据以导入我们的业务评论数据,但在此情况下,我们可能已添加用户信息、新的评论或更新了企业。让我们讨论将数据从本地 Neo4j 数据库导出到我们的新 Neo4j Aura 集群的过程。
将数据导入 Neo4j Aura 有多种不同的方法,但我们将使用推送到云的工具。如果您在 Neo4j Aura 仪表板中选择导入选项卡,您将看到一个类似向导的界面,引导您完成将本地 Neo4j 数据库上传到您的 Neo4j Aura 云数据库的步骤。我们现在将介绍这些步骤。
首先,我们想确保我们的本地 Neo4j 数据库处于停止状态。我们可以在 Neo4j Desktop 中验证这一点,如果它没有停止,则点击停止按钮(见图 8.6)。

图 8.6 停止数据库并打开管理面板
接下来,我们将在 Neo4j Desktop 中打开一个终端,这将允许我们运行针对此特定 Neo4j 实例的 neo4j-admin 命令。neo4j-admin 命令行工具具有一些有用的功能,例如从 CSV 文件中导入大量数据、生成推荐的内存配置以及推送到云的命令,我们将使用该命令将此数据库上传到我们的 Neo4j Aura 实例。
在打开按钮旁边的下拉箭头处选择,然后选择终端以打开一个带有命令提示符的新窗口。此新命令提示符的工作目录设置为安装此特定 Neo4j 实例的目录:
$ pwd
/Users/lyonwj/Library/Application Support/com.Neo4j.Relate/Data/dbmss/
dbms-54c2c495-211d-408d-8c9e-6a65cce61d91
现在,我们准备使用推送到云的命令将此数据库上传到 Neo4j Aura。我们将指定我们的 Neo4j Aura 实例的 Bolt URI 以及 --overwrite 标志,以指示我们想要替换在 Neo4j Aura 实例中可能已经创建的任何数据。我们将被提示输入数据库用户名和密码,然后我们的本地数据库将被导出并上传到我们的 Neo4j Aura 数据库:
$ bin/neo4j-admin push-to-cloud --bolt-uri \
neo4j+s://97a0fe69.databases.neo4j.io --database neo4j --overwrite
Neo4j aura username (default: neo4j):neo4j
Neo4j aura password for neo4j:
Done: 68 files, 879.4KiB processed.
Dumped contents of database 'neo4j' into '/Users/lyonwj/Library/Application
Support/com.Neo4j.Relate/Data/dbmss/
dbms-54c2c495-211d-408d-8c9e-6a65cce61d91/dump-of-neo4j-1612960685687'
Upload
.................... 10%
.................... 20%
.................... 30%
.................... 40%
.................... 50%
.................... 60%
.................... 70%
.................... 80%
.................... 90%
.................... 100%
We have received your export and it is currently being loaded into your
Aura instance.
You can wait here, or abort this command and head over to the console to
be notified of when your database is running.
Import progress (estimated)
.................... 10%
.................... 20%
.................... 30%
.................... 40%
.................... 50%
.................... 60%
.................... 70%
.................... 80%
.................... 90%
.................... 100%
Your data was successfully pushed to Aura and is now running.
现在,我们可以验证数据是否已上传到我们的 Neo4j Aura 实例。如果我们再次运行 aura-connect.js 脚本,我们应该看到数据库中总共有 36 个节点:
$ node aura-connect.js
Found 36 nodes in the database
8.2.4 使用 Neo4j Bloom 探索图
我们还可以直观地检查和探索我们刚刚上传到 Neo4j Aura 的数据。让我们回到 Neo4j Aura 仪表板,这次我们将使用 Neo4j Bloom 打开数据库。Neo4j Bloom 是一个用于与 Neo4j 图进行视觉交互的图探索应用程序,并包含在 Neo4j Aura 中。从 Neo4j Aura 仪表板,点击 探索 按钮。将打开一个新标签页,我们将被提示使用数据库用户名和密码登录。
一旦我们登录,Neo4j Bloom 将连接到我们的 Neo4j Aura 实例,并允许我们直观地探索我们的图数据。首先,我们需要配置一个 视角(见图 8.7)。在 Neo4j Bloom 中,一个视角定义了应该公开的图数据域或视图以及如何样式化这些数据。就我们的目的而言,默认生成的视角应该足够,因此选择 创建视角 从数据库生成一个视角,然后选择要用于可视化的视角。

图 8.7 在 Neo4j Bloom 中创建视角
一旦我们创建了视角,我们就可以开始对图进行视觉探索。Neo4j Bloom 中的这个主要视图称为场景,它为我们提供了一个画布,我们可以根据选择的数据在上面绘制图数据。要将数据带入场景,我们使用自然语言,如搜索栏中的搜索词,这些词将被翻译成图模式(见图 8.8)。例如,如果我们开始输入 User name: Will WROTE Review,我们可以看到开始为我们建议有用的自动完成图模式。选择这些模式之一将执行搜索,并将与图搜索模式匹配的数据填充到场景中。

图 8.8 Neo4j Bloom 中的自然语言搜索
我们之前提到,视角可以配置可视化样式(见图 8.9)。我们可以配置的一种样式是用于表示可视化中节点的图标。通过在图例面板中选择一个类别,我们可以应用样式,例如节点的颜色、大小、图标或标题。

图 8.9 在 Neo4j Bloom 中配置类别图标
可视化是交互式的,可以用来探索图或验证上传的数据是否符合预期。选择节点将允许我们查看它们的属性(见图 8.10)。我们还可以在节点或关系上右键单击以进一步扩展或过滤场景中显示的数据。

图 8.10 在 Neo4j Bloom 中查看节点详情
到目前为止,我们已经配置了 Neo4j Aura 集群,更改了密码,上传了数据,探索了数据,并在 Neo4j Bloom 中验证和探索了我们的图。现在,让我们将注意力转向使用 Netlify 和 AWS Lambda 部署我们的 React 应用程序和 GraphQL API。
8.3 使用 Netlify Build 部署 React 应用程序
要部署我们的 React 应用程序,我们将使用 Netlify。Netlify 是一个专注于构建和部署 Web 应用程序的流畅开发体验和工作流程的云平台。Netlify 结合了自动构建系统、全球内容分发网络、无服务器函数、边缘处理器和其他功能,所有这些都被封装在一个专注于流畅开发体验和工作流程的平台中。
具有类似功能的包括 Vercel、DigitalOcean App Platform、Cloudflare Pages 和 Azure Static Web Apps。Netlify 也提供免费层,因此我们可以部署我们的应用程序并尝试该服务,而无需输入信用卡或产生任何费用。
Netlify 还允许我们通过提交和拉取请求到 Git 版本控制系统(如 GitHub 或 GitLab)来触发构建和部署。在本节中,我们将使用 GitHub 来触发 Netlify 部署并展示 Netlify 的一个出色功能,称为预览构建,它允许我们从拉取请求中部署和测试应用程序。
8.3.1 将网站添加到 Netlify
让我们从导航到netlify.com并点击注册按钮来创建一个免费的 Netlify 账户开始。由于我们将利用 GitHub 集成从 GitHub 部署和更新我们的应用程序,我们可以使用 GitHub 账户登录 Netlify,然后我们的 Netlify 账户将与 GitHub 关联(见图 8.11)。我们还可以选择其他登录方式,例如电子邮件和密码,并在稍后选择将 Netlify 账户链接到 GitHub。

图 8.11 登录 Netlify
登录后,我们将看到我们添加到 Netlify 的网站概览(见图 8.12)。由于我们刚刚创建了账户,这个页面略显空旷。

图 8.12 Netlify 仪表板
要将我们的第一个站点添加到 Netlify,让我们为我们的应用程序创建一个 GitHub 仓库,这样我们就可以将其作为站点添加到 Netlify 以开始部署。我们需要为我们的应用程序创建一个新的 GitHub 仓库(见图 8.13)。为此,首先导航到 github.com/new。我们需要为我们的仓库选择一个名称——我选择了 grandstack-business-reviews。我们还可以选择将我们的仓库设置为私有,如果我们不想将其暴露给公众。

图 8.13 创建新的 GitHub 仓库
我们现在已创建了一个空的 GitHub 仓库,现在是时候将我们的业务评审应用程序代码添加到仓库中。此屏幕显示了初始化 git 仓库、提交代码和推送到 GitHub 所使用的常用终端命令(见图 8.14)。还有一个可以与 GitHub 一起使用的桌面客户端;然而,我们将使用命令行来完成这项工作。

图 8.14 将本地 Git 仓库推送到 GitHub 的说明
让我们打开一个终端,导航到包含我们一直在构建的 React 应用程序的 web-react 目录。首先,我们初始化一个空的 GitHub 仓库:
$ git init
我们可以使用 git status 命令查看我们本地工作目录的状态:
$ git status
On branch main
No commits yet
Untracked files:
(use "git add <file>..." to include in what will be committed)
.gitignore
README.md
package-lock.json
package.json
public/
src/
nothing added to commit but untracked files present (use "git add" to track)
在这种情况下,我们还没有向仓库提交任何内容,所以让我们暂存要添加的代码。为此,我们将使用 git add 命令:
$ git add -A
-A 标志表示我们想要将项目中的所有文件暂存以添加。我们通常不想将 所有 文件添加到仓库中;像 node_modules 目录和机密信息这样的东西不应该被提交到版本控制中。我们之前用来创建 React 应用程序骨架的 create-react-app 工具也创建了一个 .gitignore 文件,其中包含要排除的文件的规则。多亏了这个文件,我们可以在暂存文件进行提交时安全地使用 -A 标志。现在,当我们再次运行 git status 时,我们将看到要添加到我们提交中的所有文件:
$ git status
On branch main
No commits yet
Changes to be committed:
(use "git rm --cached <file>..." to unstage)
new file: .gitignore
new file: README.md
new file: package-lock.json
new file: package.json
new file: public/favicon.ico
new file: public/index.xhtml
new file: public/logo192.png
new file: public/logo512.png
new file: public/manifest.json
new file: public/robots.txt
new file: src/App.css
new file: src/App.js
new file: src/App.test.js
new file: src/BusinessResults.js
new file: src/Profile.js
new file: src/index.css
new file: src/index.js
new file: src/logo.svg
new file: src/serviceWorker.js
new file: src/setupTests.js
让我们使用 git commit 命令进行提交。每个提交还包括一个消息,表明提交中引入的原因或功能。此消息可以使用 -m 标志添加,或者我们可以省略该标志,然后被提示输入提交消息:
$ git commit -m "initial commit"
[main (root-commit) 0bb81ca] initial commit
20 files changed, 14609 insertions(+)
create mode 100644 .gitignore
create mode 100644 README.md
create mode 100644 package-lock.json
create mode 100644 package.json
create mode 100644 public/favicon.ico
create mode 100644 public/index.xhtml
create mode 100644 public/logo192.png
create mode 100644 public/logo512.png
create mode 100644 public/manifest.json
create mode 100644 public/robots.txt
create mode 100644 src/App.css
create mode 100644 src/App.js
create mode 100644 src/App.test.js
create mode 100644 src/BusinessResults.js
create mode 100644 src/Profile.js
create mode 100644 src/index.css
create mode 100644 src/index.js
create mode 100644 src/logo.svg
create mode 100644 src/serviceWorker.js
create mode 100644 src/setupTests.js
接下来,我们将我们的本地 Git 仓库与创建的远程 GitHub 仓库连接起来:
$ git remote add origin \
git@github.com:johnymontana/grandstack-business-reviews.git
最后,我们使用 git push 命令将我们的本地提交推送到远程 GitHub 仓库:
$ git push -u origin main
Enumerating objects: 24, done.
Counting objects: 100% (24/24), done.
Delta compression using up to 16 threads
Compressing objects: 100% (24/24), done.
Writing objects: 100% (24/24), 175.60 KiB | 1.60 MiB/s, done.
Total 24 (delta 0), reused 0 (delta 0)
To github.com:johnymontana/grandstack-business-reviews.git
* [new branch] main -> main
Branch 'main' set up to track remote branch 'main' from 'origin'.
如果我们刷新我们的 GitHub 仓库网页,现在我们将看到我们已提交的代码和提交历史(见图 8.15)。

图 8.15 在 GitHub 上查看我们的新仓库
现在,我们准备好使用 Netlify 部署我们的 React 应用程序。返回 Netlify 仪表板,点击 从 Git 添加网站。我们将被提示选择要连接的 Git 提供商以及要添加的存储库。选择 GitHub,并选择我们刚刚创建并推送代码的存储库(见图 8.16)。

图 8.16 在 Netlify 中添加新网站
Netlify 将检查代码以确定这是一个使用 npm run build 命令构建的 React 应用程序,内容应从 /build 目录提供。我们在这里通常不需要做任何更改,因为默认设置通常足以构建和部署我们的 React 应用程序。如果需要,我们可以在稍后更改这些构建设置(见图 8.17)。

图 8.17 配置我们的新 Netlify 网站
Netlify 现在将从 GitHub 拉取我们的代码以构建和部署网站。我们可以在构建过程中从仪表板查看构建日志。Netlify 中的每个网站都分配了一个 URL 和 SSL 证书,因此一旦构建和部署完成,我们就可以立即预览我们的应用程序,而无需添加自定义域名(见图 8.18)。

图 8.18 在 Netlify 中配置部署设置
一旦构建完成,我们就可以在网页浏览器中导航到我们的应用程序(见图 8.19)。在这种情况下,URL 是 hungry-thompson-86fbf3.netlify.app/。

图 8.19 Netlify 网站部署进行中
但我们有一个问题:GraphQL API 指向 http://localhost:4000,即我们的本地机器,这意味着加载此应用程序的其他人将无法连接到 GraphQL API 并查看这些结果。我们可以通过在网页浏览器中打开开发者工具并检查网络请求来验证这一点。我们将在下一节部署 GraphQL API 应用程序,但让我们先探索 Netlify 的几个功能(见图 8.20)。

图 8.20 我们新部署的应用程序
8.3.2 为 Netlify 构建设置环境变量
如果我们查看创建 Apollo 客户端实例以连接到我们的 GraphQL API 的 src/index.js,我们会看到我们已将 GraphQL API 的 URI 固定编码为 http://localhost:4000,如以下列表所示。
列表 8.2 src/index.js:使用 Apollo Link 连接到我们的 GraphQL API
...
const httpLink = createHttpLink({
uri: "http://localhost:4000",
});
...
这对于本地开发和测试来说是不错的,但现在我们希望使用相同的代码进行本地开发和我们的部署应用程序。为了允许在开发期间使用本地 GraphQL URI,但在我们的部署应用程序中连接到部署的 GraphQL API,我们将设置一个在构建时读取 GraphQL URI 的环境变量。我们将根据使用的环境确定此值——对于本地开发,我们将保持 GraphQL URI 为 http://localhost:4000,但我们将为我们的 Netlify 构建配置不同的值。
让我们创建一个 .env 文件来存储本地开发环境变量。Create React App 的一个便利功能是,任何在 .env 中指定的值都将被设置为环境变量,并且任何以 REACT_APP 开头的变量将在构建期间在客户端 React 应用程序中替换。让我们在下面的 .env 文件中设置我们想要用于开发的 GraphQL API 的本地值。
列表 8.3 .env:为我们的 React 应用程序设置环境变量
REACT_APP_GRAPHQL_URI=/graphql
NEO4J_URI=neo4j://localhost:7687
NEO4J_USER=neo4j
NEO4J_PASSWORD=letmein
REACT_APP_AUTH0_DOMAIN=grandstack.auth0.com
REACT_APP_AUTH0_CLIENT_ID=4xw3K3cjvw0hyT4Mjp4RuOVSxvVYcOFF
REACT_APP_AUTH0_AUDIENCE=https://reviews.grandstack.io
在下一节中,我们将更新我们的代码以从这些环境变量中读取,以设置我们的 GraphQL API 的 URI 并指定我们的 Auth0 域、客户端 ID 和受众值。
列表 8.4 src/index.js:使用环境变量
...
const httpLink = createHttpLink({
uri: process.env.REACT_APP_GRAPHQL_URI
});
...
ReactDOM.render(
<React.StrictMode>
<Auth0Provider
domain={process.env.REACT_APP_AUTH0_DOMAIN}
clientId={process.env.REACT_APP_AUTH0_CLIENT_ID}
redirectUri={window.location.origin}
audience={process.env.REACT_APP_AUTH0_AUDIENCE}
>
<AppWithApollo />
</Auth0Provider>
</React.StrictMode>,
document.getElementById("root")
);
对于本地开发,我们希望使用本地的 GraphQL API URI 进行开发,但在部署的应用程序中,我们希望 React 应用程序连接到部署的 GraphQL API。为了启用此功能,我们现在将在我们的网站 Netlify 构建设置中设置 REACT_APP_GRAPHQL_URI 环境变量(见图 8.21)。在我们的网站 Netlify 控制台中,选择 站点设置,然后在左侧导航中选择 构建和部署。我们将创建一个名为 REACT_APP_GRAPHQL_URI 的新环境变量,其值为 /graphql。

图 8.21 设置 Netlify 环境变量
这意味着我们的部署应用程序将尝试连接到同一域上的 /graphql 上的 GraphQL API。我们还没有在这里部署 GraphQL API,所以我们的应用程序现在将返回错误,直到我们添加 GraphQL API。
8.3.3 Netlify 部署预览
Netlify 等服务的一个便利功能是部署预览。部署预览 是由代码更改(通常来自拉取请求)触发的构建,部署到一个临时 URL,与主应用程序不同。这个构建具有主应用程序的所有功能,可以在拉取请求提交并更改反映在主应用程序之前与团队成员和其他利益相关者共享以进行审查。
让我们通过创建一个拉取请求并部署一个更新我们的 React 应用程序以从 REACT_APP_GRAPHQL_URI 环境变量中读取的预览来查看这是如何工作的。如果我们运行 git status 命令,我们会看到我们对 src/index.js 进行了更改:
$ git status
On branch main
Your branch is up to date with 'origin/main'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: index.js
no changes added to commit (use "git add" and/or "git commit -a")
让我们切换到一个新的 Git 分支,称为 env-var-graphql-uri。我们将把我们的更改提交到这个新分支:
$ git checkout -b env-var-graphql-uri
Switched to a new branch 'env-var-graphql-uri'
现在,让我们将我们的更改添加到 index.js 中进行提交。由于我们已经将我们的工作目录切换到了一个新的 Git 分支,这个提交将是对 env-var-graphql-uri 分支的提交,而不是主分支:
$ git add index.js
$ git commit -m "use environment variable to specify GraphQL URI"
[env-var-graphql-uri 92f1142] use environment variable to
specify GraphQLURI
1 file changed, 1 insertion(+), 1 deletion(-)
接下来,我们将这个新分支推送到 GitHub。由于我们正在将新分支推送到我们的远程仓库,GitHub 会友好地告诉我们,我们可以从这个新分支创建一个拉取请求:
$ git push origin env-var-graphql-uri
Enumerating objects: 7, done.
Counting objects: 100% (7/7), done.
Delta compression using up to 16 threads
Compressing objects: 100% (4/4), done.
Writing objects: 100% (4/4), 415 bytes | 415.00 KiB/s, done.
Total 4 (delta 3), reused 0 (delta 0)
remote: Resolving deltas: 100% (3/3), completed with 3 local objects.
remote:
remote: Create a pull request for 'env-var-graphql-uri'on
GitHub by visiting: remote: https://github.com/johnymontana/
grandstack-business-reviews/pull/new/env-var-graphql-uri
remote:
To github.com:johnymontana/grandstack-business-reviews.git
* [new branch] env-var-graphql-uri -> env-var-graphql-uri
拉取请求是从仓库的另一个分支或分叉请求更改以合并到主分支的一种方式。让我们创建一个拉取请求,请求将新的分支,env-var-graphql-uri,合并到主分支(见图 8.22)。

图 8.22 在 GitHub 中创建拉取请求
由于我们已经将 Netlify 连接到这个 GitHub 仓库,Netlify 将立即基于这个拉取请求中的更改启动部署预览构建。我们可以在 GitHub 上拉取请求页面的 检查 部分查看此构建的状态。一旦构建完成,我们可以访问这个部署预览来查看反映在实时部署中的更改(见图 8.23)。我们还可以将这个临时 URL 分享给其他人以审查网站更改。

图 8.23 从拉取请求触发 Netlify 部署预览
一旦我们对这些更改感到满意,我们就会合并拉取请求。我们可以在 GitHub 上通过点击 合并拉取请求 按钮来完成此操作。这将把从 env-var-graphql-uri 分支到主分支的更改合并。然后,这次合并将在 Netify 上触发构建和部署,这将替换我们应用程序的主版本(见图 8.24)。

图 8.24 查看我们的 Netlify 构建状态
现在我们已经部署了 React 应用程序,是时候部署我们的 GraphQL API 了。为此,我们将我们的 GraphQL API 转换为无服务器函数,以便可以在 AWS Lambda 服务上部署。我们将利用 Netlify Functions 功能来实现这一点。
8.4 使用 AWS Lambda 和 Netlify Functions 的无服务器 GraphQL
AWS Lambda 是一个 FaaS 计算平台,允许我们按需运行代码,而无需配置或管理服务器。函数在响应事件时被调用,例如 HTTP 请求。当与 AWS 的 API Gateway 服务结合使用时,Lambda 函数可以用来实现 API 端点和应用程序,例如 GraphQL API。AWS Lambda 支持 Node.js、Python、Java、Go、Ruby、Swift 和 C#,并且可以包含打包的依赖项。与其他按小时计费的成本云服务不同,AWS Lambda 的定价基于请求数量,以及这些请求的持续时间以 1 毫秒为增量进行测量。
Netlify 函数服务允许我们直接从 Netlify 函数部署 Lambda 函数,无需创建 AWS 账户。Netlify 使用与 Git 版本控制相同的特性(如部署预览)来处理 Lambda 函数的构建和部署,这意味着我们可以将 Lambda 函数的代码与网站的其他部分一起管理。目前,Netlify 可以部署 Node.js 和 Go 的 Lambda 函数。
到目前为止,我们已经将我们的 GraphQL API 应用程序作为 Node.js Express 服务器使用 Apollo Server 构建。在本节中,我们将使用特定于 Lambda 的 Apollo Server 版本将我们的 GraphQL API 转换为 Lambda 函数,并使用 Netlify 函数功能与我们的 Netlify 网站一起部署。
8.4.1 将 GraphQL API 作为 Lambda 函数提供服务
由于我们的 Lambda GraphQL API 将作为我们 Netlify 网站的一部分通过 Netlify 部署,我们将代码和依赖项放在现有的项目中。让我们安装所需的依赖项:
npm install apollo-server-lambda @neo4j/graphql
➥ @neo4j/graphql-plugin-auth neo4j-driver
注意,我们安装了 apollo-server-lambda,这是 Apollo Server 的一个特殊版本,它将允许我们将我们的 GraphQL API 结构化为 Lambda 函数。我们还安装了 Neo4j JavaScript 驱动程序、Neo4j GraphQL 集成库以及我们在上一章中看到的用于处理 JWT 的库。
让我们在与我们的 React 应用程序位于同一目录下创建一个新文件,src/graphql.js。稍后,我们将把这个文件提交到版本控制并推送到 GitHub,触发 Netlify 构建 和部署。我们将使用 apollo-server-lambda 创建一个简单的 GraphQL API,它有一个名为 greetings 的查询字段,返回一个问候消息,如下所示。
列表 8.5 src/graphql.js:使用 AWS Lambda 的简单 GraphQL API
const { ApolloServer, gql } = require("apollo-server-lambda"); ❶
const typeDefs = gql`
type Query {
greetings(name: String = "GRANDstack"): String
}
`;
const resolvers = {
Query: {
greetings: (parent, args, context) => {
return `Hello, ${args.name}!`;
},
},
};
const server = new ApolloServer({
typeDefs,
resolvers,
});
const serverHandler = server.createHandler();
exports.handler = (event, context, callback) => { ❷
return serverHandler(
{
...event,
requestContext: event.requestContext || {},
},
context,
callback
);
};
❶ 注意,我们正在导入 Apollo Server 的 apollo-server-lambda 版本。
❷ 由于我们正在创建 AWS Lambda 函数,我们需要导出一个包装我们的 Apollo Server 实例的处理函数。
接下来,我们需要配置我们的 Netlify 网站以便它知道我们创建的新 Lambda 函数的位置,并且我们希望在网站的 /graphql 端点处提供 GraphQL API。为此,我们将在项目的根目录中创建一个 netlify.toml 文件,如下所示。
列表 8.6 netlify.toml:配置 Netlify 构建
[build]
command = "npm run build"
functions = "src/lambda"
publish = "build"
[[redirects]]
from = "/graphql"
to = "/.netlify/functions/graphql"
status = 200
默认情况下,我们的 Netlify 函数在 /.netlify/functions/ 下暴露,后面跟着函数的文件名。我们创建了一个重定向,因此我们的 GraphQL API 可以在 /graphql 下访问。
8.4.2 Netlify 开发 CLI
到目前为止,我们将 Netlify 视为我们 React 应用程序的部署服务。如果我们想在本地构建和提供 React 应用程序,那么在运行 npm run start 时,我们使用了 react-scripts 工具,而没有涉及 Netlify。现在,我们添加 Lambda 函数后,我们需要做更多的工作来在本地测试我们的应用程序。我们将安装 Netlify 命令行工具,使用 Netlify dev 构建和运行我们的 GraphQL Lambda 函数和 React 应用程序:
$ npm install netlify-cli -g
现在我们已经安装了 Netlify CLI,我们可以使用 dev 命令在本地启动我们的网站。这将本地构建和提供我们的 React 应用程序和 Lambda 函数,而不会触发部署:
$ netlify dev
运行 netlify dev 后,我们可以打开一个网络浏览器并导航到 http://localhost:8888/graphql。我们应该看到 Apollo Studio,在那里我们可以运行针对我们的 Lambda GraphQL API 的 GraphQL 查询,如下所示。
列表 8.7 查询我们的简单 GraphQL API
{
greetings
}
此查询的结果将显示我们在解析器中定义的问候消息:
{
"data": {
"greetings": "Hello, GRANDstack!"
}
}
当然,这只是一个简单的 Hello World GraphQL API,所以让我们将业务评论应用程序的其余 GraphQL API 应用程序迁移过来。
8.4.3 将我们的 GraphQL API 转换为 Netlify 函数
如列表 8.8 所示,要将现有的 GraphQL API 转换为使用 AWS Lambda 和 apollo-server-lambda,我们需要更改几行。最显著的变化是使用 apollo-server-lambda 包,而不是 apollo-server-express,并为我们的 AWS Lambda 导出处理函数。否则,这看起来将与我们在第七章中构建的 GraphQL API 代码相似。
列表 8.8 src/graphql.js:将我们的 GraphQL API 转换为 AWS Lambda 函数
const { ApolloServer, gql } = require("apollo-server-lambda"); ❶
const neo4j = require("neo4j-driver");
const { Neo4jGraphQL } = require("@neo4j/graphql");
const {
Neo4jGraphQLAuthJWKSPlugin,
} = require("@neo4j/graphql-plugin-auth");
const resolvers = {
Business: {
waitTime: (obj, args, context, info) => {
var options = [0, 5, 10, 15, 30, 45];
return options[Math.floor(Math.random() * options.length)];
},
},
};
const typeDefs = gql`
type Query {
fuzzyBusinessByName(searchString: String): [Business]
@cypher(
statement: """
CALL
db.index.fulltext.queryNodes('businessNameIndex',
$searchString+'~')
YIELD node RETURN node
"""
)
}
type Business {
businessId: ID!
waitTime: Int! @computed
averageStars: Float!
@auth(rules: [{ isAuthenticated: true }])
@cypher(
statement: """
MATCH (this)<-[:REVIEWS]-(r:Review) RETURN avg(r.stars)
"""
)
recommended(first: Int = 1): [Business!]!
@cypher(
statement: """
MATCH (this)<-[:REVIEWS]-(:Review)<-[:WROTE]-(u:User)
MATCH (u)-[:WROTE]->(:Review)-[:REVIEWS]->(rec:Business)
WITH rec, COUNT(*) AS score
RETURN rec ORDER BY score DESC LIMIT $first
"""
)
name: String!
city: String!
state: String!
address: String!
location: Point!
reviews: [Review!]! @relationship(type: "REVIEWS", direction: IN)
categories: [Category!]!
@relationship(type: "IN_CATEGORY", direction: OUT)
}
type User {
userId: ID!
name: String!
reviews: [Review!]! @relationship(type: "WROTE", direction: OUT)
}
extend type User
@auth(
rules: [
{ operations: [READ], where: { userId: "$jwt.sub" } }
{ operations: [CREATE, UPDATE, DELETE], roles: ["admin"] }
]
)
type Review {
reviewId: ID! @id
stars: Float!
date: Date!
text: String
user: User! @relationship(type: "WROTE", direction: IN)
business: Business! @relationship(type: "REVIEWS", direction: OUT)
}
extend type Review
@auth(
rules: [
{
operations: [CREATE, UPDATE]
bind: { user: { userId: "$jwt.sub" } }
}
]
)
type Category {
name: String!
businesses: [Business!]!
@relationship(type: "IN_CATEGORY", direction: IN)
}
`;
const driver = neo4j.driver(
process.env.NEO4J_URI,
neo4j.auth.basic(process.env.NEO4J_USER, process.env.NEO4J_PASSWORD)
);
const neoSchema = new Neo4jGraphQL({
typeDefs,
resolvers,
driver,
plugins: {
auth: new Neo4jGraphQLAuthJWKSPlugin({
jwksEndpoint: "https://grandstack.auth0.com/.well-known/jwks.json",
}),
},
});
const initServer = async () => {
return await neoSchema.getSchema().then((schema) => {
const server = new ApolloServer({
schema,
context: ({ event }) => ({ req: event }), ❷
});
const serverHandler = server.createHandler();
return serverHandler;
});
};
exports.handler = async (event, context, callback) => { ❸
const serverHandler = await initServer();
return serverHandler(
{
...event,
requestContext: event.requestContext || {},
},
context,
callback
);
};
❶ 使用 Apollo Server 的 apollo-server-lambda 版本,而不是 apollo-server
❷ 我们在这里使用事件,因为 AWS Lambda 的请求签名与 Express 略有不同。
❸ 为我们的 AWS Lambda 函数导出处理函数
我们现在可以将更改提交到这个文件并推送到 GitHub 以部署。部署我们的应用程序几乎完成。在下一节中,我们将添加一个自定义域名并将其分配给 Netlify 中的网站。
8.4.4 在 Netlify 中添加自定义域名
到目前为止,我们的应用程序一直在 Netlify 分配的hungry-thompson-86fbf3.netlify.app/子域名上运行。让我们设置一个与网站品牌更匹配的自定义域名。在 Netlify 中,从顶部导航栏选择域名。从这里,我们可以添加自定义域名并将它们分配给 Netlify 中的网站(见图 8.25)。

图 8.25 在 Netlify 中添加自定义域名
我们可以直接从 Netlify 购买域名,或者添加通过其他注册商购买的域名。在这种情况下,我想添加我在其他地方购买的域名,所以我将域名指向 Netlify 的名称服务器,允许 Netlify 管理域和域的 DNS 记录(见图 8.26)。

图 8.26 将我们的域名指向 Netlify 名称服务器
最后,我们需要更新 Auth0 应用程序设置,以便使用新域名提供的 Auth0 认证功能。我们将在 Auth0 中更新允许回调 URL和允许注销 URL,添加默认的 localhost URL 以及我们的 Netlify 网站 URL 和我们的自定义域名(见图 8.27)。

图 8.27 在 Auth0 中更新允许的回调 URL
有了这些,我们的应用程序现在已部署并准备好在我们的自定义域名上使用(见图 8.28)。

图 8.28 登录后我们的部署的完整堆栈 GraphQL 应用程序
8.5 我们的部署方法
在本章中,我们探讨了一种部署我们的全栈 GraphQL 应用程序的方法,该方法采用了利用托管服务(特别是 Neo4j Aura、Netlify 和 AWS Lambda)的优势(见图 8.29)。在本章开头,我们讨论了一些托管服务的一般优缺点。让我们从开发者的角度回顾一下这些服务。

图 8.29 从开发者视角看完整的 GraphQL 部署
Netlify 允许自动构建和部署我们的 React 应用程序到 Netlify 全球内容分发网络,确保我们的前端应用程序对世界上任何人都可访问,而无需不必要的网络延迟。将我们的 GraphQL API 转换为 AWS Lambda 函数并利用 Netlify Functions 意味着我们能够将 API 应用程序集成到相同的代码库中。通过集成 GitHub,我们的开发和部署工作流程得到改善,使我们能够从拉取请求中创建预览部署。
由于我们使用了 Neo4j Aura 数据库作为服务,我们能够利用 Neo4j Desktop 和 Neo4j Browser 等开发者工具进行开发,而不必担心在云中维护和操作 Neo4j 集群。现在我们的应用程序已经部署,在下一章中,我们将从业务审查应用程序转向更高级的 GraphQL 功能,例如抽象类型、基于游标的分页和 Relay 连接模型,以及在图中的关系属性操作。
8.6 练习
-
使用 Neo4j Bloom 查找已评论属于最多类别的业务用户。这位用户评论了哪些类别?提示:创建一个 Neo4j Bloom 搜索短语可能有助于这个练习。请参阅
mng.bz/XZR6上的文档。 -
创建一个新的拉取请求,更新业务审查应用程序,使其始终按业务名称排序结果。使用 Netlify 的部署功能在合并拉取请求并更新应用程序之前审查此更新。
-
创建一个新的 Netlify Function,使用 Neo4j JavaScript 驱动程序查询我们的 Neo4j Aura 集群,并返回最新评论的列表。在部署之前,使用 netlify dev 命令在本地运行它。使用 netlify.toml 配置将 /reviews 重定向到该函数。
摘要
-
利用托管云服务可以平滑开发者在部署和维护 Web 应用程序时的体验,并解决全栈开发者可能负责应用程序所有组件的扩展、运营和定价问题,这可能对全栈开发者具有吸引力。
-
Neo4j Aura 是一种托管云数据库服务,提供一键配置的 Neo4j 集群。这些数据库实例可以根据需要扩展或缩减,并消除对 Neo4j 维护或运营的需求。
-
Netlify 平台和 CDN 可用于自动化构建和部署 Web 应用程序,利用 GitHub 集成和部署预览功能,使得在应用程序发布前审查更改变得更加容易。
-
GraphQL API 可以作为 AWS Lambda 函数部署,利用无状态扩展和基于需求的定价,这使得 AWS Lambda 具有吸引力。Netlify Functions 可用于将 AWS Lambda 函数作为 Netlify 站点的一部分进行配置,从而消除单独的代码库或部署流程的需求。
9 高级 GraphQL 考虑事项
本章涵盖
-
利用联合和接口的抽象类型带来的好处
-
使用偏移量和游标分页查询结果
-
使用关系属性,利用 Relay 连接类型进行操作
到目前为止,我们还没有充分利用 GraphQL 类型系统中最强大和最重要的特性之一——抽象类型,它允许我们在单个 GraphQL 字段中表示多个具体类型。同样,我们也没有真正利用属性图模型的一个重要特性——关系属性,它允许我们将属性与连接节点的关联关系相关联,而不仅仅是节点本身。在本章中,我们将看到如何利用 GraphQL 支持的抽象联合和接口类型。我们还将利用关系属性,并在过程中介绍 GraphQL 连接对象和分页方法。我们将从我们的业务审查应用转向简化我们的数据模型,转而关注一个简单的在线商店 API,该商店销售两种类型的产品:书籍和视频。
9.1 GraphQL 抽象类型
GraphQL 支持两种抽象类型:接口和联合。抽象类型允许我们在单个字段中表示多个具体类型(或多个类型的数组)。当一或多个字段在具体类型之间共享时,使用接口;它们声明了必须在具体类型中实现共享的字段。这样,接口可以被视为一个合同,它指定了类型必须具有的最小字段集以实现接口。联合不需要在具体类型之间共享字段,也不共享这种合同的概念。因此,联合只是具体类型的简单分组。
9.1.1 接口类型
接口类型用于表示概念上相似且至少共享一个公共字段的多种对象类型。例如,我们的商店 API 可能有一个 person 的概念。每个人可以是客户或员工。每个人都会有诸如姓名、姓氏和用户名等字段。然而,只有客户会有送货地址,只有员工会有雇佣日期。在 GraphQL 类型定义中,我们可以表示这个概念,如以下列表所示。
列表 9.1 在 GraphQL 类型定义中定义接口
interface Person {
firstName: String!
lastName: String!
username: String!
}
type Customer implements Person {
firstName: String!
lastName: String!
username: String!
shippingAddress: String
}
type Employee implements Person {
firstName: String!
lastName: String!
username: String!
hireDate: DateTime!
}
type Query {
people: [Person]
}
实现(或具体)类型必须实现接口中声明的所有字段,然后可以定义与类型相关联的其他字段。在这里,Customer 和 Employee 都实现了 Person 接口,因此必须包含 firstName、lastName 和 username 字段。Customer 添加了 shippingAddress 字段,而 Employee 添加了 hireDate 字段。
查询“人”查询字段将返回一个对象数组,其中每个对象可以是员工或客户对象。我们在 GraphQL 查询中使用内联片段来指定要返回的每个类型的选择集和字段,如下所示。内联片段允许我们请求具体类型的字段并包含一个类型条件。
列表 9.2 使用内联片段查询接口
{
people {
__typename
firstName
lastName
username
... on Customer {
shippingAddress
}
... on Employee {
hireDate
}
}
}
我们还包括了 __typename 元字段,它告诉我们人数组中每个对象的实际类型。
9.1.2 联合类型
联合体与接口类似,因为它们是抽象类型,可以用来表示多个具体类型;然而,联合体中组成的具体类型不需要有任何公共字段。联合体的一个常见用例是表示搜索结果。例如,我们的商店 API 可能支持一个产品搜索功能,允许用户搜索可能是书籍或视频的项目。为了启用此功能,我们创建了一个包含书籍和视频类型的 Product 联合体以及一个返回产品对象数组的 Query 字段搜索,如下一列表所示。
列表 9.3 在 GraphQL 类型定义中定义联合体
type Video {
name: String!
sku: String!
}
type Book {
title: String!
isbn: String!
}
union Product = Video | Book
type Query {
search(term: String!): [Product!]!
}
与我们使用内联片段查询接口的具体类型的字段类似,我们在查询联合体时也使用内联片段。然而,由于联合体类型本身不包含任何字段,当我们查询联合体而不使用内联片段时,我们只能请求 __typename 元字段,如下所示。
列表 9.4 查询联合体
{
search(term: "GraphQL") {
__typename
... on Book {
title
isbn
}
... on Video {
name
sku
}
}
}
9.1.3 使用 Neo4j GraphQL 库的抽象类型
现在我们已经对接口和联合体进行了一些探索,让我们看看如何使用 Neo4j GraphQL 库在 GraphQL API 中利用抽象类型。让我们放下我们的业务审查应用,开始一个新的应用,用于我们想象中的书店和视频店。在一个新的目录下,运行以下命令来创建一个新的 Node.js 项目:
npm init -y
接下来,我们将安装我们新的 Node.js GraphQL API 应用程序的依赖项,这应该现在已经很熟悉了:
npm install @neo4j/graphql graphql apollo-server neo4j-driver dotenv
如果你想继续使用前几章中的业务审查应用,你可以在 Neo4j Aura 或本地使用 Neo4j Desktop 创建一个新的 Neo4j 数据库。或者,你可以继续使用相同的数据库,并运行以下 Cypher 语句来删除业务审查数据:
MATCH (a) DETACH DELETE a
创建一个新的 .env 文件来定义指定 Neo4j 数据库连接凭据的环境变量,设置环境变量 NEO4J_USER、NEO4J_URI 和 NEO4J_PASSWORD 的值,如下所示。
列表 9.5 .env:请确保用您的 Aura 连接凭据替换以下值
NEO4J_URI=neo4j+s://932a071e.databases.neo4j.io
NEO4J_USER=neo4j
NEO4J_PASSWORD=wH4-tvNOxzKlDZwIEqgNPm-8iS-tJ9gOgr1ScSq9yiM
现在我们有一个新的 Node.js 项目以及一个新的或空的 Neo4j 数据库,让我们首先定义我们的 API 的 GraphQL 类型定义,看看抽象类型如何帮助我们简化 API 架构。
建模在线书店和视频店 API
让我们从我们的新 API 开始,去(虚拟)白板:arrows.app。遵循我们在第三章中确定的图数据建模过程,我们将确定应用程序中的实体(节点),它们是如何连接的(关系),以及它们的属性(节点属性)。让我们保持简单,关注那些将下订单的用户以及将包含书籍和/或视频的订单。创建处理这些要求的属性图模型,我们最终得到一个相当直接的图模型(见图 9.1)。

图 9.1 销售书籍和视频的在线商店的图数据模型
正如我们在第四章中看到的,我们可以使用这个属性图模型图来翻译映射到这个属性图模型的 GraphQL 类型定义,使用 @relationship GraphQL 模式指令来捕获我们关系的方向和类型,如下面的列表所示。
列表 9.6 我们在线商店数据模型的 GraphQL 类型定义
type User {
username: String
orders: [Order!]! @relationship(type: "PLACED", direction: OUT)
}
type Order {
orderId: ID! @id
created: DateTime! @timestamp(operations: [CREATE])
customer: User! @relationship(type: "PLACED", direction: IN)
books: [Book!]! @relationship(type: "CONTAINS", direction: OUT)
videos: [Video!]! @relationship(type: "CONTAINS", direction: OUT)
}
type Video {
name: String
sku: String
}
type Book {
title: String
isbn: String
}
注意,我们正在使用 @id 和 @timestamp 指令来自动生成这些值,因此客户端不需要将它们传递给 API。我们的客户端不应该担心为订单生成一个随机的唯一 ID,或者传递订单创建的时间,因为这样做也会带来安全影响。
但看看 Order.books 和 Order.videos 字段。为了看到订单中包含的产品,我们的客户端需要请求这两个字段——其中一个可能是空数组。这对客户端来说有点尴尬;让我们看看我们如何通过使用抽象类型,特别是使用联合类型来改进这一点,因为我们的 Video 和 Book 类型没有共享任何公共字段。而不是 Order.books 和 Order.videos 字段,让我们在下一个列表中定义一个新的联合类型 Product,并添加一个 Order.products 字段,这将允许我们在单个字段中处理与订单连接的产品(无论是书籍还是视频)。
列表 9.7 使用联合类型为我们在线商店数据模型定义的 GraphQL 类型定义
type User {
username: String
orders: [Order!]! @relationship(type: "PLACED", direction: OUT)
}
union Product = Video | Book ❶
type Order {
orderId: ID! @id
created: DateTime! @timestamp(operations: [CREATE])
customer: User! @relationship(type: "PLACED", direction: IN)
products: [Product!]! @relationship(type: "CONTAINS", direction: OUT) ❷
}
type Video {
name: String
sku: String
}
type Book {
title: String
isbn: String
}
❶ 定义一个名为 Product 的联合类型,可以是 Video 或 Book 类型
❷ 在 Order 类型上的关系字段中使用我们新的 Product 类型
创建 GraphQL 服务器
现在我们已经最终确定了我们的 GraphQL 类型定义,让我们使用它们来创建一个使用 Neo4j GraphQL 库的 GraphQL API。让我们创建一个新的 index.js,包含这些新的类型定义以及使用 Apollo 服务器和 Neo4j GraphQL 库创建 GraphQL API 所需的代码,如下所示。
列表 9.8 index.js:我们的在线商店的 GraphQL API
const { gql, ApolloServer } = require("apollo-server");
const { Neo4jGraphQL } = require("@neo4j/graphql");
const neo4j = require("neo4j-driver");
require("dotenv").config();
const typeDefs = gql`
type User {
username: String
orders: [Order!]! @relationship(type: "PLACED", direction: OUT)
}
union Product = Video | Book
type Order {
orderId: ID! @id
created: DateTime! @timestamp(operations: [CREATE])
customer: User! @relationship(type: "PLACED", direction: IN)
products: [Product!]!
@relationship(
type: "CONTAINS"
direction: OUT
)
}
type Video {
name: String
sku: String
}
type Book {
title: String
isbn: String
}
`;
const driver = neo4j.driver(
process.env.NEO4J_URI,
neo4j.auth.basic(process.env.NEO4J_USER, process.env.NEO4J_PASSWORD)
);
const neoSchema = new Neo4jGraphQL({ typeDefs, driver });
neoSchema.getSchema().then((schema) => {
const server = new ApolloServer({
schema,
});
server.listen().then(({ url }) => {
console.log(`GraphQL server ready on ${url}`);
});
});
这个文件的结构应该与过去章节中我们定义 GraphQL 类型定义、创建 Neo4j 驱动实例以及使用 Neo4j GraphQL 库生成 GraphQL 模式以由 Apollo 服务器提供的内容相似。现在让我们开始我们的 GraphQL 服务器:
node index.js
GraphQL server ready on http://localhost:4000/
在 GraphQL 模式突变中使用抽象类型
接下来,让我们打开我们的网络浏览器并导航到 http://localhost:4000,在那里我们将使用 Apollo Studio 并开始使用我们模式中由 Neo4j GraphQL 库生成的 GraphQL 变更在数据库中创建一些数据。首先,让我们使用生成的 createUsers GraphQL 变更创建两个用户,如下所示。
列表 9.9 GraphQL 变更:创建用户
mutation {
createUsers(
input: [{ username: "bobbytables" }, { username: "graphlover123" }]
) {
users {
username
}
}
}
在这个 GraphQL 操作的响应中,我们应该看到用户对象,这些用户名是我们传递给变更是操作的:
{
"data": {
"createUsers": {
"users": [
{
"username": "bobbytables"
},
{
"username": "graphlover123"
}
]
}
}
}
在下一个列表中,让我们为我们的商店创建一些产品,正如我们所说的,这个商店销售书籍和视频。为此,我们将使用 createBooks 和 createVideos 变更。
列表 9.10 GraphQL 变更:创建产品
mutation {
createBooks(
input: [
{ title: "Full Stack GraphQL", isbn: "9781617297038" }
{ title: "Graph Algorithms", isbn: "9781492047681" }
{ title: "Graph-Powered Machine Learning", isbn: "9781617295645" }
]
) {
books {
title
isbn
}
}
createVideos(
input: [
{ name: "Intro To Neo4j 4.x", sku: "v001" }
{ name: "Building GraphQL APIs", sku: "v002" }
]
) {
videos {
sku
name
}
}
}
在响应中,我们将有包含我们刚刚创建的书籍和视频对象的数组:
{
"data": {
"createBooks": {
"books": [
{
"title": "Full Stack GraphQL",
"isbn": "9781617297038"
},
{
"title": "Graph Algorithms",
"isbn": "9781492047681"
},
{
"title": "Graph-Powered Machine Learning",
"isbn": "9781617295645"
}
]
},
"createVideos": {
"videos": [
{
"sku": "v001",
"name": "Intro To Neo4j 4.x"
},
{
"sku": "v002",
"name": "Building GraphQL APIs"
}
]
}
}
}
现在我们已经准备好创建一些订单了。我们可以用几种不同的方式来做这件事——例如,使用 updateUsers 变更——但让我们使用 createOrders 变更,如下一个列表所示。由于创建和 orderId 字段的值正在为我们自动生成,我们不需要在变更是中指定这些值。
列表 9.11 GraphQL 变更:创建单个订单
mutation {
createOrders(
input: {
customer: {
connect: { where: { node: { username: "graphlover123" } } }
}
products: {
Book: {
connect: [
{ where: { node: { title: "Graph Algorithms" } } }
{ where: { node: { title: "Full Stack GraphQL" } } }
]
}
Video: {
connect: { where: { node: { name: "Building GraphQL APIs" } } }
}
}
}
) {
orders {
orderId
created
customer {
username
}
products {
__typename
... on Book {
title
isbn
}
... on Video {
name
sku
}
}
}
}
}
注意在产品选择中使用了内联片段。我们知道该字段返回一个 Product 对象数组,这是一个联合类型,并且每个对象可以解析为 Book 或 Video。我们可以向选择中添加 __typename 字段,这将告诉我们每个对象的实际类型,但为了返回实际类型的字段(Book 或 Video),我们需要使用内联片段来指定当解析的对象的实际类型与内联片段中指定的类型匹配时要返回的字段:
products {
__typename
... on Book {
title
isbn
}
... on Video {
name
sku
}
}
在响应对象中,我们将看到我们的订单对象已经被分配了随机 ID 值以及时间戳。注意,我们的产品数组是 Book 和 Video 对象的混合:
{
"data": {
"createOrders": {
"orders": [
{
"orderId": "dfdebf08-3ce5-494e-9843-d5286f4dc8f4",
"created": "2021-08-15T13:43:15.117Z",
"customer": {
"username": "graphlover123"
},
"products": [
{
"__typename": "Video",
"name": "Building GraphQL APIs",
"sku": "v002"
},
{
"__typename": "Book",
"title": "Graph Algorithms",
"isbn": "9781492047681"
},
{
"__typename": "Book",
"title": "Full Stack GraphQL",
"isbn": "9781617297038"
}
]
}
]
}
}
}
如果我们使用 Neo4j 浏览器来检查我们通过我们的 GraphQL API 创建的数据,我们可以看到我们的订单、用户和产品的图表示以及它们是如何连接的(见图 9.2)。

图 9.2 包含两本书和一个视频的订单
在下一个列表中,让我们使用另一个 GraphQL 变更创建更多订单。
列表 9.12 GraphQL 变更:创建多个订单
mutation {
createOrders(
input: [
{
customer: {
connect: { where: { node: { username: "bobbytables" } } }
}
products: {
Book: {
connect: { where: { node: { isbn: "9781617297038" } } }
}
}
}
{
customer: {
connect: { where: { node: { username: "graphlover123" } } }
}
products: {
Book: {
connect: { where: { node: { isbn: "9781492047681" } } }
}
}
}
{
customer: {
connect: { where: { node: { username: "graphlover123" } } }
}
products: {
Book: {
connect: [{ where: { node: { isbn: "9781617295645" } } }]
}
Video: { connect: { where: { node: { sku: "v001" } } } }
}
}
]
) {
orders {
orderId
created
customer {
username
}
products {
__typename
... on Book {
title
isbn
}
... on Video {
name
sku
}
}
}
}
}
注意,我们可以传递一个输入对象数组,在单个 GraphQL 变更中创建多个订单:
{
"data": {
"createOrders": {
"orders": [
{
"orderId": "38cfd8e4-f866-4c8a-ae97-e9e7c9e72b0b",
"created": "2021-08-16T13:33:08.288Z",
"customer": {
"username": "bobbytables"
},
"products": [
{
"__typename": "Book",
"title": "Full Stack GraphQL",
"isbn": "9781617297038"
}
]
},
{
"orderId": "597ba737-de86-4772-b541-6a0bf4a25817",
"created": "2021-08-16T13:33:08.288Z",
"customer": {
"username": "graphlover123"
},
"products": [
{
"__typename": "Book",
"title": "Graph Algorithms",
"isbn": "9781492047681"
}
]
},
{
"orderId": "dfc08de3-68f9-407c-8c72-1b02eb7a9b4e",
"created": "2021-08-16T13:33:08.288Z",
"customer": {
"username": "graphlover123"
},
"products": [
{
"__typename": "Video",
"name": "Intro To Neo4j 4.x",
"sku": "v001"
},
{
"__typename": "Book",
"title": "Graph-Powered Machine Learning",
"isbn": "9781617295645"
}
]
}
]
}
}
}
现在我们已经创建了几个订单及其相关的书籍和视频,让我们探索如何在 GraphQL 中分页数据结果。
9.2 使用 GraphQL 进行分页
许多应用程序在表格或列表中显示数据。当填充这些列表视图时,应用程序可能只从服务器请求总结果集的子集——通常,只需要渲染当前视图所需的数据。例如,在我们的在线商店的上下文中,我们可能想显示按时间顺序排序的所有订单列表,或者允许特定用户查看他们所有的订单。然而,可能有数千甚至数百万订单;我们不希望从服务器获取所有这些订单(那将需要通过网络发送大量数据)。
相反,我们会通过请求某些块(或页),正如它们在应用程序中渲染的那样,来分页订单数据。例如,我们可能最初请求前 20 个订单,按创建日期排序。然后,当用户滚动到前 20 个订单的末尾时,就会从服务器请求下一页的结果。GraphQL 提供了两种分页类型:偏移和游标。
9.2.1 偏移分页
偏移分页使用两个字段参数,通常称为 limit(限制)和 offset(偏移),将数组字段的输出分块成页。我们通常使用第三个参数,sort(排序),来指定数组的排序顺序。limit 参数指定要包含的结果数量,而 offset 是在返回值之前跳过的对象数量,并且通过 limit 的值递增以获取下一页。例如,如果我们想将结果分块成每页 10 个,那么第一页将使用偏移值 0 和限制值 10,第二页将使用偏移值 10 和限制值 10,依此类推。
让我们想象我们的商店应用程序有一个查看订单视图,在这个视图中,所有订单都按订单创建日期排序显示在表格中。加载所有这些数据的 GraphQL 查询可能看起来像以下这样。
列表 9.13 按创建日期排序查询所有订单
query {
orders(options: { sort: { created: DESC } }) {
orderId
created
}
}
这个查询返回了所有订单。如果我们有数百万订单呢?我们会发送过多的数据到网络上,并且我们的用户将需要等待很长时间才能加载和显示订单!我们的应用程序一次只能显示这么多订单,所以最终我们没有充分利用大部分数据。相反,我们希望切片我们的订单结果,并且只返回与应用程序显示相关的子集。我们将订单分页到每页 2 个,请求第一页,如下所示。
列表 9.14 使用偏移分页查询订单
query {
orders(options: { limit: 2, offset: 0, sort: { created: DESC } }) {
orderId
created
}
}
GraphQL 分页查询
然后,我们增加偏移量值以获取下一页。但我们如何知道要请求多少页?我们通常希望能够在应用程序中显示总页数,以便用户知道他们正在处理多少数据。为了便于实现这一点,我们可以利用 计数查询。Neo4j GraphQL 库为每个在数据库中返回该类型节点数的类型生成一个计数查询字段,如下一列表所示。客户端应用程序可以使用这个数字来计算总页数。
列表 9.15 包含 ordersCount 字段的偏移量分页
query {
ordersCount
orders(options: { limit: 2, offset: 0, sort: { created: DESC } }) {
orderId
created
}
}
如果我们使用过滤器,例如过滤在某个日期之后下订单的订单,我们可以将相同的过滤器参数传递给计数查询以确定结果总数并计算客户端要显示的页数。
9.2.2 游标分页
游标分页是另一种常用的模型。我们不是使用数字偏移量来将结果切割成页面,而是使用一个游标,它是一个不透明的字符串值,用于标识结果页面中的最后一个对象。为了看到游标分页的实际应用,让我们想象我们的应用程序有一个特定用户的订单视图;例如,一个用户可能希望查看他们所有已下订单,按订单创建日期排序。
要使用 Neo4j GraphQL 库进行游标分页,我们首先请求 ordersConnection 字段,而不是 orders 字段。ordersConnection 字段被称为 Relay 连接对象。让我们首先看看这些 Relay 连接是如何使用的,然后探索 Relay 连接模型。
列表 9.16 使用 ordersConnection Relay 连接类型
query {
users(where: { username: "graphlover123" }) {
username
ordersConnection(sort: { node: { created: ASC } }) {
edges {
node {
created
orderId
}
}
}
}
}
注意,我们为 ordersConnection 字段的选取集中现在包括了嵌套的边和节点字段。那里发生了什么?
Relay 连接模型
这些 连接 字段由 Neo4j GraphQL 库为每个关系字段生成,并符合“Relay 游标连接规范”(relay.dev/graphql/connections.htm),通常被称为 Relay 规范 或 Relay 连接。Relay 是一个包含许多超出本书范围功能的 GraphQL 客户端;然而,这个 Relay 规范已成为在 GraphQL 中实现游标分页的常见蓝图,并引入了连接类型的概念。
这些连接类型以两种方式提供了游标分页的标准方法。首先,使用常见的字段参数 first 和 after 来切割和分页结果。其次,连接允许一种标准的方法来分页结果,提供游标和其他关于结果集的元信息,例如是否还有更多结果可供客户端在分页结果中获取。
根据 Relay 规范,每个连接对象必须包含一个 edges 数组字段和一个 pageInfo 对象字段。edges 字段是一个由 Relay 规范定义的 edge types 列表,它封装了关系,连接我们图中的节点。pageInfo 字段包含有关页面的元数据,例如 hasNextPage 和 hasPreviousPage,以及用于请求下一页和前一页的游标:startCursor 和 endCursor。此外,Neo4j GraphQL 库还添加了一个 totalCount 字段,它告诉我们边的总数。
让我们看看接下来的列表中是如何实现的。我们将向之前的查询添加第一个:2 字段参数,以分页大小为 2 的页面来分页订单。我们还将请求 pageInfo 对象和 totalCount 字段。
列表 9.17 使用 pageInfo 对象检索元数据
query {
users(where: { username: "graphlover123" }) {
username
ordersConnection(first: 2, sort: { node: { created: ASC } }) {
totalCount
pageInfo {
endCursor
hasNextPage
hasPreviousPage
}
edges {
node {
created
orderId
}
}
}
}
}
现在结果包括前两个订单,它们被封装在 edges 数组中,以及包含一个游标 endCursor 的 pageInfo 元数据对象,我们可以使用它来获取下一页的结果:
{
"data": {
"users": [
{
"username": "graphlover123",
"ordersConnection": {
"totalCount": 3,
"pageInfo": {
"endCursor": "YXJyYXljb25uZWN0aW9uOjE=",
"hasNextPage": true,
"hasPreviousPage": false
},
"edges": [
{
"node": {
"created": "2021-08-15T13:43:15.117Z",
"orderId": "dfdebf08-3ce5-494e-9843-d5286f4dc8f4"
}
},
{
"node": {
"created": "2021-08-16T13:33:08.288Z",
"orderId": "dfc08de3-68f9-407c-8c72-1b02eb7a9b4e"
}
}
]
}
}
]
}
}
要请求下一页的结果,我们将 endCursor 的值作为 ordersConnection 字段 after 字段参数的值,如下所示。
列表 9.18 使用游标分页检索下一页的订单
query {
users(where: { username: "graphlover123" }) {
username
ordersConnection(
first: 2
after: "YXJyYXljb25uZWN0aW9uOjE="
sort: { node: { created: ASC } }
) {
totalCount
pageInfo {
endCursor
hasNextPage
hasPreviousPage
}
edges {
node {
created
orderId
}
}
}
}
}
这次,我们将在结果中看到 hasNextPage 是 false,这告诉我们没有更多分页结果供客户端获取:
{
"data": {
"users": [
{
"username": "graphlover123",
"ordersConnection": {
"totalCount": 3,
"pageInfo": {
"endCursor": "YXJyYXljb25uZWN0aW9uOjI=",
"hasNextPage": false,
"hasPreviousPage": true
},
"edges": [
{
"node": {
"created": "2021-08-16T13:33:08.288Z",
"orderId": "597ba737-de86-4772-b541-6a0bf4a25817"
}
}
]
}
}
]
}
}
Relay 连接模型提供了一个有用的标准用于游标分页。由 Relay 规范定义的边类型还引入了一种表示属性图模型中我们尚未使用的一个强大功能的方法:关系属性。
9.3 关系属性
在属性图模型中,关系属性 是存储在关系上的属性,用于表示在关系连接的两个端节点上下文中具有意义的值。例如,在我们的存储数据模型中,我们如何表示添加到订单中的特定物品的数量?表示这个概念的最佳方式是在 CONTAINS 关系上存储一个属性,表示添加到订单中的该物品(书籍或视频)的数量。

图 9.3 更新在线商店数据模型以包含关系属性
在图 9.3 中,我们已向 CONTAINS 关系添加了一个整型属性数量。现在,例如,如果我们想在下单时购买两本 Full Stack GraphQL 书,我们可以为这个属性设置一个值,2。但我们在 GraphQL API 中如何表示这一点呢?
9.3.1 接口和 @relationship GraphQL 模式指令
我们使用@relationship 指令与 Neo4j GraphQL 库一起指定属性图关系类型和方向,使用 type 和 direction 参数。@relationship 指令还接受一个可选参数 properties,可以用来指定关系属性。properties 参数接受一个接口类型的名称,该类型定义了映射到关系属性的 GraphQL 字段。
为了表示我们的关系属性字段,我们首先定义一个包含我们的关系属性字段的接口类型。由于我们只想在 CONTAINS 关系上添加单个关系属性字段 quantity,我们将创建一个包含单个字段的 Contains 接口。接下来,在 Order.products 字段上使用的@relationship 指令中,我们添加 properties: "Contains",以表明我们想要使用 Contains 接口来表示 CONTAINS 关系的属性。我们的更新后的 GraphQL 类型定义如下所示;让我们继续在 index.js 中更新这些。
列表 9.19 使用接口在 GraphQL 中表示关系属性
interface Contains {
quantity: Int
}
type User {
username: String
orders: [Order!]! @relationship(type: "PLACED", direction: OUT)
}
type Order {
orderId: ID! @id
created: DateTime! @timestamp(operations: [CREATE])
customer: User! @relationship(type: "PLACED", direction: IN)
products: [Product!]!
@relationship(type: "CONTAINS", direction: OUT, properties: "Contains")
}
type Video {
title: String
sku: String
}
type Book {
title: String
isbn: String
}
union Product = Video | Book
注意,由于 Product 是一个联合类型,代表 Video 和 Book 类型,我们已捕获定义视频和图书的数量关系属性——这是抽象类型强大功能的一个绝佳例子!在更新 index.js 中的 GraphQL 类型定义后,我们需要重新启动我们的 GraphQL Node.js 应用程序。
9.3.2 创建关系属性
现在我们已经更新了我们的 GraphQL 类型定义,包括数量关系属性,让我们看看我们如何利用这个新的关系属性。首先,我们将创建一个新的订单,但这次,我们将订购 10 本Full Stack GraphQL图书。为此,我们将在使用 createOrders 突变时,在输入对象的 connect 对象中包含 edge: { quantity: 10},如以下所示。
列表 9.20 在 GraphQL 突变中使用关系属性
mutation {
createOrders(
input: {
customer: {
connect: { where: { node: { username: "graphlover123" } } }
}
products: {
Book: {
connect: {
edge: { quantity: 10 }
where: { node: { title: "Full Stack GraphQL" } }
}
}
}
}
) {
orders {
created
orderId
productsConnection {
edges {
quantity
node {
... on Book {
title
}
}
}
}
}
}
}
要检索数量值,我们现在在 productsConnection 字段中的边缘类型对象上有一个名为 quantity 的字段,它表示此订单包含 10 本图书,如查询结果所示:
{
"data": {
"createOrders": {
"orders": [
{
"created": "2021-08-18T22:17:28.285Z",
"orderId": "48faa3f4-553b-42ed-a08f-e7781aed3c17",
"productsConnection": {
"edges": [
{
"quantity": 10,
"node": {
"title": "Full Stack GraphQL"
}
}
]
}
}
]
}
}
}
多亏了 Relay 连接规范的力量,我们现在可以在 GraphQL 中表示并处理关系属性!
9.4 完成全栈 GraphQL
我们现在已经学会了如何利用 GraphQL、图数据库、React 和云服务构建和保障全栈 Web 应用程序的力量。本书的目标在很大程度上是展示全栈 GraphQL 各个部分的结合。让我们回顾一下本书中涵盖的内容,并概述一些进一步学习资源的路径。
在第一部分,我们介绍了图思维、GraphQL 和 Neo4j 图数据库。我们学习了 GraphQL 的好处,如何编写 GraphQL 查询,以及构建 GraphQL 服务器的基本方法。我们的图思维扩展到覆盖图数据库,并介绍了 Neo4j 和 Cypher 查询语言。在第二部分,我们涵盖了用于构建用户界面的 React JavaScript 框架,以及使用 Apollo Client 在我们的 React 应用程序中获取 GraphQL 数据。最后,在第三部分,我们处理了在 GraphQL API 和 React 应用程序中的身份验证和授权,以及使用如 Auth0、Neo4j AuraDB、Netlify 和无服务器函数等托管云服务进行部署。
Neo4j GraphQL 库是 Full Stack GraphQL 的核心组件,它提供了创建由 Neo4j 支持的强大 GraphQL API 的能力,而不需要编写样板代码;然而,我们在这本书中没有机会涵盖该库的许多功能。随着您继续使用 GraphQL 构建应用程序的旅程,我鼓励您更多地了解一些这些功能,例如使用聚合和更多的模式指令,如@cypher 和@auth,这些指令允许我们丰富我们的 GraphQL API。关于 Neo4j GraphQL 库的进一步学习资源是neo4j.com/docs/graphql-manual/current的文档。
我还希望将另一个主题包含在本书中,那就是使用 React 框架和工具来提高使用 React 构建前端应用程序的开发者体验。Next.js 就是这样一种框架,它建立在 React 之上,并捆绑了许多 React 本身所缺少的常见功能。凭借其 API Routes 功能,Next.js 甚至包括构建 GraphQL API 的能力,这是一种将后端逻辑 colocated 的有趣方法。Next.js 文档中的入门教程是一个极好的动手介绍:nextjs.org/docs/getting-started。
要继续您的图数据库和 Neo4j 的图之旅,Neo4j 的 GraphAcademy 提供的免费在线培训是一个极好的资源,涵盖了包括本书未涉及的一些主题,如图数据科学和用不同语言和框架构建应用程序。您可以从graphacademy.neo4j.com开始使用 GraphAcademy。
最后,我发布了一个博客和通讯,深入探讨了这些主题中的许多。您可以在lyonwj.com在线找到它。
9.5 练习
-
客户为商品支付的价格可能有所不同。例如,价格可能会改变或作为促销活动的一部分暂时降低。为订单中每个支付的商品添加一个关系属性来存储价格。
-
编写一个@cypher 指令字段来计算订单小计。务必考虑订单中包含的每个项目的数量。
-
编写一个 GraphQL 查询以分页显示订单中包含的项目,首先使用偏移量分页,然后使用基于游标的分页。你能从最后一页导航到第一页吗?
摘要
-
GraphQL 支持两种抽象类型,可以用来表示多个具体类型:联合和接口。
-
当具体类型共享公共字段时使用接口,可以将其视为定义实现接口要求的契约。
-
联合不共享这种契约的概念,可以在具体类型不共享公共字段时使用。
-
使用 GraphQL 进行分页的两种常见方法包括使用偏移量和游标。偏移量分页使用数字偏移量将结果分页,而游标分页使用一个不透明的游标。
-
Relay 规范定义了一个通用的 连接 类型,可以用于在 GraphQL 中启用基于游标的分页。
-
这些 Relay 连接类型也可以用于使用 GraphQL 模型关系属性。


浙公网安备 33010602011771号