Netflix DGS Note

1 篇文章 0 订阅
1 篇文章 0 订阅

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注解有两个参数:

ParameterDescription
parentType包含字段的类型
fielddatafetcher负责的字段

例:有如下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对象,包含titileactor字段,但是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类型作为parentTypefield的参数,如果使用了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都可以设置defaultValuerequired参数。如果一共@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服务。

这就是数据加载器进入的地方。

错误处理

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值