領先一步
VMware 提供培訓和認證,助您加速前進。
瞭解更多這是一篇由 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)
執行語句後,圖結構形成如下形狀。
資料集的圖檢視
值得注意的資訊是,即使所有使用者都相互關注,Mastodon 伺服器也只以一個方向連線。伺服器 chaos.social 上的使用者無法在 mastodon.social 上搜索或探索時間線。
免責宣告:本例中的伺服器聯邦關係是虛構的單向關係。
要跟隨示例進行操作,您應使用以下最低版本:
最好是訪問 https://start.spring.io 並建立一個包含 Spring Data Neo4j 和 Spring GraphQL 依賴項的新專案。如果您有點懶惰,也可以從此連結下載空專案。
要完全跟隨示例進行操作,您需要在系統上安裝 Docker。如果您沒有此選項或不想使用 Docker,可以對於本地部署使用 Neo4j Desktop 或純粹的 Neo4j Server 構件,或者作為託管選項使用 Neo4j Aura 或一個空的 Neo4j Sandbox。稍後會有一個關於如何連線到手動啟動的例項的說明。不需要使用企業版,社群版完全可用。
在本示例中,繁重的配置工作將由 Spring Boot 自動配置完成。無需手動設定 Bean。要了解幕後發生了什麼,請查閱 Spring for GraphQL 文件。稍後,將引用文件中的特定章節。
首先要做的是建模領域類。正如在匯入中已經看到的,只有 Servers
和 Accounts
。
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 實體中的駝峰命名 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://:7687
spring.neo4j.authentication.username=neo4j
spring.neo4j.authentication.password=verysecret
如果尚未進行,可以啟動資料庫並執行上面的 Cypher 語句來設定資料。在本文的後面部分,將使用 Neo4j-Migrations 來確保資料庫始終處於所需狀態。
在深入瞭解 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 模式,它不僅定義了我們的實體,還定義了與控制器中的方法同名的查詢(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 中查詢
為了驗證 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 真正結合起來?
第一步,可以刪除 AccountController
中的 accounts
方法。重新啟動應用程式並再次使用上面提到的請求進行查詢,仍然會得到相同的結果。
之所以奏效,是因為 Spring for GraphQL 從 GraphQL 模式中識別出結果型別 (Account
陣列)。它會掃描與該型別匹配的符合條件的 Spring Data 倉庫。這些倉庫必須針對給定型別擴充套件 QueryByExampleExecutor
或 QuerydslPredicateExecutor
(非本文範圍)。在本例中,AccountRepository
已經隱式地被標記為 QueryByExampleExecutor
,因為它繼承自已經定義了執行器的 Neo4jRespository
。@GraphQlRepository
註解讓 Spring for GraphQL 知道這個倉庫在可能的情況下可以且應該用於查詢。
無需修改任何實際程式碼,可以在模式中定義第二個查詢欄位。這次它應該按使用者名稱過濾結果。乍一看使用者名稱似乎是唯一的,但在聯邦宇宙中,這隻適用於特定的例項。多個例項可能有完全相同的使用者名稱。為了符合這種行為,查詢應該能夠返回 Accounts
陣列。
關於查詢示例 (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 呼叫流程
儘管示例資料集不是很大,但擁有一個適當的功能來分塊請求結果資料通常很有用。Spring for GraphQL 使用 遊標連線規範 (Cursor Connections specification)。
包含所有型別的完整模式規範如下所示。
帶有遊標連線的模式
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]
}
儘管我個人喜歡一個完整的有效模式,但可以跳過定義中所有與 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"
}
}
}
}
還可以透過使用定義的 last
和 before
引數向後滾動資料。另外,將這種滾動與已知的查詢示例特性結合使用也是完全有效的,可以在 GraphQL 模式中定義一個查詢,它也接受 Account
的欄位作為過濾條件。
帶分頁的過濾
accountScroll(username:String, first: Int, after: String, last: Int, before:String): AccountConnection
使用 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();
}
}
在模式中的 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 的遷移專案。為了確保資料庫始終處於乾淨的狀態,提供了初始 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>
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 映象。
從測試類啟動帶容器的應用程式
@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。