观察 GraphQL 的实际应用

您将构建什么

您将构建一个服务,该服务将在 http://localhost:8080/graphql 接受 GraphQL 请求,并由 MongoDB 数据存储支持。我们将使用指标和跟踪来更好地了解应用程序在运行时如何运行。

观察 GraphQL 的实际应用

构建 Web API 的方式有很多种;使用 Spring MVC 或 Spring WebFlux 开发类似 REST 的服务是一个非常受欢迎的选择。对于您的 Web 应用程序,您可能希望

  • 在端点返回多少信息方面具有更大的灵活性

  • 使用具有强类型的模式来帮助 API 消费(例如,由移动或 React 应用程序)

  • 暴露高度连接的、图状数据

GraphQL API 可以帮助您解决这些用例,而 Spring for GraphQL 为您的应用程序提供了熟悉的编程模型。

本指南将引导您了解如何使用 Spring for GraphQL 在 Java 中创建 GraphQL 服务。我们将从一些 GraphQL 概念开始,并构建一个用于探索音乐库的 API,该 API 支持分页和可观察性。

GraphQL 简介

GraphQL 是一种用于从服务器检索数据的查询语言。在这里,我们将考虑构建一个用于访问音乐库的 API。

对于某些 JSON Web API,您可以使用以下模式来获取专辑及其曲目的信息。首先,使用专辑的标识符从 http://localhost:8080/albums/{id} 端点获取专辑信息,例如 GET http://localhost:8080/albums/339

{
    "id": 339,
    "name": "Greatest hits",
    "artist": {
        "id": 339,
        "name": "The Spring team"
      },
    "releaseDate": "2005-12-23",
    "ean": "9294950127462",
    "genres": ["Coding music"],
    "trackCount": "10",
    "trackIds": [1265, 1266, 1267, 1268, 1269, 1270, 1271, 1272, 1273, 1274]
}

然后,通过使用每个曲目标识符调用曲目端点来获取此专辑中每个曲目的信息,例如 GET http://localhost:8080/tracks/1265

{
  "id": 1265,
  "title": "Spring music",
  "number": 1,
  "duration": 128,
  "artist": {
    "id": 339,
    "name": "The Spring team"
  },
  "album": {
    "id": 339,
    "name": "Greatest hits",
    "trackCount": "14"
  },
  "lyrics": "https://example.com/lyrics/the-spring-team/spring-music.txt"
}

设计此 API 权衡利弊:每个端点应该提供多少信息,以及如何导航关系?像 Spring Data REST 这样的项目为此类问题提供了不同的替代方案。

另一方面,使用 GraphQL API,我们可以将 GraphQL 文档发送到单个端点,例如 POST http://localhost:8080/graphql

query albumDetails {
  albumById(id: "339") {
    name
    releaseDate
    tracks {
      id
      title
      duration
    }
  }
}

这个 GraphQL 请求表示

  • 执行一个查询,查找 id 为 "339" 的专辑

  • 对于专辑类型,返回其名称和发布日期

  • 对于此专辑的每首曲目,返回其 id、标题和持续时间

响应采用 JSON 格式,例如

{
  "albumById": {
    "name": "Greatest hits",
    "releaseDate": "2005-12-23",
    "tracks": [
      {"id": 1265, "title": "Spring music", "duration": 128},
      {"id": 1266, "title": "GraphQL apps", "duration": 132}
    ]
  }
}

GraphQL 提供三个重要特性

  1. 一种 Schema Definition Language (SDL),可用于编写 GraphQL API 的模式。此模式是静态类型的,因此服务器确切地知道请求可以查询哪些类型的对象以及这些对象包含哪些字段。

  2. 一种 Domain Specific Language (DSL),用于描述客户端想要查询或修改的内容;它作为文档发送到服务器。

  3. 一个引擎,负责解析、验证和执行传入请求,并将它们分发给“数据获取器”(Data Fetchers)以获取相关数据。

您可以在 GraphQL 的官方页面上了解更多有关 GraphQL 的信息,它支持多种编程语言。

您需要什么

从初始项目开始

此项目已在 https://start.spring.io 上创建,包含 Spring for GraphQLSpring WebSpring Data MongoDBSpring Boot DevtoolsDocker Compose Support 依赖项。它还包含生成随机种子数据以供应用程序使用的类。

在您的机器上运行 docker daemon 后,您可以首先在 IDE 中或通过命令行使用 ./gradlew :bootRun 运行项目。您应该会看到日志显示在应用程序启动之前,已下载 Mongo DB 镜像并创建了一个新容器

INFO 72318 --- [utReader-stderr] o.s.boot.docker.compose.core.DockerCli   :  mongo Pulling
...
INFO 72318 --- [utReader-stderr] o.s.boot.docker.compose.core.DockerCli   :  406b5efbdb81 Pull complete
...
INFO 72318 --- [utReader-stderr] o.s.boot.docker.compose.core.DockerCli   :  Container initial-mongo-1  Healthy
INFO 72318 --- [  restartedMain] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data MongoDB repositories in DEFAULT mode.
INFO 72318 --- [  restartedMain] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 193 ms. Found 2 MongoDB repository interfaces.
...
INFO 72318 --- [  restartedMain] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port 8080 (http)
...
INFO 72318 --- [  restartedMain] i.s.g.g.GraphqlMusicApplication          : Started GraphqlMusicApplication in 36.601 seconds (process running for 37.244)

您还应该看到在启动期间生成随机数据并保存到数据存储中

INFO 72318 --- [  restartedMain] i.s.g.g.tracks.DemoDataRunner            : Album{id='6601e06f454bc9438702e300', title='Zero and One', genres=[K-Pop (Korean Pop)], artists=[Artist{id='6601e06f454bc9438702e2f6', name='Code Culture'}], releaseDate=2010-02-07, ean='9317657099044', trackIds=[6601e06f454bc9438702e305, 6601e06f454bc9438702e306, 6601e06f454bc9438702e307, 6601e06f454bc9438702e308, 6601e06f454bc9438702e301, 6601e06f454bc9438702e302, 6601e06f454bc9438702e303, 6601e06f454bc9438702e304]}
INFO 72318 --- [  restartedMain] i.s.g.g.tracks.DemoDataRunner            : Album{id='6601e06f454bc9438702e309', title='Hello World', genres=[Country], artists=[Artist{id='6601e06f454bc9438702e2f6', name='Code Culture'}], releaseDate=2016-07-21, ean='8864328013898', trackIds=[6601e06f454bc9438702e30e, 6601e06f454bc9438702e30f, 6601e06f454bc9438702e30a, 6601e06f454bc9438702e312, 6601e06f454bc9438702e30b, 6601e06f454bc9438702e30c, 6601e06f454bc9438702e30d, 6601e06f454bc9438702e310, 6601e06f454bc9438702e311]}
INFO 72318 --- [  restartedMain] i.s.g.g.tracks.DemoDataRunner            : Album{id='6601e06f454bc9438702e314', title='808s and Heartbreak', genres=[Folk], artists=[Artist{id='6601e06f454bc9438702e313', name='Virtual Orchestra'}], releaseDate=2016-02-19, ean='0140055845789', trackIds=[6601e06f454bc9438702e316, 6601e06f454bc9438702e317, 6601e06f454bc9438702e318, 6601e06f454bc9438702e319, 6601e06f454bc9438702e31b, 6601e06f454bc9438702e31c, 6601e06f454bc9438702e31d, 6601e06f454bc9438702e315, 6601e06f454bc9438702e31a]}
INFO 72318 --- [  restartedMain] i.s.g.g.tracks.DemoDataRunner            : Album{id='6601e06f454bc9438702e31e', title='Noise Floor', genres=[Classical], artists=[Artist{id='6601e06f454bc9438702e313', name='Virtual Orchestra'}], releaseDate=2005-01-06, ean='0913755396673', trackIds=[6601e06f454bc9438702e31f, 6601e06f454bc9438702e327, 6601e06f454bc9438702e328, 6601e06f454bc9438702e323, 6601e06f454bc9438702e324, 6601e06f454bc9438702e325, 6601e06f454bc9438702e326, 6601e06f454bc9438702e320, 6601e06f454bc9438702e321, 6601e06f454bc9438702e322]}
INFO 72318 --- [  restartedMain] i.s.g.g.tracks.DemoDataRunner            : Album{id='6601e06f454bc9438702e329', title='Language Barrier', genres=[EDM (Electronic Dance Music)], artists=[Artist{id='6601e06f454bc9438702e313', name='Virtual Orchestra'}], releaseDate=2017-07-19, ean='7701504912761', trackIds=[6601e06f454bc9438702e32c, 6601e06f454bc9438702e32d, 6601e06f454bc9438702e32e, 6601e06f454bc9438702e32f, 6601e06f454bc9438702e330, 6601e06f454bc9438702e331, 6601e06f454bc9438702e32a, 6601e06f454bc9438702e332, 6601e06f454bc9438702e32b]}
INFO 72318 --- [  restartedMain] i.s.g.g.tracks.DemoDataRunner            : Playlist{id='6601e06f454bc9438702e333', name='Favorites', author='rstoyanchev'}
INFO 72318 --- [  restartedMain] i.s.g.g.tracks.DemoDataRunner            : Playlist{id='6601e06f454bc9438702e334', name='Favorites', author='bclozel'}

我们现在准备开始实现音乐库 API:首先,定义 GraphQL 模式,然后实现逻辑来获取客户端请求的数据。

获取专辑

首先,在 src/main/resources/graphql 文件夹中添加一个新文件 schema.graphqls,内容如下

type Query {
    """
    Get a particular Album by its ID.
    """
    album(id: ID!): Album
}

"""
An Album.
"""
type Album {
    id: ID!
    "The Album title."
    title: String!
    "The list of music genres for this Album."
    genres: [String]
    "The list of Artists who authored this Album."
    artists: [Artist]
    "The EAN for this Album."
    ean: String
}

"""
Person or group featured on a Track, or authored an Album.
"""
type Artist {
    id: ID!
    "The Artist name."
    name: String
    "The Albums this Artist authored."
    albums: [Album]
}

此模式描述了我们的 GraphQL API 将暴露的类型和操作:ArtistAlbum 类型,以及 album 查询操作。每个类型由字段组成,这些字段可以由模式定义的其他类型表示,或者是由指向具体数据(例如 StringBooleanInt 等)的“标量”类型表示。您可以在官方 GraphQL 文档中了解更多有关 GraphQL 模式和类型的信息。

设计模式是流程中至关重要的一部分 - 我们的客户端将严重依赖它来使用我们的 API。您可以通过 GraphiQL 轻松试用您的 API,GraphiQL 是一个基于 Web 的 UI,可让您探索模式并查询您的 API。通过在 application.properties 中配置以下内容来启用应用程序中的 GraphiQL UI。

spring.graphql.graphiql.enabled=true

您现在可以启动应用程序。在我们使用 GraphiQL 探索模式之前,您应该在控制台中看到以下日志

INFO 65464 --- [  restartedMain] o.s.b.a.g.GraphQlAutoConfiguration       : GraphQL schema inspection:
	Unmapped fields: {Query=[album]}
	Unmapped registrations: {}
	Skipped types: []

因为模式是明确定义且强类型的,Spring for GraphQL 可以检查您的模式和应用程序,以便告知您不一致之处。在这里,检查告诉我们 album 查询在我们的应用程序中尚未实现。

现在让我们将以下类添加到我们的应用程序中

package io.spring.guides.graphqlmusic.tracks;

import java.util.Optional;

import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.stereotype.Controller;

import static org.springframework.data.mongodb.core.query.Criteria.where;
import static org.springframework.data.mongodb.core.query.Query.query;

@Controller
public class TracksController {

    private final MongoTemplate mongoTemplate;

    public TracksController(MongoTemplate mongoTemplate) {
        this.mongoTemplate = mongoTemplate;
    }

    @QueryMapping
    public Optional<Album> album(@Argument String id) {
        return this.mongoTemplate.query(Album.class)
                .matching(query(where("id").is(id)))
                .first();
    }

}

实现我们的 GraphQL API 与使用 Spring MVC 开发 REST 服务非常相似。我们提供带有 @Controller 注解的组件,并定义负责实现模式一部分的处理方法。

我们的控制器实现了一个名为 album 的方法,该方法用 @QueryMapping 注解。Spring for GraphQL 将使用此方法获取专辑数据并完成请求。在这里,我们使用 MongoTemplate 查询 MongoDB 索引并获取相关数据。

现在,导航到 http://localhost:8080/graphiql。在窗口左上方,您应该看到一个书本图标,可让您打开文档浏览器。如您所见,模式及其内联文档以可导航文档的形式呈现。模式确实是我们与 GraphQL API 用户之间的关键契约。

graphiql album query

在应用程序的启动日志中选择一个专辑 ID,并使用它通过 GraphiQL 发送查询。将以下查询粘贴到左侧面板并执行查询。

query {
  album(id: "659bcbdc7ed081085697ba3d") {
    title
	genres
    ean
  }
}

GraphQL 引擎接收我们的文档,解析其内容并验证其语法,然后将调用分派给所有注册的数据获取器。在这里,我们的 album 控制器方法将用于获取 ID 为 "659bcbdc7ed081085697ba3d"Album 实例。所有请求的字段都将由 graphql-java 自动支持的属性数据获取器加载。

您应该在右侧面板中获取请求的数据。

{
  "data": {
    "album": {
      "title": "Artificial Intelligence",
      "genres": [
        "Indie Rock"
      ],
      "ean": "5037185097254"
    }
  }
}

Spring for GraphQL 支持一种注解模型,我们可以使用它自动将我们的控制器方法注册为 GraphQL 引擎中的数据获取器。注解类型(有几种)、方法名称、方法参数和返回类型都用于理解意图并相应地注册控制器方法。在本教程的后续章节中,我们将更广泛地使用此模型。

如果您想立即了解更多关于 @Controller 方法签名,请查看Spring for GraphQL 参考文档中的专用部分

定义自定义标量

让我们再次看看现有的 Album 类。您会注意到 releaseDate 字段是 java.time.LocalDate 类型,这是 GraphQL 未知的类型,我们希望在模式中暴露它。在这里,我们将在模式中声明自定义标量类型,并提供将数据从其标量表示形式映射到其 java.time.LocalDate 形式的代码,反之亦然。

首先,将以下标量定义添加到 src/main/resources/graphql/schema.graphqls

scalar Date @specifiedBy(url:"https://tools.ietf.org/html/rfc3339")

scalar Url @specifiedBy(url:"https://www.w3.org/Addressing/URL/url-spec.txt")

"""
A duration, in seconds.
"""
scalar Duration

标量是模式可以组合以描述复杂类型的基本类型。一些标量由 GraphQL 语言本身提供,但您也可以定义自己的标量或重用库提供的标量。由于标量是模式的一部分,我们应该精确地定义它们,最好指向一个规范。

对于我们的应用程序,我们将使用 GraphQL Java graphql-java-extended-scalars 库提供的 DateUrl 标量。首先,我们需要确保我们依赖于

implementation 'com.graphql-java:graphql-java-extended-scalars:22.0'

我们的应用程序已经包含一个 DurationSecondsScalar 实现,展示了如何为 Duration 实现自定义标量。标量需要在应用程序中注册到 GraphQL 引擎,因为在将 GraphQL 模式与应用程序连接时需要它们。在此阶段,我们需要有关类型、标量和数据获取器的所有信息。由于模式是类型安全的,如果我们在模式中使用 GraphQL 引擎未知的标量定义,应用程序将失败。

我们可以提供一个 RuntimeWiringConfigurer bean 来注册我们的标量

package io.spring.guides.graphqlmusic;

import graphql.scalars.ExtendedScalars;
import io.spring.guides.graphqlmusic.support.DurationSecondsScalar;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.graphql.execution.RuntimeWiringConfigurer;

@Configuration
public class GraphQlConfiguration {

    @Bean
    public RuntimeWiringConfigurer runtimeWiringConfigurer() {
        return wiringBuilder -> wiringBuilder.scalar(ExtendedScalars.Date)
                .scalar(ExtendedScalars.Url)
                .scalar(DurationSecondsScalar.INSTANCE);
    }

}

我们现在可以改进我们的模式并为 Album 类型声明 releaseDate 字段

"""
An Album.
"""
type Album {
    id: ID!
    "The Album title."
    title: String!
    "The list of music genres for this Album."
    genres: [String]
    "The list of Artists who authored this Album."
    artists: [Artist]
    "The release date for this Album."
    releaseDate: Date
    "The EAN for this Album."
    ean: String
}

并查询给定专辑的信息

query {
  album(id: "659c342e11128b11e08aa115") {
    title
    genres
    releaseDate
    ean
  }
}

正如预期的那样,发布日期信息将使用我们通过 Date 标量实现的日期格式进行序列化。

{
  "data": {
    "album": {
      "title": "Assembly Language",
      "genres": [
        "Folk"
      ],
      "releaseDate": "2015-08-07",
      "ean": "8879892829172"
    }
  }
}

与基于 HTTP 的 REST 不同,单个 GraphQL 请求可以包含许多操作。这意味着与 Spring MVC 不同,单个 GraphQL 操作可能涉及执行多个 @Controller 方法。由于 GraphQL 引擎在内部调度所有这些调用,因此很难具体了解应用程序中发生了什么。在下一节中,我们将使用可观察性功能来更好地了解内部情况。

启用可观察性

随着 Spring Boot 3.0 和 Spring Framework 6.0 的发布,Spring 团队彻底重新审视了 Spring 应用程序中的可观察性。可观察性现在内置于 Spring 库中,为您提供 Spring MVC 请求、Spring Batch 作业、Spring Security 基础设施等的指标和跟踪。

可观察性在运行时记录,并根据应用程序配置生成指标和跟踪。它们通常用于调查分布式系统中的生产和性能问题。在这里,我们将使用它们来可视化 GraphQL 请求如何处理以及数据获取操作如何分布。

首先,让我们将 Spring Boot ActuatorMicrometer TracingZipkin 添加到我们的 build.gradle

	implementation 'org.springframework.boot:spring-boot-starter-actuator'
	implementation 'io.micrometer:micrometer-tracing-bridge-brave'
	implementation 'io.zipkin.reporter2:zipkin-reporter-brave'

我们还需要更新 compose.yaml 文件,以便也创建一个新的 Zipkin 容器来收集记录的跟踪信息。

services:
  mongodb:
    image: 'mongo:latest'
    environment:
      - 'MONGO_INITDB_DATABASE=mydatabase'
      - 'MONGO_INITDB_ROOT_PASSWORD=secret'
      - 'MONGO_INITDB_ROOT_USERNAME=root'
    ports:
      - '27017'
  zipkin:
    image: 'openzipkin/zipkin:latest'
    ports:
      - '9411:9411'

根据设计,并非所有请求都会系统地记录跟踪信息。对于本实验,我们将采样概率更改为 "1.0" 以可视化所有请求。在我们的 application.properties 中,添加以下内容

management.tracing.sampling.probability=1.0

现在,刷新 GraphiQL UI 页面,然后像之前一样获取专辑。您现在可以在浏览器中加载 Zipkin UI,地址为 http://localhost:9411/zipkin/,然后点击“运行查询”按钮。您应该会看到两个跟踪;默认情况下,它们按持续时间排序。所有跟踪都以 "http post /graphql" span 开头,这是预期的:我们所有的 GraphQL 查询都将在 "/graphql" 端点使用 HTTP 传输和 POST 请求。

首先,点击包含 2 个 span 的跟踪。此跟踪由以下组成

  1. 服务器在 "/graphql" 端点接收到的 HTTP 请求的 span

  2. GraphQL 请求本身的 span,标记为 IntrospectionQuery

GraphiQL UI 加载时,会触发一个“内省查询”,请求获取 GraphQL 模式和所有可用的元数据。利用这些信息,它将帮助我们探索模式,甚至自动完成我们的查询。

现在,点击包含 3 个 span 的跟踪。此跟踪由以下组成

  1. 服务器在 "/graphql" 端点接收到的 HTTP 请求的 span

  2. GraphQL 请求本身的 span,标记为 MyQuery

  3. 第三个 span graphql field album 显示 GraphQL 引擎使用我们的数据获取器获取专辑信息

zipkin album query

在下一节中,我们将为应用程序添加更多功能,并查看更复杂的查询如何反映为跟踪。

添加基本曲目信息

到目前为止,我们使用单个数据获取器实现了一个简单查询。但是,正如我们所见,GraphQL 的核心在于导航图状数据结构并请求其不同部分。在这里,我们将添加获取专辑曲目信息的功能。

首先,我们应该将 tracks 字段添加到我们的 Album 类型,并将 Track 类型添加到现有的 schema.graphqls

"""
An Album.
"""
type Album {
    id: ID!
    "The Album title."
    title: String!
    "The list of music genres for this Album."
    genres: [String]
    "The list of Artists who authored this Album."
    artists: [Artist]
    "The release date for this Album."
    releaseDate: Date
    "The EAN for this Album."
    ean: String
    "The collection of Tracks this Album is made of."
    tracks: [Track]
}

"""
A song in a particular Album.
"""
type Track {
 id: ID!
 "The track number in the corresponding Album."
 number: Int
 "The track title."
 title: String!
 "The track duration."
 duration: Duration
 "Average user rating for this Track."
 rating: Int
}

然后,我们需要一种方法从数据库中获取给定专辑的曲目实体,并按曲目编号排序。为此,我们将 findByAlbumIdOrderByNumber 方法添加到 TrackRepository 接口中。

public interface TrackRepository extends MongoRepository<Track, String> {

    List<Track> findByAlbumIdOrderByNumber(String albumId);

}

我们现在需要为 GraphQL 引擎提供一种获取给定专辑实例的曲目信息的方法。这可以通过 @SchemaMapping 注解完成,将 tracks 方法添加到 TracksController 中。

@Controller
public class TracksController {

    private final MongoTemplate mongoTemplate;

    private final TrackRepository trackRepository;

    public TracksController(MongoTemplate mongoTemplate, TrackRepository trackRepository) {
        this.mongoTemplate = mongoTemplate;
        this.trackRepository = trackRepository;
    }

    @QueryMapping
    public Optional<Album> album(@Argument String id) {
        return this.mongoTemplate.query(Album.class)
                .matching(query(where("id").is(id)))
                .first();
    }

    @SchemaMapping
    public List<Track> tracks(Album album) {
        return this.trackRepository.findByAlbumIdOrderByNumber(album.getId());
    }
}

所有 GraphQL @*Mapping 注解实际上都是 @SchemaMapping 的变体。此注解表示控制器方法负责获取特定类型上特定字段的数据: * 父类型信息来源于方法参数的类型名称,这里是 Album。 * 字段名称通过查看控制器方法名称检测,这里是 tracks

注解本身允许您在属性中手动指定此信息,以防方法名称或类型名称与您的模式不匹配。

    @SchemaMapping(field="tracks", typeName = "Album")
    public List<Track> fetchTracks(Album album) {
        //...
    }

我们的 @QueryMapping 注解的 album 方法也是 @SchemaMapping 的变体。在这里,我们将 album 字段的父类型视为 QueryQuery 是 GraphQL 中用于存储我们 GraphQL API 的所有查询的保留类型。我们可以使用以下内容修改我们的 album 控制器方法,仍然获得相同的结果。

    @SchemaMapping(field="album", typeName = "Query")
    public Optional<Album> fetchAlbum(@Argument String id) {
        //...
    }

我们的控制器方法声明不是关于将 HTTP 请求映射到方法,而是真正关于如何从我们的模式中获取字段。

现在让我们使用以下查询来看一下实际效果,这次获取专辑曲目的信息

query MyQuery {
  album(id: "65e995e180660661697f4413") {
    title
    ean
    releaseDate
    tracks {
      title
      duration
      number
    }
  }
}

您应该会得到类似如下的结果

{
  "data": {
    "album": {
      "title": "System Shock",
      "ean": "5125589069110",
      "releaseDate": "2006-02-25",
      "tracks": [
        {
          "title": "The Code Contender",
          "duration": 177,
          "number": 1
        },
        {
          "title": "The Code Challenger",
          "duration": 151,
          "number": 2
        },
        {
          "title": "The Algorithmic Beat",
          "duration": 189,
          "number": 3
        },
        {
          "title": "Springtime in the Rockies",
          "duration": 182,
          "number": 4
        },
        {
          "title": "Spring Is Coming",
          "duration": 192,
          "number": 5
        },
        {
          "title": "The Networker's Lament",
          "duration": 190,
          "number": 6
        },
        {
          "title": "Spring Affair",
          "duration": 166,
          "number": 7
        }
      ]
    }
  }
}

我们现在应该看到一个包含 4 个 span 的跟踪,其中 2 个使用我们的 albumtracks 数据获取器。

zipkin album tracks query

测试 GraphQL 控制器

测试您的代码是开发生命周期中的重要组成部分。应用程序不应依赖于完整的集成测试,我们应该在不涉及整个模式或实时服务器的情况下测试我们的控制器。

GraphQL 通常在 HTTP 之上使用,但技术本身是“传输无关的”,这意味着它不局限于 HTTP,可以在许多传输层之上工作。例如,您可以使用 HTTP、WebSocket 或 RSocket 运行 Spring for GraphQL 应用程序。

现在让我们实现收藏歌曲支持:我们应用程序的每个用户都可以创建他们收藏曲目的自定义播放列表。首先,我们可以在模式中声明 Playlist 类型,并添加一个新的 favoritePlaylist 查询方法,用于显示给定用户的收藏曲目。

"""
A named collection of tracks, curated by a user.
"""
type Playlist {
    id : ID!
    "The playlist name."
    name: String
    "The user name of the author of this playlist."
    author: String
}
type Query {
    """
    Get a particular Album by its ID.
    """
    album(id: ID!): Album

    """
    Get favorite tracks published by a particular user.
    """
    favoritePlaylist(
        "The Playlist author username."
        authorName: String!): Playlist

}

现在创建 PlaylistController 并按如下方式实现查询

package io.spring.guides.graphqlmusic.tracks;

import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.stereotype.Controller;

import java.util.Optional;

@Controller
public class PlaylistController {

 private final PlaylistRepository playlistRepository;

 public PlaylistController(PlaylistRepository playlistRepository) {
  this.playlistRepository = playlistRepository;
 }

 @QueryMapping
 public Optional<Playlist> favoritePlaylist(@Argument String authorName) {
  return this.playlistRepository.findByAuthorAndNameEquals(authorName, "Favorites");
 }

}

Spring for GraphQL 提供了称为“测试器”(testers)的测试工具,它们充当客户端,帮助您对返回的响应执行断言。所需的依赖项 'org.springframework.graphql:spring-graphql-test' 已经在我们的类路径中,所以让我们编写第一个测试。

Spring Boot @GraphQlTest 测试切片 将有助于设置轻量级集成测试,这些测试仅涉及我们基础设施的相关部分。

在这里,我们将测试类声明为 @GraphQlTest,它将测试 PlaylistController。我们还需要包含定义模式所需自定义标量的 GraphQlConfiguration 类。

Spring Boot 将为我们自动配置一个 GraphQlTester 实例,我们可以用它来测试模式中的 favoritePlaylist 查询。由于这不是一个包含实时服务器、数据库连接和所有其他组件的完整集成测试,因此我们的任务是为控制器模拟缺失的组件。我们的测试模拟了 PlaylistRepository 的预期行为,因为我们将其声明为 @MockBean

package io.spring.guides.graphqlmusic.tracks;


import io.spring.guides.graphqlmusic.GraphQlConfiguration;
import org.junit.jupiter.api.Test;
import org.mockito.BDDMockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.graphql.GraphQlTest;
import org.springframework.context.annotation.Import;
import org.springframework.graphql.test.tester.GraphQlTester;
import org.springframework.test.context.bean.override.mockito.MockitoBean;

import java.util.Optional;

@GraphQlTest(controllers = PlaylistController.class)
@Import(GraphQlConfiguration.class)
class PlaylistControllerTests {

 @Autowired
 private GraphQlTester graphQlTester;

 @MockitoBean
 private PlaylistRepository playlistRepository;

 @MockitoBean
 private TrackRepository trackRepository

 @Test
 void shouldReplyWithFavoritePlaylist() {
  Playlist favorites = new Playlist("Favorites", "bclozel");
  favorites.setId("favorites");

  BDDMockito.when(playlistRepository.findByAuthorAndNameEquals("bclozel", "Favorites")).thenReturn(Optional.of(favorites));

  graphQlTester.document("""
                  {
                    favoritePlaylist(authorName: "bclozel") {
                      id
                      name
                      author
                    }
                  }
                  """)
          .execute()
          .path("favoritePlaylist.name").entity(String.class).isEqualTo("Favorites");
 }

}

如您所见,GraphQlTester 允许您发送 GraphQL 文档并对 GraphQL 响应执行断言。您可以在Spring for GraphQL 参考文档中找到有关测试器的更多信息

分页

在上一节中,我们定义了一个查询来获取用户的收藏歌曲。但是 Playlist 类型到目前为止不包含任何曲目信息。我们可以向 Playlist 类型添加一个 tracks: [Track] 属性,但与专辑不同,专辑中的曲目数量相对有限,而用户可以选择添加大量歌曲作为收藏。

GraphQL 社区创建了一个Connections 规范,该规范实现了 GraphQL API 分页模式的所有最佳实践。Spring for GraphQL 支持此规范,并帮助您在不同的数据存储技术之上实现分页。

首先,我们需要更新 Playlist 类型以暴露曲目信息。在这里,tracks 属性将不会返回完整的 Track 实例列表,而是返回一个 TrackConnection 类型。

"""
A named collection of tracks, curated by a user.
"""
type Playlist {
    id : ID!
    "The playlist name."
    name: String
    "The user name of the author of this playlist."
    author: String
    tracks(
        "Returns the first n elements from the list."
        first: Int,
        "Returns the last n elements from the list."
        last: Int,
        "Returns the elements in the list that come before the specified cursor."
        before: String,
        "Returns the elements in the list that come after the specified cursor."
        after: String): TrackConnection
}

TrackConnection 类型应在模式中描述。根据规范,连接类型应包含有关当前页的信息,以及图的实际边缘。每个边缘指向一个节点(实际的 Track 元素)并包含光标信息,光标是一个不透明的字符串,指向集合中的特定位置。

此信息需要为模式中的每个 Connection 类型重复,并且不会给应用程序带来额外的语义。这就是为什么这部分由 Spring for GraphQL 在运行时自动添加到模式中,因此无需将其添加到您的模式文件中

type TrackConnection {
	edges: [TrackEdge]!
	pageInfo: PageInfo!
}

type TrackEdge {
	node: Track!
	cursor: String!
}

type PageInfo {
	hasPreviousPage: Boolean!
	hasNextPage: Boolean!
	startCursor: String
	endCursor: String
}

tracks(first: Int, last: Int, before: String, after: String) 合约可以以两种方式使用

  1. 向前分页,获取光标为 "somevalue" 的元素 afterfirst 10 个元素

  2. 向后分页,获取光标为 "somevalue" 的元素 beforelast 10 个元素

这意味着 GraphQL 客户端将通过在有序集合中提供位置、方向和计数来请求一个“页面”的元素。Spring Data 支持滚动,支持偏移量和键集策略。

让我们向 TrackRepository 添加一个新方法,以支持我们用例的分页。

package io.spring.guides.graphqlmusic.tracks;

import java.util.List;
import java.util.Set;

import org.springframework.data.domain.Limit;
import org.springframework.data.domain.ScrollPosition;
import org.springframework.data.domain.Window;
import org.springframework.data.mongodb.repository.MongoRepository;

public interface TrackRepository extends MongoRepository<Track, String> {

    List<Track> findByAlbumIdOrderByNumber(String albumId);

    Window<Track> findByIdInOrderByTitle(Set<String> trackIds, ScrollPosition scrollPosition, Limit limit);

}

我们的方法将“查找”与给定集中列出的 ID 匹配的曲目,并按标题排序。ScrollPosition 包含位置和方向,Limit 参数是元素计数。我们从这个方法获得一个 Window<Track>,作为访问元素和进行分页的方式。

现在让我们更新 PlaylistController,添加一个 @SchemaMapping 来获取给定 PlaylistTracks

package io.spring.guides.graphqlmusic.tracks;

import org.springframework.data.domain.Limit;
import org.springframework.data.domain.ScrollPosition;
import org.springframework.data.domain.Window;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.graphql.data.method.annotation.SchemaMapping;
import org.springframework.graphql.data.query.ScrollSubrange;
import org.springframework.stereotype.Controller;

import java.util.Optional;
import java.util.Set;

@Controller
public class PlaylistController {

 private final PlaylistRepository playlistRepository;

 private final TrackRepository trackRepository;

 public PlaylistController(PlaylistRepository playlistRepository, TrackRepository trackRepository) {
  this.playlistRepository = playlistRepository;
  this.trackRepository = trackRepository;
 }

 @QueryMapping
 public Optional<Playlist> favoritePlaylist(@Argument String authorName) {
  return this.playlistRepository.findByAuthorAndNameEquals(authorName, "Favorites");
 }

 @SchemaMapping
 Window<Track> tracks(Playlist playlist, ScrollSubrange subrange) {
  Set<String> trackIds = playlist.getTrackIds();
  ScrollPosition scrollPosition = subrange.position().orElse(ScrollPosition.offset());
  Limit limit = Limit.of(subrange.count().orElse(10));
  return this.trackRepository.findByIdInOrderByTitle(trackIds, scrollPosition, limit);
 }

}

first: Int, last: Int, before: String, after: String 参数被收集到一个 ScrollSubrange 实例中。在我们的控制器中,然后我们可以获取我们感兴趣的 ID 和分页参数的信息。

您可以使用以下查询运行此示例,首先请求用户 "bclozel" 的前 10 个元素。

{
  favoritePlaylist(authorName: "bclozel") {
    id
    name
    author
    tracks(first: 10) {
      edges {
        node {
          id
          title
        }
        cursor
      }
      pageInfo {
        hasNextPage
      }
    }
  }
}

您应该会得到类似如下的响应

{
 "data": {
  "favoritePlaylist": {
   "id": "66029f5c6eba07579da6f800",
   "name": "Favorites",
   "author": "bclozel",
   "tracks": {
    "edges": [
     {
      "node": {
       "id": "66029f5c6eba07579da6f785",
       "title": "Coding All Night"
      },
      "cursor": "T18x"
     },
     {
      "node": {
       "id": "66029f5c6eba07579da6f7f1",
       "title": "Machine Learning"
      },
      "cursor": "T18y"
     },
     {
      "node": {
       "id": "66029f5c6eba07579da6f7bf",
       "title": "Spirit of Spring"
      },
      "cursor": "T18z"
     },
     {
      "node": {
       "id": "66029f5c6eba07579da6f795",
       "title": "Spring Break Anthem"
      },
      "cursor": "T180"
     },
     {
      "node": {
       "id": "66029f5c6eba07579da6f7c0",
       "title": "Spring Comes"
      },
      "cursor": "T181"
     }
    ],
    "pageInfo": {
     "hasNextPage": true
    }
   }
  }
 }
}

每个边缘都提供其自己的光标信息 - 服务器在运行时解码此不透明字符串,并将其转换为集合中的位置。例如,base64 解码 "T180" 将得到 "O_4",这意味着偏移量滚动中的第 4 个元素。此值并非旨在由客户端解码,也不包含除集合中特定光标位置之外的任何语义。

然后我们可以使用此光标信息向 API 请求 "T181" 之后的 5 个元素。

{
  favoritePlaylist(authorName: "bclozel") {
    id
    name
    author
    tracks(first: 5, after: "T181") {
      edges {
        node {
          id
          title
        }
        cursor
      }
      pageInfo {
        hasNextPage
      }
    }
  }
}

然后我们可以期望得到类似如下的响应

{
  "data": {
    "favoritePlaylist": {
      "id": "66029f5c6eba07579da6f800",
      "name": "Favorites",
      "author": "bclozel",
      "tracks": {
        "edges": [
          {
            "node": {
              "id": "66029f5c6eba07579da6f7a3",
              "title": "Spring Has Sprung"
            },
            "cursor": "T182"
          },
          {
            "node": {
              "id": "66029f5c6eba07579da6f7a2",
              "title": "Spring Rain"
            },
            "cursor": "T183"
          },
          {
            "node": {
              "id": "66029f5c6eba07579da6f766",
              "title": "Spring Wind Chimes"
            },
            "cursor": "T184"
          },
          {
            "node": {
              "id": "66029f5c6eba07579da6f7d9",
              "title": "Springsteen"
            },
            "cursor": "T185"
          },
          {
            "node": {
              "id": "66029f5c6eba07579da6f779",
              "title": "Springtime Again"
            },
            "cursor": "T18xMA=="
          }
        ],
        "pageInfo": {
          "hasNextPage": true
        }
      }
    }
  }
}

恭喜,您已经构建了这个 GraphQL API,现在更好地理解了数据获取是如何在幕后进行的!

获取代码