NestJS 9 GraphQL 中文文档(二) - 解析器

589 阅读17分钟

解析器为GraphQL操作(查询、突变、或订阅)转换成数据提供了说明。它们返回我们在schema中定义的相同类型的数据——无论是同步或作为解析该类型结果的promise。通常来说,你需要手动创建解析图。另一方面,@nestjs/graphql包可以使用元数据自动生成解析图,这些元数据是用于注释类的装饰器提供的。

为了演示使用包功能来创建GraphQL API的过程,我们将创建一个简单的作者 API。

代码优先

在代码优先方式中,我们不遵循通过手动编写GraphQL SDL创建我们的GraphQL schema的典型过程。取而代之的是,我们使用TypeScript装饰器从TypeScript类的定义来生成SDL。@nestjs/graphql 包读取通过装饰器定义的元数据并自动为你生成schema。

对象类型

GraphQL schema中的大多数定义都是对象类型。你定义的每一个对象都应该表示一个域对象,因为应用程序的客户端需要与之交互。举个例子,我们有一个简单的API,要请求一个包含他们文章的作者列表,所以我们应当定义这个Author类型和Post类型来支持这个功能。

如果我们使用模式优先方式,我们应当像下面这样定义一个schema:

type Author {
  id: Int!
  firstName: String
  lastName: String
  posts: [Post!]!
}

在本例中,使用代码优先方式,我们使用TypeScript类来定义schemas,并使用TypeScript装饰器来注释这些类的字段。 在代码优先方式中,和上述SDL对等的写法则是:

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类型和可选性的元数据,或者使用一个命令行插件为我们生成这些。

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类型系统之间可能存在歧义时,该类型函数是必需的。具体来说:stringboolean类型是非必需的;number(必须被映射为GraphQL中的IntFloat)是必需的。该类型函数应该简单的返回想要的GraphQL类型(如这些章节中的各种示例所示)。

选项对象可以有以下任何一种键/值对:

  • nullable:表明该字段是否可为空(在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]] 将代表一个整数矩阵。

要声明一个数组的项(不是数组本身)可以为空,需设置 nullable属性为 'item',如下所示:

@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对象类型在GraphQL schema中生成的SDL如下所示:

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在运行时会把它们都合并起来。关于代码组织可以查看模块一节。

注意
AuthorsServicePostsService类内部的逻辑根据需求可以简单也可以复杂。示例代码的重点是要展示如何构造解析器,以及它们是如何与其他提供者交互的。

在上述例子中,我们创建好了AuthorsReslover,其中定义了一个查询解析器函数和一个字段解析器函数。要创建解析器,我们需要创建一个类,把解析函数作为它的方法,并使用@Reslover() 装饰器来注释这个类。

在这个例子中,我们定义了一个查询处理器,基于请求中发送的id来获得作者对象。要指定该方法是一个查询处理器,使用@Query() 装饰器即可。

虽然传递给@Resolver() 装饰的参数是可选的,但是当我们的图变得复杂时它就会发挥作用。它用于提供字段解析器函数在向下遍历对象图时使用的父对象。

在我们的示例中,因为这个类包含了一个字段解析器函数(即Author对象类型中的posts属性),我们必须给@Reslover() 装饰器提供一个值,来指定哪个类是此类中定义的所有字段解析器的父类型(即对应的ObjectType类名)。从示例中应该清楚,在编写字段解析器时,有必要访问父对象(正在解析的字段是其成员的对象)。在此示例中,我们使用字段解析器填充作者的文章数组,该解析器调用以作者ID作为参数的服务。因此需要在@Reslover() 装饰器中识别父对象。请注意@Parent() 方法参数装饰器的相应使用,然后在字段解析器中提取对该父对象的引用。

我们可以定义多个@Query() 解析器函数(在此类和任何其他解析器类中),它们将与解析器映射中的相应条目一起聚合到生成的SDL中的单个查询类型定义中。这样你就可以在靠近模型和服务的地方定义它们所使用的查询,并使它们在模块中井井有条。

提示
Nest命令行工具提供了一个生成器(schematic),它可以自动生成所有模版代码来帮助我们避免手动做这些工作,让开发更简单。在此了解更多关于此功能的信息。

查询类型命名

在上述示例中,@Query() 装饰器生成了一个GraphQL schema 查询类型,它的命名是基于方法名的。例如,考虑上面示例中的这段构造:

@Query(returns => Author)
async author(@Args('id', { type: () => Int }) id: number) {
  return this.authorsService.findOneById(id);
}

这在我们的schema中为作者查询生成了以下的条目(该查询类型使用了和方法名相同的名字):

type Query {
  author(id: Int!): Author
}

提示 在此了解更多关于GraphQL查询的信息。

按照惯例,我们更喜欢将这些名字解耦;例如,我们更喜欢用像getAuthor() 这样的名字来命名我们的查询处理方法,但是仍然使用author为我们的查询类型命名。同样的做法也适用于字段解析器。我们可以通过传一个映射名字作为@Query()@ResloveField() 装饰器的参数,轻松做到这些,如下所示:

//authors/authors.resolver.ts

@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 schema如下:

type Query {
  author(id: Int!): Author
}

查询装饰器选项

@Query() 装饰器的选项对象(上面我们传 {name: 'author'} 的地方 )接受许多键/值对:

  • name:查询的名字;string
  • description:被用来生成GraphQL schema 文档的描述(例如:在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表现的足够信息(例如,IntFloat)。因此我们必须显式传递类型引用。我们将第二个参数传递给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:字符是否可以为空

查询处理器方法可以接收多个参数。假设我们要根据firstNamelastName获取作者。在这种情况下,我们可以调用@Args() 两次:

getAuthor(
  @Args('firstName', { nullable: true }) firstName?: string,
  @Args('lastName', { defaultValue: '' }) lastName?: string,
) {}

专用参数类

通过内连 @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装饰器来手动指定类型和可选性,或者使用命令行插件

最终在SDL中生成的部分GraphQL schema就是下面这段:

type Query {
  author(firstName: String, lastName: String = ''): Author
}

提示
注意像GetAuthorArgs这种参数类和ValidationPipe配合的很好(了解更多)。

类继承

你可以使用标准的TypeScript类继承,来创建具有可扩展的通用实用类型功能(字段和字段属性、验证等)的基类。例如,你可能有一组与分页相关的参数,它们总是包含标准offsetlimit字段,但还有其他类型特定的索引字段。你可以按下面的层次结构设置类。

@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中手动定义 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暴露了一个查询 - 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包中导出的。

注意
AuthorsServicePostsService类内部的逻辑,根据需求可以简单也可以复杂。这个例子的主要目的是展示如何构造解析器以及它们是如何与其他提供者交互的。

@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中它为作者查询生成了以下的条目(该查询类型使用了与方法名相同的名字)。

按照惯例,我们更愿意将这些解耦,使用诸如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命令行工具提供了一个生成器(schematic),可以自动生成所有模版代码,这会帮助我们省去这些工作,并使开发者的体验更简单。在此了解更多关于这个功能的信息。

生成类型

由于我们使用了模式优先方式并启用了输入生成功能(在上一章中展示的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>;
}

通过生成类(而不是默认技术中生成接口),你可以将声明式验证装饰器与模式优先方法结合使用,这是一个非常有用的技术(了解更多)。例如,你可以添加class-validator装饰器来生成如下所示的createPostInput类,在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参数的对比:

NestJSApollo
@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使用反射来检查由装饰器提供的元数据,并自动将类转换为正确的解析器映射。

你唯一需要注意的另外一件事就是提供(即:在某些模块中被列为提供者)解析器类(AuthorsResolver),并在某处导入这个模块(AuthorsModule),以便Nest能够使用它。

例如,我们可以在AuthorsModule中执行此操作,它还可以提供在此上下文中所需的其他服务。确保在某处(例如,在根模块,或根模块导入的其他模块)导入AuthorsModule

//authors/authors.module.ts

@Module({
  imports: [PostsModule],
  providers: [AuthorsService, AuthorsResolver],
})
export class AuthorsModule {}

提示
通过所谓的域模型(类似于你在REST API中组织入口的方式)组织代码是非常有用的。在这种方法中,将模型(ObjectType类)、解析器和服务都放在一个代表域模型的Nest模块中。将所有这些组件都保存在每个模块中的单个文件夹中。当你这样做并使用Nest命令行工具来生成每个元素时,Nest会自动为你将所有这些部分连接在一起(在适当的文件夹中定位文件,在providerimports数组中生车给你条目等)。