GraphQL vs REST:何时选择GraphQL
GraphQL优势场景:
┌─────────────────────────────────────────┐
│ ✓ 多客户端需要不同数据(Web、Mobile、API)│
│ ✓ 频繁变化的前端需求 │
│ ✓ 复杂的关系数据查询 │
│ ✓ 需要减少网络请求次数 │
│ ✓ 实时数据需求(Subscription) │
│ │
│ REST更适合的场景: │
│ ✓ 简单的CRUD操作 │
│ ✓ 文件上传下载 │
│ ✓ 需要HTTP缓存 │
│ ✓ 团队对GraphQL不熟悉 │
│ ✓ 简单的API,数据关系不复杂 │
└─────────────────────────────────────────┘
Schema设计最佳实践
类型设计
# schema.graphql
# 使用interface定义共享字段
interface Node {
id: ID!
createdAt: DateTime!
updatedAt: DateTime!
}
# 实现interface
type User implements Node {
id: ID!
createdAt: DateTime!
updatedAt: DateTime!
username: String!
email: String!
profile: UserProfile
posts(first: Int, after: String): PostConnection!
followers: UserConnection!
}
type UserProfile {
displayName: String
bio: String
avatar: String
website: String
}
# 使用union处理多种返回类型
union SearchResult = User | Post | Comment
# 使用enum限制值范围
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
# 使用input类型处理复杂输入
input CreatePostInput {
title: String!
content: String!
tags: [String!]!
status: PostStatus = DRAFT
}
type Post implements Node {
id: ID!
createdAt: DateTime!
updatedAt: DateTime!
title: String!
content: String!
status: PostStatus!
author: User!
tags: [String!]!
comments(first: Int, after: String): CommentConnection!
likes: Int!
}
# Relay规范的连接类型(分页)
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type PostEdge {
node: Post!
cursor: String!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
# Query和Mutation
type Query {
node(id: ID!): Node
user(id: ID!): User
posts(
first: Int
after: String
status: PostStatus
authorId: ID
): PostConnection!
search(query: String!, first: Int): SearchResultConnection!
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
likePost(id: ID!): Post!
}
# Subscription实现实时推送
type Subscription {
postCreated(authorId: ID): Post!
commentAdded(postId: ID!): Comment!
}
DataLoader解决N+1问题
// dataloader/userLoader.js
const DataLoader = require('dataloader');
const { User } = require('../models');
// 批量加载用户
const batchUsers = async (ids) => {
const users = await User.findAll({
where: { id: ids }
});
// 确保返回顺序与ids一致
const userMap = new Map(users.map(u => [u.id, u]));
return ids.map(id => userMap.get(id) || null);
};
const createUserLoader = () => new DataLoader(batchUsers, {
cache: true,
maxBatchSize: 100
});
// dataloader/postLoader.js
const batchPostsByAuthor = async (authorIds) => {
const posts = await Post.findAll({
where: { authorId: authorIds },
order: [['createdAt', 'DESC']]
});
// 按authorId分组
const postsByAuthor = new Map();
authorIds.forEach(id => postsByAuthor.set(id, []));
posts.forEach(post => {
postsByAuthor.get(post.authorId).push(post);
});
return authorIds.map(id => postsByAuthor.get(id));
};
const createPostsByAuthorLoader = () => new DataLoader(batchPostsByAuthor);
// context.js - 每个请求创建新的DataLoader实例
const createContext = ({ req }) => {
return {
user: req.user,
loaders: {
user: createUserLoader(),
postsByAuthor: createPostsByAuthorLoader(),
post: createPostLoader(),
commentsByPost: createCommentsByPostLoader(),
likeCount: createLikeCountLoader()
}
};
};
module.exports = createContext;
Resolver实现
// resolvers/userResolver.js
const userResolver = {
User: {
profile: async (user, args, { loaders }) => {
// 使用DataLoader批量加载
return loaders.profile.load(user.id);
},
posts: async (user, { first = 10, after }, { loaders }) => {
// 分页查询
const posts = await loaders.postsByAuthor.load(user.id);
// 实现cursor分页
let startIndex = 0;
if (after) {
const afterIndex = posts.findIndex(p => p.id === after);
startIndex = afterIndex + 1;
}
const slicedPosts = posts.slice(startIndex, startIndex + first);
return {
edges: slicedPosts.map(post => ({
node: post,
cursor: post.id
})),
pageInfo: {
hasNextPage: startIndex + first < posts.length,
hasPreviousPage: startIndex > 0,
startCursor: slicedPosts[0]?.id || null,
endCursor: slicedPosts[slicedPosts.length - 1]?.id || null
},
totalCount: posts.length
};
},
followers: async (user, args, { db }) => {
return db.query(`
SELECT u.* FROM users u
JOIN followers f ON f.follower_id = u.id
WHERE f.following_id = ?
`, [user.id]);
}
},
Query: {
user: async (_, { id }, { loaders }) => {
return loaders.user.load(id);
},
posts: async (_, { first = 10, after, status, authorId }, { db }) => {
let query = `
SELECT * FROM posts
WHERE 1=1
`;
const params = [];
if (status) {
query += ' AND status = ?';
params.push(status);
}
if (authorId) {
query += ' AND author_id = ?';
params.push(authorId);
}
if (after) {
query += ' AND id < ?';
params.push(after);
}
query += ' ORDER BY created_at DESC LIMIT ?';
params.push(first + 1); // 多取一个判断hasNextPage
const posts = await db.query(query, params);
const hasNextPage = posts.length > first;
if (hasNextPage) {
posts.pop();
}
return {
edges: posts.map(post => ({
node: post,
cursor: post.id
})),
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: posts[0]?.id || null,
endCursor: posts[posts.length - 1]?.id || null
},
totalCount: await db.query('SELECT COUNT(*) FROM posts')
};
},
search: async (_, { query, first = 10 }, { db }) => {
// 全文搜索
const results = await db.query(`
SELECT 'User' as __typename, id, username as title, null as content
FROM users WHERE username LIKE ?
UNION ALL
SELECT 'Post' as __typename, id, title, content
FROM posts WHERE title LIKE ? OR content LIKE ?
UNION ALL
SELECT 'Comment' as __typename, id, null as title, content
FROM comments WHERE content LIKE ?
LIMIT ?
`, [`%${query}%`, `%${query}%`, `%${query}%`, `%${query}%`, first]);
return {
edges: results.map(r => ({ node: r, cursor: r.id })),
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: results[0]?.id,
endCursor: results[results.length - 1]?.id
}
};
}
}
};
module.exports = userResolver;
错误处理
// errors/customErrors.js
const { GraphQLError } = require('graphql');
class AppError extends GraphQLError {
constructor(message, code, extensions = {}) {
super(message, {
extensions: {
code,
...extensions
}
});
}
}
class NotFoundError extends AppError {
constructor(resource, id) {
super(`${resource} with id ${id} not found`, 'NOT_FOUND', {
resource,
id
});
}
}
class AuthenticationError extends AppError {
constructor(message = 'Authentication required') {
super(message, 'UNAUTHENTICATED');
}
}
class AuthorizationError extends AppError {
constructor(message = 'Not authorized') {
super(message, 'FORBIDDEN');
}
}
class ValidationError extends AppError {
constructor(message, fields = {}) {
super(message, 'VALIDATION_ERROR', { fields });
}
}
module.exports = {
AppError,
NotFoundError,
AuthenticationError,
AuthorizationError,
ValidationError
};
// resolvers/errorHandling.js
const { NotFoundError, AuthenticationError, AuthorizationError } = require('../errors');
const resolvers = {
Query: {
user: async (_, { id }, { loaders, user }) => {
if (!user) {
throw new AuthenticationError();
}
const foundUser = await loaders.user.load(id);
if (!foundUser) {
throw new NotFoundError('User', id);
}
return foundUser;
},
post: async (_, { id }, { loaders, user }) => {
const post = await loaders.post.load(id);
if (!post) {
throw new NotFoundError('Post', id);
}
// 草稿帖子只有作者可见
if (post.status === 'DRAFT' && post.authorId !== user?.id) {
throw new AuthorizationError('You cannot view this draft post');
}
return post;
}
},
Mutation: {
createPost: async (_, { input }, { user, db }) => {
if (!user) {
throw new AuthenticationError();
}
// 验证输入
if (input.title.length < 5) {
throw new ValidationError('Title must be at least 5 characters', {
title: 'Title is too short'
});
}
const post = await db.query(`
INSERT INTO posts (title, content, status, author_id, created_at)
VALUES (?, ?, ?, ?, NOW())
`, [input.title, input.content, input.status, user.id]);
return post;
}
}
};
性能优化
// middleware/queryComplexity.js
const { getComplexity, simpleEstimator } = require('graphql-query-complexity');
const complexityMiddleware = async (req, res, next) => {
const { query, variables } = req.body;
const complexity = getComplexity({
schema,
query: parse(query),
variables,
estimators: [
simpleEstimator({ defaultComplexity: 1 })
]
});
const maxComplexity = 1000;
if (complexity > maxComplexity) {
return res.status(400).json({
errors: [{
message: `Query is too complex: ${complexity}. Maximum allowed complexity: ${maxComplexity}`
}]
});
}
req.complexity = complexity;
next();
};
// middleware/queryDepth.js
const depthLimit = require('graphql-depth-limit');
const schema = makeExecutableSchema({
typeDefs,
resolvers,
validationRules: [depthLimit(5)] // 最大深度5层
});
// resolvers/optimization.js
const optimizedResolvers = {
Post: {
// 避免在resolver中做重复查询
author: (post, args, { loaders }) => {
// post已经包含authorId,直接用DataLoader加载
return loaders.user.load(post.authorId);
},
// 批量加载关联数据
comments: async (post, { first = 10 }, { loaders }) => {
return loaders.commentsByPost.load(post.id);
},
// 使用缓存
likeCount: async (post, args, { loaders }) => {
return loaders.likeCount.load(post.id);
}
},
Query: {
// 使用索引优化查询
posts: async (_, { first, after, authorId }, { db }) => {
// 确保在author_id和created_at上有复合索引
const query = authorId
? 'SELECT * FROM posts WHERE author_id = ? ORDER BY created_at DESC LIMIT ?'
: 'SELECT * FROM posts ORDER BY created_at DESC LIMIT ?';
const params = authorId ? [authorId, first + 1] : [first + 1];
return db.query(query, params);
}
}
};
// cache/redisCache.js
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
const cachedResolver = (resolver, cacheKey, ttl = 300) => {
return async (parent, args, context) => {
const key = cacheKey(parent, args);
// 尝试从缓存获取
const cached = await redis.get(key);
if (cached) {
return JSON.parse(cached);
}
// 执行resolver
const result = await resolver(parent, args, context);
// 存入缓存
await redis.setex(key, ttl, JSON.stringify(result));
return result;
};
};
// 使用示例
const resolvers = {
Query: {
user: cachedResolver(
async (_, { id }, { loaders }) => loaders.user.load(id),
(parent, { id }) => `user:${id}`,
600 // 10分钟缓存
)
}
};
Subscription实现
// subscriptions/postSubscription.js
const { PubSub } = require('graphql-subscriptions');
const pubsub = new PubSub();
const POST_CREATED = 'POST_CREATED';
const COMMENT_ADDED = 'COMMENT_ADDED';
const subscriptionResolvers = {
Subscription: {
postCreated: {
subscribe: (_, { authorId }) => {
// 可选:过滤特定作者
if (authorId) {
return pubsub.asyncIterator([`${POST_CREATED}_${authorId}`]);
}
return pubsub.asyncIterator([POST_CREATED]);
},
resolve: (payload) => payload.post
},
commentAdded: {
subscribe: (_, { postId }) => {
return pubsub.asyncIterator([`${COMMENT_ADDED}_${postId}`]);
},
resolve: (payload) => payload.comment
}
},
Mutation: {
createPost: async (_, { input }, { user, db }) => {
const post = await db.query(/* ... */);
// 发布事件
pubsub.publish(POST_CREATED, { post });
pubsub.publish(`${POST_CREATED}_${user.id}`, { post });
return post;
},
addComment: async (_, { postId, content }, { user, db }) => {
const comment = await db.query(/* ... */);
pubsub.publish(`${COMMENT_ADDED}_${postId}`, { comment });
return comment;
}
}
};
// server.js - WebSocket配置
const { createServer } = require('http');
const { execute, subscribe } = require('graphql');
const { SubscriptionServer } = require('subscriptions-transport-ws');
const { ApolloServer } = require('apollo-server-express');
const startServer = async () => {
const app = express();
const apolloServer = new ApolloServer({
schema,
context: createContext,
subscriptions: {
onConnect: (connectionParams, webSocket) => {
// 验证连接
if (connectionParams.authToken) {
return validateToken(connectionParams.authToken)
.then(user => ({ user }));
}
throw new Error('Missing auth token');
}
}
});
await apolloServer.start();
apolloServer.applyMiddleware({ app });
const httpServer = createServer(app);
SubscriptionServer.create(
{
schema,
execute,
subscribe,
onConnect: (connectionParams) => {
// WebSocket连接时的认证
}
},
{
server: httpServer,
path: apolloServer.graphqlPath
}
);
httpServer.listen(4000, () => {
console.log('Server ready at http://localhost:4000/graphql');
console.log('Subscriptions ready at ws://localhost:4000/graphql');
});
};
测试策略
// tests/graphql.test.js
const { graphql } = require('graphql');
const schema = require('../schema');
const createContext = require('../context');
describe('GraphQL API', () => {
let context;
beforeEach(() => {
context = createContext({ user: { id: '1', username: 'testuser' } });
});
test('should fetch user by id', async () => {
const query = `
query GetUser($id: ID!) {
user(id: $id) {
id
username
email
}
}
`;
const result = await graphql({
schema,
source: query,
variableValues: { id: '1' },
contextValue: context
});
expect(result.errors).toBeUndefined();
expect(result.data.user).toMatchObject({
id: '1',
username: expect.any(String),
email: expect.any(String)
});
});
test('should handle N+1 problem with DataLoader', async () => {
const query = `
query {
posts(first: 10) {
edges {
node {
id
title
author {
id
username
}
}
}
}
}
`;
// 监控SQL查询次数
const queryCount = monitorSQLQueries(() => {
return graphql({
schema,
source: query,
contextValue: context
});
});
// 应该只有2次查询:一次获取posts,一次批量获取authors
expect(queryCount).toBeLessThanOrEqual(2);
});
test('should enforce query depth limit', async () => {
const deepQuery = `
query {
user(id: "1") {
posts {
edges {
node {
author {
posts {
edges {
node {
author {
username
}
}
}
}
}
}
}
}
}
}
`;
const result = await graphql({
schema,
source: deepQuery,
contextValue: context
});
expect(result.errors).toBeDefined();
expect(result.errors[0].message).toContain('depth');
});
});
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。