リクエスト実行
ExecutionGraphQlService は、GraphQL Java を呼び出してリクエストを実行するための主要な Spring 抽象化です。HTTP などの基礎となるトランスポートは、リクエストを処理するために ExecutionGraphQlService に委譲します。
主な実装である DefaultExecutionGraphQlService は、起動する graphql.GraphQL インスタンスにアクセスするための GraphQlSource で構成されています。
GraphQLSource
GraphQlSource は、使用する graphql.GraphQL インスタンスを公開するための契約であり、そのインスタンスを構築するためのビルダー API も含まれています。デフォルトのビルダーは GraphQlSource.schemaResourceBuilder() から入手できます。
Boot スターターはこのビルダーのインスタンスを作成し、それをさらに初期化して、構成可能な場所からスキーマファイルをロードし、GraphQlSource.Builder に適用するプロパティを公開し、RuntimeWiringConfigurer Bean、GraphQL メトリクスの計測 (英語) Bean、および例外解決用の DataFetcherExceptionResolver および SubscriptionExceptionResolver Bean を検出します。さらにカスタマイズするには、GraphQlSourceBuilderCustomizer Bean を宣言することもできます。例:
import org.springframework.boot.graphql.autoconfigure.GraphQlSourceBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class GraphQlConfig {
@Bean
public GraphQlSourceBuilderCustomizer sourceBuilderCustomizer() {
return (builder) ->
builder.configureGraphQl((graphQlBuilder) ->
graphQlBuilder.executionIdProvider(new CustomExecutionIdProvider()));
}
}スキーマリソース
GraphQlSource.Builder は、1 つ以上の Resource インスタンスを使用して構成し、解析およびマージすることができます。つまり、スキーマファイルはほぼすべての場所からロードできます。
デフォルトでは、Boot スターターは、場所 classpath:graphql/** (通常は src/main/resources/graphql) で拡張子が ".graphqls" または ".gqls" のスキーマファイルを探します。ファイルシステムの場所、または Spring Resource 階層でサポートされている任意の場所を使用することもできます。これには、リモートの場所、ストレージ、メモリからスキーマファイルをロードするカスタム実装が含まれます。
classpath*:graphql/**/ を使用して、複数のクラスパスの場所にまたがるスキーマファイルを検索します。複数のモジュールにわたって。 |
スキーマの作成
デフォルトでは、GraphQlSource.Builder は GraphQL Java SchemaGenerator を使用して graphql.schema.GraphQLSchema を作成します。これは一般的な用途では機能しますが、別のジェネレーターを使用する必要がある場合は、schemaFactory コールバックを登録できます。
GraphQlSource.Builder builder = ...
builder.schemaResources(..)
.configureRuntimeWiring(..)
.schemaFactory((typeDefinitionRegistry, runtimeWiring) -> {
// create GraphQLSchema
})Spring Boot でこれを構成する方法については、GraphQlSource セクションを参照してください。
連盟に興味がある場合は、フェデレーションセクションを参照してください。
RuntimeWiringConfigurer
RuntimeWiringConfigurer は、次のものを登録できます。
カスタムスカラー型。
ディレクティブを処理するコード。
直接
DataFetcher登録。さらに…
Spring アプリケーションは通常、直接 DataFetcher 登録を実行する必要はありません。代わりに、コントローラーメソッドは、RuntimeWiringConfigurer である AnnotatedControllerConfigurer を介して DataFetcher として登録されます。 |
| GraphQL Java、サーバーアプリケーションは、Jackson をデータのマップとの間の直列化にのみ使用します。クライアント入力はマップに解析されます。サーバー出力は、フィールド選択セットに基づいてマップにアセンブルされます。これは、Jackson シリアライゼーション / デシリアライゼーションアノテーションに依存できないことを意味します。代わりに、カスタムスカラー型 (英語) を使用できます。 |
Boot スターターは、型 RuntimeWiringConfigurer の Bean を検出し、GraphQlSource.Builder に登録します。つまり、ほとんどの場合、構成には次のようなものがあります。
@Configuration
public class GraphQlConfig {
@Bean
public RuntimeWiringConfigurer runtimeWiringConfigurer(BookRepository repository) {
GraphQLScalarType scalarType = ... ;
SchemaDirectiveWiring directiveWiring = ... ;
return wiringBuilder -> wiringBuilder
.scalar(scalarType)
.directiveWiring(directiveWiring);
}
}WiringFactory を追加する必要がある場合。スキーマ定義を考慮して登録を行うには、RuntimeWiring.Builder と出力 List<WiringFactory> の両方を受け入れる代替 configure メソッドを実装します。これにより、任意の数のファクトリを追加して、順番に呼び出すことができます。
TypeResolver
GraphQlSource.Builder は、RuntimeWiringConfigurer を介してまだ登録されていない GraphQL インターフェースおよびユニオンに使用するデフォルトの TypeResolver として ClassNameTypeResolver を登録します。GraphQL Java の TypeResolver の目的は、GraphQL インターフェースまたは Union フィールドの DataFetcher から返される値の GraphQL オブジェクト型を決定することです。
ClassNameTypeResolver は、値の単純なクラス名を GraphQL オブジェクト型に一致させようとします。一致しない場合は、基本クラスやインターフェースを含むスーパー型をナビゲートして、一致を探します。ClassNameTypeResolver は、Class から GraphQL オブジェクト型名へのマッピングとともに、名前抽出関数を構成するオプションを提供します。これは、より多くのコーナーケースをカバーできます。
GraphQlSource.Builder builder = ...
ClassNameTypeResolver classNameTypeResolver = new ClassNameTypeResolver();
classNameTypeResolver.setClassNameExtractor((klass) -> {
// Implement Custom ClassName Extractor here
});
builder.defaultTypeResolver(classNameTypeResolver);Spring Boot でこれを構成する方法については、GraphQlSource セクションを参照してください。
ディレクティブ
GraphQL 言語は、「GraphQL ドキュメントで代替ランタイム実行と型検証動作を記述する」ディレクティブをサポートしています。ディレクティブは Java のアノテーションに似ていますが、GraphQL ドキュメントの型、フィールド、フラグメント、操作で宣言されています。
GraphQL Java は、アプリケーションがディレクティブを検出して処理するのに役立つ SchemaDirectiveWiring 契約を提供します。詳細については、GraphQL Java ドキュメントのスキーマディレクティブ (英語) を参照してください。
Spring GraphQL では、RuntimeWiringConfigurer を介して SchemaDirectiveWiring を登録できます。Boot スターターはそのような Bean を検出するため、次のようなものになる可能性があります。
@Configuration
public class GraphQlConfig {
@Bean
public RuntimeWiringConfigurer runtimeWiringConfigurer() {
return builder -> builder.directiveWiring(new MySchemaDirectiveWiring());
}
}| ディレクティブサポートの例については、Graphql Java の拡張検証 [GitHub] (英語) ライブラリを確認してください。 |
ExecutionStrategy
GraphQL Java の ExecutionStrategy は、リクエストされたフィールドのフェッチを実行します。ExecutionStrategy を作成するには、DataFetcherExceptionHandler を提供する必要があります。デフォルトでは、Spring for GraphQL は例外に従って使用する例外ハンドラーを作成し、それを GraphQL.Builder に設定します。次に、GraphQL Java はそれを使用して、構成された例外ハンドラーを持つ AsyncExecutionStrategy インスタンスを作成します。
カスタム ExecutionStrategy を作成する必要がある場合は、同じ方法で DataFetcherExceptionResolver を検出し、例外ハンドラーを作成し、それを使用してカスタム ExecutionStrategy を作成できます。例: Spring Boot アプリケーションの場合:
@Bean
GraphQlSourceBuilderCustomizer sourceBuilderCustomizer(
ObjectProvider<DataFetcherExceptionResolver> resolvers) {
DataFetcherExceptionHandler exceptionHandler =
DataFetcherExceptionResolver.createExceptionHandler(resolvers.stream().toList());
AsyncExecutionStrategy strategy = new CustomAsyncExecutionStrategy(exceptionHandler);
return sourceBuilder -> sourceBuilder.configureGraphQl(builder ->
builder.queryExecutionStrategy(strategy).mutationExecutionStrategy(strategy));
}スキーマ変換
スキーマの作成後にスキーマをトラバースして変換し、スキーマに変更を加えたい場合は、builder.schemaResources(..).typeVisitorsToTransformSchema(..) を介して graphql.schema.GraphQLTypeVisitor を登録できます。これはスキーマトラバーサルよりもコストがかかるため、スキーマを変更する必要がない限り、通常は変換よりもトラバーサルを優先することに注意してください。
スキーマトラバーサル
スキーマの作成後にスキーマをトラバースし、変更を GraphQLCodeRegistry に適用する場合は、builder.schemaResources(..).typeVisitors(..) を介して graphql.schema.GraphQLTypeVisitor を登録できます。ただし、そのような訪問者はスキーマを変更できないことに注意してください。スキーマを変更する必要がある場合は、スキーマ変換を参照してください。
スキーママッピングインスペクション
クエリ、ミューテーション、サブスクリプション操作に DataFetcher がない場合、データは返されず、役立つことは何もありません。同様に、DataFetcher 登録によって明示的にカバーされず、一致する Class プロパティを見つけるデフォルトの PropertyDataFetcher によって暗黙的にカバーされないスキーマ型のフィールドは、常に null になります。
GraphQL Java は、すべてのスキーマフィールドがカバーされていることを確認するためのチェックを実行しません。また、下位レベルのライブラリである GraphQL Java は、DataFetcher が何を返すことができるのか、どの引数に依存するのかを単に知らないため、そのような検証を実行できません。これにより、テストカバレッジによっては、クライアントで「サイレント」 null 値または null 以外のフィールドエラーが発生する可能性がある実行時までギャップが検出されない可能性があります。
Spring for GraphQL の SelfDescribingDataFetcher インターフェースを使用すると、DataFetcher は戻り値の型や予期される引数などの情報を公開できます。コントローラーメソッド、Querydsl、例示による問い合わせのすべての組み込み Spring DataFetcher 実装は、このインターフェースの実装です。アノテーション付きコントローラーの場合、戻り値の型と予期される引数はコントローラーのメソッドシグネチャーに基づきます。これにより、起動時にスキーママッピングをインスペクションして次のことを確認できるようになります。
スキーマフィールドには、
DataFetcher登録または対応するClassプロパティのいずれかがあります。DataFetcher登録は、存在するスキーマフィールドを参照します。DataFetcher引数には、一致するスキーマフィールド引数があります。
アプリケーションが Kotlin で記述されている場合、または null セーフアノテーションを使用している場合は、さらにインスペクションを実行できます。GraphQL スキーマは、null 許容型(Book)と null 非許容型(Book!)を宣言できます。これにより、アプリケーションがスキーマの null 許容要件に違反しないことを保証できます。
スキーマフィールドが非 null 値の場合、関連する Class プロパティと DataFetcher の戻り値型も非 null 値であることを確認します。逆の状況はエラーとはみなされません。つまり、スキーマに null 許容フィールド author: Author があり、アプリケーションが @NonNull Author getAuthor(); を宣言している場合、インスペクタはこれをエラーとして報告しません。アプリケーションは、スキーマ内のフィールドを必ずしも非 null 値にする必要はありません。データ取得操作中にエラーが発生すると、GraphQL エンジンは null が許可されるまで階層内のフィールドを強制的に null 値に設定します。部分レスポンスは GraphQL の重要な機能であるため、スキーマは null 値を考慮して設計する必要があります。
フィールド引数が NULL 値可能である場合、DataFetcher パラメーターも NULL 値可能であることを保証します。この場合、ユーザー入力が NULL 値許容規約に違反する場合には、実行時エラーにつながるため、アプリケーションに入力しないでください。
スキーマインスペクションを有効にするには、以下に示すように GraphQlSource.Builder をカスタマイズします。この場合、レポートは単にログに記録されますが、任意のアクションを実行することもできます。
GraphQlSource.Builder builder = ...
builder.schemaResources(..)
.inspectSchemaMappings(report -> {
logger.debug(report);
});レポートの例:
GraphQL schema inspection:
Unmapped fields: {Book=[title], Author[firstName, lastName]} (1)
Unmapped registrations: {Book.reviews=BookController#reviews[1 args]} (2)
Unmapped arguments: {BookController#bookSearch[1 args]=[myAuthor]} (3)
Field nullness errors: {Book=[title is NON_NULL -> 'Book#title' is NULLABLE]} (4)
Argument nullness errors: {BookController#bookById[1 args]=[java.lang.String id should be NULLABLE]} (5)
Skipped types: [BookOrAuthor] (6)| 1 | まったくカバーされていないスキーマフィールド |
| 2 | 存在しないフィールドへの DataFetcher 登録 |
| 3 | DataFetcher は存在しない引数を期待しました |
| 4 | "title" スキーマフィールドは null ではありませんが、Book.getTitle() は @Nullable です |
| 5 | bookById(id: ID) には null 可能な "id" 引数がありますが、Book bookById(@NonNull String id) は null ではありません。 |
| 6 | スキップされたスキーマ型 (次に説明します) |
場合によっては、スキーマ型の Class 型が不明であることがあります。おそらく DataFetcher が SelfDescribingDataFetcher を実装していないか、宣言された戻り値の型が一般的すぎるか (例: Object)、または不明であるか (例: List<?>)、あるいは DataFetcher が完全に欠落している可能性があります。このような場合、スキーマ型は検証できなかったためスキップされたものとしてリストされます。スキップされた型ごとに、スキップされた理由を説明する DEBUG メッセージが表示されます。
ユニオンとインターフェース
共用体の場合、インスペクションはメンバー型を反復処理し、対応するクラスを見つけようとします。インターフェースの場合、インスペクションは実装型を反復処理し、対応するクラスを探します。
デフォルトでは、次の場合に、対応する Java クラスがすぐに検出されます。
Classの単純名は、インターフェース実装型名の GraphQL ユニオンメンバーと一致し、Classは、ユニオンまたはインターフェースフィールドにマップされたコントローラーメソッドまたはコントローラークラスの戻り値の型と同じパッケージに配置されます。Classは、マップされたフィールドが具体的なユニオンメンバーまたはインターフェース実装型であるスキーマの他の部分でインスペクションされます。明示的な
Classから GraphQL 型へのマッピングを持つ TypeResolver を登録しました。
上記のヘルプのいずれにも該当せず、GraphQL 型がスキーマインスペクションレポートでスキップされたと報告される場合は、次のカスタマイズを行うことができます。
GraphQL 型名を Java クラスに明示的にマップします。
GraphQL 型名を単純な
Class名に適合させる方法をカスタマイズする関数を構成します。これは、特定の Java クラスの命名規則に役立ちます。GraphQL 型を Java クラスにマッピングするための
ClassNameTypeResolverを提供します。
例:
GraphQlSource.Builder builder = ...
builder.schemaResources(..)
.inspectSchemaMappings(
initializer -> initializer.classMapping("Author", Author.class)
logger::debug);オペレーションキャッシング
GraphQL Java は、操作を実行する前に、操作を解析して検証する必要があります。これは、パフォーマンスに大きな影響を与える可能性があります。再解析と検証の必要性を回避するために、アプリケーションは Document インスタンスをキャッシュして再利用する PreparsedDocumentProvider を構成できます。GraphQL Java ドキュメント (英語) は、PreparsedDocumentProvider を介したクエリキャッシュの詳細を提供します。
Spring GraphQL では、GraphQlSource.Builder#configureGraphQl を通じて PreparsedDocumentProvider を登録できます。
// Typically, accessed through Spring Boot's GraphQlSourceBuilderCustomizer
GraphQlSource.Builder builder = ...
// Create provider
PreparsedDocumentProvider provider =
new ApolloPersistedQuerySupport(new InMemoryPersistedQueryCache(Collections.emptyMap()));
builder.schemaResources(..)
.configureRuntimeWiring(..)
.configureGraphQl(graphQLBuilder -> graphQLBuilder.preparsedDocumentProvider(provider))Spring Boot でこれを構成する方法については、GraphQlSource セクションを参照してください。
スレッドモデル
ほとんどの GraphQL リクエストは、ネストされたフィールドの取得における同時実行の恩恵を受けます。これが、今日のほとんどのアプリケーションが GraphQL Java の AsyncExecutionStrategy に依存している理由です。これにより、データフェッチャーは CompletionStage を返し、シリアルではなく同時実行が可能になります。
Java 21 と仮想スレッドは、より多くのスレッドを効率的に使用するための重要な機能を追加しますが、リクエストの実行をより迅速に完了するには、シリアルではなく同時に実行する必要があります。
Spring for GraphQL は以下をサポートします。
リアクティブデータフェッチャーであり、これらは
AsyncExecutionStrategyによって期待されるようにCompletionStageに適応されます。戻り値として
CompletionStage。Kotlin コルーチンメソッドであるコントローラーメソッド。
@SchemaMapping および @BatchMapping メソッドは、Spring Framework
VirtualThreadTaskExecutorなどのExecutorに送信されたCallableを返すことができます。これを有効にするには、AnnotatedControllerConfigurerでExecutorを構成する必要があります。
Spring for GraphQL は、トランスポートとして Spring MVC または WebFlux のいずれかで実行されます。Spring MVC は、結果の CompletableFuture が GraphQL Java エンジンが返された直後に実行されない限り、非同期リクエスト実行を使用します。これは、リクエストが十分に単純で、非同期データフェッチを必要としない場合に当てはまります。
GraphQL リクエストのタイムアウト
GraphQL クライアントは、サーバー側で大量のリソースを消費するリクエストを送信することがあります。これを防ぐ方法は数多くありますが、その一つがリクエストタイムアウトの設定です。これにより、レスポンスの実現に時間がかかりすぎる場合、サーバー側でリクエストが確実に閉じられます。
Spring for GraphQL は、Web トランスポート用の TimeoutWebGraphQlInterceptor を提供します。アプリケーションは、このインターセプターにタイムアウト時間を設定できます。リクエストがタイムアウトすると、サーバーは特定の HTTP ステータスでエラーを返します。この場合、インターセプターはチェーンに「キャンセル」シグナルを送信し、リアクティブデータフェッチャーは進行中の作業を自動的にキャンセルします。
このインターセプターは WebGraphQlHandler 上で構成できます。
TimeoutWebGraphQlInterceptor timeoutInterceptor = new TimeoutWebGraphQlInterceptor(Duration.ofSeconds(5));
WebGraphQlHandler webGraphQlHandler = WebGraphQlHandler
.builder(executionGraphQlService)
.interceptor(timeoutInterceptor)
.build();
GraphQlHttpHandler httpHandler = new GraphQlHttpHandler(webGraphQlHandler);Spring Boot アプリケーションでは、インターセプターを Bean として提供するだけで十分です。
import java.time.Duration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.graphql.server.TimeoutWebGraphQlInterceptor;
@Configuration(proxyBeanMethods = false)
public class HttpTimeoutConfiguration {
@Bean
public TimeoutWebGraphQlInterceptor timeoutWebGraphQlInterceptor() {
return new TimeoutWebGraphQlInterceptor(Duration.ofSeconds(5));
}
} よりトランスポート固有のタイムアウトについては、GraphQlWebSocketHandler や GraphQlSseHandler などのハンドラー実装に専用のプロパティがあります。
リアクティブ DataFetcher
デフォルトの GraphQlSource ビルダーは、DataFetcher が Mono または Flux を返すためのサポートを有効にします。これは、Flux 値が集約されてリストに変換される CompletableFuture に適応させます。ただし、リクエストが GraphQL サブスクリプションリクエストである場合を除きます。この場合、戻り値は Reactive Streams Publisher のままです。GraphQL レスポンスのストリーミング用。
リアクティブ DataFetcher は、トランスポート層 (WebFlux リクエスト処理など) から伝播された Reactor コンテキストへのアクセスに依存できます。WebFlux コンテキストを参照してください。
サブスクリプションリクエストの場合、GraphQL Java は、アイテムが利用可能になり、リクエストされたフィールドがすべてフェッチされるとすぐにアイテムを生成します。これには複数の非同期データフェッチレイヤーが含まれるため、アイテムは元の順序とは別の順序でネットワーク経由で送信される可能性があります。GraphQL Java でアイテムをバッファリングして元の順序を維持するには、GraphQLContext で SubscriptionExecutionStrategy.KEEP_SUBSCRIPTION_EVENTS_ORDERED 構成フラグを設定します。これは、たとえばカスタム Instrumentation を使用して実行できます。
import graphql.ExecutionResult;
import graphql.execution.SubscriptionExecutionStrategy;
import graphql.execution.instrumentation.InstrumentationContext;
import graphql.execution.instrumentation.InstrumentationState;
import graphql.execution.instrumentation.SimpleInstrumentationContext;
import graphql.execution.instrumentation.SimplePerformantInstrumentation;
import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class GraphQlConfig {
@Bean
public SubscriptionOrderInstrumentation subscriptionOrderInstrumentation() {
return new SubscriptionOrderInstrumentation();
}
static class SubscriptionOrderInstrumentation extends SimplePerformantInstrumentation {
@Override
public InstrumentationContext<ExecutionResult> beginExecution(InstrumentationExecutionParameters parameters,
InstrumentationState state) {
// Enable option for keeping subscription results in upstream order
parameters.getGraphQLContext().put(SubscriptionExecutionStrategy.KEEP_SUBSCRIPTION_EVENTS_ORDERED, true);
return SimpleInstrumentationContext.noOp();
}
}
}コンテキストの伝播
Spring for GraphQL は、HTTP トランスポートから GraphQL Java を介して、DataFetcher およびそれが呼び出す他のコンポーネントにコンテキストを透過的に伝播するためのサポートを提供します。これには、Spring MVC リクエスト処理スレッドからの ThreadLocal コンテキストと WebFlux 処理パイプラインからの Reactor Context の両方が含まれます。
WebMvc
GraphQL Java によって呼び出される DataFetcher およびその他のコンポーネントは、たとえば非同期 WebGraphQlInterceptor または DataFetcher が別のスレッドに切り替わる場合など、常に Spring MVC ハンドラーと同じスレッドで実行されるとは限りません。
Spring for GraphQL は、サーブレットコンテナースレッドから、実行するために GraphQL Java によって呼び出される DataFetcher およびその他のコンポーネントのスレッドへの ThreadLocal 値の伝播をサポートします。これを行うには、対象の ThreadLocal 値に対してアプリケーションで io.micrometer.context.ThreadLocalAccessor を実装する必要があります。
public class RequestAttributesAccessor implements ThreadLocalAccessor<RequestAttributes> {
@Override
public Object key() {
return RequestAttributesAccessor.class.getName();
}
@Override
public RequestAttributes getValue() {
return RequestContextHolder.getRequestAttributes();
}
@Override
public void setValue(RequestAttributes attributes) {
RequestContextHolder.setRequestAttributes(attributes);
}
@Override
public void reset() {
RequestContextHolder.resetRequestAttributes();
}
}io.micrometer.context.ContextRegistry#getInstance() 経由でアクセスできるグローバル ContextRegistry インスタンスを使用して、起動時に ThreadLocalAccessor を手動で登録できます。java.util.ServiceLoader メカニズムを介して自動的に登録することもできます。
WebFlux
リアクティブ DataFetcher は、チェーンを処理する WebFlux リクエストから発生する Reactor コンテキストへのアクセスに依存できます。これには、WebGraphQlInterceptor コンポーネントによって追加された Reactor コンテキストが含まれます。
例外
GraphQL Java では、DataFetcherExceptionHandler は、レスポンスの「エラー」セクションでデータ取得の例外を表現する方法を決定します。アプリケーションは単一のハンドラーのみを登録できます。
Spring for GraphQL は、デフォルトの処理を提供し、DataFetcherExceptionResolver 契約を有効にする DataFetcherExceptionHandler を登録します。アプリケーションは GraphQLSource ビルダーを介して任意の数のリゾルバーを登録でき、それらは Exception を List<graphql.GraphQLError> に解決するまで順番に登録されます。Spring Boot スターターは、この型の Bean を検出します。
DataFetcherExceptionResolverAdapter は、protected メソッド resolveToSingleError および resolveToMultipleErrors を備えた便利な基本クラスです。
アノテーション付きコントローラープログラミングモデルでは、柔軟なメソッドシグネチャーを持つアノテーション付き例外ハンドラーメソッドを使用してデータフェッチ例外を処理できます。詳細については、@GraphQlExceptionHandler を参照してください。
GraphQLError は、以下を定義する GraphQL Java graphql.ErrorClassification または Spring GraphQL ErrorType に基づいてカテゴリに割り当てることができます。
BAD_REQUESTUNAUTHORIZEDFORBIDDENNOT_FOUNDINTERNAL_ERROR
例外が未解決のままの場合、デフォルトでは、カテゴリ名と DataFetchingEnvironment からの executionId を含む一般的なメッセージを持つ INTERNAL_ERROR として分類されます。実装の詳細が漏洩しないように、メッセージは意図的に不透明になっています。アプリケーションは、DataFetcherExceptionResolver を使用してエラーの詳細をカスタマイズできます。
未解決の例外は、executionId とともに ERROR レベルでログに記録され、クライアントに送信されたエラーに関連付けられます。解決された例外は DEBUG レベルで記録されます。
例外のリクエスト
GraphQL Java エンジンは、リクエストの解析時に検証またはその他のエラーが発生し、リクエストの実行が妨げられる場合があります。このような場合、レスポンスには、null を含む「データ」キーと、グローバルな、つまりフィールドパスがない 1 つ以上のリクエストレベルの「エラー」が含まれます。
DataFetcherExceptionResolver は、実行が開始される前、DataFetcher が呼び出される前に発生するため、このようなグローバルエラーを処理できません。アプリケーションは、トランスポートレベルのインターセプターを使用して、ExecutionResult のエラーをインスペクションおよび変換できます。WebGraphQlInterceptor の例を参照してください。
サブスクリプションの例外
サブスクリプションリクエストの Publisher は、エラーシグナルで完了する場合があります。この場合、基になるトランスポート (WebSocket など) は、GraphQL エラーのリストを含む最終的な「エラー」型のメッセージを送信します。
データ DataFetcher は最初に Publisher を作成するだけなので、DataFetcherExceptionResolver はサブスクリプション Publisher からのエラーを解決できません。その後、トランスポートは Publisher にサブスクライブし、エラーで完了する可能性があります。
アプリケーションは、サブスクリプション Publisher からの例外を解決して GraphQL エラーに解決し、クライアントに送信するために SubscriptionExceptionResolver を登録できます。
ページネーション
GraphQL カーソル接続仕様 (英語) は、各アイテムがカーソルとペアになっているアイテムのサブセットを一度に返すことで、大規模な結果セットをナビゲートする方法を定義します。カーソルを使用すると、クライアントは参照されたアイテムの前または後にさらにアイテムをリクエストできます。
仕様ではこのパターンを「接続」と呼んでおり、名前が ~Connection で終わるスキーマ型はページ分割された結果セットを表す接続型です。すべての接続型には「エッジ」と呼ばれるフィールドが含まれており、~Edge 型には実際のアイテム、カーソル、前方および後方にさらにアイテムが存在するかどうかを示す "pageInfo" と呼ばれるフィールドが含まれます。
接続タイプ
接続型には、明示的に宣言されていない場合、Spring for GraphQL の ConnectionTypeDefinitionConfigurer が起動時に透過的に追加できる定型定義が必要です。つまり、必要なのは以下のみで、接続型とエッジ型は自動的に追加されます。
type Query {
books(first:Int, after:String, last:Int, before:String): BookConnection
}
type Book {
id: ID!
title: String!
} 仕様で定義されている前方ページネーションの first および after 引数により、クライアントは特定のカーソルの「後」にある「最初の」N 項目をリクエストできます。同様に、後方ページネーション引数の last および before 引数により、特定のカーソルの「前」にある「最後の」N 項目をリクエストできます。
仕様では、first と last の両方を含めることは推奨されておらず、ページ区切りの結果が不明確になることも示されています。Spring for GraphQL では、first または after が存在する場合、last と before は無視されます。 |
接続型を生成するには、ConnectionTypeDefinitionConfigurer を次のように構成します。
GraphQlSource.schemaResourceBuilder()
.schemaResources(..)
.typeDefinitionConfigurer(new ConnectionTypeDefinitionConfigurer)上記により、次の型定義が追加されます。
type BookConnection {
edges: [BookEdge]!
pageInfo: PageInfo!
}
type BookEdge {
node: Book!
cursor: String!
}
type PageInfo {
hasPreviousPage: Boolean!
hasNextPage: Boolean!
startCursor: String
endCursor: String
}Boot スターターはデフォルトで ConnectionTypeDefinitionConfigurer を登録します。
ConnectionAdapter
スキーマの接続タイプに加えて、同等の Java 型も必要になります。GraphQL Java は、汎用の Connection および Edge 型、PageInfo など、提供します。
コントローラーメソッドから Connection を返すことはできますが、基礎となるデータページ区切りメカニズムを Connection に適合させ、カーソルを作成し、~Edge ラッパーを追加し、PageInfo を作成するための定型コードが必要になります。
Spring for GraphQL は、アイテムのコンテナーを Connection に適合させるための ConnectionAdapter 契約を定義します。アダプターは、DataFetcher デコレータから呼び出され、ConnectionFieldTypeVisitor によって追加されます。次のように構成できます。
ConnectionAdapter adapter = ... ;
GraphQLTypeVisitor visitor = ConnectionFieldTypeVisitor.create(List.of(adapter)) (1)
GraphQlSource.schemaResourceBuilder()
.schemaResources(..)
.typeDefinitionConfigurer(..)
.typeVisitors(List.of(visitor)) (2)| 1 | 1 つ以上の ConnectionAdapter を使用して型 ビジターを作成します。 |
| 2 | 型ビジターを登録します。 |
Spring Data の Window と Slice には、組み込みの ConnectionAdapter があります。独自のカスタムアダプターを作成することもできます。ConnectionAdapter 実装は、返される項目のカーソルを作成するために CursorStrategy に依存します。同じ戦略は、ページ区切り入力を含む Subrange コントローラーメソッド引数をサポートするためにも使用されます。
CursorStrategy
CursorStrategy は、大きな結果セット内の項目の位置を参照する String カーソルをエンコードおよびデコードするための規約です。カーソルはインデックスまたはキーセットに基づくことができます。
ConnectionAdapter はこれを使用して、返された項目のカーソルをエンコードします。アノテーション付きコントローラーメソッド、Querydsl リポジトリ、および例示による問い合わせリポジトリは、これを使用してページ分割リクエストからカーソルをデコードし、Subrange を作成します。
CursorEncoder は、文字列カーソルをさらにエンコードおよびデコードして、クライアントに対して不透明にする関連契約です。EncodingCursorStrategy は CursorStrategy と CursorEncoder を組み合わせたものです。Base64CursorEncoder、NoOpEncoder を使用することも、独自に作成することもできます。
Spring Data ScrollPosition には CursorStrategy が内蔵されています。Spring Data が存在する場合、Boot スターターは CursorStrategy<ScrollPosition> を Base64Encoder に登録します。
ソート
GraphQL リクエストでソート情報を提供する標準的な方法はありません。ただし、ページネーションは安定した並べ替え順序に依存します。デフォルトの順序を使用することも、入力型を公開して GraphQL 引数からソートの詳細を抽出することもできます。
コントローラーメソッドの引数として、Spring Data の Sort のサポートが組み込まれています。これが機能するには、SortStrategy Bean が必要です。
バッチ読み込み
Book とその Author を指定すると、書籍用に 1 つの DataFetcher を作成し、その作成者用に別の DataFetcher を作成できます。これにより、作成者の有無にかかわらず本を選択できますが、本と作成者が一緒にロードされないことを意味します。これは、各本の作成者が個別にロードされるため、複数の本を照会する場合に特に効率的ではありません。これは、N+1 選択問題として知られています。
DataLoader
GraphQL Java は、関連するエンティティをバッチで読み込むための DataLoader メカニズムを提供します。詳細は GraphQL Java ドキュメント (英語) で確認できます。以下は、その仕組みの概要です。
一意のキーを指定して、エンティティをロードできる
DataLoaderRegistryにDataLoaderを登録します。DataFetcherはDataLoaderにアクセスし、使用して ID 別にエンティティをロードできます。DataLoaderは、Future を返すことで読み込みを延期し、バッチで実行できるようにします。DataLoaderは、ロードされたエンティティのリクエストごとのキャッシュを維持し、効率をさらに向上させます。
BatchLoaderRegistry
GraphQL Java の完全なバッチ読み込みメカニズムでは、いくつかの BatchLoader インターフェースの 1 つを実装し、DataLoader としてラップして DataLoaderRegistry の名前で登録する必要があります。
Spring GraphQL の API は少し異なります。登録の場合、ファクトリメソッドを公開する主要な BatchLoaderRegistry と、任意の数のバッチロード関数を作成および登録するためのビルダーが 1 つだけあります。
@Configuration
public class MyConfig {
public MyConfig(BatchLoaderRegistry registry) {
registry.forTypePair(Long.class, Author.class).registerMappedBatchLoader((authorIds, env) -> {
// return Mono<Map<Long, Author>
});
// more registrations ...
}
}Boot スターターは、上記のように構成に挿入できる BatchLoaderRegistry Bean を宣言するか、バッチ読み込み関数を登録するためにコントローラーなどの任意のコンポーネントに挿入できます。次に、BatchLoaderRegistry は DefaultExecutionGraphQlService に注入され、リクエストごとの DataLoader 登録を保証します。
デフォルトでは、DataLoader 名はターゲットエンティティのクラス名に基づいています。これにより、@SchemaMapping メソッドはジェネリクス型で DataLoader 引数を宣言でき、名前を指定する必要はありません。ただし、名前は、必要に応じて他の DataLoaderOptions と共に BatchLoaderRegistry ビルダーを介してカスタマイズできます。
デフォルトの DataLoaderOptions をグローバルに構成し、登録の開始点として使用するには、Boot の BatchLoaderRegistry Bean をオーバーライドし、Supplier<DataLoaderOptions> を受け入れる DefaultBatchLoaderRegistry のコンストラクターを使用できます。
多くの場合、関連するエンティティをロードするときに、@BatchMapping コントローラーメソッドを使用できます。これは、BatchLoaderRegistry および DataLoader を直接使用する必要性を回避するショートカットです。
BatchLoaderRegistry には他にも重要な利点があります。バッチ読み込み関数と @BatchMapping メソッドから同じ GraphQLContext へのアクセスをサポートし、それらへのコンテキストの伝播を保証します。これが、アプリケーションがそれを使用することが期待される理由です。独自の DataLoader 登録を直接実行することは可能ですが、そのような登録では上記の利点が失われます。
レシピのバッチロード
単純なケースでは、定型文を最小限に抑えられる @BatchMapping アノテーションが最適な選択肢となることがよくあります。より高度なユースケースでは、BatchLoaderRegistry の方が柔軟性が高くなります。
上記の通りおよび DataLoader は load() 呼び出しをキューに入れ、すべて一度に、またはバッチでディスパッチします。つまり、1 回のディスパッチで、異なる @SchemaMapping 呼び出しと異なる GraphQL コンテキストのエンティティをロードできます。ロードされたエンティティは、リクエストの存続期間中、GraphQL Java によってキーごとにキャッシュされるため、開発者はメモリ消費と I/O 呼び出し回数を最適化するためのさまざまな戦略を検討する必要があります。
次のセクションでは、友人に関する情報を読み込むための以下のスキーマについて考えてみましょう。友人をフィルタリングし、特定の飲み物を好む友人だけを読み込むことができることに注目してください。
type Query {
me: Person
people: [Person]
}
input FriendsFilter {
favoriteBeverage: String
}
type Person {
id: ID!
name: String
favoriteBeverage: String
friends(filter: FriendsFilter): [Person]
} この問題への対処法としては、まず DataLoader に特定の人物の友達全員を読み込み、次に @SchemaMapping レベルで不要な友達を除外するという方法があります。これにより、DataLoader キャッシュに Person インスタンスがさらに多く読み込まれ、メモリ使用量も増加しますが、I/O 呼び出し回数は減少する可能性があります。
public FriendsControllerFiltering(BatchLoaderRegistry registry) {
registry.forTypePair(Integer.class, Person.class).registerMappedBatchLoader((personIds, env) -> {
Map<Integer, Person> friends = new HashMap<>();
personIds.forEach((personId) -> friends.put(personId, this.people.get(personId))); (1)
return Mono.just(friends);
});
}
@QueryMapping
public Person me() {
return ...
}
@QueryMapping
public Collection<Person> people() {
return ...
}
@SchemaMapping
public CompletableFuture<List<Person>> friends(Person person, @Argument FriendsFilter filter, DataLoader<Integer, Person> dataLoader) {
return dataLoader
.loadMany(person.friendsId())
.thenApply(filter::apply); (2)
}
public record FriendsFilter(String favoriteBeverage) {
List<Person> apply(List<Person> friends) {
return friends.stream()
.filter((person) -> person.favoriteBeverage.equals(this.favoriteBeverage))
.toList();
}
}| 1 | すべての友達を取得し、フィルターを適用せず、ID ごとに Person をキャッシュする |
| 2 | すべての友達を読み込み、指定されたフィルターを適用します |
これは、つながりの深い友人同士の少人数グループや、人気の飲み物を扱う場合に適しています。一方、共通の友人が少ない大人数のグループや、よりニッチな飲み物を扱う場合、クライアントに実際に送信されるエントリ数件だけでも、大量のデータをメモリにロードしてしまうリスクがあります。
ここでは、人物と選択したフィルターという合成キーを持つエンティティをバッチロードするという別の戦略を採用できます。このアプローチでは、キャッシュ内で Person が重複する可能性と I/O 操作の増加という犠牲を払って、メモリに必要なエンティティだけをロードします。
public FriendsControllerComposedKey(BatchLoaderRegistry registry) {
registry.forTypePair(FriendFilterKey.class, Person[].class).registerMappedBatchLoader((keys, env) -> {
return dataStore.load(keys);
Map<FriendFilterKey, Person[]> result = new HashMap<>();
keys.forEach((key) -> { (2)
Person[] friends = key.person().friendsId().stream()
.map(this.people::get)
.filter((friend) -> key.friendsFilter().matches(friend))
.toArray(Person[]::new);
result.put(key, friends);
});
return Mono.just(result);
});
}
@QueryMapping
public Person me() {
return ...
}
@QueryMapping
public Collection<Person> people() {
return ...
}
@SchemaMapping
public CompletableFuture<Person[]> friends(Person person, @Argument FriendsFilter filter, DataLoader<FriendFilterKey, Person[]> dataLoader) {
return dataLoader.load(new FriendFilterKey(person, filter));
}
public record FriendsFilter(String favoriteBeverage) {
boolean matches(Person friend) {
return friend.favoriteBeverage.equals(this.favoriteBeverage);
}
}
public record FriendFilterKey(Person person, FriendsFilter friendsFilter) { (1)
}| 1 | このキーには人物とフィルターの両方が含まれているため、同じ友達を複数回取得する必要があります。 |
どちらの場合も、クエリは次のようになります。
query {
me {
name
friends(filter: {favoriteBeverage: "tea"}) {
name
favoriteBeverage
}
}
people {
name
friends(filter: {favoriteBeverage: "coffee"}) {
name
favoriteBeverage
}
}
}次の結果が得られます。
{
"data": {
"me": {
"name": "Brian",
"friends": [
{
"name": "Donna",
"favoriteBeverage": "tea"
}
]
},
"people": [
{
"name": "Andi",
"friends": [
{
"name": "Rossen",
"favoriteBeverage": "coffee"
},
{
"name": "Brad",
"favoriteBeverage": "coffee"
}
]
},
{
"name": "Brad",
"friends": [
{
"name": "Rossen",
"favoriteBeverage": "coffee"
},
{
"name": "Andi",
"favoriteBeverage": "coffee"
}
]
},
{
"name": "Donna",
"friends": [
{
"name": "Rossen",
"favoriteBeverage": "coffee"
},
{
"name": "Brad",
"favoriteBeverage": "coffee"
}
]
},
{
"name": "Brian",
"friends": [
{
"name": "Rossen",
"favoriteBeverage": "coffee"
}
]
},
{
"name": "Rossen",
"friends": []
}
]
}
}バッチ読み込みのテスト
BatchLoaderRegistry に DataLoaderRegistry で登録を実行させることから始めます。
BatchLoaderRegistry batchLoaderRegistry = new DefaultBatchLoaderRegistry();
// perform registrations...
DataLoaderRegistry dataLoaderRegistry = DataLoaderRegistry.newRegistry().build();
batchLoaderRegistry.registerDataLoaders(dataLoaderRegistry, graphQLContext); これで、次のように個々の DataLoader にアクセスしてテストできます。
DataLoader<Long, Book> loader = dataLoaderRegistry.getDataLoader(Book.class.getName());
loader.load(1L);
loader.loadMany(Arrays.asList(2L, 3L));
List<Book> books = loader.dispatchAndJoin(); // actual loading
assertThat(books).hasSize(3);
assertThat(books.get(0).getName()).isEqualTo("...");
// ...