关于 apollo-graphql-best-practices
apollo-graphql-best-practices 是面向开发和维护 Apollo Client、Apollo Server 应用团队的参考技能,涵盖模式与解析器设计、缓存、错误处理、性能、安全及 Relay 风格模式。
主要功能
- 01Apollo Client 配置与查询
- 02缓存策略与分页
- 03模式和解析器设计建议
- 04GraphQL 错误处理
- 05性能与安全实践
- 06Relay ID 与 DataLoader 模式
适用场景
- 01启动 Apollo 项目
- 02审查 GraphQL 模式和解析器
- 03调整缓存与分页
- 04处理 N+1 查询和查询风险
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ maximepzv/ skills --skill apollo-graphql-best-practices安装后在 Claude Code 中输入/apollo-graphql-best-practices确认已加载。
详细介绍
Apollo GraphQL 最佳实践
适用场景
在以下情况下使用此技能:
- 设置新的 Apollo Client 或 Apollo Server 项目
- 编写 GraphQL 查询、变更或订阅
- 使用
useQuery、useMutation或useLazyQuery实现 React 组件 - 配置缓存策略、类型策略或分页
- 设计 GraphQL 模式或编写解析器
- 处理 GraphQL 或网络错误
- 优化 GraphQL 性能(N+1 查询、缓存、批处理)
- 审查或重构现有 Apollo GraphQL 代码
Apollo Client
客户端设置
使用 InMemoryCache 和适当的类型策略配置 ApolloClient:
import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client";
const client = new ApolloClient({
link: new HttpLink({ uri: "/graphql" }),
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
// Define field policies for pagination, merging, etc.
},
},
// Custom key fields for entity identification
User: {
keyFields: ["email"], // Use email instead of id
},
},
}),
});
使用 useQuery 进行查询
import { useQuery, gql } from "@apollo/client";
const GET_DATA = gql`
query GetData($id: ID!) {
item(id: $id) {
id
name
}
}
`;
function Component({ id }: { id: string }) {
const { data, loading, error } = useQuery(GET_DATA, {
variables: { id },
fetchPolicy: "cache-first", // Default, use cache when available
});
if (loading) return <Loading />;
if (error) return <Error message={error.message} />;
return <Display data={data} />;
}
使用 useMutation 进行变更
使用 update 回调在变更后更新缓存:
import { useMutation, gql } from "@apollo/client";
const ADD_ITEM = gql`
mutation AddItem($input: ItemInput!) {
addItem(input: $input) {
id
name
}
}
`;
function AddItemForm() {
const [addItem, { loading }] = useMutation(ADD_ITEM, {
update(cache, { data: { addItem } }) {
cache.modify({
fields: {
items(existingItems = []) {
const newItemRef = cache.writeFragment({
data: addItem,
fragment: gql`
fragment NewItem on Item {
id
name
}
`,
});
return [...existingItems, newItemRef];
},
},
});
},
// Or use refetchQueries for simpler cases
// refetchQueries: [{ query: GET_ITEMS }],
});
return (
<form onSubmit={(e) => {
e.preventDefault();
addItem({ variables: { input: { name: "New Item" } } });
}}>
<button type="submit" disabled={loading}>Add</button>
</form>
);
}
错误处理
GraphQL 可以在返回错误的同时返回部分数据(与 REST 不同,REST 中单个错误会导致整个请求失败)。使用 errorPolicy 控制此行为:
| 策略 | 行为 |
|---|---|
none | 将任何 GraphQL 错误视为网络错误,并丢弃数据(默认) |
ignore | 忽略 GraphQL 错误,仅返回数据 |
all | 同时返回数据和错误,以便渲染部分数据 |
import { useQuery } from "@apollo/client";
function Component() {
const { data, error } = useQuery(QUERY, {
errorPolicy: "all" // Receive partial data with errors
});
if (error) {
// Check if we have partial data to display
if (data) {
// Render partial data with error notification
return (
<div>
<ErrorBanner message={error.message} />
<Display data={data} />
</div>
);
}
// No data at all - show full error
if (error.networkError) {
return <div>Network error: {error.message}</div>;
}
return <div>Error: {error.graphQLErrors[0]?.message}</div>;
}
return <div>{data?.field}</div>;
}
缓存类型策略
配置缓存规范化、自定义标识符和字段合并:
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
// Offset-based pagination with proper merging
items: {
keyArgs: ["filter"], // Cache separately per filter value
merge(existing = [], incoming, { args }) {
const offset = args?.offset ?? 0;
const merged = existing.slice(0);
for (let i = 0; i < incoming.length; i++) {
merged[offset + i] = incoming[i];
}
return merged;
},
},
},
},
// Custom cache key using different field than 'id'
User: {
keyFields: ["email"], // Use email as unique identifier
},
// Entities without id field
Token: {
keyFields: false, // Treat as singleton (no normalization)
},
// Composite key for join tables
OrderItem: {
keyFields: ["orderId", "productId"],
},
},
});
关键概念:
- 缓存规范化:Apollo 使用
__typename:id作为缓存键,将对象存储在扁平查找表中 - keyFields:自定义用于标识实体的字段(默认为
id或_id) - keyArgs:控制哪些参数会创建独立的缓存条目
- merge:定义如何合并现有数据和传入数据(分页必不可少)
获取策略
| 策略 | 行为 |
|---|---|
cache-first | 读取缓存,缺失时获取数据(默认) |
cache-only | 仅读取缓存,不获取数据 |
network-only | 始终获取数据并更新缓存 |
no-cache | 始终获取数据,不缓存 |
cache-and-network | 立即返回缓存数据,然后获取数据 |
Apollo Server
服务端设置
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
interface Context {
user?: User;
db: Database;
}
const server = new ApolloServer<Context>({
typeDefs,
resolvers,
});
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => ({
user: await getUserFromToken(req.headers.authorization),
db: await getDatabase(),
}),
listen: { port: 4000 },
});
模式设计原则
- 默认使用非空类型 - 除非字段确实可以为 null,否则添加
! - 优先使用特定类型 - 标识符使用
ID!,日期使用自定义标量 - 面向客户端设计 - 围绕 UI 需求构建模式,而不是围绕数据库模式
- 变更使用输入类型 - 将相关参数归为一组
type Query {
user(id: ID!): User
users(filter: UserFilter, pagination: Pagination): UserConnection!
}
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
}
input CreateUserInput {
email: String!
name: String!
}
type CreateUserPayload {
user: User
errors: [Error!]
}
解析器
const resolvers = {
Query: {
user: async (_, { id }, context) => {
return context.db.users.findById(id);
},
},
Mutation: {
createUser: async (_, { input }, context) => {
if (!context.user) {
throw new GraphQLError("Not authenticated", {
extensions: { code: "UNAUTHENTICATED" },
});
}
const user = await context.db.users.create(input);
return { user, errors: [] };
},
},
// Field resolvers for computed/related data
User: {
posts: (parent, _, context) => {
return context.db.posts.findByUserId(parent.id);
},
},
};
错误处理
使用带有描述性代码的 GraphQLError:
import { GraphQLError } from "graphql";
// In resolver
if (!user) {
throw new GraphQLError("User not found", {
extensions: {
code: "NOT_FOUND",
argumentName: "id",
},
});
}
// In context for auth errors
context: async ({ req }) => {
const user = await getUser(req);
if (!user) {
throw new GraphQLError("Authentication required", {
extensions: {
code: "UNAUTHENTICATED",
http: { status: 401 },
},
});
}
return { user };
};
标准错误代码
| 代码 | 使用场景 |
|---|---|
UNAUTHENTICATED | 缺少身份验证或身份验证无效 |
FORBIDDEN | 已通过身份验证,但未获授权 |
BAD_USER_INPUT | 参数值无效 |
NOT_FOUND | 请求的资源不存在 |
INTERNAL_SERVER_ERROR | 意外的服务端错误 |
性能提示
- 使用 DataLoader - 批处理并缓存数据库调用,以避免 N+1 查询
- 实现分页 - 切勿返回无上限的列表
- 使用持久化查询 - 减小生产环境中的请求大小
- 启用 APM - 使用 Apollo Studio 监控查询性能
- 延迟加载片段 - 使用
@defer指令拆分大型查询 - 配置缓存 TTL - 为缓存响应设置合适的
maxAge - 限制查询深度 - 防止可能导致性能问题的深度嵌套查询
- 设置查询复杂度限制 - 为字段分配成本,并拒绝过于复杂的查询
安全最佳实践
- 查询深度限制 - 防止恶意的深度嵌套查询
- 查询复杂度分析 - 为字段分配成本,拒绝超出阈值的查询
- 速率限制 - 限制每个客户端/IP 的请求频率
- 在生产环境中禁用内省 - 向未授权用户隐藏架构
- 输入验证 - 在解析器中验证所有用户输入
- 字段级授权 - 在字段解析器中检查权限,而不仅仅是在查询级别检查
// Example: formatError to hide internal errors in production
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== "production",
formatError: (error) => {
// Log full error internally
console.error(error);
// Don't expose internal errors to clients
if (error.extensions?.code === "INTERNAL_SERVER_ERROR") {
return new GraphQLError("Internal server error", {
extensions: { code: "INTERNAL_SERVER_ERROR" },
});
}
return error;
},
});
Relay 风格模式
全局 ID
使用 base64 编码的 type:uuid 格式作为全局唯一标识符。这遵循 Relay 全局对象标识规范。
优势:
- 支持高效的客户端缓存和数据重新获取
- 允许通过
node(id: ID!)查询按 ID 获取任意实体 - 将类型信息嵌入 ID,防止意外的跨类型查询
- Relay 等库可以自动生成分页和重新获取查询
// utils/globalId.ts
export type GlobalIdType = "user" | "request" | "classified" | "purchase";
export function encodeGlobalId(type: GlobalIdType, uuid: string): string {
return Buffer.from(`${type}:${uuid}`).toString("base64");
}
export function decodeGlobalId(globalId: string): { type: string; uuid: string } {
const decoded = Buffer.from(globalId, "base64").toString("utf-8");
const [type, uuid] = decoded.split(":");
if (!type || !uuid) {
throw new Error("Invalid global ID format");
}
return { type, uuid };
}
// Examples
encodeGlobalId("request", "abc-123"); // -> "cmVxdWVzdDphYmMtMTIz"
decodeGlobalId("cmVxdWVzdDphYmMtMTIz"); // -> { type: 'request', uuid: 'abc-123' }
Node 接口
实现 Node 接口以统一获取实体。这是 Relay 规范中的核心模式,提供:
- 通过 ID 重新获取任意对象的标准方式
- 为客户端库提供高效的缓存管理
- 类型安全的多态查询
# schema/node.graphql
interface Node {
id: ID!
}
extend type Query {
"""
Fetch any entity by its global ID.
Returns null if the ID is invalid or the entity doesn't exist.
"""
node(id: ID!): Node
}
// resolvers/node.ts
export const nodeResolvers = {
Query: {
async node(parent: any, { id }: { id: string }, context: GraphQLContext) {
try {
const { type, uuid } = decodeGlobalId(id);
switch (type) {
case "request":
return context.loaders.request.load(uuid);
case "classified":
return context.loaders.classified.load(uuid);
case "purchase":
return context.loaders.purchase.load(uuid);
default:
return null;
}
} catch (error) {
return null; // Silent failure for invalid IDs
}
},
},
Node: {
__resolveType(obj: any) {
// Detect type based on unique fields or __typename
if (obj.__typename) return obj.__typename;
if ("condition" in obj && "priceMinimum" in obj) return "Request";
if ("platformConfigId" in obj && "visibilityStatus" in obj) return "Classified";
if ("buyerEmail" in obj || "shippingAddress" in obj) return "Purchase";
return null;
},
},
};
带全局 ID 的实体类型
type Request implements Node {
id: ID! # Global ID (base64 encoded)
title: String!
description: String!
# ... other fields
}
// resolvers/request.ts
Request: {
// Encode database UUID to global ID
id(request: any) {
return encodeGlobalId("request", request.id);
},
}
用于批处理的 DataLoader
对于高效的 Node 查询和避免 N+1 问题至关重要。N+1 问题是指获取项目列表(1 次查询)后,又为每个项目单独获取相关数据(N 次查询)。
DataLoader 的关键原则:
- 每个请求都创建全新的 DataLoader(绝不在请求之间共享)
- 始终按输入键的顺序返回结果
- 在单个请求中使用缓存,对相同的获取操作去重
- 设置
maxBatchSize,避免查询过大
// dataloaders/request.loader.ts
import DataLoader from "dataloader";
export function createRequestLoader(db: Database) {
return new DataLoader<string, any>(
async (uuids) => {
// Batch fetch all requested entities in a single query
// e.g., SELECT * FROM requests WHERE id IN (uuid1, uuid2, ...)
const results = await db.requests.findByIds([...uuids]);
// CRITICAL: Return results in same order as input uuids
// DataLoader requires 1:1 mapping between keys and results
const resultMap = new Map(results.map((r) => [r.id, r]));
return uuids.map((uuid) => resultMap.get(uuid) ?? null);
},
{
cache: true, // Cache within this request
maxBatchSize: 100, // Limit batch size for DB query performance
}
);
}
// dataloaders/index.ts
export interface Loaders {
request: DataLoader<string, any>;
classified: DataLoader<string, any>;
purchase: DataLoader<string, any>;
}
export function createLoaders(db: Database): Loaders {
return {
request: createRequestLoader(db),
classified: createClassifiedLoader(db),
purchase: createPurchaseLoader(db),
};
}
带 DataLoader 的上下文
每个请求都创建全新的 DataLoader:
// server.ts
export interface GraphQLContext {
user: User | null;
permissions: Permissions;
db: Database;
loaders: Loaders;
}
app.use(
"/graphql",
expressMiddleware(apolloServer, {
context: async ({ req }): Promise<GraphQLContext> => ({
user: (req as any).connectedUser ?? null,
permissions: (req as any).connectedUserPermissions,
db: database,
loaders: createLoaders(database), // Fresh loaders per request
}),
})
);
基于偏移量的分页(Connection 模式)
# schema/common.graphql
type PageInfo {
total: Int!
offset: Int!
limit: Int!
hasNextPage: Boolean!
hasPreviousPage: Boolean!
}
input PaginationInput {
offset: Int = 0
limit: Int = 50
}
type RequestConnection {
items: [Request!]!
pageInfo: PageInfo!
}
extend type Query {
requests(pagination: PaginationInput): RequestConnection!
}
// resolvers/request.ts
async requests(
parent: any,
{ pagination = {} }: { pagination?: { offset?: number; limit?: number } },
context: GraphQLContext
) {
const { offset = 0, limit = 50 } = pagination;
const [items, total] = await Promise.all([
context.db.requests.findMany({
where: { deletedAt: null },
skip: offset,
take: limit,
orderBy: { createdAt: "asc" },
}),
context.db.requests.count({ where: { deletedAt: null } }),
]);
return {
items,
pageInfo: {
total,
offset,
limit,
hasNextPage: offset + limit < total,
hasPreviousPage: offset > 0,
},
};
}
静默处理权限(查询)
对于查询,返回 null,而不是抛出错误:
async request(parent: any, { id }: { id: string }, context: GraphQLContext) {
try {
const { type, uuid } = decodeGlobalId(id);
// Wrong type - return null silently
if (type !== "request") {
return null;
}
// No permission - return null silently
if (!context.permissions?.canViewRequest?.(uuid)) {
return null;
}
const request = await context.loaders.request.load(uuid);
return request ?? null;
} catch (error) {
console.error("Error fetching request:", error);
return null;
}
}
查询示例
# Fetch single entity by global ID
query GetRequest {
request(id: "cmVxdWVzdDphYmMxMjM=") {
id
title
description
}
}
# Using node query for any entity
query GetNode {
node(id: "cmVxdWVzdDphYmMxMjM=") {
id
... on Request {
title
price
}
... on Classified {
status
url
}
}
}
# Paginated list
query ListRequests {
requests(pagination: { offset: 0, limit: 10 }) {
items {
id
title
}
pageInfo {
total
hasNextPage
}
}
}
评论
支持 Markdown