GraphQL の動作を観測する

構築するもの

MongoDB データストアを利用して、http://localhost:8080/graphql で GraphQL リクエストを受け入れるサービスを構築します。メトリクスとトレースを使用して、実行時にアプリケーションがどのように動作するかをより深く理解します。

GraphQL の動作を観測する

Web 用の API を構築する方法は数多くありますが、Spring MVC または Spring WebFlux を使用して REST のようなサービスを開発することは非常に一般的な選択肢です。Web アプリケーションでは、次のような方法が考えられます。

  • エンドポイントから返される情報量の柔軟性の向上

  • API の利用を容易にするために、強い型付けを持つスキーマを使用する (モバイルや React アプリなど)

  • 高度に連結されたグラフのようなデータを公開する

GraphQL API はこれらのユースケースを解決するのに役立ち、Spring for GraphQL はアプリケーションに使い慣れたプログラミングモデルを提供します。

このガイドでは、Spring for GraphQL を使用して Java で GraphQL サービスを作成するプロセスについて説明します。まず、GraphQL の概念をいくつか紹介し、ページネーションと Observability のサポートを備えた音楽ライブラリを探索するための API を構築します。

GraphQL の簡単な導入

GraphQL はサーバーからデータを取得するためのクエリ言語です。ここでは、音楽ライブラリにアクセスするための API の構築を検討します。

一部の JSON Web API では、次のパターンを使用してアルバムとそのトラックに関する情報を取得できます。まず、GET http://localhost:8080/albums/339 などの識別子を使用して、http://localhost:8080/albums/{id} エンドポイントからアルバム情報を取得します。

{
    "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 http://localhost:8080/tracks/1265 を使用してトラックエンドポイントを呼び出して、このアルバムの各トラックに関する情報を取得します。

{
  "id": 1265,
  "title": "Spring music",
  "number": 1,
  "duration": 128,
  "artist": {
    "id": 339,
    "name": "The Spring team"
  },
  "album": {
    "id": 339,
    "name": "Greatest hits",
    "trackCount": "14"
  },
  "lyrics": "https://example.com/lyrics/the-spring-team/spring-music.txt"
}

この API の設計はトレードオフが重要です。エンドポイントごとにどれだけの情報を提供する必要があるか、関連をナビゲートするにはどうすればよいかなどです。Spring Data REST のようなプロジェクトは、このような問題に対してさまざまな代替案を提供します。

一方、GraphQL API を使用すると、GraphQL ドキュメントを POST http://localhost:8080/graphql のような単一のエンドポイントに送信できます。

query albumDetails {
  albumById(id: "339") {
    name
    releaseDate
    tracks {
      id
      title
      duration
    }
  }
}

この GraphQL リクエストは次のように述べています。

  • ID "339" のアルバムのクエリを実行する

  • アルバム型の場合は、その名前と 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 は次の 3 つの重要な機能を提供します。

  1. GraphQL API のスキーマを記述するために使用できるスキーマ定義言語 (SDL)。このスキーマは静的に型付けされているため、サーバーはリクエストがクエリできるオブジェクトの種類と、それらのオブジェクトに含まれるフィールドを正確に認識します。

  2. クライアントが照会または変更したい内容を記述するためのドメイン固有言語。これはドキュメントとしてサーバーに送信されます。

  3. 受信したリクエストを解析、検証、実行し、関連するデータを取得するために「データフェッチャー」に配布するエンジン。

多くのプログラミング言語で動作する GraphQL 全般の詳細については、公式ページ (英語) を参照してください。

必要なもの

最初のプロジェクトから

このプロジェクトは、Spring for GraphQLSpring WebSpring Data MongoDBSpring Boot DevtoolsDocker Compose サポートの依存関係を持つ https://start.spring.io 上に作成されました。また、アプリケーションで動作するためにランダムシードデータを生成するクラスも含まれています。

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 クエリ操作) を記述します。各型は、スキーマで定義された別の型、または具体的なデータを指す「スカラー」型 (StringBooleanInt など) で表すことができるフィールドで構成されます。GraphQL スキーマと型の詳細については、公式の GraphQL ドキュメントを参照 (英語) してください。

スキーマの設計はプロセスの重要な部分です。当社のクライアントは、API を使用するためにこれに大きく依存します。スキーマを調べて API をクエリできる Web ベースの UI である GraphiQL [GitHub] (英語) のおかげで、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 アノテーション付きコンポーネントを提供し、スキーマの一部を実行するハンドラーメソッドを定義します。

コントローラーは、@QueryMapping というアノテーションが付けられた album というメソッドを実装します。Spring for GraphQL はこのメソッドを使用してアルバムデータを取得し、リクエストを満たします。ここでは、MongoTemplate を使用して MongoDB インデックスをクエリし、関連データを取得しています。

ここで、http://localhost:8080/graphiql に移動します。ウィンドウの左上に、ドキュメントエクスプローラーを開くための本のアイコンが表示されます。ご覧のとおり、スキーマとそのインラインドキュメントは、ナビゲート可能なドキュメントとしてレンダリングされます。スキーマは、GraphQL API ユーザーとの重要な契約です。

graphiql album query

アプリケーションの起動ログでアルバム ID を選択し、それを使用して GraphiQL でクエリを送信します。次のクエリを左側のパネルに貼り付けて、クエリを実行します。

query {
  album(id: "659bcbdc7ed081085697ba3d") {
    title
	genres
    ean
  }
}

GraphQL エンジンはドキュメントを受け取り、その内容を解析して構文を検証し、登録されているすべてのデータフェッチャーに呼び出しを送信します。ここでは、album コントローラーメソッドを使用して、ID "659bcbdc7ed081085697ba3d" の Album インスタンスを取得します。リクエストされたすべてのフィールドは、graphql-java が自動的にサポートするプロパティデータフェッチャーによって読み込まれます。

リクエストされたデータは右側のパネルに表示されます。

{
  "data": {
    "album": {
      "title": "Artificial Intelligence",
      "genres": [
        "Indie Rock"
      ],
      "ean": "5037185097254"
    }
  }
}

Spring for GraphQL は、GraphQL エンジンでコントローラーメソッドをデータフェッチャーとして自動的に登録するために使用できるアノテーションモデルをサポートしています。アノテーション型 (複数あります)、メソッド名、メソッドパラメーター、戻り値の型はすべて、意図を理解し、それに応じてコントローラーメソッドを登録するために使用されます。このモデルは、このチュートリアルの次のセクションでさらに広範囲に使用します。

今すぐ @Controller メソッドシグネチャーについて詳しく知りたい場合は、Spring for GraphQL リファレンスドキュメントの専用セクションを参照してください。

カスタムスカラーの定義

既存の Album クラスをもう一度見てみましょう。フィールド releaseDate は java.time.LocalDate 型であることがわかります。これは GraphQL では不明な型であり、スキーマで公開する必要があります。ここでは、スキーマでカスタムスカラー型を宣言し、スカラー表現から java.time.LocalDate 形式にデータをマッピングするコードを提供します (逆も同様)。

まず、src/main/resources/graphql/schema.graphqls に次のスカラー定義を追加します。

scalar Date @specifiedBy(url:"https://tools.ietf.org/html/rfc3339")

scalar Url @specifiedBy(url:"https://www.w3.org/Addressing/URL/url-spec.txt")

"""
A duration, in seconds.
"""
scalar Duration

スカラーは、複雑な型を記述するためにスキーマで構成できる基本型です。一部のスカラーは GraphQL 言語自体によって提供されますが、独自のスカラーを定義したり、ライブラリによって提供されるスカラーを再利用したりすることもできます。スカラーはスキーマの一部であるため、正確に定義し、理想的には仕様を指すようにする必要があります。

このアプリケーションでは、GraphQL Java graphql-java-extended-scalars ライブラリによって提供される 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 Scalar によって実装された日付形式で直列化されます。

{
  "data": {
    "album": {
      "title": "Assembly Language",
      "genres": [
        "Folk"
      ],
      "releaseDate": "2015-08-07",
      "ean": "8879892829172"
    }
  }
}

REST over HTTP とは異なり、単一の GraphQL リクエストには多くの操作を含めることができます。つまり、Spring MVC とは異なり、単一の GraphQL 操作には複数の @Controller メソッドの実行が含まれる可能性があります。GraphQL エンジンはこれらすべての呼び出しを内部でディスパッチするため、アプリケーションで何が起こっているかを具体的に把握することは困難です。次のセクションでは、監視機能を使用して、内部で何が起こっているかをよりよく理解します。

観測を有効にする

Spring Boot 3.0 と Spring Framework 6.0 では、Spring チームは Spring アプリケーションの可観測性ストーリーを完全に再検討しました。可観測性は現在 Spring ライブラリに組み込まれており、Spring MVC リクエスト、Spring Batch ジョブ、Spring Security インフラストラクチャなどのメトリクスとトレースを提供します。

観測は実行時に記録され、アプリケーションの構成に応じてメトリクスとトレースを生成できます。これらは通常、分散システムにおける運用とパフォーマンスの課題を調査するために使用されます。ここでは、これらを使用して、GraphQL リクエストがどのように処理され、データ取得操作がどのように分散されるかを視覚化します。

まず、build.gradleSpring Boot ActuatorMicrometer トレースZipkin を追加しましょう。

	implementation 'org.springframework.boot:spring-boot-starter-actuator'
	implementation 'io.micrometer:micrometer-tracing-bridge-brave'
	implementation 'io.zipkin.reporter2:zipkin-reporter-brave'

記録されたトレースを収集するための新しい Zipkin コンテナーも作成するために、compose.yaml ファイルを更新する必要があります。

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 ページをリフレッシュし、以前と同じようにアルバムを取得します。これで、ブラウザーの http://localhost:9411/zipkin/ で Zipkin UI を読み込み、「クエリの実行」ボタンをクリックできます。すると、2 つのトレースが表示されます。デフォルトでは、トレースは期間順に並べられています。すべてのトレースは "http post /graphql" スパンで始まりますが、これは予想どおりです。すべての GraphQL クエリは、"/graphql" エンドポイントで POST リクエストによる HTTP トランスポートを使用します。

まず、2 つのスパンを含むトレースをクリックします。このトレースは、次のものから構成されます。

  1. "/graphql" エンドポイントでサーバーが受信した HTTP リクエストのスパン

  2. GraphQL リクエスト自体のスパン。IntrospectionQuery としてタグ付けされています

GraphiQL UI が読み込まれると、GraphQL スキーマと利用可能なすべてのメタデータを要求する「イントロスペクションクエリ」が起動されます。この情報により、スキーマの調査やクエリの自動補完が可能になります。

次に、3 つのスパンを含むトレースをクリックします。このトレースは、次のもので構成されています。

  1. "/graphql" エンドポイントでサーバーが受信した HTTP リクエストのスパン

  2. GraphQL リクエスト自体のスパン。MyQuery としてタグ付けされています

  3. 3 番目のスパン 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
}

次に、特定のアルバムのトラックエンティティをデータベースから取得し、トラック番号で並べ替える方法が必要です。これを行うには、TrackRepository インターフェースに findByAlbumIdOrderByNumber メソッドを追加します。

public interface TrackRepository extends MongoRepository<Track, String> {

    List<Track> findByAlbumIdOrderByNumber(String albumId);

}

ここで、GraphQL エンジンに、特定のアルバムインスタンスのトラック情報を取得する方法を提供する必要があります。これは、TracksController に tracks メソッドを追加することで、@SchemaMapping アノテーションを使用して実行できます。

@Controller
public class TracksController {

    private final MongoTemplate mongoTemplate;

    private final TrackRepository trackRepository;

    public TracksController(MongoTemplate mongoTemplate, TrackRepository trackRepository) {
        this.mongoTemplate = mongoTemplate;
        this.trackRepository = trackRepository;
    }

    @QueryMapping
    public Optional<Album> album(@Argument String id) {
        return this.mongoTemplate.query(Album.class)
                .matching(query(where("id").is(id)))
                .first();
    }

    @SchemaMapping
    public List<Track> tracks(Album album) {
        return this.trackRepository.findByAlbumIdOrderByNumber(album.getId());
    }
}

すべての GraphQL @*Mapping アノテーションは、実際には @SchemaMapping アノテーションのバリアントです。このアノテーションは、コントローラーメソッドが特定の型の特定のフィールドのデータを取得する責任があることを示します。* 親型情報は、メソッド引数の型名 (ここでは Album) から取得されます。* フィールド名は、コントローラーメソッド名 (ここでは tracks) を調べることによって検出されます。

メソッド名または型名がスキーマと一致しない場合、アノテーション自体によって、属性でこの情報を手動で指定できます。

    @SchemaMapping(field="tracks", typeName = "Album")
    public List<Track> fetchTracks(Album album) {
        //...
    }

@QueryMapping アノテーション付き album メソッドも @SchemaMapping のバリアントです。ここでは、親型が Query である album フィールドを検討しています。Query は、GraphQL が GraphQL API のすべてのクエリを保存する予約型です。次のように album コントローラーメソッドを変更しても同じ結果が得られます。

    @SchemaMapping(field="album", typeName = "Query")
    public Optional<Album> fetchAlbum(@Argument String id) {
        //...
    }

コントローラーメソッドの宣言は、HTTP リクエストをメソッドにマッピングすることではなく、スキーマからフィールドを取得する方法を記述することです。

それでは、次のクエリで実際に動作を確認してみましょう。今回はアルバムトラックに関する情報を取得します。

query MyQuery {
  album(id: "65e995e180660661697f4413") {
    title
    ean
    releaseDate
    tracks {
      title
      duration
      number
    }
  }
}

次のような結果が得られるはずです。

{
  "data": {
    "album": {
      "title": "System Shock",
      "ean": "5125589069110",
      "releaseDate": "2006-02-25",
      "tracks": [
        {
          "title": "The Code Contender",
          "duration": 177,
          "number": 1
        },
        {
          "title": "The Code Challenger",
          "duration": 151,
          "number": 2
        },
        {
          "title": "The Algorithmic Beat",
          "duration": 189,
          "number": 3
        },
        {
          "title": "Springtime in the Rockies",
          "duration": 182,
          "number": 4
        },
        {
          "title": "Spring Is Coming",
          "duration": 192,
          "number": 5
        },
        {
          "title": "The Networker's Lament",
          "duration": 190,
          "number": 6
        },
        {
          "title": "Spring Affair",
          "duration": 166,
          "number": 7
        }
      ]
    }
  }
}

これで、4 つのスパンを持つトレースが表示されます。そのうち 2 つは album および tracks データフェッチャーです。

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 テストスライスは、インフラストラクチャの関連部分のみを対象とした軽量の統合テストのセットアップに役立ちます。

ここでは、PlaylistController をテストする @GraphQlTest としてテストクラスを宣言します。また、スキーマに必要なカスタムスカラーを定義する GraphQlConfiguration クラスも含める必要があります。

Spring Boot は、スキーマに対して favoritePlaylist クエリをテストするために使用できる GraphQlTester インスタンスを自動構成します。これはライブサーバー、データベース接続、他のすべてのコンポーネントとの完全な統合テストではないため、コントローラーの不足しているコンポーネントをモックするのは私たちのジョブです。テストでは、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 コミュニティは、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) 契約は 2 つの方法で使用できます。

  1. first 10 要素を取得し、カーソル "somevalue" を持つ要素を after してページ送りします。

  2. 逆方向にページングし、last 10 個の要素を取得し、before カーソル "somevalue" を持つ要素を取得します。

つまり、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 を更新して、指定された Playlist の Tracks を取得する @SchemaMapping を追加してみましょう。

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 番目の要素を意味します。この値は、クライアントによってデコードされることを意図しておらず、コレクション内の特定のカーソル位置以外の意味を保持しません。

このカーソル情報を使用して、"T181" の後の 5 つの要素を API に要求できます。

{
  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 を構築し、バックグラウンドでデータ取得がどのように行われるかをより深く理解できるようになりました。

コードを入手する

プロジェクト