请求执行
ExecutionGraphQlService 是调用 GraphQL Java 执行请求的主要 Spring 抽象。底层传输层(例如 HTTP)会委托 ExecutionGraphQlService 来处理请求。
主要实现 DefaultExecutionGraphQlService 配置了一个 GraphQlSource,用于访问 graphql.GraphQL 实例以执行调用。
GraphQLSource
GraphQlSource 是一个用于暴露 graphql.GraphQL 实例以供使用的契约,它同时包含一个用于构建该实例的构建器 API。默认构建器可通过 GraphQlSource.schemaResourceBuilder() 获取。
Boot Starter 创建了此构建器的一个实例,并进一步初始化它以从可配置位置加载模式文件,暴露属性以应用于 GraphQlSource.Builder,检测 RuntimeWiringConfigurer bean、用于 GraphQL 指标的 Instrumentation 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 可以配置一个或多个 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
})
请参阅 GraphQlSource 部分,了解如何使用 Spring Boot 进行配置。
如果您对联邦感兴趣,请参阅联邦部分。
RuntimeWiringConfigurer
RuntimeWiringConfigurer 可用于注册以下内容:
-
自定义标量类型。
-
处理指令的代码。
-
直接注册
DataFetcher。 -
以及更多…
Spring 应用程序通常不需要直接执行 DataFetcher 注册。相反,控制器方法会通过 AnnotatedControllerConfigurer(一个 RuntimeWiringConfigurer)注册为 DataFetcher。
GraphQL Java 服务器应用程序仅使用 Jackson 进行数据映射的序列化和反序列化。客户端输入被解析为映射。服务器输出根据字段选择集组装成映射。这意味着你不能依赖 Jackson 的序列化/反序列化注解。相反,你可以使用自定义标量类型。
Boot Starter 会检测类型为 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,例如为了进行考虑模式定义的注册,请实现另一个 configure 方法,该方法同时接受 RuntimeWiring.Builder 和一个输出的 List<WiringFactory>。这允许你添加任意数量的工厂,然后按顺序调用它们。
TypeResolver
GraphQlSource.Builder 会将 ClassNameTypeResolver 注册为默认的 TypeResolver,用于处理那些尚未通过 RuntimeWiringConfigurer 进行此类注册的 GraphQL 接口和联合类型。在 GraphQL Java 中,TypeResolver 的作用是确定从 GraphQL 接口或联合类型字段的 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 文档中的 Schema Directives。
在 Spring GraphQL 中,你可以通过 RuntimeWiringConfigurer 注册一个 SchemaDirectiveWiring。Boot Starter 会自动检测这些 bean,因此你可以这样配置:
@Configuration
public class GraphQlConfig {
@Bean
public RuntimeWiringConfigurer runtimeWiringConfigurer() {
return builder -> builder.directiveWiring(new MySchemaDirectiveWiring());
}
}
有关指令支持的示例,请查看 GraphQL Java 扩展验证库。
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,以便在创建后遍历和转换 schema,并对 schema 进行修改。请注意,这比 Schema 遍历 成本更高,因此通常更推荐使用遍历而非转换,除非你需要对 schema 进行更改。
模式遍历
您可以通过 builder.schemaResources(..).typeVisitors(..) 注册一个 graphql.schema.GraphQLTypeVisitor,以便在创建模式后遍历它,并可能对 GraphQLCodeRegistry 应用更改。但请注意,此类访问者无法更改模式。如果您需要对模式进行更改,请参阅模式转换。
模式映射检查
如果查询、变更或订阅操作没有配置 DataFetcher,它将不会返回任何数据,也无法执行有效操作。同样地,对于既未通过 DataFetcher 注册显式覆盖,也未被默认 PropertyDataFetcher(该组件通过匹配 Class 属性实现隐式映射)覆盖的 schema 类型字段,其返回值将始终为 null。
GraphQL Java 不会执行检查以确保每个模式字段都被覆盖,作为一个底层库,GraphQL Java 根本不知道 DataFetcher 能返回什么或它依赖于哪些参数,因此无法执行此类验证。这可能导致一些漏洞,根据测试覆盖情况,这些漏洞可能直到运行时才被发现,届时客户端可能会遇到“静默”的 null 值或非空字段错误。
Spring for GraphQL 中的 SelfDescribingDataFetcher 接口允许 DataFetcher 暴露诸如返回类型和预期参数等信息。所有内置的、用于控制器方法、Querydsl 和 Query by Example 的 Spring DataFetcher 实现都是此接口的实现。对于带注解的控制器,返回类型和预期参数基于控制器方法签名。这使得在启动时检查模式映射成为可能,以确保以下内容:
-
Schema字段要么有
DataFetcher注册,要么有对应的Class属性。 -
DataFetcher注册指向一个已存在的schema字段。 -
DataFetcher参数与schema字段参数相匹配。
如果应用程序是用 Kotlin 编写的,或者使用了空安全注解,那么可以进行进一步的检查。GraphQL 模式可以声明可空类型(Book)和非空类型(Book!)。因此,我们可以确保应用程序不会违反模式中的非空性要求。
当模式字段为非空时,我们会确保相关的 Class 属性和 DataFetcher 返回类型也是非空的。相反的情况不被视为错误:当模式中有一个可空字段 author: Author,而应用程序声明了 @NonNull Author getAuthor(); 时,检查器不会将其视为错误。应用程序不一定需要在模式中将字段设为非空,因为在数据获取操作期间发生的任何错误都会迫使 GraphQL 引擎将层次结构中的字段置为 null,直到允许 null 为止。部分响应是 GraphQL 的一个关键特性,因此在设计模式时应考虑到可空性。
当字段参数可为空时,我们确保 DataFetcher 参数也可为空。在这种情况下,如果用户输入违反了可空性约定,则不应将其传递给应用程序,否则将导致运行时故障。
要启用模式检查,请按如下方式自定义 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>
未以任何方式覆盖的 Schema 字段
向不存在的字段注册
DataFetcherDataFetcher期望的参数不存在Schema 字段 "title" 是非空的,但
Book.getTitle()是@NullablebookById(id: ID)有一个可为空的 "id" 参数,但Book bookById(@NonNull String id)是非空的。被跳过的 Schema 类型(将在下文解释)
在某些情况下,模式类型的 Class 类型是未知的。可能是 DataFetcher 未实现 SelfDescribingDataFetcher,或者声明的返回类型过于通用(例如 Object)或未知(例如 List<?>),又或者 DataFetcher 可能完全缺失。在这种情况下,模式类型会被列为已跳过,因为它无法被验证。对于每个跳过的类型,都会有一条 DEBUG 消息解释其被跳过的原因。
联合类型与接口类型
对于联合类型,检查会遍历成员类型并尝试找到对应的类。对于接口类型,检查会遍历实现类型并寻找对应的类。
默认情况下,以下情况可以自动检测到对应的 Java 类:
-
Class的简单名称与 GraphQL 联合类型成员或接口实现类型的名称匹配,并且Class位于与映射到联合类型或接口字段的控制器方法返回类型或控制器类相同的包中。 -
Class在模式的其他部分被检查,其中映射字段属于具体的联合类型成员或接口实现类型。 -
您已注册了一个 TypeResolver,其中包含明确的
Class到 GraphQL 类型的映射。
如果以上帮助均无效,且 GraphQL 类型在模式检查报告中显示为已跳过,您可以进行以下自定义设置:
-
显式地将 GraphQL 类型名称映射到一个或多个 Java 类。
-
配置一个函数,用于自定义如何将 GraphQL 类型名称适配为简单的
Class名称。这有助于遵循特定的 Java 类命名约定。 -
提供一个
ClassNameTypeResolver来将 GraphQL 类型映射到 Java 类。
例如:
GraphQlSource.Builder builder = ...
builder.schemaResources(..)
.inspectSchemaMappings(
initializer -> initializer.classMapping("Author", Author.class)
logger::debug);
操作缓存
GraphQL Java 在执行操作前必须解析并验证操作,这可能会显著影响性能。为了避免重复解析和验证,应用程序可以配置一个 PreparsedDocumentProvider 来缓存和重用 Document 实例。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方法可以返回Callable,该Callable会被提交给一个Executor(例如 Spring Framework 的VirtualThreadTaskExecutor)。要启用此功能,您必须在AnnotatedControllerConfigurer上配置一个Executor。
Spring for GraphQL 可在 Spring MVC 或 WebFlux 作为传输层的基础上运行。Spring MVC 采用异步请求执行机制,除非 GraphQL Java 引擎返回后,生成的 CompletableFuture 能立即完成——这种情况通常发生在请求足够简单且无需异步数据获取时。
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,这些类型会被适配为 CompletableFuture。其中 Flux 值会被聚合并转换为列表,除非请求是 GraphQL 订阅请求。在这种情况下,返回值将保持为 Reactive Streams Publisher,用于流式传输 GraphQL 响应。
一个响应式的 DataFetcher 可以依赖于从传输层传播的 Reactor 上下文,例如来自 WebFlux 请求处理,请参阅 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
DataFetcher 以及 GraphQL Java 调用的其他组件可能并不总是在与 Spring MVC 处理器相同的线程上执行,例如,如果异步的 WebGraphQlInterceptor 或 DataFetcher 切换到了不同的线程。
Spring for GraphQL 支持将 ThreadLocal 值从 Servlet 容器线程传播到 GraphQL Java 调用 DataFetcher 及其他组件执行时所在的线程。为此,应用程序需要为感兴趣的 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();
}
}
您可以在启动时通过全局的 ContextRegistry 实例手动注册 ThreadLocalAccessor,该实例可通过 io.micrometer.context.ContextRegistry#getInstance() 访问。您也可以通过 java.util.ServiceLoader 机制自动注册它。
WebFlux
响应式数据获取器可以依赖源自 WebFlux 请求处理链的 Reactor 上下文。这包括由 WebGraphQlInterceptor 组件添加的 Reactor 上下文。
异常
在 GraphQL Java 中,DataFetcherExceptionHandler 用于决定如何将数据获取过程中产生的异常呈现在响应的 "errors" 部分。一个应用只能注册一个处理程序。
Spring for GraphQL 注册了一个 DataFetcherExceptionHandler,该处理器提供默认处理并启用 DataFetcherExceptionResolver 契约。应用程序可以通过 GraphQLSource 构建器注册任意数量的解析器,这些解析器将按顺序执行,直到其中一个将 Exception 解析为 List<graphql.GraphQLError>。Spring Boot 启动器会自动检测此类型的 Bean。
DataFetcherExceptionResolverAdapter 是一个便捷的基类,它提供了受保护的方法 resolveToSingleError 和 resolveToMultipleErrors。
注解控制器编程模型支持通过带有灵活方法签名的注解异常处理方法处理数据获取异常,详情请参阅@GraphQlExceptionHandler。
GraphQLError 可以根据 GraphQL Java 的 graphql.ErrorClassification 或 Spring GraphQL 的 ErrorType 被分配到一个类别,它们定义了以下内容:
-
BAD_REQUEST -
UNAUTHORIZED -
FORBIDDEN -
NOT_FOUND -
INTERNAL_ERROR
如果异常未被解决,默认情况下,它会被归类为 INTERNAL_ERROR,并附带一条通用消息,其中包含类别名称和来自 DataFetchingEnvironment 的 executionId。该消息故意设计得较为模糊,以避免泄露实现细节。应用程序可以使用 DataFetcherExceptionResolver 来自定义错误详情。
未解决的异常会以 ERROR 级别记录,并附带 executionId 以关联发送给客户端的错误信息。已解决的异常则以 DEBUG 级别记录。
请求异常
GraphQL Java 引擎在解析请求时可能会遇到验证或其他错误,从而阻止请求执行。在这种情况下,响应会包含一个值为 null 的 "data" 键,以及一个或多个请求级别的 "errors",这些错误是全局性的,即没有字段路径。
DataFetcherExceptionResolver 无法处理此类全局错误,因为它们是在执行开始之前、任何 DataFetcher 被调用之前引发的。应用程序可以使用传输层拦截器来检查和转换 ExecutionResult 中的错误。请参阅 WebGraphQlInterceptor 下的示例。
订阅异常
订阅请求的 Publisher 可能会以错误信号完成,此时底层传输(例如 WebSocket)会发送一条最终类型为 "error" 的消息,其中包含一系列 GraphQL 错误。
DataFetcherExceptionResolver 无法解析来自订阅 Publisher 的错误,因为数据 DataFetcher 仅在初始时创建 Publisher。此后,传输层会订阅该 Publisher,而该 Publisher 随后可能会以错误完成。
应用程序可以注册一个 SubscriptionExceptionResolver,用于处理来自订阅 Publisher 的异常,以便将这些异常解析为 GraphQL 错误并发送给客户端。
分页
GraphQL Cursor Connection 规范 定义了一种导航大型结果集的方法,它每次返回一个项目子集,其中每个项目都与一个游标配对,客户端可以使用该游标来请求在引用项目之前或之后的更多项目。
规范将此模式称为*"连接"*,名称以 ~Connection 结尾的模式类型是一种连接类型,表示分页结果集。所有连接类型都包含一个名为 "edges" 的字段,其中 ~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 Starter 默认会注册 ConnectionTypeDefinitionConfigurer。
ConnectionAdapter
除了模式中的连接类型外,你还需要对应的 Java 类型。GraphQL Java 提供了这些类型,包括通用的 Connection 和 Edge 类型,以及 PageInfo。
你可以从控制器方法返回 Connection,但这需要样板代码来将底层数据分页机制适配到 Connection,包括创建游标、添加 ~Edge 包装器以及创建 PageInfo。
Spring for GraphQL 定义了 ConnectionAdapter 契约,用于将项目容器适配为 Connection。适配器由 DataFetcher 装饰器调用,而该装饰器则由 ConnectionFieldTypeVisitor 添加。您可以按如下方式配置它:
ConnectionAdapter adapter = ... ;
GraphQLTypeVisitor visitor = ConnectionFieldTypeVisitor.create(List.of(adapter)) 1
GraphQlSource.schemaResourceBuilder()
.schemaResources(..)
.typeDefinitionConfigurer(..)
.typeVisitors(List.of(visitor)) 2
创建一个包含一个或多个
ConnectionAdapter的类型访问器。注册该类型访问器。
Spring Data 内置了针对 Window 和 Slice 的内置 ConnectionAdapter。你也可以创建自定义适配器。ConnectionAdapter 的实现依赖于 CursorStrategy 来为返回的条目创建游标。同样的策略也用于支持包含分页输入的 Subrange 控制器方法参数。
CursorStrategy
CursorStrategy 是一个用于编码和解码字符串游标的契约,该游标指向大型结果集中某个项目的位置。游标可以基于索引或键集。
ConnectionAdapter 使用此功能对返回项的游标进行编码。Annotated Controllers 方法、Querydsl 存储库以及 Query by Example 存储库使用它来解码分页请求中的游标,并创建一个 Subrange。
CursorEncoder 是一个相关合约,它进一步对字符串游标进行编码和解码,使其对客户端不透明。EncodingCursorStrategy 将 CursorStrategy 与 CursorEncoder 结合使用。你可以使用 Base64CursorEncoder、NoOpEncoder 或创建自己的编码器。
Spring Data 的 ScrollPosition 有一个内置的 CursorStrategy。当 Spring Data 存在时,Boot Starter 会使用 Base64Encoder 注册一个 CursorStrategy<ScrollPosition>。
排序
GraphQL 请求中没有提供排序信息的标准方式。然而,分页依赖于稳定的排序顺序。您可以使用默认排序,或者通过公开输入类型并从 GraphQL 参数中提取排序细节来实现。
Spring Data 的 Sort 作为控制器方法参数时,内置 了对其的支持。要实现此功能,您需要有一个 SortStrategy bean。
批量加载
给定一个Book及其Author,我们可以为书籍创建一个DataFetcher,为作者创建另一个。这样可以选择带或不带作者的书籍,但意味着书籍和作者不会一起加载,这在查询多本书时尤其低效,因为每本书的作者都是单独加载的。这被称为N+1查询问题。
DataLoader
GraphQL Java 提供了 DataLoader 机制用于批量加载相关实体。完整细节可在 GraphQL Java 文档 中找到。以下是其工作原理的总结:
-
在
DataLoaderRegistry中注册DataLoader,使其能够根据唯一键加载实体。 -
DataFetcher可以访问DataLoader,并使用它们通过 ID 加载实体。 -
DataLoader通过返回一个 future 来延迟加载,以便能够批量执行。 -
DataLoader维护一个每个请求的已加载实体缓存,这可以进一步提高效率。
BatchLoaderRegistry
GraphQL Java 中的完整批量加载机制需要实现多个 BatchLoader 接口之一,然后将其包装并注册为带有名称的 DataLoader,并放入 DataLoaderRegistry 中。
Spring GraphQL 中的 API 略有不同。对于注册,只有一个中心化的 BatchLoaderRegistry,它提供了工厂方法和构建器来创建和注册任意数量的批量加载函数:
@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 Starter 声明了一个 BatchLoaderRegistry Bean,您可以将其注入到配置中(如上所示),或注入到任何组件(例如控制器)中,以便注册批量加载函数。相应地,BatchLoaderRegistry 会被注入到 DefaultExecutionGraphQlService 中,确保每个请求的 DataLoader 注册。
默认情况下,DataLoader 的名称基于目标实体的类名。这使得 @SchemaMapping 方法可以声明一个带有泛型类型的 DataLoader 参数,而无需指定名称。然而,如有必要,可以通过 BatchLoaderRegistry 构建器自定义名称,以及其他 DataLoaderOptions。
要全局配置默认的 DataLoaderOptions 作为所有注册的起点,你可以覆盖 Boot 的 BatchLoaderRegistry bean,并使用接受 Supplier<DataLoaderOptions> 参数的 DefaultBatchLoaderRegistry 构造函数。
在许多情况下,加载关联实体时,你可以使用 @BatchMapping 控制器方法,这是一种快捷方式,可以替代直接使用 BatchLoaderRegistry 和 DataLoader 的需求。
BatchLoaderRegistry 还提供了其他重要优势。它支持从批量加载函数和 @BatchMapping 方法访问相同的 GraphQLContext,并确保上下文传播到这些方法中。这就是为什么应用程序应该使用它的原因。虽然可以直接执行自己的 DataLoader 注册,但这样的注册将无法获得上述优势。
批量加载配方
对于简单直接的场景,@BatchMapping 注解通常是最佳选择,样板代码最少。对于更高级的用例,BatchLoaderRegistry 提供了更大的灵活性。
如上所述,DataLoader 会排队处理 load() 调用,并可能一次性或分批分派它们。这意味着一次分派可以为不同的 @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();
}
}
获取所有好友且不应用筛选器,通过其 id 缓存 Person
加载所有好友,然后应用给定的筛选器
这非常适合关系紧密的小型朋友圈和热门饮品。相反,如果我们处理的是大型朋友圈且共同好友较少,或是更小众的饮品,我们可能会为仅发送给客户端的少量条目加载大量数据到内存中。
这里,我们可以采用另一种策略,通过批量加载具有组合键的实体:即人员和所选筛选条件。这种方法将仅在内存中加载足够数量的实体,代价是缓存中可能存在重复的 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
}
由于该键同时包含人员和筛选条件,我们将需要多次获取同一好友
在这两种情况下,查询:
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("...");
// ...