领先一步
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://localhost: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
启动。浏览到 http://localhost: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。