使用Spring處理GraphQL與Spring Data Neo4j

工程 | Mark Paluch | 2023年6月27日 | ...

引言

這是一篇由Gerrit Meier撰寫的客座部落格,他來自Neo4j,負責維護Spring Data Neo4j模組。

幾周前,Spring (for) GraphQL 釋出了 1.2.0 版本,帶來了一系列新功能。其中還包括與 Spring Data 模組的更佳整合。受這些變化的啟發,我們在 Spring Data Neo4j 中增加了更多支援,以便在與 Spring GraphQL 結合使用時提供最佳體驗。本文將指導您建立一個在 Neo4j 中儲存資料並支援 GraphQL 的 Spring 應用程式。如果您只對領域部分感興趣,可以愉快地跳過下一節;)

領域

在此示例中,我選擇深入 Fediverse。更具體地說,將 伺服器使用者 放在重點。為什麼選擇這個領域,現在留給讀者在接下來的段落中發現。

資料本身與可以從 Mastodon API 中獲取的屬性一致。為了簡化資料集,資料是透過手動建立而不是獲取 所有 資料。這使得資料集更容易檢查。Cypher 匯入語句如下所示

Cypher 匯入

CREATE (s1:Server {
 uri:'mastodon.social', title:'Mastodon', registrations:true,
 short_description:'The original server operated by the Mastodon gGmbH non-profit'})
CREATE (meistermeier:Account {id:'106403780371229004', username:'meistermeier', display_name:'Gerrit Meier'})
CREATE (rotnroll666:Account {id:'109258442039743198', username:'rotnroll666', display_name:'Michael Simons'})
CREATE
(meistermeier)-[:REGISTERED_ON]->(s1),
(rotnroll666)-[:REGISTERED_ON]->(s1)

CREATE (s2:Server {
 uri:'chaos.social', title:'chaos.social', registrations:false,
 short_description:'chaos.social – a Fediverse instance for & by the Chaos community'})
CREATE (odrotbohm:Account {id:'108194553063501090', username:'odrotbohm', display_name:'Oliver Drotbohm'})

CREATE
(odrotbohm)-[:REGISTERED_ON]->(s2)

CREATE
(odrotbohm)-[:FOLLOWS]->(rotnroll666),
(odrotbohm)-[:FOLLOWS]->(meistermeier),
(meistermeier)-[:FOLLOWS]->(rotnroll666),
(meistermeier)-[:FOLLOWS]->(odrotbohm),
(rotnroll666)-[:FOLLOWS]->(meistermeier),
(rotnroll666)-[:FOLLOWS]->(odrotbohm)

CREATE
(s1)-[:CONNECTED_TO]->(s2)

執行語句後,圖形成此形狀。

資料集的圖檢視

graph data set

值得注意的是,即使所有使用者都關注彼此,Mastodon 伺服器也只以一個方向連線。chaos.social 伺服器上的使用者無法搜尋或瀏覽 mastodon.social 上的時間線。

免責宣告:在此示例中,伺服器的聯合使用了非雙向關係。

元件

要跟進示例,您應該使用以下最低版本

  • Spring Boot 3.1.1 (包含以下內容)
    • Spring Data Neo4j 7.1.1
    • Spring GraphQL 1.2.1
  • Neo4j 版本 5

最好訪問 https://start.spring.io 並使用 Spring Data Neo4j 和 Spring GraphQL 依賴項建立一個新專案。如果您有點懶,也可以從此 連結 下載空的專案。

要 100% 跟隨示例,您需要在系統中安裝 Docker。如果您沒有此選項或不想使用 Docker,可以使用 Neo4j Desktop 或純 Neo4j Server 偽影進行本地部署,或作為託管選項 Neo4j Aura空的 Neo4j Sandbox。稍後會有關於如何連線到手動啟動的例項的說明。企業版不是必需的,社群版一切都能正常工作。

Spring for GraphQL 的第一步

在此示例中,大部分配置工作將由 Spring Boot 自動配置完成。無需手動設定 bean。有關幕後發生情況的更多資訊,請參閱 Spring for GraphQL 文件。稍後將引用文件的特定部分。

實體和 Spring Data Neo4j 設定

首先要做的就是對領域類進行建模。如匯入中所示,只有 ServersAccounts

賬戶領域類

@Node
public class Account {

	@Id String id;
	String username;
	@Property("display_name") String displayName;
	@Relationship("REGISTERED_ON") Server server;
	@Relationship("FOLLOWS") List<Account> following;

	// constructor, etc.
}

可以合理地假設,ID 是(伺服器)唯一的。

  • 在此以及稍後 Server 中的幾行中,使用 @Property 將資料庫欄位 display_name 對映到 Java 實體中的駝峰式命名 displayName

伺服器領域類

@Node
public class Server {

	@Id String uri;
	String title;
	@Property("registrations") Boolean registrationsAllowed;
	@Property("short_description") String shortDescription;
	@Relationship("CONNECTED_TO") List<Server> connectedServers;

	// constructor, etc.
}

有了這些實體類,就可以建立 AccountRepository

賬戶儲存庫

@GraphQlRepository
public interface AccountRepository extends Neo4jRepository<Account, String> { }

有關為何使用此註解的詳細資訊稍後將提供。此處包含介面的完整性。

要連線到 Neo4j 例項,需要在 application.properties 檔案中新增連線引數。

spring.neo4j.uri=neo4j://:7687
spring.neo4j.authentication.username=neo4j
spring.neo4j.authentication.password=verysecret

如果尚未完成,可以啟動資料庫並執行上面的 Cypher 語句來設定資料。在本文的後續部分,將使用 Neo4j-Migrations 來確保資料庫始終處於所需狀態。

Spring for GraphQL 設定

在研究 Spring Data 和 Spring for GraphQL 的整合功能之前,應用程式將使用帶有 @Controller 樣板批註的類進行設定。該控制器將由 Spring for GraphQL 註冊為 accounts 查詢的 DataFetcher

@Controller
class AccountController {

    private final AccountRepository repository;

    AccountController(AccountRepository repository) {
            this.repository = repository;
    }

    @QueryMapping
    List<Account> accounts() {
            return repository.findAll();
    }
}

定義一個 GraphQL schema,該 schema 不僅定義了我們的實體,還定義了與控制器中的方法名(accounts)同名的查詢。

type Query {
    accounts: [Account]!
}
type Account {
    id: ID!
    username: String!
    displayName: String!
    server: Server!
    following: [Account]
    lastMessage: String!
}

type Server {
    uri: ID!
    title: String!
    shortDescription: String!
    connectedServers: [Server]
}

此外,為了方便地瀏覽 GraphQL 資料,應在 application.properties 中啟用 GraphiQL。這是開發時的一個有用工具。通常應停用此設定以用於生產部署。

spring.graphql.graphiql.enabled=true

首次執行

如果一切都已按照上述描述設定好,就可以使用 ./mvnw spring-boot:run 啟動應用程式。訪問 https://:8080/graphiql?path=/graphql 將會顯示 GraphiQL 瀏覽器。

在 GraphiQL 中查詢

graphiql

為了驗證 accounts 方法是否正常工作,嚮應用程式傳送一個 GraphQL 請求。

第一個 GraphQL 請求

{
  accounts {
    username
  }
}

並且預期的答案從伺服器返回。

GraphQL 響應

{
  "data": {
    "accounts": [
      {
        "username": "meistermeier"
      },
      {
        "username": "rotnroll666"
      },
      {
        "username": "odrotbohm"
      }
    ]
  }
}

當然,控制器中的方法可以透過新增引數來調整,使用 @Argument 來處理引數,或者獲取請求的欄位(此處為 accounts.username)以減少透過網路傳輸的資料量。在前面的示例中,儲存庫將獲取給定領域實體的所有屬性,包括所有關係。這些資料將在很大程度上被丟棄,以便只向用戶返回 username

此示例應能讓您對 Annotated Controllers 的功能有一個初步瞭解。透過新增 Spring Data Neo4j 的查詢生成和對映功能,就建立了一個(簡單的)GraphQL 應用程式。

但此時,這兩個庫似乎在這個應用程式中並行存在,而不是真正整合。SDN 和 Spring for GraphQL 如何才能真正結合起來?

Spring Data Neo4j GraphQL 整合

作為第一步,可以刪除 AccountController 中的 accounts 方法。重新啟動應用程式並使用上面的請求再次查詢它,仍然會得到相同的結果。

這樣做是因為 Spring for GraphQL 識別出 GraphQL schema 中的結果型別(Account 的陣列)。它會掃描符合條件的 Spring Data 儲存庫,這些儲存庫必須擴充套件 QueryByExampleExecutorQuerydslPredicateExecutor(在此部落格文章中未包含)。在此示例中,AccountRepository 已被隱式標記為 QueryByExampleExecutor,因為它擴充套件了 Neo4jRespository,而後者已經定義了執行器。@GraphQlRepository 註解使 Spring for GraphQL 知道該儲存庫可以並且應該用於查詢(如果可能)。

在不更改實際程式碼的情況下,可以在 schema 中定義第二個查詢欄位。這次應該按使用者名稱過濾結果。乍一看,使用者名稱看起來是唯一的,但在 Fediverse 中,這隻對給定的例項有效。多個例項可能具有相同的使用者名稱。為了尊重這種行為,查詢應該能夠返回一個 Accounts 陣列。

關於 query by example (Spring Data commons) 的文件提供了關於此機制內部工作原理的更多詳細資訊。

更新的查詢型別

type Query {
    account(username: String!): [Account]!

重新啟動應用程式後,現在可以互動式地將使用者名稱新增為查詢的引數。

查詢具有相同使用者名稱的賬戶陣列

{
  account(username: "meistermeier") {
    username
    following {
      username
      server {
        uri
      }
    }
  }
}

顯然,只有 Account 具有此使用者名稱。

按使用者名稱查詢的響應

{
  "data": {
    "account": [
      {
        "username": "meistermeier",
        "following": [
          {
            "username": "rotnroll666",
            "server": {
              "uri": "mastodon.social"
            }
          },
          {
            "username": "odrotbohm",
            "server": {
              "uri": "chaos.social"
            }
          }
        ]
      }
    ]
  }
}

在後臺,Spring for GraphQL 將欄位作為引數新增到傳遞給儲存庫的示例物件中。Spring Data Neo4j 然後檢查示例,為 Cypher 查詢建立匹配條件,執行它,並將結果傳送回 Spring GraphQL 進行進一步處理,將結果塑造成正確的響應格式。

(示意圖)API 呼叫流程

example flow

分頁

儘管示例資料集並不龐大,但通常最好具有適當的功能來分塊請求結果資料。Spring for GraphQL 使用 Cursor Connections 規範

包含所有型別的完整 schema 規範如下所示。

帶有遊標連線的 Schema

type Query {
    accountScroll(username:String, first: Int, after: String, last: Int, before:String): AccountConnection
}
type AccountConnection {
    edges: [AccountEdge]!
    pageInfo: PageInfo!
}

type AccountEdge {
    node: Account!
    cursor: String!
}

type PageInfo {
    hasPreviousPage: Boolean!
    hasNextPage: Boolean!
    startCursor: String
    endCursor: String
}
type Account {
    id: ID!
    username: String!
    displayName: String!
    server: Server!
    following: [Account]
    lastMessage: String!
}

type Server {
    uri: ID!
    title: String!
    shortDescription: String!
    connectedServers: [Server]
}

儘管我個人喜歡擁有一個完整的有效 schema,但也可以在定義中跳過所有遊標連線特定的部分。僅帶有 AccountConnection 定義的查詢就足以讓 Spring for GraphQL 推導並填充缺失的部分。引數的讀取方式如下:

  • first:要獲取的資料量(如果沒有預設值)
  • after:資料應在之後獲取的滾動位置
  • last:在 before 位置之前要獲取的資料量
  • before:資料應獲取到的(不包含)滾動位置

還有一個問題:結果集按什麼順序返回?在這種情況下,穩定的排序順序是必須的,否則無法保證資料庫以可預測的順序返回資料。儲存庫還需要擴充套件 QueryByExampleDataFetcher.QueryByExampleBuilderCustomizer 並實現 customize 方法。在那裡也可以為查詢新增預設限制,在本例中為 1,以演示分頁。

已新增排序順序(和限制)

@GraphQlRepository
interface AccountRepository extends Neo4jRepository<Account, String>,
       QueryByExampleDataFetcher.QueryByExampleBuilderCustomizer<Account>
{

	@Override
	default QueryByExampleDataFetcher.Builder<Account, ?> customize(QueryByExampleDataFetcher.Builder<Account, ?> builder) {
		return builder.sortBy(Sort.by("username"))
				.defaultScrollSubrange(new ScrollSubrange(ScrollPosition.offset(), 1, true));
	}

}

應用程式重新啟動後,現在可以呼叫第一個分頁查詢。

第一個元素的 Pagination

{
  accountScroll {
    edges {
      node {
        username
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

為了獲得用於進一步互動的元資料,還請求了 pageInfo 的一部分。

第一個元素的結果

{
  "data": {
    "accountScroll": {
      "edges": [
        {
          "node": {
            "username": "meistermeier"
          }
        }
      ],
      "pageInfo": {
        "hasNextPage": true,
        "endCursor": "T18x"
      }
    }
  }
}

現在可以使用 endCursor 進行下一次互動。使用此值作為 after 的值,並將限制設定為 2 來查詢應用程式...

最後一個元素的 Pagination

{
  accountScroll(after:"T18x", first: 2) {
    edges {
      node {
        username
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

...結果是最後一個(或最後幾個)元素。此外,沒有下一頁的標記(hasNextPage=false)表明分頁已到達資料集的末尾。

最後一個元素的結果

{
  "data": {
    "accountScroll": {
      "edges": [
        {
          "node": {
            "username": "odrotbohm"
          }
        },
        {
          "node": {
            "username": "rotnroll666"
          }
        }
      ],
      "pageInfo": {
        "hasNextPage": false,
        "endCursor": "T18z"
      }
    }
  }
}

也可以透過使用定義的 lastbefore 引數向後滾動資料。此外,完全可以將此滾動與已知的 query by example 功能相結合,並在 GraphQL schema 中定義一個也接受 Account 欄位作為過濾條件的查詢。

帶分頁的過濾器

accountScroll(username:String, first: Int, after: String, last: Int, before:String): AccountConnection

讓我們聯合

使用 GraphQL 的一個主要優點是引入聯合資料的選項。簡而言之,這意味著應用程式資料庫中儲存的資料可以被豐富,如本例所示,可以從遠端系統/微服務/...。最終,資料將透過 GraphQL 介面作為一個實體呈現。消費者無需關心多個系統組裝了該結果。

可以透過利用已定義的控制器來實現這種資料聯合。

用於聯合資料的 SchemaMapping

@Controller
class AccountController {

    @SchemaMapping
    String lastMessage(Account account) {
        var id = account.getId();
        String serverUri = account.getServer().getUri();

        WebClient webClient = WebClient.builder()
                        .baseUrl("https://" + serverUri)
                        .build();

        return webClient.get()
                        .uri("/api/v1/accounts/{id}/statuses?limit=1", id)
                        .exchangeToMono(clientResponse ->
                            clientResponse.statusCode().equals(HttpStatus.OK)
                            ? clientResponse
                                    .bodyToMono(String.class)
                                    .map(AccountController::extractData)
                            : Mono.just("could not retrieve last status")
                        )
                        .block();
    }

}

在 schema 中向 Account 新增 lastMessage 欄位並重新啟動應用程式,現在就有選項來查詢帶有此附加資訊的賬戶了。

帶聯合資料的查詢

{
  accounts {
    username
    lastMessage
  }
}

帶聯合資料的響應

{
  "data": {
    "accounts": [
      {
        "username": "meistermeier",
        "lastMessage": "@taseroth erst einmal schauen, ob auf die Aussage auch Taten folgen ;)"
      },
      {
        "username": "odrotbohm",
        "lastMessage": "Some #jMoleculesp/#SpringCLI integration cooking to easily add the former[...]"
      },
      {
        "username": "rotnroll666",
        "lastMessage": "Werd aber das Rad im Rückwärts-Turbo schon irgendwie vermissen."
      }
    ]
  }
}

再次檢視控制器,可以清楚地看到資料檢索現在是一個瓶頸。對於每個 Account,都會發出一個接一個的請求。但是,Spring for GraphQL 有助於改善每個 Account 的順序請求情況。解決方案是在 lastMessage 欄位上使用 @BatchMapping,而不是 @SchemaMapping

用於聯合資料的 BatchMapping

@Controller
public class AccountController {
	@BatchMapping
	public Flux<String> lastMessage(List<Account> accounts) {
		return Flux.concat(
			accounts.stream().map(account -> {
				var id = account.getId();
				String serverUri = account.getServer().getUri();

				WebClient webClient = WebClient.builder()
						.baseUrl("https://" + serverUri)
						.build();

				return webClient.get()
						.uri("/api/v1/accounts/{id}/statuses?limit=1", id)
						.exchangeToMono(clientResponse ->
								clientResponse.statusCode().equals(HttpStatus.OK)
								? clientResponse
									.bodyToMono(String.class)
									.map(AccountController::extractData)
								: Mono.just("could not retrieve last status")
						);
		}).toList());
	}

}

為了進一步改善這種情況,建議也為結果引入適當的快取。聯合資料可能不需要在每次請求時都獲取,而只在一定時間後重新整理。

測試和測試資料

Neo4j-Migrations

Neo4j-Migrations 是一個將遷移應用於 Neo4j 的專案。為了確保資料庫中始終存在乾淨的資料狀態,提供了一個初始 Cypher 語句。其內容與本文開頭的 Cypher 程式碼片段相同。事實上,內容直接包含在此檔案中。

透過提供 Spring Boot 啟動器將 Neo4j-Migrations 放入類路徑,它將執行預設資料夾(resources/neo4j/migrations)中的所有遷移。

Neo4j-Migrations 依賴定義

<dependency>
    <groupId>eu.michael-simons.neo4j</groupId>
    <artifactId>neo4j-migrations-spring-boot-starter</artifactId>
    <version>${neo4j-migrations.version}</version>
    <scope>test</scope>
</dependency>

Testcontainers

Spring Boot 3.1 帶來了 Testcontainers 的新功能。其中一項功能是自動設定屬性,無需定義 @DynamicPropertySource。在測試執行期間,容器啟動後,(Spring Boot 已知的)屬性將被填充。

首先,需要在 pom.xml 檔案中新增 Testcontainers Neo4j 的依賴定義。

Testcontainers 依賴定義

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>neo4j</artifactId>
    <scope>test</scope>
</dependency>

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

為了使用 Testcontainers Neo4j,將建立一個容器定義介面

容器配置

interface Neo4jContainerConfiguration {

    @Container
    @ServiceConnection
    Neo4jContainer<?> neo4jContainer = new Neo4jContainer<>(DockerImageName.parse("neo4j:5"))
            .withRandomPassword()
            .withReuse(true);

}

然後可以使用 @ImportTestContainers 註解在(整合)測試類中使用它。

@ImportTestContainers 註解的測試

@SpringBootTest
@ImportTestcontainers(Neo4jContainerConfiguration.class)
class Neo4jGraphqlApplicationTests {

    final GraphQlTester graphQlTester;

    @Autowired
    public Neo4jGraphqlApplicationTests(ExecutionGraphQlService graphQlService) {
        this.graphQlTester = ExecutionGraphQlServiceTester.builder(graphQlService).build();
    }

    @Test
    void resultMatchesExpectation() {
        String query = "{" +
                "  account(username:\"meistermeier\") {" +
                "    displayName" +
                "  }" +
                "}";

        this.graphQlTester.document(query)
                .execute()
                .path("account")
                .matchesJson("[{\"displayName\":\"Gerrit Meier\"}]");

    }

}

為了完整性,此類還包括 GraphQlTester 和一個測試應用程式 GraphQL API 的示例。

開發時的 Testcontainers

現在也可以直接從測試資料夾執行整個應用程式並使用 Testcontainers 映象。

從測試類使用容器啟動應用程式

@TestConfiguration(proxyBeanMethods = false)
class TestNeo4jGraphqlApplication {

	public static void main(String[] args) {
		SpringApplication.from(Neo4jGraphqlApplication::main)
				.with(TestNeo4jGraphqlApplication.class)
				.run(args);
	}

	@Bean
	@ServiceConnection
	Neo4jContainer<?> neo4jContainer() {
		return new Neo4jContainer<>("neo4j:5").withRandomPassword();
	}
}

@ServiceConnection 註解還負責使從測試類啟動的應用程式知道容器正在執行的座標(連線字串、使用者名稱、密碼等)。

要在 IDE 外部啟動應用程式,現在也可以呼叫 ./mvnw spring-boot:test-run。如果測試資料夾中只有一個帶有 main 方法的類,它將被啟動。

遺漏的主題 / 嘗試一下

QueryByExampleExecutor 並行,Spring Data Neo4j 模組支援 QuerydslPredicateExecutor。要使用它,儲存庫需要擴充套件 CrudRepository 而不是 Neo4jRepository,並將其宣告為給定型別的 QuerydslPredicateExecutor。新增對滾動/分頁的支援還需要新增 QuerydslDataFetcher.QuerydslBuilderCustomizer 並實現其 customize 方法。

本文介紹的整個基礎設施也適用於響應式堆疊。基本上,將所有內容都加上 Reactive... 字首(如 ReactiveQuerybyExampleExecutor)將使其成為一個響應式應用程式。

最後但並非最不重要的一點是,這裡使用的滾動機制基於 OffsetScrollPosition。還有一個 KeysetScrollPosition 可用。它利用排序屬性與定義 ID 結合使用。

@Override
default QueryByExampleDataFetcher.Builder<Account, ?> customize(QueryByExampleDataFetcher.Builder<Account, ?> builder) {
	return builder.sortBy(Sort.by("username"))
			.defaultScrollSubrange(new ScrollSubrange(ScrollPosition.keyset(), 1, true));
}

總結

很高興看到 Spring Data 模組中的便捷方法不僅為使用者的用例提供了更廣泛的可訪問性,而且還被其他 Spring 專案用來減少需要編寫的程式碼量。這使得現有程式碼庫的維護量減少,並有助於關注業務問題而不是基礎設施。

這篇帖子有點長,因為我明確希望至少觸及查詢被呼叫時發生的事情的表面,而不僅僅是談論神奇的結果。

請繼續探索可能實現的功能以及應用程式對不同型別查詢的行為。在一篇博文中涵蓋所有可用主題和功能幾乎是不可能的。

祝您 GraphQL 編碼和探索愉快。您可以在 GitHub 上找到示例專案,地址為 https://github.com/meistermeier/spring-graphql-neo4j

獲取 Spring 新聞通訊

透過 Spring 新聞通訊保持聯絡

訂閱

領先一步

VMware 提供培訓和認證,助您加速進步。

瞭解更多

獲得支援

Tanzu Spring 提供 OpenJDK™、Spring 和 Apache Tomcat® 的支援和二進位制檔案,只需一份簡單的訂閱。

瞭解更多

即將舉行的活動

檢視 Spring 社群所有即將舉行的活動。

檢視所有