觀察 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,用於探索具有分頁和可觀察性支援的音樂庫。

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”的專輯執行查詢

  • 對於專輯型別,返回其名稱和 releaseDate

  • 對於此專輯的每個曲目,返回其 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 的更多資訊,它適用於多種程式語言。

你需要什麼

從初始專案開始

此專案已在 https://start.spring.io 上建立,包含 Spring for GraphQLSpring WebSpring Data MongoDBSpring Boot DevtoolsDocker 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 將公開的型別和操作: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:24.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 庫中,為您提供 Spring MVC 請求、Spring Batch 作業、Spring Security 基礎設施等的指標和跟蹤。

觀測在執行時記錄,並根據應用程式配置生成指標和跟蹤。它們通常用於調查分散式系統中的生產和效能問題。在這裡,我們將使用它們來視覺化 GraphQL 請求如何處理以及資料獲取操作如何分發。

首先,讓我們將 Micrometer TracingZipkin 新增到我們的 build.gradle

	implementation 'org.springframework.boot:spring-boot-micrometer-tracing'
	implementation 'org.springframework.boot:spring-boot-starter-zipkin'

	testImplementation 'org.springframework.boot:spring-boot-micrometer-tracing-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-zipkin-test'

我們還需要更新 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/,然後點選“Run query”按鈕。您應該會看到兩條跟蹤;預設情況下,它們按持續時間排序。所有跟蹤都以 "http post /graphql" span 開始,這是預期的:我們所有的 GraphQL 查詢都將使用 HTTP 傳輸,並在 "/graphql" 端點上傳送 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 提供了稱為“測試器”的測試工具,它們將充當客戶端並幫助您對返回的響應執行斷言。所需的依賴項 '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 java.util.Optional;

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.graphql.test.autoconfigure.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 型別到目前為止不包含任何曲目資訊。我們可以將 tracks: [Track] 屬性新增到 Playlist 型別,但是與專輯中曲目數量有限不同,我們的使用者可以選擇新增大量歌曲作為收藏。

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,它為給定的 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,現在更好地瞭解了資料獲取在幕後是如何發生的!

獲取程式碼