- 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 elseSchema 定义语言(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 方式的全面比较:
| Feature | GraphQL | REST | gRPC | tRPC |
|---|---|---|---|---|
| Transport | HTTP + WebSocket | HTTP/1.1 or HTTP/2 | HTTP/2 (binary) | HTTP (JSON) |
| Endpoint | Single /graphql | One per resource | Service methods | Type-safe procedures |
| Contract | SDL (.graphql file) | OpenAPI (optional) | Protocol Buffers | TypeScript types |
| Type Safety | codegen required | OpenAPI + codegen | Built-in (proto gen) | Built-in (inferred) |
| Over-fetching | Solved by design | Common problem | Solved (streaming) | Controlled per procedure |
| HTTP Caching | Hard (needs APQ) | Simple GET caching | N/A | Needs setup |
| Real-time | Subscriptions (WS) | SSE or WebSocket | Bidirectional streaming | Subscriptions |
| File Upload | Multipart spec | Native multipart | Not standard | Separate REST endpoint |
| Browser | Full support | Full support | grpc-web proxy only | Full support |
| Learning Curve | Medium-High | Low | High | Low (TypeScript devs) |
| N+1 Problem | DataLoader required | Managed per endpoint | Streaming mitigates | Co-location avoids it |
| Versioning | Schema evolution | URL /v1 /v2 | Proto backward compat | TypeScript refactor |
| Ecosystem | Very large (Apollo) | Largest (universal) | Large (Google) | Growing (Vercel) |
| Best For | Multi-client flexible APIs | Public/simple APIs | Internal microservices | Full-stack TS monorepo |
Decision Guide: When to Use Each Approach
- Multiple client types (web/mobile/IoT)
- Complex nested data requirements
- Rapid frontend iteration
- Real-time subscriptions needed
- Developer-facing flexible API
- Simple CRUD, URLs map to resources
- Public API (universally understood)
- Heavy HTTP GET caching
- File upload/download workflows
- Webhook / 3rd-party integrations
- High-performance internal services
- Polyglot microservices (multi-language)
- Bidirectional streaming required
- Low-latency critical path
- Contract-first with .proto files
- 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 是一个很好的替代方案。