领先一步
VMware提供培训和认证,以加速您的进步。
了解更多这是来自 Gerrit Meier(Neo4j,负责维护 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)
运行该语句后,图将形成此形状。
数据集的图形视图
值得注意的是,即使所有用户都相互关注,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 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 领域类
@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 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 中查询
为了验证 `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 如何真正结合起来?
第一步,可以删除 `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 调用流程
虽然示例数据集并不庞大,但通常情况下,拥有适当的功能来允许分块请求结果数据非常有用。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 的一大优势是可以引入联合数据。简而言之,这意味着存储在(例如)应用程序数据库中的数据可以像在本例中一样,用来自远程系统/微服务的额外数据来丰富。
可以使用已经定义的控制器来实现此数据联合。
联合数据的 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 的项目。为了确保数据库中始终存在干净的数据状态,提供了初始 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>
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。