GraphQL和持久查询

最近有很多机会考虑在互联网上公开GraphQL API应该注意什么,我利用空闲时间小心翼翼地进行摸索。

我想写一下今天有关在GraphQL客户端和服务器之间插入类似反向代理功能的内容。

我想做的事情

1. 排除不必要的查询

假设我们使用GraphQL实现了像电子商务(EC)或媒体网站这样的服务的Web API层,即使未登录也可以浏览信息。不论是电子商务还是媒体,为了提高详细页面的回流率,将详细页面相互关联的模式设计是很自然的事情。

如果使用GraphQL的模式定义来写,就像下面这样的形象。

type Product {
  id: ID!
  name: String!
  relatedProducts: [Product]
}

type Query {
  product(id: ID!): Product
}

当提供了这个模式后,根据GraphQL的语法,客户端可以构建任意深度的嵌套查询。

query {
  product(id: "001") {
    relatedProducts {
      relatedProducts {
        relatedProducts {
          relatedProducts {
            # ...
          }
        }
      }
    }
  }
}

实际上,服务器端(解析器)的实现方式取决于具体情况。但在实际服务中,并不会出现像这样深度嵌套获取相关产品的场景。

然而,如果被有恶意的人执行这样的查询,可能会占用API和数据库资源,并导致整个服务变慢的风险存在。

嵌套的情况可以采取个别的措施,比如在解析器中设置阈值并引发错误。但从更通用的角度考虑,我们希望”只处理开发者批准的查询”。

2. HTTP缓存

比如,我们来考虑热门商品和热门文章的排名。通常,这些排名是批量确定的,不会实时变动,商品信息也是如此。

如果是这样的“无论谁看都是相同内容的数据”的查询,我们宁愿将其缓存到CDN(边缘服务器)等地,而不必麻烦地获取实际的API。这不仅可以避免浪费服务器端资源,还可以提高终端用户的性能,只要能在CDN上回送请求即可。

在GraphQL的背景下,提到缓存时,不仅考虑到图中每个节点数据的缓存(这也是重要的),但这篇文章将仅限于讨论所谓的HTTP缓存。

解决方案:持续查询

通常情况下,GraphQL的查询以以下JSON body的形式通过单一的HTTP端点进行POST请求来实现。

{
  variables: {
    id: "001",
  },
  query: `
    query FindProduct($id: String!){
      product(id: $id) {
        name
      }
    }
  `,
}

然而,如果继续保持现状,以下将成为问题所在。

    • クライアントがクエリを自由に指定できてしまう。想定しないクエリを防げない

 

    POSTだとHTTPキャッシュに乗せられない

因此,我们来考虑以下的方法。

    • クライアントの開発完了時に、クエリ本文からhashを計算し、hash値と元となったクエリをサーバーに教えておく

 

    • ランタイムでは計算したハッシュとvariablesを送信

 

    サーバーは送信されたハッシュからクエリを復元してGraphQLのAPIを実行する。送信されたhashが既知でなければリクエストを棄却する

通过使用哈希值,可以防止第三方执行任意查询。

此外,如果在服务器端添加适当的Cache-Control头部,可以通过将HTTP方法设置为GET,将内容放入CDN或浏览器缓存中。

GET /graphql?variables=...&query_hash=17159db0bc

出于这样的方式来执行GraphQL的客户端-服务器通信被称为持久化查询(Persisted Query),因此在这篇文章中也会使用这个术语。

实施示例 lì)

我稍微动手实现了一下。顺便说一句,我没有使用Apollo Engine和Relay Compiler。

chrome_capt.png

这个样例比较粗糙,但在这个界面上发送了以下两个GraphQL查询。

    • 上段部分: ユーザー情報を含むヘッダー相当。no-cache

 

    下段部分: 商品ランキング相当。cache-control max-ageあり

另外,可以看到HTTP请求的详细信息如下,我们可以看到发送的不是GraphQL查询本体,而是sha256的哈希值。

req_detail.png

如果您对细节感兴趣,实际的源代码可以在 https://github.com/Quramy/apollo-sandbox/tree/for-persisted-query-entry 找到,请随时参考。

对于Persisted Query相关的要点,可以用以下方式进行概述:

    1. 通过apollo-tooling的 apollo client:extract 命令,生成一个manifest文件,其中包含每个查询的sha256和查询内容的对应关系。

 

    1. 在客户端构建时,通过类似的逻辑计算查询内容的sha256,并传递给apollo-link-persisted-queries。同时,在这个link的配置中,将fetch方法更改为GET请求。

构建时的sha256计算是通过使用custom TypeScript transformer对TypeScript的gql标签进行操作来实现的(https://github.com/Quramy/ts-transform-graphql-tag)。
如果是喜欢.graphql文件的人,可以创建一个webpack loader来处理。

通过读取生成的manifest文件中的hash,实现一个反向代理,用于从hash中恢复查询内容。

此外,在与缓存控制相关的方面,我们使用了apollo-server框架的 @cacheControl 指令来在API的主体部分附加用于缓存控制的扩展信息到响应体中,然后在反向代理层中获取max-age的最小值,并将其附加到HTTP响应头中。

export const typeDefs = gql`
  type Product {
    id: ID!
    name: String!
    description: String
    price: Int!
  }
  type ProductConnection {
    nodes: [Product!]! @cacheControl(maxAge: 300)
    totalCount: Int!
  }
  type User {
    id: ID!
    name: String!
    wishlist: ProductConnection!
  }
  type Query {
    user: User!
    ranking: ProductConnection! @cacheControl(maxAge: 300)
    product(id: ID!): Product @cacheControl(maxAge: 3600)
  }
`;

最后

尽管 cacheControl 部分依赖于apollo的功能,但是我们成功实现了基于Persisted Query的HTTP缓存机制和查询保护机制,而无需使用特别的服务如Apollo Engine。

这次我们使用express来实现了Reverse Proxy,但是在基础设施方面,你可以根据服务的基础架构选择你喜欢的平台,比如Lambda或者Cloud Functions。而且针对CDN,只需要查看URL和Cache-Control就好,所以它应该能够与大多数产品搭配使用。

另外,由于可以在URL上保留不仅仅是哈希值还有查询名称,所以在nginx或者ALB层的日志监控中,似乎也可以收集一定程度上的按查询统计的运行情况。

从某种程度上来说,我觉得在构建方面确实存在着相当的复杂性。
实际上,在编写自定义转换器的过程中,我发现不得不在GraphQL的AST中编写访问器,如果在处理这些方面出了差错,反向代理将会崩溃。
这并不仅限于持久化查询,但是在接触GraphQL时,无论如何,静态分析工具的质量都将产生重大影响。
对于这一点,我想更深入地研究,包括选择工具。

截止到2019年2月,Apollo Engine的代理功能已经被宣布为废弃,因此我不太愿意使用它。
广告
将在 10 秒后关闭
bannerAds