解析器(Resolvers)
解析器提供了将 GraphQL 操作(查询、变更或订阅)转换为数据的指令。它们返回与我们在 schema 中指定的形状相同的数据——可以是同步返回,也可以是一个解析为该形状结果的 promise。通常,你需要手动创建一个解析器映射(resolver map)。而 @nestjs/graphql 包则利用你用于注解类的装饰器所提供的元数据,自动生成解析器映射。为了演示如何使用该包的特性来创建 GraphQL API,我们将创建一个简单的作者 API。
代码优先(Code first)
在代码优先方式中,我们不遵循通过手写 GraphQL SDL 来创建 GraphQL schema 的传统流程。相反,我们使用 TypeScript 装饰器从 TypeScript 类定义生成 SDL。@nestjs/graphql 包读取通过装饰器定义的元数据,并自动为你生成 schema。
对象类型(Object types)
GraphQL schema 中的大部分定义都是对象类型。你定义的每个对象类型都应代表一个应用客户端可能需要交互的领域对象。例如,我们的示例 API 需要能够获取作者列表及其帖子,因此我们应该定义 Author 类型和 Post 类型来支持此功能。
如果我们使用 schema 优先方式,我们会用如下 SDL 来定义这样的 schema:
type Author {
id: Int!
firstName: String
lastName: String
posts: [Post!]!
}在这种情况下,使用代码优先方式,我们使用 TypeScript 类来定义 schema,并使用 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 的元数据反射系统有一些限制,例如无法确定一个类由哪些属性组成,或者无法识别给定属性是可选还是必需。由于这些限制,我们必须在 schema 定义类中显式使用 @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:用于指定字段是否可为空(在@nestjs/graphql中,每个字段默认不可为空);booleandescription:用于设置字段描述;stringdeprecationReason:用于将字段标记为已弃用;string
例如:
@Field({ description: `Book title`, deprecationReason: 'Not useful in v2 schema' })
title: string;提示
你还可以为整个对象类型添加描述或将其标记为已弃用:@ObjectType({ description: 'Author model' })。
当字段是数组时,我们必须在 Field() 装饰器的类型函数中手动指示数组类型,如下所示:
@Field(type => [Post])
posts: Post[];提示
使用数组方括号表示法([ ]),我们可以指示数组的深度。例如,使用 [[Int]] 表示一个整数矩阵。
要声明数组的元素(而非数组本身)可为空,请将 nullable 属性设置为 'items',如下所示:
@Field(type => [Post], { nullable: 'items' })
posts: Post[];提示
如果数组及其元素都可为空,请将 nullable 设置为 'itemsAndList'。
现在 Author 对象类型已创建完毕,让我们定义 Post 对象类型。
// posts/models/post.model.ts
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 schema 部分:
type Post {
id: Int!
title: String!
votes: Int
}代码优先解析器
至此,我们已经定义了可以存在于数据图中的对象(类型定义),但客户端还没有办法与这些对象交互。为了解决这个问题,我们需要创建一个解析器类。在代码优先方式中,解析器类既定义解析器函数,又生成 Query 类型。通过下面的示例,这一点将变得清晰:
// authors/authors.resolver.ts
@Resolver(() => Author)
export class AuthorsResolver {
constructor(
private authorsService: AuthorsService,
private postsService: PostsService,
) {}
@Query(() => 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 类名)。从示例中可以清楚地看出,在编写字段解析器函数时,需要访问父对象(即被解析字段所属的对象)。在这个示例中,我们使用一个字段解析器来填充作者的 posts 数组,该解析器调用一个以作者 id 为参数的服务。因此,需要在 @Resolver() 装饰器中标识父对象。注意相应地使用 @Parent() 方法参数装饰器在字段解析器中提取对父对象的引用。
我们可以定义多个 @Query() 解析器函数(在这个类中或在任何其他解析器类中),它们将被聚合到生成的 SDL 中的单个 Query 类型定义中,以及解析器映射中的相应条目。这允许你在模型和服务附近定义查询,并将它们在模块中良好地组织。
提示
Nest CLI 提供了一个生成器(schematic),可以自动生成所有的样板代码,帮助我们避免这些手动工作,使开发者体验更加简单。在这里阅读更多关于此功能的信息。
查询类型名称
在上面的示例中,@Query() 装饰器根据方法名生成 GraphQL schema 查询类型名称。例如,考虑上面示例中的以下构造:
@Query(() => Author)
async author(@Args('id', { type: () => Int }) id: number) {
return this.authorsService.findOneById(id);
}这将在我们的 schema 中生成以下 author 查询条目(查询类型使用与方法名相同的名称):
type Query {
author(id: Int!): Author
}提示
在这里了解更多关于 GraphQL 查询的信息。
按照惯例,我们倾向于解耦这些名称;例如,我们倾向于为查询处理方法使用 getAuthor() 这样的名称,但仍然使用 author 作为查询类型名称。这同样适用于我们的字段解析器。我们可以通过将映射名称作为参数传递给 @Query() 和 @ResolveField() 装饰器来轻松实现这一点,如下所示:
// authors/authors.resolver.ts
@Resolver(() => Author)
export class AuthorsResolver {
constructor(
private authorsService: AuthorsService,
private postsService: PostsService,
) {}
@Query(() => Author, { name: 'author' })
async getAuthor(@Args('id', { type: () => Int }) id: number) {
return this.authorsService.findOneById(id);
}
@ResolveField('posts', () => [Post])
async getPosts(@Parent() author: Author) {
const { id } = author;
return this.postsService.findAll({ authorId: id });
}
}上面的 getAuthor 处理方法将在 SDL 中生成以下 GraphQL schema 部分:
type Query {
author(id: Int!): Author
}Query 装饰器选项
@Query() 装饰器的选项对象(上面我们传递了 {name: 'author'})接受以下键/值对:
name:查询的名称;stringdescription:用于生成 GraphQL schema 文档的描述(例如在 GraphQL playground 中);stringdeprecationReason:设置查询元数据以将查询显示为已弃用(例如在 GraphQL playground 中);stringnullable:查询是否可以返回 null 数据响应;boolean或'items'或'itemsAndList'(有关'items'和'itemsAndList'的详细信息,请参见上文)
Args 装饰器选项
使用 @Args() 装饰器从请求中提取参数以在方法处理器中使用。这与 REST 路由参数参数提取的工作方式非常相似。
通常,你的 @Args() 装饰器很简单,不需要对象参数,就像上面的 getAuthor() 方法一样。例如,如果标识符的类型是 string,以下构造就足够了,它只是从入站 GraphQL 请求中提取命名字段以用作方法参数。
@Args('id') id: string在 getAuthor() 的情况下,使用了 number 类型,这带来了一个挑战。number TypeScript 类型没有提供关于预期 GraphQL 表示的足够信息(例如 Int 与 Float)。因此,我们必须显式地传递类型引用。我们通过向 Args() 装饰器传递第二个参数(包含参数选项)来实现,如下所示:
@Query(() => Author, { name: 'author' })
async getAuthor(@Args('id', { type: () => Int }) id: number) {
return this.authorsService.findOneById(id);
}选项对象允许我们指定以下可选的键值对:
type:返回 GraphQL 类型的函数defaultValue:默认值;anydescription:描述元数据;stringdeprecationReason:弃用字段并提供描述原因的元数据;stringnullable:字段是否可为空
查询处理方法可以接受多个参数。假设我们想根据 firstName 和 lastName 获取作者。在这种情况下,我们可以调用 @Args 两次:
getAuthor(
@Args('firstName', { nullable: true }) firstName?: string,
@Args('lastName', { defaultValue: '' }) lastName?: string,
) {}提示
对于 firstName(一个 GraphQL 可空字段),不必在该字段的类型中添加 null 或 undefined 这样的非值类型。但请注意,你需要在解析器中对这些可能的非值类型进行类型守卫,因为 GraphQL 可空字段允许这些类型传递到你的解析器中。
专用参数类
使用内联 @Args() 调用时,像上面示例那样的代码会变得臃肿。相反,你可以创建一个专用的 GetAuthorArgs 参数类,并在处理方法中按如下方式访问它:
@Args() args: GetAuthorArgs使用 @ArgsType() 创建 GetAuthorArgs 类,如下所示:
// authors/dto/get-author.args.ts
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 插件。此外,对于 firstName(一个 GraphQL 可空字段),不必在该字段的类型中添加 null 或 undefined 这样的非值类型。但请注意,你需要在解析器中对这些可能的非值类型进行类型守卫,因为 GraphQL 可空字段允许这些类型传递到你的解析器中。
这将在 SDL 中生成以下 GraphQL schema 部分:
type Query {
author(firstName: String, lastName: String = ''): Author
}提示
请注意,像 GetAuthorArgs 这样的参数类与 ValidationPipe 配合得很好(了解更多)。
类继承
你可以使用标准的 TypeScript 类继承来创建具有通用实用类型特性(字段和字段属性、验证等)的基类,这些基类可以被扩展。例如,你可能有一组与分页相关的参数,它们始终包含标准的 offset 和 limit 字段,但也包含特定类型的其他索引字段。你可以按如下方式设置类层次结构。
基础 @ArgsType() 类:
@ArgsType()
class PaginationArgs {
@Field(() => Int)
offset: number = 0;
@Field(() => 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(() => 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(() => [classRef], { name: `findAll${classRef.name}` })
async findAll(): Promise<T[]> {
return [];
}
}
return BaseResolverHost;
}请注意以下几点:
- 需要显式的返回类型(上面是
any);否则 TypeScript 会抱怨使用了私有类定义。建议:定义一个接口而不是使用any。 Type从@nestjs/common包导入isAbstract: true属性表示不应为此类生成 SDL(Schema Definition Language 语句)。注意,你也可以为其他类型设置此属性以抑制 SDL 生成。
以下是如何生成 BaseResolver 的具体子类:
@Resolver(() => 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(() => String)
cursor: string;
@Field(() => classRef)
node: T;
}
@ObjectType({ isAbstract: true })
abstract class PaginatedType implements IPaginatedType<T> {
@Field(() => [EdgeType], { nullable: true })
edges: EdgeType[];
@Field(() => [classRef], { nullable: true })
nodes: T[];
@Field(() => Int)
totalCount: number;
@Field()
hasNextPage: boolean;
}
return PaginatedType as Type<IPaginatedType<T>>;
}定义好上述基类后,我们现在可以轻松创建继承此行为的特化类型。例如:
@ObjectType()
class PaginatedAuthor extends Paginated(Author) {}Schema 优先(Schema first)
如上一章所述,在 schema 优先方式中,我们首先在 SDL 中手动定义 schema 类型(了解更多)。请看以下 SDL 类型定义。
提示
为了本章的方便,我们将所有 SDL 聚合在一个位置(例如一个 .graphql 文件,如下所示)。在实践中,你可能会发现以模块化方式组织代码更合适。例如,为每个领域实体创建单独的 SDL 文件(包含类型定义),以及相关的服务、解析器代码和 Nest 模块定义类,放在该实体的专用目录中,会很有帮助。Nest 将在运行时聚合所有单独的 schema 类型定义。
type Author {
id: Int!
firstName: String
lastName: String
posts: [Post]
}
type Post {
id: Int!
title: String!
votes: Int
}
type Query {
author(id: Int!): Author
}Schema 优先解析器
上面的 schema 暴露了一个查询——author(id: Int!): Author。
提示
在这里了解更多关于 GraphQL 查询的信息。
现在让我们创建一个 AuthorsResolver 类来解析作者查询:
// authors/authors.resolver.ts
@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 schema 类型关联。例如,考虑上面示例中的以下构造:
@Query()
async author(@Args('id') id: number) {
return this.authorsService.findOneById(id);
}这将在我们的 schema 中生成以下 author 查询条目(查询类型使用与方法名相同的名称):
type Query {
author(id: Int!): Author
}按照惯例,我们倾向于解耦这些名称,使用 getAuthor() 或 getPosts() 这样的名称作为解析器方法。我们可以通过将映射名称作为参数传递给装饰器来轻松实现,如下所示:
// authors/authors.resolver.ts
@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),可以自动生成所有的样板代码,帮助我们避免这些手动工作,使开发者体验更加简单。在这里阅读更多关于此功能的信息。
生成类型
假设我们使用 schema 优先方式,并启用了类型生成功能(在上一章中使用 outputAs: 'class' 所示),一旦你运行应用程序,它将生成以下文件(在你在 GraphQLModule.forRoot() 方法中指定的位置)。例如,在 src/graphql.ts 中:
// 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>;
}通过生成类(而不是默认的生成接口技术),你可以将声明式验证装饰器与 schema 优先方式结合使用,这是一种极其有用的技术(了解更多)。例如,你可以向生成的 CreatePostInput 类添加 class-validator 装饰器,如下所示,以对 title 字段强制执行最小和最大字符串长度:
import { MinLength, MaxLength } from 'class-validator';
export class CreatePostInput {
@MinLength(3)
@MaxLength(50)
title: string;
}但是,如果你直接向自动生成的文件添加装饰器,每次重新生成文件时它们都会被覆盖。相反,创建一个单独的文件并简单地扩展生成的类。
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() 和 @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。
// authors/authors.module.ts
@Module({
imports: [PostsModule],
providers: [AuthorsService, AuthorsResolver],
})
export class AuthorsModule {}提示
按照所谓的领域模型来组织你的代码是有帮助的(类似于在 REST API 中组织入口点的方式)。在这种方法中,将你的模型(ObjectType 类)、解析器和服务保持在一个代表领域模型的 Nest 模块中。将所有这些组件放在每个模块的单个文件夹中。当你这样做并使用 Nest CLI 来生成每个元素时,Nest 会自动将所有这些部分连接在一起(将文件放在适当的文件夹中,在 provider 和 imports 数组中生成条目等)。