优化GraphQL性能的教程

708 阅读5分钟

缓解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的客户的firstNamelastName 。第二个端点返回给定ID的客户的streetAddress,postcode,citycountry

写一个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性能焦虑时,请深呼吸并退后一步。问问自己这是否真的会成为一个问题。如果它一个问题,请考虑你有什么机制可以解决它,而不影响你的模式。 你的客户会为此感谢你的。