Spring Boot 与 Docker
观察 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。

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]
}

然后,通过调用包含每个曲目标识符的 tracks 端点来获取该专辑的每首曲目的信息,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. 一种模式定义语言(SDL),可用于编写 GraphQL API 的模式。该模式是静态类型的,因此服务器确切地知道请求可以查询哪些类型的对象以及这些对象包含哪些字段。

  2. 一种领域特定语言,用于描述客户端想要查询或更改的内容;该内容以文档形式发送到服务器。

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

您可以访问GraphQL 官方页面了解更多关于 GraphQL 的通用知识,它可以与多种编程语言一起使用。

所需条件

  • 一个喜欢的文本编辑器或 IDE

  • Java 17 或更高版本

  • 本地需要安装 docker 以在开发期间运行容器:该应用程序使用 Spring Boot 的 docker compose 支持 在开发时启动外部服务。

从初始项目开始

该项目是在 https://start.spring.io 上创建的,包含了 Spring for GraphQLSpring WebSpring Data MongoDBSpring Boot DevtoolsDocker Compose Support 依赖项。它还包含生成随机种子数据的类,以便与我们的应用程序一起使用。

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

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,这是一个基于 Web 的 UI,可让您探索模式并查询 API。通过在 application.properties 中进行以下配置,您可以在应用程序中启用 GraphiQL UI:

spring.graphql.graphiql.enabled=true

您现在可以启动您的应用程序了。在我们使用 GraphiQL 探索我们的 schema 之前,您应该已经在控制台中看到以下日志:

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 实现一个自定义 Scalar。Scalars 需要在我们的应用程序中注册到 GraphQL 引擎中,因为当 GraphQL 模式与应用程序连接时,它们是必需的。在这个阶段,我们将需要所有关于类型、Scalars 和数据获取器的信息。由于模式的类型安全特性,如果我们在模式中使用 GraphQL 引擎未知的 Scalar 定义,应用程序将会失败。

我们可以贡献一个注册我们Scalars的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 Scalar 实现的日期格式进行序列化。

{
  "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 应用程序中的可观测性(Observability)方案。可观测性现在已内置到 Spring 库中,为 Spring MVC 请求、Spring Batch 任务、Spring Security 基础设施等提供了指标和追踪功能。

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

首先,让我们在 build.gradle 中添加 Spring Boot ActuatorMicrometer TracingZipkin

    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/ 并点击 "Run query" 按钮。然后您应该会看到两条跟踪记录;默认情况下,它们按持续时间排序。所有跟踪记录都以一个 "http post /graphql" 的 span 开始,这是预期的:我们所有的 GraphQL 查询都将使用 HTTP 传输,并在 "/graphql" 端点上发送 POST 请求。

首先,点击包含 2 个 span 的跟踪记录。该跟踪记录由以下部分组成:

  1. 一个用于表示服务器在 "/graphql" 端点上收到的 HTTP 请求的跨度

  2. 一个用于表示 GraphQL 请求本身的跨度,该请求被标记为 IntrospectionQuery

GraphiQL 界面在加载时,会触发一个“内省查询”,该查询会请求 GraphQL 模式及所有可用的元数据。通过这些信息,它将帮助我们探索模式,甚至自动补全我们的查询。

现在,点击包含 3 个跨度的跟踪。该跟踪由以下部分组成:

  1. 一个用于表示服务器在 "/graphql" 端点上接收到的 HTTP 请求的 span

  2. 一个用于表示 GraphQL 请求本身的 span,该请求被标记为 MyQuery

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

zipkin 专辑查询

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

添加基本 Track 信息

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

首先,我们应该在 Album 类型中添加 tracks 字段,并将 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
}

接下来,我们需要有一种方法可以从数据库中获取指定专辑的曲目实体,并按照曲目编号进行排序。让我们通过在 TrackRepository 接口中添加 findByAlbumIdOrderByNumber 方法来实现这一点:

public interface TrackRepository extends MongoRepository<Track, String> {

    List<Track> findByAlbumIdOrderByNumber(String albumId);

}

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

@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 的一种变体。在这里,我们通过其父类型 Query 来考虑 album 字段。Query 是一个保留类型,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 查询。由于这不是一个包含实时服务器、数据库连接和所有其他组件的完整集成测试,因此我们需要为 Controller 模拟缺失的组件。我们的测试模拟了 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" 的元素之后的 first 10 个元素

  2. 向后分页,通过获取游标为 "somevalue" 的元素之前的 last 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
    }
   }
  }
 }
}

每条边都提供了自己的游标信息——这个不透明的字符串由服务器解码,并在运行时转换为集合中的位置。例如,对 "T180" 进行 base64 解码将得到 "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
        }
      }
    }
  }
}

您可以在 Spring for GraphQL 参考文档中了解更多关于分页的信息

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

本页目录