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。

这个样例比较粗糙,但在这个界面上发送了以下两个GraphQL查询。
-
- 上段部分: ユーザー情報を含むヘッダー相当。no-cache
- 下段部分: 商品ランキング相当。cache-control max-ageあり
另外,可以看到HTTP请求的详细信息如下,我们可以看到发送的不是GraphQL查询本体,而是sha256的哈希值。

如果您对细节感兴趣,实际的源代码可以在 https://github.com/Quramy/apollo-sandbox/tree/for-persisted-query-entry 找到,请随时参考。
对于Persisted Query相关的要点,可以用以下方式进行概述:
-
- 通过apollo-tooling的 apollo client:extract 命令,生成一个manifest文件,其中包含每个查询的sha256和查询内容的对应关系。
-
- 在客户端构建时,通过类似的逻辑计算查询内容的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时,无论如何,静态分析工具的质量都将产生重大影响。
对于这一点,我想更深入地研究,包括选择工具。