缓解GraphQL性能焦虑症
学习如何编写GraphQL服务器是我见过的那些GraphQL新手的最大挑战之一。 这是因为它需要改变思维方式,特别是如果你习惯于编写REST服务器。具体来说,你必须考虑实现整个GraphQL模式,以任何方式进行查询,而不是孤立地实现单个端点。
这种对灵活性的需求会让一些开发者感到焦虑,尤其是在涉及到性能的时候。为了解决这些问题,一些开发者会惊慌失措,先发制人地降低GraphQL模式的灵活性,以应对预期的问题。但这样做,他们就开始否定了首先使用GraphQL的主要好处:能够在像互联网一样缓慢和滞后的网络上灵活和有效地访问数据。如果走到极端,开发者甚至会出现这样的情况:他们支付了使用GraphQL的所有额外费用,却没有获得任何好处。
确实,严格来说,编写GraphQL服务器的最简单方法不一定是最有效的,特别是与编写同等的REST服务器相比。这是使用GraphQL的部分权衡。然而,在大多数情况下,这种性能差异对最终用户来说并不明显,这也是事实。此外,即使发现有实际的性能问题,也有很多机制可以处理它,而不影响你的模式。
在这篇文章中,我将向你展示一个例子,说明GraphQL服务器开发者常见的性能焦虑的来源,以及它如何欺骗他们,使他们认为他们必须对他们的模式进行调整来处理它。然后,我将介绍一种替代的方法,它可以实现两全其美--一个高性能的解决方案和 最简单的模式。在这种情况下,我将使用Apollo服务器来编写服务器。我假设你对GraphQL和Apollo Server都有一些了解。
一个例子
想象一下,你的数据图需要能够包含你的客户的名字和地址的信息。在这种情况下,满足这些要求的简单GraphQL模式可能看起来像这样。
import gql from "graphql"
const typeDefs = gql`{
type Customer {
id: ID!
firstName: String
lastName: String
streetAddress: String
postcode: String
city: String
country: String
}
type Query {
customer(id: ID!): Customer
}
}`
...
给定一个特定的客户ID,这个模式可以让我们查询到我们需要的关于客户的所有信息。到目前为止,情况不错。
然而,想象一下,当需要实现一个能够满足这个模式的服务器时,我们发现实际上有两个不同的REST端点来获取数据。
/customers/{customerId}/customers/{customerId}/address
第一个端点返回具有给定ID的客户的firstName 和lastName 。第二个端点返回给定ID的客户的streetAddress,postcode,city 和country 。
写一个Apollo就很容易了 [RESTDataSource](https://www.apollographql.com/docs/apollo-server/data/data-sources/#restdatasource-reference)从REST端点为我们获取这些数据。
import { RESTDataSource } from "apollo-datasource-rest"
...
class CustomersDataSource extends RESTDataSource {
constructor() {
super()
this.baseUrl = 'https://customers.example.com'
}
findById(id) {
return this.get(`/customers/${id}`)
}
findAddressById(id) {
return this.get(`/customers/${id}/address`)
}
}
...
然后我们可以写一个解析器来获取所有这些数据。
...
const resolvers = {
Query: {
async customer(_, { id }, { dataSources }) {
return {
id,
...(await dataSources.customers.findById(id)),
...(await dataSources.customers.findAddressById(id))
}
}
}
}
...
并将模式、数据源和解析器打包成一个简单的Apollo服务器实例。
...
const server = new ApolloServer({
typeDefs,
resolvers,
dataSources: () => ({
customers: new CustomersDataSource()
})
});
// Launch the server
server.listen().then(({ url }) => {
console.log(`🚀 Server ready at ${url}`);
});
然而,大多数开发者很快就会发现,这种实现并不是特别好的。它将调用findAddressById ,即使用户实际上并没有询问任何地址字段。因此,如果我运行这个查询。
query GetCustomerName($id: ID!) {
customer(id: $id) {
firstName
lastName
}
}
那么服务器仍然会调用/customers/{customerId}/address端点,尽管我们实际上没有使用该端点的任何数据。
面对这种情况,许多刚接触GraphQL的开发者会认为,他们能够解决这个问题的唯一方法就是调整他们的GraphQL模式。通常,他们决定为地址创建一个单独的新类型,以便它更接近于REST端点的结构。
type Customer {
id: ID!
firstName: String
lastName: String
address: Address
}
type Address {
streetAddress: String
postcode: String
city: String
country: String
}
type Query {
customer(id: ID!): Customer
}
然后,这个模式可以通过为Customer 类型上的address 字段添加一个自定义解析器来实现。
export resolvers = {
Query: {
async customer(_, { id }, { dataSources }) {
return {
id,
...(await dataSources.customer.findById(id))
}
}
},
Customer: {
address(customer, _, {dataSources}) {
return dataSources.customer.findAddressById(customer.id)
}
}
}
(如果你是编写自定义解析器的新手,对这里发生的事情有点模糊不清,我建议你阅读Apollo服务器解析器的文档)
所以现在GetCustomerName 查询不会触发对/customers/{customerId}/address的调用。它将只调用/customers/{customerId}。
要真正获得地址信息,你必须运行这样的查询。
query GetCustomerNameAndAddress($id: ID!) {
customer(id: $id) {
firstName
lastName
address {
streetAddress
postcode
city
country
}
}
}
问题解决了,是吗?嗯,我想是的。但为了解决这个问题,我们不得不调整我们的GraphQL模式。这打破了原则性的GraphQL概念,即拥有一个抽象的、以需求为导向的模式。换句话说,我们让底层REST API服务的细节决定了我们模式的形状。其结果是一个比它需要的更复杂的模式。此外,当我们试图将越来越多的服务统一到一个模式后面时,这个问题就会变得更加复杂,因为每个服务都不可避免地有自己的怪癖和做事的方式。GraphQL的目标是向客户隐藏所有这些差异,但我们在这里做的恰恰相反。
该怎么做
那么,我们如何才能获得两全其美的东西:一个简单的模式和良好的性能?好吧,让我们恢复到我们的原始模式,其中地址字段直接在Customer 类型上。然后,我们将为每个地址字段编写自定义解析器。
export resolvers = {
Query: {
customer(_, { id }, { dataSources }) {
return {
id,
...dataSources.customer.findById(id)
}
}
},
Customer: {
streetAddress(customer, _, {dataSources}) {
return dataSources.customer.findAddressById(customer.id).streetAddress
}
postcode(customer, _, {dataSources}) {
return dataSources.customer.findAddressById(customer.id).postcode
}
city(customer, _, {dataSources}) {
return dataSources.customer.findAddressById(customer.id).city
}
country(customer, _, {dataSources}) {
return dataSources.customer.findAddressById(customer.id).country
}
}
}
乍一看,这可能是一个潜在的更糟糕的解决方案。如果你在一次查询中要求一个以上的地址字段,它不会重复调用/customers/{customerId}/address端点吗?
简而言之,答案是否定的。这是因为RESTDataSource使用一个内存缓存来存储过去的检索结果。这个缓存的时间与每个传入的GraphQL请求一样长(这就是为什么我们提供给ApolloServer 构造函数的dataSources 参数是一个函数--它在每个传入的请求中被重新调用),但这对于我们所需要的来说已经足够长了。
所以现在,我们可以运行查询了:
query GetCustomerNameAndAddress($id: ID!) {
customer(id: $id) {
firstName
lastName
streetAddress
postcode
city
country
}
}
而/customers/{customerId}/address将只在第一个地址字段被解析时被调用。当其他三个被解决时,将使用缓存的值。
这正是Apollo数据源的设计目的。然而,这可能不是很明显或直观的,特别是如果你过去曾使用过REST服务器。像RESTDataSource 这样的工具让我们把解决方案推回服务器,并按我们的意愿保留模式,但我们必须知道如何使用它。
在这种情况下,要在模式的复杂性、实现的复杂性和性能之间进行权衡。模式比较简单,但是为了提高性能,实现就比较复杂了。然而,这是正确的权衡,因为有许多潜在的客户受到模式设计的影响,但只有一台服务器需要以高性能的方式来实现该设计。因此,把复杂性放在服务器上是合理的。
总结
我在这里介绍的例子并不是我所看到的唯一类型的GraphQL性能焦虑。我见过的开发者非常担心流氓式深度查询的可能性,以至于他们从模式中删除了双向的父/子关系,而选择了每种类型的两种变化--一种是访问父对象,另一种是访问子对象。这让我很奇怪,为什么他们一开始要使用GraphQL而不是REST。最可悲的是,你可以用许多缓解措施来阻止流氓查询,而不影响你的模式。如果GitHub能够设法保护他们的公共GraphQL API而不破坏模式,那么你也可以。
现在,我已经花了很多年时间来构建GraphQL服务器,如果我的团队中有人提出担心某个特定的模式设计将无法执行,我会告诉他们不要担心。相反,我说我有信心,一旦我们得到了我们想要的模式,那么我们就能找到一种方法来实现它,使其满足我们的性能需求。
因此,当你下次发现自己遇到GraphQL性能焦虑时,请深呼吸并退后一步。问问自己这是否真的会成为一个问题。如果它是一个问题,请考虑你有什么机制可以解决它,而不影响你的模式。 你的客户会为此感谢你的。