GraphQL API 设计原则
一个设计良好的 GraphQL API 是自文档化、高效且令人愉悦的。以下模式区分了优秀 API 与普通 API。
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),
}),
});
解析器模式
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;
},
});
基于 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 设计规则
- 默认可空,确定时非空——避免破坏客户端
- 使用接口实现多态——
interface Node { id: ID! } - Mutation 使用输入类型——而不是单独的参数
- Mutation 使用负载类型——允许以后添加字段
- Relay 分页——对于大数据集,游标优于偏移量
- 有限集合使用枚举——
enum PostStatus { DRAFT PUBLISHED ARCHIVED }