65.9K
CodeProject 正在变化。 阅读更多。
Home

Node.js 服务使用 NestJS 和 GraphQL

starIconstarIconstarIconstarIconstarIcon

5.00/5 (10投票s)

2020年12月16日

CPOL

18分钟阅读

viewsIcon

11453

downloadIcon

137

在 NestJS 框架下使用 GraphQL,包含数据缓存、JWT 认证、TLS 及其他有用功能

目录

简介

NestJS 是一个用于构建高效、可扩展的 Node.js 服务器端应用程序的框架。它使用渐进式 JavaScript,采用 TypeScript 构建并完全支持 TypeScript(但仍允许开发人员编写纯 JavaScript),并结合了 OOP(面向对象编程)、FP(函数式编程)和 FRP(函数响应式编程)的元素。NestJS 文档可以在 这里 找到。

GraphQL 是一种用于 API 的开源数据查询和操作语言,以及用于通过现有数据满足查询的运行时。GraphQL 于 2012 年由 Facebook 内部开发,并于 2015 年公开发布。目前,GraphQL 项目由 GraphQL 基金会运行。它提供了一种开发 Web API 的方法,并与 REST 和其他 Web 服务架构进行了比较和对比。它允许客户端定义所需数据的结构,服务器返回相同结构的数据,从而防止返回过多的数据。

NestJS 框架和 GraphQL 技术在 Node.js 开发者中越来越受欢迎。前者提供了便捷的支持,只需最少的代码即可实现许多有用的功能,如日志记录、OpenAPI(Swagger)、TLS、登录等。后者提供了方便灵活的技术,用于根据预定义模式以分层格式接收数据,并最大限度地减少网络负载。NestJS 文档中对 其对 GraphQL 的支持 有详细的描述。本文旨在提交一个在 NestJS 基础服务中运行的 GraphQL 组件的可行示例。

尽管本文的重点是在 NestJS 下实现 GraphQL,但我们还将解决现代服务中诸如日志记录、安全通信、身份验证和 OpenAPI 支持等重要功能。在保留 NestJS 服务整体布局及其模块化结构的同时,大部分功能被放置在下面描述的可重用库中。

服务要求

让我们概述一下服务的主要要求。

  • 优化从数据库获取数据

    我们的服务旨在接收客户端数据请求,从数据库获取所需数据并响应客户端。使用 GraphQL 允许服务仅响应客户端请求的数据,并避免通过网络发送额外数据。但为了实现良好的性能,我们需要优化数据库访问。这可以通过最小化对数据库的查询次数以及仅获取客户端所需数据来实现。下面将讨论这两个主题。

  • 在服务启动时上传 GraphQL schema 和 resolvers

    此功能允许服务在不重新部署的情况下使用任何 GraphQL schema(在给定数据库实体类型集内)进行工作。上传可以从磁盘(便于开发期间调试)或从 Web(用于生产环境)进行。

  • REST 和 OpenAPI 支持

    该服务应提供 RESTful API,用于运行时调优、配置、健康检查等。OpenAPI 支持对于检查、分析和记录 RESTful API 非常有用。

  • 身份验证和授权

    这些功能允许服务识别客户端及其所需操作的权限。

  • 传输层安全 (TLS)

    此功能可保护消息在网络传输过程中不被读取。TLS 的使用已成为现代服务几乎强制性的要求。

项目结构

示例软件的整体结构如图 1 所示。

Chart

图 1. 示例结构。

主服务是一个基于 NestJS 的服务,包含 GraphQL 和 RESTful 组件。它使用位于 .\libs 目录中的一组库。这些库实现了服务中使用的通用功能,稍后将进行讨论。GraphQL 支持的代码主要位于 gql-module-lib 库中,其主要功能在类 Gql 中,包含 static 方法。应用程序 graphQL-module-generator 根据 GraphQL schema 生成 GraphQL 模块文件(包含服务类(请勿将 NestJS 服务类与服务应用程序混淆)和 resolver 类)。简单的第三方文件服务器提供对 GraphQL schema 和主服务模块的 Web 访问。

数据访问与 N+1 问题

GraphQL 意味着使用 resolver。NestJS 文档描述了根据 GraphQL schema 创建 resolver 类型 并将它们集成到 NestJS 模块结构中。这确保了在形成 Web 响应分层结构时,框架会调用适当的 resolve 函数。resolve 函数如何从数据库和/或其他服务获取实际数据由开发人员决定。如果每个 resolve 函数都发出一个 SELECT 查询,那么这些查询的总数等于每个层级返回行的总和。例如,通过这种方法,在我们的示例 AllPersons 查询的最高级别,会获取所有 n 个人。然后在第二级,会执行 nSELECT 查询来获取每个人的从属关系和关系。在每个后续级别都会观察到类似的情况。显然,大量的返回行会导致大量数据库查询,从而导致严重的性能损失。在这种情况下,数据库查询的数量为

database_queries = Σ entries(level - 1)
                                 levels

这个问题通常被称为 N+1 问题。它如图 2 所示。

Non-Optimized GraphQL

图 2. 非优化的 GraphQL。

除了性能问题,上述方法还使得数据库事务的实现更加困难和低效。显然,数据访问必须进行优化。

让我们考虑以下示例。我们有一个类型 Person,它有几个标量类型的属性,如姓名、姓氏、地址、电话等,以及两个复杂类型的属性,如 affiliations(它是复杂 Affiliation 类型的数组)和 relations(它是复杂 Relation 类型的数组)(相应的 GraphQL schema 可以在文件 .\nestjs-graphql-service\gql\schema.gql 中看到)。复杂类型 AffiliationRelation 进而由标量和复杂类型的属性组成。例如,查询 PersonBySurname 返回所有姓氏为给定姓氏的 PersonPerson 类型对象的数组)。

NestJS 文档推荐创建 resolver 类,其中包含下面被称为 resolver 的 resolve 方法。在 GraphQL 查询执行期间,根据这些建议构建的 resolver 会被框架自动调用。

优化的方法如下:已知 GraphQL 查询的返回类型,在查询 resolver 中获取该类型的数据,然后获取其所有复杂属性类型的数据。让我们以 GraphQL 查询 PersonBySurname 为例。该查询应返回 Person 类型对象的数组。所以我们的第一个数据库查询将是

SELECT ... FROM persons WHERE surname = '...'

此查询会为给定姓氏的所有人带来 Person 类型的标量属性。但是 Person 类型还包含复杂类型的属性,如 affiliationsrelations。为了获取它们,我们需要查询相应的数据库表。一般来说,查询如下所示

SELECT ... FROM ... WHERE id IN ( entries(level - 1) )

对复杂类型的所有内部属性执行类似的过程。

以下一组 SELECT 数据库查询是由我们代码示例中的 AllPerson GraphQL 查询生成的

SELECT _id, id, givenName, surname, address FROM persons
SELECT _id, id, organization_id, role_id, person_id FROM affiliations _
                                          WHERE person_id IN (6,7,8,9,10,11,12)
SELECT _id, id, name, address, parent_id FROM organizations WHERE _id IN (8,6,9,10,7)
SELECT _id, id, name, parent_id FROM organizations WHERE _id IN (6,7)
SELECT _id, id, name, description FROM roles WHERE _id IN (4,5,6)
SELECT _id, id, kind, p1_id, p2_id FROM relations WHERE p1_id IN (6,7,8,9,10,11,12)
SELECT _id, id, givenName FROM persons WHERE _id IN (10,7,8,9,6) 

正如你所看到的,数据库调用(SELECT)的数量对应于 GraphQL 查询的内部层数,并且与每个级别获取的记录数无关。

有趣的是,上述 SELECT 集合包含一些重复。这是由于 GraphQL schema 中的实体关系。所以严格来说,通过在执行 SELECT 之前检查缓存,可以进一步减少数据库访问。但这会增加代码的复杂性,因此未在此示例中实现。这些查询为我们带来了满足 GraphQL 查询所需的所有数据。

现在,拥有所有必需的数据后,我们必须根据 GraphQL 查询以结构化的顺序呈现它们。但这个问题已经被我们已有的 resolver 解决了。resolve 方法的返回值将自动插入到 GraphQL 查询的响应对象中。所以我们只需要在 GraphQL 查询 resolver 中提供基于我们已从数据库获取的数据的返回值。最简单的方法是将获取的数据放入内存缓存对象中,并将其附加到可跨所有 resolver 访问的 context 对象中。缓存组织为一个字典,其键根据 resolver 而定。每个 resolver 返回从缓存中提取的带有相应键的数据。在这种情况下,数据库查询的数量为

database_queries = levels

此过程如图 3 所示。

Optimized GraphQL

图 3. 优化的 GraphQL。

这些返回值构成了在完成 GraphQL 查询时发送给客户端的响应对象。由于缓存对象是 context 对象的一个属性,它将与 context 一起在 GraphQL 查询处理结束时被销毁。因此,缓存对象为每个客户端请求创建,其生命周期对应于该请求的处理。

基于内存缓存的数据获取优化提高了性能。但其限制在于可用内存(RAM)的大小。如果缓存对象太大而无法容纳在单个进程内存中,则可以使用分布式缓存解决方案,如 RedisMemcached 或类似工具。在本文中,为了简单起见,我们假设使用简单的进程内内存缓存。Facebook 也提供了类似的内存缓存解决方案,名为 dataloader 包。但它需要调整才能与 NestJS 一起使用。

基于 Schema 的自动 Resolver 生成

我们看到,根据 NestJS 文档中解释的规则,resolver 类及其方法是根据 GraphQL schema 构建的。这个结构包含在适当的模块中,确保框架能够按正确的顺序调用 resolver。现在我们可以更进一步,根据 GraphQL schema 自动生成 resolver,为开发人员提供 resolver 类和方法,甚至部分实现。这可以通过一个独立的 gql-resolver-generator 应用程序来完成。此应用程序以 GraphQL schema 为输入,并生成一个完整的 GraphQL 模块,包括 resolver 和服务类,并优化了数据库访问和内存缓存使用。Resolver 生成器解析 GraphQL schema,并为 GraphQL 查询、字段和突变创建 resolver 类和方法。它还创建一个用于数据库访问的服务类。Resolver 和服务方法的名称是根据特定的命名约定定义的。

在本工作中,采用了半自动 GraphQL 模块生成。这意味着方法的签名和部分实现是自动生成的,而部分实现(特别是数据库查询)需要手动完成。请注意,对于给定的数据库结构,GraphQL 模块生成可以完全自动化,无需手动添加任何代码。但在本文中,我们决定为了通用性而仅限于半自动方法。因此,我们的 GraphQL 模块生成以 GraphQL schema 为输入,并生成一个包含 resolver 和相应服务类的 GraphQL 模块文件。手动调整模块后,即可使用。

在我们的代码示例中,gql-resolver-generator 工具生成了文件 .\generated-gql.module.ts.\schema.ts。文件 .\generated-gql.module.ts 在手动调整后将被转换为文件 .\nestjs-graphql-service\src\modules\gql.module.ts。文件 .\schema.ts 包含与 TypeORM 实体类型对应的类型,并可作为它们的样板文件。实际上,gql-resolver-generator 可以设计成自动生成实体类型。

从 Web 加载 GraphQL Schema 和 Module

经典的 NestJS GraphQL 实现意味着从磁盘加载 GraphQL schema 和模块。但这并不总是方便的。当我们有一组不同的 GraphQL schema 及其 resolver 时,最好能在 NestJS 服务启动时从 Web 加载它们。这使我们能够使用相同的服务实例处理不同的 GraphQL schema。另一方面,也应支持从磁盘加载 GraphQL schema 和模块的功能,例如方便调试。在这种情况下,我们可以为各种 GraphQL schema 使用相同的服务结构。在我们的示例中,第三方 .\file-server 应用程序提供对 GraphQL schema 和模块的 Web 访问。库 .\libs\gql-module-lib 加载 GraphQL schema,而库 module-loader-lib 加载 GraphQL 模块文件 gql.module.ts

只从数据库获取请求的数据

GraphQL 的核心思想是只将客户端实际请求的数据发送给客户端。但我们也希望只从数据库表中选择请求的数据。这在 .\libs\gql-module-lib 库的 Gql 类中通过比较请求的字段与数据库表列名以及特定的命名约定来实现。

响应拦截

有时,我们希望在响应对象(数据或错误消息)发送给客户端之前对其进行最后修改。为此,我们在 apollo-server-corerequestPipeline.js 文件中添加了一个小代码片段,并将其更新版本复制到 .\libs\gql-module-lib\node_modules\apollo-server-express\node_modules\apollo-server-core\dist\requestPipeline.js

有用功能和库

常用工具

common-utils-lib 包含常用类和函数的集合,包括一个简单的字典。

配置

config-lib 负责配置。它提供了从 .env 文件读取数据的方法。

日志记录

日志记录在 logger-lib 库中实现。它基于 winston 框架。它唯一的代码文件 index.ts 提供了 nest-winston 包的配置,用于将适当的日志输出到文件(分别用于信息和错误)和控制台。日志记录配置从 .env 文件读取。

参考: config-lib

动态模块上传

module-loader-lib 负责动态模块上传(在本例中为 GraphQL 模块)。

参考: common-utils-lib, config-lib, logger-lib

GraphQL 支持

gql-module-lib 提供了以下问题的解决方案

  • 数据获取优化,
  • 动态加载 GraphQL schema,以及
  • 仅从数据库获取所需数据。

参考: common-utils-lib, logger-lib

访问 SQL Server

sql-base-lib 提供了基本的 SQL Server 支持软件。它包括建立与 SQL Server 的连接和一个简单的事务机制。

参考: config-lib

自定义 Guard 和 Interceptor

interceptors-lib 包含示例服务中使用的主要自定义 interceptor 和 guard。

参考: config-lib, logger-lib

TLS 支持

来自 interceptors-lib 库的 TlsGuard 提供了对 TLS 的支持。其他相关代码放置在 bootstrap() 函数(文件 main.ts)中。它创建一个带有适当密钥和证书的 httpOptions 的服务。

身份验证

auth-lib 基于 JSON Web Token (JWT) 方法为 GraphQL resolver 和 REST controller 提供身份验证。数据库表 users 提供身份验证和授权的数据。为了说明授权,其 permission 列包含一个两位数的字符串,每个位置填充 0(拒绝访问)或 1(允许访问)。第一个位置表示查询执行权限,第二个位置控制对突变执行的访问。

参考: config-lib, sql-base-lib

登录

login-lib 在使用身份验证时提供用户登录的方法。类 LoginController 提供 login() 方法,该方法接受用户凭据,处理 POST /auth/login 请求,以及处理 GET /profile 请求的“受保护”方法 getProfile(),该请求返回当前用户的配置文件。

参考: config-lib, interceptors-lib, auth-lib

OpenAPI (Swagger)

NestJS 为 OpenAPI 提供了出色的支持,只需几行代码。此代码位于 main.ts 文件中的 bootstrap() 函数中,并带有相应的注释。

代码示例

必备组件

你的机器上应该安装了 Node.js 和 SQL Server。为了测试身份验证和 TLS,应该安装 Postman,也许还需要 curl。虽然理论上可以在没有 IDE 的情况下运行示例,但使用一些开发环境(如 WebStormVisual Studio Code)来操作它会更方便。

首次运行

首次运行示例时,请遵循以下步骤

  1. 启动 SQL Server Management Studio(或其他管理 SQL Server 的 IDE),然后执行 .\script_PersonDb.sql 来创建 PersonDB 数据库。
  2. 打开 .\libs 目录并运行命令文件 _BUILD_LIBS.cmd 来构建所有库。
  3. 打开 .\init-db 目录并运行命令文件 _NPM_INSTALL.cmd, _BUILD.cmd_RUN.cmd 。这会构建并运行应用程序,用一些初始数据填充 PersonDB 数据库。在构建之前,实体文件已从 .\typeorm-entities 目录复制到 .\init-db\src\sql\entities 目录。
  4. 打开 .\nestjs-graphql-service 目录并运行命令文件 _NPM_INSTALL.cmd_BUILD.cmd 来构建主服务。在构建之前,实体文件已从 .\typeorm-entities 目录复制到 .\nestjs-graphql-service\src\sql\entities 目录。构建后,文件 .\nestjs-graphql-service\gql\schema.gql.\nestjs-graphql-service\dist\modules\gql.module.js 已复制到 .\file-server\gql 目录。
  5. 默认情况下,主服务配置(通过其 .env 文件)为从 file-server 服务加载 GraphQL schema 和模块,并在没有 TLS 和身份验证的情况下运行。因此,file-server 服务应在主服务之前启动。这可以通过运行命令文件 _RUN_FILE_SERVER.cmd 来完成。你会在一个单独的控制台窗口中看到它,消息为:“Server listening on port 9000”。如果你想让主服务从磁盘加载 GraphQL schema 和模块,那么你需要在 .env 文件中注释掉“# Load from Web”段,并取消注释“# Load locally”段,同时用你的本地路径替换 GQL_URL 的值。
  6. .\nestjs-graphql-service 目录中运行命令文件 _RUN.cmd 来启动主服务。它将打开另一个控制台窗口,显示关于服务进度的几条消息,从“Main Service started”开始,到“http on port 3000”和“http on port 443”结束。在 file-server 控制台窗口中,将出现消息“GET /gql/gql.module.js”和“GET /gql/schema.gql”。
  7. 启动你喜欢的浏览器(我已用 Google Chrome 和 Microsoft Edge 测试过)。首先,让我们访问 localhost:3000/gql。GraphQL 实现提供了一个 Playground Web 应用程序来测试服务。它的网页出现在你的浏览器中。将文件 .\nestjs-graphql-service\gql\graphql-queries-mutation.txt 的内容复制(或简单地拖放)到出现页面的左侧(查询面板),然后使用三角形按钮执行查询和突变。

可以看到,通过运行突变,我们在数据库中添加了两个人。第二次执行相同的突变会失败,并出现条目已存在的数据库消息。你可以尝试使用符合给定 GraphQL schema 的各种查询和突变。PersonsBySurname GraphQL 查询的结果如下表 4 所示。

PersonsBySurname GraphQL query

图 4. PersonsBySurname GraphQL 查询。

我们也可以测试我们服务的 RESTful 部分。让我们浏览 https://:3000/numEcho/3 并观察 3 作为结果出现。我们也可以使用 OpenAPI 浏览 https://:3000/api。结果如下表 5 所示。

OpenAPI

图 5. OpenAPI。

认证和 TLS

现在你可能想测试启用身份验证和 TLS 的服务。为此,我们需要停止主服务(按 Ctrl-C),对其配置文件 .env 进行相应更改,然后重新启动服务。要启用身份验证,应将参数 IS_AUTH_ON 设置为 true,要启用 TLS,应将参数 IS_TLS_ON 设置为 true。请注意,此处使用自签名证书进行 TLS 测试。因此,它会受到自签名证书的限制。

进行这些更改后,请运行命令文件 _RUN.cmd 并观察控制台日志中的变化。现在,在身份验证到位的情况下,为了使用我们的主服务,我们必须登录。让我们代表用户 Rachel 使用 curl 进行登录,如下所示

curl -d "username=Rachel&password=rrr" -X POST -k https:///auth/login

我们也可以使用 Postman 来完成此操作,使用

POST https:///auth/login?username=Rachel&password=rrr

作为响应,我们得到一个 access_token,该令牌可用于对服务的进一步请求。

现在我们可以使用 Postman 执行 GraphQL 查询和突变,在 Authorization 页面上将访问令牌作为 Token 传递。

RESTful API 可以使用 OpenAPI 进行测试,在 Authorize -> Value 文本框中插入访问令牌。现在执行 GET /profile 将显示用户 Rachel 的 ID(“u_1”)和她的权限(“11”)。并且 GET /numEcho/{n} 也只在授权后才能工作。

GraphQL 服务、Resolver 和 Module 生成

如前所述,可以根据 schema 生成 GraphQL 服务、resolver 和模块。相应的应用程序位于 .\graphql-module-generator 目录中。应在 .\graphql-module-generator 目录中执行文件 _NPM_INSTALL.cmd, _BUILD.cmd_RUN.cmd 以生成 .\generated-gql.module.ts 文件。此文件包含 SqlService, GqlModule 和 resolver 类的样板代码。在此示例文件中,.\generated-gql.module.ts 可以通过向其 resolver 添加实际的 SQL 查询来转换为主服务的 .\nestjs-graphql-service\src\modules\gql.module.ts 文件。在实际应用中,根据已知的数据库结构和适当的命名约定,gql.module.ts 文件的生成可能完全自动化。graphql-module-generator 应用程序还生成文件 .\schema.ts,其内容对于编写 TypeORM 实体类很有用(实际上,这些类最初位于 .\typeorm-entities\*.entity.ts 文件中,并如前所述复制到适当的项目目录中,也可以根据数据库结构自动生成)。

响应变更

如前所述,有时我们希望在发送给客户端的数据或错误消息中进行最终更改。命令文件 .\nestjs-graphql-service\_BUILD.cmdrequestPipeline.js 文件的更新版本复制到正确的位置。添加的代码片段调用由 resolver 在 gql.module.ts 文件中提供的回调函数 transformResponse(),该函数附加到请求 context 对象。

结论

本文介绍了基于 Node.js NestJS 的服务示例,其中包含 GraphQL 和 REST 组件。GraphQL 的使用在几个方面进行了优化,主要是通过减少每个客户端请求的数据库查询次数。该服务使用 TLS 进行安全通信和 JWT 身份验证机制。

历史

  • 2020 年 12 月 16 日:初始版本
© . All rights reserved.