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的使用,前面已经举了例子,这里再讲一下最佳实践:

  1. 每个请求创建新的DataLoader实例: DataLoader的缓存是请求级别的,每个请求都要创建新的实例,不要全局共享,否则会导致数据不一致,或者内存泄漏。
  1. 按资源类型创建不同的DataLoader: 比如,用户用userLoader,文章用postLoader,评论用commentLoader,不要混在一起,这样更清晰,更易维护。
  1. 批量加载函数要返回和输入顺序一致的结果: DataLoader要求批量加载函数返回的结果,和输入的ID顺序一致,每个ID对应一个结果,即使是null或者undefined,也要返回,不能少,不能乱序。
  1. 可以用DataLoader加载非数据库的数据: DataLoader不仅可以加载数据库的数据,还可以加载其他的数据,比如缓存、第三方API、文件等,只要是批量加载的场景,都可以用DataLoader。
  1. 可以设置缓存键函数: 默认情况下,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错误、权限不足等,这类错误,需要我们自己处理。

运行时错误的处理,最佳实践:

  1. 在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;
    },
  },
};
  1. 自定义错误类型和错误码: 定义不同的错误类型,比如NotFoundError、UnauthorizedError、ForbiddenError、ValidationError等,每个错误有对应的错误码,方便客户端处理。可以用graphql-errors库,或者自己封装。
  1. 错误信息要友好,不要泄露敏感信息: 错误信息要清晰、友好,让用户知道发生了什么,怎么处理,但是不要泄露敏感信息,比如数据库错误信息、堆栈跟踪、内部IP等,避免安全隐患。生产环境,要屏蔽详细的错误信息,只返回通用的错误提示。
  1. 部分错误不影响其他字段: GraphQL的一个优点是,某个字段出错,不会影响其他字段,其他字段正常返回,错误信息放在errors数组里。所以,不要因为一个字段出错,就抛出错误,导致整个查询失败,要尽量让错误局部化,不影响其他字段。
  1. 用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的最佳实践:

  1. Schema设计要合理: 类型、字段、查询、变更,要设计合理,命名清晰,语义明确,不要太复杂,也不要太简单。
  1. 用SDL定义Schema: 用GraphQL Schema Definition Language定义Schema,清晰、易读、易维护,不要用代码定义Schema。
  1. Resolver要薄,业务逻辑要分离: Resolver只负责参数处理和调用业务逻辑,不要把业务逻辑都写在Resolver里,要把业务逻辑分离到Service层,方便复用和测试。
  1. 用DataLoader解决N+1问题: 这是必须的,只要有嵌套的列表字段,就要用DataLoader,避免N+1查询。
  1. 做好错误处理: 统一错误处理,错误信息友好,不泄露敏感信息,部分错误不影响其他字段。
  1. 做好权限控制: 字段级、操作级、数据级权限控制,确保数据安全。
  1. 做好性能优化: 缓存、批量查询、异步化、数据库优化,提升性能。
  1. 做好安全加固: 身份认证、频率限制、输入验证、查询深度和复杂度限制、禁用内省、HTTPS,确保安全。
  1. 添加版本控制: Schema的变更,要向后兼容,不要破坏性变更,如果要破坏性变更,要升级版本,或者用@deprecated标记,给客户端过渡时间。
  1. 完善文档: Schema要加注释,说明每个类型、字段、参数的含义,方便客户端使用。可以用GraphQL Playground或者GraphiQL,提供交互式文档。
  1. 测试: 对Schema、Resolver、业务逻辑,进行单元测试、集成测试,确保功能正确,性能达标。
  1. 监控和日志: 监控GraphQL的性能、错误、使用率,记录日志,方便排查问题和优化。

写在最后

GraphQL API进阶:这些技巧你可能不知道。

GraphQL是一种强大的API查询语言,相比REST API,更灵活、更高效,但是要用好它,也需要掌握一些进阶技巧和最佳实践,才能充分发挥它的优势,避免踩坑。

本文从基本概念、到进阶技巧(批量查询、查询复杂度分析、查询持久化、DataLoader、缓存、错误处理、分页、订阅、权限控制)、到性能优化、到安全加固、再到最佳实践,详细分享了GraphQL API的进阶技巧和最佳实践,希望能帮助大家更好地使用GraphQL,构建高性能、高可用、安全的API。

GraphQL虽然强大,但是也不是银弹,不是所有的场景都适合用GraphQL,要根据实际的业务场景,选择合适的API技术。如果是简单的CRUD,REST可能更合适;如果是复杂的查询,客户端需要按需获取数据,GraphQL可能更合适。

最后,用一句话结尾:

"GraphQL是一把双刃剑,用好了,能大大提升开发效率和用户体验;用不好,可能会有性能和安全问题。掌握进阶技巧和最佳实践,才能用好GraphQL,发挥它的最大价值。"

祝大家都能用好GraphQL,构建高性能、高可用、安全的API!