Netflix DGS(Domain Graph Service)
DGS框架(Domain Graph Service)是Netflix开发的一个用于Spring Boot的GraphQL服务框架。
包含以下功能:
- 基于注解的Spring Boot编程模型
- 将查询测试编写为单元测试的测试框架
- Gradle插件用于创建schema的Type类
- 更加容器的和GraphQL Federation集成
- 和Spring Security集成
- GraphQL订阅(WebSockets和SSE)
- 文件上传
- 异常处理
- 许多扩展点
Getting Started
创建一个新的Spring Boot应用
DGS框架基于Spring Boot,如果你还没有Spring Boot应用,可以使用Spring Initializr 轻松的创建一个。可以使用Maven 或者 Gradle,Java 8 或者更新的JDK,亦或Kotlin。DGS推荐使用Gradle因为有很方便的代码生成插件!
你只需要引入Spring Web的依赖即可
implementation("org.springframework.boot:spring-boot-starter-web")
引入DGS框架的依赖
Gradle:
repositories {
mavenCentral()
}
dependencies {
implementation "com.netflix.graphql.dgs:graphql-dgs-spring-boot-starter:latest.release"
}
Maven:
<dependency>
<groupId>com.netflix.graphql.dgs</groupId>
<artifactId>graphql-dgs-spring-boot-starter</artifactId>
<!-- Make sure to set the latest framework version! -->
<version>${dgs.framework.version}</version>
</dependency>
注意:DGS 框架需要Kotlin 1.4,并不支持Kotlin 1.3,老版本的Spring Boot可能使用Kotlin 1.3
创建一个 Schema
DGS是为了Schema优先开发设计的。框架会选择在 src/main/resources/schema
下所有的schema文件,创建一个schema.graphqls文件:
type Query {
shows(titleFilter: String): [Show]
}
type Show {
title: String
releaseYear: Int
}
这个schema允许查询shows的集合,并且title是可选的筛选条件。
实现Data Fetcher
Data Fetcher负责返回一个查询所需要的数据。创建两个新类ShowsDataFetcher和Show。有代码生成插件可以自动生成这些类:
@DgsComponent
public class ShowsDatafetcher {
private final List<Show> shows = List.of(
new Show("Stranger Things", 2016),
new Show("Ozark", 2017),
new Show("The Crown", 2016),
new Show("Dead to Me", 2019),
new Show("Orange is the New Black", 2013)
);
@DgsQuery
public List<Show> shows(@InputArgument String titleFilter) {
if(titleFilter == null) {
return shows;
}
return shows.stream().filter(s -> s.getTitle().contains(titleFilter)).collect(Collectors.toList());
}
}
public class Show {
private final String title;
private final Integer releaseYear ;
public Show(String title, Integer releaseYear) {
this.title = title;
this.releaseYear = releaseYear;
}
public String getTitle() {
return title;
}
public Integer getReleaseYear() {
return releaseYear;
}
}
使用GraphQL测试App
启动应用并在浏览器中访问http://localhost:8080/graphiql。 GraphiQL是DGS框架的一种开箱即用的查询编辑器,可以使用下面的查询语句进行查询:
{
shows {
title
releaseYear
}
}
GraphiQL有自动监测字段正确性的功能,如果有自动不正确会提示错误:
或者使用postman:
注意不像REST的请求,你必须指定查询中你想要返回的字段。这就是GraphQL的强大之处。
graphql编辑器实际上只是一个使用服务端/graphql提供的UI。也可以将UI连接到的后端,例如使用React and the Apollo Client。
配置
配置GraphQL schema文件的位置
可以使用dgs.graphql.schema-locations
属性来指定GraphQL schema文件的位置。默认情况下会尝试在classpath下加载schema文件夹,i.e. classpath*:schema/**/*.graphql*
。下面例子使用graphql-schemas文件夹来替换schema文件夹:
dgs:
graphql:
schema-locations:
- classpath*:graphql-schemas/**/*.graphql*
如果你需要加载多个路径的schema文件,可以使用List方式配置:
dgs:
graphql:
schema-locations:
- classpath*:graphql-schemas/**/*.graphql*
- classpath*:graphql-experimental-schemas/**/*.graphql*
获取数据
使用@DgsData
注解来创建一个Data Fetcher,下面来了解一下datafetcher的一些细节。
@DgsData, @DgsQuery, @DgsMutation 和 @DgsSubscription 注解
使用@DgsData
标注在Java/Kotlin 的方法上使其成为一个datafetcher。这个被标注的方法必须在被@DgsComponent
注解标注的类中。
@DgsData
注解有两个参数:
Parameter | Description |
---|---|
parentType | 包含字段的类型 |
field | datafetcher负责的字段 |
例:有如下schema:
type Query {
shows: [Show]
}
type Show {
title: String
actors: [Actor]
}
可以使用单个datafetcher来实现这个schema:
@DgsComponent
public class ShowDataFetcher {
@DgsData(parentType = "Query", field = "shows")
public List<Show> shows() {
//Load shows from a database and return the list of Show objects
return shows;
}
}
如果field
参数没有设置,那么方法名则被用于field的名字。@DgsQuery
,@DgsMutation
,@DgsSubscription
都是相关操作的简写。下面的定义都是等价的:
@DgsData(parentType = "Query", field = "shows")
public List<Show> shows() { .... }
// The "field" argument is omitted. It uses the method name as the field name.
@DgsData(parentType = "Query")
public List<Show> shows() { .... }
// The parentType is "Query", the field name is derived from the method name.
@DgsQuery
public List<Show> shows() { .... }
// The parentType is "Query", the field name is explicitly specified.
@DgsQuery(field = "shows")
public List<Show> shows() { .... }
注意datafetcher是如何返回复杂对象或对象列表的。不必为每个字段创建单独的datafetcher。框架只会返回在查询中指定的字段。例如,如果用户查询:
{
shows {
title
}
}
尽管方法返回Show对象,包含titile
和actor
字段,但是actor
字段会在方法返回前被过滤掉。
Child datafetchers
在之前的例子中假设你可以使用一次查询得到了数据库中数据的集合。无论用户GraphQL中包含了什么字段,花费都是一样的。如果一个指定的字段需要额外的查询呢?例如加载”actors“需要一次额外的查询,而用户不需要这个字段,那么这个额外的查询将是被浪费的。
在这种情况下,最好为这种需要额外开销的字段创建一个单独的datafetcher:
@DgsData(parentType = "Query", field = "shows")
public List<Show> shows() {
//Load shows, which doesn't include "actors"
return shows;
}
@DgsData(parentType = "Show", field = "actors")
public List<Actor> actors(DgsDataFetchingEnvironment dfe) {
Show show = dfe.getSource();
actorsService.forShow(show.getId());
return actors;
}
actors
datafetcher只有在查询中包含actors
这个字段时才会被执行。actors
datafetcher同样介绍了一个新的概念:DgsDataFetchingEnvironment
提供了对上下文、查询本身、数据加载器和源对象的访问。Source 对象就是包含这个字段的对象。例如这个例子的source就是Show
对象,可以获取show的标识符用了获取actors。
注意shows
datafetcher 返回了Show对象的集合,而actors
datafetcher 用来获取单个show的actors。框架会为shows
datafetcher 返回的每一个对象执行actors
的datafetcher。如果actors在数据库中加载的,那么将会导致N+1问题,为了解决N+1问题,可以使用 data loaders(Section 7)。
注:有关更复杂的嵌套datafetcher场景,以及在相关datafetcher中传递上下文的情况,查看nested datafetchers guide。(Advance章节)
使用@InputArgument
在GraphQL中使用一个或多个输入参数是很常见的。根据GraphQL规定,一个Input Argument可以是:
- 一个输入类型(An Input type)
- 一个标量(A scalar)
- 一个枚举(An enum)
其他类型,例如输出类型,unions和接口不允许作为input argument。
可以在datafetcher的方法中使用@InputArgument
注解来获取input argument作为方法参数。框架内部使用Jackson去转换参数为对应的类型。
type Query {
shows(title: String, filter: ShowFilter): [Show]
}
input ShowFilter {
director: String
genre: ShowGenre
}
enum ShowGenre {
commedy, action, horror
}
@DgsData(parentType = "Query", field = "shows")
public List<Show> shows(@InputArgument String title, @InputArgument ShowFilter filter)
如果input argument和方法参数名字不匹配,可以使用@InputArgument
注解中value
来指定名称。
Nullability in Kotlin for input arguments
如果你正在使用Kotlin那么你必须考虑输入类型可能为空。如果在schema中定义input argument 可以为空,则代码必须使用nullable类型来反映这一点。如果非空类型接收到空值,Kotlin将抛出异常。
例如:
# name is a nullable input argument
hello(name: String): String
datafetcher中则必须写成这样:
fun hello(@InputArgument hello: String?)
在Java中不必担心这件事,类型可以一直为null,你只需要在你的数据获取代码中检查null即可。
使用集合类型@InputArgument
一个input argument可以为集合类型。如果一个集合类型作为输入类型,你必须在@InputArgument
中显示的指定类型。
type Query {
hello(people:[Person]): String
}
public String hello(@InputArgument(collectionType = Person.class) List<Person> people)
Optional 和 @InputArgument搭配使用
input argument 在schema中经常被定义成optional。你的datafetcher需要进行null检查,去判断他们是否提供了参数。同样可以使用Optional来包装代替null检查。
public List<Show> shows(@InputArgument(collectionType = ShowFilter.class) Optional<ShowFilter> filter)
如果使用复杂类型,则需要提供collectionType
参数,类似使用集合类型。如果参数没有被提供,那么值将会是Optional.empty()。使用Optional完全属于个人喜好。
Codegen constants
目前为止@DgsData
例子中,我们仍然在使用String类型作为parentType
和 field
的参数,如果使用了code generation 插件,你可以使用生成的常量代替,CodeGen会生成DgsConstants
类包含schema中所有的type和field。可以像下面例子一样使用:
type Query {
shows: [Show]
}
@DgsData(parentType = DgsConstants.QUERY_TYPE, field = DgsConstants.QUERY.Shows)
public List<Show> shows() {}
使用常量可以方便地在编译期检查schema和datafetcher之间的问题。
@RequestHeader & @RequestParam
一些情况下可能你需要检查http header或者request中的其他元素。可以轻松的使用@RequestHeader注解获取HTTP header值。@RequestHeader注解和Spring MVC 中的是同一个。
public String hello(@RequestHeader String host)
Header的值可以是list,如果设置了多个值,可以List来接收,类似参数,否则则被认为是单个的string。
相似的你可以使用@RequestParam
来获取请求的参数@RequestHeader
和@RequestParam
都可以设置defaultValue
和required
参数。如果一共@RequestHeader
或@RequestParam
是required的,没有defaultValue
且值没有被提供,那么则会抛出DgsInvalidInputArgumentException
。
使用DgsRequestData
可以在datafetching上下文中获取DgsRequestData
对象,DgsRequestData
中使用HttpHeaders
来存放HTTP header,而请求本身表示为WebRequest
类型,两者都是Spring MVC中的类型。根据你的运行时依赖,可以进一步转换WebRequest
的类型,例如ServletWebRequest
。
@DgsData(parentType = "Query", field = "serverName")
public String serverName(DgsDataFetchingEnvironment dfe) {
DgsRequestData requestData = DgsContext.getRequestData(dfe);
return ((ServletWebRequest)requestData.getWebRequest()).getRequest().getServerName();
}
和@InputArgument
相似,header或者parameter都可以包装成Optional
类型。
使用 Context
DgsRequestData
用来表示datafetching的上下文,你可以通过创建DgsCustomContextBuilder
来进一步定义context。
@Component
public class MyContextBuilder implements DgsCustomContextBuilder<MyContext> {
@Override
public MyContext build() {
return new MyContext();
}
}
public class MyContext {
private final String customState = "Custom state!";
public String getCustomState() {
return customState;
}
}
一个data fetcher可以通过调用getCustomContext()
方法来获取context
@DgsData(parentType = "Query", field = "withContext")
public String withContext(DataFetchingEnvironment dfe) {
MyContext customContext = DgsContext.getCustomContext(dfe);
return customContext.getCustomState();
}
相似的,自定义的context可以用于DataLoader:
@DgsDataLoader(name = "exampleLoaderWithContext")
public class ExampleLoaderWithContext implements BatchLoaderWithContext<String, String> {
@Override
public CompletionStage<List<String>> load(List<String> keys, BatchLoaderEnvironment environment) {
MyContext context = DgsContext.getCustomContext(environment);
return CompletableFuture.supplyAsync(() -> keys.stream().map(key -> context.getCustomState() + " " + key).collect(Collectors.toList()));
}
}
测试
该DGS框架允许你写轻量级测试,引导部分的框架,就足以运行查询。
在开始写测试用例前,确保JUnit是开启的。
dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
test {
useJUnitPlatform()
}
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
创建一个测试类并写入以下内容来测试ShowsDatafetcher
:
import com.netflix.graphql.dgs.DgsQueryExecutor;
import com.netflix.graphql.dgs.autoconfig.DgsAutoConfiguration;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(classes = {DgsAutoConfiguration.class, ShowsDatafetcher.class})
class ShowsDatafetcherTest {
@Autowired
DgsQueryExecutor dgsQueryExecutor;
@Test
void shows() {
List<String> titles = dgsQueryExecutor.executeAndExtractJsonPath(
" { shows { title releaseYear }}",
"data.shows[*].title");
assertThat(titles).contains("Ozark");
}
}
@SpringBootTest
注解将类标注为Spring的测试用例,如果没有指定具体的class,Spring将会启动classpath下所有的组件,对于小的应用来说没什么问题,但是很庞大的应用来说就会很浪费资源和时间,因此我们可以添加我们需要测试的类来提升运行速度。在这个例子中我们需要通过使用DgsAutoConfiguration
引入DGS框架,以及ShowsDataFetcher类。
为了执行查询,在测试类中注入DgsQueryExecutor
,这个接口有多个方法用来执行查询并返回结果。它会执行和/graphql endpoint相同的代码,但是你不需要在你的test中处理HTTP请求。DgsQueryExecutor
采用JSON 路径,因此这些方法可以轻松地从您感兴趣的响应中仅提取数据。DgsQueryExecutor还包括用于将结果反序列化为Java类的方法(例如,executeAndExtractJsonPathAsObject
),它使用Jackson作为底层,Json path受开源的JsonPath library支持。
构建GraphQL Query用于测试
现在我们可以编写一个使用GraphQLQueryRequest
来构建查询并使用GraphQLResponse
提取响应的测试。
@Test
public void showsWithQueryApi() {
GraphQLQueryRequest graphQLQueryRequest = new GraphQLQueryRequest(
new ShowsGraphQLQuery.Builder().titleFilter("Oz").build(),
new ShowsProjectionRoot().title()
);
List<String> titles = dgsQueryExecutor.executeAndExtractJsonPath(graphQLQueryRequest.serialize(), "data.shows[*].title");
assertThat(titles).containsExactly("Ozark");
}
在Test中Mock外部服务请求
Datafetcher 和外部系统(database,gRPC)通信并不罕见,但是如果这种情况存在于Test中,将会有下面两种问题:
- 增加了延迟:如果存在大量的外部通信,你的Test将会变得很慢。
- 增加了脆弱性:是你的代码存在bug,还是外部系统出现了问题?
在许多情况下都更推荐mock这些外部服务。spring通过@Mockbean注解提供了很好的支持,你可以在DGS的测试中使用它。
public interface ShowsService {
List<Show> shows();
}
@Service
public class ShowsServiceImpl implements ShowsService {
@Override
public List<Show> shows() {
return List.of(
new Show("Stranger Things", 2016),
new Show("Ozark", 2017),
new Show("The Crown", 2016),
new Show("Dead to Me", 2019),
new Show("Orange is the New Black", 2013)
);
}
}
在这个例子中shows仍然在内存中,这里可以考虑成外部服务。
@SpringBootTest(classes = {DgsAutoConfiguration.class, ShowsDataFetcher.class})
public class ShowsDataFetcherTests {
@Autowired
DgsQueryExecutor dgsQueryExecutor;
@MockBean
ShowsService showsService;
@BeforeEach
public void before() {
Mockito.when(showsService.shows()).thenAnswer(invocation -> List.of(new Show("mock title", 2020)));
}
@Test
public void showsWithQueryApi() {
GraphQLQueryRequest graphQLQueryRequest = new GraphQLQueryRequest(
new ShowsGraphQLQuery.Builder().build(),
new ShowsProjectionRoot().title()
);
List<String> titles = dgsQueryExecutor.executeAndExtractJsonPath(graphQLQueryRequest.serialize(), "data.shows[*].title");
assertThat(titles).containsExactly("mock title");
}
}
同样可以mock失败的情况:
@Test
void showsWithException() {
Mockito.when(showsService.shows()).thenThrow(new RuntimeException("nothing to see here"));
ExecutionResult result = dgsQueryExecutor.execute(" { shows { title releaseYear }}");
assertThat(result.getErrors()).isNotEmpty();
assertThat(result.getErrors().get(0).getMessage()).isEqualTo("java.lang.RuntimeException: nothing to see here");
}
当执行查询时发生错误时,错误将被包装在QueryException
中。 这使您可以轻松检查错误。 QueryException
的消息是所有错误的串联。 使用getErrors()
方法可以访问各个错误,以进行进一步检查。
Mutations
DGS框架使用@DgsData注解使用下面方法同意可以获取到输入的参数:
type Mutation {
addRating(title: String, stars: Int):Rating
}
type Rating {
avgStars: Float
}
@DgsComponent
public class RatingMutation {
@DgsData(parentType = "Mutation", field = "addRating")
public Rating addRating(DataFetchingEnvironment dataFetchingEnvironment) {
int stars = dataFetchingEnvironment.getArgument("stars");
if(stars < 1) {
throw new IllegalArgumentException("Stars must be 1-5");
}
String title = dataFetchingEnvironment.getArgument("title");
System.out.println("Rated " + title + " with " + stars + " stars") ;
return new Rating(stars);
}
}
注意可以通过调用DataFetchingEnvironment.getArgument
方法来获取参数,像datafetcher一样。
Input Type
在上面的示例中,输入是两种标准标量类型。 您还可以使用复杂类型,并且应在schema中将它们定义为input
类型。 input
类型与GraphQL中的类型几乎相同,但是有一些额外的规则。
根据GraphQL规范,一个input 类型应该总是以Map
类型传到datafetcher,意味着DataFetchingEnvironment.getArgument
获取一个input类型会获取到一个Map
。而不是可能具有的Java / Kotlin表示形式。 该框架具有与此相关的便利机制,下面将对其进行讨论。 让我们首先来看一个直接使用DataFetchingEnvironment的示例。
type Mutation {
addRating(input: RatingInput):Rating
}
input RatingInput {
title: String,
stars: Int
}
type Rating {
avgStars: Float
}
@DgsComponent
public class RatingMutation {
@DgsData(parentType = "Mutation", field = "addRating")
public Rating addRating(DataFetchingEnvironment dataFetchingEnvironment) {
Map<String,Object> input = dataFetchingEnvironment.getArgument("input");
RatingInput ratingInput = new ObjectMapper().convertValue(input, RatingInput.class);
System.out.println("Rated " + ratingInput.getTitle() + " with " + ratingInput.getStars() + " stars") ;
return new Rating(ratingInput.getStars());
}
}
class RatingInput {
private String title;
private int stars;
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public int getStars() {
return stars;
}
public void setStars(int stars) {
this.stars = stars;
}
}
Input 参数作为datafetcher的方法参数
通过框架可以很容易的获取到input参数,你可以在datafetcher的方法中指定argument做为方法参数。
@DgsComponent
public class RatingMutation {
@DgsData(parentType = "Mutation", field = "addRating")
public Rating addRating(@InputArgument("input") RatingInput ratingInput) {
//No need for custom parsing anymore!
System.out.println("Rated " + ratingInput.getTitle() + " with " + ratingInput.getStars() + " stars") ;
return new Rating(ratingInput.getStars());
}
}
@InputArgument
注解对于指定input argument的名字很有必要,因为可以按照任意顺序指定参数。如果没有注解,框架将尝试使用参数名称,但这仅在使用特定的编译器设置编译代码的情况下才可行。输入参数可以与DgsFetchingEnvironment
参数组合使用。
@DgsComponent
public class RatingMutation {
@DgsData(parentType = "Mutation", field = "addRating")
public Rating addRating(@InputArgument("input") RatingInput ratingInput, DataFetchingEnvironment dfe) {
//No need for custom parsing anymore!
System.out.println("Rated " + ratingInput.getTitle() + " with " + ratingInput.getStars() + " stars") ;
System.out.println("DataFetchingEnvironment: " + dfe.getArgument(ratingInput));
return new Rating(ratingInput.getStars());
}
}
代码生成
代码生成插件会根据你GraphQL 的schema文件在build的过程中生成代码,该插件生成以下内容:
- types的数据类型,input types,enums 和 interfaces。
- 一个
DgsConstant
类包含了types和fields的名字。 - datafecther示例。
- 类型安全的查询API,代表你的查询
Quick Start
代码生成通常集成在构建中。 Gradle插件一直可用,最近社区提供了一个Maven插件。
为了使用这个插件,在你项目的build.gradle
文件中添加如下内容:
// Using plugins DSL
plugins {
id "com.netflix.dgs.codegen" version "[REPLACE_WITH_CODEGEN_PLUGIN_VERSION]"
}
或者,您可以在构建脚本中设置类路径依赖项:
buildscript {
dependencies{
classpath 'com.netflix.graphql.dgs.codegen:graphql-dgs-codegen-gradle:[REPLACE_WITH_CODEGEN_PLUGIN_VERSION]'
}
}
apply plugin: 'com.netflix.dgs.codegen'
接下来,您需要添加任务配置,如下所示:
generateJava{
schemaPaths = ["${projectDir}/src/main/resources/schema"] // List of directories containing schema files
packageName = 'com.example.packagename' // The package name to use to generate sources
generateClient = true // Enable generating the type safe query API
}
注意:请使用插件的最新版本
异步数据获取
data loader在加载数据时解决了N+1的问题。
N + 1问题解释
假设您要查询电影列表,并且每部电影都包含有关该电影导演的一些数据。 还假定Movie和Director实体由两个不同的服务拥有。 在简单的实现中,要加载50部电影,您将不得不调用Director服务50次:每部电影一次。 总共有51个查询:一个查询获取电影列表,而50个查询获取每部电影的导演数据。 这显然效果不佳。
创建要加载的控制器列表,并在单个调用中一次加载所有控制器,会更加有效。 首先必须由Director服务支持,因为该服务需要提供一种加载Director列表的方法。 电影服务中的数据获取程序也必须很智能,以照顾将请求分批处理到Directors服务。
这就是数据加载器进入的地方。