文件上传
在 GraphQL 中,你将文件上传操作建模为从客户端到 DGS 的 GraphQL 变异请求。
以下章节描述了如何使用Multipart POST请求实现文件的上传和下载。关于文件上传的更多内容和最佳实践,请看Apollo博客中Khalil Stemmler的Apollo服务器文件上传最佳实践。
Apollo Server File Upload Best Practices
多部分文件上传
多部分请求是一个HTTP请求,它在一个请求中包含多个部分:突变查询、文件数据、JSON对象(the mutation query, file data, JSON objects),以及其他你喜欢的东西。你可以使用Apollo的上传客户端,甚至是一个简单的cURL,使用您在模式中建模为 Mutation 的多部分请求来发送文件数据流。
![image-20220718161146952](https://i-blog.csdnimg.cn/blog_migrate/beac604d6d7e7cc2282be78ef64f2c45.png)
[ GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec)
有关使用 GraphQL multipart上传文件的多部分 POST 请求的规范,请参阅 GraphQL 多部分请求规范。
DGS框架支持Upload scalar,你可以用它在你的突变查询中指定文件为MultipartFile。当你发送文件上传的多部分请求时,框架会处理每一部分,并组装出最终的GraphQL查询,交给你的数据提取器进行进一步处理。
下面是一个将文件上传到 DGS 的 Mutation 查询示例:
scalar Upload
extend type Mutation {
uploadScriptWithMultipartPOST(input: Upload!): Boolean
}
请注意,您需要在架构中声明Upload
scalar,尽管实现是由 DGS 框架提供的。在您的 DGS 中,添加一个数据提取器以将其作为 MultipartFile 进行处理,如下所示:
@DgsData(parentType = DgsConstants.MUTATION.TYPE_NAME, field = "uploadScriptWithMultipartPOST")
public boolean uploadScript(DataFetchingEnvironment dfe) throws IOException {
// NOTE: Cannot use @InputArgument or Object Mapper to convert to class, because MultipartFile cannot be
// deserialized
MultipartFile file = dfe.getArgument("input");
String content = new String(file.getBytes());
return ! content.isEmpty();
}
请注意,您将无法使用 Jackson 对象映射器来反序列化包含 MultipartFile 的类型,因此您需要从输入中显式获取文件参数。
在您的客户端上,您可以使用 apollo-upload-client 将您的 Mutation 查询作为带有文件数据的多部分 POST 请求发送。这是您配置链接的方式:
import { createUploadLink } from 'apollo-upload-client'
const uploadLink = createUploadLink({ uri: uri })
const authedClient = authLink && new ApolloClient({
link: uploadLink)),
cache: new InMemoryCache()
})
设置完成后,设置 Mutation 查询并将用户选择的文件作为变量传递:
Subscriptions
GraphQL 订阅使客户端能够随着时间的推移从服务器接收查询更新。从服务器推送更新通知就是一个很好的例子。
DGS 框架支持开箱即用的订阅。
服务器端编程模型
在DGS框架中,一个订阅号被实现为一个带有@DgsSubscription
注解的数据获取器。@DgsSubscription
只是@DgsData(parentType = "Subscription")
的简写。与普通的数据获取器不同的是,订阅必须返回org.reactivestreams.Publisher.DGS
的数据。
import reactor.core.publisher.Flux;
import org.reactivestreams.Publisher;
@DgsComponent
public class SubscriptionDataFetcher {
@DgsSubscription
public Publisher<Stock> stocks() {
return Flux.interval(Duration.ofSeconds(0), Duration.ofSeconds(1)).map(t -> new Stock("NFLX", 500 + t));
}
}
Publisher 接口来自 Reactive Streams。 Spring 框架附带了 Reactor 库以使用 Reactive Streams。
WebSockets
GraphQL规范并没有指定一个传输协议。然而,WebSockets是最流行的传输协议,并且被DGS框架所支持。Apollo定义了一个子协议,由客户端库支持并由DGS框架实现。
要启用WebSockets支持,请在build.gradle中添加以下模块。
implementation 'com.netflix.graphql.dgs:graphql-dgs-subscriptions-websockets-autoconfigure:latest.release'
订阅端点在/subscriptions上。普通的GraphQL查询可以发送到/graphql,而订阅请求则发送到/subscriptions。Apollo客户端通过一个链接支持WebSockets。通常情况下,你想用HTTP链接和WS链接来配置Apollo客户端,并根据查询类型在它们之间分割。
单元测试订阅
与 "正常 "的数据获取器测试类似,你使用DgsQueryExecutor来执行一个查询。就像普通查询一样,这将导致一个ExecutionResult。订阅查询不是直接在getData()方法中返回一个结果,而是返回一个Publisher。一个Publisher可以使用Reactor的测试功能进行断言。Publisher 的每个 onNext 都是另一个 ExecutionResult。这个ExecutionResult包含实际的数据!
你可能需要花点时间来理解这个嵌套的ExecutionResult的概念,但它提供了一个测试Subscriptions的绝佳方法,包括角落里的情况。
下面是这种测试的一个简单例子。这个例子测试了上面的股票订阅。股票订阅每秒钟产生一个结果,所以测试使用VirtualTime来快进时间,不需要在测试中等待。
还要注意的是,发射的ExecutionResult返回一个Map<String, Object>,而不是你的数据提取器返回的Java类型。使用Jackson Objectmapper将地图转换为Java对象。
@SpringBootTest(classes = {DgsAutoConfiguration.class, SubscriptionDataFetcher.class})
class SubscriptionDataFetcherTest {
@Autowired
DgsQueryExecutor queryExecutor;
ObjectMapper objectMapper = new ObjectMapper();
@Test
void stocks() {
ExecutionResult executionResult = queryExecutor.execute("subscription Stocks { stocks { name, price } }");
Publisher<ExecutionResult> publisher = executionResult.getData();
VirtualTimeScheduler virtualTimeScheduler = VirtualTimeScheduler.create();
StepVerifier.withVirtualTime(() -> publisher, 3)
.expectSubscription()
.thenRequest(3)
.assertNext(result -> assertThat(toStock(result).getPrice()).isEqualTo(500))
.assertNext(result -> assertThat(toStock(result).getPrice()).isEqualTo(501))
.assertNext(result -> assertThat(toStock(result).getPrice()).isEqualTo(502))
.thenCancel()
.verify();
}
private Stock toStock(ExecutionResult result) {
Map<String, Object> data = result.getData();
return objectMapper.convertValue(data.get("stocks"), Stock.class);
}
}
在这个例子中,订阅是孤立工作的;它只是每秒钟发出一个结果。在其他情况下,订阅可能依赖于系统中发生的其他事情,比如突变的处理(the processing of a mutation)。这样的场景很容易在单元测试中设置,只需在测试中运行多个查询/突变(queries/mutations),就能看到所有的工作。
请注意,单元测试实际上只测试您的代码。它不关心传输协议。这正是您的测试所需要的,因为您的测试应该专注于测试您的代码,而不是框架代码。
集成测试订阅
虽然大多数订阅逻辑应该在单元测试中进行测试,但用客户端进行端到端测试也是很有用的。这可以通过DGS客户端实现,并且在随机端口的@SpringBootTest中运行良好。下面的例子启动了一个订阅,并发送至mutations,应该会导致订阅的更新。这个例子使用了Websockets,但同样可以用于SSE。这个例子的代码可以在示例项目中找到。
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ReviewSubscriptionIntegrationTest {
@LocalServerPort
private Integer port;
private WebSocketGraphQLClient webSocketGraphQLClient;
private MonoGraphQLClient graphQLClient;
private MonoRequestExecutor requestExecutor = (url, headers, body) -> WebClient.create(url)
.post()
.bodyValue(body)
.headers(consumer -> headers.forEach(consumer::addAll))
.exchangeToMono(r -> r.bodyToMono(String.class).map(responseBody -> new HttpResponse(r.rawStatusCode(), responseBody, r.headers().asHttpHeaders())));
@BeforeEach
public void setup() {
webSocketGraphQLClient = new WebSocketGraphQLClient("ws://localhost:" + port + "/subscriptions", new ReactorNettyWebSocketClient());
graphQLClient = new DefaultGraphQLClient("http://localhost:" + port + "/graphql");
}
@Test
public void testWebSocketSubscription() {
GraphQLQueryRequest subscriptionRequest = new GraphQLQueryRequest(
ReviewAddedGraphQLQuery.newRequest().showId(1).build(),
new ReviewAddedProjectionRoot().starScore()
);
GraphQLQueryRequest addReviewMutation1 = new GraphQLQueryRequest(
AddReviewGraphQLQuery.newRequest().review(SubmittedReview.newBuilder().showId(1).starScore(5).username("DGS User").build()).build(),
new AddReviewProjectionRoot().starScore()
);
GraphQLQueryRequest addReviewMutation2 = new GraphQLQueryRequest(
AddReviewGraphQLQuery.newRequest().review(SubmittedReview.newBuilder().showId(1).starScore(3).username("DGS User").build()).build(),
new AddReviewProjectionRoot().starScore()
);
Flux<Integer> starScore = webSocketGraphQLClient.reactiveExecuteQuery(subscriptionRequest.serialize(), Collections.emptyMap()).map(r -> r.extractValue("reviewAdded.starScore"));
StepVerifier.create(starScore)
.thenAwait(Duration.ofSeconds(1)) //This await is necessary because of issue [#657](https://github.com/Netflix/dgs-framework/issues/657)
.then(() -> {
graphQLClient.reactiveExecuteQuery(addReviewMutation1.serialize(), Collections.emptyMap(), requestExecutor).block();
})
.then(() ->
graphQLClient.reactiveExecuteQuery(addReviewMutation2.serialize(), Collections.emptyMap(), requestExecutor).block())
.expectNext(5)
.expectNext(3)
.thenCancel()
.verify();
}
}
接口和联合
只要你在你的模式中使用接口interface类型或联合union 类型,你就必须注册类型解析器。GraphQL文档中解释了接口类型和联合类型。
例如,以下模式定义了具有两个不同具体对象类型实现的 Movie 接口类型。
type Query {
movies: [Movie]
}
interface Movie {
title: String
}
type ScaryMovie implements Movie {
title: String
gory: Boolean
scareFactor: Int
}
type ActionMovie implements Movie {
title: String
nrOfExplosions: Int
}
下面的数据获取器被注册用来返回一个电影列表。该数据获取器返回一个组合的电影类型。
@DgsComponent
public class MovieDataFetcher {
@DgsData(parentType = "Query", field = "movies")
public List<Movie> movies() {
return Lists.newArrayList(
new ActionMovie("Crouching Tiger", 0),
new ActionMovie("Black hawk down", 10),
new ScaryMovie("American Horror Story", true, 10),
new ScaryMovie("Love Death + Robots", false, 4)
);
}
}
GraphQL运行时需要知道ActionMovie的一个Java实例代表ActionMovie GraphQL类型。这种映射是由TypeResolver负责的。
提示:如果你的Java类型名称和GraphQL类型名称相同,DGS框架会自动创建一个TypeResolver
。不需要添加任何代码!
注册类型解析器
如果你的Java类型的名称和GraphQL类型不匹配,你需要提供一个TypeResolver。类型解析器帮助框架从具体的Java类型映射到模式中正确的对象类型。
使用 @DgsTypeResolver
注解来注册一个类型解析器。该注解有一个 name 属性;将其设置为 [GraphQL] 模式中的接口类型或联合类型的名称。该解析器接收一个Java接口类型的对象,并返回一个字符串,该字符串是模式中定义的实例的具体对象类型。下面是上面介绍的Movie接口类型的一个类型解析器。
@DgsTypeResolver(name = "Movie")
public String resolveMovie(Movie movie) {
if(movie instanceof ScaryMovie) {
return "ScaryMovie";
} else if(movie instanceof ActionMovie) {
return "ActionMovie";
} else {
throw new RuntimeException("Invalid type: " + movie.getClass().getName() + " found in MovieTypeResolver");
}
}
你可以将@DgsTypeResolver
注解添加到任何@DgsComponent
类。这意味着你可以将类型解析器与负责返回该类型数据的数据提取器放在同一个类中,或者你可以为它创建一个单独的类。
安全
使用@Secured 进行细粒度访问控制
DGS框架使用众所周知的@Secured注解与Spring Security进行整合。Spring Security本身可以通过多种方式进行配置,这已经超出了本文档的范围。然而,一旦Spring Security设置完毕,你就可以将@Secured应用于你的数据提取器,这与你将其应用于Spring MVC的REST控制器的方式非常相似。
@DgsComponent
public class SecurityExampleFetchers {
@DgsData(parentType = "Query", field = "hello")
public String hello() {
return "Hello to everyone";
}
@Secured("admin")
@DgsData(parentType = "Query", field = "secureGroup")
public String secureGroup() {
return "Hello to admins only";
}
}