觀察 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

{
    "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://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 提供了三個重要的東西

  1. 一種綱要定義語言 (SDL),您可以使用它來編寫 GraphQL API 的綱要。這個綱要是靜態型別的,因此伺服器確切知道請求可以查詢哪些類型的物件,以及這些物件包含哪些欄位。

  2. 一種領域特定語言,用於描述客戶端想要查詢或變更的內容;這會作為文件傳送到伺服器。

  3. 一個引擎,用於剖析、驗證和執行傳入的請求,並將它們分發到 "資料提取器" 以取得相關資料。

您可以在 官方頁面 上了解更多關於 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 綱要,然後實作邏輯以提取客戶端請求的資料。

提取專輯

首先,將一個新檔案 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,這是一個基於 Web 的 UI,可讓您探索綱要和查詢您的 API。透過在 application.properties 中配置以下內容,在您的應用程式中啟用 GraphiQL UI

spring.graphql.graphiql.enabled=true

您現在可以啟動您的應用程式。在我們使用 GraphiQL 探索我們的綱要之前,您應該在 CONSOLE 中看到以下日誌

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 使用者的關鍵合約。

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 引擎會在內部分派所有這些呼叫,因此很難具體地看到我們的應用程式中發生了什麼。 在下一節中,我們將使用可觀測性 (Observability) 功能來更好地了解底層發生了什麼。

啟用可觀測性 (Observations)

透過 Spring Boot 3.0 和 Spring Framework 6.0,Spring 團隊完全重新審視了 Spring 應用程式中的可觀測性 (Observability) 故事。 可觀測性 (Observability) 現在已內建於 Spring 函式庫中,為您提供 Spring MVC 請求、Spring Batch 作業、Spring Security 基礎架構等的指標和追蹤。

可觀測性 (Observations) 會在執行時記錄,並且可以根據應用程式配置產生指標和追蹤。 它們通常用於調查分散式系統中的生產和效能問題。 在這裡,我們將使用它們來視覺化 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://127.0.0.1:9411/zipkin/ 並點擊 "Run query" 按鈕。 然後您應該會看到兩個追蹤; 預設情況下,它們按持續時間排序。 所有追蹤都以 "http post /graphql" span 開始,這是預期的:我們所有的 GraphQL 查詢都將使用 HTTP 傳輸,並在 "/graphql" 端點上發出 POST 請求。

首先,點擊包含 2 個 spans 的追蹤。 此追蹤由以下部分組成

  1. 一個 span 用於我們的伺服器在 "/graphql" 端點上收到的 HTTP 請求

  2. 一個 span 用於 GraphQL 請求本身,該請求被標記為 IntrospectionQuery

載入後,GraphiQL UI 會觸發一個 "introspection query",該查詢會要求 GraphQL 模式和所有可用的中繼資料。 透過這些資訊,它將幫助我們探索模式,甚至自動完成我們的查詢。

現在,點擊包含 3 個 spans 的追蹤。 此追蹤由以下部分組成

  1. 一個 span 用於我們的伺服器在 "/graphql" 端點上收到的 HTTP 請求

  2. 一個 span 用於 GraphQL 請求本身,該請求被標記為 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 個 spans 的追蹤,其中 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 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 類型不包含任何曲目資訊。 我們可以將 tracks: [Track] 屬性新增到 Playlist 類型,但與專輯不同,專輯中的曲目數量在某種程度上受到限制,我們的使用者可以選擇新增大量的歌曲作為最愛。

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) 合約可以用兩種方式使用

  1. 向前分頁,方法是取得 after 游標為 "somevalue" 的元素之後的 first 10 個元素

  2. 向後分頁,方法是取得 before 游標為 "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 擷取給定 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
        }
      }
    }
  }
}

您可以在 Spring for GraphQL 參考文件中找到有關分頁的更多資訊。

恭喜,您已經建構了此 GraphQL API,並且現在更好地了解資料擷取如何在幕後發生!

取得程式碼