GraphQL

选讲

让客户端精准获取所需数据的查询语言

🎯 学习目标

学完本章,你将能够:

  • 理解 GraphQL 与 REST 的差异,能根据场景选择
  • 使用 Apollo Server 定义 Schema 和实现 Resolver
  • 实现 Query、Mutation 和 Subscription 三种操作
  • 使用 DataLoader 解决 N+1 查询问题
  • 在前端 React 组件中集成 GraphQL 客户端

你有没有想过

为什么移动端只要用户名,接口却返回了整个用户对象?为什么查用户和他的文章要发两次请求?

这是 REST API 的典型痛点:过度获取和获取不足。

GraphQL 就是为了解决这些问题而生的。它让客户端像点菜一样,要什么字段就写什么字段,一个请求拿完所有数据。

REST vs GraphQL

两种 API 设计理念的全面对比

特性RESTGraphQL
端点数量多个(每资源一个 URL)单个端点(/graphql)
数据获取固定结构客户端指定字段
关联数据需要多次请求一次请求嵌套获取
版本管理v1, v2, v3...无版本,持续演进
文档需单独维护Schema 即文档
缓存HTTP 缓存天然支持需额外策略

这个比喻只是为了帮你建立直观感受:REST 像去食堂打饭,菜品已经配好;GraphQL 像去自助餐,想吃什么自己选。实际上 GraphQL 底层是强类型查询引擎,比 REST 更复杂。

三种核心操作

查询、变更、订阅

Query(查询)读数据,Mutation(变更)写数据,Subscription(订阅)实时推送。

# 查询:获取用户和他的文章
query GetUserInfo {
  user(id: 1) {
    name
    email
    posts {
      title
      createdAt
    }
  }
}

# 变更:创建文章
mutation CreatePost {
  createPost(input: {
    title: "我的第一篇"
    content: "Hello GraphQL!"
  }) {
    id
    title
  }
}

Schema 定义语言

用 SDL 描述你的数据模型

Schema 是 GraphQL 的类型系统,定义了数据的形状和可执行的操作。

type User {
  id: ID!
  email: String!
  name: String!
  role: Role!
  posts(status: PostStatus): [Post!]!
  createdAt: DateTime!
}

enum Role {
  ADMIN
  EDITOR
  USER
}

type Query {
  user(id: ID!): User
  users(limit: Int = 20): [User!]!
}

type Mutation {
  createPost(input: CreatePostInput!): Post!
}

! 标记表示字段不可为 null。谨慎使用——一旦标记,该字段查询失败会导致整个父对象返回 null。

完整 Schema

一个真实项目的 schema.graphql 长什么样

# src/graphql/schema.graphql

scalar DateTime @scalar(class: "DateTime")

enum PostStatus {
  DRAFT
  PUBLISHED
  ARCHIVED
}

type Post {
  id: ID!
  title: String!
  content: String!
  status: PostStatus!
  author: User!
  createdAt: DateTime!
  updatedAt: DateTime!
}

type User {
  id: ID!
  name: String!
  email: String!
  posts(status: PostStatus): [Post!]!
}

input CreatePostInput {
  title: String!
  content: String!
  status: PostStatus = DRAFT
}

input UpdatePostInput {
  title: String
  content: String
  status: PostStatus
}

type Query {
  me: User
  user(id: ID!): User
  posts(first: Int = 20, after: String): PostConnection!
}

type Mutation {
  createPost(input: CreatePostInput!): Post!
  updatePost(id: ID!, input: UpdatePostInput!): Post!
  deletePost(id: ID!): Boolean!
}

Input 类型专用于 Mutation 参数,和 type 分开是因为输入和输出结构经常不同。DateTime 是自定义标量,需在服务端注册对应的解析器。

Part 1: Apollo Server

搭建类型安全的 GraphQL 服务端

从安装 Apollo Server 到编写 Query、Mutation、Subscription 的 Resolver。这部分涵盖了服务端开发的完整链路:定义 Schema、解析请求、启动服务器。

安装与 Context

搭建 GraphQL 服务的第一步

$ npm install @apollo/server graphql
$ npm install -D @types/node
// src/graphql/context.ts
import { Request, Response } from 'express';
import { PrismaClient } from '@prisma/client';

export interface GraphQLContext {
  prisma: PrismaClient;
  currentUser: { userId: number; role: string } | null;
  req: Request;
  res: Response;
}

export function createContext(prisma: PrismaClient) {
  return async ({ req, res }: { req: Request; res: Response }) => {
    const authHeader = req.headers.authorization;
    let currentUser = null;
    if (authHeader?.startsWith('Bearer ')) {
      const token = authHeader.split(' ')[1];
      currentUser = verifyAccessToken(token);
    }
    return { prisma, currentUser, req, res };
  };
}

Resolver 实现

每个字段都可以有自己的解析函数

// src/graphql/resolvers/user.resolver.ts
export const userResolvers = {
  Query: {
    me: async (_: any, __: any, ctx: GraphQLContext) => {
      if (!ctx.currentUser) {
        throw new GraphQLError('未认证', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }
      return ctx.prisma.user.findUnique({
        where: { id: ctx.currentUser.userId },
      });
    },
    users: async (_: any, { limit, offset }, ctx) => {
      return ctx.prisma.user.findMany({
        take: limit, skip: offset,
        select: { id: true, name: true, email: true,
                  password: false },
      });
    },
  },
  User: {
    posts: async (parent, { status }, ctx) => {
      return ctx.prisma.post.findMany({
        where: { authorId: parent.id, ...status },
      });
    },
  },
};

Mutation Resolver

增删改的 Resolver 怎么写

Mutation 负责写数据。关键是做好输入校验和权限检查——谁能改什么,必须在 Resolver 里卡住。

// src/graphql/resolvers/post.resolver.ts
export const postResolvers = {
  Mutation: {
    createPost: async (_, { input }, ctx) => {
      if (!ctx.currentUser) {
        throw new GraphQLError('请先登录', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }
      return ctx.prisma.post.create({
        data: {
          title: input.title,
          content: input.content,
          authorId: ctx.currentUser.userId,
        },
      });
    },
    updatePost: async (_, { id, input }, ctx) => {
      const post = await ctx.prisma.post.findUnique({ where: { id } });
      if (!post) throw new GraphQLError('文章不存在');
      if (post.authorId !== ctx.currentUser?.userId) {
        throw new GraphQLError('只能修改自己的文章', {
          extensions: { code: 'FORBIDDEN' },
        });
      }
      return ctx.prisma.post.update({
        where: { id }, data: input,
      });
    },
    deletePost: async (_, { id }, ctx) => {
      const post = await ctx.prisma.post.findUnique({ where: { id } });
      if (!post) throw new GraphQLError('文章不存在');
      if (post.authorId !== ctx.currentUser?.userId
          && ctx.currentUser?.role !== 'ADMIN') {
        throw new GraphQLError('没有删除权限', {
          extensions: { code: 'FORBIDDEN' },
        });
      }
      await ctx.prisma.post.delete({ where: { id } });
      return true;
    },
  },
};

启动服务器

将 Schema 和 Resolver 组装起来

// src/server.ts
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import express from 'express';
import cors from 'cors';

const typeDefs = fs.readFileSync(
  path.join(__dirname, './graphql/schema.graphql'), 'utf-8'
);

const resolvers = {
  Query: { ...userResolvers.Query, ...postResolvers.Query },
  Mutation: { ...postResolvers.Mutation },
  User: userResolvers.User,
  Post: postResolvers.Post,
};

const server = new ApolloServer({ typeDefs, resolvers });
await server.start();

app.use('/graphql', cors(), express.json(),
  expressMiddleware(server, {
    context: createContext(prisma),
  })
);
await httpServer.listen({ port: 3000 });

实时数据:Subscription

用 WebSocket 推送实时更新

Query 拉数据,Mutation 推一次,Subscription 持续推。聊天室、实时通知、协作编辑,都需要 Subscription。它底层走 WebSocket,服务端数据一变,客户端立刻收到。

Subscription 服务端

Schema 定义与 PubSub 事件发布

# Schema 新增
type Subscription {
  messageAdded(roomId: ID!): Message!
}

type Message {
  id: ID!
  content: String!
  sender: User!
  createdAt: DateTime!
}
// src/graphql/pubsub.ts
// 生产环境应替换为 Redis PubSub(如 graphql-redis-subscriptions)
import { PubSub } from 'graphql-subscriptions';
export const pubsub = new PubSub();

// Mutation 中发布事件
createMessage: async (_, { input }, ctx) => {
  const message = await ctx.prisma.message.create({
    data: { ...input, senderId: ctx.currentUser.userId },
    include: { sender: true },
  });
  pubsub.publish('MESSAGE_ADDED', {
    messageAdded: message,
  });
  return message;
},

// Subscription Resolver
Subscription: {
  messageAdded: {
    subscribe: (_, { roomId }) =>
      pubsub.asyncIterableIterator(`MESSAGE_ADDED_${roomId}`),
  },
}

Subscription 客户端

用 graphql-ws 连接实时数据流

客户端用 graphql-ws 库建立 WebSocket 连接。订阅一建立,服务端每次发布事件,客户端就收到回调。

// 前端:graphql-ws 客户端
import { createClient } from 'graphql-ws';

const wsClient = createClient({
  url: 'ws://localhost:3000/graphql',
  // token 从内存/状态管理读取,不要用 localStorage(防 XSS,见第 44 章)
  connectionParams: () => ({
    token: getAccessToken(),
  }),
});

// 订阅新消息
wsClient.subscribe(
  {
    query: `subscription OnMessage($roomId: ID!) {
      messageAdded(roomId: $roomId) {
        content sender { name } createdAt
      }
    }`,
    variables: { roomId: 'room-1' },
  },
  {
    next: (data) => {
      // 收到实时消息,更新 UI
      console.log('新消息:', data.messageAdded);
    },
    error: (err) => console.error('订阅错误:', err),
    complete: () => console.log('订阅结束'),
  }
);

服务端需安装 graphql-ws 并配置 WebSocketServer。Apollo Server 4 推荐用 graphql-ws 替代旧的 subscriptions-transport-ws。生产环境 PubSub 应换 Redis 方案。

排错:N+1 查询

症状:一个查询产生几十甚至上百条 SQL 语句,接口响应极慢。
原因:查询返回 N 个 Post,每个 Post 又查 author,产生 N+1 次数据库查询。
修复:使用 DataLoader 批量加载,或在 resolver 中用 include 预加载关联数据。
// 修复:在 resolver 中使用 include 预加载
posts: async (parent, _, ctx) => {
  return ctx.prisma.post.findMany({
    where: { authorId: parent.id },
    include: { author: true }, // 一次 JOIN 搞定
  });
}

DataLoader

用批量加载彻底消灭 N+1

include 只解决固定关联。当多个 Resolver 各自查询同一条数据时,需要 DataLoader 做 batching + caching。

$ npm install dataloader
// src/graphql/dataloaders.ts
import DataLoader from 'dataloader';
import { PrismaClient } from '@prisma/client';

// 批量加载用户:传入 [1, 2, 3],一次查回 [user1, user2, user3]
export function createUserLoader(prisma: PrismaClient) {
  return new DataLoader(async (ids: number[]) => {
    const users = await prisma.user.findMany({
      where: { id: { in: ids } },
    });
    const map = new Map(users.map(u => [u.id, u]));
    return ids.map(id => map.get(id) ?? null);
  });
}

// 挂载到 Context
export function createContext(prisma: PrismaClient) {
  return async ({ req, res }) => ({
    prisma,
    currentUser: null,
    loaders: {
      user: createUserLoader(prisma), // 每个请求独立实例
    },
    req, res,
  });
}

// Resolver 中使用
User: {
  author: async (post, _, { loaders }) => {
    return loaders.user.load(post.authorId); // 自动批量
  },
}

DataLoader 在单个请求内缓存。同一个请求中多次 load(1) 只查一次数据库。每个请求必须创建新的 DataLoader 实例。

游标分页

Relay 风格的连接分页

列表数据不能一次返回全部。游标分页用 edges + pageInfo 的结构,比 offset 分页更稳定(不怕数据变动)。

# Schema 定义
type Query {
  posts(first: Int!, after: String): PostConnection!
}

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type PostEdge {
  node: Post!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
}
// Resolver 实现
posts: async (_, { first, after }, ctx) => {
  const cursor = after
    ? { id: parseInt(atob(after)) } : undefined;
  const [posts, totalCount] = await Promise.all([
    ctx.prisma.post.findMany({
      take: first + 1,           // 多查一条判断 hasNext
      cursor, orderBy: { id: 'desc' },
    }),
    ctx.prisma.post.count(),
  ]);
  const hasNextPage = posts.length > first;
  const edges = posts.slice(0, first).map(post => ({
    node: post,
    cursor: btoa(String(post.id)), // Base64 编码游标
  }));
  return {
    edges,
    totalCount,
    pageInfo: {
      hasNextPage,
      endCursor: edges.at(-1)?.cursor ?? null,
    },
  };
}

Part 2: 生产实践

错误处理、认证授权、类型生成与前端集成

写完 Schema 和 Resolver 只是第一步。真正上线还需要标准化错误、控制权限、自动生成类型。这一部分把 GraphQL 服务从"能用"推向"好用"。

错误处理

让错误信息对客户端有用

GraphQL 默认会暴露内部错误细节。生产环境需要统一格式化错误,附加业务错误码。

// src/graphql/errors.ts
import { GraphQLError } from 'graphql';

export class AppError extends GraphQLError {
  constructor(message: string, code: string, statusCode = 400) {
    super(message, {
      extensions: { code, http: { status: statusCode } },
    });
  }
}

export const formatError = (err: GraphQLError) => {
  // 映射 Prisma 错误到业务错误
  if (err.message.includes('Unique constraint')) {
    return new AppError('数据已存在', 'DUPLICATE', 409);
  }
  // 生产环境隐藏内部错误
  if (process.env.NODE_ENV === 'production' && !err.extensions?.code) {
    return new AppError('服务器内部错误', 'INTERNAL_ERROR', 500);
  }
  return err;
};

在 Apollo Server 中通过 formatError 配置项统一处理。客户端根据 extensions.code 做对应 UI 提示。

认证与授权

谁在调用?有没有权限?

认证(Authentication)在 Context 中完成,解析 token 拿到用户。授权(Authorization)在 Resolver 中检查。

// 认证:Context 工厂(回顾)
export function createContext(prisma: PrismaClient) {
  return async ({ req }) => {
    const token = req.headers.authorization?.split(' ')[1];
    const currentUser = token
      ? verifyAccessToken(token) // JWT 验证
      : null;
    return { prisma, currentUser };
  };
}

// 授权:Resolver 中的守卫
const resolvers = {
  Mutation: {
    updatePost: async (_, { id, input }, ctx) => {
      if (!ctx.currentUser) {
        throw new AppError('请先登录', 'UNAUTHENTICATED', 401);
      }
      const post = await ctx.prisma.post.findUnique({ where: { id } });
      if (post.authorId !== ctx.currentUser.userId
          && ctx.currentUser.role !== 'ADMIN') {
        throw new AppError('没有权限', 'FORBIDDEN', 403);
      }
      return ctx.prisma.post.update({ where: { id }, data: input });
    },
  },
};

代码生成

Schema 改了,类型自动跟着变

$ npm install -D @graphql-codegen/cli
$ npm install -D @graphql-codegen/typescript
$ npm install -D @graphql-codegen/typescript-resolvers
$ npm install -D @graphql-codegen/typescript-operations
// codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  schema: './src/graphql/schema.graphql',
  documents: ['src/**/*.graphql'],
  generates: {
    'src/graphql/generated/resolvers-types.ts': {
      plugins: ['typescript', 'typescript-resolvers'],
    },
    'src/graphql/generated/operations.ts': {
      plugins: ['typescript', 'typescript-operations'],
    },
  },
};
export default config;

前端客户端选择

从轻量到重量,按需选择

客户端特点适用场景
原生 fetch零依赖小型项目
graphql-request轻量、支持 TS中小型项目
Apollo Client缓存、DevTools大型 SPA
urql轻量可插拔React 项目
// 轻量方案:graphql-request
import { GraphQLClient } from 'graphql-request';

const client = new GraphQLClient('http://localhost:3000/graphql', {
  headers: () => {
    // token 从内存/状态管理读取,不要用 localStorage(防 XSS,见第 44 章)
    const token = getAccessToken();
    return token ? { Authorization: `Bearer ${token}` } : {};
  },
});

const data = await client.request(query, variables);

React 中使用

自定义 Hook 封装 GraphQL 查询

// src/hooks/usePosts.ts
export function usePosts(options = {}) {
  const { first = 20, status } = options;
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  const fetchPosts = useCallback(async (after?: string) => {
    setLoading(true);
    const result = await query(GetPostsDocument, {
      first, after, status,
    });
    setData(result);
    setLoading(false);
  }, [first, status]);

  useEffect(() => { fetchPosts(); }, [fetchPosts]);

  return { posts: data?.posts.edges, loading, loadMore: fetchPosts };
}

排错:查询安全

症状:服务端 CPU 飙升,数据库连接池耗尽。
原因:客户端构造了深度嵌套的恶意查询,或一次性请求海量数据。
修复:配置查询深度限制、复杂度限制和查询持久化。
// 使用 depth-limiting 防止恶意嵌套
import depthLimit from 'graphql-depth-limit';

const server = new ApolloServer({
  typeDefs, resolvers,
  validationRules: [depthLimit(5)], // 最大嵌套深度5层
});

知识回顾

GraphQL 全栈开发核心知识点

Schema 设计

SDL 定义类型,Input 类型做校验,自定义标量扩展数据类型。

Resolver 编写

Query 读数据,Mutation 写数据,Subscription 推数据。每个字段一个函数。

性能优化

DataLoader 批量加载解决 N+1,游标分页控制数据量。

生产就绪

错误码标准化,认证放进 Context,深度限制防恶意查询。

试一试

练习一

任务:编写一个 GraphQL Schema,包含 Book 类型(id, title, author, price)和查询 books。

# 提示:参考 User 类型的写法
# 你的 Schema:

参考答案

type Book {
  id: ID!
  title: String!
  author: String!
  price: Float!
}

type Query {
  books: [Book!]!
  book(id: ID!): Book
}

练习二

任务:为上面的 Schema 编写一个 Resolver,用内存数组模拟数据源。

// 提示:用 const books = [...] 存数据
// 你的 Resolver:

参考答案

const books = [
  { id: '1', title: 'Node入门', author: '张三', price: 59.0 },
  { id: '2', title: 'GraphQL实战', author: '李四', price: 79.0 },
];

export const resolvers = {
  Query: {
    books: () => books,
    book: (_, { id }) => books.find(b => b.id === id),
  },
};

📝 要点总结

本章核心知识点

Schema 优先

SDL 定义 API 契约,前后端基于 Schema 并行开发

Resolver

每个字段对应一个 Resolver,灵活控制数据获取逻辑

DataLoader

批处理 + 缓存,彻底解决 GraphQL 的 N+1 查询问题

Subscription

基于 WebSocket 的实时订阅,适合通知和实时数据推送

下一章

GraphQL 让 API 变得灵活而精确。下一章我们看看现代全栈框架——Next.js、Nuxt、Remix 如何融合前后端。

1 / X