GraphQL 架构折腾手记
这次 API 重构,从 REST 迁移到 GraphQL,解决了不少实际问题,但也踩了一些坑。
最初的问题
原来的 API 是标准的 REST 风格:
GET /api/users/:id # 获取用户信息
GET /api/users/:id/posts # 获取用户的文章
GET /api/posts/:id/comments # 获取文章的评论
问题很明显:
过度获取:前端只需要用户名,但 API 返回了完整信息,包括邮箱、手机号等敏感数据。
获取不足:要显示一篇文章,需要调用三次 API:文章详情、作者信息、评论列表。
多端适配困难:Web 端和移动端对数据的需求不一样,Web 需要完整信息,移动端只需要简略信息。
文档维护困难:接口改了,文档没跟上,前端按旧文档调用,结果报错。
GraphQL 的基本思路
GraphQL 的核心思想是:让前端告诉后端它需要什么数据,而不是后端决定返回什么数据。
一个 GraphQL 查询大概长这样:
query GetPost($id: ID!) {
post(id: $id) {
id
title
content
author {
id
name
avatar
}
comments {
id
content
author {
name
}
}
}
}
前端只要改一下查询,就能控制返回的数据结构,不用后端改接口。
实践中的迁移
Schema 定义
先定义 GraphQL Schema:
type User {
id: ID!
name: String!
email: String!
avatar: String
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
comments: [Comment!]!
}
type Comment {
id: ID!
content: String!
author: User!
post: Post!
}
type Query {
user(id: ID!): User
post(id: ID!): Post
posts: [Post!]!
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
}
type Subscription {
postCreated: Post!
commentAdded(postId: ID!): Comment!
}
解析器实现
每个字段都需要一个解析器(Resolver):
const resolvers = {
Query: {
user: (parent, args) => {
return db.user.findUnique({ where: { id: args.id } });
},
post: (parent, args) => {
return db.post.findUnique({ where: { id: args.id } });
},
posts: () => {
return db.post.findMany();
}
},
User: {
posts: (parent) => {
return db.post.findMany({ where: { authorId: parent.id } });
}
},
Post: {
author: (parent) => {
return db.user.findUnique({ where: { id: parent.authorId } });
},
comments: (parent) => {
return db.comment.findMany({ where: { postId: parent.id } });
}
},
Comment: {
author: (parent) => {
return db.user.findUnique({ where: { id: parent.authorId } });
}
}
};
踩过的坑
坑一:N+1 查询问题
GraphQL 的嵌套查询很容易导致 N+1 问题:
// 问题:每篇文章都单独查一次作者
const posts = await db.post.findMany();
for (const post of posts) {
post.author = await db.user.findUnique({ where: { id: post.authorId } });
}
10 篇文章,就要查 11 次数据库(1 次查文章 + 10 次查作者)。
解决:使用 DataLoader 批处理。
const DataLoader = require('dataloader');
// 创建 loader
const userLoader = new DataLoader(async (userIds) => {
const users = await db.user.findMany({ where: { id: { in: userIds } } });
return userIds.map(id => users.find(user => user.id === id));
});
// 在解析器中使用
const resolvers = {
Post: {
author: (parent) => {
return userLoader.load(parent.authorId);
}
}
};
DataLoader 会把同一批请求合并,10 次查询变成 1 次批量查询。
坑二:查询复杂度无限制
GraphQL 允许前端自由写查询,如果不加限制,一个恶意查询就能把服务器搞崩:
# 恶意查询:深度嵌套
query {
user(id: "1") {
posts {
author {
posts {
author {
posts {
# 无限嵌套
}
}
}
}
}
}
}
解决:限制查询深度和复杂度。
const depthLimit = require('graphql-depth-limit');
const { createComplexityLimitRule } = require('graphql-validation-complexity');
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(7), // 限制查询深度
createComplexityLimitRule(1000) // 限制复杂度
]
});
坑三:权限控制复杂
REST 里,权限控制相对简单:谁有权限访问哪个 URL 就行。GraphQL 里,权限控制要细到字段级别。
type User {
id: ID!
name: String!
email: String! # 需要登录
phone: String! # 需要登录且是本人
admin: Boolean! # 只有管理员可见
}
解决:在解析器里加权限检查。
const resolvers = {
User: {
email: (parent, args, context) => {
if (!context.user) {
throw new Error('Unauthorized');
}
return parent.email;
},
phone: (parent, args, context) => {
if (!context.user || context.user.id !== parent.id) {
throw new Error('Unauthorized');
}
return parent.phone;
},
admin: (parent, args, context) => {
if (!context.user || !context.user.isAdmin) {
throw new Error('Unauthorized');
}
return parent.admin;
}
}
};
坑四:缓存不友好
REST 可以直接利用 HTTP 缓存,GraphQL 只有一个 endpoint(通常是 /graphql),没法靠 URL 区分不同请求,缓存难做。
解决:
- 使用 Apollo Client 的客户端缓存
- 后端实现查询结果缓存(基于查询字符串)
- 关键数据用 REST,其他用 GraphQL,混合方案
迁移后的效果
| 指标 | REST | GraphQL | 改善 |
|---|---|---|---|
| 平均请求数 | 3 | 1 | 67% |
| 数据传输量 | 50KB | 15KB | 70% |
| API 数量 | 25 | 3 | 88% |
| 前端改需求次数 | 高 | 低 | - |
前端开发效率明显提升,改需求不需要后端改接口,前端自己改查询就行。
什么时候用 GraphQL
适合用的场景:
- 数据需求复杂,关联查询多
- 多端接入,需求差异大
- 前端团队希望控制数据结构
- 实时数据需求强
不适合用的场景:
- 简单 CRUD 操作,REST 够用
- 需要强 HTTP 缓存支持
- 团队没有 GraphQL 经验,学习成本高
- 对延迟极其敏感,GraphQL 解析有开销
写在最后
GraphQL 不是要完全替代 REST,而是提供了一种不同的解决问题的方式。
简单场景用 REST,复杂场景考虑 GraphQL。或者混合:核心 CRUD 用 REST,复杂查询用 GraphQL。
技术选型的核心是匹配需求,不是追新。如果你团队对 REST 很熟,用户量不大,需求也简单,没必要硬上 GraphQL。
这次迁移花了两个月,中间有过反复。但回头看,GraphQL 带来的灵活性确实有价值,特别是对前端开发效率的提升。
版权声明: 本文首发于 指尖魔法屋-GraphQL 架构折腾手记(https://blog.thinkmoon.cn/post/17-graphql-rest-migration/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。