觀察 GraphQL 的實際應用

您將構建什麼

您將構建一個服務,該服務將在 https://: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,您可以使用以下模式來獲取專輯及其曲目的資訊。首先,使用專輯的識別符號從 https://:8080/albums/{id} 端點獲取專輯資訊,例如 GET https://: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 https://: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://: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 索引並獲取相關資料。

現在,導航到 https://: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,地址為 https://: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,現在更好地理解了資料獲取是如何在幕後進行的!

獲取程式碼