{
"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]
}
体验 GraphQL
您将构建什么
您将构建一个服务,该服务将在 https://127.0.0.1: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,您可以使用以下模式来获取有关专辑及其曲目信息。首先,从 https://127.0.0.1:8080/albums/{id}
端点获取专辑信息及其标识符,例如 GET https://127.0.0.1:8080/albums/339
然后,通过使用每个曲目的标识符调用曲目端点来获取此专辑中每个曲目信息,GET https://127.0.0.1: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 https://127.0.0.1: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 提供了三项重要内容
-
模式定义语言 (SDL),您可以使用它来编写 GraphQL API 的模式。此模式是静态类型的,因此服务器准确地知道请求可以查询哪些类型的对象以及这些对象包含哪些字段。
-
用于描述客户端想要查询或修改什么的领域特定语言;这作为文档发送到服务器。
-
一个解析、验证和执行传入请求的引擎,并将它们分发到“数据获取器”以获取相关数据。
您可以在其 官方页面 上了解有关 GraphQL 的更多信息,GraphQL 可与多种编程语言一起使用。
您需要什么
-
您喜欢的文本编辑器或 IDE
-
Java 17 或更高版本
-
在开发期间运行容器需要本地 docker 安装:此应用程序使用 Spring Boot 的 docker compose 支持 在开发时启动外部服务。
从初始项目开始
此项目已在 https://start.spring.io 上创建,并具有 Spring for GraphQL、Spring Web、Spring Data MongoDB、Spring Boot Devtools 和 Docker Compose Support 依赖项。它还包含用于生成随机种子数据以与我们的应用程序一起使用的类。
一旦 docker 守护程序在您的机器上运行,您首先可以在 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 模式,然后实现客户端请求数据的逻辑。
获取专辑
首先,将一个名为 schema.graphqls
的新文件添加到 src/main/resources/graphql
文件夹中,内容如下
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 将公开的类型和操作:Artist
和 Album
类型,以及 album
查询操作。每个类型都由可以由模式定义的另一个类型或指向具体数据片段的“标量”类型(如 String
、Boolean
、Int
等)组成的字段组成。您可以在 GraphQL 官方文档中了解有关 GraphQL 模式和类型的更多信息。
设计模式是流程的关键部分 - 我们的客户端将严重依赖它来使用我们的 API。您可以轻松地尝试您的 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 索引并获取相关数据。
现在,导航到 https://127.0.0.1:8080/graphiql。在窗口的左上方,您应该会看到一个书籍图标,可让您打开文档浏览器。如您所见,模式及其内联文档呈现为可导航的文档。模式确实是与我们的 GraphQL API 用户的关键契约。
在应用程序的启动日志中选择一个专辑 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
库提供的 Date
和 Url
标量。首先,我们需要将其作为依赖项添加到我们的项目中
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 Actuator、Micrometer Tracing和Zipkin添加到我们的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页面,然后像以前一样获取专辑。您现在可以在浏览器中以https://127.0.0.1:9411/zipkin/加载Zipkin UI并点击“运行查询”按钮。然后您应该会看到两个跟踪;默认情况下,它们按持续时间排序。所有跟踪都以“http post /graphql”
跨度开头,这是预期的:我们所有的GraphQL查询都将使用HTTP传输,在“/graphql”
端点上使用POST请求。
首先,点击包含2个跨度的跟踪。此跟踪由以下组成:
-
我们服务器在
“/graphql”
端点接收到的HTTP请求的跨度。 -
GraphQL请求本身的跨度,标记为
IntrospectionQuery
。
加载时,GraphiQL UI会触发一个“内省查询”,该查询会请求GraphQL模式和所有可用的元数据。有了这些信息,它将帮助我们探索模式,甚至自动完成我们的查询。
现在,点击包含3个跨度的跟踪。此跟踪由以下组成:
-
我们服务器在
“/graphql”
端点接收到的HTTP请求的跨度。 -
GraphQL请求本身的跨度,标记为
MyQuery
。 -
第三个跨度
graphql field album
,显示GraphQL引擎使用我们的数据提取器获取专辑信息。
在下一节中,我们将向我们的应用程序添加更多功能,并了解更复杂的查询如何反映为跟踪。
添加基本的曲目信息
到目前为止,我们已经使用单个数据提取器实现了简单的查询。但正如我们所见,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
}
然后,我们需要一种方法来为给定的专辑从我们的数据库中获取曲目实体,并按曲目编号对它们进行排序。让我们通过向我们的TrackRepository
接口添加findByAlbumIdOrderByNumber
方法来做到这一点。
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
的变体。在这里,我们通过其父类型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个跨度的跟踪,其中2个包含我们的album
和tracks
数据提取器。
测试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 提供了名为“测试器”的测试实用程序,它们将充当客户端并帮助您对返回的响应执行断言。所需的依赖项'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.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.graphql.test.tester.GraphQlTester;
import java.util.Optional;
@GraphQlTest(controllers = PlaylistController.class)
@Import(GraphQlConfiguration.class)
class PlaylistControllerTests {
@Autowired
private GraphQlTester graphQlTester;
@MockBean
private PlaylistRepository playlistRepository;
@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 社区创建了一个连接规范,该规范实现了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)
契约可以通过两种方式使用:
-
向前分页,通过获取游标为“somevalue”的元素之后的
first
10个元素。 -
向后分页,通过获取游标为“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
,该@SchemaMapping
为给定的Playlist
获取Tracks
。
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
}
}
}
}
}
恭喜,您已经构建了这个GraphQL API,并且现在更好地理解了幕后数据获取是如何发生的!