11. 解析器链
20m

概述

我们可以 房源的便利设施,但只能通过 listing(id: ID)并非 通过 featuredListings。发生了什么?

在本课中,我们将

  • 了解
  • 了解 source 在数据提取器方法中的作用
  • 将本地上下文从一个数据提取器方法传递到下一个

检查数据源响应

让我们再次检查来自我们 GET /featured-listings 端点的响应。打开一个新的浏览器标签页,然后粘贴下面的 URL。

GET /featured-listings 端点
https://rt-airlock-services-listing.herokuapp.com/featured-listings

我们返回的数组包含我们期望的房源对象;但我们会注意到每个房源对象中有一个属性不同!从 /featured-listings 端点,房源的 "amenities" 列表是一个数组,其中包含 每个便利设施的标识符。没有其他数据!

这是 REST API 中的常见模式。想象一下,如果每个房源的便利设施列表包含 完整 的每个便利设施的数据。这将导致非常大的响应,因为要返回房源列表 以及 每个房源的完整便利设施列表!如果端点决定返回比我们已经拥有的三个房源更多的房源,问题将进一步加剧。

为了确保我们可以返回房源及其 所有 便利设施的完整数据,我们需要对 REST API 进行一次额外的调用。在这种情况下,要调用一个新的端点:GET /listings/{listing_id}/amenities

下一个问题是:我们在代码的哪个地方进行此调用?

处理缺少的 amenities

为了处理我们缺少的 amenities,我们 可以 更新我们的 featuredListings 数据提取器方法。我们可以为其添加一些额外的逻辑来访问 GET /listings/{listing_id} 以获取这些额外数据。

实现后续调用的建议
@DgsQuery
public List<ListingModel> featuredListings() throws IOException {
List<ListingModel> listings = listingService.featuredListingsRequest();
// traverse listings for each listing id?
// make a follow-up request to /listings/{listing_id}/amenities
// then recompose each ListingModel object with the new amenities data?
return listings;
}

嗯,这可以实现;但意味着 每次 我们 featuredListings 时,我们都会 始终 对 REST API 进行额外的网络调用,无论 是否要求房源的 amenities

所以,我们将使用 链。

遵循解析器链

一个 解析器链 是数据提取器方法(在某些其他框架中称为 解析器 函数)在解析特定 时调用的顺序。它可以包含顺序路径和并行分支。

让我们从我们的项目中举一个例子。这个 GetListing 检索房源的标题。

query GetListing($listingId: ID!) {
listing(id: $listingId) {
title
}
}

在解析此 时, 将首先调用 Query.listing() 数据提取器方法,该方法返回一个 Listing 类型,然后调用 Listing.title() 方法,该方法返回一个 String 类型并结束链。

Resolver chain in a diagram

注意: 我们不需要为 单独 的数据提取器方法定义 Listing.title,因为 title 属性可以直接从 Query.listing 返回的实例中返回。

此链中的每个数据提取器方法都会将其返回值作为 DgsDataFetchingEnvironment 上的一个属性传递给下一个方法。

DgsDataFetchingEnvironment 是数据提取器方法可选使用的,但它包含有关正在执行的 、服务器上下文以及我们关心的参数的很多信息:source

在此示例中,Listing.title() 数据提取器方法可以使用 DgsDataFetchingEnvironmentsource 属性访问 Listing 对象,该对象是 Query.listing() 方法返回的。

让我们看看另一个

query GetListingAmenities($listingId: ID!) {
listing(id: $listingId) {
title
amenities {
name
}
}
}

这次,我们添加了更多 并请求了每个房源的便利设施列表,特别是它们的 name 值。

我们的 链正在增长,并添加了一个并行分支。

Resolver chain in a diagram

因为 Listing.amenities 返回一个可能包含多个便利设施的列表,所以此 可能运行多次以检索每个便利设施的名称。

沿着 的轨迹,Listing.amenities() 将可以访问 Listing 作为 source,就像 Amenity.name() 可以访问 Amenity 对象作为 source 一样。

如果我们的 没有包含 amenities (就像我们展示的第一个示例一样),那么 Listing.amenities() 方法将永远不会被调用!

Listing.amenities 数据提取器方法

现在我们已经了解了 链,我们可以使用它来确定插入房源便利设施的额外 REST API 调用的最佳位置。

请记住,我们正在讨论是否要将其包含在 Query.featuredListings 数据提取器方法中,这样每次我们 调用 特色列表数据时,即使操作不包含 amenities ,它也会被调用:

featuredListings 数据提取器,应该是这样
@DgsQuery
public List<ListingModel> featuredListings() throws IOException {
// Not the best place to make an extra network call!
return listingService.featuredListingsRequest();
}

相反,我们将添加一个 新的 数据提取器方法到 ListingDataFetcher 类中 - 一个专门负责满足 Listing.amenities 来自我们模式的字段。

对于我们最后两个数据提取器方法,我们使用了 @DgsQuery 注释。这是因为我们定义的方法负责为我们模式中的 Query 类型上的 提供数据。

但是,这次我们想定义一个方法,负责为 Listing 类型上的 填充数据。为此,我们需要使用不同的注释: @DgsData

schema.graphqls
type Listing {
id: ID!
# ... other Listing fields
"The amenities available for this listing"
amenities: [Amenity!]! # We want to define a datafetcher method for THIS field!
}

The @DgsData 注释

The @DgsData 注释允许我们指定我们为其定义方法的 类型和 。对于 Listing.amenities ,它将如下所示:

@DgsData(parentType="Listing", field="amenities")

如果我们给我们的方法与 同样的名称,amenities,我们可以在注释中省略 field="amenities" 规范。

现在,让我们在我们的 ListingDataFetcher 类中定义此方法。

ListingDataFetcher
@DgsData(parentType="Listing")
public void amenities() {}

我们将在顶部导入新的 @DgsData 注释,以及我们服务器生成的 Amenity 类型,我们将在稍后使用它。

ListingDataFetcher
import com.netflix.graphql.dgs.DgsData;
import com.example.listings.generated.types.Amenity;

Returning Listing.amenities

立即,我们可以将我们方法的返回类型更新为 List Amenity 类型。

ListingDataFetcher
@DgsData(parentType="Listing")
public List<Amenity> amenities() {
// TODO
}

现在,是时候利用那个 source 了,我们在本课的前面提到了它。请记住,source 是我们为其解析便利设施的列表实例;它是 先前 数据提取器方法返回的值。

我们还没有为 Listing 类型的 idtitle 等定义单独的数据提取器方法,因此我们的服务器将在每个 ListingModel 实例上查找这些属性,这些实例是我们 列表或特色列表时返回的。但是,我们的 Listing.amenities 现在拥有自己的数据提取器方法。我们的服务器不会仅仅在 ListingModel 实例中检查其 amenities 属性,它将依赖于 方法来最终决定为列表的便利设施返回哪些数据。

为了简化工作,此方法将它正在为其解析便利设施的 ListingModel 作为 source 属性接收 DgsDataFetchingEnvironment。这使我们能够访问和使用 ListingModel 属性(例如其 id)来使我们的后续请求成为可能。

让我们在文件顶部导入 DgsDataFetchingEnvironment

ListingDataFetcher
// ... other imports
import com.netflix.graphql.dgs.DgsDataFetchingEnvironment;

接下来,我们将它作为 添加到我们称为 dfe 的方法中。

ListingDataFetcher
@DgsData(parentType="Listing")
public List<Amenity> amenities(DgsDataFetchingEnvironment dfe) {
// TODO
}

要访问 source 属性,我们将调用 dfe.getSource()。我们将以 ListingModel 类型接收此值,称为 listing

ListingDataFetcher
public List<Amenity> amenities(DgsDataFetchingEnvironment dfe) {
ListingModel listing = dfe.getSource();
}

接下来,我们将访问 listing 中的 id 属性。

ListingDataFetcher
public List<Amenity> amenities(DgsDataFetchingEnvironment dfe) {
ListingModel listing = dfe.getSource();
String id = listing.getId();
}

那么,我们如何判断我们正在解析的 ListingModel 实例是否 已经 附加了所有便利设施数据?我们知道,当我们 单个列表时,我们从 REST API 获取所有便利设施数据;相反,当我们查询 featuredListings 时,我们只获得每个列表的便利设施 id 列表;namecategory 为空!它们没有包含在 REST API 响应中。

// Amenities on an instance of ListingModel when returned by the Query.listing datafetcher
Listing{id='listing-1',
amenities='[Amenity{id='am-2',category='Accommodation Details',name='Towel'},
Amenity{id='am-10',category='Space Survival',name='Oxygen'},
Amenity{id='am-11',category='Space Survival',name='Prepackaged meals'}]'}
// Amenities on an instance of ListingModel when returned by the Query.featuredListings datafetcher
Listing{id='listing-1',
amenities='[Amenity{id='am-2',category='null',name='null'},
Amenity{id='am-10',category='null',name='null'},
Amenity{id='am-11',category='null',name='null'}]}

DGS 中的本地上下文

DGS 为我们提供了一种在数据提取器之间传递自定义上下文的方法:使用 DataFetcherResult 类型。我们将使用此类型的 localContext 属性来帮助我们确定我们是否正在为 ListingModel 解析便利设施,该 已经 在类上设置了便利设施,或者我们是否需要进行后续请求。

首先,让我们在文件顶部导入 DataFetcherResult 类型,以及 Java 的 Map 实用程序。

ListingDataFetcher
import graphql.execution.DataFetcherResult;
import java.util.Map;

然后,我们将 listing 方法的返回类型用 DataFetcherResult 包装起来。

ListingDataFetcher
@DgsQuery
public DataFetcherResult<ListingModel> listing(@InputArgument String id) {
return listingService.listingRequest(id);
}

现在,我们将在代码中看到一个错误:listing 方法不再返回我们指示的类型。我们不会直接返回数据,而是将其捕获在 ListingModel 中,称为 listing

ListingDataFetcher
@DgsQuery
public DataFetcherResult<ListingModel> listing(@InputArgument String id) {
ListingModel listing = listingService.listingRequest(id);
}

要返回 DataFetcherResult 类型,我们需要构建该类型的实例,并附加其他属性。语法将类似于以下内容:

return DataFetcherResult.<T>newResult()
.data() // pass in the data to return
.localContext() // attach local context
.build();

让我们使用此语法更新我们的方法。对于 T 的值,即 ,我们将给出我们方法的原始返回类型,ListingModel。我们将 listing 传递到 data() 中。

ListingDataFetcher
return DataFetcherResult.<ListingModel>newResult()
.data(listing)
.localContext()
.build();

The localContext 属性是我们将在此数据提取器方法中使用的属性,以指示链中的 下一个 数据提取器,我们返回的 ListingModel 已经 拥有其完整的便利设施数据。

为此,我们将传入一个新的 Map,其中包含一个属性 hasAmenityData,我们将其设置为 true

ListingDataFetcher
return DataFetcherResult.<ListingModel>newResult()
.data(listing)
.localContext(Map.of("hasAmenityData", true))
.build();

现在,让我们对 featuredListings 做同样的事情。语法将与之前相同,除了 localContext 的值,我们将在其中将 hasAmenityData 属性设置为 false

ListingDataFetcher
@DgsQuery
public DataFetcherResult<List<ListingModel>> featuredListings() throws IOException {
List<ListingModel> listings = listingService.featuredListingsRequest();
return DataFetcherResult.<List<ListingModel>>newResult()
.data(listings)
.localContext(Map.of("hasAmenityData", false))
.build();
}

使用 getLocalContext 检索上下文

我们的 Query.listingQuery.featuredListings 数据提取器方法都设置了一些自定义上下文;现在,让我们设置允许我们 检索 来自另一个数据提取器方法的上下文的语法。

滚动回到 Listing.amenities 数据提取器方法。

我们将再次使用 dfe 参数,这次我们将调用 getLocalContext。我们将以 Map<String, Boolean> 类型接收它,我们将称其为 localContext

ListingDataFetcher
Map<String, Boolean> localContext = dfe.getLocalContext();

我们将检查 localContext 是否存在,以及 hasAmenityDatalocalContext 上是否为 true,如果是,我们可以安全地假设我们的 ListingModel 实例已经设置了其 amenities 属性并完全填充。

ListingDataFetcher
Map<String, Boolean> localContext = dfe.getLocalContext();
if (localContext != null && localContext.get("hasAmenityData")) {
return listing.getAmenities();
}

现在,如果我们的本地上下文 没有 设置 hasAmenityDatatrue,那么我们将假设需要进行后续请求以获取完整的便利设施数据。

ListingDataFetcher
Map<String, Boolean> localContext = dfe.getLocalContext();
if (localContext != null && localContext.get("hasAmenityData")) {
return listing.getAmenities();
}
// TODO: FOLLOW-UP REQUEST HERE

让我们在 ListingService 中构建可以请求便利设施数据的调用。

请求便利设施

现在让我们跳到我们的 datasources/ListingService 文件并构建此方法。首先,从我们的 generated 文件夹导入 Amenity 类型。

datasources/ListingService
// ... other imports
import com.example.listings.generated.types.Amenity;

我们需要此方法返回一个 ListAmenity 类型,因为这是我们的 Listing.amenities 所期望返回的。它将接收我们想要检索其便利设施的列表的 id,一个 String 我们称之为 listingId

datasources/ListingService
public List<Amenity> amenitiesRequest(String listingId) {
// TODO
}

我们将使用之前使用的相同样板启动此请求:在我们的类 client 实例上调用 get,并连接 uri。我们正在访问 /listings/{listing_id}/amenities 端点,将 listingId 作为 {listing_id} 的值传递。

datasources/ListingService
client
.get()
.uri("/listings/{listing_id}/amenities", listingId)

接下来,我们将连接 retrievebody

datasources/ListingService
client
.get()
.uri("/listings/{listing_id}/amenities", listingId)
.retrieve()
.body()

由于此端点的响应包含与我们的 Amenity 类匹配的对象列表,因此我们可以首先将整个响应作为 JsonNode 返回,然后再次调用我们的 mapper.readValue 方法。

接下来,我们将遍历数组中的每个对象,从每个对象中创建一个 Amenity 实例。试一试,按照我们在 featuredListingsRequest 方法中实施的相同步骤操作。准备好了就将你的方法与下面的最终状态进行比较!

datasources/ListingService
public List<Amenity> amenitiesRequest(String listingId) throws IOException {
JsonNode response = client
.get()
.uri("/listings/{listing_id}/amenities", listingId)
.retrieve()
.body(JsonNode.class);
if (response != null) {
return mapper.readValue(response.traverse(), new TypeReference<List<Amenity>>() {
});
}
return null;
}

完成数据提取器

让我们通过调用我们的新方法来完成我们的数据提取器。回到 ListingDataFetcher 中,在 amenities 方法中,我们将使用列表的 id 作为 并返回调用 listingService.amenitiesRequest 的结果。

ListingDataFetcher
@DgsData(parentType="Listing")
public List<Amenity> amenities(DgsDataFetchingEnvironment dfe) throws IOException {
ListingModel listing = dfe.getSource();
String id = listing.getId();
Map<String, Boolean> localContext = dfe.getLocalContext();
if (localContext != null && localContext.get("hasAmenityData")) {
return listing.getAmenities();
}
return listingService.amenitiesRequest(id);
}

只需要最后一件事情!我们的 ListingService 对便利设施的请求 可能 会导致可能发生的异常,所以让我们考虑一下这个异常。

ListingDataFetcher
@DgsData(parentType = "Listing")
public List<Amenity> amenities(DgsDataFetchingEnvironment dfe) throws IOException {
// ... amenity fetching logic!
}
任务!

探索时间:第二轮!

服务器重启,并运行最新的更改了吗?很好!现在,当我们回到沙盒并运行 获取 featuredListings 及其便利设施列表时,我们得到了我们想要的东西!

query GetFeaturedListings {
featuredListings {
id
title
description
amenities {
id
name
category
}
}
}

👏👏👏

与 REST 方法比较

现在再次戴上我们的产品应用程序开发人员帽子!让我们比较一下如果我们使用 REST 而不是 ,此功能会是什么样子。

如果我们使用 REST,应用程序逻辑将包括

  • /featured-listings 端点进行 HTTP GET 调用
  • 对响应中的 每个列表 进行额外的 HTTP GET 调用以访问 GET /listings/{listing_id}/amenities。根据列表的数量,等待所有这些调用解析可能需要一段时间。此外,这引入了 常见的 N+1 问题
  • 仅检索 idnamecategory 属性,丢弃响应的其余部分。根据响应,这可能意味着我们获取了大量 未使用的 数据!而且大型响应会带来成本。

使用 ,客户端会编写简洁、干净、可读的 ,并且数据会以他们指定的精确格式返回,不多不少!

所有提取数据、进行额外 HTTP 调用和过滤所需 的逻辑都在 端完成。我们仍然有 N+1 问题,但它发生在服务器端(响应和请求速度更加一致,通常更快),而不是客户端(网络速度 且不一致)。

注意:我们可以在 端使用 数据加载器 来解决 N+1 问题,我们将在即将推出的课程中介绍这一点。

主要收获

  • 一个 链是解析特定 时调用数据提取器函数的顺序。它可以包含顺序路径以及并行分支。
  • 此链中的每个数据提取器方法都会将其返回值作为名为 DgsDataFetchingEnvironment 的大型对象的 source 属性传递给下一个方法。
  • DgsDataFetchingEnvironment 对象是一个可选参数,所有数据提取器方法都可以访问。它包含在 链中调用的前一个数据提取器的返回值,以及有关正在执行的 的其他数据。
  • 我们可以使用 DGS 的 DataFetcherResult 来自定义数据提取器方法返回的值,附加本地上下文,链中的下一个数据提取器方法可以访问该上下文。

接下来

对查询有信心了吗?是时候探索 的另一面了:

上一步

分享你关于本课的问题和评论

此课程目前处于

测试版
.你的反馈有助于我们改进!如果你卡住了或困惑,请告诉我们,我们会帮助你。所有评论都是公开的,必须遵循 Apollo 行为准则。请注意,已解决或已处理的评论可能会被删除。

你需要一个 GitHub 帐户才能在下面发布。没有? 在我们的 Odyssey 论坛上发布。