GraphQL是Facebook开源的一种API查询语言,相比REST API,它更灵活、更高效,客户端可以按需获取数据,避免过度获取和获取不足的问题。
2017年,GraphQL越来越流行,很多公司都开始使用GraphQL,我们团队也在用GraphQL做API。但是,很多人只是会用GraphQL的基本功能,定义Schema,写Resolver,就能用了,对一些进阶技巧和最佳实践,了解得并不多,导致GraphQL的性能不好,或者有安全隐患,或者维护困难。
今天就来分享一下GraphQL API的进阶技巧和最佳实践,从基本概念、到进阶技巧、到性能优化、到安全加固、到最佳实践、再到踩坑和经验,详细分享,帮助大家更好地使用GraphQL,构建高性能、高可用、安全的API。
一、GraphQL基本概念回顾
在讲进阶技巧之前,先简单回顾一下GraphQL的基本概念,方便大家理解。
Schema(模式): GraphQL的核心,定义了API的类型、字段、查询、变更、订阅等,是客户端和服务端之间的契约。Schema用GraphQL Schema Definition Language(SDL)定义,比如:
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
}
type Query {
user(id: ID!): User
post(id: ID!): Post
}
type Mutation {
createUser(name: String!, email: String!): User!
createPost(title: String!, content: String!, authorId: ID!): Post!
}Query(查询): 用于获取数据,对应REST的GET请求。客户端可以指定需要的字段,按需获取数据。
Mutation(变更): 用于修改数据,对应REST的POST/PUT/DELETE请求。
Subscription(订阅): 用于实时数据推送,客户端订阅某个事件,当事件发生时,服务端主动推送数据给客户端。
Resolver(解析器): 每个字段都有对应的Resolver,负责返回该字段的数据。Resolver是GraphQL的核心,所有的数据获取逻辑,都在Resolver里实现。
Scalar Type(标量类型): GraphQL内置的标量类型,有String、Int、Float、Boolean、ID,也可以自定义标量类型,比如DateTime、JSON、Email等。
Input Type(输入类型): 用于Mutation的参数,和普通类型类似,但是用input关键字定义。
Interface(接口): 和面向对象的接口类似,定义了一组字段,类型可以实现接口,实现接口的类型必须有接口定义的所有字段。
Union(联合类型): 表示一个字段可以返回多种类型中的一种,比如搜索结果,可以是用户,也可以是文章。
Directive(指令): 用于在Schema或者查询中,添加一些元信息,控制GraphQL的执行行为,比如@skip、@include、@deprecated等,也可以自定义指令。
了解了基本概念之后,我们来讲进阶技巧。
二、进阶技巧
1. 批量查询(Batching)
GraphQL的一个常见问题是N+1查询,比如,查询一个用户的文章列表,然后每篇文章又要查询作者,这样就会有N+1次数据库查询,效率很低。
解决方案:批量查询,把多个查询合并成一个批量查询,减少数据库查询次数。比如,查询文章列表的时候,把所有的作者ID收集起来,然后一次查询出所有的作者,再在内存里组装,这样只需要两次数据库查询。
在GraphQL里,可以用DataLoader来实现批量查询,DataLoader是Facebook开源的一个工具,专门用于解决GraphQL的N+1问题,支持批量加载和缓存。
DataLoader的使用很简单,定义一个批量加载函数,然后用DataLoader包装,在Resolver里调用loader.load(id),DataLoader会自动把多个加载请求合并成一个批量请求,然后返回结果。
const userLoader = new DataLoader(async (ids) => {
const users = await User.findAll({ where: { id: ids } });
return ids.map(id => users.find(user => user.id === id));
});
// 在Resolver里
const postResolver = {
author: (post) => userLoader.load(post.authorId),
};用了DataLoader之后,N+1问题就解决了,数据库查询次数大大减少,性能提升很多。
2. 查询复杂度分析(Query Complexity Analysis)
GraphQL的一个安全隐患是,客户端可以发送非常复杂的查询,比如嵌套很多层,或者查询很多数据,导致服务端压力很大,甚至宕机,也就是所谓的"深度嵌套攻击"或者"过度获取攻击"。
解决方案:查询复杂度分析,给每个字段设置一个复杂度值,然后计算整个查询的复杂度,如果复杂度超过阈值,就拒绝执行,返回错误。
比如,简单的字段复杂度是1,有子字段的字段复杂度是子字段复杂度之和加1,列表字段复杂度乘以列表长度。然后,设置一个最大复杂度,比如1000,超过的查询就拒绝执行。
很多GraphQL库都支持查询复杂度分析,比如graphql-query-complexity,可以很方便地实现。
import { createComplexityLimitRule } from 'graphql-query-complexity';
const complexityLimit = createComplexityLimitRule(1000, {
onComplete: (complexity) => {
console.log('Query complexity:', complexity);
},
});
// 加到validationRules里
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [complexityLimit],
});用了查询复杂度分析之后,就能防止客户端发送过于复杂的查询,保护服务端的安全和稳定。
3. 查询持久化(Persisted Queries)
GraphQL的查询,通常是客户端发送查询字符串给服务端,服务端解析执行。但是,查询字符串可能很大,每次都发送,浪费带宽;而且,服务端每次都要解析查询,浪费CPU。
解决方案:查询持久化,把常用的查询,预先存到服务端,给每个查询一个ID,客户端只需要发送查询ID和变量,不需要发送完整的查询字符串,服务端根据ID取出查询,执行即可。
查询持久化的好处:
- 减少请求体积,节省带宽;
- 服务端不需要每次解析查询,提高性能;
- 可以白名单控制,只允许执行预先定义的查询,提高安全性;
- 可以提前对查询进行分析和优化。
很多GraphQL库都支持查询持久化,比如Apollo Server的Persisted Queries,或者Relay的Persisted Queries。
// 客户端
const query = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
`;
// 自动生成查询ID,发送查询ID和变量
client.query({ query, variables: { id: '1' } });
// 服务端,开启Persisted Queries
const server = new ApolloServer({
typeDefs,
resolvers,
persistedQueries: {
cache: new InMemoryLRUCache({ maxSize: 1000 }),
},
});用了查询持久化之后,请求体积小了,性能提升了,安全性也提高了。
4. 数据加载器(DataLoader)
前面讲批量查询的时候,提到了DataLoader,这里再详细讲一下,因为DataLoader是GraphQL性能优化的核心工具,非常重要。
DataLoader是Facebook开源的一个工具,专门用于解决GraphQL的N+1问题,支持批量加载和缓存。
DataLoader的核心功能:
- 批量加载: 把同一个事件循环里的多个加载请求,合并成一个批量请求,减少数据库查询次数;
- 缓存: 同一个请求里,同一个ID的数据,只加载一次,后续直接从缓存取,避免重复加载;
- 错误处理: 批量加载中的某个ID出错,不会影响其他ID,会单独返回错误。
DataLoader的使用,前面已经举了例子,这里再讲一下最佳实践:
- 每个请求创建新的DataLoader实例: DataLoader的缓存是请求级别的,每个请求都要创建新的实例,不要全局共享,否则会导致数据不一致,或者内存泄漏。
- 按资源类型创建不同的DataLoader: 比如,用户用userLoader,文章用postLoader,评论用commentLoader,不要混在一起,这样更清晰,更易维护。
- 批量加载函数要返回和输入顺序一致的结果: DataLoader要求批量加载函数返回的结果,和输入的ID顺序一致,每个ID对应一个结果,即使是null或者undefined,也要返回,不能少,不能乱序。
- 可以用DataLoader加载非数据库的数据: DataLoader不仅可以加载数据库的数据,还可以加载其他的数据,比如缓存、第三方API、文件等,只要是批量加载的场景,都可以用DataLoader。
- 可以设置缓存键函数: 默认情况下,DataLoader用ID作为缓存键,如果需要自定义缓存键,可以设置cacheKeyFn,比如,根据ID和其他参数生成缓存键。
用好了DataLoader,GraphQL的N+1问题就解决了,性能会提升很多。
5. 缓存(Caching)
缓存是性能优化的重要手段,GraphQL也不例外,但是GraphQL的缓存,和REST的缓存不太一样,因为GraphQL的查询是动态的,同一个接口,可以有不同的查询,不同的字段,所以不能简单地用URL做缓存键。
GraphQL的缓存,主要有以下几个层次:
1. 字段级缓存: 在Resolver里,对单个字段的数据进行缓存,比如,用户信息,缓存到Redis里,下次查询的时候,直接从Redis取,不需要查数据库。字段级缓存,和普通的缓存一样,用资源类型+ID做缓存键,很容易实现。
2. 查询级缓存: 对整个查询的结果进行缓存,用查询字符串+变量做缓存键,下次同样的查询,直接返回缓存的结果,不需要执行Resolver。查询级缓存,适合不经常变化的查询,比如首页数据、配置信息等。但是,要注意缓存的失效,数据更新的时候,要主动清除相关的缓存。
3. 响应缓存: 用HTTP缓存,对GraphQL的响应进行缓存,比如,用Cache-Control头,或者用CDN缓存。但是,GraphQL通常用POST请求,HTTP缓存对POST请求的支持不好,所以响应缓存用得不多,除非用GET请求,或者查询持久化。
4. 客户端缓存: 客户端也可以缓存GraphQL的查询结果,比如,Apollo Client、Relay,都有内置的缓存,会自动缓存查询结果,下次同样的查询,直接从客户端缓存取,不需要发请求,性能更好,用户体验更好。
缓存的最佳实践:
- 优先用字段级缓存,因为粒度细,容易控制,数据一致性好;
- 查询级缓存,适合不经常变化的查询,要注意缓存失效;
- 客户端缓存,一定要开启,能大大提升用户体验;
- 缓存要设置过期时间,或者主动失效,保证数据一致性;
- 注意缓存的防穿透、防击穿、防雪崩。
6. 错误处理(Error Handling)
GraphQL的错误处理,和REST不太一样,GraphQL有自己的错误处理机制,但是很多人用得不好,导致错误信息不清晰,或者错误处理不统一。
GraphQL的错误,主要有两种:
- 解析错误: 查询语法错误,或者字段不存在,或者类型不匹配,这类错误,GraphQL会自动处理,返回错误信息,不会执行Resolver。
- 运行时错误: Resolver执行过程中发生的错误,比如数据库错误、第三方API错误、权限不足等,这类错误,需要我们自己处理。
运行时错误的处理,最佳实践:
- 在Resolver里抛出错误: 发生错误的时候,直接throw一个GraphQL的错误,比如GraphQLError,或者自定义的错误,GraphQL会自动把错误信息放到响应的errors数组里。
import { GraphQLError } from 'graphql';
const resolvers = {
Query: {
user: (parent, { id }) => {
const user = User.findById(id);
if (!user) {
throw new GraphQLError('User not found', {
extensions: { code: 'USER_NOT_FOUND' },
});
}
return user;
},
},
};- 自定义错误类型和错误码: 定义不同的错误类型,比如NotFoundError、UnauthorizedError、ForbiddenError、ValidationError等,每个错误有对应的错误码,方便客户端处理。可以用graphql-errors库,或者自己封装。
- 错误信息要友好,不要泄露敏感信息: 错误信息要清晰、友好,让用户知道发生了什么,怎么处理,但是不要泄露敏感信息,比如数据库错误信息、堆栈跟踪、内部IP等,避免安全隐患。生产环境,要屏蔽详细的错误信息,只返回通用的错误提示。
- 部分错误不影响其他字段: GraphQL的一个优点是,某个字段出错,不会影响其他字段,其他字段正常返回,错误信息放在errors数组里。所以,不要因为一个字段出错,就抛出错误,导致整个查询失败,要尽量让错误局部化,不影响其他字段。
- 用formatError统一处理错误: 很多GraphQL库都支持formatError,可以统一处理错误,比如,记录错误日志,转换错误格式,屏蔽敏感信息等。
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (error) => {
console.error(error);
return {
message: error.message,
code: error.extensions?.code || 'INTERNAL_ERROR',
};
},
});用好了错误处理,GraphQL的API会更健壮,更易用,用户体验也更好。
7. 分页(Pagination)
GraphQL的分页,和REST不太一样,因为GraphQL的列表,可以嵌套,而且客户端可以按需获取字段,所以分页的实现,也有一些讲究。
GraphQL的分页,主要有两种方式:
1. 偏移分页(Offset-based Pagination): 用offset和limit,或者page和pageSize,比如,第一页offset=0,limit=10,第二页offset=10,limit=10。偏移分页,简单易懂,但是有一些缺点,比如,数据量大的时候,offset很大,查询效率低;数据变化的时候,分页结果会重复或者遗漏。
2. 游标分页(Cursor-based Pagination): 用游标(cursor),比如,上一页最后一条数据的ID,作为下一页的游标,查询的时候,从游标之后开始查询。游标分页,效率高,因为可以用索引,而且数据变化的时候,不会重复或者遗漏。但是,实现稍微复杂一些,而且不能跳页。
GraphQL推荐用游标分页,也就是Relay风格的分页,用edges、node、cursor、pageInfo等结构,标准、统一,客户端容易处理。
Relay风格分页的Schema:
type PostEdge {
cursor: String!
node: Post!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type Query {
posts(first: Int, after: String, last: Int, before: String): PostConnection!
}Resolver的实现:
const resolvers = {
Query: {
posts: (parent, { first, after, last, before }) => {
// 实现游标分页逻辑
// 返回edges、pageInfo、totalCount
},
},
};游标分页的最佳实践:
- 用不透明的游标,比如base64编码的ID,不要让客户端知道游标的具体含义;
- 支持first/after和last/before,支持向前和向后分页;
- 返回totalCount,方便客户端知道总条数;
- 返回pageInfo,告诉客户端有没有下一页、上一页,以及开始和结束的游标;
- 列表字段,尽量用分页,不要一次性返回所有数据,避免性能问题。
8. 订阅(Subscription)
GraphQL的订阅,用于实时数据推送,客户端订阅某个事件,当事件发生时,服务端主动推送数据给客户端。订阅,适合实时性要求高的场景,比如聊天、通知、实时数据更新等。
订阅的实现,通常用WebSocket,因为HTTP是请求-响应模式,不适合服务端主动推送。很多GraphQL库都支持订阅,比如Apollo Server的Subscription,用WebSocket实现。
订阅的Schema:
type Subscription {
newPost: Post!
postUpdated(id: ID!): Post!
}Resolver的实现:
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
const resolvers = {
Subscription: {
newPost: {
subscribe: () => pubsub.asyncIterator(['NEW_POST']),
},
},
Mutation: {
createPost: (parent, args) => {
const post = Post.create(args);
pubsub.publish('NEW_POST', { newPost: post });
return post;
},
},
};订阅的最佳实践:
- 订阅的事件,要明确,不要太泛,比如,newPost比postChanged好;
- 订阅可以带参数,比如,postUpdated(id: ID!),只订阅特定文章的更新;
- 要处理订阅的取消,客户端断开连接的时候,要清理资源,避免内存泄漏;
- 生产环境,不要用内存的PubSub,要用支持分布式的PubSub,比如Redis Pub/Sub,因为多实例的时候,内存的PubSub不能跨实例推送;
- 订阅的数据,要和查询的类型一致,方便客户端处理。
9. 权限控制(Authorization)
GraphQL的权限控制,也是一个重要的话题,因为GraphQL的查询很灵活,客户端可以查询任何字段,所以要做好权限控制,避免未授权的用户访问敏感数据。
GraphQL的权限控制,主要有以下几个层次:
1. 字段级权限: 对每个字段,进行权限检查,比如,只有登录用户才能查询用户的邮箱,只有管理员才能查询用户的密码。字段级权限,粒度细,控制精确,但是实现起来比较繁琐,每个字段都要写权限检查。
可以用自定义指令,来实现字段级权限,比如@auth、@hasRole,在Schema里用指令标记需要权限的字段,然后在Resolver里,或者用中间件,统一检查权限。
directive @auth(role: Role) on FIELD_DEFINITION
type User {
id: ID!
name: String!
email: String! @auth(role: USER)
password: String! @auth(role: ADMIN)
}2. 操作级权限: 对整个Query或者Mutation,进行权限检查,比如,只有登录用户才能执行某个Mutation,只有管理员才能执行某个Query。操作级权限,粒度粗一些,但是实现简单,适合大部分场景。
可以在Resolver里,先检查权限,再执行逻辑;或者用中间件,统一检查权限。
3. 数据级权限: 对查询的数据,进行权限过滤,比如,用户只能查询自己的文章,不能查询别人的文章;管理员可以查询所有的文章。数据级权限,需要在Resolver里,根据用户的权限,添加查询条件,过滤数据。
权限控制的最佳实践:
- 不要在客户端做权限控制,一定要在服务端做,因为客户端的权限控制,可以被绕过;
- 权限检查,要尽早,在Resolver执行之前,或者在Resolver的开头,就检查权限,避免不必要的计算;
- 用自定义指令,统一管理权限,不要在每个Resolver里重复写权限检查代码;
- 敏感字段,一定要做权限控制,比如密码、手机号、邮箱等,不要让未授权的用户查询;
- 权限错误,要返回明确的错误信息,比如401 Unauthorized、403 Forbidden,方便客户端处理。
三、性能优化
除了上面的进阶技巧,还有一些性能优化的方法,在这里总结一下:
1. 避免N+1查询: 用DataLoader,批量加载数据,解决N+1问题,这是GraphQL性能优化最重要的一点。
2. 只查询需要的字段: 客户端要按需获取数据,不要查询不需要的字段,减少数据传输和计算。服务端也要注意,不要在Resolver里查询不需要的数据。
3. 用缓存: 字段级缓存、查询级缓存、客户端缓存,都要用起来,减少数据库查询,提高响应速度。
4. 限制查询深度和复杂度: 用查询深度限制和查询复杂度分析,防止客户端发送过于复杂的查询,保护服务端。
5. 批量操作: Mutation的批量操作,要用批量INSERT、批量UPDATE,不要循环里一条一条操作,减少数据库交互次数。
6. 异步化: 耗时的操作,比如发送邮件、生成报表、调用第三方API,要异步化,用消息队列,不要同步阻塞,提高响应速度。
7. 数据库优化: 添加合适的索引,优化SQL语句,数据库结构优化,配置优化,读写分离,这些都能提升数据库的性能,从而提升GraphQL的性能。
8. 连接池: 数据库连接、Redis连接、HTTP连接,都要用连接池,避免频繁创建和销毁连接,提高性能。
9. 监控和分析: 监控GraphQL的性能,比如,每个查询的响应时间、每个Resolver的执行时间、数据库查询次数、缓存命中率等,找出性能瓶颈,针对性优化。
四、安全加固
GraphQL的安全,也很重要,因为GraphQL的查询很灵活,如果不做好安全加固,很容易被攻击。
安全加固的要点:
1. 身份认证: 对用户进行身份认证,比如,JWT、Session、OAuth等,确保只有登录用户才能访问API。
2. 权限控制: 前面讲过,字段级、操作级、数据级权限控制,确保用户只能访问有权限的数据。
3. 查询深度限制: 限制查询的最大深度,比如,最多嵌套10层,防止深度嵌套攻击。
4. 查询复杂度分析: 前面讲过,限制查询的最大复杂度,防止过度获取攻击。
5. 频率限制: 对每个用户,或者每个IP,进行频率限制,比如,每分钟最多100次请求,防止暴力攻击和DDoS攻击。
6. 输入验证: 对客户端的输入,进行严格的验证,比如,类型、长度、格式、范围等,防止注入攻击、XSS攻击等。
7. 错误信息脱敏: 生产环境,错误信息要脱敏,不要泄露敏感信息,比如数据库错误、堆栈跟踪、内部IP等。
8. 禁用内省: 生产环境,禁用GraphQL的内省(introspection),不要让客户端获取Schema,避免攻击者了解API的结构,进行针对性攻击。
9. HTTPS: 一定要用HTTPS,加密传输,防止数据被窃听、篡改。
10. 查询持久化+白名单: 用查询持久化,只允许执行预先定义的查询,白名单控制,提高安全性。
五、最佳实践
最后,总结一下GraphQL的最佳实践:
- Schema设计要合理: 类型、字段、查询、变更,要设计合理,命名清晰,语义明确,不要太复杂,也不要太简单。
- 用SDL定义Schema: 用GraphQL Schema Definition Language定义Schema,清晰、易读、易维护,不要用代码定义Schema。
- Resolver要薄,业务逻辑要分离: Resolver只负责参数处理和调用业务逻辑,不要把业务逻辑都写在Resolver里,要把业务逻辑分离到Service层,方便复用和测试。
- 用DataLoader解决N+1问题: 这是必须的,只要有嵌套的列表字段,就要用DataLoader,避免N+1查询。
- 做好错误处理: 统一错误处理,错误信息友好,不泄露敏感信息,部分错误不影响其他字段。
- 做好权限控制: 字段级、操作级、数据级权限控制,确保数据安全。
- 做好性能优化: 缓存、批量查询、异步化、数据库优化,提升性能。
- 做好安全加固: 身份认证、频率限制、输入验证、查询深度和复杂度限制、禁用内省、HTTPS,确保安全。
- 添加版本控制: Schema的变更,要向后兼容,不要破坏性变更,如果要破坏性变更,要升级版本,或者用@deprecated标记,给客户端过渡时间。
- 完善文档: Schema要加注释,说明每个类型、字段、参数的含义,方便客户端使用。可以用GraphQL Playground或者GraphiQL,提供交互式文档。
- 测试: 对Schema、Resolver、业务逻辑,进行单元测试、集成测试,确保功能正确,性能达标。
- 监控和日志: 监控GraphQL的性能、错误、使用率,记录日志,方便排查问题和优化。
写在最后
GraphQL API进阶:这些技巧你可能不知道。
GraphQL是一种强大的API查询语言,相比REST API,更灵活、更高效,但是要用好它,也需要掌握一些进阶技巧和最佳实践,才能充分发挥它的优势,避免踩坑。
本文从基本概念、到进阶技巧(批量查询、查询复杂度分析、查询持久化、DataLoader、缓存、错误处理、分页、订阅、权限控制)、到性能优化、到安全加固、再到最佳实践,详细分享了GraphQL API的进阶技巧和最佳实践,希望能帮助大家更好地使用GraphQL,构建高性能、高可用、安全的API。
GraphQL虽然强大,但是也不是银弹,不是所有的场景都适合用GraphQL,要根据实际的业务场景,选择合适的API技术。如果是简单的CRUD,REST可能更合适;如果是复杂的查询,客户端需要按需获取数据,GraphQL可能更合适。
最后,用一句话结尾:
"GraphQL是一把双刃剑,用好了,能大大提升开发效率和用户体验;用不好,可能会有性能和安全问题。掌握进阶技巧和最佳实践,才能用好GraphQL,发挥它的最大价值。"
祝大家都能用好GraphQL,构建高性能、高可用、安全的API!
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录