使用 Spring Data Neo4j 进行 Spring GraphQL 开发

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

引言

这是来自 Gerrit MeierNeo4j,负责维护 Spring Data Neo4j 模块)的客座博客文章。

几周前,Spring 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 依赖项创建一个新项目。如果您比较懒惰,也可以从此链接下载空项目:此链接

要完全按照示例操作,您需要在系统上安装 Docker。如果您没有此选项或不想使用 Docker,您可以使用 Neo4j Desktop 或普通的 Neo4j Server 工件进行本地部署,或者作为托管选项使用 Neo4j Aura空的 Neo4j Sandbox。稍后将会有关于如何连接到手动启动的实例的说明。不需要使用企业版,社区版也能正常工作。

Spring GraphQL 的第一步

在这个例子中,配置的繁重工作将由 Spring Boot 自动配置完成。无需手动设置 bean。要了解更多有关幕后发生情况的信息,请查看 Spring GraphQL 文档。稍后,将引用文档的特定部分。

实体和 Spring Data Neo4j 设置

首先要做的就是对领域类进行建模。正如在导入中已经看到的,只有 `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://127.0.0.1:7687
spring.neo4j.authentication.username=neo4j
spring.neo4j.authentication.password=verysecret

如果尚未完成,可以启动数据库并运行上面的 Cypher 语句来设置数据。在本文的后面部分,将使用 Neo4j-Migrations 来确保数据库始终处于所需状态。

Spring GraphQL 设置

在研究 Spring Data 和 Spring GraphQL 的集成功能之前,将使用 `@Controller` 构造型注解类来设置应用程序。Spring 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://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* 给用户。

此示例应该让您了解使用 带注解的控制器 可以做什么。通过添加 Spring Data Neo4j 的查询生成和映射功能,创建了一个(简单的)GraphQL 应用程序。

但此时,这两个库在这个应用程序中似乎并行存在,而不是像集成一样。SDN 和 Spring GraphQL 如何真正结合起来?

Spring Data Neo4j GraphQL 集成

第一步,可以删除 `AccountController` 中的 `accounts` 方法。重新启动应用程序并使用上面的请求再次查询它,仍然会得到相同的结果。

这是因为 Spring for GraphQL 识别了 GraphQL schema 中的结果类型(数组)`Account`。它会扫描匹配该类型的合格 Spring Data 仓库。这些仓库必须扩展 `QueryByExampleExecutor` 或 `QuerydslPredicateExecutor`(本博文未涉及)才能用于给定类型。在这个例子中,`AccountRepository` 因为扩展了 `Neo4jRespository`(已定义执行器),所以已经隐式地标记为 `QueryByExampleExecutor`。`@GraphQlRepository` 注解使 Spring for GraphQL 意识到这个仓库可以并且应该在可能的情况下用于查询。

无需更改实际代码,就可以在 schema 中定义第二个查询字段。这次它应该按用户名过滤结果。乍一看用户名似乎是唯一的,但在 Fediverse 中,这仅对于给定的实例才成立。多个实例可能拥有完全相同的用户名。为了尊重这种行为,查询应该能够返回一个 `Accounts` 数组。

关于按示例查询(Spring Data 公共组件) 的文档提供了有关此机制内部工作原理的更多详细信息。

更新后的查询类型

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 使用游标连接规范

包含所有类型的完整 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));
	}

}

应用程序重新启动后,现在可以调用第一个分页查询了。

第一个元素的分页

{
  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 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` 依次发出有序请求的情况。解决方案是使用`@BatchMapping` 来代替 `@SchemaMapping`,应用于 `lastMessage` 字段。

联合数据的 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 迁移

Neo4j 迁移 是一个将迁移应用于 Neo4j 的项目。为了确保数据库中始终存在干净的数据状态,提供了初始 Cypher 语句。它与本文开头处的 Cypher 代码片段具有相同的内容。实际上,内容直接包含在此文件中。

通过提供 Spring Boot 启动器将 Neo4j 迁移放在类路径上,它将运行默认文件夹(`resources/neo4j/migrations`)中的所有迁移。

Neo4j 迁移依赖项定义

<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社区中所有即将举行的活动。

查看全部