搭配 Spring Data Neo4j 使用 Spring for GraphQL

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

簡介

這是一篇由來自 Neo4jGerrit Meier 所撰寫的客座部落格文章,他維護 Spring Data Neo4j 模組。

幾個星期前,Spring (for) GraphQL 的 1.2.0 版本發布,其中包含許多新功能。這也包括與 Spring Data 模組更完善的整合。在這些變更的推動下,Spring Data Neo4j 中新增了更多支援,以便在使用 Spring GraphQL 時提供最佳體驗。本文將指導您建立一個 Spring 應用程式,該應用程式的資料儲存在 Neo4j 中,並支援 GraphQL。如果您只對該領域的部分感興趣,您可以跳過下一節 ;)

領域

對於這個範例,我選擇深入研究 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

Account 領域類別

@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 實體中的 camel-case displayName

Server 領域類別

@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

Account 儲存庫

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

稍後將說明為什麼使用此註解。此處用於介面的完整性。

若要連線到 Neo4j 執行個體,需要將連線參數新增至application.properties 檔案。

spring.neo4j.uri=neo4j://127.0.0.1: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 註冊為查詢 accountsDataFetcher

@Controller
class AccountController {

    private final AccountRepository repository;

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

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

定義 GraphQL 結構描述,該結構描述不僅定義我們的實體,還定義與控制器中的方法(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://127.0.0.1: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 中定義第二個 *Query 欄位*。這次應該根據使用者名稱過濾結果。乍看之下,使用者名稱看起來是唯一的,但在 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,也可以跳過定義中的所有 *Cursor Connections* 特定部分。只需具有 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));
	}

}

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

第一個元素的分頁

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

為了取得進一步互動的中繼資料,也要求了 pageInfo 的某些部分。

第一個元素的結果

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

現在 endCursor 可以用於下一個互動。 使用此值作為 *after* 的值並限制為 2 來查詢應用程式...

最後一個元素的分頁

{
  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();
    }

}

lastMessage 欄位新增到 schema 中的 Account 並重新啟動應用程式,現在可以查詢具有此額外資訊的帳戶。

具有聯合資料的查詢

{
  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 starter 將 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。為了使用它,repository 需要擴展 CrudRepository 而不是 Neo4jRepository,並且還需要將其宣告為給定類型的 QuerydslPredicateExecutor。 添加對滾動/分頁的支援,還需要添加 QuerydslDataFetcher.QuerydslBuilderCustomizer 並實現其 customize 方法。

本部落格文章中介紹的整個基礎架構也適用於 reactive stack。基本上,在所有內容前加上 Reactive...(例如 ReactiveQuerybyExampleExecutor)將使其變成一個 reactive 應用程式。

最後但同樣重要的是,這裡使用的滾動機制是基於 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 社群中所有即將舉行的活動。

查看全部