Spring Data Neo4j

对于使用 Spring Framework 或 Spring Boot 且希望利用响应式开发原则的 Java 开发人员,本指南介绍了如何通过 Spring Data Neo4j 项目实现 Spring 集成。该库提供了对 Neo4j 的便捷访问,包括对象映射、Spring Data 存储库、转换、事务处理、响应式支持等。

响应式开发

Neo4j(4.0+ 版本)结合了 响应式宣言 (Reactive Manifesto) 的原则,用于通过驱动程序在数据库和客户端之间传递数据。开发人员可以利用响应式方法来处理查询并返回结果。这意味着驱动程序与数据库之间的通信可以根据客户端的数据需求进行动态管理和调整。

响应式编程原则允许消费方(应用程序和其他系统)指定在特定时间窗口内接收的数据量。Neo4j 数据库驱动程序还将维护从服务器请求数据的速率限制,从而在整个 Neo4j 堆栈中提供流控。

无论事务或数据量如何(即使在高负载时期),系统都可以根据可用资源来限制一次发送和接收的数据量。这可以防止过载、崩溃或故障,并避免传输丢失或在停机后需要追赶处理大量任务的情况。

Project Reactor 是许多响应式开发实现(包括 Spring 的实现)的核心基础。Neo4j 使用 Project Reactor 组件的 Spring 实现,在相关应用程序中为图数据库提供响应式支持。

Spring Data Neo4j

Spring Data Neo4j 6 是 Spring Data Neo4j 项目的一个新的主要版本。其功能优势之一是对响应式事务的能力和支持,此外还有其他改进和增加的功能,例如完全不可变的实体和基于 Java record 的映射支持。

虽然 SDN 同时提供命令式和响应式应用程序开发,但本指南将侧重于响应式实现。SDN 中的命令式应用程序代码和文档可在 Github 项目 上找到。

我们可以看到 SDN 库中一些最显著的功能和变化,如下所示。

特征

  • 支持命令式和响应式应用程序开发

  • 使用内置 OGM(对象图映射)库进行轻量级映射

  • 不可变实体(适用于 Java 和 Kotlin 语言)

  • 用于驱动程序之上模板架构的全新 Neo4j 客户端和响应式客户端功能

SDN 完全支持众所周知的命令式编程模型(非常类似于 Spring Data JDBC 或 JPA)。它还完全支持基于 Reactive Streams 的较新的响应式编程,包括 响应式事务。这两种功能都包含在同一个二进制文件中。

响应式编程模型需要 4.0+ 的 Neo4j 实例(旧版本不支持响应式驱动程序)以及应用程序端的响应式 Spring。

SDN 6 与以前版本的 Spring Data Neo4j 的一个关键区别在于,OGM(对象图映射)层不再是一个单独的库。相反,Spring Data 基础架构现在可以处理 OGM 的功能。

入门

在接下来的几个部分中,我们将介绍创建响应式应用程序的所有步骤。

准备数据库

对于此示例,我们将使用 Neo4j 标准的电影图数据集,因为它随每个 Neo4j 实例免费提供,且体积较小。

如果尚未完成,请 下载 Neo4j Desktop创建/启动数据库

您可以在 Web 浏览器中通过 URL https://:7474 与数据库交互并加载数据。请注意提示符中准备运行的命令 (:play movies)。执行该命令,命令行下方会出现一个交互式幻灯片。在该指南的第二张幻灯片上,执行冗长的 Cypher 语句,以使用我们的电影测试数据填充您的数据库。

创建新的 Spring Boot 项目

设置 Spring Boot 项目的最简单方法是使用 start.spring.io 上的 Spring Initializr。它也集成在主流 IDE 中,以防您不想使用网站。

然后,您可以更改项目的默认组、构件、名称和描述。接下来,我们可以选择项目依赖项。我们可以搜索并添加 Neo4jSpring Reactive Web starter,以获取创建基于 Spring 的响应式 Web 应用程序所需的内容。

步骤完成后,我们可以点击底部的 Generate 按钮来创建我们的项目骨架并下载它。Spring Initializr 将负责为您创建项目结构,并为选定的构建工具准备好基础文件和设置。

其他依赖项

如果您查看 Github 上的项目,可能会注意到 pom.xml 中还有其他一些依赖项。其中几个用于向项目添加测试,一个是用于开发人员工具的依赖项,还有几个用于测试容器。

有关测试功能的更多信息,请参阅 文档

测试和开发工具依赖项
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>io.projectreactor</groupId>
    <artifactId>reactor-test</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.17.6</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>neo4j</artifactId>
    <version>1.17.6</version>
    <scope>test</scope>
</dependency>

添加配置

现在,我们需要添加一些配置以连接到数据库。我们可以找到 application.properties 文件并根据需要进行配置。

spring.neo4j.uri=neo4j+s://abcd.databases.neo4j.io
spring.neo4j.authentication.username=neo4j
spring.neo4j.authentication.password=secret

您需要将密码调整为您在创建 Neo4j 实例时设置的密码。

前三行是我们的 Neo4j 数据库 URI 和凭据。您在此处输入的用户名和密码应与您的个人数据库匹配。这是连接到 Neo4j 实例所需的最低配置。

无需为驱动程序添加任何其他配置,这得益于 SDN 6 开箱即提供的 Spring Boot 驱动程序自动配置。

其他配置

日志记录

还有一个我们可以定义的附加属性。它不是必需属性,但确实允许我们查看 Cypher 语句,并更好地洞察应用程序背后运行的内容。

logging.level.org.springframework.data.neo4j=DEBUG

数据库选择

自 4.0 版本起,Neo4j 支持 多租户。我们可以通过提供属性静态选择数据库。

spring.data.neo4j.database = my-database

对于更高级的用例,可以执行动态选择,具体文档记录在 此处

创建领域

定义了项目依赖项并设置了配置后,我们就可以开始为数据领域定义实体了!领域层应完成两件事:

  1. 将图映射到对象。

  2. 提供对这些对象的访问。

我们的数据包含电影和人物实体,它们显示了人们如何参与各种电影,例如谁参与了演出、导演、编剧、制作等。我们需要为每个实体(MoviePerson)定义一个领域类。

SDN 支持 Neo4j Java 驱动程序支持的所有数据类型。要了解如何将 Neo4j 类型映射到本机语言类型,请参阅文档中的 本节

电影实体

@Node("Movie")
public class MovieEntity {
	@Id
	private final String title;
	@Property("tagline")
	private final String description;
	@Relationship(type = "ACTED_IN", direction = INCOMING)
	private Set<PersonEntity> actors = new HashSet<>();
	@Relationship(type = "DIRECTED", direction = INCOMING)
	private Set<PersonEntity> directors = new HashSet<>();
	public MovieEntity(String title, String description) {
		this.title = title;
		this.description = description;
	}
	//Getters omitted for brevity
}

在第一行中,@Node 注释用于将类标记为受管实体。它还配置了 Neo4j 标签,默认为类名,但您也可以定义自定义标签。

类定义内的前几行将实体的 id 字段设置为 title 属性。标题是此领域中的唯一业务键,但如果另一个领域中没有唯一键,则可以使用 @Id@GeneratedValue 注释的组合来生成唯一的技术键。还为 UUID 提供了生成器。

下面的两行设置了 tagline(或 description)属性。@Property 注释用作一种将字段名称映射为与图属性不同的名称的方法。通过这种方式,您可以映射应用程序实体和数据库领域之间的差异。

在下一个注释中,@Relationship 定义了电影和人物实体之间的关系,其类型为 ACTED_IN,用于显示哪些人参演了特定的电影。下面的两行定义了 MovieEntityPersonEntity 之间用于导演电影的人员的另一种关系。

然后,下一个代码块为具有节点属性(titledescription)的实体定义了一个构造函数。

如上所述,您可以将 SDN 与 Kotlin 一起使用,并使用 Kotlin 的数据类对您的领域进行建模。如果您想或需要完全在 Java 中工作,也可以使用 Project Lombok 来缩短定义和样板代码。

人物实体

@Node("Person")
public class PersonEntity {
	@Id
	private final String name;
	private final Integer born;
	public PersonEntity(Integer born, String name) {
		this.born = born;
		this.name = name;
	}
    //Getters omitted
}

这个人物实体类看起来与我们上面的 MovieEntity 类非常相似。@Node 注释定义了它是一个数据库领域实体。标识了一个唯一键字段(在本例中为 name 属性),并将 born 属性定义为该类上的另一个属性。类的构造函数遵循这些属性。

请注意,我们没有定义从人物返回到电影的关系。在我们的用例中,我们只想检索电影以及参与其中的人。我们的应用程序不需要我们单独提取人物实体的信息,因此我们不需要在另一个方向上定义关系。

如果领域需要在双方提取相关实体,我们需要在双方都添加注释和属性。

定义 Spring Data 存储库

应用程序中的存储库将扩展一个开箱即用的存储库,称为 ReactiveNeo4jRepository

如果要构建命令式应用程序,可以扩展 Neo4jRepository。此外,虽然在技术上不被禁止,但不建议或不支持在同一个应用程序中混合使用命令式和响应式数据库访问。

因为我们的存储库实现了响应式功能,所以我们可以访问来自 Project ReactorMonoFlux 响应式类型作为方法返回值。Mono 类型返回 0 或 1 个结果,而 Flux 返回 0 或 n 个结果。如果我们期望从查询中返回单个对象,将使用 Mono 的返回类型;如果我们期望从查询中返回多个对象,将使用 Flux 类型。

电影存储库

public interface MovieRepository extends ReactiveNeo4jRepository<MovieEntity, String> {
	Mono<MovieEntity> findOneByTitle(String title);
}

对于我们的应用程序,我们需要与 Neo4j 图数据库交互,因此我们将创建一个扩展 Neo4j 存储库的接口。

由于我们想为应用程序使用响应式功能,我们将扩展 ReactiveNeo4jRepository,它在几个扩展的 Spring 存储库之上提供了响应式、Neo4j 特定的实现细节。ReactiveNeo4jRepository 需要指定两个类型——我们的类类型及其 id 类型。一旦我们在此处添加了 MovieEntityString(我们的电影 id 字段是 title),我们就可以开始定义我们想要使用的方法。

在接口定义中,有一个我们将为 findOneByTitle() 定义的方法。此方法将允许我们基于电影标题搜索数据库,我们期望针对我们感兴趣的电影看到单个电影返回或根本没有返回。

为了获得 0 或 1 的返回结果,我们可以使用 Mono<MovieEntity> 的响应式返回类型。我们还将向该方法传递一个标题(String),因为我们希望允许用户输入任何电影标题作为搜索值。

人物存储库

虽然 Github 代码中有一个 PersonRepository 接口,但它仅用于该应用程序的测试目的,因此我们不在此详细介绍。有关此应用程序在 SDN 中进行测试的更多信息,请参阅 文档

不过,它确实演示了使用自定义查询和 Flux 返回类型,因此它可能作为示例或为其他应用程序提供模板而引起关注。

设置控制器

有了存储库,我们就有了访问数据库中电影数据的方法。现在让我们定义允许用户访问这些方法并查询数据库的端点。

控制器充当数据层和用户界面之间的信使,以接受来自用户的请求并返回响应。代码逻辑和数据操作通常放在这里,根据它接收到的输入协调不同的响应。

因为我们的用例范围只对电影感兴趣,所以我们只需要创建一个控制器来访问电影数据。

MovieController.java

@RestController
@RequestMapping("/movies")
public class MovieController {
	private final MovieRepository movieRepository;
	public MovieController(MovieRepository movieRepository) {
		this.movieRepository = movieRepository;
	}
	//method implementations with walkthroughs below
}

首先,我们需要有一些注释来声明这是一个 REST 请求控制器 (@RestController),并将请求映射到特定路径的控制器方法 (@RequestMapping,端点为 /movies)。

在类定义中,我们首先注入存储库接口并为其创建一个构造函数。这使我们能够从存储库接口和领域类访问数据层。

现在我们需要添加更多代码来定义端点并实现我们的数据方法。

@PutMapping
Mono<MovieEntity> createOrUpdateMovie(@RequestBody MovieEntity newMovie) {
	return movieRepository.save(newMovie);
}

首先是 createOrUpdateMovie() 的实现。我们从 @PutMapping 注释开始,以指定 PUT 请求(覆盖或替换对象)。我们想要指定一个要覆盖或创建的单个电影,因此我们使用 Mono 的返回类型并传入包含其所有预期字段的电影对象。在方法内,我们将通过调用电影存储库的 save() 方法来保存该新电影或已更新的电影。

现在,如果您滚动回上面定义的 MovieRepository 接口,您可能会注意到我们没有在那里定义保存方法。这是因为 Spring Data 存储库为我们提供了一些开箱即用的默认方法。save()findAll() 等方法是几乎每个应用程序都想要或需要的方法,因此 Spring 提供了它们,我们不必每次创建数据访问时都实现这些基本方法。

让我们在控制器中添加另一个用于 getMovies() 的方法。

@GetMapping(value = { "", "/" }, produces = MediaType.TEXT_EVENT_STREAM_VALUE)
Flux<MovieEntity> getMovies() {
	return movieRepository.findAll();
}

@GetMapping 注释告诉我们,我们只是从数据库检索数据,而不是修改或插入。我们在注释中有两个参数,其中我们传递 URL 路径上的任何附加深度(在本例中没有附加深度 - 只是 /movies),并且我们想要返回一个文本事件流。这是我们的媒体类型,因为我们期望 Flux 的结果(0 到 n 个数量),并且我们想要在它们到达时返回它们(响应式流),而不是一次性聚合和返回所有结果(命令式 json 对象)。就像我们之前的方法一样,我们调用电影存储库并访问开箱即用的 findAll() 方法以返回数据库中的所有电影。

下一个方法是我们 MovieRepository 接口中定义的方法。

@GetMapping("/by-title")
Mono<MovieEntity> byTitle(@RequestParam String title) {
	return movieRepository.findOneByTitle(title);
}

起始的 @GetMapping 指定了 /by-title 的子路径。由于我们正在搜索单个电影,用户将在其中输入标题作为搜索字符串,我们期望使用类型 Mono 返回 0 或 1 个结果,并将用户定义的电影标题参数传入方法。在返回中,我们再次调用电影存储库并访问我们定义的 findOneByTitle() 方法,传入搜索标题。

对于最后一个方法定义,我们想要允许用户从我们的数据库中删除电影。

@DeleteMapping("/\{id\}")
Mono<Void> delete(@PathVariable String id) {
	return movieRepository.deleteById(id);
}

我们使用 @DeleteMapping 注释并将子路径端点指定为 /movies/{id}(其中 id 代表我们想要删除的电影的 id)。我们一次只想要删除一个电影,并且我们不期望返回对象(因为它将被删除且不再存在于数据库中),因此我们将 Mono<Void> 指定为返回类型。该方法定义并传入一个路径变量(其中用户输入定义 url 路径)作为要删除的电影 id,然后使用开箱即用的 deleteById() 方法和电影 id 调用电影存储库。

运行应用程序

所有代码就位后,我们应该准备好构建并运行我们的应用程序,并尝试我们设置的端点!我们可以运行应用程序(从 IDE 中的菜单选项或命令行),然后打开 Web 浏览器或命令行与端点进行交互。对于此示例,我们将展示如何从命令行角度进行交互。

无论您以哪种方式连接,我们都将使用 localhost:8080/movies 路径访问 findAll() 方法并检索数据库中的所有电影,然后添加任何定义的子路径以深入研究其他方法。我们可以点击下方显示的每个端点,并验证一切是否按预期工作。

从命令行进行交互

以下是命令行中每个端点的语法:

  • localhost:8080/movies 用于 getMovies() 方法

curl https://:8080/movies

结果:检索数据库中的所有电影

  • localhost:8080/movies <movieToUpdateOrCreate> 用于 createOrUpdateMovie() 方法

curl -X "PUT" "https://:8080/movies" \
     -H 'Content-Type: application/json; charset=utf-8' \
     -d $'{
  "title": "Aeon Flux",
  "description": "Reactive is the new cool"
}'

结果:在我们的数据库中创建新电影 Aeon Flux

  • localhost:8080/movies/by-title 用于 byTitle() 方法

curl https://:8080/movies/by-title\?title\=Aeon%20Flux

结果:检索有关特定电影的信息(在此查询中为 Aeon Flux

  • localhost:8080/movies/{id} 用于 delete() 方法

curl -X DELETE https://:8080/movies/847

结果:使用 id 删除电影(在本例中为 Aeon Flux 电影)