mcpField
A
apollo-graphql-best-practices

apollo-graphql-best-practices

github.com/maximepzv开发042 次安装40 周前更新
代码仓库

关于 apollo-graphql-best-practices

apollo-graphql-best-practices 是面向开发和维护 Apollo Client、Apollo Server 应用团队的参考技能,涵盖模式与解析器设计、缓存、错误处理、性能、安全及 Relay 风格模式。

主要功能

  1. 01Apollo Client 配置与查询
  2. 02缓存策略与分页
  3. 03模式和解析器设计建议
  4. 04GraphQL 错误处理
  5. 05性能与安全实践
  6. 06Relay ID 与 DataLoader 模式

适用场景

  1. 01启动 Apollo 项目
  2. 02审查 GraphQL 模式和解析器
  3. 03调整缓存与分页
  4. 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 },
});
模式设计原则
  1. 默认使用非空类型 - 除非字段确实可以为 null,否则添加 !
  2. 优先使用特定类型 - 标识符使用 ID!,日期使用自定义标量
  3. 面向客户端设计 - 围绕 UI 需求构建模式,而不是围绕数据库模式
  4. 变更使用输入类型 - 将相关参数归为一组
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意外的服务端错误

性能提示

  1. 使用 DataLoader - 批处理并缓存数据库调用,以避免 N+1 查询
  2. 实现分页 - 切勿返回无上限的列表
  3. 使用持久化查询 - 减小生产环境中的请求大小
  4. 启用 APM - 使用 Apollo Studio 监控查询性能
  5. 延迟加载片段 - 使用 @defer 指令拆分大型查询
  6. 配置缓存 TTL - 为缓存响应设置合适的 maxAge
  7. 限制查询深度 - 防止可能导致性能问题的深度嵌套查询
  8. 设置查询复杂度限制 - 为字段分配成本,并拒绝过于复杂的查询

安全最佳实践

  1. 查询深度限制 - 防止恶意的深度嵌套查询
  2. 查询复杂度分析 - 为字段分配成本,拒绝超出阈值的查询
  3. 速率限制 - 限制每个客户端/IP 的请求频率
  4. 在生产环境中禁用内省 - 向未授权用户隐藏架构
  5. 输入验证 - 在解析器中验证所有用户输入
  6. 字段级授权 - 在字段解析器中检查权限,而不仅仅是在查询级别检查
// 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