解析器提供将 GraphQL 操作(查询、变异或订阅)转换为数据的指令。它们返回我们在架构中指定的相同形状的数据 - 同步或作为解析为该形状结果的承诺。通常,您手动创建 解析器映射。另一方面,@nestjs/graphql
包使用您用于注释类的装饰器提供的元数据自动生成解析器映射。为了演示使用包功能创建 GraphQL API 的过程,我们将创建一个简单的作者 API。
代码优先
在代码优先方法中,我们不遵循通过手动编写 GraphQL SDL 来创建 GraphQL 架构的典型过程。相反,我们使用 TypeScript 装饰器从 TypeScript 类定义生成 SDL。@nestjs/graphql
包读取通过装饰器定义的元数据并自动为您生成架构。
对象类型
GraphQL 模式中的大多数定义都是对象类型。您定义的每个对象类型都应代表应用程序客户端可能需要与之交互的域对象。例如,我们的示例 API 需要能够获取作者及其帖子的列表,因此我们应该定义 Author
类型和 Post
类型来支持此功能。
如果我们使用模式优先方法,我们将使用 SDL 定义这样的模式,如下所示:
type Author {
id: Int!
firstName: String
lastName: String
posts: [Post!]!
}
在这种情况下,使用代码优先方法,我们使用 TypeScript 类定义模式并使用 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 的元数据反射系统有几个限制,例如,无法确定类由哪些属性组成,也无法识别给定属性是可选的还是必需的。由于这些限制,我们必须在架构定义类中明确使用 @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 类型(如这些章节中的各个示例所示)。
options 对象可以具有以下任何键/值对:
nullable
:用于指定字段是否可空(在 SDL 中,每个字段默认不可空);boolean
description
:用于设置字段描述;string
deprecationReason
:用于将字段标记为已弃用;string
例如:
@Field({ description: `Book title`, deprecationReason: 'Not used 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
对象类型。
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
}
代码优先解析器
此时,我们已经定义了可以存在于数据图中的对象(类型定义),但客户端还没有办法与这些对象交互。为了解决这个问题,我们需要创建一个解析器类。在代码优先方法中,解析器类既定义解析器函数又生成查询类型。通过下面的示例,这一点将变得清晰:
@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 将在运行时将它们组合起来。有关代码组织的更多信息,请参阅下面的 module 部分。
AuthorsService
和 PostsService
类中的逻辑可以根据需要简单或复杂。此示例的重点是展示如何构建解析器以及它们如何与其他提供程序交互。
在上面的示例中,我们创建了 AuthorsResolver
,它定义了一个查询解析器函数和一个字段解析器函数。要创建解析器,我们创建一个类,其中解析器函数作为方法,并使用 @Resolver()
装饰器注释该类。
在此示例中,我们定义了一个查询处理程序,以根据请求中发送的 id
获取作者对象。要指定该方法是查询处理程序,请使用 @Query()
装饰器。
传递给 @Resolver()
装饰器的参数是可选的,但当我们的图变得不平凡时就会发挥作用。它用于提供字段解析器函数在遍历对象图时使用的父对象。
在我们的示例中,由于该类包含一个 字段解析器 函数(用于 Author
对象类型的 posts
属性),我们 必须 为 @Resolver()
装饰器提供一个值,以指示哪个类是此类中定义的所有字段解析器的父类型(即相应的 ObjectType
类名)。从示例中可以清楚看出,在编写字段解析器函数时,需要访问父对象(正在解析的字段所属的对象)。在此示例中,我们使用字段解析器填充作者的帖子数组,该解析器调用以作者的id
为参数的服务。因此需要在@Resolver()
装饰器中标识父对象。请注意@Parent()
方法参数装饰器的相应用法,然后在字段解析器中提取对该父对象的引用。
我们可以定义多个@Query()
解析器函数(在此类中以及在任何其他解析器类中),它们将与解析器映射中的相应条目一起聚合到生成的 SDL 中的单个查询类型定义中。这允许您定义接近他们使用的模型和服务的查询,并将它们在模块中保持井然有序。
Nest CLI 提供了一个生成器(示意图),可以自动生成所有样板代码,以帮助我们避免执行所有这些操作,并使开发人员的体验更加简单。阅读有关此功能的更多信息此处(/recipes/crud-generator)。
查询类型名称
在上面的示例中,@Query()
装饰器根据方法名称生成 GraphQL 架构查询类型名称。例如,考虑上面示例中的以下构造:
@Query(returns => Author)
async author(@Args('id', { type: () => Int }) id: number) {
return this.authorsService.findOneById(id);
}
这会在我们的模式中为作者查询生成以下条目(查询类型使用与方法名称相同的名称):
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 操场中);一个string
deprecationReason
:设置查询元数据以将查询显示为已弃用(例如,在 GraphQL 操场中);一个string
nullable
:查询是否可以返回空数据响应;boolean
或'items'
或'itemsAndList'
(有关'items'
和'itemsAndList'
的详细信息,请参阅上文)
Args 装饰器选项
使用 @Args()
装饰器从请求中提取参数,以供方法处理程序使用。这与 REST 路由参数参数提取 的工作方式非常相似。
通常,您的 @Args()
装饰器会很简单,并且不需要对象参数,如上面的 getAuthor()
方法所示。例如,如果标识符的类型是字符串,则以下构造就足够了,只需从入站 GraphQL 请求中提取命名字段以用作方法参数即可。
@Args('id') id: string
在 getAuthor()
情况下,使用 number
类型,这带来了挑战。 TypeScript 类型number
没有给我们提供足够的关于预期 GraphQL 表示的信息(例如,Int
与Float
)。因此,我们必须明确传递类型引用。我们通过将第二个参数传递给包含参数选项的Args()
装饰器来实现这一点,如下所示:
@Query(returns => Author, { name: 'author' })
async getAuthor(@Args('id', { type: () => Int }) id: number) {
return this.authorsService.findOneById(id);
}
options 对象允许我们指定以下可选的键值对:
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 { ArgsType, Field } 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, Int, ObjectType } 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) {}
Schema 优先
如上一章所述,在 schema 优先方法中,我们首先在 SDL 中手动定义 schema 类型(阅读更多内容)。请考虑以下 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);
}
这将在我们的架构中为作者查询生成以下条目(查询类型使用与方法名称相同的名称):
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 提供了一个生成器(示意图),可自动生成所有样板代码,以帮助我们避免执行所有这些操作,并使开发人员的体验更加简单。阅读有关此功能的更多信息 此处。
生成类型
假设我们使用模式优先方法并启用了类型生成功能(使用 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>;
}
通过生成类(而不是生成接口的默认技术),您可以将声明性验证装饰器与模式优先方法结合使用,这是一种非常有用的技术(阅读更多)。例如,您可以将class-validator
装饰器添加到生成的CreatePostInput
类中,如下所示,以在title
字段上强制执行最小和最大字符串长度:
import { MaxLength, MinLength } from 'class-validator'
export class CreatePostInput {
@MinLength(3)
@MaxLength(50)
title: string
}
但是,如果您将装饰器直接添加到自动生成的文件中,则每次生成文件时它们都会被覆盖。相反,创建一个单独的文件并简单地扩展生成的类。
import { MaxLength, MinLength } 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
使用反射来自检通过装饰器提供的元数据,并自动将类转换为正确的解析器映射。
您唯一需要注意的其他事情是提供(即在某个模块中将其列为提供程序
)解析器类(AuthorsResolver
),并将模块(AuthorsModule
)导入某处,以便 Nest 能够使用它。
例如,我们可以在AuthorsModule
中执行此操作,它还可以提供此上下文所需的其他服务。确保在某处导入AuthorsModule
(例如,在根模块中,或由根模块导入的其他模块中)。
@Module({
imports: [PostsModule],
providers: [AuthorsService, AuthorsResolver],
})
export class AuthorsModule {}
通过所谓的域模型来组织代码会很有帮助(类似于在 REST API 中组织入口点的方式)。在这种方法中,将您的模型(ObjectType
类)、解析器和服务放在表示域模型的 Nest 模块中。将所有这些组件保存在每个模块的单个文件夹中。当您执行此操作并使用 Nest CLI 生成每个元素时,Nest 会自动将所有这些部分连接在一起(将文件定位在适当的文件夹中,在provider
和imports
数组中生成条目等)。