Skip to content

GraphQL

一、GraphQL vs REST

维度RESTGraphQL
数据获取固定结构,可能过多或过少客户端按需声明字段
端点数量多个(/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 graphql
typescript
// 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 dataloader
typescript
// 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 starthttp://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 显示 400autoSchemaFile 路径不存在确保目录存在;或改用 autoSchemaFile: true(内存模式)
class-validator 校验不生效未配置全局 ValidationPipeapp.useGlobalPipes(new ValidationPipe())
Subscription 不推送客户端用 HTTP 而非 WebSocket连接时指定 { subscriptionTransports: [SubscriptionTransport.WS] }

NestJS 深度学习体系