实验性 GraphQL

这篇博文是对最初介绍性文章《超音速亚原子 GraphQL》的后续,在这里我们将探讨更多功能,其中一些是实验性的,我们希望根据您的反馈最终将其纳入 MicroProfile GraphQL 规范!

我们将探讨以下内容

  • 操作上下文 - 优化您的下游进程。

  • 缓存 - 缓存您的端点。

  • 异步 - 并发执行多个请求或数据源。

  • 批量 - 解决 N+1 问题。

  • 泛型支持。

  • 事件和自定义执行。

  • 转换和映射。

  • 构建工具 - Maven 和 Gradle 支持。

所有源代码均在此处提供:github.com/phillip-kruger/graphql-experimental

操作上下文

Context 对象是一个实验性对象,可以注入到您的代码中的任何位置,位于 @GraphQLApi 的下游。

它位于 SmallRye GraphQL 的 api 模块中,旨在最终将其移至 MicroProfile GraphQL API。

示例

我们有一个 Person GraphQL 端点,它使用某个服务从存储它的任何地方获取人员信息。

端点

@GraphQLApi
public class PersonEndpoint {

    @Inject
    PersonService personService;

    @Query
    public List<Person> getPeople(){
        return personService.getAllPeople();
    }

    @Query
    public Person getPerson(int id){
        return personService.getPerson(id);
    }
}

Person 是一个基本的 POJO,可以有多个关系,而这些关系又包含 Person。因此,调用数据库获取一个人,可能会返回更多人,具体取决于关系的数量。在我们的例子中,Person 1 有一个 Spouse,即 Person 2。

classdiagramm

现在假设 PersonService 调用数据库或某个其他存储来获取数据。我们现在可以注入 Context 对象来获取请求的详细信息,并优化我们的调用。

@ApplicationScoped
public class PersonService {

    @Inject Context context;

    public Person getPerson(int id){
        // Use context to get more information on the query
        // Let's print out the context here and see what we have
        System.out.println(context);

        // Get the person from the datastore here.
    }
}

让我们执行一个 Query 来获取 Person 1 的名字和姓氏。

{
  person(id:1){
    names
    surname
  }
}

那么,我们可以从 Context 中获得什么?

我们可以获得几样东西

executionId = 30337360
request = {"query":"{\n  person(id:1){\n    names\n    surname\n  }\n}","variables":null}
operationName = null
operationTypes = [Query]
parentTypeName = Query
variables = null
query = {
  person(id:1){
    names
    surname
  }
},
fieldName = person
selectedFields = ["names","surname"]
source = null
arguments = {id=1}
path = /person

我们可能想知道的是哪些字段被请求了,以便我们进行更好的数据库查询。

所以 fieldName(person)和 selectedFields(names,surname)是我们需要的。

更复杂的 GraphQL 请求将导致更复杂的 datasource 查询,例如,如果我们想知道关系,我们会这样做:

{
  person(id:1){
    names
    surname
    relations{
      relationType
      person{
        names
        surname
      }
    }
  }
}

这将为我们提供 Context selectedFields 中的内容。

[
   "names",
   "surname",
   {
      "relations":[
         {
            "relationType":[
            ]
         },
         {
            "person":[
               "names",
               "surname"
            ]
         }
      ]
   }
]

Source 方法中的 Context

让我们使用 @Source 向 Person 添加一个字段,看看 Context 能给我们带来什么。首先,我们将添加一个服务,该服务从 API(exchangeratesapi.io)获取汇率。这允许我们将该人员的汇率与某种货币进行关联。

在 Java 中,我们添加此 Source 方法:

public ExchangeRate getExchangeRate(@Source Person person, CurencyCode against){
    Map<CurencyCode, Double> map = exchangeRateService.getExchangeRates(against);
    Double rate = map.get(person.curencyCode);
    return new ExchangeRate(person.curencyCode, against, rate);
}

现在我们可以查询该(ExchangeRate)字段了。

{
  person(id:1){
    names
    surname
    exchangeRate(against:GBP){
      rate
    }
  }
}

当我们在 ExchangeRateServiceInject 并打印 Context 时,我们现在得到:

executionId = 17333236733
request = {"query":"{\n  person(id:1){\n    names\n    surname\n    exchangeRate(against:GBP){\n      rate\n    }\n  }\n}","variables":null}
operationName = null
operationTypes = [Query]
parentTypeName = Person
variables = null
query = {
  person(id:1){
    names
    surname
    exchangeRate(against:GBP){
      rate
    }
  }
}
fieldName = exchangeRate
selectedFields = ["rate"]
source = com.github.phillipkruger.user.model.Person@7929ad0a
arguments = {against=GBP}
fieldName = exchangeRate
path = /person/exchangeRate

请注意,fieldName 现在是 exchangeRate,selectedFields 是 ["rate"]。您还会注意到 source 字段已填充了 person。

缓存

另一个经常出现的问题是如何缓存您的端点结果。举个例子,假设汇率信息可以每天更新,因此我们不希望每次调用都向 exchangeratesapi.io 发起调用。

您只需使用 Quarkus 随附的缓存!只需包含缓存扩展即可。

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-cache</artifactId>
</dependency>

并在您的方法上添加 @CacheResult 注解。

@CacheResult(cacheName = "exchange-rate-cache")
public ExchangeRate getExchangeRate(@Source Person person, CurencyCode against){
    Map<CurencyCode, Double> map = exchangeRateService.getExchangeRates(against);
    Double rate = map.get(person.curencyCode);
    return new ExchangeRate(person.curencyCode, against, rate);
}

在此处阅读有关 Quarkus 缓存的更多信息:quarkus.io/guides/cache

异步

现在,让我们添加另一个服务,该服务返回一个城市的天气状况。

@GraphQLApi
public class TravelEndpoint {

    @Inject
    WeatherService weatherService;

    @Query
    public Weather getWeather(String city){
        return weatherService.getWeather(city);
    }

}

假设这个人正在前往伦敦,您现在可以这样做:

{
  person(id:1){
    names
    surname
    exchangeRate(against:GBP){
      rate
    }
  }
  weather(city:"London"){
    description
    min
    max
  }
}

目前,person 和 weather 查询将按顺序执行,而且没有真正的原因应该是这样。我们可以在获取 person 的同时获取天气。

async1

让我们更改 Java 代码。

@Query
public CompletableFuture<Person> getPerson(int id){
    return CompletableFuture.supplyAsync(() -> personService.getPerson(id));
}

@Query
public CompletableFuture<Weather> getWeather(String city){
    return weatherService.getWeather(city);
}

现在 person 和 weather 正在并发获取。

假设这个人实际上想去伦敦纽约,我们可以这样做:

{
  person(id:1){
    names
    surname
    gbp:exchangeRate(against:GBP){
      rate
    }
    usd:exchangeRate(against:USD){
      rate
    }
  }
  uk:weather(city:"London"){
    description
    min
    max
  }
  us:weather(city:"New York"){
    description
    min
    max
  }
}

我们现在可以更改代码以并发获取汇率。

async2
public CompletableFuture<ExchangeRate> getExchangeRate(@Source Person person, CurencyCode against){
    return CompletableFuture.supplyAsync(() -> exchangeRateService.getExchangeRate(against,person.curencyCode));
}

批量

如果您想获取所有 person,并且您包含了一个带有 Source 方法的字段(例如 exchangeRate),这意味着对于每个 person,我们都会调用 getExchangeRate 方法。根据 person 的数量,这可能会产生大量调用。因此,您可能更希望执行一个批量 source 方法。

batch

这将允许您在一个方法中获取所有 person,并进行一次调用来获取他们的汇率。

因此,让我们将 getExchangeRate 方法更改为接受 person 的 List 并返回 ExchangeRateList

public List<ExchangeRate> getExchangeRate(@Source List<Person> people, CurencyCode against){
    Map<CurencyCode, Double> map = exchangeRateService.getExchangeRates(against);

    List<ExchangeRate> rates = new ArrayList<>();
    for(Person person : people){
        Double rate = map.get(person.curencyCode);
        rates.add(new ExchangeRate(person.curencyCode, against, rate));
    }

    return rates;
}
上面的代码在只有一个 person 的 getPerson 方法上仍然有效。

查询所有 person。

{
  people{
    names
    surname
    exchangeRate(against:GBP){
      rate
    }
  }
}

这将调用所有 person 的 getExchangeRate 方法。

泛型

现在是 2050 年,我们需要扩展我们的旅行服务以也处理外星人。让我们添加一个泛型 Being 类型。

public class Being<T> {

    private T being;

    public Being() {
    }

    public Being(T being) {
        this.being = being;
    }

    public T getBeing() {
        return being;
    }

    public void setBeing(T being) {
        this.being = being;
    }
}

现在更改 Endpoint 以允许 people 和 alien 查询。

@Query
public Being<Person> getPerson(int id){
    return new Being<>(personService.getPerson(id));
}

@Query
public Being<Alien> getAlien(int id){
    return new Being<>(alienService.getAlien(id));
}

然后我们可以查询人类和外星生物。

{
  person(id:1){
    being{
      names
      surname
    }
  }
  alien(id:1){
    being{
      type
      from
    }
  }
}

事件和自定义执行

当您启用与 MicroProfile Metrics、MicroProfile OpenTracing 和 Bean Validation 的集成时,事件会在内部使用,但您也可以参与这些事件。这些都是 CDI 事件,可以使用 @Observes 注解。

在构建 Schema 时

当我们在类路径中扫描注解和类型时,我们会构建一个所有操作的模型。您可以通过参与创建操作事件来操作此模型。

public Operation createOperation(@Observes Operation operation) {
    // Here manipulate operation
    return operation;
}

在最终 Schema 构建之前,在扫描所有注解和上述事件之后,您可以参与并为 Schema 做出贡献:这会暴露底层的 graphql-java 实现细节,当您想做一些 SmallRye GraphQL 尚未实现的事情时,例如订阅,这可能会很有用。

public GraphQLSchema.Builder beforeSchemaBuild(@Observes GraphQLSchema.Builder builder) {
    // Here add you own, in example a subscription
    return builder;
}

在运行请求时

events

在此示例请求中:

{
  person(id:1){
    names
    surname
    exchangeRate(against:USD){
      rate
      base
    }
  }
}

请求流程如下:

  • Execution 服务获取请求。

  • Person 通过 datafetcher 获取。

  • 您的 CDI Bean(@GraphQLApi)方法(getPerson)正在被调用。

  • 汇率正在获取,并将上述 person 作为参数传递。

  • 您的 CDI Bean(@GraphQLApi)方法(getExchangeRate)正在被调用。

  • 正在返回数据。

您可以在所有这些点上接收事件。

public void beforeExecute(@Observes @BeforeExecute Context context) {
    System.err.println(">>>>> Received beforeExecute event [" + context.getQuery() + "]");
}

public void beforeDataFetch(@Observes @BeforeDataFetch Context context) {
    System.err.println(">>>>> Received beforeDataFetch event [" + context.getQuery() + "]");
}

public void beforeInvoke(@Observes InvokeInfo invokeInfo) {
    System.err.println(">>>>> Received beforeInvoke event [" + invokeInfo.getOperationMethod().getName() + "]");
}

public void afterDataFetch(@Observes @AfterDataFetch Context context) {
    System.err.println(">>>>> Received afterDataFetch event [" + context.getQuery() + "]");
}

public void afterExecute(@Observes @AfterExecute Context context) {
    System.err.println(">>>>> Received afterExecute event [" + context.getQuery() + "]");
}

当发生错误时,您还可以收到事件。

public void errorExecute(@Observes @ErrorExecute ErrorInfo errorInfo) {
    System.err.println(">>>>> Received errorExecute event [" + errorInfo.getT() + "]");
}

public void errorDataFetch(@Observes @ErrorDataFetch ErrorInfo errorInfo) {
    System.err.println(">>>>> Received errorDataFetch event [" + errorInfo.getT() + "]");
}

直接使用 Execution 服务

默认假定的行为是通过 HTTP 与您的端点进行交互,但是您可以自己注入 ExecutionService 并执行请求。

例如,让我们在启动时执行一个获取所有 person 的名字的请求。

{
  people{
    names
  }
}

我们现在可以这样做:

@ApplicationScoped
public class StartupService {

    @Inject ExecutionService executionService;

    public void init(@Observes StartupEvent event){

        JsonObjectBuilder builder = Json.createObjectBuilder();
        builder.add("query", ALL_NAMES);
        JsonObject request = builder.build();
        JsonObject response = executionService.execute(request);

        System.err.println(">>>>> " + response);

    }

    private static final String ALL_NAMES = "{\n" +
                                            "people{\n" +
                                            "    names\n" +
                                            "  }\n" +
                                            "}";
}

转换和映射

默认情况下,日期和数字值可以使用JsonB 格式进行转换。

public class Person {
    public String name;

    @JsonbDateFormat("dd.MM.yyyy")
    private Date birthDate;

    @JsonbNumberFormat("#0.00")
    public BigDecimal salary;
}

MicroProfile GraphQL 规范将相关的 Java 类型映射到 GraphQL Scalar。您可以更改现有字段的映射,以映射到另一个 Scalar 类型,如下所示:

@ToScalar(Scalar.Int.class)
Long id; // This usually maps to BigInteger

在 GraphQL Schema 中,这现在将映射到 int

您还可以添加一个 Object,该 Object 应转换为 Scalar 类型而不是复杂对象。例如,您可能有一个 Email Object,但不想在 GraphQL 中使用复杂类型,而是将其映射到 String

为此,您的 Email POJO 需要实现 toString 方法,并具有一个接受 String 的构造函数,或者一个静态的 Email fromString(String s) 方法,或者一个 setValue(String value) 方法。

public class Email {
    private String value;

    public Email() {
    }

    public Email(String value) {
        this.value = value;
    }

    public String getValue() {
        return value;
    }

    public void setValue(String value) {
        this.value = value;
    }

    @Override
    public String toString() {
        return value;
    }
}

然后,您可以将其用作 Response 上的字段,并添加 @ToScalar 注解,即 person。

@ToScalar(Scalar.String.class)
Email email; // This usually maps to a complex object

构建工具

最后,已添加支持,可以使用 maven(或 gradle)在构建时生成 Schema。

例如,在 maven 中,您可以将此添加到您的 pom.xml

<plugin>
    <artifactId>smallrye-graphql-maven-plugin</artifactId>
    <groupId>io.smallrye</groupId>
    <executions>
        <execution>
            <goals>
                <goal>generate-schema</goal>
            </goals>
        </execution>
    </executions>
</plugin>

生成的 Schema 将存储在 target/generated/ 中。