当前位置:Java -> 如何真正使用Java构建GraphQL服务器

如何真正使用Java构建GraphQL服务器

Netflix DGSSpring for GraphQL是使用Java或Kotlin构建GraphQL服务器的现代框架。本教程旨在演示使用Java中广受推崇的graphql-java库构建GraphQL服务器的过程。本系列的后续安装将使用Hibernate ORM、Netflix DGS和Spring for GraphQL来增强这个项目。我们还将报告这些不同方法的性能,最终的安装程序将对这些不同方法之间的权衡进行调查。其中一些权衡的预览将出现在最初的安装程序中,包括本系列的第一部分。

TL;DR

经过深入研究,我确定使用Netflix DGS建立GraphQL服务器需要大量的努力,包括集成多个框架、大量的样板代码和频繁的上下文切换。我的观察与GraphQL中其他以模式为先、以解析为导向的方法论相一致,其特点是开发周期长、API表面、对数据模型依赖强。

在我看来,最佳选择是采用数据为先、以编译为导向的策略来实现GraphQL。我将在文章末尾分享我对这种替代方法的看法。

GraphQL中的“模式为先”方法是什么?

Netflix DGS库的维护者强烈推荐了通常被称为“模式为先”的GraphQL服务器开发。核心graphql-java库的维护者也是如此,他们在他们的书中写道:

“模式为先”指的是GraphQL模式的设计应该是独立完成的,不应该从其他地方生成或推断得出。模式不应该从数据库模式、Java域类或REST API生成。

模式应该是模式先,因为它们应该是经过深思熟虑创建的,而不仅仅是生成的。虽然GraphQL API与用于获取数据的数据库模式或REST API有许多共同之处,但模式仍应该是经过深思熟虑构建的。

我们坚信这是任何真实的GraphQL API的唯一可行方法,并且我们只会专注于这种方法。Spring for GraphQL和GraphQL Java只支持“模式为先”。

其他人并不确定,包括graphql-java文档的维护者,他们写道他们的库“提供了两种不同的定义模式的方式[以代码为先和以模式为先]”。

作为支持第三种方式的低代码和以数据为先开发的倡导者,本文中我们所在公司走出了我们的舒适区,遵循了正统的模式为先开发方式使用最先进的Netflix DGS库在Java中开发演示GraphQL服务器,并对经验进行了报告。

什么构成了GraphQL服务器?

“在记录时间内使用Java或Kotlin构建一个完整功能的GraphQL服务器。”
– Netflix DGS入门指南

Netflix DGS是一个使用Java或Kotlin构建GraphQL服务器的框架,但是“GraphQL服务器”是什么,它有哪些特性?根据我们的经验,GraphQL服务器必须具备以下特性。

功能性关注

满足最终用户用例需求的主要软件功能:

  • 查询:用于从数据模型获取数据的灵活、通用的语言,是GraphQL规范的一部分
  • 变更:用于更改数据模型中数据的灵活、通用的语言,是GraphQL规范的一部分
  • 订阅:用于从数据模型中获得实时或准实时数据的灵活、通用的语言,是GraphQL规范的一部分
  • 业务逻辑:表达常见应用逻辑的模型:授权、验证和副作用
  • 集成:合并来自其他服务的数据和功能的能力

非功能性关注

支持功能关注的辅助软件功能,操作人员的服务质量(QoS)保证:

  • 缓存:用于获取特定类别操作的更好性能的可配置时间与空间的折衷
  • 安全性:防范攻击和威胁向量
  • 可观察性:发出诊断信息,如度量、日志和跟踪,以帮助操作和故障排除
  • 可靠性:促进高效和正确运行的高质量工程

Netflix DGS实战

将这些特性作为我们对“GraphQL服务器”的定义,它们构成了我们理想的最终状态。那就是我们的目标。使用Netflix DGS,我们如何达到这个目标?这是一个要求很多特性的庞大任务,所以要踏实慢慢来。我们如何实现第一个特性的更加简化版本的目标?

查询是一个灵活的、通用的语言,用于从数据模型获取数据,它是GraphQL规范的一部分。

我们如何实现一个Netflix DGS GraphQL服务器来从数据模型获取数据?为了更“具体”,将“数据模型”理解为数据库。作为一个方便的参考点,将它作为具有SQL API的关系型数据库。我们如何开始呢?

1.获取Spring Boot

Netflix DGS框架"基于Spring Boot 3.0",因此选择DGS意味着选择Spring Boot而不是替代方案,比如QuarkusVert.x,这两者是Spring的受欢迎替代品,也是Spring Boot的Java应用程序框架基础。在没有现有的Spring Boot应用程序可供构建的情况下,可以使用基于Web的Spring Initializr创建一个新的Spring Boot应用程序。成熟的Java和Spring Boot商店可能会替换他们自己优化的创建过程,但Initializr是实现快速迁移承诺的最佳方式。

Spring Initializr screenshot

2. 获取Netflix DGS

由于Spring Boot是"主观的",它包括一些但并非所有相关的"电池"。因此,我们必须在Gradle构建文件或Maven POM文件中添加DGS本身


<dependencyManagement>

    <dependencies>

      ...

        <dependency>

            <groupId>com.netflix.graphql.dgs</groupId>

            <artifactId>graphql-dgs-platform-dependencies</artifactId>

            <version>4.9.16</version>

            <type>pom</type>

            <scope>import</scope>

        </dependency>

      ...

    </dependencies>

</dependencyManagement>



<dependencies>

  ...

    <dependency>

        <groupId>com.netflix.graphql.dgs</groupId>

        <artifactId>graphql-dgs-spring-boot-starter</artifactId>

    </dependency>

  ...

</dependencies>


3. 获取GraphQL模式

由于DGS框架"专为基于模式的开发设计",因此首先需要创建一个模式文件。这是为了GraphQL API,但该API是在数据模型上,因此该模式基本上就是一个数据模型。虽然从基本数据模型(数据库)中生成模式可能很诱人,但选择DGS意味着从头开始编写新的数据模型。请注意,如果这个新的GraphQL数据模型与基础数据库数据模型强烈相似,这一步可能会感觉像重复。请忽略这种感觉。

type Query {

    shows(titleFilter: String): [Show]

    secureNone: String

    secureUser: String

    secureAdmin: String

}



type Mutation {

    addReview(review: SubmittedReview): [Review]

    addReviews(reviews: [SubmittedReview]): [Review]

    addArtwork(showId: Int!, upload: Upload!): [Image]! @skipcodegen

}



type Subscription {

    reviewAdded(showId: Int!): Review

}



type Show {

    id: Int

    title: String @uppercase

    releaseYear: Int

    reviews(minScore:Int): [Review]

    artwork: [Image]

}



type Review {

    username: String

    starScore: Int

    submittedDate: DateTime

}



input SubmittedReview {

    showId: Int!

    username: String!

    starScore: Int!

}



type Image {

    url: String

}



scalar DateTime

scalar Upload

directive @skipcodegen on FIELD_DEFINITION

directive @uppercase on FIELD_DEFINITION


4. 获取DataFetchers

DataFetcher是DGS中的基本抽象。它扮演着Model-View-Controller(MVC)架构中的Controller角色。 DataFetcher是一个带有@DgsQuery@DgsData注释的Java或Kotlin方法,在一个用@DgsComponent注释装饰的类中。注释的功能是指示DGS运行时将该方法视为GraphQL模式中类型的字段的解析器,在执行涉及该字段的查询时调用该方法,并在编制查询的响应有效载荷时包括该字段的数据。在模式中定义了类型和字段的情况下,该步骤可能也会感觉重复。同样地,请忽略这种感觉。

package com.example.demo.datafetchers;



import com.example.demo.generated.types.*;

import com.example.demo.services.*;

import com.netflix.graphql.dgs.*;

import java.util.*;

import java.util.stream.*;

@DgsComponent            // Mark this class as DGS Component

public class ShowsDataFetcher {

    private final ShowsService showsService;



    public ShowsDataFetcher(ShowsService showsService) {

        this.showsService = showsService;

    }



    @DgsQuery            // Mark this class as a DGS DataFetcher

    public List<Show> shows(@InputArgument("titleFilter") String titleFilter) {

        if (titleFilter == null) return showsService.shows();

        return showsService.shows().stream().filter(s -> s.getTitle().contains(titleFilter)).collect(Collectors.toList());

    }

}


5. 获取POJO(可选)

如果DataFetchers在MVC架构中扮演Controller的角色,通常会有相应的组件用于Models。DGS并不需要它们,因此可以被视为"可选的",尽管DGS示例和典型的Spring应用程序都有它们。它们可以是JavaRecords,甚至是JavaMaps(稍后会详细介绍)。尽管如此,通常它们是Plain Old Java Objects(POJOs)并且是内存中应用层数据模型的基本单元,通常与基础持久性数据库数据模型相对应。数据库表,GraphQL模式类型,GraphQL模式顶级查询字段,DGS DataFetchers和POJO之间的一对一对应可能会感觉像更多的重复。继续忽略这些感觉。

public class Show {

    private final UUID id;

    private final String title;

    private final Integer releaseYear;



    public Show(UUID id, String title, Integer releaseYear) {

        this.id = id;

        this.title = title;

        this.releaseYear = releaseYear;

    }



    public UUID getId() {

        return id;

    }



    public String getTitle() {

        return title;

    }



    public Integer getReleaseYear() {

        return releaseYear;

    }

}  


6. 获取实际数据(不可选)

不幸的是,DGS入门指南提供的示例只是返回硬编码的内存数据,这对于实际应用程序来说并不是一个选择,因为它的数据存储在具有SQL API的关系数据库中,如上所述。

当然,多层MVC架构中的模型和控制器层独立于视图层,不需要是GraphQL或DGS特定的。因此,有意见的DGS指南不该对如何在模型对象和关系型数据库之间精确映射数据持有意见。在现实应用中,通常会使用对象关系映射(ORM)框架,比如 HibernateJOOQ,但这些工具都有自己的入门指南:

考虑选择Hibernate是因为它更受欢迎,得到了更广泛的行业支持,拥有更多的学习资源,并且与Spring生态系统的集成稍微更好 - 这些都是关键因素。在这种情况下,以下是一些剩下的步骤。

7. 获取Hibernate

现在是时候向应用程序添加另一个“电池”了,就像Spring和DGS一样,Hibernate要么添加到Gradle构建文件,要么添加到Maven POM文件。

<dependencyManagement>

  <dependencies>

    ...

    <dependency>

      <groupId>org.hibernate.orm</groupId>

      <artifactId>hibernate-platform</artifactId>

      <version>6.4.4.Final</version>

      <type>pom</type>

      <scope>import</scope>

    </dependency>

    ...

  </dependencies>

</dependencyManagement>



<dependencies>

  ...

  <dependency>

    <groupId>org.hibernate.orm</groupId>

    <artifactId>hibernate-core</artifactId>

  </dependency>

  ...

</dependencies>


8. 连接到数据库

应用程序需要访问数据库,这可以通过在${project.basedir}/src/main/resources目录中简单地配置一个hibernate.properties文件来配置到Hibernate。

hibernate.connection.url=<JDBC url>

hibernate.connection.username=<DB role name>

hibernate.connection.password=<DB credential secret>


9. 获取POJOs和表之间的映射

Hibernate可以通过在这些类上加注解(如@Entity@Table@Id等)来将表映射到POJOs - 这些又是MVC架构中的模型 - 来指示Hibernate运行时将这些类视为从数据库检索(以及将更改刷新回数据库)对应数据的目标。

@Entity                // Mark this as a persistent Entity

@Table(name = "shows")        // Name its table if different

public class Show {

    @Id                // Mark the field as a primary key

    @GeneratedValue        // Specify that the db generates this

    private final UUID id;

    private final String title;

    private final Integer releaseYear;



    public Show(UUID id, String title, Integer releaseYear) {

        this.id = id;

        this.title = title;

        this.releaseYear = releaseYear;

    }



    public UUID getId() {

        return id;

    }



    public String getTitle() {

        return title;

    }



    public Integer getReleaseYear() {

        return releaseYear;

    }

}  


Hibernate还可以将表映射到Java Map实例(哈希表),而不是POJOs,如前所述。这是通过所谓的动态映射完成的,所谓的映射文件。通常,应用程序中的每个模型都将有一个XML映射文件,其命名规范为modelname.hbm.xml,位于${project.basedir}/main/resources目录中。这种方法取代了编写Java POJO文件、用Hibernate注解对其进行注释以及编写XML映射文件并将等效的元数据嵌入其中的工作。也许在交易中没有得到太多收获,但现在忽略这些感觉应该是第二天性了。

<!DOCTYPE hibernate-mapping PUBLIC

    "-//Hibernate/Hibernate Mapping DTD 3.0//EN"

    "http://www.hibernate.org/dtd/hibernate-mapping-3.0.dtd">

<hibernate-mapping>

    <class entity-name="Show">

      <id name="id" column="id" length="32" type="string"/> <!--no native UUID type in Hibernate mapping-->

      <property name="title" not-null="true" length="50" type="string"/>

      <property name="releaseYear" not-null="true" length="50" type="integer"/>

    </class>

</hibernate-mapping>


10. 进行检索

如上所述,DGS演示示例Controller类从硬编码的内存数据中检索数据。在实际应用程序中,应该用在这三个关键框架之间建立重要联系的实现来替换此实现:DGS、Spring和Hibernate。

package com.example.demo.services;



import com.example.demo.generated.types.*;

import java.util.*;

import org.hibernate.cfg.*;

import org.springframework.stereotype.*;

import static org.hibernate.cfg.AvailableSettings.*;



@Service

public class ShowsServiceImpl implements ShowsService {

    private StandardServiceRegistry registry;

    private SessionFactory sessionFactory;



    public ShowsServiceImpl() {

        try {

            this.registry = new StandardServiceRegistryBuilder().build();

            this.sessionFactory = 

                new MetadataSources(this.registry)             

                .addAnnotatedClass(Show.class) // Add every Model class to the Hibernate metadata

                .buildMetadata()                  

                .buildSessionFactory();           

        }

        catch (Exception e) {

            StandardServiceRegistryBuilder.destroy(this.registry);

        }

    }



    @Override

    public List<Show> shows() {

        List<Show> shows = new ArrayList<>();

        sessionFactory.inTransaction(session -> {

                session.createSelectionQuery("from Show", Show.class) // 'Show' is mentioned twice

                    .getResultList()   

                    .forEach(show -> shows.add(show));

            });

        return shows;

    }

}


11. 进行迭代

此时,已经有了相当多的代码和其他软件工件:

  • GraphQL模式文件(*.sdl
  • 项目构建文件(build.gradlepom.xml
  • 配置文件(hibernate.properties
  • DGS DataFetcher文件(*.java
  • POJO文件(*.java
  • 其他控制器文件(*.java

同时还有来自三个框架的包、符号和注解:

  • DGS
  • Spring
  • Hibernate

然而,即使它能够编译,单独运行可能只是走运而已,要让它正常工作可能还需要可重复的单元测试代码来快速而自信地对项目进行迭代。幸运的是,Spring Initialzr已经向项目构建文件中添加了JUnit和Spring Boot测试组件。然而,要使用诸如Mockito这样的框架的模拟数据,必须将其组件添加到项目构建文件中。

<dependencies>

  ...

  <dependency>

    <groupId>org.mockito</groupId>

    <artifactId>mockito-inline</artifactId>

    <version>{mockitoversion}</version>

  </dependency>

  <dependency>

    <groupId>org.mockito</groupId>

    <artifactId>mockito-junit-jupiter</artifactId>

    <version>{mockitoversion}</version>

    <scope>test</scope>

  </dependency>

  ...

</dependencies>


现在需要为每个DataFetcher创建单元测试类文件,确保根据需要设置模拟数据。

import com.netflix.graphql.dgs.*;

import com.netflix.graphql.dgs.autoconfig.*;

import java.util.*;

import org.junit.jupiter.api.*;

import org.mockito.*;

import org.springframework.beans.factory.annotation.*;

import org.springframework.boot.test.context.*;

import static org.assertj.core.api.Assertions.assertThat;



@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");

    }

}


12. 达到一个里程碑

最终,在编写测试用例的同时对实现进行迭代后,会达到一个里程碑。有一个功能正常的Netflix DGS GraphQL服务器从关系数据库中的基础数据模型中获取了一些数据。在这一成功的基础上,回顾一下目前为止已经完成了什么,以便开始计划还需要完成什么。

回想一下我们对“GraphQL服务器”的定义,即以前面列出的功能和非功能关注点为特征,并注意哪些特性已经实现了,哪些还没有:

  • [✓] 查询
  • [ ] 灵活的通用查询
  • [ ] 变更
  • [ ] 订阅
  • [ ] 业务逻辑
  • [ ] 集成
  • [ ] 缓存
  • [ ] 安全性
  • [ ] 可靠性和错误处理

还有很长的路要走。诚然,像Spring、Hibernate和Netflix DGS这样的框架的提供者已经考虑到了这一点。这些框架的好处是它们提供了代码组织、提供了关于它的思考的思维模型、提供了来自经验的意见的指导,并提供了“亮光大道”来添加这些其他功能并实现“完整功能的GraphQL服务器”的承诺。

例如,我们可以通过切换到JPA实体图检索来在Hibernate中恢复一些我们所寻求的灵活性。或者,可以通过DGS DataFetchers组成Hibernate查询语言(HQL)查询,Hibernate随后执行这些查询来检索数据,而不是通过Hibernate直接获取数据。这种类似于编译SQL(甚至可能是从GraphQL编译)的特征强大但又将底层数据库SQL方言的细节抽象化。

同样地,可以在Hibernate(或者如果是ORM的话,也可以在JOOQ上)中配置缓存。还可以在Spring Boot中进行配置。像安全性这样的其他功能可以通过像Bucket4j这样的工具添加到Spring中。至于业务逻辑,代码本身(Java或Kotlin)在DataFetchers、Controllers和Model POJOs中提供了明显的安装业务逻辑的地点。当然,Spring固然有一些设施来帮助处理这些问题,比如授权和数据验证。

因此,很可能可以通过将Netflix DGS、Spring Boot、ORM和其他框架和库相结合,来用Java或Kotlin构建一个“完整功能的GraphQL服务器”。但是,有几个问题需要解决:

  • 能否“以创纪录的时间”完成这个目标?
  • 是否应该尝试用这种方式构建一个“完整功能的GraphQL服务器”?
  • 是否有更好的方法?

GraphQL是否有更快的途径?

请记住,目标从来不是为了编写Java或Kotlin代码、搬弄框架、忍受无休止的上下文切换或生成样板文件。目标是:

在创纪录的时间内,用Java或Kotlin构建一个完整功能的GraphQL服务器。

回到基本问题,可以回答上面的第三个问题:是否有更好的方法?是的。

GraphQL

GraphQL是什么?回答这个问题的一个方式是列出它的特点。其中一些特点是:

  • GraphQL是一种灵活的、通用的类型查询语言。
  • 类型具有字段。
  • 字段可以与其他类型相关。

如果这听起来很熟悉,是因为其他的数据组织方式也具备了相同的特点。比如SQL就是其中之一。它并不是唯一的一个,但是是一个非常常见的方式。另一方面,GraphQL还有几个次要但相关的特点:

  • GraphQL具有直观的语义。
  • GraphQL具有高度规范的机器可读输入格式。
  • GraphQL具有高度规范的机器可读输出格式。

如果SQL比GraphQL更强大,GraphQL比SQL更统一,并且比SQL更容易使用。它们似乎是天作之合,这引发了一些明显的问题:

  • GraphQL能否编译成SQL(和其他查询语言)?
  • GraphQL应该编译成SQL吗?
  • 这会带来哪些权衡?

GraphQL转换为SQL:编译器优于解决器

第一个问题的答案肯定是“是的”。Hasura、PostGraphile、Prisma和Supabase都在这样做。PostgREST甚至也做了类似的事情,尽管不是为GraphQL。

第二个问题的答案显然要取决于第三个问题的答案。

那么,采用编译器方法而不是解析器方法的权衡是什么?首先,有哪些好处?

好处 评论

快速

真正以“记录时间”交付。

统一

一劳永逸地解决数据提取问题。

特性

围绕核心构建功能性和非功能性关注点。

高效

自然地避免 N+1 问题

利用

充分利用底层数据库的所有功能。

操作

轻松部署、监控和维护应用程序。

其次,有哪些缺点,如何加以减轻?

缺点 评论 缓解策略

耦合

API与数据模型捆绑。

数据库视图和其他形式的间接访问。

业务逻辑

代码是实现业务逻辑的自然地方。

数据库视图、函数和其他逃生舱。

非正统

违背常规智慧的困难。

以"以记录时间构建完整的GraphQL服务器"真正做到。

结论

GraphQL社区普遍认为,采用基于架构的、基于解析器的方法,通常涉及在诸如Java、Kotlin、Python、Ruby或JavaScript等语言中手动创建数据获取器,会导致开发周期延长、API有限、实现浅显、与数据模型紧密耦合,从而导致运行效率低下和脆弱性。这是一个相当简单的观点。

随着行业的发展,人们越来越认识到需要采用更复杂的数据为先、编译器为导向的方法。随着数据量、速度和种类的不断扩大,企业正在探讨成百上千的数据源和数据表。手动创建反映这些数据结构的GraphQL模式,并在架构的多个层次上复制此过程是不切实际和不可持续的。

就像我们在遇到需要数据解决方案的新业务领域时不会重新发明轮子一样,我们在构建API服务器时也不应该从零开始。相反,我们应该利用功能全面、专门构建的API产品,高效自信地使其适应我们的特定需求。这种方法使我们能够迅速而有效地应对业务挑战,从而节约时间,专注于解决下一组问题。

推荐阅读: 百度面经(21)

本文链接: 如何真正使用Java构建GraphQL服务器