15_spring_data_neo4j简单教程
KnowledgeGraph
教程代码示例
https://gitee.com/suveng/graph-movie-app
Spring Data Neo4j 简单教程
简介
Spring Data Neo4j 是 Spring Data 项目的一部分,它提供了对 Neo4j 图数据库的集成支持。通过 Spring Data Neo4j,开发者可以轻松地在 Spring Boot 应用中使用 Neo4j 数据库,利用图数据库的优势处理复杂的关系数据。
本教程基于一个完整的电影图数据库应用,展示如何使用 Spring Data Neo4j 构建图数据库应用程序。
环境准备
1. 安装 Neo4j
首先需要安装 Neo4j 数据库。可以通过以下方式安装:
- Docker 方式:
docker run \
--publish=7474:7474 --publish=7687:7687 \
--env NEO4J_AUTH=neo4j/your_password \
--env NEO4J_PLUGINS=["apoc"] \
neo4j:5.15.0-community
注意:
-
使用
neo4j:5.15.0-community指定了具体的Neo4j版本(社区版) -
推荐使用稳定版本,避免使用
latest标签,以确保环境一致性 -
如果需要企业版功能,可以使用
neo4j:5.15.0-enterprise -
此命令不包含数据卷挂载,容器删除后数据会丢失。如需持久化数据,可以添加
--volume参数
Neo4j社区版与企业版的区别
Neo4j提供两个主要版本:社区版(Community Edition)和企业版(Enterprise Edition),它们有以下主要区别:
社区版(Community Edition)
-
免费使用:完全免费,适合开发、测试和小规模生产环境
-
核心功能:包含完整的图数据库核心功能
-
基础安全:提供基本的认证和授权功能
-
单机部署:支持单实例部署
-
基础备份:提供基本的备份和恢复功能
-
适用场景:个人项目、学习、开发环境、小型应用
企业版(Enterprise Edition)
-
商业许可:需要购买商业许可证
-
高级安全:提供LDAP/Active Directory集成、Kerberos认证、SSL/TLS加密等
-
集群支持:支持因果集群(Causal Clustering),实现高可用性和水平扩展
-
高级备份:提供在线备份、增量备份和更强大的恢复功能
-
监控和管理:包含更详细的监控指标和管理工具
-
性能优化:包含更多性能优化功能
-
适用场景:大型企业应用、高可用性要求的生产环境、需要处理大规模数据的场景
选择建议
-
对于学习、开发和小型项目,社区版已经足够
-
对于需要高可用性、高级安全功能或大规模部署的生产环境,建议使用企业版
-
可以先使用社区版进行开发,后续根据需要升级到企业版
注意:
-
NEO4J_AUTH=neo4j/your_password设置用户名和密码,请将your_password替换为您自己的密码 -
NEO4J_PLUGINS=["apoc"]可选,安装APOC插件以提供更多功能 -
首次启动时,Neo4j会使用提供的凭据创建用户
关于APOC插件
APOC (Awesome Procedures On Cypher) 是 Neo4j 的一个扩展插件,提供了许多额外的函数和过程,大大增强了 Neo4j 的功能。APOC插件包含:
-
数据处理功能:JSON处理、XML处理、日期时间操作等
-
图算法:最短路径、中心性算法、社区检测等
-
数据导入导出:从各种数据源(CSV、JSON、XML、数据库等)导入数据
-
图重构:节点合并、关系合并、图变换等
-
元数据操作:索引管理、约束管理、模式信息查询等
-
虚拟关系和节点:动态创建不实际存储在数据库中的关系和节点
APOC插件是可选的,但对于生产环境和复杂应用场景非常有用。如果您不需要这些额外功能,可以移除 NEO4J_PLUGINS=["apoc"] 环境变量。
- 官方下载安装:访问 Neo4j 官网 下载对应平台的安装包
2. 创建 Spring Boot 项目
创建一个新的 Spring Boot 项目,推荐使用 Spring Boot 3.x 版本(如 3.2.0)以确保与 Neo4j 5.x 的兼容性。
在 pom.xml 中添加以下依赖:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>graph-movie-app</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>graph-movie-app</name>
<description>Spring Boot Neo4j Movie Graph Application</description>
<properties>
<java.version>17</java.version>
<testcontainers.version>1.19.3</testcontainers.version>
</properties>
<dependencies>
<!-- Spring Boot Neo4j Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-neo4j</artifactId>
</dependency>
<!-- Spring Boot Web Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Validation Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Spring Boot Test Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Testcontainers Neo4j -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>neo4j</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<!-- Testcontainers JUnit Jupiter -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<!-- JUnit Jupiter -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!-- Lombok (Optional) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Fastjson for JSON serialization -->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.43</version>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2-extension-spring6</artifactId>
<version>2.0.43</version>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>${testcontainers.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
测试依赖说明
- spring-boot-starter-test:
- Spring Boot 的核心测试启动器
- 包含了大多数测试所需的库,如 JUnit 5、Mockito、AssertJ 等
- 提供了 Spring Boot 测试上下文支持
- junit-jupiter:
- JUnit 5 的核心模块
- 提供了现代的测试框架和注解(@Test、@BeforeEach、@AfterEach 等)
- 支持参数化测试和动态测试
- mockito-core:
- 强大的模拟框架
- 用于创建和管理模拟对象
- 支持 BDD(行为驱动开发)风格的测试
- testcontainers:
- 提供轻量级的、一次性的数据库实例
- 用于集成测试,确保测试环境的一致性
- 支持多种数据库,包括 Neo4j
- testcontainers-neo4j:
- TestContainers 的 Neo4j 模块
- 专门用于在测试中启动和管理 Neo4j 容器
- 确保每个测试都有干净的数据库环境
测试配置
在 src/test/resources/application-test.yml 中添加测试配置:
spring:
neo4j:
uri: bolt://localhost:7687
authentication:
username: neo4j
password: test_password
logging:
level:
org.springframework.data.neo4j: DEBUG
使用 TestContainers 进行集成测试
如果需要使用 TestContainers 进行集成测试,可以创建以下测试基类:
import org.junit.jupiter.api.BeforeAll;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.Neo4jContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
public abstract class Neo4jIntegrationTestBase {
@Container
static Neo4jContainer<?> neo4jContainer = new Neo4jContainer<>("neo4j:5.15.0-community")
.withAdminPassword("test_password");
@DynamicPropertySource
static void neo4jProperties(DynamicPropertyRegistry registry) {
registry.add("spring.neo4j.uri", neo4jContainer::getBoltUrl);
registry.add("spring.neo4j.authentication.username", () -> "neo4j");
registry.add("spring.neo4j.authentication.password", () -> "test_password");
}
}
然后集成测试类可以继承这个基类:
public class MovieIntegrationTest extends Neo4jIntegrationTestBase {
// 测试代码...
}
版本兼容性说明:
-
Spring Boot 3.x 与 Neo4j 5.x 兼容性最佳
-
如果使用 Spring Boot 2.x,建议使用 Neo4j 4.x 版本
-
Spring Boot 3.x 需要 Java 17 或更高版本
配置连接
在 application.properties 或 application.yml 中配置 Neo4j 连接信息:
# Neo4j 连接配置
spring.neo4j.uri=bolt://localhost:7687
spring.neo4j.authentication.username=neo4j
spring.neo4j.authentication.password=your_password
或者使用 YAML 格式:
spring:
neo4j:
uri: bolt://localhost:7687
authentication:
username: neo4j
password: your_password
定义节点实体
使用 @Node 注解定义图数据库中的节点实体。项目中的实体类使用了更多注解和验证功能:
Person 实体类
package com.example.graphmovieapp.domain.entity;
import org.springframework.data.neo4j.core.schema.Id;
import org.springframework.data.neo4j.core.schema.Node;
import org.springframework.data.neo4j.core.schema.Property;
import org.springframework.data.neo4j.core.schema.Relationship;
import org.springframework.data.neo4j.core.schema.GeneratedValue;
import com.alibaba.fastjson2.annotation.JSONField;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Max;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* Person实体类,表示图数据库中的人物节点
*/
@Node("Person")
public class Person {
/**
* 人物ID,主键
*/
@Id
@GeneratedValue
private Long id;
/**
* 人物姓名
*/
@Property("name")
@NotBlank(message = "人物姓名不能为空")
private String name;
/**
* 出生年份
*/
@Property("born")
@Min(value = 1800, message = "出生年份不能早于1800年")
@Max(value = 2100, message = "出生年份不能晚于2100年")
private Integer born;
/**
* 参演的电影列表(ACTED_IN关系)
*/
@Relationship(type = "ACTED_IN", direction = Relationship.Direction.OUTGOING)
@JSONField(serialize = false, deserialize = false)
private List<Movie> actedInMovies = new ArrayList<>();
/**
* 执导的电影列表(DIRECTED关系)
*/
@Relationship(type = "DIRECTED", direction = Relationship.Direction.OUTGOING)
@JSONField(serialize = false, deserialize = false)
private List<Movie> directedMovies = new ArrayList<>();
/**
* 默认构造函数
*/
public Person() {
}
/**
* 带参数的构造函数
*/
public Person(String name, Integer born) {
this.name = name;
this.born = born;
}
// getter和setter方法...
/**
* 添加参演的电影
*/
public void addActedInMovie(Movie movie) {
if (!actedInMovies.contains(movie)) {
actedInMovies.add(movie);
}
}
/**
* 移除参演的电影
*/
public void removeActedInMovie(Movie movie) {
actedInMovies.remove(movie);
}
/**
* 添加执导的电影
*/
public void addDirectedMovie(Movie movie) {
if (!directedMovies.contains(movie)) {
directedMovies.add(movie);
}
}
/**
* 移除执导的电影
*/
public void removeDirectedMovie(Movie movie) {
directedMovies.remove(movie);
}
// equals、hashCode和toString方法...
}
Movie 实体类
package com.example.graphmovieapp.domain.entity;
import org.springframework.data.neo4j.core.schema.Id;
import org.springframework.data.neo4j.core.schema.Node;
import org.springframework.data.neo4j.core.schema.Property;
import org.springframework.data.neo4j.core.schema.Relationship;
import org.springframework.data.neo4j.core.schema.GeneratedValue;
import com.alibaba.fastjson2.annotation.JSONField;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Max;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* Movie实体类,表示图数据库中的电影节点
*/
@Node("Movie")
public class Movie {
/**
* 电影ID,主键
*/
@Id
@GeneratedValue
private Long id;
/**
* 电影标题
*/
@Property("title")
@NotBlank(message = "电影标题不能为空")
private String title;
/**
* 发行年份
*/
@Property("released")
@Min(value = 1800, message = "发行年份不能早于1800年")
@Max(value = 2100, message = "发行年份不能晚于2100年")
private Integer released;
/**
* 演员列表(ACTED_IN关系的入向)
*/
@Relationship(type = "ACTED_IN", direction = Relationship.Direction.INCOMING)
@JSONField(serialize = false, deserialize = false)
private List<Person> actors = new ArrayList<>();
/**
* 导演列表(DIRECTED关系的入向)
*/
@Relationship(type = "DIRECTED", direction = Relationship.Direction.INCOMING)
@JSONField(serialize = false, deserialize = false)
private List<Person> directors = new ArrayList<>();
/**
* 默认构造函数
*/
public Movie() {
}
/**
* 带参数的构造函数
*/
public Movie(String title, Integer released) {
this.title = title;
this.released = released;
}
// getter和setter方法...
/**
* 添加演员
*/
public void addActor(Person actor) {
if (!actors.contains(actor)) {
actors.add(actor);
}
}
/**
* 移除演员
*/
public void removeActor(Person actor) {
actors.remove(actor);
}
/**
* 添加导演
*/
public void addDirector(Person director) {
if (!directors.contains(director)) {
directors.add(director);
}
}
/**
* 移除导演
*/
public void removeDirector(Person director) {
directors.remove(director);
}
// equals、hashCode和toString方法...
}
创建仓库接口
创建继承自 Neo4jRepository 的仓库接口,提供丰富的数据访问方法:
PersonRepository
package com.example.graphmovieapp.repository;
import com.example.graphmovieapp.domain.entity.Person;
import com.example.graphmovieapp.domain.entity.Movie;
import org.springframework.data.neo4j.repository.Neo4jRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.repository.query.Param;
import org.springframework.data.neo4j.repository.query.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* PersonRepository接口,提供对Person实体的数据访问操作
*/
@Repository
public interface PersonRepository extends Neo4jRepository<Person, Long> {
/**
* 按姓名查找人物
*/
Optional<Person> findByName(String name);
/**
* 按出生年份查找人物
*/
List<Person> findByBorn(Integer born);
/**
* 按姓名模糊查询人物
*/
List<Person> findByNameContaining(String name);
/**
* 按姓名模糊查询人物(支持分页)
*/
Page<Person> findByNameContaining(String name, Pageable pageable);
/**
* 查找指定演员参演的所有电影
*/
@Query("MATCH (p:Person {name: $name})-[:ACTED_IN]->(m:Movie) RETURN m")
List<Movie> findMoviesActedByPerson(@Param("name") String name);
/**
* 查找指定演员参演的所有电影(支持分页)
*/
@Query(value = "MATCH (p:Person {name: $name})-[:ACTED_IN]->(m:Movie) RETURN m ORDER BY m.title SKIP $skip LIMIT $limit",
countQuery = "MATCH (p:Person {name: $name})-[:ACTED_IN]->(m:Movie) RETURN count(m)")
Page<Movie> findMoviesActedByPerson(@Param("name") String name, Pageable pageable);
/**
* 查找指定导演执导的所有电影
*/
@Query("MATCH (p:Person {name: $name})-[:DIRECTED]->(m:Movie) RETURN m")
List<Movie> findMoviesDirectedByPerson(@Param("name") String name);
/**
* 查找参演指定电影的演员
*/
@Query("MATCH (p:Person)-[:ACTED_IN]->(m:Movie {title: $title}) RETURN p")
List<Person> findActorsInMovie(@Param("title") String title);
/**
* 查找参演指定电影的演员(支持分页)
*/
@Query(value = "MATCH (p:Person)-[:ACTED_IN]->(m:Movie {title: $title}) RETURN p ORDER BY p.name SKIP $skip LIMIT $limit",
countQuery = "MATCH (p:Person)-[:ACTED_IN]->(m:Movie {title: $title}) RETURN count(p)")
Page<Person> findActorsInMovie(@Param("title") String title, Pageable pageable);
/**
* 查找指定电影的导演
*/
@Query("MATCH (p:Person)-[:DIRECTED]->(m:Movie {title: $title}) RETURN p")
List<Person> findDirectorsOfMovie(@Param("title") String title);
}
MovieRepository
package com.example.graphmovieapp.repository;
import com.example.graphmovieapp.domain.entity.Movie;
import com.example.graphmovieapp.domain.entity.Person;
import org.springframework.data.neo4j.repository.Neo4jRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.repository.query.Param;
import org.springframework.data.neo4j.repository.query.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* MovieRepository接口,提供对Movie实体的数据访问操作
*/
@Repository
public interface MovieRepository extends Neo4jRepository<Movie, Long> {
/**
* 按标题查找电影
*/
Optional<Movie> findByTitle(String title);
/**
* 按发行年份查找电影
*/
List<Movie> findByReleased(Integer released);
/**
* 按标题模糊查询电影
*/
List<Movie> findByTitleContaining(String title);
/**
* 按标题模糊查询电影(支持分页)
*/
@Query(value = "MATCH (m:Movie) WHERE m.title CONTAINS $title RETURN m ORDER BY m.title SKIP $skip LIMIT $limit",
countQuery = "MATCH (m:Movie) WHERE m.title CONTAINS $title RETURN count(m)")
Page<Movie> findByTitleContaining(@Param("title") String title, Pageable pageable);
/**
* 按发行年份范围查找电影
*/
List<Movie> findByReleasedBetween(Integer start, Integer end);
/**
* 按发行年份范围查找电影(支持分页)
*/
@Query(value = "MATCH (m:Movie) WHERE $start <= m.released <= $end RETURN m ORDER BY m.released SKIP $skip LIMIT $limit",
countQuery = "MATCH (m:Movie) WHERE $start <= m.released <= $end RETURN count(m)")
Page<Movie> findByReleasedBetween(@Param("start") Integer start, @Param("end") Integer end, Pageable pageable);
/**
* 查找指定演员参演的电影数量
*/
@Query("MATCH (p:Person {name: $name})-[:ACTED_IN]->(m:Movie) RETURN count(m)")
Long countMoviesActedByPerson(@Param("name") String name);
/**
* 查找同时担任演员和导演的电影
*/
@Query("MATCH (p:Person {name: $name})-[:ACTED_IN]->(m:Movie) WHERE (p)-[:DIRECTED]->(m) RETURN m")
List<Movie> findMoviesActedAndDirectedByPerson(@Param("name") String name);
/**
* 查找合作演员(参演同一电影的不同演员)
*/
@Query("MATCH (p1:Person {name: $name})-[:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(p2:Person) WHERE p1 <> p2 RETURN DISTINCT p2")
List<Person> findCoActors(@Param("name") String name);
}
创建服务层
创建服务层接口和实现类来处理业务逻辑:
MovieService 接口
package com.example.graphmovieapp.service;
import com.example.graphmovieapp.domain.entity.Movie;
import com.example.graphmovieapp.domain.entity.Person;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.List;
import java.util.Optional;
/**
* MovieService接口,提供电影相关的业务操作
*/
public interface MovieService {
/**
* 保存电影
*/
Movie saveMovie(Movie movie);
/**
* 根据ID查找电影
*/
Optional<Movie> findMovieById(Long id);
/**
* 查找所有电影
*/
List<Movie> findAllMovies();
/**
* 根据ID删除电影
*/
void deleteMovie(Long id);
/**
* 根据标题查找电影
*/
Optional<Movie> findMovieByTitle(String title);
/**
* 根据发行年份查找电影
*/
List<Movie> findMoviesByReleased(Integer released);
/**
* 根据标题模糊查询电影(支持分页)
*/
Page<Movie> findMoviesByTitleContaining(String title, Pageable pageable);
/**
* 根据发行年份范围查找电影
*/
List<Movie> findMoviesByReleasedBetween(Integer start, Integer end);
/**
* 查找指定演员参演的电影数量
*/
Long countMoviesActedByPerson(String name);
/**
* 查找同时担任演员和导演的电影
*/
List<Movie> findMoviesActedAndDirectedByPerson(String name);
/**
* 查找合作演员
*/
List<Person> findCoActors(String name);
/**
* 创建电影并添加演员和导演
*/
Movie createMovieWithActorsAndDirectors(Movie movie, List<String> actorNames, List<String> directorNames);
}
MovieService 实现类
package com.example.graphmovieapp.service.impl;
import com.example.graphmovieapp.domain.entity.Movie;
import com.example.graphmovieapp.domain.entity.Person;
import com.example.graphmovieapp.repository.MovieRepository;
import com.example.graphmovieapp.repository.PersonRepository;
import com.example.graphmovieapp.service.MovieService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.List;
import java.util.Optional;
/**
* MovieService实现类,提供电影相关的业务操作
*/
@Service
@Transactional
public class MovieServiceImpl implements MovieService {
private final MovieRepository movieRepository;
private final PersonRepository personRepository;
/**
* 构造函数,注入Repository依赖
*/
@Autowired
public MovieServiceImpl(MovieRepository movieRepository, PersonRepository personRepository) {
this.movieRepository = movieRepository;
this.personRepository = personRepository;
}
/**
* 保存电影
*/
@Override
@Transactional
public Movie saveMovie(Movie movie) {
if (movie == null) {
throw new IllegalArgumentException("电影不能为空");
}
return movieRepository.save(movie);
}
/**
* 根据ID查找电影
*/
@Override
@Transactional(readOnly = true)
public Optional<Movie> findMovieById(Long id) {
if (id == null) {
throw new IllegalArgumentException("电影ID不能为空");
}
return movieRepository.findById(id);
}
/**
* 查找所有电影
*/
@Override
@Transactional(readOnly = true)
public List<Movie> findAllMovies() {
return movieRepository.findAll();
}
/**
* 创建电影并添加演员和导演
*/
@Override
@Transactional
public Movie createMovieWithActorsAndDirectors(Movie movie, List<String> actorNames, List<String> directorNames) {
if (movie == null) {
throw new IllegalArgumentException("电影不能为空");
}
if (movie.getTitle() == null || movie.getTitle().trim().isEmpty()) {
throw new IllegalArgumentException("电影标题不能为空");
}
// 保存电影
Movie savedMovie = movieRepository.save(movie);
// 添加演员
if (actorNames != null && !actorNames.isEmpty()) {
for (String actorName : actorNames) {
if (actorName != null && !actorName.trim().isEmpty()) {
Optional<Person> actorOpt = personRepository.findByName(actorName);
if (actorOpt.isPresent()) {
Person actor = actorOpt.get();
actor.addActedInMovie(savedMovie);
savedMovie.addActor(actor);
personRepository.save(actor);
} else {
// 如果演员不存在,创建一个新的演员记录
Person newActor = new Person(actorName, null);
newActor.addActedInMovie(savedMovie);
savedMovie.addActor(newActor);
personRepository.save(newActor);
}
}
}
}
// 添加导演
if (directorNames != null && !directorNames.isEmpty()) {
for (String directorName : directorNames) {
if (directorName != null && !directorName.trim().isEmpty()) {
Optional<Person> directorOpt = personRepository.findByName(directorName);
if (directorOpt.isPresent()) {
Person director = directorOpt.get();
director.addDirectedMovie(savedMovie);
savedMovie.addDirector(director);
personRepository.save(director);
} else {
// 如果导演不存在,创建一个新的导演记录
Person newDirector = new Person(directorName, null);
newDirector.addDirectedMovie(savedMovie);
savedMovie.addDirector(newDirector);
personRepository.save(newDirector);
}
}
}
}
return movieRepository.save(savedMovie);
}
}
创建控制器
创建 REST API 控制器来提供 HTTP 接口:
package com.example.graphmovieapp.controller;
import com.example.graphmovieapp.domain.entity.Movie;
import com.example.graphmovieapp.domain.entity.Person;
import com.example.graphmovieapp.service.MovieService;
import com.example.graphmovieapp.service.PersonService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.util.List;
import java.util.Map;
import java.util.HashMap;
import java.util.Optional;
/**
* MovieController类,提供电影相关的REST API端点
*/
@RestController
@RequestMapping("/api/movies")
public class MovieController {
private final MovieService movieService;
private final PersonService personService;
/**
* 构造函数,注入Service依赖
*/
@Autowired
public MovieController(MovieService movieService, PersonService personService) {
this.movieService = movieService;
this.personService = personService;
}
/**
* 创建电影
*/
@PostMapping
public ResponseEntity<Movie> createMovie(@Valid @RequestBody Movie movie) {
try {
Movie savedMovie = movieService.saveMovie(movie);
return new ResponseEntity<>(savedMovie, HttpStatus.CREATED);
} catch (IllegalArgumentException e) {
return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST);
}
}
/**
* 根据ID获取电影
*/
@GetMapping("/{id}")
public ResponseEntity<Movie> getMovieById(@PathVariable Long id) {
try {
Optional<Movie> movie = movieService.findMovieById(id);
return movie.map(value -> new ResponseEntity<>(value, HttpStatus.OK))
.orElseGet(() -> new ResponseEntity<>(HttpStatus.NOT_FOUND));
} catch (IllegalArgumentException e) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
}
/**
* 获取所有电影
*/
@GetMapping
public ResponseEntity<List<Movie>> getAllMovies() {
List<Movie> movies = movieService.findAllMovies();
return new ResponseEntity<>(movies, HttpStatus.OK);
}
/**
* 删除电影
*/
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteMovie(@PathVariable Long id) {
try {
movieService.deleteMovie(id);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
} catch (IllegalArgumentException e) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
}
/**
* 根据标题获取电影
*/
@GetMapping("/by-title")
public ResponseEntity<Movie> getMovieByTitle(@RequestParam String title) {
try {
Optional<Movie> movie = movieService.findMovieByTitle(title);
return movie.map(value -> new ResponseEntity<>(value, HttpStatus.OK))
.orElseGet(() -> new ResponseEntity<>(HttpStatus.NOT_FOUND));
} catch (IllegalArgumentException e) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
}
/**
* 标题模糊查询(分页)
*/
@GetMapping("/search")
public ResponseEntity<Page<Movie>> searchMoviesByTitle(
@RequestParam String title,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
try {
Pageable pageable = PageRequest.of(page, size);
Page<Movie> movies = movieService.findMoviesByTitleContaining(title, pageable);
return new ResponseEntity<>(movies, HttpStatus.OK);
} catch (IllegalArgumentException e) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
}
/**
* 根据年份范围获取电影
*/
@GetMapping("/by-year-range")
public ResponseEntity<List<Movie>> getMoviesByYearRange(
@RequestParam Integer start,
@RequestParam Integer end) {
try {
List<Movie> movies = movieService.findMoviesByReleasedBetween(start, end);
return new ResponseEntity<>(movies, HttpStatus.OK);
} catch (IllegalArgumentException e) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
}
/**
* 获取电影的演员
*/
@GetMapping("/{id}/actors")
public ResponseEntity<List<Person>> getActorsInMovie(@PathVariable Long id) {
try {
Optional<Movie> movieOpt = movieService.findMovieById(id);
if (!movieOpt.isPresent()) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
List<Person> actors = personService.findActorsInMovie(movieOpt.get().getTitle());
return new ResponseEntity<>(actors, HttpStatus.OK);
} catch (IllegalArgumentException e) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
}
/**
* 创建电影并添加演员和导演
*/
@PostMapping("/create-with-cast")
public ResponseEntity<Movie> createMovieWithCast(@RequestBody Map<String, Object> request) {
try {
// 从请求体中提取电影信息
Object movieDataObj = request.get("movie");
if (movieDataObj == null) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
Map<String, Object> movieData = (Map<String, Object>) movieDataObj;
Movie movie = new Movie();
if (movieData.containsKey("title")) {
movie.setTitle((String) movieData.get("title"));
}
if (movieData.containsKey("released")) {
Integer released = (Integer) movieData.get("released");
movie.setReleased(released);
}
// 提取演员姓名列表
@SuppressWarnings("unchecked")
List<String> actorNames = (List<String>) request.get("actorNames");
// 提取导演姓名列表
@SuppressWarnings("unchecked")
List<String> directorNames = (List<String>) request.get("directorNames");
// 创建电影并添加演员和导演
Movie savedMovie = movieService.createMovieWithActorsAndDirectors(movie, actorNames, directorNames);
return new ResponseEntity<>(savedMovie, HttpStatus.CREATED);
} catch (ClassCastException | NullPointerException e) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
} catch (IllegalArgumentException e) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
} catch (Exception e) {
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
}
Spring Boot 主类
创建 Spring Boot 应用程序的主类:
package com.example.graphmovieapp;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Spring Boot应用程序主类
* Neo4j电影图数据库应用程序的入口点
*/
@SpringBootApplication
public class GraphMovieAppApplication {
public static void main(String[] args) {
SpringApplication.run(GraphMovieAppApplication.class, args);
}
}
高级特性
1. 分页查询
Spring Data Neo4j 支持分页查询,便于处理大量数据:
// 在Repository中定义分页查询方法
@Query(value = "MATCH (m:Movie) WHERE m.title CONTAINS $title RETURN m ORDER BY m.title SKIP $skip LIMIT $limit",
countQuery = "MATCH (m:Movie) WHERE m.title CONTAINS $title RETURN count(m)")
Page<Movie> findByTitleContaining(@Param("title") String title, Pageable pageable);
// 在Service中使用分页
@Transactional(readOnly = true)
public Page<Movie> findMoviesByTitleContaining(String title, Pageable pageable) {
if (title == null || title.trim().isEmpty()) {
throw new IllegalArgumentException("标题关键字不能为空");
}
if (pageable == null) {
throw new IllegalArgumentException("分页参数不能为空");
}
return movieRepository.findByTitleContaining(title, pageable);
}
// 在Controller中提供分页端点
@GetMapping("/search")
public ResponseEntity<Page<Movie>> searchMoviesByTitle(
@RequestParam String title,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
try {
Pageable pageable = PageRequest.of(page, size);
Page<Movie> movies = movieService.findMoviesByTitleContaining(title, pageable);
return new ResponseEntity<>(movies, HttpStatus.OK);
} catch (IllegalArgumentException e) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
}
2. 复杂Cypher查询
支持执行复杂的图数据库查询:
// 查找合作演员
@Query("MATCH (p1:Person {name: $name})-[:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(p2:Person) WHERE p1 <> p2 RETURN DISTINCT p2")
List<Person> findCoActors(@Param("name") String name);
// 查找同时担任演员和导演的电影
@Query("MATCH (p:Person {name: $name})-[:ACTED_IN]->(m:Movie) WHERE (p)-[:DIRECTED]->(m) RETURN m")
List<Movie> findMoviesActedAndDirectedByPerson(@Param("name") String name);
// 统计演员参演的电影数量
@Query("MATCH (p:Person {name: $name})-[:ACTED_IN]->(m:Movie) RETURN count(m)")
Long countMoviesActedByPerson(@Param("name") String name);
3. JSON序列化控制
使用FastJSON注解控制关系的序列化:
@Relationship(type = "ACTED_IN", direction = Relationship.Direction.OUTGOING)
@JSONField(serialize = false, deserialize = false)
private List<Movie> actedInMovies = new ArrayList<>();
这可以避免在JSON序列化时出现循环引用问题。
4. 事务管理
使用Spring的@Transactional注解进行事务管理:
@Service
@Transactional
public class MovieServiceImpl implements MovieService {
@Override
@Transactional(readOnly = true)
public List<Movie> findAllMovies() {
return movieRepository.findAll();
}
@Override
@Transactional
public Movie createMovieWithActorsAndDirectors(Movie movie, List<String> actorNames, List<String> directorNames) {
// 事务性的复杂操作
// ...
}
}
5. 验证注解
使用Jakarta验证注解进行数据验证:
@Property("name")
@NotBlank(message = "人物姓名不能为空")
private String name;
@Property("born")
@Min(value = 1800, message = "出生年份不能早于1800年")
@Max(value = 2100, message = "出生年份不能晚于2100年")
private Integer born;
运行应用
创建完所有组件后,可以运行应用程序:
1. 启动应用程序
mvn spring-boot:run
应用程序将在默认端口8080上启动。
2. 测试API端点
可以使用curl或其他HTTP客户端测试API:
创建电影
curl -X POST http://localhost:8080/api/movies \
-H "Content-Type: application/json" \
-d '{"title": "The Matrix", "released": 1999}'
创建带演员和导演的电影
curl -X POST http://localhost:8080/api/movies/create-with-cast \
-H "Content-Type: application/json" \
-d '{
"movie": {
"title": "Inception",
"released": 2010
},
"actorNames": ["Leonardo DiCaprio", "Tom Hardy"],
"directorNames": ["Christopher Nolan"]
}'
搜索电影
curl "http://localhost:8080/api/movies/search?title=Matrix&page=0&size=10"
根据年份范围查询电影
curl "http://localhost:8080/api/movies/by-year-range?start=1990&end=2000"
获取电影的演员
curl http://localhost:8080/api/movies/1/actors
3. 测试用例
项目包含了完整的测试套件,包括:
-
单元测试:测试Repository、Service和Controller层
-
集成测试:使用TestContainers测试整个应用程序
-
端到端测试:测试完整的API工作流程
运行测试:
mvn test
总结
通过本教程,我们构建了一个完整的基于 Spring Data Neo4j 的电影图数据库应用程序。该项目展示了:
核心功能
- 图数据库建模:
- 使用 @Node 注解定义人物和电影节点
- 使用 @Relationship 注解定义演员和导演关系
- 支持双向关系导航
- 丰富的数据访问:
- 基本CRUD操作
- 复杂的图查询(合作演员、多角色人物等)
- 分页查询支持
- 条件查询和统计查询
- 分层架构设计:
- Repository层:数据访问抽象
- Service层:业务逻辑处理
- Controller层:REST API接口
- 清晰的职责分离
- 高级特性:
- 事务管理
- 数据验证
- JSON序列化控制
- 异常处理
- 测试覆盖:
- 单元测试、集成测试
- 使用TestContainers进行数据库测试
- TDD开发方法
项目特点
-
完整的电影图数据库应用:包含人物、电影、演员关系、导演关系等完整功能
-
现代化的技术栈:Spring Boot 3.2.0、Neo4j 5.x、Java 17
-
企业级代码质量:完整的注解、异常处理、事务管理
-
RESTful API设计:提供完整的HTTP接口
-
全面的测试策略:确保代码质量和可靠性
Spring Data Neo4j 提供了强大的功能来操作 Neo4j 图数据库,使得开发者可以:
-
使用注解轻松定义节点和关系
-
通过仓库接口简化数据访问
-
使用自定义 Cypher 查询处理复杂查询需求
-
利用 Spring 的事务管理确保数据一致性
-
支持分页、排序等高级功能
通过这个实际项目,你应该能够开始使用 Spring Data Neo4j 构建自己的图数据库应用程序,处理复杂的关系数据模型。

浙公网安备 33010602011771号