教程

简介

Neo4j-OGM 电影示例是一个应用程序,它允许您管理电影、与电影相关的人员以及评分。

尽管为了展示使用 Neo4j-OGM 所需的步骤而简化了功能,但它包含了您期望在实际应用程序中存在的所有层。

在此示例中,我们特意决定不依赖任何外部框架(如 Spring 或 Quarkus),并排除了 Web 部分等,以避免这些框架带来的额外复杂性。这样做的好处是,我们可以更专注于 Neo4j-OGM 的行为。

该应用程序的完整源代码可在 Github 上获取。

构建领域模型

在编写任何代码之前,我们先在白板上画出我们的图模型。为此,我们使用 https://arrows.app

我们的领域将包含 Movies(电影),每个电影都将与 Persons(人员)建立不同的关系。我们有 Actors(演员)、Directors(导演)和 Reviewers(评论者)。

Graph model

定义所需的领域类后,得到以下代码。

class Movie {
    String title;
    List<Actor> actors;
    List<Person> directors;
    List<Reviewer> reviewers;
}

class Person {
    String name;
}

class Actor {
	List<String> roles;

	Movie movie;
	Person person;
}

class Reviewer {
	String review;
	int rating;

	Movie movie;
	Person person;
}

每当 PersonMovie 中表演时,他或她会扮演一个或多个角色 (roles)。为了对此建模,我们将模型中被称为“关系实体”(relationship entity)的元素添加到模型中,在本例中为 Actor

我们为评论 MoviePerson 准备了类似的关系表示,并将其称为 Reviewer

配置 Neo4j-OGM

Neo4j-OGM 依赖 Neo4j Java 驱动程序与数据库进行交互。驱动程序本身使用 Bolt 协议与 Neo4j 实例进行通信。

依赖项

要开始使用 Neo4j-OGM,我们需要将核心依赖和 Bolt 依赖添加到我们的项目中。尽管 Neo4j-OGM 4+ 仅支持通过 Bolt 进行连接,但仍需要显式定义依赖项。

Neo4j-OGM 的 Maven 依赖项
<dependencies>
    <dependency>
        <groupId>org.neo4j</groupId>
        <artifactId>neo4j-ogm-core</artifactId>
        <version>5.0.5</version>
    </dependency>
    <dependency>
        <groupId>org.neo4j</groupId>
        <artifactId>neo4j-ogm-bolt-driver</artifactId>
        <version>5.0.5</version>
    </dependency>
</dependencies>

如果您使用的是 Gradle 或其他系统,请相应地进行配置。

有关依赖项的更多信息,请参考 依赖管理

Neo4j-OGM 配置

Neo4j-OGM 的配置分为两部分。首先,需要定义 SessionFactory 配置。这是您定义数据库连接的地方。此处最常见的参数是 Neo4j URI 以及您希望 Neo4j-OGM 用于连接的凭据。

第二步是针对应用程序定义 Neo4j-OGM 应该扫描哪些包以查找符合条件的领域类。

Configuration configuration = new Configuration.Builder()
    .uri("neo4j://:7687")
    .credentials("neo4j", "verysecret")
    .build();

SessionFactory sessionFactory = new SessionFactory(configuration, "org.neo4j.ogm.example");

注解领域模型

与 Hibernate 或 JPA 一样,Neo4j-OGM 允许您对 POJO 进行注解,以便将它们映射到图中的节点、关系和属性。

节点实体

使用 @NodeEntity 注解的 POJO 将在图中表示为节点。

分配给此节点的标签可以通过注解上的 label 属性指定;如果未指定,它将默认为实体的简单类名。此外,每个父类也会为实体贡献一个标签。当我们需要检索超类型的集合时,这非常有用。

让我们继续,为我们之前编写的代码中的所有节点实体添加注解。

@NodeEntity
public class Movie {
    String title;
    List<Actor> actors;
    List<Person> directors;
    List<Reviewer> reviewers;
}

@NodeEntity
public class Person {
    String name;
}

关系

接下来,是节点之间的关系。

实体中引用另一个实体的每个字段都由图中的关系支持。@Relationship 注解允许您指定关系的类型和方向。默认情况下,方向假定为 OUTGOING,类型为大写蛇形命名法(UPPER_SNAKE_CASE)的字段名称。

我们将明确指定关系类型,以避免使用默认值,并使后续重构类变得更容易(不依赖于字段名)。同样,我们将修改上一节中看到的代码。

@NodeEntity
public class Movie {
    String title;

    @Relationship(type = "ACTED_IN", direction = Relationship.Direction.INCOMING)
    List<Actor> actors;

    @Relationship(type = "DIRECTED", direction = Relationship.Direction.INCOMING)
    List<Person> directors;

    @Relationship(type = "REVIEWED", direction = Relationship.Direction.INCOMING)
    List<Reviewer> reviewers;
}

关系实体

有时,某些内容并不完全是节点实体。

在此演示中,剩余需要注解的类是 ActorReviewer。正如前面讨论的,这是一个关系实体,因为它管理 MoviePerson 之间底层的 ACTED_INREVIEWED 关系。它们不是简单的关系,因为它们保存了诸如 rolessummaryscore 等属性。

关系实体必须使用 @RelationshipEntity 注解,并指明关系类型。此外,它还需要定义它从哪里开始(Person)以及到哪里结束(Movie)。

我们还将向 Neo4j-OGM 指示此关系的起始节点和结束节点。

@RelationshipEntity(type = "ACTED_IN")
public class Actor {

    List<String> roles;

	@StartNode
    Person person;

    @EndNode
    Movie movie;
}

@RelationshipEntity("REVIEWED")
public class Reviewer {

    @Property("summary")
    String review;

    int rating;

    @EndNode
    Movie movie;

    @StartNode
    Person person;
}

标识符

持久化到图中的每个节点和关系都必须有一个 ID。Neo4j-OGM 使用它来识别并将实体重新连接到内存中的图中。标识符可以是主 ID 或原生图 ID。

  • 主 ID - 任何标有 @Id 的属性,由用户设置,并可选地带有 @GeneratedValue 注解。

  • 原生 ID - 此 ID 对应于节点或关系首次保存时由 Neo4j 数据库生成的 ID,必须为 Long 类型。

不要在长期运行的应用程序中依赖原生 ID。Neo4j 会重用已删除节点的 ID。建议用户为自己的领域对象制定唯一的标识符(或使用 UUID)。

有关更多信息,请参阅 节点实体

我们的实体现在也将定义 @Id 字段。对于 Movie,我们选择了 title(标题),对于 Person,我们选择了 name(姓名)属性。在实际场景中,姓名和标题不能被视为唯一,但对于此示例而言已经足够。

@NodeEntity
public class Movie {
    @Id
    String title;

    @Relationship(type = "ACTED_IN", direction = Relationship.Direction.INCOMING)
    List<Actor> actors;

    @Relationship(type = "DIRECTED", direction = Relationship.Direction.INCOMING)
    List<Person> directors;

    @Relationship(type = "REVIEWED", direction = Relationship.Direction.INCOMING)
    List<Reviewer> reviewers;
}

@NodeEntity
public class Person {
    @Id
    String name;
}

此外,我们将生成的内部 ID 引用添加到关系实体中。

@RelationshipEntity(type = "ACTED_IN")
public class Actor {

    @Id @GeneratedValue
    Long id;

    List<String> roles;

    @StartNode
    Person person;

    @EndNode
    Movie movie;
}

@RelationshipEntity("REVIEWED")
public class Reviewer {

    @Id @GeneratedValue
    Long id;

    @Property("summary")
    String review;

    int rating;

    @EndNode
    Movie movie;

    @StartNode
    Person person;
}

无参构造函数

我们快完成了!

Neo4j-OGM 还要求提供一个公共的无参构造函数,以便能够从我们所有已注解的实体中构建对象。由于我们没有定义任何自定义构造函数,因此可以直接使用。

与模型交互

好了,我们的领域实体已经注解完毕,现在我们可以将它们持久化到图中了!

会话 (Sessions)

智能对象映射功能由 Session 对象提供。Session 是从 SessionFactory 中获取的。

我们将 只配置一次 SessionFactory,并让它根据需要创建尽可能多的会话。

Session 会跟踪对实体和关系所做的更改,并在保存时持久化已修改的内容。一旦某个实体被会话跟踪,在同一个会话范围内重新加载此实体将导致会话缓存返回之前加载的实体。但是,如果实体或其相关实体从图中检索到额外的关系,会话中的子图将会扩展。

就本示例应用程序而言,我们将使用短生命周期的会话——每个操作使用一个新的会话——以避免陈旧数据的问题。

我们的示例应用程序将使用以下操作

public class MovieService {

    Movie findMovieByTitle(String title) {
        // implementation
    }

    List<Movie> allMovies() {
        // implementation
    }

    Movie updateTagline(String title, String newTagline) {
        // implementation
    }
}

这些与图的 CRUD 交互全部由 Session 处理。每当我们想要执行一个工作单元时,我们会打开会话,并使用同一个 Session 实例完成所有操作。让我们看看服务(以及带有 SessionFactory 实例化的构造函数)的实现。

public class MovieService {

    final SessionFactory sessionFactory;

    public MovieService() {
        Configuration config = new Configuration.Builder()
            .uri("neo4j://:7687")
            .credentials("neo4j", "verysecret")
            .build();
        this.sessionFactory = new SessionFactory(config, "org.neo4j.ogm.example");
    }

    Movie findMovieByTitle(String title) {
        Session session = sessionFactory.openSession();
        return Optional.ofNullable(
                session.queryForObject(Movie.class, "MATCH (m:Movie {title:$title}) return m", Map.of("title", title)))
            .orElseThrow(() -> new MovieNotFoundException(title));
    }

    public List<Movie> allMovies() {
        Session session = sessionFactory.openSession();
        return new ArrayList<>(session.loadAll(Movie.class));
    }

    Movie updateTagline(String title, String newTagline) {
        Session session = sessionFactory.openSession();
        Movie movie = session.queryForObject(Movie.class, "MATCH (m:Movie{title:$title}) return m", Map.of("title", title));
        Movie updatedMovie = movie.withTagline(newTagline);
        session.save(updatedMovie);
        return updatedMovie;
    }
}

正如您在 updateTagline 方法中所看到的,Session 在开始时立即打开,以便在同一个实例内进行加载和持久化操作。

当服务获取所有 Movies 时,默认的加载深度 1 会应用于该操作。更高的值对我们的示例没有任何影响,因为我们只是在加载 Movies 及其直接邻居。关于 加载深度 的章节为您提供了关于关系遍历默认行为的更多见解。

自定义查询和映射

正如您已经看到的,我们使用了带有 Session#query 的自定义查询来按标题获取 Movie。有时,您的查询返回的数据并不适合您之前定义的实体。

假设我们要收集与所有评论者关联的 Movie 的平均评分。当然,我们也可以在应用程序本身中执行此操作,但在数据库端准备数据不仅可以减少网络传输的数据量,而且在大多数情况下,数据库执行这些操作的速度要快得多。

public record MovieRating(String title, float rating, List<String> reviewers) {
}

向我们的服务中添加另一个包含自定义查询的方法,结果如下

List<MovieRating> getRatings() {
    Session session = sessionFactory.openSession();
    List<MovieRating> ratings = session.queryDto(
        "MATCH (m:Movie)<-[r:REVIEWED]-(p:Person) RETURN m.title as title, avg(r.rating) as rating, collect(p.name) as reviewers",
        Map.of(),
        MovieRating.class);
    return ratings;
}

总结

无需太多努力,我们就构建了将此应用程序联系在一起的所有服务。剩下的只需要添加控制器并构建 UI。功能齐全的应用程序可在 Github 上找到。

我们鼓励您阅读后续的参考指南,并通过 fork 该应用程序并进行扩展来应用所学的概念。