Java中实现GraphQL完整指南

对于寻求创建强大而高效的 GraphQL API 服务器的 Java 开发人员来说,本指南是宝贵的资源。

本详细指南将带您了解在 Java 中为实际应用程序实现 GraphQL 的所有步骤。它涵盖了 GraphQL 的基本概念,包括其查询语言和数据模型,并强调了它与编程语言和关系数据库的相似之处。

它还提供了使用 Spring Boot、Spring for GraphQL 和关系数据库在 Java 中构建 GraphQL API 服务器的实用分步过程。该设计强调持久性、灵活性、效率和现代性。此外,该博客还讨论了该过程中涉及的权衡和挑战。 

最后,它提出了一种超越传统方法的替代路径,提出了“GraphQL 到 SQL 编译器”的潜在好处,并探索了获取 GraphQL API 而不是构建 API 的选项。 

什么是 GraphQL  GraphQL 是“API 的查询语言”,但您也可以说它是一种 API 或一种构建 API 的方式。这与REST形成了鲜明对比,GraphQL 是 REST 的演变和替代方案。GraphQL 提供了 REST 的多项改进:

  • 表达力:客户端可以说出他们需要从服务器获取什么数据,不多也不少。
  • 效率:表达力可以提高效率,减少网络噪音和带宽浪费。
  • 可发现性:要知道要对服务器说什么,客户端需要知道可以对服务器说什么。可发现性使数据消费者能够准确了解数据生产者提供的内容。
  • 简单性:GraphQL 将客户置于驾驶座上,因此应该具有良好的驾驶人体工程学。GraphQL 高度规则的机器可读语法、简单的执行模型和简单的规范使其适合于可互操作和可组合的工具

GraphQL 也是查询语言的数据模型:

  • 类型:类型是一个简单的值(一个标量)或一组字段(一个对象)。虽然你可以自然而然地为自己的问题域引入新类型,但也有一些特殊类型(称为操作)。其中之一是查询,它是数据请求的根源(为了简单起见,暂时将订阅放在一边)。类型本质上是一组规则,用于确定一段数据(或对该段数据的请求)是否有效地符合给定类型。GraphQL 类型非常类似于 C++、Java 和 Typescript 等编程语言中的用户定义类型,并且非常类似于关系数据库中的表。
  • 字段:一种类型中的字段包含一个或多个有效符合另一种类型的数据,从而建立类型之间的关系。GraphQL 字段非常类似于编程语言中用户定义类型的属性,也非常类似于关系数据库中的列。GraphQL 类型之间的关系非常类似于编程语言中的指针或引用,也非常类似于关系数据库中的外键约束。

为什么我们应该将它视为 REST 的替代品?

  • 对于数据使用者:GraphQL 的表现力、效率、可发现性和简单性使数据消费者的生活更加轻松。
  • 对于数据生产者:GraphQL 的表现力、效率、可发现性和简单性使数据生产者的生活变得更加困难。

Java GraphQL 库 该领域有三个重要的相互依赖的参与者:

  • Graphql-java:graphql-java是一个用于在 Java 中使用 GraphQL 的底层基础库,始于 2015 年。由于其他参与者依赖并使用 graphql-java,因此将 graphql-java 视为非可选的。另一个关键选择是您是否使用Spring Boot框架。如果您不使用 Spring Boot,那么就到此为止! 由于这是先决条件,按照 ThoughtWorks Radar 的说法,这是不可避免的。
  • Netflix DGS:  DGS是一个更高级的库,用于在 Java 中使用 Spring Boot 处理 GraphQL,该库于 2021 年开始使用。如果您使用 DGS,那么您还将在后台使用 graphql-java,但通常您不会接触 graphql-java。相反,您将在整个 Java 代码中散布注释,以识别执行 GraphQL 请求的代码段(称为“解析器”或“数据获取器”)。ThoughtWorks表示DGS 将于 2023 年 试用,但这是一个动态空间,他们的观点可能已经改变。我说暂缓,原因如下。
  • Spring for GraphQL:  Spring for GraphQL是另一个用于在 Java 中使用 Spring Boot 的更高级库,它始于 2023 年左右,也是基于注释的。对于 ThoughtWorks 来说,它可能太新了,但对我来说并不太新。我说采用,然后继续阅读以了解原因。

如何用 Java 为真实应用程序构建 GraphQL API 服务器? 对于“真正的应用程序”的含义,人们有不同的看法。就本指南而言,我在此所说的“真正的应用程序”是指至少具有以下功能的应用程序:

  • 持久性:许多教程、入门指南和概述仅涉及内存数据模型,远远没有涉及与数据库的交互。本指南向您展示了跨越这一关键鸿沟的一些方法,并讨论了一些相关的后果、挑战和权衡。这是一个庞大的话题,所以我只是触及了皮毛,但这只是一个开始。主要目标是支持查询操作。延伸目标是支持Mutation操作。Subscription目前,操作完全不可能实现。
  • 灵活性:我在上面写道,我们让 GraphQL API 具有多大的表达力、效率、可发现性和简单性,从技术上讲是我们做出的选择,但实际上这是我们做出的其他选择所产生的属性。我还写道,构建 GraphQL API 服务器对于数据生产者来说很困难。因此,许多数据生产者通过回拨 API 的其他属性来应对这一困难。现实世界中的许多 GraphQL API 服务器不灵活、肤浅、浅薄,并且在许多方面都是“名义上的 GraphQL”。本指南展示了超越现状所涉及的一些内容,以及它如何与其他属性(如效率)产生冲突。剧透警告:它并不漂亮。
  • 效率:公平地说,现实世界中的许多 GraphQL API 服务器通过将 REST API 端点编码为浅层 GraphQL 模式,实现了不错的效率,尽管是以牺牲灵活性为代价的。GraphQL 中的标准方法是数据加载器模式,但很少有教程真正展示如何使用它,即使是使用内存数据模型,更不用说数据库了。本指南提供了一种数据加载器模式的实现来解决 N+1 问题。再次,我们看到了它如何与灵活性和简单性产生矛盾。
  • 现代性:任何编写访问数据库的 Java 应用程序的人都必须选择如何访问数据库。这可能只涉及JDBC和原始 SQL(用于关系数据库),但可以说当前的行业标准仍然是使用对象关系映射 (ORM )层,如Hibernate、jooq或标准JPA。让 ORM 与 GraphQL 很好地配合是一项艰巨的任务,可能并不明智,甚至可能根本不可能。几乎没有其他指南会触及这一点。本指南至少会在将来尝试使用 ORM!

本指南中,遵循的用 Java 为关系数据库构建 GraphQL API 服务器的方法如下:

  1. 选择Spring Boot作为整体服务器框架。
  2. 对于 GraphQL 特定的部分,选择 Spring for GraphQL。
  3. 暂时选择Spring Data for JDBC来代替 ORM 进行数据访问。
  4. 选择Maven而不是Gradle,因为我更喜欢前者。如果你选择后者,那你就得自己决定了。
  5. 选择PostgreSQL作为数据库。大多数原则应该适用于几乎任何关系数据库,但您必须从某个地方开始。
  6. 选择Docker Compose来编排开发数据库服务器。还有其他方法可以引入数据库,但同样,您必须从某个地方开始。
  7. 选择Chinook数据模型。当然,你会有自己的数据模型,但 Chinook 是用于说明目的的不错选择,因为它相当丰富,有相当多的表和关系,远远超出了无处不在但微不足道的To-Do应用程序,适用于各种数据库,并且通常易于理解。
  8. 选择Spring Initializr来引导应用程序。Java 中有很多仪式,任何快速完成其中一部分的方式都是受欢迎的。
  9. 创建GraphQL 模式文件。这是 graphql-java、DGS 和 Spring for GraphQL 的必要步骤。奇怪的是,Spring for GraphQL 概述似乎忽略了这一步,但 DGS“入门”指南会提醒我们。许多“思想领袖”会劝告您将底层数据模型与 API 隔离开来。理论上,您可以通过将 GraphQL 类型与数据库表分开来做到这一点。实际上,这是繁重工作的根源。
  10. 编写 Java 模型类,为架构文件中的每个 GraphQL 类型和数据库中的每个表编写一个。您可以自由地为此数据模型或任何其他数据模型做出其他选择,甚至可以编写代码或 SQL 视图以将底层数据模型与 API 隔离开来,但请问当表/类/类型的数量增长到数百或数千时,这到底有多重要。
  11. 编写 Java 控制器类,每个根字段至少有一个方法。实际上,这是最低要求。可能还会有更多。顺便说一句,这些方法是您的“解析器”。
  12. 用 @Controller 对每个控制器类进行注解,以告诉 Spring 将其注入可为网络流量提供服务的 Java Bean。
  13. 用 @SchemaMapping 或 QueryMapping 为每个解析器/数据捕获器方法添加注释,告诉 Spring for GraphQL 如何执行 GraphQL 操作的各个部分。
  14. 以任何必要的方式实现这些解析器/数据撷取器方法,以调解与数据库的交互。在第 0 版中,这将只是简单的原始 SQL 语句。
  15. 通过用 @BatchMapping 替换 @SchemaMapping 或 @QueryMapping 来升级其中一些解析器/数据撷取器方法。后一种注解向 Spring for GraphQL 表明,我们希望通过解决 N+1 问题来提高执行效率,为此我们准备付出更多代码的代价。
  16. 重构这些 @BatchMapping 注释方法,通过接受(和处理)相关实体的标识符列表而不是单个相关实体的单个标识符来支持
  17. 数据加载器模式。
  18. 为每种可能的交互编写大量测试用例。
  19. 只需在应用程序接口上使用模糊测试器fuzz-tester ,然后就可以收工了。

提供了一个公共存储库(步骤 1-5)其中包含易于使用、易于运行、易于阅读和易于理解的有效代码。

下面重点介绍了一些重要步骤,将它们放在上下文中,讨论所涉及的选择,并提供一些替代方案。

步骤 6:选择 Docker Compose 来编排开发数据库服务器

version: "3.6"

services:

  postgres:

    image: postgres:16

    ports:

      - ${PGPORT:-5432}:5432

    restart: always

    environment:

      POSTGRES_PASSWORD: postgres

      PGDATA: /var/lib/pgdata

    volumes:

      - ./initdb.d-postgres:/docker-entrypoint-initdb.d:ro

      - type: tmpfs

        target: /var/lib/pg/data

步骤 7:选择 Chinook 数据模型 YugaByte的 Chinook 文件对于 PostgreSQL 来说开箱即用,是一个不错的选择。只需确保有一个子目录initdb.d-postgres并将 Chinook DDL 和 DML 文件下载到该目录中,注意为它们提供数字前缀,以便 PostgreSQL 初始化脚本按正确的顺序运行它们。

mkdir -p ./initdb.d-postgres

wget -O ./initdb.d-postgres/04_chinook_ddl.sql https://raw.githubusercontent.com/YugaByte/yugabyte-db/master/sample/chinook_ddl.sql

wget -O ./initdb.d-postgres/05_chinook_genres_artists_albums.sql https://raw.githubusercontent.com/YugaByte/yugabyte-db/master/sample/chinook_genres_artists_albums.sql

wget -O ./initdb.d-postgres/06_chinook_songs.sql https://raw.githubusercontent.com/YugaByte/yugabyte-db/master/sample/chinook_songs.sql

启动: docker compose up -d

抽查数据库有效性的方法有很多。如果 Docker Compose 服务似乎已正确启动,这里有一种使用 psql 的方法。

psql "postgresql://postgres:postgres@localhost:5432/postgres" -c '\d'

List of relations

 Schema |      Name       | Type  |  Owner   

--------+-----------------+-------+----------

 public | Album           | table | postgres

 public | Artist          | table | postgres

 public | Customer        | table | postgres

 public | Employee        | table | postgres

 public | Genre           | table | postgres

 public | Invoice         | table | postgres

 public | InvoiceLine     | table | postgres

 public | MediaType       | table | postgres

 public | Playlist        | table | postgres

 public | PlaylistTrack   | table | postgres

 public | Track           | table | postgres

 public | account         | table | postgres

 public | account_summary | view  | postgres

 public | order           | table | postgres

 public | order_detail    | table | postgres

 public | product         | table | postgres

 public | region          | table | postgres

(17 rows)

步骤 8:选择 Spring Initializr 来引导应用程序 此表格的关键在于做出以下选择:

  • Project: Maven
  • Language: Java
  • Spring Boot: 3.2.5
  • Packaging: Jar
  • Java: 21
  • Dependencies: Spring for GraphQL
    • PostgreSQL Driver
您还可以做出其他选择(如 Gradle、Java 22、MySQL 等),但请记住,本指南仅使用上述选择进行了测试。

第 9 步:创建 GraphQL Schema模式结构文件 Maven 项目有一个标准的目录布局,在该布局中,资源文件打包到构建工件(JAR 文件)的标准位置是 ./src/main/java/resources。在该目录下创建一个子目录 graphql,并存放一个 schema.graphqls 文件。还有其他方法来组织 graphql-java、DGS 和 Spring for GraphQL 所需的 GraphQL 模式文件,但它们的根目录都是 ./src/main/java/resources(对于 Maven 项目)。

在 schema.graphqls 文件(或等同文件)中,首先将定义根查询对象,并为我们希望在 API 中使用的每种 GraphQL 类型提供根级别字段。首先,每个表的 Query 下都有一个根级字段,每个表都有一个相应的类型。例如,查询

type Query {

  Artist(limit: Int): [Artist]

  ArtistById(id: Int): Artist

  Album(limit: Int): [Album]

  AlbumById(id: Int): Album

  Track(limit: Int): [Track]

  TrackById(id: Int): Track

  Playlist(limit: Int): [Playlist]

  PlaylistById(id: Int): Playlist

  PlaylistTrack(limit: Int): [PlaylistTrack]

  PlaylistTrackById(id: Int): PlaylistTrack

  Genre(limit: Int): [Genre]

  GenreById(id: Int): Genre

  MediaType(limit: Int): [MediaType]

  MediaTypeById(id: Int): MediaType

  Customer(limit: Int): [Customer]

  CustoemrById(id: Int): Customer

  Employee(limit: Int): [Employee]

  EmployeeById(id: Int): Employee

  Invoice(limit: Int): [Invoice]

  InvoiceById(id: Int): Invoice

  InvoiceLine(limit: Int): [InvoiceLine]

  InvoiceLineById(id: Int): InvoiceLine

}

请注意这些字段的参数。我在编写时让每个具有 List 返回类型的根级字段都接受一个可选的 limit 参数,该参数接受一个 Int。这样做的目的是支持限制从根级字段中返回的条目的数量。还要注意的是,每个具有 Scalar 对象返回类型的根字段都接受一个可选的 id 参数,该参数也接受一个 Int。这样做的目的是支持通过标识符获取单个条目(在 Chinook 数据模型中,所有标识符都是整数主键)。

下面是一些相应 GraphQL 类型的示例:

type Album {

  AlbumId  : Int

  Title    : String

  ArtistId : Int

  Artist   : Artist

  Tracks   : [Track]

}



type Artist {

  ArtistId: Int

  Name: String

  Albums: [Album]

}



type Customer {

  CustomerId   : Int

  FirstName    : String

  LastName     : String

  Company      : String

  Address      : String

  City         : String

  State        : String

  Country      : String

  PostalCode   : String

  Phone        : String

  Fax          : String

  Email        : String

  SupportRepId : Int

  SupportRep   : Employee

  Invoices     : [Invoice]

}

根据自己的需要填写 schema.graphqls 文件的其余部分,公开自己喜欢的任何表(如果创建视图,也可能公开视图)。或者,直接使用共享存储库中的完整版本。

第 10 步编写 Java 模型类 在标准的 Maven 目录布局中,Java 源代码放在 ./src/main/java 及其子目录中。在您使用的 Java 包的适当子目录中,创建 Java 模型类。这些类可以是普通旧 Java 对象(POJO)。它们可以是 Java 记录类。它们可以是任何你喜欢的类型,只要它们有针对 GraphQL 模式中相应字段的 "getter "和 "setter "属性方法。在本指南的存储库中,我选择 Java 记录类只是为了尽量减少模板。

package com.graphqljava.tutorial.retail.models;

  public class ChinookModels {

      public static

          record Album

          (

           Integer AlbumId,

           String Title,

           Integer ArtistId

           ) {}



      public static

          record Artist

          (

           Integer ArtistId,

           String Name

           ) {}



      public static

          record Customer

          (

           Integer CustomerId,

           String FirstName,

           String LastName,

           String Company,

           String Address,

           String City,

           String State,

           String Country,

           String PostalCode,

           String Phone,

           String Fax,

           String Email,

           Integer SupportRepId

           ) {}

  ...

}

步骤 11-14:编写 Java 控制器类,注释每个控制器,注释每个解析器/数据拾取器,并实现这些解析器/数据拾取器 这些是 Spring @Controller 类,其中包含 Spring for GraphQL QueryMapping 和 @SchemaMapping 解析器/数据捕获器方法。这些方法是应用程序真正的工作母机,它们接受输入参数,调解与数据库的交互,验证数据,实现(或委托)业务逻辑代码段,安排发送到数据库的 SQL 和 DML 语句,返回数据,处理数据,并将其发送到 GraphQL 库(graphql-java、DGS、Spring for GraphQL)打包并发送到客户端。在实现这些功能时,我们可以做出很多选择,我无法一一详述。我只想说明我是如何实现的,强调一些需要注意的事项,并讨论一些可用的选项。

作为参考,我们将查看示例库中 ChinookControllers 文件的一部分。

package com.graphqljava.tutorial.retail.controllers; // It's got to go into a package somewhere.



import java.sql.ResultSet;    // There's loads of symbols to import.

import java.sql.SQLException;    // This is Java and there's no getting around that.

import java.util.List;

import java.util.Map;

import java.util.stream.Collectors;



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

import org.springframework.graphql.data.ArgumentValue;

import org.springframework.graphql.data.method.annotation.BatchMapping;

import org.springframework.graphql.data.method.annotation.QueryMapping;

import org.springframework.graphql.data.method.annotation.SchemaMapping;

import org.springframework.jdbc.core.RowMapper;

import org.springframework.jdbc.core.simple.JdbcClient;

import org.springframework.jdbc.core.simple.JdbcClient.StatementSpec;

import org.springframework.stereotype.Controller;



import com.graphqljava.tutorial.retail.models.ChinookModels.Album;

import com.graphqljava.tutorial.retail.models.ChinookModels.Artist;

import com.graphqljava.tutorial.retail.models.ChinookModels.Customer;

import com.graphqljava.tutorial.retail.models.ChinookModels.Employee;

import com.graphqljava.tutorial.retail.models.ChinookModels.Genre;

import com.graphqljava.tutorial.retail.models.ChinookModels.Invoice;

import com.graphqljava.tutorial.retail.models.ChinookModels.InvoiceLine;

import com.graphqljava.tutorial.retail.models.ChinookModels.MediaType;

import com.graphqljava.tutorial.retail.models.ChinookModels.Playlist;

import com.graphqljava.tutorial.retail.models.ChinookModels.PlaylistTrack;

import com.graphqljava.tutorial.retail.models.ChinookModels.Track;





public class ChinookControllers { // You don't have to nest all your controllers in one file. It's just what I do.

    @Controller public static class ArtistController { // Tell Spring about this controller class.

        @Autowired JdbcClient jdbcClient; // Lots of ways to get DB access from the container.  This is one way in Spring Data.

        RowMapper<Artist>          // I'm not using an ORM, and only a tiny bit of help from Spring Data.

            mapper = new RowMapper<>() {  // Consequently, there are these RowMapper utility classes involved.

                    public Artist mapRow (ResultSet rs, int rowNum) throws SQLException {

                        return

                        new Artist(rs.getInt("ArtistId"),

                                   rs.getString("Name"));}};

        @SchemaMapping Artist Artist (Album album) { // @QueryMapping when we can, @SchemaMapping when we have to

            return                     // Here, we're getting an Artist for a given Album.

                jdbcClient

                .sql("select * from \"Artist\" where \"ArtistId\" = ? limit 1"// Simple PreparedStatement wrapper

                .param(album.ArtistId()) // Fish out the relating field ArtistId and pass it into the PreparedStatement

                .query(mapper)         // Use our RowMapper to turn the JDBC Row into the desired model class object.

                .optional()         // Use optional to guard against null returns!

                .orElse(null);}

        @QueryMapping(name = "ArtistById") Artist // Another resolver, this time to get an Artist by its primary key identifier

            artistById (ArgumentValue<Integer> id) { // Note the annotation "name" parameter, when the GraphQL field name doesn't match exactly the method name

            for (Artist a : jdbcClient.sql("select * from \"Artist\" where \"ArtistId\" = ?").param(id.value()).query(mapper).list()) return a;

            return null;}

        @QueryMapping(name = "Artist") List<Artist> // Yet another resolver, this time to get a List of Artists.

            artist (ArgumentValue<Integer> limit) { // Note the one "limit" parameter.  ArgumentValue<T> is the way you do this with GraphQL for Java.

            StatementSpec

                spec = limit.isOmitted() ? // Switch SQL on whether we did or did not get the limit parameter.

                jdbcClient.sql("select * from \"Artist\"") :

                jdbcClient.sql("select * from \"Artist\" limit ?").param(limit.value());

            return        // Run the SQL, map the results, return the List.

                spec

                .query(mapper)

                .list();}}

...

这里有很多东西需要解读,让我们一步步来。首先,我在示例中包含了 package 和 import 语句,因为网上的教程和指南往往为了简洁而忽略了这些细节。然而,这样做的问题是,它不是可编译或可运行的代码。你不知道这些符号来自哪里,在哪个包里,来自哪个库。在编写代码时,IntelliJ、VSCode 甚至 Emacs 等任何像样的编辑器都能帮你理清这些问题,但在阅读博客文章时,你却不知道。此外,不同库中的符号之间可能存在名称冲突和歧义,因此即使是智能编辑器也会让读者挠头。

接下来,请原谅嵌套的内部类。你可以根据自己的喜好,把类分解成各自独立的文件。我就是这么做的,主要是为了像这样的教学目的,以促进行为的局部性,这只是一种花哨的说法,"让我们不要让读者跳过很多障碍来理解代码"。

现在进入代码的实质部分。除了 "如何获得数据库连接?"、"如何映射数据?"等令人头疼的细节外,我希望你能透过林林总总的代码看到以下模式:

  • 模式文件(schema.graphqls)中的每个字段如果不是简单的标量字段(如 Int、String、Boolean),都可能需要一个解析器/数据捕获器。
  • 每个解析器都是通过 Java 方法实现的。
  • 每个解析器方法都有 @SchemaMapping、@QueryMapping 或 @BatchMapping 注释。
  • 尽量使用 @QueryMapping,因为它更简单。必要时使用 @SchemaMapping(你的集成开发环境会提醒你)。
  • 如果能让 Java 方法名称与 GraphQL 字段名称保持一致,就能减少代码量,但不要把它当成联邦案例。您可以通过注解中的名称参数来解决这个问题。
  • 除非你做了一些 "与众不同 "的事情(比如添加过滤、排序和分页),否则你可能会通过主键获取单个条目或条目列表。您不会获取 "子 "条目;这由 GraphQL 库和它们处理 GraphQL 操作的递归分而治之方式来处理。注意:这会影响性能、效率和代码复杂性。
  • 上述项目中的 "与众不同 "指的是您希望为 GraphQL API 添加的丰富性。想要限制操作?过滤谓词?聚合?支持这些情况将涉及更多 ArgumentValue<> 参数、更多 SchemaMapping 解析器方法以及更多组合。面对现实吧。
  • 你会体验到聪明的冲动,你会体验到创建抽象来动态响应越来越复杂的参数、过滤器和其他条件组合的冲动。

第 15 步:使用数据加载器模式升级一些解析器/数据捕获器方法 你很快就会意识到,这会导致与数据库的交互过于频繁,发送过多的小型 SQL 语句,影响性能和可用性。这就是众所周知的 "N+1 "问题。

简而言之,N+1 问题可以用我们的 Chinook 数据模型来说明。假设我们有这样一个 GraphQL 查询。

query {

  Artist(limit: 10) {

    ArtistId

    Album {

      AlbumId

      Track {

        TrackId

      }

    }

  }

}
  • 最多可获取 10 个Artist 艺术家条目。
  • 针对每个Artist艺术家,获取所有相关的Album 专辑条目。
  • 针对每个Album专辑,获取所有相关的Track 曲目。
  • 对于每个条目,只需获取其标识符字段:ArtistId, AlbumId, TrackId.
  • 此查询嵌套在 "Artist "下面 2 层。设为 n=2。
  • Albumis 专辑是Artist 艺术家的列表封装类型,Track 曲目也是Albumis 专辑的列表封装类型。假设典型的 cardinality 为 m。

通常涉及多少条 SQL 语句

  • 1 获取 10 个Artist 艺术家条目。
  • 10*m 获取Album 专辑条目。
  • 10*m^m 获取Track 曲目条目。
一般来说,我们可以看到查询次数会随着 m^n 的增大而增大,而 m^n 是 n 的指数。无论如何,从表面上看,这种获取数据的方式效率之低令人震惊。

还有另一种方法,也是 GraphQL 社区解决 N+1 问题的标准答案:数据加载器模式(又称 "批处理")。这包含三个理念:

  1. 与其使用一个标识符获取单个父实体(如Artist艺术家)的相关子实体(如Album),不如使用标识符列表一次性获取所有父实体的相关实体。
  2. 根据各自的父实体(用代码表示)对生成的子实体进行分组。
  3. 同时,我们还可以在执行 GraphQL 操作的整个过程中缓存实体,以防某个实体在图中出现在多个地方。
现在,请看一些代码。下面是我们示例中的代码。

@BatchMapping(field = "Albums"public Map<Artist, List<Album>> // Switch to @BatchMapping

    albumsForArtist (List<Artist> artists) { // 在父母名单中选择父母,而不是单个父母。

    return

        jdbcClient

        .sql("select * from \"Album\" where \"ArtistId\" in (:ids)"// 使用 SQL "in "谓词和 "nbsp; "标识符列表。

        .param("ids", artists.stream().map(x -> x.ArtistId()).toList()) //从父对象列表中获取标识符列表

        .query(mapper)    // Can re-use our usual mapper

        .list()

        .stream().collect(Collectors.groupingBy(x -> artists.stream().collect(Collectors.groupingBy(Artist::ArtistId)).get(x.ArtistId()).getFirst()));

    // ^ Java 成语 用于根据父
Album
对子
Album
进行分组 。

}

和之前一样,让我们来解读一下。 首先,我们将 @QueryMapping 或 @SchemaMapping 注解切换为 @BatchMapping,向 Spring for GraphQL 发出信号,表明我们要使用数据加载器模式。

其次,我们将单个Artist艺术家参数转换为 List 参数。

第三,我们必须以某种方式安排必要的 SQL(本例中使用 in 谓词)和相应的参数(从 List 参数中提取的 List)。

第四,我们必须以某种方式将子条目(本例中为Album)排序到正确的父条目(本例中为Album )。有很多方法可以做到这一点,这只是其中一种方法。 重要的一点是,无论如何做,都必须用 Java 来完成。

最后一点:注意没有 limit 参数。它去哪儿了? 原来 Spring for GraphQL 不支持 @BatchMapping 的 InputValue。哦,好吧!  在这种情况下,这并不是什么大损失,因为可以说这些限制参数意义不大。

有多少人真正需要艺术家专辑的随机子集? 不过,如果我们有过滤和排序功能,问题就严重多了。过滤和排序参数更合理,如果有了它们,我们就必须想办法把它们隐藏到数据加载器模式中。大概可以做到,但不会像在方法上加上 @BatchMapping 注解和在 Java 流上做手脚那么容易。

这就提出了一个关于 "N+1 问题 "的重要观点, 但这个观点从未被提及,而这种忽视只会在现实世界中夸大问题的严重性。

如果我们有限制和/或过滤,那么我们就有办法把相关子实体的万有引力降到 m 以下(记得我们把 m 视为子实体的典型万有引力)。

在现实世界中,设置限制或更准确地说是过滤对于可用性来说是必要的。

GraphQL 应用程序接口是为人类设计的,因为数据最终会被显示在屏幕上,或以其他方式呈现给人类用户,而人类用户必须吸收并处理这些数据。

人类在感知、认知和记忆方面都有很大的局限性,无法处理大量的数据。 只有另一台机器(即计算机)才有可能处理大量数据,但如果您要将大量数据从一台机器提取到另一台机器,那么您就需要建立一个 ETL 管道。但是如果您正在使用 GraphQL 进行 ETL,那么您就做错了,应该立即停止!

无论如何,在有人类用户的现实世界中,m 和 n 都会非常小。SQL 查询次数不会随着 m^n 的增大而增加。实际上,N+1 问题会使 SQL 查询次数增加,但不是任意增加,而是大约增加一个常数。在设计良好的应用程序中,它可能是一个远小于 100 的常数因子。在面对 N+1 问题时,在平衡开发人员时间、复杂性和硬件扩展方面的权衡时要考虑到这一点。

上面方法是构建 GraphQL API 服务器的唯一方法吗? 我们看到,构建 GraphQL 服务器的“简单方法”通常是在教程和“入门”指南中提供的方法,并且是通过微小的不切实际的内存数据模型来实现的,而无需数据库。

我们看到,上面详细描述了构建 GraphQL 服务器(使用 Java)的“真正方法”,无论使用什么库或框架,都涉及:

  • 编写模式文件条目(可能针对每个表)
  • 编写 Java 模型类(可能针对每个表)
  • 编写 Java 解析器方法(可能针对每个表中的每个字段)
  • 最终编写代码来解决任意复杂的输入参数组合
  • 编写代码以有效地预算 SQL 操作
我们还观察到,GraphQL 适合“使用累加器方法的递归分而治之”:GraphQL 查询沿类型和字段边界递归地划分和细分为“图”,图中的内部节点由解析器单独处理,但数据以图数据样式向上传递,累积到返回给用户的 JSON 信封中。GraphQL 库将传入的查询分解为类似于抽象语法树 ( AST ) 的东西,为所有内部节点触发 SQL 语句(暂时忽略数据加载器模式),然后重新组合数据。而我们是它的心甘情愿的帮凶!

我们还观察到,按照上述方法构建 GraphQL 服务器会产生其他结果:

  • 大量重复
  • 大量样板代码
  • 定制服务器
  • 与特定数据模型绑定
根据上述方法多次构建 GraphQL 服务器,您将会发现这些现象,并自然而然地产生强烈的冲动去构建更复杂的抽象,以减少重复、减少样板、通用化服务器并将它们与任何特定数据模型分离。这就是我所说的构建 GraphQL API 的“自然方式”,因为它是从教程和“入门”指南的简单“简单方式”以及解析器甚至数据加载器的繁琐“真实方式”自然演变而来的。 
  • 使用嵌套解析器网络构建 GraphQL 服务器具有一定的灵活性和动态性,但需要大量代码。
  • 通过限制、分页、过滤和排序来增加灵活性和动态性,则需要更多代码。

虽然它可能是动态的,但正如我们所看到的,它也会与数据库进行大量的交互。

要减少交互,就必须将许多零散的 SQL 语句组合成更少的 SQL 语句,而这些 SQL 语句可以单独完成更多的工作。 这就是数据加载器模式的作用:它将 SQL 语句的数量从 "几十条 "减少到 "少于 10 条但多于 1 条"。

  • 在实践中,这可能并不是一个巨大的胜利,它是以开发人员的时间和失去的动态性为代价的,但它是在生成更少、更复杂查询的道路上迈出的一步。
  • 这条道路的终点是 "1":SQL 语句的最佳数量(忽略缓存)是 1。

生成一条巨大的 SQL 语句,完成获取数据的所有工作,同时教它生成 JSON,这是你使用 GraphQL 服务器(对于关系型数据库)所能做到的最好结果。

这将是一项艰巨的工作,但你可以感到欣慰的是,只要你做对了一次,就不需要再做第二次,

  • 方法就是自省introspecting 数据库以生成schema。
做到这一点,你将构建的就不是一个 "GraphQL API 服务器",而是一个 "GraphQL to SQL 编译器"。
  • 承认构建一个 GraphQL to SQL 编译器是你一直在做的事情,接受这个事实,并精益求精。

你可能再也不需要构建另一个 GraphQL 服务器了。还有什么比这更好的呢?

如何选择 "构建 "而非 "购买" 当然,这里的 "购买 "实际上只是一般概念的代名词,即 "获取 "现有的解决方案,而不是构建一个。这并不一定需要购买软件,因为软件可以是免费和开源的。在这里,我想区分的是是否构建定制解决方案。如果可以获取现有的解决方案(无论是商业的还是开源的),有几种选择:

  • Apollo
  • Hasura
  • PostGraphile
  • Prisma

如果您选择使用 Java 构建 GraphQL 服务器,我希望这篇文章能帮助您摆脱无休止的教程、"入门 "指南和 "待办事项 "应用程序的束缚。在不断变化的环境中,这些都是庞大的主题,需要迭代的方法和适量的重复。