正在加载,请稍候…

GraphQL API 设计最佳实践:2026 年 Schema 优先开发

设计生产级 GraphQL API——涵盖 schema 设计模式、DataLoader 解决 N+1 问题、分页策略、订阅、错误处理、速率限制和持久化查询。

GraphQL API 设计最佳实践:2026 年 Schema 优先开发

GraphQL API 设计原则

一个设计良好的 GraphQL API 是自文档化、高效且令人愉悦的。以下模式区分了优秀 API 与普通 API。

GraphQL API 设计最佳实践:2026 年 Schema 优先开发 插图

Schema 优先设计

在编写解析器之前定义 schema。这迫使你思考消费者体验。

# schema.graphql
type Query {
  user(id: ID!): User
  users(filter: UserFilter, pagination: PaginationInput): UserConnection!
  post(id: ID!): Post
}

type Mutation {
  createUser(input: CreateUserInput!): CreateUserPayload!
  updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
  deleteUser(id: ID!): DeleteUserPayload!
}

type Subscription {
  userCreated: User!
  postPublished(authorId: ID): Post!
}

type User {
  id: ID!
  name: String!
  email: String!
  posts(first: Int, after: String): PostConnection!
  createdAt: DateTime!
}

# Relay 风格分页
type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type UserEdge {
  node: User!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

使用 DataLoader 解决 N+1 问题

N+1 问题:获取 10 篇文章 → 10 次独立的作者查询。

import DataLoader from 'dataloader';

// 每个请求创建 DataLoader(不要全局创建——防止跨请求缓存)
function createLoaders(db) {
  return {
    userById: new DataLoader(async (ids) => {
      const users = await db.users.findMany({
        where: { id: { in: ids } },
      });
      
      // 必须按 ids 相同顺序返回数组
      const userMap = new Map(users.map(u => [u.id, u]));
      return ids.map(id => userMap.get(id) ?? null);
    }),
    
    postsByAuthorId: new DataLoader(async (authorIds) => {
      const posts = await db.posts.findMany({
        where: { authorId: { in: authorIds } },
      });
      
      const grouped = authorIds.map(id =>
        posts.filter(p => p.authorId === id)
      );
      return grouped;
    }),
  };
}

// 在 Apollo Server 上下文中
const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: ({ req }) => ({
    db,
    loaders: createLoaders(db),
    userId: getAuthUserId(req),
  }),
});

GraphQL API 设计最佳实践:2026 年 Schema 优先开发 插图

解析器模式

const resolvers = {
  Query: {
    user: async (_, { id }, { loaders }) => {
      return loaders.userById.load(id);
    },
    
    users: async (_, { filter, pagination }, { db }) => {
      const { first = 20, after } = pagination ?? {};
      const cursor = after ? decodeCursor(after) : null;
      
      const items = await db.users.findMany({
        where: buildFilter(filter),
        take: first + 1,
        cursor: cursor ? { id: cursor } : undefined,
        orderBy: { createdAt: 'desc' },
      });
      
      const hasNextPage = items.length > first;
      const nodes = hasNextPage ? items.slice(0, -1) : items;
      
      return {
        edges: nodes.map(node => ({
          node,
          cursor: encodeCursor(node.id),
        })),
        pageInfo: {
          hasNextPage,
          hasPreviousPage: !!after,
          startCursor: nodes[0] ? encodeCursor(nodes[0].id) : null,
          endCursor: nodes.at(-1) ? encodeCursor(nodes.at(-1).id) : null,
        },
        totalCount: await db.users.count({ where: buildFilter(filter) }),
      };
    },
  },
  
  User: {
    posts: async (user, { first = 10 }, { loaders }) => {
      return loaders.postsByAuthorId.load(user.id);
    },
  },
  
  Mutation: {
    createUser: async (_, { input }, { db, userId }) => {
      if (!userId) throw new AuthenticationError('Not authenticated');
      
      const user = await db.users.create({ data: input });
      return { user, success: true };
    },
  },
};

错误处理模式

import { GraphQLError } from 'graphql';

// 领域特定错误
class NotFoundError extends GraphQLError {
  constructor(resource, id) {
    super(`${resource} ${id} not found`, {
      extensions: { code: 'NOT_FOUND', resource, id },
    });
  }
}

class AuthenticationError extends GraphQLError {
  constructor(message = 'Not authenticated') {
    super(message, {
      extensions: { code: 'UNAUTHENTICATED' },
    });
  }
}

class ForbiddenError extends GraphQLError {
  constructor(message = 'Forbidden') {
    super(message, {
      extensions: { code: 'FORBIDDEN' },
    });
  }
}

// 全局错误格式化器
const server = new ApolloServer({
  formatError: (formattedError, error) => {
    // 生产环境不暴露内部错误
    if (process.env.NODE_ENV === 'production') {
      if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
        return new GraphQLError('Internal server error');
      }
    }
    return formattedError;
  },
});

GraphQL API 设计最佳实践:2026 年 Schema 优先开发 插图

基于 WebSocket 的订阅

import { createClient } from 'graphql-ws';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';

const wsServer = new WebSocketServer({ port: 4001 });
const serverCleanup = useServer({ schema }, wsServer);

// 解析器
const resolvers = {
  Subscription: {
    postPublished: {
      subscribe: async function* (_, { authorId }, { pubsub }) {
        for await (const event of pubsub.subscribe('POST_PUBLISHED')) {
          if (!authorId || event.authorId === authorId) {
            yield { postPublished: event };
          }
        }
      },
    },
  },
};

基于成本分析的速率限制

import { createComplexityLimitRule } from 'graphql-validation-complexity';

const server = new ApolloServer({
  validationRules: [
    createComplexityLimitRule(1000, {
      onCost: (cost, document) => {
        console.log('Query cost:', cost);
      },
      createError: (max, actual) =>
        new GraphQLError(`Query cost ${actual} exceeds maximum ${max}`),
    }),
  ],
});

持久化查询

import { ApolloServerPluginCacheControl } from '@apollo/server/plugin/cacheControl';
import { InMemoryLRUCache } from '@apollo/utils.keyvaluecache';

const server = new ApolloServer({
  plugins: [ApolloServerPluginCacheControl({ defaultMaxAge: 5 })],
  cache: new InMemoryLRUCache({ maxSize: Math.pow(2, 20) * 30 }),
  persistedQueries: {
    cache: new InMemoryLRUCache(),
  },
});

Schema 设计规则

  1. 默认可空,确定时非空——避免破坏客户端
  2. 使用接口实现多态——interface Node { id: ID! }
  3. Mutation 使用输入类型——而不是单独的参数
  4. Mutation 使用负载类型——允许以后添加字段
  5. Relay 分页——对于大数据集,游标优于偏移量
  6. 有限集合使用枚举——enum PostStatus { DRAFT PUBLISHED ARCHIVED }