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

查看所有