解析器(Resolvers)
解析器为将 GraphQL 操作(查询、变更或订阅)转换为数据提供了指令。它们以与我们在模式中指定的相同数据形状返回数据,可以是同步地返回,也可以是一个解析为该形状结果的承诺(Promise)。通常情况下,您需要手动创建一个解析器映射(resolver map)。然而,@nestjs/graphql
包则利用您用于注释类的装饰器提供的元数据自动生成解析器映射。为了演示使用该包的功能来创建 GraphQL API 的过程,我们将创建一个简单的作者 API。
代码优先#
在代码优先(code-first)方法中,我们不遵循传统的手动编写 GraphQL SDL 来创建 GraphQL 模式的过程。相反,我们使用 TypeScript 装饰器从 TypeScript 类定义中生成 SDL。@nestjs/graphql
包会读取通过装饰器定义的元数据,并自动为您生成模式。
对象类型#
在 GraphQL 模式中,大部分定义都是对象类型(object types)。您定义的每个对象类型应该表示应用程序客户端可能需要进行交互的领域对象。例如,我们的示例 API 需要能够获取作者及其文章的列表,因此我们应该定义Author
类型和Post
类型来支持此功能。
如果我们采用模式优先(schema-first)方法,我们会使用类似下面的 SDL 来定义这样的模式:
type Author {
id: Int!
firstName: String
lastName: String
posts: [Post!]!
}
在这种情况下,使用代码优先方法,我们使用 TypeScript 类定义模式,并使用 TypeScript 装饰器来注释这些类的字段。相当于上述 SDL 的代码优先方法的等效代码是:
authors/models/author.model.ts
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { Post } from './post';
@ObjectType()
export class Author {
@Field(type => Int)
id: number;
@Field({ nullable: true })
firstName?: string;
@Field({ nullable: true })
lastName?: string;
@Field(type => [Post])
posts: Post[];
}
提示:TypeScript 的元数据反射系统存在一些限制,这使得无法确定类包含哪些属性,也无法识别给定属性是可选的还是必需的。由于这些限制,我们必须要么在我们的模式定义类中显式地使用@Field()
装饰器来提供有关每个字段的 GraphQL 类型和可选性的元数据,要么使用 CLI 插件来为我们生成这些信息。
Author
对象类型,就像任何类一样,由一系列字段组成,每个字段声明一种类型。字段的类型对应于 GraphQL 类型。字段的 GraphQL 类型可以是另一个对象类型,也可以是标量类型。GraphQL 标量类型是原始类型(如ID
、String
、Boolean
或 Int
),它们解析为单个值。
提示 除了 GraphQL 内置的标量类型,您还可以定义自定义标量类型(阅读 更多)。
上面的Author
对象类型定义将导致 Nest 生成我们上面展示的 SDL:
type Author {
id: Int!
firstName: String
lastName: String
posts: [Post!]!
}
@Field()
装饰器接受一个可选的类型函数(例如,type => Int
),还可以接受一个选项对象。
类型函数在 TypeScript 类型系统和 GraphQL 类型系统之间可能存在歧义时是必需的。具体来说:对于 string
和 boolean
类型,不需要类型函数;但对于 number
类型,它是必需的(必须映射到 GraphQL 的 Int
或 Float
)。类型函数应该简单地返回所需的 GraphQL 类型(正如在这些章节中的各种示例中所示)。
选项对象可以具有以下任意键值对:
nullable
:用于指定字段是否可为 null(在 SDL 中,默认情况下,每个字段都是非空的);类型为boolean
description
:用于设置字段描述;类型为string
deprecationReason
:用于将字段标记为已弃用;类型为string
例如:
@Field({ description: `Book title`, deprecationReason: 'Not useful in v2 schema' })
title: string;
提示 您还可以为整个对象类型添加描述或将其标记为已弃用:@ObjectType({ description: 'Author model' })
。
当字段是数组时,我们必须在 Field()
装饰器的类型函数中手动指定数组类型,如下所示:
@Field(type => [Post])
posts: Post[];
提示 使用数组括号表示法([ ]
),我们可以指示数组的深度。例如,使用[[Int]]
将表示一个整数矩阵。
要声明数组的项目(而不是数组本身)可为 null,将nullable
属性设置为'items'
,如下所示:
@Field(type => [Post], { nullable: 'items' })
posts: Post[];
提示 如果数组本身和其项目都可为 null,将nullable
设置为'itemsAndList'
。
现在Author
对象类型已经创建,让我们来定义Post
对象类型。
import { Field, Int, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class Post {
@Field(type => Int)
id: number;
@Field()
title: string;
@Field(type => Int, { nullable: true })
votes?: number;
}
Post
对象类型将导致在 SDL 中生成以下部分的 GraphQL 模式:
type Post {
id: Int!
title: String!
votes: Int
}
代码优先解析器#
在这一点上,我们已经定义了可以在数据图中存在的对象(类型定义),但客户端尚无法与这些对象进行交互。为了解决这个问题,我们需要创建一个解析器类。在代码优先方法中,解析器类不仅定义了解析器函数,还生成了查询类型。随着我们在下面的示例中逐步进行,这将变得清晰:
authors/authors.resolver.ts
@Resolver(of => Author)
export class AuthorsResolver {
constructor(
private authorsService: AuthorsService,
private postsService: PostsService,
) {}
@Query(returns => Author)
async author(@Args('id', { type: () => Int }) id: number) {
return this.authorsService.findOneById(id);
}
@ResolveField()
async posts(@Parent() author: Author) {
const { id } = author;
return this.postsService.findAll({ authorId: id });
}
}
提示 所有的装饰器(例如,@Resolver
、@ResolveField
、@Args
等)都从@nestjs/graphql
包中导出。
您可以定义多个解析器类。Nest 将在运行时将它们组合在一起。有关代码组织的更多信息,请参阅下面的模块部分。
注意AuthorsService
和PostsService
类内部的逻辑可以根据需要是简单或复杂的。本示例的主要目的是展示如何构建解析器以及它们如何与其他提供者进行交互。
在上面的示例中,我们创建了AuthorsResolver
,它定义了一个查询解析器函数和一个字段解析器函数。要创建解析器,我们创建一个类,将解析器函数作为方法,并使用@Resolver()
装饰器对类进行注释。
在这个示例中,我们定义了一个查询处理程序,用于根据请求中传递的id
获取作者对象。要指定方法是查询处理程序,使用@Query()
装饰器。
传递给@Resolver()
装饰器的参数是可选的,但在图变得复杂时会发挥作用。它用于为字段解析器函数提供父对象,这些函数在遍历对象图时向下遍历。
在我们的示例中,由于该类包括一个字段解析器函数(用于Author
对象类型的posts
属性),我们必须为@Resolver()
装饰器提供一个值,以指示哪个类是在此类中定义的所有字段解析器的父类型(即对应的ObjectType
类名)。正如从示例中可以明确看出的那样,当编写字段解析器函数时,有必要访问父对象(字段解析正在解析的对象的成员)。在这个示例中,我们使用一个调用服务的字段解析器来填充作者的文章数组,该服务将作者的id
作为参数。因此需要在@Resolver()
装饰器中标识父对象。注意对应的使用@Parent()
方法参数装饰器,以从字段解析器中提取对该父对象的引用。
我们可以定义多个@Query()
解析器函数(无论是在此类中还是在任何其他解析器类中),它们将被聚合到单个查询类型定义中,在生成的 SDL 中与解析器映射中的适当条目一起。这允许您在接近模型和使用的服务的地方定义查询,并将它们在模块中保持良好组织。
提示 Nest CLI 提供了一个生成器(schematic),它可以自动生成所有样板代码,帮助我们避免进行所有这些繁琐的步骤,从而简化开发者体验。阅读更多关于这个功能的内容,请查看这里。
查询类型名称#
在上面的示例中,@Query()
装饰器基于方法名生成了一个 GraphQL 模式查询类型名称。例如,考虑上面示例中的以下构造:
@Query(returns => Author)
async author(@Args('id', { type: () => Int }) id: number) {
return this.authorsService.findOneById(id);
}
这将为我们的模式生成以下关于author
查询的条目(查询类型使用与方法名相同的名称):
type Query {
author(id: Int!): Author
}
提示 您可以在这里了解更多关于 GraphQL 查询的信息。
通常,我们倾向于将这些名称解耦;例如,我们更喜欢为查询处理程序方法使用类似于getAuthor()
的名称,但仍然为查询类型名称使用author
。字段解析器也是如此。我们可以通过将映射名称作为@Query()
和@ResolveField()
装饰器的参数传递来轻松实现这一点,如下所示:
@Resolver(of => Author)
export class AuthorsResolver {
constructor(
private authorsService: AuthorsService,
private postsService: PostsService,
) {}
@Query(returns => Author, { name: 'author' })
async getAuthor(@Args('id', { type: () => Int }) id: number) {
return this.authorsService.findOneById(id);
}
@ResolveField('posts', returns => [Post])
async getPosts(@Parent() author: Author) {
const { id } = author;
return this.postsService.findAll({ authorId: id });
}
}
上述的getAuthor
处理程序方法将导致在 SDL 中生成以下部分的 GraphQL 模式:
type Query {
author(id: Int!): Author
}
查询装饰器选项#
@Query()
装饰器的选项对象(在上面传递{ name: 'author' }
的地方)接受许多键值对:
name
:查询的名称;类型为string
description
:将用于生成 GraphQL 模式文档的描述(例如,在 GraphQL playground 中);类型为string
deprecationReason
:将查询元数据设置为显示查询已弃用(例如,在 GraphQL playground 中);类型为string
nullable
:查询是否可以返回空数据响应;类型为boolean
或'items'
或'itemsAndList'
(有关'items'
和'itemsAndList'
的详细信息,请参见上面的内容)
参数装饰器选项#
使用@Args()
装饰器从请求中提取参数,以便在方法处理程序中使用。这与REST 路由参数参数提取的方式非常类似。
通常,@Args()
装饰器会很简单,并且不需要像上面getAuthor()
方法中那样的对象参数。例如,如果标识符的类型是字符串,以下构造就足够了,它会简单地从传入的 GraphQL 请求中提取指定的字段,并将其用作方法参数。
@Args('id') id: string
在getAuthor()
情况下,使用了number
类型,这会带来一些挑战。number
TypeScript 类型并不能提供关于预期的 GraphQL 表示的足够信息(例如,Int
vs. Float
)。因此,我们必须显式地传递类型引用。我们通过向Args()
装饰器传递第二个参数来做到这一点,该参数包含参数选项,如下所示:
@Query(returns => Author, { name: 'author' })
async getAuthor(@Args('id', { type: () => Int }) id: number) {
return this.authorsService.findOneById(id);
}
选项对象允许我们指定以下可选的键值对:
type
:返回 GraphQL 类型的函数defaultValue
:默认值;any
description
:描述元数据;string
deprecationReason
:用于弃用字段并提供描述其原因的元数据;string
nullable
:字段是否可为空
查询处理程序方法可以接受多个参数。假设我们想要根据作者的firstName
和lastName
获取作者。在这种情况下,我们可以两次调用@Args
:
getAuthor(
@Args('firstName', { nullable: true }) firstName?: string,
@Args('lastName', { defaultValue: '' }) lastName?: string,
) {}
专用参数类#
使用内联的@Args()
调用,像上面的示例代码会变得臃肿。相反,您可以创建一个专门的GetAuthorArgs
参数类,并在处理程序方法中按如下方式访问它:
@Args() args: GetAuthorArgs
使用@ArgsType()
创建GetAuthorArgs
类,如下所示:
import { MinLength } from 'class-validator';
import { Field, ArgsType } from '@nestjs/graphql';
@ArgsType()
class GetAuthorArgs {
@Field({ nullable: true })
firstName?: string;
@Field({ defaultValue: '' })
@MinLength(3)
lastName: string;
}
提示 再次强调,由于 TypeScript 的元数据反射系统的限制,必须要么使用@Field
装饰器手动指定类型和可选性,要么使用 CLI 插件。
这将导致在 SDL 中生成以下部分的 GraphQL 模式:
type Query {
author(firstName: String, lastName: String = ''): Author
}
提示 请注意,像GetAuthorArgs
这样的参数类非常适用于ValidationPipe
(阅读更多信息)。
类继承#
您可以使用标准的 TypeScript 类继承来创建具有通用类型特性(字段和字段属性、验证等)的基类,这些特性可以进行扩展。例如,您可能有一组与分页相关的参数,总是包括标准的offset
和limit
字段,但还包括其他特定类型的索引字段。您可以按照下面的方式设置一个类层次结构。
基础@ArgsType()
类:
@ArgsType()
class PaginationArgs {
@Field((type) => Int)
offset: number = 0;
@Field((type) => Int)
limit: number = 10;
}
基础@ArgsType()
类的特定类型子类:
@ArgsType()
class GetAuthorArgs extends PaginationArgs {
@Field({ nullable: true })
firstName?: string;
@Field({ defaultValue: '' })
@MinLength(3)
lastName: string;
}
对@ObjectType()
对象也可以采用相同的方法。在基类上定义通用属性:
@ObjectType()
class Character {
@Field((type) => Int)
id: number;
@Field()
name: string;
}
在子类上添加特定类型的属性:
@ObjectType()
class Warrior extends Character {
@Field()
level: number;
}
您还可以在解析器中使用继承。您可以通过结合继承和 TypeScript 泛型来确保类型安全。例如,要创建一个带有通用findAll
查询的基类,请使用以下构造:
function BaseResolver<T extends Type<unknown>>(classRef: T): any {
@Resolver({ isAbstract: true })
abstract class BaseResolverHost {
@Query((type) => [classRef], { name: `findAll${classRef.name}` })
async findAll(): Promise<T[]> {
return [];
}
}
return BaseResolverHost;
}
请注意以下几点:
- 必须使用明确的返回类型(上面是
any
):否则 TypeScript 会对私有类定义的使用进行投诉。建议:定义一个接口,而不是使用any
。 Type
是从@nestjs/common
包中导入的。isAbstract: true
属性表示不应为此类生成 SDL(模式定义语言语句)。注意,您也可以为其他类型设置此属性以抑制 SDL 生成。
以下是如何生成BaseResolver
的具体子类:
@Resolver((of) => Recipe)
export class RecipesResolver extends BaseResolver(Recipe) {
constructor(private recipesService: RecipesService) {
super();
}
}
这个结构将生成以下的 SDL:
type Query {
findAllRecipe: [Recipe!]!
}
泛型#
在上面的内容中,我们看到了泛型的一个用法。这个强大的 TypeScript 特性可以用来创建有用的抽象。例如,这是一个基于此文档的样例基于游标的分页实现:
import { Field, ObjectType, Int } from '@nestjs/graphql';
import { Type } from '@nestjs/common';
interface IEdgeType<T> {
cursor: string;
node: T;
}
export interface IPaginatedType<T> {
edges: IEdgeType<T>[];
nodes: T[];
totalCount: number;
hasNextPage: boolean;
}
export function Paginated<T>(classRef: Type<T>): Type<IPaginatedType<T>> {
@ObjectType(`${classRef.name}Edge`)
abstract class EdgeType {
@Field((type) => String)
cursor: string;
@Field((type) => classRef)
node: T;
}
@ObjectType({ isAbstract: true })
abstract class PaginatedType implements IPaginatedType<T> {
@Field((type) => [EdgeType], { nullable: true })
edges: EdgeType[];
@Field((type) => [classRef], { nullable: true })
nodes: T[];
@Field((type) => Int)
totalCount: number;
@Field()
hasNextPage: boolean;
}
return PaginatedType as Type<IPaginatedType<T>>;
}
有了上面定义的基类,现在我们可以轻松地创建继承此行为的专用类型。例如:
@ObjectType()
class PaginatedAuthor extends Paginated(Author) {}
模式优先#
如在前一章节中提到的,采用模式优先方法时,我们首先手动在 SDL 中定义模式类型(阅读更多)。考虑以下 SDL 类型定义。
提示 为了方便起见,在本章中,我们已将所有的 SDL 聚合到一个位置(例如,一个.graphql
文件,如下所示)。在实践中,您可能会发现以模块化的方式组织代码更合适。例如,为了表示每个领域实体,创建单独的 SDL 文件来定义类型,同时还有相关的服务、解析器代码和 Nest 模块定义类,并将其放在一个专用的目录中。Nest 将在运行时聚合所有单独的模式类型定义。
type Author {
id: Int!
firstName: String
lastName: String
posts: [Post]
}
type Post {
id: Int!
title: String!
votes: Int
}
type Query {
author(id: Int!): Author
}
模式优先解析器#
上面的模式暴露了一个单一的查询 - author(id: Int!): Author
。
提示 您可以在这里了解更多关于 GraphQL 查询的信息。
现在,让我们创建一个AuthorsResolver
类来解析作者查询:
@Resolver('Author')
export class AuthorsResolver {
constructor(
private authorsService: AuthorsService,
private postsService: PostsService,
) {}
@Query()
async author(@Args('id') id: number) {
return this.authorsService.findOneById(id);
}
@ResolveField()
async posts(@Parent() author) {
const { id } = author;
return this.postsService.findAll({ authorId: id });
}
}
提示 所有装饰器(例如,@Resolver
、@ResolveField
、@Args
等)都从@nestjs/graphql
包中导出。
注意AuthorsService
和PostsService
类内部的逻辑可以根据需要是简单或复杂的。本示例的主要目的是展示如何构建解析器以及它们如何与其他提供者进行交互。
@Resolver()
装饰器是必需的。它接受一个可选的字符串参数,表示一个类的名称。只要类中包含@ResolveField()
装饰器,就需要提供这个类名,以便告知 Nest 装饰的方法与一个父类型关联(在我们当前的示例中是Author
类型)。或者,可以为每个方法单独设置@Resolver()
,而不是在类的顶部设置:
@Resolver('Author')
@ResolveField()
async posts(@Parent() author) {
const { id } = author;
return this.postsService.findAll({ authorId: id });
}
在这种情况下(在方法级别使用@Resolver()
装饰器),如果类内部有多个@ResolveField()
装饰器,必须为所有的装饰器都添加@Resolver()
。这不被认为是最佳实践(因为会创建额外的开销)。
提示 传递给@Resolver()
的任何类名参数不会影响查询(@Query()
装饰器)或变更操作(@Mutation()
装饰器)。
警告 在方法级别使用@Resolver
装饰器不受代码优先方法的支持。
在上面的示例中,@Query()
和@ResolveField()
装饰器与基于方法名的 GraphQL 模式类型相关联。例如,考虑上面示例中的以下构造:
@Query()
async author(@Args('id') id: number) {
return this.authorsService.findOneById(id);
}
这将在我们的模式中为author
查询生成以下条目(查询类型使用与方法名相同的名称):
type Query {
author(id: Int!): Author
}
通常,我们更愿意解耦这些,使用getAuthor()
或getPosts()
这样的名称来命名解析器方法。我们可以通过将映射名称作为参数传递给装饰器来轻松实现这一点,如下所示:
@Resolver('Author')
export class AuthorsResolver {
constructor(
private authorsService: AuthorsService,
private postsService: PostsService,
) {}
@Query('author')
async getAuthor(@Args('id') id: number) {
return this.authorsService.findOneById(id);
}
@ResolveField('posts')
async getPosts(@Parent() author) {
const { id } = author;
return this.postsService.findAll({ authorId: id });
}
}
提示 Nest CLI 提供了一个生成器(schematic),它可以自动生成所有样板代码,帮助我们避免进行所有这些繁琐的步骤,从而简化开发者体验。阅读更多关于这个功能的内容,请查看这里。
生成类型#
假设我们使用了模式优先的方法,并启用了类型生成功能(在前面章节中展示的outputAs: 'class'
),一旦运行应用程序,它将在您在GraphQLModule.forRoot()
方法中指定的位置生成以下文件。例如,在src/graphql.ts
中:
export (class Author {
id: number;
firstName?: string;
lastName?: string;
posts?: Post[];
})
export class Post {
id: number;
title: string;
votes?: number;
}
export abstract class IQuery {
abstract author(id: number): Author | Promise<Author>;
}
通过生成类(而不是默认的生成接口的技术),您可以将声明性验证装饰器与模式优先方法结合使用,这是一种非常有用的技术(阅读更多)。例如,您可以向生成的CreatePostInput
类添加class-validator
装饰器,如下所示,以强制执行title
字段上的最小和最大字符串长度:
import { MinLength, MaxLength } from 'class-validator';
export class CreatePostInput {
@MinLength(3)
@MaxLength(50)
title: string;
}
注意 要启用输入(和参数)的自动验证,请使用ValidationPipe
。阅读更多关于验证的信息,请查看此处,更具体地了解有关管道的信息,请查看此处。
然而,如果您直接在自动生成的文件中添加装饰器,它们将在每次生成文件时被覆盖。相反,创建一个单独的文件,然后只需扩展生成的类即可。
import { MinLength, MaxLength } from 'class-validator';
import { Post } from '../../graphql.ts';
export class CreatePostInput extends Post {
@MinLength(3)
@MaxLength(50)
title: string;
}
GraphQL参数装饰器#
我们可以使用专用的装饰器访问标准的 GraphQL 解析器参数。以下是 Nest 装饰器与它们表示的普通 Apollo 参数的对比。
@Root() and @Parent() | root /parent |
@Context(param?: string) | context / context[param] |
@Info(param?: string) | info / info[param] |
@Args(param?: string) | args / args[param] |
这些参数具有以下含义:
root
:一个包含从父字段解析器返回的结果的对象,或者在顶级Query
字段的情况下,是从服务器配置传递的rootValue
。context
:在特定查询中所有解析器之间共享的对象;通常用于包含每个请求的状态。info
:一个包含有关查询的执行状态的信息的对象。args
:一个包含传递给查询中字段的参数的对象。
模块#
完成上述步骤后,我们已经以声明性方式指定了GraphQLModule
生成解析器映射所需的所有信息。GraphQLModule
使用反射来检查通过装饰器提供的元数据,并自动将类转换为正确的解析器映射。
您唯一需要处理的其他事情是提供(即在某个模块中列出为provider
)解析器类(AuthorsResolver
),并在某个地方导入该模块(AuthorsModule
),以便 Nest 能够利用它。
例如,我们可以在AuthorsModule
中执行此操作,该模块还可以提供此上下文中所需的其他服务。确保在某个地方导入了AuthorsModule
(例如,在根模块中,或者在根模块导入的某个其他模块中)。
@Module({
imports: [PostsModule],
providers: [AuthorsService, AuthorsResolver],
})
export class AuthorsModule {}
提示 将代码按照您的所谓领域模型进行组织是很有帮助的(类似于在 REST API 中组织入口点的方式)。在这种方法中,将您的模型(ObjectType
类)、解析器和服务放在表示领域模型的 Nest 模块中。在每个模块中保持所有这些组件都在一个单独的文件夹中。当您这样做,并使用Nest CLI来生成每个元素时,Nest 会自动为您将所有这些部分连接在一起(将文件定位在适当的文件夹中,生成provider
和imports
数组中的条目等)。