GraphQL
一、GraphQL vs REST
| 维度 | REST | GraphQL |
|---|---|---|
| 数据获取 | 固定结构,可能过多或过少 | 客户端按需声明字段 |
| 端点数量 | 多个(/users, /posts...) | 单一端点(/graphql) |
| 类型系统 | 无(靠文档约定) | Schema 是强类型契约 |
| 实时数据 | 需要额外 WebSocket | 内置 Subscription |
| N+1 问题 | 需手动优化 | DataLoader 自动批处理 |
| 适合场景 | 简单 CRUD、公开 API | 复杂数据图、BFF、移动端 |
二、安装配置(Code First)
NestJS 支持两种方式:Code First(TypeScript 类生成 Schema,推荐)和 Schema First(先写 SDL)。
bash
npm install @nestjs/graphql @nestjs/apollo @apollo/server graphqltypescript
// app.module.ts
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { join } from 'path';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'), // 自动生成 schema 文件
sortSchema: true, // schema 字段按字母排序(便于 git diff)
playground: process.env.NODE_ENV !== 'production',
context: ({ req }) => ({ req }), // 将 HTTP 请求传入 context(认证用)
subscriptions: {
'graphql-ws': true, // 启用 WebSocket 订阅
},
}),
],
})
export class AppModule {}三、ObjectType(数据模型)
typescript
// posts/models/post.model.ts
import { ObjectType, Field, Int, ID } from '@nestjs/graphql';
@ObjectType()
export class Post {
@Field(() => ID)
id: number;
@Field()
title: string;
@Field({ nullable: true })
content?: string;
@Field(() => Boolean)
published: boolean;
@Field(() => Int)
authorId: number;
@Field(() => User) // 嵌套对象类型
author: User;
@Field(() => [Tag]) // 数组类型
tags: Tag[];
@Field(() => Int, { nullable: true })
commentCount?: number;
@Field()
createdAt: Date;
}
@ObjectType()
export class User {
@Field(() => ID)
id: number;
@Field()
email: string;
@Field()
name: string;
// 敏感字段不加 @Field,不暴露给客户端
password: string;
}
// 分页响应
@ObjectType()
export class PostConnection {
@Field(() => [Post])
items: Post[];
@Field(() => Int)
total: number;
@Field(() => Int)
page: number;
@Field(() => Int)
totalPages: number;
}四、InputType(输入参数)
typescript
// posts/dto/create-post.input.ts
import { InputType, Field, Int } from '@nestjs/graphql';
import { IsString, MinLength, IsOptional, IsBoolean, IsArray } from 'class-validator';
@InputType()
export class CreatePostInput {
@Field()
@IsString()
@MinLength(1)
title: string;
@Field()
@IsString()
content: string;
@Field(() => Boolean, { defaultValue: false })
@IsBoolean()
@IsOptional()
published?: boolean;
@Field(() => [Int], { nullable: true })
@IsOptional()
@IsArray()
tagIds?: number[];
}
@InputType()
export class UpdatePostInput {
@Field({ nullable: true })
@IsOptional()
@IsString()
title?: string;
@Field({ nullable: true })
@IsOptional()
content?: string;
@Field(() => Boolean, { nullable: true })
@IsOptional()
published?: boolean;
}
@InputType()
export class PostsFilterInput {
@Field({ nullable: true })
keyword?: string;
@Field(() => Boolean, { nullable: true })
published?: boolean;
@Field(() => Int, { defaultValue: 1 })
page: number = 1;
@Field(() => Int, { defaultValue: 10 })
limit: number = 10;
}五、Resolver(查询与变更)
Resolver 是 GraphQL 的 Controller:
typescript
// posts/posts.resolver.ts
import { Resolver, Query, Mutation, Args, Int, Context } from '@nestjs/graphql';
@Resolver(() => Post)
export class PostsResolver {
constructor(private postsService: PostsService) {}
// ── Query(查询)──────────────────────────────
@Query(() => PostConnection, { name: 'posts' })
findAll(@Args('filter', { nullable: true }) filter?: PostsFilterInput) {
return this.postsService.findAll(filter);
}
@Query(() => Post, { name: 'post', nullable: true })
findOne(@Args('id', { type: () => Int }) id: number) {
return this.postsService.findOne(id);
}
// ── Mutation(变更)───────────────────────────
@Mutation(() => Post)
@UseGuards(GqlAuthGuard)
createPost(
@Args('input') input: CreatePostInput,
@Context() ctx: { req: Request & { user: User } },
) {
return this.postsService.create(input, ctx.req.user.id);
}
@Mutation(() => Post)
@UseGuards(GqlAuthGuard)
updatePost(
@Args('id', { type: () => Int }) id: number,
@Args('input') input: UpdatePostInput,
@Context() ctx: any,
) {
return this.postsService.update(id, input, ctx.req.user.id);
}
@Mutation(() => Boolean)
@UseGuards(GqlAuthGuard)
async deletePost(
@Args('id', { type: () => Int }) id: number,
) {
await this.postsService.remove(id);
return true;
}
// ── Field Resolver(关联数据)─────────────────
// 当 Query 返回 Post 时,自动解析 author 字段
@ResolveField(() => User)
author(@Parent() post: Post) {
return this.usersService.findOne(post.authorId);
}
@ResolveField(() => [Tag])
tags(@Parent() post: Post) {
return this.tagsService.findByPost(post.id);
}
@ResolveField(() => Int)
async commentCount(@Parent() post: Post) {
return this.commentsService.countByPost(post.id);
}
}六、GraphQL 认证守卫
typescript
// auth/guards/gql-auth.guard.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { GqlExecutionContext } from '@nestjs/graphql';
@Injectable()
export class GqlAuthGuard extends AuthGuard('jwt') {
// GraphQL 的请求对象在 context 里,需要重写 getRequest
getRequest(context: ExecutionContext) {
const ctx = GqlExecutionContext.create(context);
return ctx.getContext().req;
}
}
// 自定义 @CurrentUser() 装饰器(GraphQL 版)
export const CurrentUser = createParamDecorator(
(_data: unknown, context: ExecutionContext) => {
const ctx = GqlExecutionContext.create(context);
return ctx.getContext().req.user;
},
);
// 使用
@Mutation(() => Post)
@UseGuards(GqlAuthGuard)
createPost(
@Args('input') input: CreatePostInput,
@CurrentUser() user: User, // ← GraphQL 版 @CurrentUser()
) {
return this.postsService.create(input, user.id);
}七、DataLoader(解决 N+1 问题)
每个 Field Resolver 单独查数据库会导致 N+1。DataLoader 自动批处理:
bash
npm install dataloadertypescript
// users/users.loader.ts
import DataLoader from 'dataloader';
@Injectable({ scope: Scope.REQUEST }) // 每个请求一个实例(避免跨请求缓存)
export class UsersLoader {
constructor(private usersService: UsersService) {}
readonly batchLoad = new DataLoader<number, User>(
async (ids: readonly number[]) => {
// 一次查询所有 ID 对应的用户
const users = await this.usersService.findByIds([...ids]);
const userMap = new Map(users.map(u => [u.id, u]));
// 返回顺序必须与输入 ids 一致
return ids.map(id => userMap.get(id) ?? null);
},
{ cache: true }, // 同一请求内相同 ID 只查一次
);
}
// posts/posts.resolver.ts
@Resolver(() => Post)
export class PostsResolver {
constructor(private usersLoader: UsersLoader) {}
// 无论有多少篇文章,author 查询只会合并成一次 SQL
@ResolveField(() => User)
author(@Parent() post: Post) {
return this.usersLoader.batchLoad.load(post.authorId);
}
}八、Subscription(实时推送)
typescript
// posts/posts.resolver.ts
import { Subscription, PubSub } from '@nestjs/graphql';
const pubSub = new PubSub(); // 生产环境用 graphql-redis-subscriptions
@Resolver(() => Post)
export class PostsResolver {
@Mutation(() => Post)
async createPost(@Args('input') input: CreatePostInput) {
const post = await this.postsService.create(input);
// 发布事件,触发订阅
await pubSub.publish('postCreated', { postCreated: post });
return post;
}
// 客户端订阅新文章
@Subscription(() => Post, {
filter: (payload, variables) =>
// 过滤:只推送特定作者的文章
!variables.authorId || payload.postCreated.authorId === variables.authorId,
})
postCreated(@Args('authorId', { nullable: true, type: () => Int }) _authorId?: number) {
return pubSub.asyncIterator('postCreated');
}
}graphql
# 客户端订阅语法
subscription {
postCreated(authorId: 1) {
id
title
author {
name
}
}
}九、完整 Schema 示例
自动生成的 schema.gql:
graphql
type Post {
id: ID!
title: String!
content: String
published: Boolean!
author: User!
tags: [Tag!]!
commentCount: Int
createdAt: DateTime!
}
type PostConnection {
items: [Post!]!
total: Int!
page: Int!
totalPages: Int!
}
input CreatePostInput {
title: String!
content: String!
published: Boolean = false
tagIds: [Int!]
}
type Query {
post(id: Int!): Post
posts(filter: PostsFilterInput): PostConnection!
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: Int!, input: UpdatePostInput!): Post!
deletePost(id: Int!): Boolean!
}
type Subscription {
postCreated(authorId: Int): Post!
}十、错误处理
typescript
// GraphQL 使用 UserInputError 而非 BadRequestException
import { UserInputError, ForbiddenError } from '@nestjs/apollo';
@Mutation(() => Post)
async createPost(@Args('input') input: CreatePostInput) {
if (!input.title.trim()) {
throw new UserInputError('标题不能为空', {
invalidArgs: { title: input.title },
});
}
return this.postsService.create(input);
}
// 自定义错误格式(统一响应结构)
GraphQLModule.forRoot({
formatError: (error) => ({
message: error.message,
code: error.extensions?.code,
// 生产环境不暴露堆栈
...(process.env.NODE_ENV !== 'production' && {
stack: error.extensions?.stacktrace,
}),
}),
})可运行 Demo:
practice/07-extensions— Code First GraphQL Demo,npm start→ http://localhost:3000/graphql
常见错误
| 错误 | 原因 | 解决 |
|---|---|---|
Cannot determine GraphQL output type for "xxx" | @Field(() => Type) 缺失或循环依赖 | 所有 @Field 必须显式声明类型;循环引用用 () => [Type] 懒加载 |
Schema must contain uniquely named types | 同名 ObjectType 注册了两次 | 检查是否有重名的 @ObjectType() 类,合并或重命名 |
| N+1 查询 | @ResolveField 每次单独查 DB | 使用 DataLoader 批处理,scope: Scope.REQUEST |
| Playground 显示 400 | autoSchemaFile 路径不存在 | 确保目录存在;或改用 autoSchemaFile: true(内存模式) |
class-validator 校验不生效 | 未配置全局 ValidationPipe | app.useGlobalPipes(new ValidationPipe()) |
| Subscription 不推送 | 客户端用 HTTP 而非 WebSocket | 连接时指定 { subscriptionTransports: [SubscriptionTransport.WS] } |