データ統合

Spring for GraphQL を使用すると、既存の Spring テクノロジを活用し、一般的なプログラミングモデルに従って、GraphQL を介して基になるデータソースを公開できます。

このセクションでは、@GraphQlRepository でマークされたリポジトリの自動検出および GraphQL クエリ登録のオプションを含む、Querydsl または Query by Example リポジトリを DataFetcher に適応させる簡単な方法を提供する Spring Data の統合レイヤーについて説明します。

Querydsl

Spring for GraphQL は、Querydsl (英語) を使用して Spring Data QueryDSL 拡張機能を介してデータをフェッチすることをサポートしています。Querydsl は、アノテーションプロセッサーを使用してメタモデルを生成することにより、クエリ述語を表現するための柔軟で型安全なアプローチを提供します。

例: リポジトリを QuerydslPredicateExecutor として宣言します。

public interface AccountRepository extends Repository<Account, Long>,
			QuerydslPredicateExecutor<Account> {
}

次に、それを使用して DataFetcher を作成します。

// For single result queries
DataFetcher<Account> dataFetcher =
		QuerydslDataFetcher.builder(repository).single();

// For multi-result queries
DataFetcher<Iterable<Account>> dataFetcher =
		QuerydslDataFetcher.builder(repository).many();

// For paginated queries
DataFetcher<Iterable<Account>> dataFetcher =
		QuerydslDataFetcher.builder(repository).scrollable();

上記の DataFetcher を RuntimeWiringConfigurer で登録できるようになりました。

DataFetcher は、GraphQL 引数から Querydsl Predicate を構築し、それを使用してデータをフェッチします。Spring Data は、JPA、MongoDB、Neo4j、LDAP の QuerydslPredicateExecutor をサポートします。

GraphQL 入力型である単一の引数の場合、QuerydslDataFetcher は 1 レベル下にネストし、引数のサブマップの値を使用します。

リポジトリが ReactiveQuerydslPredicateExecutor の場合、ビルダーは DataFetcher<Mono<Account>> または DataFetcher<Flux<Account>> を返します。Spring Data は、MongoDB および Neo4j のこのバリアントをサポートしています。

ビルドのセットアップ

ビルドで Querydsl を構成するには、公式のリファレンスドキュメント (英語) に従ってください。

例:

dependencies {
	//...

	annotationProcessor "com.querydsl:querydsl-apt:$querydslVersion:jakarta",
			'jakarta.persistence:jakarta.persistence-api'
}

compileJava {
	options.annotationProcessorPath = configurations.annotationProcessor
}
<build>
	<plugins>
			<plugin>
				<groupId>org.apache.maven.plugins</groupId>
				<artifactId>maven-compiler-plugin</artifactId>
				<configuration>
					<annotationProcessorPaths>
						<!-- Explicit opt-in required via annotationProcessors or
										annotationProcessorPaths on Java 22+, see https://bugs.openjdk.org/browse/JDK-8306819 -->
						<annotationProcessorPath>
							<groupId>com.querydsl</groupId>
							<artifactId>querydsl-apt</artifactId>
							<version>${querydsl.version}</version>
							<classifier>jakarta</classifier>
						</annotationProcessorPath>
						<annotationProcessorPath>
							<groupId>jakarta.persistence</groupId>
							<artifactId>jakarta.persistence-api</artifactId>
						</annotationProcessorPath>
					</annotationProcessorPaths>

					<!-- Recommended: Some IDE's might require this configuration to include generated sources for IDE usage -->
					<generatedTestSourcesDirectory>target/generated-test-sources</generatedTestSourcesDirectory>
					<generatedSourcesDirectory>target/generated-sources</generatedSourcesDirectory>
				</configuration>
			</plugin>
	</plugins>
</build>

カスタム

QuerydslDataFetcher は、GraphQL 引数をプロパティにバインドして Querydsl Predicate を作成する方法のカスタマイズをサポートしています。デフォルトでは、引数は使用可能な各プロパティに対して「等しい」としてバインドされます。これをカスタマイズするには、QuerydslDataFetcher ビルダーメソッドを使用して QuerydslBinderCustomizer を提供します。

リポジトリ自体が QuerydslBinderCustomizer のインスタンスである場合があります。これは自動検出され、自動登録中に透過的に適用されます。ただし、手動で QuerydslDataFetcher をビルドする場合は、ビルダーメソッドを使用して適用する必要があります。

QuerydslDataFetcher はインターフェースと DTO 射影をサポートしてクエリ結果を変換してから、これらをさらに GraphQL 処理のために返します。

射影とは何かについては、Spring Data ドキュメントを参照してください。GraphQL で射影を使用する方法を理解するには、選択セットと射影を参照してください。

Querydsl リポジトリで Spring Data 射影を使用するには、射影 インターフェースまたはターゲット DTO クラスのいずれかを作成し、projectAs メソッドを介して構成して、ターゲット型を生成する DataFetcher を取得します。

class Account {

	String name, identifier, description;

	Person owner;
}

interface AccountProjection {

	String getName();

	String getIdentifier();
}

// For single result queries
DataFetcher<AccountProjection> dataFetcher =
		QuerydslDataFetcher.builder(repository).projectAs(AccountProjection.class).single();

// For multi-result queries
DataFetcher<Iterable<AccountProjection>> dataFetcher =
		QuerydslDataFetcher.builder(repository).projectAs(AccountProjection.class).many();

自動登録

リポジトリに @GraphQlRepository のアノテーションが付けられている場合、DataFetcher がまだ登録されておらず、戻り値の型がリポジトリのドメイン型と一致するクエリに対して自動的に登録されます。これには、単一値クエリ、複数値クエリ、ページ分割されたクエリが含まれます。

デフォルトでは、クエリによって返される GraphQL 型の名前は、リポジトリドメイン型の単純な名前と一致する必要があります。必要に応じて、@GraphQlRepository の typeName 属性を使用して、ターゲットの GraphQL 型名を指定できます。

ページ分割されたクエリの場合、リポジトリドメイン型の単純名は、末尾 Connection を除いた Connection 型名と一致する必要があります (例: Book は BooksConnection と一致します)。自動登録の場合、ページ分割はオフセットベースで、1 ページあたり 20 項目になります。

自動登録は、特定のリポジトリが QuerydslBinderCustomizer を実装しているかどうかを検出し、QuerydslDataFetcher ビルダーメソッドを介して透過的に適用します。

自動登録は、QuerydslDataFetcher から取得できる組み込みの RuntimeWiringConfigurer を介して実行されます。Boot スターターは @GraphQlRepository Bean を自動的に検出し、使用して RuntimeWiringConfigurer を初期化します。

リポジトリがそれぞれ QuerydslBuilderCustomizer または ReactiveQuerydslBuilderCustomizer を実装している場合、自動登録はリポジトリインスタンスで customize(Builder) を呼び出すことによってカスタマイズを適用します。

例示による問い合わせ

Spring Data は、例示による問い合わせを使用したデータのフェッチをサポートしています。例示による問い合わせ (QBE) は、ストア固有のクエリ言語を使用してクエリを記述する必要のない単純なクエリ手法です。

QueryByExampleExecutor であるリポジトリを宣言することから始めます。

public interface AccountRepository extends Repository<Account, Long>,
			QueryByExampleExecutor<Account> {
}

QueryByExampleDataFetcher を使用して、リポジトリを DataFetcher に変換します。

// For single result queries
DataFetcher<Account> dataFetcher =
		QueryByExampleDataFetcher.builder(repository).single();

// For multi-result queries
DataFetcher<Iterable<Account>> dataFetcher =
		QueryByExampleDataFetcher.builder(repository).many();

// For paginated queries
DataFetcher<Iterable<Account>> dataFetcher =
		QueryByExampleDataFetcher.builder(repository).scrollable();

上記の DataFetcher を RuntimeWiringConfigurer で登録できるようになりました。

DataFetcher は、GraphQL 引数マップを使用してリポジトリのドメイン型を作成し、それをサンプルオブジェクトとして使用してデータをフェッチします。Spring Data は、JPA、MongoDB、Neo4j、Redis の QueryByExampleDataFetcher をサポートします。

GraphQL 入力型である単一の引数の場合、QueryByExampleDataFetcher は 1 レベル下にネストし、引数のサブマップの値とバインドします。

リポジトリが ReactiveQueryByExampleExecutor の場合、ビルダーは DataFetcher<Mono<Account>> または DataFetcher<Flux<Account>> を返します。Spring Data は、MongoDB、Neo4j、Redis、R2dbc のこのバリアントをサポートしています。

ビルドのセットアップ

例示による問い合わせは、それがサポートされているデータストアの Spring Data モジュールにすでに含まれているため、有効にするために追加のセットアップは必要ありません。

カスタム

QueryByExampleDataFetcher はインターフェースと DTO 射影をサポートしてクエリ結果を変換してから、これらをさらに GraphQL 処理のために返します。

射影とは何かについては、Spring Data ドキュメントを参照してください。GraphQL での射影のロールを理解するには、選択セットと射影を参照してください。

例示による問い合わせ リポジトリで Spring Data 射影を使用するには、射影 インターフェースまたはターゲット DTO クラスのいずれかを作成し、projectAs メソッドを介して構成して、ターゲット型を生成する DataFetcher を取得します。

class Account {

	String name, identifier, description;

	Person owner;
}

interface AccountProjection {

	String getName();

	String getIdentifier();
}

// For single result queries
DataFetcher<AccountProjection> dataFetcher =
		QueryByExampleDataFetcher.builder(repository).projectAs(AccountProjection.class).single();

// For multi-result queries
DataFetcher<Iterable<AccountProjection>> dataFetcher =
		QueryByExampleDataFetcher.builder(repository).projectAs(AccountProjection.class).many();

自動登録

リポジトリに @GraphQlRepository のアノテーションが付けられている場合、DataFetcher がまだ登録されておらず、戻り値の型がリポジトリのドメイン型と一致するクエリに対して自動的に登録されます。これには、単一値クエリ、複数値クエリ、ページ分割されたクエリが含まれます。

デフォルトでは、クエリによって返される GraphQL 型の名前は、リポジトリドメイン型の単純な名前と一致する必要があります。必要に応じて、@GraphQlRepository の typeName 属性を使用して、ターゲットの GraphQL 型名を指定できます。

ページ分割されたクエリの場合、リポジトリドメイン型の単純名は、末尾 Connection を除いた Connection 型名と一致する必要があります (例: Book は BooksConnection と一致します)。自動登録の場合、ページ分割はオフセットベースで、1 ページあたり 20 項目になります。

自動登録は、QueryByExampleDataFetcher から取得できる組み込みの RuntimeWiringConfigurer を介して実行されます。Boot スターターは @GraphQlRepository Bean を自動的に検出し、使用して RuntimeWiringConfigurer を初期化します。

リポジトリがそれぞれ QueryByExampleBuilderCustomizer または ReactiveQueryByExampleBuilderCustomizer を実装している場合、自動登録はリポジトリインスタンスで customize(Builder) を呼び出すことによってカスタマイズを適用します。

選択セットと射影

発生する一般的な質問は、GraphQL 選択セットを Spring Data Projection と比較して、それぞれがどのようなロールを果たしているのかということです。

簡単に言えば、Spring for GraphQL は、GraphQL クエリを直接 SQL または JSON クエリに変換するデータゲートウェイではないということです。代わりに、既存の Spring テクノロジを活用でき、GraphQL スキーマと基礎となるデータモデル間の 1 対 1 のマッピングを想定していません。そのため、データモデルのクライアント主導の選択とサーバー側の変換が補完的なロールを果たすことができます。

理解を深めるために、データ層の複雑さを管理するための推奨されるアプローチとして、Spring Data がドメイン駆動 (DDD) 設計を推進していることを考慮してください。DDD では、集約の制約に従うことが重要です。部分的にロードされた集約は集約機能に制限を課す可能性があるため、定義上、集約は完全にロードされた場合にのみ有効です。

Spring Data では、集約をそのまま公開するか、GraphQL の結果として返す前にデータモデルに変換を適用するかを選択できます。前者を実行するだけで十分な場合もあります。デフォルトでは、Querydsl例示による問い合わせの統合により、GraphQL の選択セットが、基になる Spring Data モジュールが選択を制限するために使用するプロパティパスのヒントに変わります。

それ以外の場合は、GraphQL スキーマに適応するために、基礎となるデータモデルを縮小または変換することが役立ちます。Spring Data は、インターフェースと DTO 射影を通じてこれをサポートします。

インターフェース射影は、データストアクエリの結果に応じて、プロパティが null である場合とそうでない場合がある場所を公開する固定のプロパティセットを定義します。インターフェース射影には 2 種類あり、どちらも基になるデータソースからどのプロパティを読み込むかを決定します。

DTO 射影は、コンストラクターまたは getter メソッドのいずれかに変換コードを配置できるため、より高いレベルのカスタマイズを提供します。

DTO 射影は、個々のプロパティが射影自体によって決定されるクエリから具体化されます。DTO 射影は、通常、完全な引数のコンストラクター (Java レコードなど) で使用されるため、必要なすべてのフィールド (または列) がデータベースクエリ結果の一部である場合にのみ構築できます。

スクロール

ページネーションで説明したように、GraphQL カーソル接続仕様は ConnectionEdgePageInfo スキーマ型によるページネーションのメカニズムを定義しますが、GraphQL Java は同等の Java 型表現を提供します。

Spring for GraphQL は、Spring Data ページネーション型 Window および Slice を透過的に適応させるための組み込み ConnectionAdapter 実装を提供します。次のように設定できます。

CursorStrategy<ScrollPosition> strategy = CursorStrategy.withEncoder(
		new ScrollPositionCursorStrategy(),
		CursorEncoder.base64()); (1)

GraphQLTypeVisitor visitor = ConnectionFieldTypeVisitor.create(List.of(
		new WindowConnectionAdapter(strategy),
		new SliceConnectionAdapter(strategy))); (2)

GraphQlSource.schemaResourceBuilder()
		.schemaResources(..)
		.typeDefinitionConfigurer(..)
		.typeVisitors(List.of(visitor)); (3)
1ScrollPosition を Base64 エンコードされたカーソルに変換する戦略を作成します。
2DataFetcher から返される Window および Slice を適応させるための型 ビジターを作成します。
3 型の訪問者を登録します。

リクエスト側では、コントローラーメソッドで ScrollSubrange メソッド引数を宣言して、前方または後方にページネーションできます。これが機能するには、CursorStrategy が ScrollPosition を Bean としてサポートすることを宣言する必要があります。

Boot スターターは CursorStrategy<ScrollPosition> Bean を宣言し、Spring Data がクラスパス上にある場合は上記のように ConnectionFieldTypeVisitor を登録します。

キーセットの位置

KeysetScrollPosition の場合、カーソルはキーセットから作成する必要があります。キーセットは基本的にキーと値のペアの Map です。キーセットからカーソルを作成する方法を決定するには、ScrollPositionCursorStrategy を CursorStrategy<Map<String, Object>> で構成します。デフォルトでは、JsonKeysetCursorStrategy はキーセット Map を JSON に書き込みます。これは、String、Boolean、Integer、Double などの単純な型には機能しますが、その他の型は、ターゲットの型情報がないと同じ型に戻すことができません。Jackson ライブラリには、JSON に型情報を含めることができるデフォルトの型指定機能があります。安全に使用するには、許可される型のリストを指定する必要があります。例:

PolymorphicTypeValidator validator = BasicPolymorphicTypeValidator.builder()
		.allowIfBaseType(Map.class)
		.allowIfSubType(ZonedDateTime.class)
		.build();

JsonMapper mapper = JsonMapper.builder()
    .activateDefaultTyping(validator, DefaultTyping.NON_FINAL)
       .enable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS)
       .build();

その後、JsonKeysetCursorStrategy を作成できます。

ObjectMapper mapper = ... ;

CodecConfigurer configurer = ServerCodecConfigurer.create();
configurer.defaultCodecs().jacksonJsonDecoder(new JacksonJsonDecoder(mapper));
configurer.defaultCodecs().jacksonJsonEncoder(new JacksonJsonEncoder(mapper));

JsonKeysetCursorStrategy strategy = new JsonKeysetCursorStrategy(configurer);

デフォルトでは、JsonKeysetCursorStrategy が CodecConfigurer なしで作成され、Jackson ライブラリがクラスパス上にある場合、上記のようなカスタマイズが DateCalendarUUID および java.time の任意の型に適用されます。

ソート

Spring for GraphQL は、GraphQL 引数から Sort を作成するための SortStrategy を定義します。AbstractSortStrategy は、ソート方向とプロパティを抽出するための抽象メソッドを使用して契約を実装します。コントローラーメソッドの引数として Sort のサポートを有効にするには、SortStrategy Bean を宣言する必要があります。

トランザクション管理

データを扱う際には、ある時点で操作のアトミック性と分離性が重要になります。これらはどちらもトランザクションの特性です。GraphQL 自体はトランザクションのセマンティクスを定義していないため、トランザクションの処理方法はサーバーとアプリケーションが決定します。

GraphQL、特に GraphQL Java は、データの取得方法について特定の見解を持たないように設計されています。GraphQL の核となる特性は、クライアントがリクエストを主導することです。フィールドは元のソースとは独立して解決できるため、コンポジションが可能になります。フィールドセットを縮小することで、取得するデータ量が少なくなり、パフォーマンスが向上します。

トランザクション内で分散フィールド解決の概念を適用するのは適切ではありません。

  • トランザクションは作業単位をまとめるため、通常は単一のトランザクション内でオブジェクトグラフ全体(典型的なオブジェクトリレーショナルマッパーの動作)を取得します。これは、クライアントがクエリを駆動できるようにするという GraphQL のコア設計とは矛盾しています。

  • 複数のデータフェッチャー間でトランザクションをオープンにしたままにして、各データフェッチャーがフラットオブジェクトのみをフェッチするようにすると、パフォーマンス面が緩和され、分離されたフィールド解決と一致しますが、必要以上に長い時間リソースを保持するトランザクションの実行時間が長くなる可能性があります。

一般的に、トランザクションは状態を変更するミューテーションに適用するのが最適であり、必ずしもデータを読み取るだけのクエリには適用できません。ただし、トランザクションによる読み取りが必要なユースケースもあります。

GraphQL is designed to support multiple mutations within a single request. Depending on the use case, you might want to:

  • Run each mutation within its own transaction.

  • Keep some mutations within a single transaction to ensure a consistent state.

  • Span a single transaction over all involved mutations.

Each approach requires a slightly different transaction management strategy.

When using Spring Framework (e.g. JDBC) or Spring Data, the Template API and repositories default (without any further instrumentation) to use implicit transactions for individual operations resulting in starting and commiting a transaction for each repository method call. This is the normal mode of operation for most databases.

The following sections are outlining two different strategies to manage transactions in a GraphQL server:

Transactional Controller Methods

The simplest approach to manage transactions is to use Spring’s Transaction Management together with @MutationMapping controller methods (or any other @SchemaMapping method) for example:

  • Declarative

  • プログラマティック

@Controller
public class AccountController {

	@MutationMapping
	@Transactional
	public Account addAccount(@Argument AccountInput input) {
		// ...
	}
}
@Controller
public class AccountController {

	private final TransactionOperations transactionOperations;

	@MutationMapping
	public Account addAccount(@Argument AccountInput input) {
		return transactionOperations.execute(status -> {
			// ...
		});
	}
}

A transaction spans from entering the addAccount method until its return. All invocations to transactional resources are part of the same transaction resulting in atomicity and isolation of the mutation.

This is the recommended approach. It gives you full control over transaction boundaries with a clearly defined entrypoint without the need to instrument GraphQL server infrastructure.

Cleaning up a transaction after the method call results that subsequent data fetching (e.g. for nested fields) is not part of the transactional method addAccount as outlined below:

@Controller
public class AccountController {

	@MutationMapping
	@Transactional
	public Account addAccount(@Argument AccountInput input) {    (1)
		// ...
	}

	@SchemaMapping
	@Transactional
	public Person person(Account account) {                      (2)
		... // fetching the person within a separate transaction
	}
}
1The addAccount method invocation runs within its own transaction.
2The person method invocation creates its own, separate transaction that is not tied to the addAccount method in case both methods were invoked as part of the same GraphQL request. A separate transaction comes with all possible drawbacks of not being part of the same transaction, such as non-repeatable reads or inconsistencies in case the data has been modified between the addAcount and person method invocations.

To run multiple mutations in a single transaction maintaining a simple setup we recommend designing a mutation method that accepts all required inputs. This method can then call multiple service methods, ensuring they all participate in the same transaction.

Transactional Instrumentation

Applying a Transactional Instrumentation is a more advanced approach to span a transaction over the entire execution of a GraphQL request. By stating a transaction before the first data fetcher is invoked your application can ensure that all data fetchers can participate in the same transaction.

When instrumenting the server, you need to ensure an ExecutionStrategy runs DataFetcher invocations serially so that all invocations are executed on the same Thread. This is mandatory: Synchronous transaction management uses ThreadLocal state to allow participation in transactions. Considering AsyncSerialExecutionStrategy as starting point is a good choice as it executes data fetchers serially.

You have two general options to implement transactional instrumentation:

  1. GraphQL Java’s Instrumentation contract allows to hook into the execution lifecycle at various stages. The Instrumentation SPI was designed with observability in mind, yet it serves as execution-agnostic extension points regardless of whether you’re using synchronous reactive, or any other asynchronous form to invoke data fetchers and is less opinionated in that regard.

  2. An ExecutionStrategy provides full control over the execution and opens a variety of possibilities how to communicate failed transactions or errors during transaction cleanup back to the client. It can also serve as good entry point to implement custom directives that allow clients specifying transactional attributes through directives or using directives in your schema to demarcate transactional boundaries for certain queries or mutations.

When manually managing transactions, ensure to clean up the transaction, that is either commiting or rolling back, after completing the unit of work. ExceptionWhileDataFetching can be a useful GraphQLError to obtain an underlying Exception. This error is constructed when using SimpleDataFetcherExceptionHandler. By default, Spring GraphQL falls back to an internal GraphQLError that doesn’t expose the original exception.

Applying transactional instrumentation creates opportunities to rethink transaction participation: All @SchemaMapping controller methods participate in the transaction regardless whether they are invoked for the root, nested fields, or as part of a mutation. Transactional controller methods (or service methods within the invocation chain) can declare transactional attributes such as propagation behavior REQUIRES_NEW to start a new transaction if required.