Java中實現GraphQL完整指南

banq發表於2024-06-07

對於尋求建立強大而高效的 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: <font>"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:<font>//raw.githubusercontent.com/YugaByte/yugabyte-db/master/sample/chinook_ddl.sql<i>

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<i>

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

啟動:
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; <font>// It's got to go into a package somewhere.<i>



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

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

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.<i>

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

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

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

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

                    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<i>

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

                jdbcClient

                .sql(
"select * from \&#34Artist\&#34 where \&#34ArtistId\&#34 = ? limit 1"// Simple PreparedStatement wrapper<i>

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

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

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

                .orElse(null);}

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

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

            for (Artist a : jdbcClient.sql(
"select * from \&#34Artist\&#34 where \&#34ArtistId\&#34 = ?").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.<i>

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

            StatementSpec

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

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

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

            return        
// Run the SQL, map the results, return the List.<i>

                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 = <font>"Albums"public Map<Artist, List<Album>> // Switch to @BatchMapping<i>

    albumsForArtist (List<Artist> artists) { 
// 在父母名單中選擇父母,而不是單個父母。<i>

    return

        jdbcClient

        .sql(
"select * from \&#34Album\&#34 where \&#34ArtistId\&#34 in (:ids)"// 使用 SQL "in "謂詞和 "nbsp; "識別符號列表。<i>

        .param(
"ids", artists.stream().map(x -> x.ArtistId()).toList()) //從父物件列表中獲取識別符號列表<i>

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

        .list()

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

    
// ^ Java 成語 用於根據父<i>
Album
對子
Album
進行分組 。

}


和之前一樣,讓我們來解讀一下。
首先,我們將 @QueryMapping 或 @SchemaMapping 註解切換為 @BatchMapping,向 Spring for GraphQL 發出訊號,表明我們要使用資料載入器模式。

其次,我們將單個Artist藝術家引數轉換為 List<Artist> 引數。

第三,我們必須以某種方式安排必要的 SQL(本例中使用 in 謂詞)和相應的引數(從 List<Album> 引數中提取的 List<Integer>)。

第四,我們必須以某種方式將子條目(本例中為Album)排序到正確的父條目(本例中為Album )。有很多方法可以做到這一點,這只是其中一種方法。
重要的一點是,無論如何做,都必須用 Java 來完成。

最後一點:注意沒有 limit 引數。它去哪兒了?
原來 Spring for GraphQL 不支援 @BatchMapping 的 InputValue<T>。哦,好吧!  在這種情況下,這並不是什麼大損失,因為可以說這些限制引數意義不大。

有多少人真正需要藝術家專輯的隨機子集?
不過,如果我們有過濾和排序功能,問題就嚴重多了。過濾和排序引數更合理,如果有了它們,我們就必須想辦法把它們隱藏到資料載入器模式中。大概可以做到,但不會像在方法上加上 @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 伺服器,我希望這篇文章能幫助您擺脫無休止的教程、"入門 "指南和 "待辦事項 "應用程式的束縛。在不斷變化的環境中,這些都是龐大的主題,需要迭代的方法和適量的重複。

 

相關文章