DevToolBox免费
博客

GraphQL完整指南:Schema、Apollo、DataLoader和性能优化

13 分钟阅读作者 DevToolBox
TL;DRGraphQL 是一种查询语言和运行时,让客户端可以从单一端点精确请求所需数据。使用 Apollo Server 构建后端(解析器、DataLoader 解决 N+1 问题),使用 Apollo Client 进行 React 开发(useQuery、useMutation、useSubscription),游标分页实现可扩展性,上下文中的 JWT 实现认证,Redis 缓存和持久化查询实现性能优化。
Key Takeaways
  • GraphQL 使用单一端点和 SDL schema 让客户端完全控制响应结构,解决过度/不足获取问题。
  • Apollo Server 解析器接收 (parent, args, context, info)——将认证用户、DataLoader 和数据源放入 context。
  • DataLoader 对生产级 GraphQL 是必须的:它将 N+1 解析器调用批处理为每个请求 tick 的单次数据库查询。
  • Apollo Client 的 InMemoryCache 通过 __typename + id 规范化,当变更返回已知对象时自动更新所有组件。
  • 使用游标分页(Relay 规范)实现稳定、可扩展的分页,能处理并发数据变更。
  • JWT 认证在 HTTP 层处理;context 函数在解析器运行前解码令牌。
  • 持久化查询 + GET 请求为 GraphQL 启用 CDN 缓存;Redis 处理服务端解析器缓存。
  • GraphQL 错误始终返回 HTTP 200——检查 errors 数组,而不是状态码。

什么是 GraphQL?

GraphQL 是一种 API 查询语言和执行查询的服务端运行时,使用你为数据定义的类型系统。由 Facebook 于 2012 年开发、2015 年开源,它解决了 REST 的根本问题:过度获取太多数据、获取不足(需要多次往返),以及无法适应不同客户端需求的固定响应结构。

其核心是 schema 优先。你用 Schema 定义语言(SDL)定义整个数据模型——类型、查询、变更和订阅——这个 schema 既是文档也是前后端之间的契约。每个 GraphQL API 都暴露单一端点(通常是 /graphql),接受 POST 请求形式的查询、变更或订阅。

关键理念是客户端驱动响应结构。GraphQL 客户端发送描述所需精确字段的查询,服务器只解析这些字段并返回对应结构——不多不少。

Single Endpoint vs Multiple REST Endpoints

# REST: multiple endpoints, fixed shapes
GET /api/users/42           # Returns ALL user fields (over-fetching)
GET /api/users/42/posts     # Separate round trip (under-fetching)
GET /api/users/42/followers # Yet another round trip

# GraphQL: one endpoint, client-defined shape
POST /graphql
{
  "query": "query { user(id: \"42\") { name avatar posts(limit: 3) { title } followersCount } }"
}
# Returns exactly: name, avatar, 3 post titles, follower count -- nothing else

Schema 定义语言(SDL)

Schema 定义语言是 GraphQL 的类型系统语法。每个 GraphQL API 都从定义所有可用类型、查询、变更和订阅的 schema 开始。schema 是 API 能力的唯一真实来源。

SDL 支持标量类型(Int、Float、String、Boolean、ID)、对象类型、枚举类型、联合类型、接口类型和输入类型。非空字段用 ! 标记,列表使用 [] 表示法。深入理解 SDL 是所有 GraphQL 工作的基础。

Complete Schema Example with All SDL Features

# Custom scalars
scalar DateTime
scalar JSON

# Enum types
enum Role { ADMIN  EDITOR  VIEWER }
enum PostStatus { DRAFT  PUBLISHED  ARCHIVED }

# Interface
interface Node { id: ID! }

# Object types
type User implements Node {
  id: ID!
  name: String!              # Non-null: field always present
  email: String!
  avatar: String             # Nullable: may be null
  role: Role!
  posts(limit: Int = 10, offset: Int = 0): [Post!]!
  followersCount: Int!
  createdAt: DateTime!
}

type Post implements Node {
  id: ID!
  title: String!
  slug: String!
  body: String!
  status: PostStatus!
  author: User!              # Resolved field -- triggers resolver
  tags: [String!]!           # Non-null list of non-null strings
  comments: [Comment!]!
  publishedAt: DateTime      # Nullable DateTime
  updatedAt: DateTime!
}

type Comment implements Node {
  id: ID!
  body: String!
  author: User!
  post: Post!
  createdAt: DateTime!
}

# Input types (mutation arguments only)
input CreatePostInput {
  title: String!
  body: String!
  tags: [String!]
  status: PostStatus = DRAFT   # Default value
}

input UpdatePostInput {
  title: String    # All optional for partial update
  body: String
  tags: [String!]
  status: PostStatus
}

input PostFilter {
  status: PostStatus
  authorId: ID
  tag: String
  search: String
}

# Root operation types
type Query {
  user(id: ID!): User
  users(limit: Int = 20, offset: Int = 0): [User!]!
  post(id: ID, slug: String): Post
  posts(filter: PostFilter, limit: Int = 20, offset: Int = 0): [Post!]!
  postsConnection(filter: PostFilter, first: Int, after: String): PostConnection!
  me: User
}

type Mutation {
  createPost(input: CreatePostInput!): Post!
  updatePost(id: ID!, input: UpdatePostInput!): Post!
  deletePost(id: ID!): Boolean!
  publishPost(id: ID!): Post!
  addComment(postId: ID!, body: String!): Comment!
}

type Subscription {
  commentAdded(postId: ID!): Comment!
  postPublished: Post!
}

# Relay-style cursor pagination types
type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}
type PostEdge {
  node: Post!
  cursor: String!
}
type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

Apollo Server 设置与解析器

Apollo Server 是 Node.js 最流行的 GraphQL 服务器。它处理 HTTP 传输、查询解析和 schema 验证、解析器执行、错误格式化和自省。Apollo Server 4 可以独立使用,也可以作为 Express、Fastify 或任何 Node HTTP 框架的中间件。

解析器是为 schema 中每个字段返回数据的函数。它们接收四个参数:parent(父字段的解析值)、args(查询中字段的参数)、context(共享请求上下文——认证用户、数据源、加载器)和 info(字段路径、schema、指令)。N+1 问题——获取列表后逐个获取关联记录导致 N+1 个查询——通过 DataLoader 批处理解决。

Apollo Server 4 with Express, Resolvers & DataLoader

// server.ts
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import express from 'express';
import http from 'http';
import DataLoader from 'dataloader';
import { readFileSync } from 'fs';

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

// DataLoader factory -- create fresh loaders per request
function createLoaders(db: Database) {
  return {
    // Batches N individual user.load(id) calls into:
    // SELECT * FROM users WHERE id IN ($1, $2, ...$N)
    userById: new DataLoader<string, User>(async (ids) => {
      const users = await db.users.findByIds([...ids]);
      const map = new Map(users.map((u: User) => [u.id, u]));
      // Return in SAME ORDER as input ids (DataLoader requirement)
      return ids.map(id => map.get(id) ?? new Error('User not found: ' + id));
    }),
    commentsByPostId: new DataLoader<string, Comment[]>(async (postIds) => {
      const comments = await db.comments.findByPostIds([...postIds]);
      const grouped = new Map<string, Comment[]>();
      comments.forEach((c: Comment) => {
        if (!grouped.has(c.postId)) grouped.set(c.postId, []);
        grouped.get(c.postId)!.push(c);
      });
      return postIds.map(id => grouped.get(id) ?? []);
    }),
  };
}

const resolvers = {
  Query: {
    user: (_: unknown, { id }: { id: string }, { db }: Context) =>
      db.users.findById(id),
    posts: (_: unknown, { filter, limit, offset }: any, { db }: Context) =>
      db.posts.findMany({ filter, limit, offset }),
    postsConnection: async (_: unknown, { filter, first = 10, after }: any, { db }: Context) => {
      const afterId = after ? Buffer.from(after, 'base64').toString() : null;
      const rows = await db.posts.findMany({
        where: { ...filter, ...(afterId ? { id: { gt: afterId } } : {}) },
        take: first + 1,
        orderBy: { createdAt: 'desc' },
      });
      const hasNextPage = rows.length > first;
      const nodes = rows.slice(0, first);
      const edges = nodes.map((node: any) => ({
        node,
        cursor: Buffer.from(node.id).toString('base64'),
      }));
      return {
        edges,
        totalCount: await db.posts.count(filter),
        pageInfo: {
          hasNextPage,
          hasPreviousPage: !!afterId,
          startCursor: edges[0]?.cursor ?? null,
          endCursor: edges[edges.length - 1]?.cursor ?? null,
        },
      };
    },
    me: (_: unknown, __: unknown, { user }: Context) => user ?? null,
  },

  Mutation: {
    createPost: (_: unknown, { input }: any, { user, db }: Context) => {
      if (!user) throw new AuthenticationError('Must be logged in');
      return db.posts.create({ ...input, authorId: user.id });
    },
    updatePost: async (_: unknown, { id, input }: any, { user, db }: Context) => {
      const post = await db.posts.findById(id);
      if (!post) throw new NotFoundError('Post');
      if (post.authorId !== user?.id) throw new ForbiddenError('Not your post');
      return db.posts.update(id, input);
    },
    deletePost: async (_: unknown, { id }: any, { user, db }: Context) => {
      const post = await db.posts.findById(id);
      if (post.authorId !== user?.id) throw new ForbiddenError('Not your post');
      await db.posts.delete(id);
      return true;
    },
  },

  // Field resolvers -- use loaders to prevent N+1
  Post: {
    author: (post: any, _: unknown, { loaders }: Context) =>
      loaders.userById.load(post.authorId),
    comments: (post: any, _: unknown, { loaders }: Context) =>
      loaders.commentsByPostId.load(post.id),
  },
  User: {
    posts: (user: any, { limit, offset }: any, { db }: Context) =>
      db.posts.findByAuthorId(user.id, { limit, offset }),
    followersCount: (user: any, _: unknown, { db }: Context) =>
      db.follows.countByFolloweeId(user.id),
  },
  Subscription: {
    commentAdded: {
      subscribe: (_: unknown, { postId }: any, { pubsub }: Context) =>
        pubsub.asyncIterator('COMMENT_ADDED_' + postId),
    },
    postPublished: {
      subscribe: (_: unknown, __: unknown, { pubsub }: Context) =>
        pubsub.asyncIterator('POST_PUBLISHED'),
    },
  },
};

// Bootstrap
const app = express();
const httpServer = http.createServer(app);
const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],
  introspection: process.env.NODE_ENV !== 'production',
});
await server.start();
app.use('/graphql', express.json(), expressMiddleware(server, {
  context: async ({ req }) => {
    const token = req.headers.authorization?.replace('Bearer ', '');
    const user = token ? await verifyJWT(token) : null;
    return { user, db, loaders: createLoaders(db), pubsub, redis };
  },
}));
await new Promise<void>(r => httpServer.listen({ port: 4000 }, r));
console.log('GraphQL server ready at http://localhost:4000/graphql');

Apollo Client 与 React Hooks

Apollo Client 是一个全面的 JavaScript 状态管理库,让你能用 GraphQL 管理本地和远程数据。对于 React,它提供基于 hooks 的 API:useQuery 用于获取数据,useMutation 用于发送变更,useSubscription 用于实时更新。Apollo Client 还包含规范化内存缓存(InMemoryCache),自动去重并在组件间更新相关数据。

InMemoryCache 通过 __typename + id 规范化对象。当变更返回与缓存对象相同 ID 的更新对象时,显示该数据的每个组件都会自动重新渲染——无需手动状态同步。缓存策略(cache-first、cache-and-network、network-only、no-cache)控制 Apollo 何时读取缓存还是请求网络。

Apollo Client Setup with Auth, Error Handling & WebSocket

// lib/apollo-client.ts
import { ApolloClient, InMemoryCache, createHttpLink, from, split } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { onError } from '@apollo/client/link/error';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
import { getMainDefinition } from '@apollo/client/utilities';

const httpLink = createHttpLink({
  uri: process.env.NEXT_PUBLIC_GRAPHQL_URL ?? '/graphql',
});

// Attach JWT from localStorage to every HTTP request
const authLink = setContext((_, { headers }) => {
  const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
  return {
    headers: { ...headers, ...(token ? { authorization: 'Bearer ' + token } : {}) },
  };
});

// Centralized error handling
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
  if (graphQLErrors) {
    for (const err of graphQLErrors) {
      if (err.extensions?.code === 'UNAUTHENTICATED') {
        // Could refresh token here and retry with forward(operation)
        window.dispatchEvent(new CustomEvent('apollo:unauthenticated'));
      }
    }
  }
  if (networkError) console.error('[Network error]', networkError);
});

// WebSocket link for subscriptions (browser only)
const wsLink = typeof window !== 'undefined'
  ? new GraphQLWsLink(createClient({
      url: (process.env.NEXT_PUBLIC_GRAPHQL_URL ?? '/graphql').replace(/^http/, 'ws'),
      connectionParams: () => ({ authToken: localStorage.getItem('token') }),
      retryAttempts: 5,
    }))
  : null;

// Route subscriptions -> WS, everything else -> HTTP
const splitLink = wsLink
  ? split(
      ({ query }) => {
        const def = getMainDefinition(query);
        return def.kind === 'OperationDefinition' && def.operation === 'subscription';
      },
      wsLink,
      from([errorLink, authLink, httpLink])
    )
  : from([errorLink, authLink, httpLink]);

export const apolloClient = new ApolloClient({
  link: splitLink,
  cache: new InMemoryCache({
    typePolicies: {
      Post: { keyFields: ['slug'] },  // Cache Post by slug, not id
      Query: {
        fields: {
          posts: {
            keyArgs: ['filter'],       // Separate cache per filter combo
            merge(existing = [], incoming, { args }) {
              const merged = [...existing];
              const offset = args?.offset ?? 0;
              incoming.forEach((item: any, i: number) => {
                merged[offset + i] = item;
              });
              return merged;
            },
          },
        },
      },
    },
  }),
  defaultOptions: {
    watchQuery: { fetchPolicy: 'cache-and-network', errorPolicy: 'all' },
    query: { fetchPolicy: 'cache-first', errorPolicy: 'all' },
  },
});

useQuery, useMutation, useSubscription in React

'use client';
import { useQuery, useMutation, useSubscription, gql } from '@apollo/client';

const GET_POSTS = gql`
  query GetPosts($filter: PostFilter, $limit: Int!, $offset: Int!) {
    posts(filter: $filter, limit: $limit, offset: $offset) {
      id slug title
      author { id name avatar }
      tags publishedAt
    }
  }
`;

const CREATE_POST = gql`
  mutation CreatePost($input: CreatePostInput!) {
    createPost(input: $input) { id slug title status author { id name } }
  }
`;

const POST_PUBLISHED = gql`
  subscription OnPostPublished {
    postPublished { id slug title author { name } }
  }
`;

export function PostFeed() {
  // --- useQuery: fetch with cache + loading states ---
  const { data, loading, error, fetchMore, refetch } = useQuery(GET_POSTS, {
    variables: { filter: { status: 'PUBLISHED' }, limit: 10, offset: 0 },
    notifyOnNetworkStatusChange: true,
    pollInterval: 0, // Set to ms > 0 for polling
  });

  // --- useMutation: with optimistic update + cache write ---
  const [createPost, { loading: creating, error: mutError }] = useMutation(CREATE_POST, {
    optimisticResponse: ({ input }) => ({
      createPost: {
        __typename: 'Post',
        id: 'temp-' + Date.now(),
        slug: input.title.toLowerCase().replace(/\s+/g, '-'),
        title: input.title,
        status: 'DRAFT',
        author: { __typename: 'User', id: 'current', name: 'You' },
      },
    }),
    update(cache, { data: { createPost: newPost } }) {
      cache.modify({
        fields: {
          posts(existingRefs = []) {
            const ref = cache.writeFragment({
              data: newPost,
              fragment: gql`fragment NewPost on Post { id slug title status author { id name } }`,
            });
            return [ref, ...existingRefs];
          },
        },
      });
    },
  });

  // --- useSubscription: receive real-time published posts ---
  useSubscription(POST_PUBLISHED, {
    onData: ({ client, data }) => {
      const published = data.data?.postPublished;
      if (published) {
        client.cache.modify({
          fields: {
            posts(existing = []) {
              const ref = client.cache.writeFragment({
                data: published,
                fragment: gql`fragment PubPost on Post { id slug title author { name } }`,
              });
              return [ref, ...existing];
            },
          },
        });
      }
    },
  });

  if (error) return <div>Error: {error.message}</div>;
  if (loading && !data) return <div>Loading posts...</div>;

  const { posts = [] } = data ?? {};

  return (
    <div>
      <button
        disabled={creating}
        onClick={() => createPost({ variables: { input: { title: 'New Post', body: '...' } } })}
      >
        {creating ? 'Creating...' : 'New Post'}
      </button>
      {posts.map((post: any) => (
        <article key={post.slug}>
          <h2>{post.title}</h2>
          <small>by {post.author.name}</small>
        </article>
      ))}
      <button onClick={() => fetchMore({ variables: { offset: posts.length } })}>
        Load more
      </button>
    </div>
  );
}

认证与授权

GraphQL 的认证在 HTTP 层处理——通常通过带 JWT 或会话 cookie 的 Authorization 头——在任何解析器运行之前。Apollo Server 的 context 函数在每个请求上运行,解码令牌并将用户附加到 context,使其对所有解析器可用。

GraphQL 中的授权(谁能做什么)更加微妙,因为单一端点处理所有操作。常见模式包括解析器级守卫(解析前检查 context.user)、schema 指令(@auth、@hasRole)和中间件层。graphql-shield 库通过规则组合提供权限层。

JWT Context Function + graphql-shield Permission Rules

// auth/jwt.ts
import jwt from 'jsonwebtoken';

export async function verifyJWT(token: string): Promise<User | null> {
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string; role: string };
    return await db.users.findById(payload.userId);
  } catch {
    return null;
  }
}

// Context function (runs on every request before any resolver)
export async function createContext({ req }: { req: Request }) {
  const auth = req.headers.get('authorization') ?? '';
  const token = auth.startsWith('Bearer ') ? auth.slice(7) : null;
  const user = token ? await verifyJWT(token) : null;
  return { user, db, loaders: createLoaders(db), pubsub, redis };
}

// auth/permissions.ts -- graphql-shield
import { shield, rule, and, or } from 'graphql-shield';

const isAuthenticated = rule({ cache: 'contextual' })(
  async (_, __, ctx: Context) => ctx.user !== null || 'Must be logged in'
);

const isAdmin = rule({ cache: 'contextual' })(
  async (_, __, ctx: Context) => ctx.user?.role === 'ADMIN' || 'Admin only'
);

const isPostOwner = rule({ cache: 'strict' })(
  async (_, { id }, ctx: Context) => {
    const post = await ctx.db.posts.findById(id);
    return post?.authorId === ctx.user?.id || 'Not your post';
  }
);

export const permissions = shield(
  {
    Query:    { me: isAuthenticated, users: isAdmin },
    Mutation: {
      createPost: isAuthenticated,
      updatePost: and(isAuthenticated, or(isPostOwner, isAdmin)),
      deletePost: and(isAuthenticated, or(isPostOwner, isAdmin)),
    },
  },
  { fallbackError: 'Not authorized', allowExternalErrors: true }
);

// Schema directive alternative (@auth in SDL)
// directive @auth on FIELD_DEFINITION
// type Mutation {
//   createPost(input: CreatePostInput!): Post! @auth
// }
//
// Transformer using @graphql-tools/schema:
// mapSchema(schema, {
//   [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
//     if (!getDirective(schema, fieldConfig, 'auth')?.[0]) return fieldConfig;
//     const { resolve = defaultFieldResolver } = fieldConfig;
//     return { ...fieldConfig, resolve(src, args, ctx, info) {
//       if (!ctx.user) throw new AuthenticationError();
//       return resolve(src, args, ctx, info);
//     }};
//   },
// });

分页:游标分页与 Relay 规范

GraphQL 支持两种主要分页方式:基于偏移的(skip/take 或 page/limit)和基于游标的。偏移分页简单但在并发插入或删除时会出问题。游标分页使用不透明游标(通常是 base64 编码的 ID 或时间戳)指向数据集中的特定位置,在数据变更时保持稳定。

Relay 连接规范是业界标准的游标分页契约。它定义了包含 edges(边对象数组,每个包含 node 和 cursor)和 pageInfo(hasNextPage、hasPreviousPage、startCursor、endCursor)的 Connection 类型。包括 Apollo Client 在内的大多数 GraphQL 客户端原生支持 Relay 规范。

Relay Cursor Pagination — Server & Client

// Server: postsConnection resolver
async function postsConnection(
  _: unknown,
  { filter, first = 10, after }: { filter?: any; first?: number; after?: string },
  { db }: Context
) {
  // Decode the opaque cursor (base64 of the row ID)
  const afterId = after
    ? Buffer.from(after, 'base64').toString('utf-8')
    : null;

  // Fetch one extra row to determine if there is a next page
  const rows = await db.posts.findMany({
    where: { ...filter, ...(afterId ? { id: { gt: afterId } } : {}) },
    orderBy: { createdAt: 'desc' },
    take: first + 1,
  });

  const hasNextPage = rows.length > first;
  const nodes = rows.slice(0, first);
  const edges = nodes.map((node: any) => ({
    node,
    cursor: Buffer.from(String(node.id)).toString('base64'),
  }));

  return {
    edges,
    totalCount: await db.posts.count({ where: filter }),
    pageInfo: {
      hasNextPage,
      hasPreviousPage: !!afterId,
      startCursor: edges[0]?.cursor ?? null,
      endCursor: edges[edges.length - 1]?.cursor ?? null,
    },
  };
}

// Client: useQuery with fetchMore (infinite scroll)
const POSTS_CONNECTION = gql`
  query PostsConnection($filter: PostFilter, $first: Int!, $after: String) {
    postsConnection(filter: $filter, first: $first, after: $after) {
      edges { cursor  node { id slug title author { name } } }
      pageInfo { hasNextPage endCursor }
      totalCount
    }
  }
`;

function PostsPaginated() {
  const { data, fetchMore } = useQuery(POSTS_CONNECTION, {
    variables: { first: 10 },
  });

  const conn = data?.postsConnection;

  return (
    <div>
      {conn?.edges.map(({ node }: any) => (
        <article key={node.id}><h3>{node.title}</h3></article>
      ))}
      {conn?.pageInfo.hasNextPage && (
        <button
          onClick={() =>
            fetchMore({
              variables: { after: conn.pageInfo.endCursor },
              updateQuery(prev, { fetchMoreResult: next }) {
                if (!next) return prev;
                return {
                  postsConnection: {
                    ...next.postsConnection,
                    edges: [
                      ...prev.postsConnection.edges,
                      ...next.postsConnection.edges,
                    ],
                  },
                };
              },
            })
          }
        >
          Load more ({conn.totalCount - conn.edges.length} remaining)
        </button>
      )}
    </div>
  );
}

GraphQL 错误处理

GraphQL 有独特的错误模型:即使出现错误,每个响应都返回 HTTP 200。错误出现在顶级 errors 数组中,与任何部分数据并列。这与 REST 根本不同,REST 用 HTTP 状态码传达错误类别。errors 数组包含带有 message、locations、path(哪个字段失败)和 extensions(自定义错误代码)的对象。

最佳实践是始终为 GraphQL 错误返回 HTTP 200 并使用 errors 数组,只为传输层失败(格式错误的 JSON、缺少查询)返回非 200 状态。在 Apollo Server 中,抛出 ApolloError(或其子类:AuthenticationError、ForbiddenError、UserInputError、NotFoundError)会自动填充 extensions.code 字段供客户端以编程方式处理。

Custom Error Classes + Response Shape + Client Handling

// errors.ts -- Custom error types with error codes
import { GraphQLError } from 'graphql';

export class AuthenticationError extends GraphQLError {
  constructor(msg = 'Authentication required') {
    super(msg, { extensions: { code: 'UNAUTHENTICATED', http: { status: 401 } } });
  }
}
export class ForbiddenError extends GraphQLError {
  constructor(msg = 'Forbidden') {
    super(msg, { extensions: { code: 'FORBIDDEN', http: { status: 403 } } });
  }
}
export class NotFoundError extends GraphQLError {
  constructor(resource: string) {
    super(resource + ' not found', { extensions: { code: 'NOT_FOUND' } });
  }
}
export class ValidationError extends GraphQLError {
  constructor(msg: string, fields?: Record<string, string>) {
    super(msg, { extensions: { code: 'VALIDATION_ERROR', fields } });
  }
}

// GraphQL response with errors (HTTP 200 always):
// {
//   "data": { "createPost": null },
//   "errors": [{
//     "message": "Authentication required",
//     "locations": [{ "line": 2, "column": 3 }],
//     "path": ["createPost"],
//     "extensions": { "code": "UNAUTHENTICATED" }
//   }]
// }

// Partial data with errors (errorPolicy: "all"):
// {
//   "data": { "me": { "name": "Alice" }, "adminStats": null },
//   "errors": [{ "path": ["adminStats"], "extensions": { "code": "FORBIDDEN" } }]
// }

// Client: programmatic error handling by code
function useTypedMutation(mutation: any, options?: any) {
  const [mutate, result] = useMutation(mutation, {
    errorPolicy: 'all',
    ...options,
  });

  function getErrorByCode(code: string) {
    return result.error?.graphQLErrors.find(
      (e) => e.extensions?.code === code
    );
  }

  return [
    mutate,
    {
      ...result,
      isUnauthenticated: !!getErrorByCode('UNAUTHENTICATED'),
      isForbidden:       !!getErrorByCode('FORBIDDEN'),
      isNotFound:        !!getErrorByCode('NOT_FOUND'),
      validationFields:  getErrorByCode('VALIDATION_ERROR')?.extensions?.fields,
      isNetworkError:    !!result.error?.networkError,
    },
  ] as const;
}

性能:DataLoader、缓存与持久化查询

N+1 问题是最常见的 GraphQL 性能陷阱。解析文章列表时,如果每个文章解析器单独获取作者,你会得到 N+1 个数据库查询(1 个获取列表 + N 个获取作者)。DataLoader 通过将单个事件循环 tick 内的所有 load() 调用批处理为单个批量请求来解决这个问题,并在同一请求内记忆化结果。

响应缓存可以在多个级别实现:Apollo Server 的内置 @cacheControl 指令设置 HTTP 缓存头,解析器级别的 Redis 缓存缓存昂贵的计算,Apollo Client 的 InMemoryCache 处理客户端缓存。持久化查询用哈希替换完整查询字符串,减少请求大小并为查询启用基于 GET 的 CDN 缓存。

DataLoader Batching + Redis Resolver Cache + APQ

// DataLoader: the N+1 solution
// Without DataLoader:
//   Resolving 20 posts -> 20 x "SELECT * FROM users WHERE id = $1" = 21 queries
// With DataLoader:
//   Resolving 20 posts -> 1 x "SELECT * FROM users WHERE id IN (...)" = 2 queries

import DataLoader from 'dataloader';

export function createUserLoader(db: any) {
  return new DataLoader<string, any>(
    async (ids) => {
      const rows = await db.query(
        'SELECT * FROM users WHERE id = ANY($1::uuid[])',
        [[...ids]]
      );
      const map = new Map(rows.map((r: any) => [r.id, r]));
      return ids.map(id => map.get(id) ?? new Error('User ' + id + ' not found'));
    },
    { maxBatchSize: 500, cache: true }
  );
}

// Redis caching at resolver level
import type { Redis } from 'ioredis';

async function cachedResolver<T>(
  redis: Redis,
  key: string,
  ttlSeconds: number,
  fetchFn: () => Promise<T>
): Promise<T> {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached) as T;
  const result = await fetchFn();
  await redis.setex(key, ttlSeconds, JSON.stringify(result));
  return result;
}

// Usage in resolver:
Query: {
  posts: async (_, args, { db, redis }) =>
    cachedResolver(
      redis,
      'posts:' + JSON.stringify(args),
      60, // 60 second TTL
      () => db.posts.findMany(args)
    ),
},

// Automatic Persisted Queries (APQ)
// -- cuts request body from ~500B to ~50B
// -- enables GET requests for CDN caching
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';

const apqLink = createPersistedQueryLink({
  sha256,
  useGETForHashedQueries: true, // Enables CDN GET caching
});

// Client flow:
// 1. Send: POST /graphql { extensions: { persistedQuery: { sha256Hash: "abc" } } }
// 2. Server miss: returns PersistedQueryNotFound
// 3. Client retries: POST /graphql { query: "...", extensions: { persistedQuery: { sha256Hash: "abc" } } }
// 4. Server stores hash, executes, responds
// 5. Next time: GET /graphql?extensions=%7B%22persistedQuery%22%3A%7B%22sha256Hash%22%3A%22abc%22%7D%7D
// 6. CDN can cache this GET response!

// @cacheControl directive (HTTP cache headers)
// type Query {
//   publicPosts: [Post!]! @cacheControl(maxAge: 300, scope: PUBLIC)
//   me: User @cacheControl(maxAge: 0, scope: PRIVATE)
// }

GraphQL vs REST vs gRPC vs tRPC

选择正确的 API 范式取决于你的使用场景、团队专业知识和基础设施。以下是 2026 年四种主要 API 方式的全面比较:

FeatureGraphQLRESTgRPCtRPC
TransportHTTP + WebSocketHTTP/1.1 or HTTP/2HTTP/2 (binary)HTTP (JSON)
EndpointSingle /graphqlOne per resourceService methodsType-safe procedures
ContractSDL (.graphql file)OpenAPI (optional)Protocol BuffersTypeScript types
Type Safetycodegen requiredOpenAPI + codegenBuilt-in (proto gen)Built-in (inferred)
Over-fetchingSolved by designCommon problemSolved (streaming)Controlled per procedure
HTTP CachingHard (needs APQ)Simple GET cachingN/ANeeds setup
Real-timeSubscriptions (WS)SSE or WebSocketBidirectional streamingSubscriptions
File UploadMultipart specNative multipartNot standardSeparate REST endpoint
BrowserFull supportFull supportgrpc-web proxy onlyFull support
Learning CurveMedium-HighLowHighLow (TypeScript devs)
N+1 ProblemDataLoader requiredManaged per endpointStreaming mitigatesCo-location avoids it
VersioningSchema evolutionURL /v1 /v2Proto backward compatTypeScript refactor
EcosystemVery large (Apollo)Largest (universal)Large (Google)Growing (Vercel)
Best ForMulti-client flexible APIsPublic/simple APIsInternal microservicesFull-stack TS monorepo

Decision Guide: When to Use Each Approach

GraphQL
  • Multiple client types (web/mobile/IoT)
  • Complex nested data requirements
  • Rapid frontend iteration
  • Real-time subscriptions needed
  • Developer-facing flexible API
REST
  • Simple CRUD, URLs map to resources
  • Public API (universally understood)
  • Heavy HTTP GET caching
  • File upload/download workflows
  • Webhook / 3rd-party integrations
gRPC
  • High-performance internal services
  • Polyglot microservices (multi-language)
  • Bidirectional streaming required
  • Low-latency critical path
  • Contract-first with .proto files
tRPC
  • Full-stack TypeScript monorepo
  • Next.js with shared type definitions
  • Small team, no separate API layer
  • Zero codegen, end-to-end types
  • Avoid REST/GraphQL boilerplate

常见问题

GraphQL 能解决过度获取和获取不足的问题吗?

是的,这是 GraphQL 的主要设计目标。客户端在查询中精确指定所需字段,服务器返回恰好那些数据。对于需要用户信息、最近订单和通知的复杂仪表板页面,GraphQL 只需一次请求;REST 则需要三次。

GraphQL 订阅如何工作,什么时候应该使用?

订阅是一种 GraphQL 操作类型(与查询和变更并列),通过 WebSocket 连接实现从服务器到客户端的实时数据推送。服务器使用 PubSub 机制发布事件,订阅的客户端自动接收更新。对于不太频繁的更新,使用 useQuery 的 pollInterval 选项轮询更简单,且更易于扩展。

代码优先和 Schema 优先的 GraphQL 有什么区别?

Schema 优先(SDL 优先)意味着手动编写 .graphql schema 文件,然后编写匹配的解析器。代码优先意味着用代码定义类型(使用 TypeGraphQL 的 TypeScript 装饰器或 Pothos),SDL 自动生成。大多数大型团队偏好 Schema 优先以获得显式契约,而代码优先在 TypeScript 项目中流行,可获得无需 codegen 的端到端类型安全。

GraphQL Federation 是什么,什么时候应该使用?

GraphQL Federation(Apollo Federation)是将多个 GraphQL 子图服务组合成单一统一超图 API 的规范。每个团队拥有自己的子图 schema(例如,用户、订单、产品),路由器/网关透明地将它们拼接在一起。当你有多个后端服务各自拥有数据图的一部分,且需要向客户端暴露统一 API 时使用 Federation——这是 GraphQL 的微服务模式。

GraphQL 如何处理文件上传?

GraphQL 的默认传输(HTTP 上的 JSON)不原生支持二进制文件上传。社区标准解决方案是 GraphQL 多部分请求规范(Node.js 的 graphql-upload 包),它扩展了 GraphQL HTTP 协议以接受将文件流映射到查询变量的 multipart/form-data。许多团队使用单独的 REST 端点进行文件上传,并在 GraphQL 变更中引用返回的 URL,将 GraphQL 仅用于结构化数据。

GraphQL 比 REST 更难缓存吗?

是的,GraphQL 的 HTTP 级缓存更难,因为所有请求都去往单一的 POST /graphql 端点。解决方案包括:持久化查询(为查询分配哈希 ID,对可缓存查询使用 GET 请求)、Apollo Client 的规范化 InMemoryCache 用于客户端缓存、使用自动持久化查询(APQ)的 CDN 缓存,以及用 Redis 键入查询+变量哈希的服务端响应缓存。

GraphQL 开发和测试有哪些工具?

关键工具:GraphiQL 和 Apollo Sandbox(带 schema 浏览器和查询构建器的浏览器 IDE)、Apollo Studio(基于云的 schema 注册表、操作指标、告警)、GraphQL Inspector(schema 差异、破坏性变更检测)、graphql-codegen(从 schema + 操作生成 TypeScript 类型和 React hooks)、Rover CLI(Apollo Federation schema 管理)、Apollo DevTools 浏览器扩展(检查缓存、活跃查询、变更)。

什么时候不应该使用 GraphQL?

以下情况避免使用 GraphQL:资源自然映射到端点的简单 CRUD API;消费者期望 REST 的公共 API;文件上传/下载繁重的工作流;主要使用 HTTP 缓存的服务;GraphQL 的设置开销不能被收益证明的非常小的团队;以及主要由服务到服务调用消费的 API,客户端定义查询的灵活性不增加价值。对于前后端共享代码库的 TypeScript 单体仓库,tRPC 是一个很好的替代方案。

𝕏 Twitterin LinkedIn
这篇文章有帮助吗?

保持更新

获取每周开发技巧和新工具通知。

无垃圾邮件,随时退订。

试试这些相关工具

{ }JSON Formatter🔓CORS TesterJWTJWT Decoder

相关文章

API测试:cURL、Supertest和k6完整指南

掌握API测试的完整指南。含HTTP方法、cURL、fetch/axios、Postman/Newman、supertest、Python httpx、Mock服务器、契约测试、k6负载测试和OpenAPI文档。

JWT 认证:完整实现指南

从零实现 JWT 认证。Token 结构、访问令牌和刷新令牌、Node.js 实现、客户端管理、安全最佳实践和 Next.js 中间件。

WebSocket完整指南:使用ws和Socket.io实现实时通信

掌握WebSocket实时通信。含浏览器API、Node.js ws、Socket.io、React hooks、Python websockets、Go gorilla/websocket、认证、扩展和错误处理完整指南。