查询计划
了解你的路由器如何协调子图间的操作
了解查询计划 以帮助你调试 Apollo Federation 的高级用法。
当你的 路由器 接收到一个进入的 GraphQL 操作 时,它需要确定如何使用你的 子图 来填充数据为每个操作的 字段。要做到这一点,路由器生成一个 查询计划:
一个 查询计划 是将单个进入的操作分解为多个可由单个 子图 解决的操作的蓝图。其中一些操作依赖于其他操作的结果,因此查询计划还定义了它们执行所需的任何排序。
示例图
假设我们的联邦 超级图 包含以下这些 子图:
type Hotel @key(fields: "id") {id: ID!address: String!}type Query {hotels: [Hotel!]!}
type Hotel @key(fields: "id") {id: ID! @externalreviews: [Review!]!}type Review {id: ID!rating: Int!description: String!}
基于这些子图,客户可以对我们的路由器执行以下查询:
query GetHotels {hotels { # Resolved by Hotels subgraphidaddressreviews { # Resolved by Reviews subgraphrating}}}
此查询包括来自酒店子图和评论子图的字段。因此,路由器需要向每个子图发送至少一个查询,以填充所有请求的字段。
查看此查询的路由器查询计划:
这种语法可能看起来很复杂。🤔 让我们来拆解它。
查询计划的构架
一个查询计划被定义为一组节点的层次结构,当序列化成JSON或GraphQL文档时看起来像。
每个查询计划的最高层是这样的QueryPlan
节点:
QueryPlan {...}
在QueryPlan
节点内定义的每个节点是以下之一:
节点 | 描述 |
---|---|
Fetch | 通知路由器在某个子图上执行特定操作。 |
Parallel | 通知路由器节点的直接子节点可以并行执行。 |
Sequence | 通知路由器节点直接的子节点必须按照列表中的顺序依次执行。 |
Flatten | 通知路由器将节点子节点的数据与当前Sequence中先前返回的数据合并。 |
Defer | 通知路由器在同一级别的嵌套中的一个或多个@defered字段块。该节点包含一个主块和一个延迟块数组。 |
Skip /Include | 通知路由器将查询计划分成两个可以在运行时改变的可能的路径。 |
以下对每个选项做了更详细的描述。
Fetch
节点
一个Fetch
节点告诉路由器在特定的子图上执行特定的GraphQL操作。每个查询计划至少包含一个Fetch
节点。
# Executes the query shown on the "books" subgraphFetch(service: "books") {{books {titleauthor}}},
节点的主体是要执行的操作,其service
参数指明要对哪个子图执行操作。
在我们的示例图中,以下查询只从Hotels子图中获取数据:
query GetHotels {hotels {idaddress}}
因为这个操作不需要在多个子图之间协调操作,所以整个查询计划中只包含一个Fetch
节点:
QueryPlan {Fetch(service: "hotels") {{hotels {idaddress}}},}
当Fetch
节点在跨子图解析实体引用时,它会使用特殊的语法。有关详情,请参阅使用Flatten
解析引用。
Parallel
节点
一个Parallel
节点告诉路由器该节点的所有直接子节点可以并行执行。此节点出现在查询计划中,当路由器可以在不同的子图上执行完全独立的操作时。
Parallel {Fetch(...) {...},Fetch(...) {...},...}
例如,假设我们的联邦图有一个Books子图和一个Movies 子图。假设客户端执行以下查询来获取书籍和电影的两个单独列表:
query GetBooksAndMovies {books {idtitle}movies {idtitle}}
在这种情况下,每个子图返回的数据不依赖于其他子图返回的数据。因此,路由器可以并行查询这两个子图。
操作的查询计划看起来是这样:
Sequence
节点
一个Sequence
节点告诉路由器节点的前驱子节点必须按列表顺序依次执行。
Sequence {Fetch(...) {...},Flatten(...) {Fetch(...) {...}},...}
该节点出现在查询计划中,当一个子图的响应依赖于另一个子图首先返回的数据时。这种情况最常见于查询请求跨多个子图定义的实体的字段时。
以一个例子来说,我们可以回到我们GetHotels
查询,它来自我们的示例图:
query GetHotels {hotels { # Resolved by Hotels subgraphidaddressreviews { # Resolved by Reviews subgraphrating}}}
在我们的示例图中,Hotel
类型是一个实体。Hotel.id
和Hotel.address
由Hotels子图解析,但Hotel.reviews
由Reviews子图解析。我们的Hotels子图需要首先解析,因为否则Reviews子图不知道为哪些酒店返回评论。
操作的查询计划看起来是这样:
如图所示,此查询计划定义了一个Sequence
,在执行Reviews子图的Fetch
操作之前,首先在Hotels子图上执行。(我们将在下文中介绍Flatten
节点和第二个Fetch
操作的特殊语法。)
Flatten
节点
一个Flatten
节点始终出现在一个Sequence
节点内部,并且它始终包含一个Fetch
节点。它告诉路由器将其Fetch
节点返回的数据与之前在当前Sequence
期间Fetch
获取的数据合并:
Flatten(path: "hotels.@") {Fetch(service: "reviews") {...}}
Flatten
节点的path
参数告诉路由器在哪里将新返回的数据与现有数据合并。@
元素在path
中表示前面的路径元素返回一个列表。
在上面的示例中,Flatten
's Fetch
返回的数据被添加到Sequence
's 存在于hotels
列表字段中的existing data。
扩展示例
再次回到我们的GetHotels
查询的示例图:
query GetHotels {hotels { # Resolved by Hotels subgraphidaddressreviews { # Resolved by Reviews subgraphrating}}}
此操作的查询计划首先指示路由器在Hotels子图上执行此查询:
{hotels {idaddress__typename # The router requests this to resolve references (see below)}}
到目前为止,我们还需要每个酒店的评论相关信息。查询计划接下来指示路由器查询Reviews子图,列出具有以下结构的Hotel
对象:
{reviews {rating}}
现在,路由器需要知道如何将这些Hotel
对象与它已经从Hotels子图中获取的数据合并。Flatten
节点的path
参数刚好告诉它这一点:
Flatten(path: "hotels.@") {...}
换句话说,“将Reviews子图返回的Hotel
对象与顶层hotels
字段返回的第一个查询中的Hotel
对象合并。”
当路由器完成此合并时,结果数据与客户端原始查询的结构完全一致:
{hotels {idaddressreviews {rating}}}
使用 Flatten
解析引用
与序列节点类似,Flatten
节点在任何子图响应依赖于必须由另一个子图先返回的数据时出现。这几乎总是涉及解决定义在多个子图中的实体字段。
在这些情况下,Flatten
节点的 Fetch
需要在检索实体的字段之前解决对实体的引用。在这种情况下,Fetch
节点使用特殊语法:
Flatten(path: "hotels.@") {Fetch(service: "reviews") {{... on Hotel {_typenameid}} =>{... on Hotel {reviews {rating}}}},}
与包含 GraphQL 操作不同,这个 Fetch
节点包含两个 GraphQL 片段,由 =>
分隔。
- 第一个 片段是正在解决的实体(在这种情况下,
Hotel
)的表示。了解更多关于实体表示的信息。 - 第二个片段包含路由器需要子图解决(在这种情况下,
Hotel.reviews
和Review.rating
)的实体字段和子字段。
当路由器看到这个特殊的 Fetch
语法时,它知道要查询子图的 Query._entities
字段。此字段使得子图能够直接访问实体的任何可用字段。
现在你已经了解了每个查询计划节点,请再次查看 示例图中的示例查询计划,以了解这些节点如何在一个完整的查询计划中一起工作。
延迟节点
一个 Defer
节点对应于查询计划中同一嵌套层级的多个 @defer
。
此节点包含一个 主块和一系列 延迟块。主块表示不延迟的查询部分。每个延迟块对应于查询的一个延迟部分。
阅读更多关于如何在 @defer 的路由器支持文章。
QueryPlan {Defer {Primary {Fetch(...) {}}, [Deferred(...) {Flatten(...) {Fetch(...) {}}}]}}
条件节点
一个 Skip
或 Include
节点将查询计划分为 if-else 分支。当操作包含 @skip
或 @include
指令时,会使用条件节点,以便查询计划可以根据提供的运行时 变量选择不同的节点。
QueryPlan {Sequence {Fetch(...) {}Include(...) {Flatten(...) {Fetch(...) { }}}}}
QueryPlan {Sequence {Fetch(...) {}Skip(...) {Flatten(...) {Fetch(...) { }}}}}
查看查询计划
您可以通过以下任何方式查看特定操作的查询计划:
- 在GraphOS Studio资源管理器中
- 注意,必须将您的联邦图在GraphOS中注册,以在资源管理器中查看查询计划。
- 作为来自
@apollo/gateway
库的直接输出(见下文)
以标题输出查询计划
自从Apollo Router Corev0.16.0+ 和 @apollo/gatewayv2.5.4+起,您可以在GraphQL响应extensions中返回以下头信息来包含查询计划:
- 包括
Apollo-Query-Plan-Experimental
头信息会将查询计划放在响应扩展中返回 - 另外,包括带有一个支持的选项的
Apollo-Query-Plan-Experimental-Format
头,将改变输出的格式:- 值
prettified
会返回人性化的查询计划字符串 - 值
internal
会返回查询计划的JSON表示形式
- 值
使用@apollo/gateway输出查询计划
您的网关可以在计算过程中输出每个传入的操作的查询计划。要这样做,请将以下内容添加到初始化您的ApolloGateway
实例的文件中:
从
@apollo/query-planner
库导入serializeQueryPlan
函数:const {serializeQueryPlan} = require('@apollo/query-planner');将
experimental_didResolveQueryPlan
选项添加到您传递给ApolloGateway
构造函数的对象中:const gateway = new ApolloGateway({experimental_didResolveQueryPlan: function(options) {if (options.requestContext.operationName !== 'IntrospectionQuery') {console.log(serializeQueryPlan(options.queryPlan));}}});