跳到主要内容

基于元数据的映射

DeepSeek V3 中英对照 Metadata-based Mapping

为了充分利用 SDN 中的对象映射功能,你应该使用 @Node 注解来标注你的映射对象。虽然映射框架在没有这个注解的情况下也能正常工作(即使没有任何注解,你的 POJO 也能被正确映射),但它允许类路径扫描器找到并预处理你的领域对象,以提取必要的元数据。如果你不使用这个注解,你的应用程序在首次存储领域对象时会有轻微的性能损失,因为映射框架需要构建其内部元数据模型,以便了解你的领域对象的属性以及如何持久化它们。

映射注解概述

从 SDN

  • @Node: 在类级别应用,表示该类是映射到数据库的候选类。

  • @Id: 在字段级别应用,用于标记用于标识目的的字段。

  • @GeneratedValue: 在字段级别与 @Id 一起应用,指定如何生成唯一标识符。

  • @Property: 在字段级别应用,用于修改从属性到字段的映射。

  • @CompositeProperty: 在字段级别应用于 Map 类型的属性,这些属性将被读取为复合属性。参见复合属性

  • @Relationship: 在字段级别应用,用于指定关系的详细信息。

  • @DynamicLabels: 在字段级别应用,用于指定动态标签的来源。

  • @RelationshipProperties: 在类级别应用,表示该类是关系属性的目标。

  • @TargetNode: 在带有 @RelationshipProperties 注解的类的字段上应用,用于从另一端标记该关系的目标。

以下注解用于指定转换并确保与 OGM 的向后兼容性。

  • @DateLong

  • @DateString

  • @ConvertWith

有关此内容的更多信息,请参见转换

来自 Spring Data commons

  • @org.springframework.data.annotation.Id 与 SDN 中的 @Id 相同,实际上,@Id 是由 Spring Data Common 的 Id 注解进行标注的。

  • @CreatedBy: 应用于字段级别,表示节点的创建者。

  • @CreatedDate: 应用于字段级别,表示节点的创建日期。

  • @LastModifiedBy: 应用于字段级别,表示节点最后一次修改的作者。

  • @LastModifiedDate: 应用于字段级别,表示节点最后一次修改的日期。

  • @PersistenceCreator: 应用于某个构造函数,用于标记该构造函数为读取实体时的首选构造函数。

  • @Persistent: 应用于类级别,表示该类是映射到数据库的候选类。

  • @Version: 应用于字段级别,用于乐观锁定,并在保存操作时检查修改。初始值为零,每次更新时会自动递增。

  • @ReadOnlyProperty: 应用于字段级别,将属性标记为只读。该属性在数据库读取时会进行填充,但不会写入。当用于关系时,请注意,如果未以其他方式关联,则该集合中的任何相关实体都不会被持久化。

请查看审计了解所有与审计支持相关的注解。

基本构建块:@Node

@Node 注解用于将一个类标记为受管理的领域类,该类的类路径将由映射上下文进行扫描。

要将对象映射到图中的节点,反之亦然,我们需要一个标签来标识要映射到的类以及从哪个类映射。

@Node 具有一个 labels 属性,允许你配置一个或多个标签,用于在读取和写入注解类的实例时使用。value 属性是 labels 的别名。如果你没有指定标签,那么将使用简单的类名作为主标签。如果你想提供多个标签,可以采取以下方式之一:

  1. labels 属性提供一个数组。数组中的第一个元素将被视为主标签。

  2. primaryLabel 提供一个值,并将其他标签放入 labels 中。

主要标签应始终是最能反映你领域类别的具体标签。

对于通过存储库或 Neo4j 模板写入的每个带注解类的实例,图中至少带有一个主标签的一个节点将被写入。反之,所有带有主标签的节点将被映射到带注解类的实例。

关于类层次结构的说明

@Node 注解不会从超类型和接口继承。然而,你可以在每个继承层次上单独为你的领域类添加注解。这使得多态查询成为可能:你可以传入基类或中间类,并获取节点正确的具体实例。这仅在用 @Node 注解的抽象基类中得到支持。在此类上定义的标签将与具体实现的标签一起作为附加标签使用。

在某些场景中,我们还支持在领域类层次结构中使用接口:

public interface SomeInterface { 1

String getName();

SomeInterface getRelated();
}

@Node("SomeInterface") 2
public static class SomeInterfaceEntity implements SomeInterface {

@Id
@GeneratedValue
private Long id;

private final String name;

private SomeInterface related;

public SomeInterfaceEntity(String name) {
this.name = name;
}

@Override
public String getName() {
return name;
}

@Override
public SomeInterface getRelated() {
return related;
}
}
java
  • 仅仅使用普通的接口名称,就像你为你的领域命名一样

  • 由于我们需要同步主要的标签,我们在实现类上放置了 @Node,这个类可能位于另一个模块中。请注意,这个值必须与实现的接口名称完全相同。无法进行重命名。

也可以使用不同的主标签来代替接口名称:

@Node("PrimaryLabelWN") 1
public interface SomeInterface2 {

String getName();

SomeInterface2 getRelated();
}

public static class SomeInterfaceEntity2 implements SomeInterface2 {

// Overrides omitted for brevity
}
java
  • @Node 注解放在接口上

也可以使用接口的不同实现,并拥有一个多态的领域模型。这样做时,至少需要两个标签:一个用于确定接口,另一个用于确定具体类:

@Node("SomeInterface3") 1
public interface SomeInterface3 {

String getName();

SomeInterface3 getRelated();
}

@Node("SomeInterface3a") 2
public static class SomeInterfaceImpl3a implements SomeInterface3 {

// Overrides omitted for brevity
}
@Node("SomeInterface3b") 3
public static class SomeInterfaceImpl3b implements SomeInterface3 {

// Overrides omitted for brevity
}

@Node
public static class ParentModel { 4

@Id
@GeneratedValue
private Long id;

private SomeInterface3 related1; 5

private SomeInterface3 related2;
}
java
  • 在此场景中,需要明确指定标识接口的标签

  • 这适用于第一个…

  • 以及第二个实现

  • 这是一个客户端或父模型,透明地使用 SomeInterface3 来处理两个关系

  • 未指定具体类型

所需的数据结构如下所示。OGM 将编写相同的内容:

Long id;
try (Session session = driver.session(bookmarkCapture.createSessionConfig()); Transaction transaction = session.beginTransaction()) {
id = transaction.run("" +
"CREATE (s:ParentModel{name:'s'}) " +
"CREATE (s)-[:RELATED_1]-> (:SomeInterface3:SomeInterface3b {name:'3b'}) " +
"CREATE (s)-[:RELATED_2]-> (:SomeInterface3:SomeInterface3a {name:'3a'}) " +
"RETURN id(s)")
.single().get(0).asLong();
transaction.commit();
}

Optional<Inheritance.ParentModel> optionalParentModel = transactionTemplate.execute(tx ->
template.findById(id, Inheritance.ParentModel.class));

assertThat(optionalParentModel).hasValueSatisfying(v -> {
assertThat(v.getName()).isEqualTo("s");
assertThat(v).extracting(Inheritance.ParentModel::getRelated1)
.isInstanceOf(Inheritance.SomeInterfaceImpl3b.class)
.extracting(Inheritance.SomeInterface3::getName)
.isEqualTo("3b");
assertThat(v).extracting(Inheritance.ParentModel::getRelated2)
.isInstanceOf(Inheritance.SomeInterfaceImpl3a.class)
.extracting(Inheritance.SomeInterface3::getName)
.isEqualTo("3a");
});
java
备注

接口不能定义标识符字段。因此,它们不能作为存储库的有效实体类型。

动态或“运行时”管理的标签

所有通过简单类名隐式定义或通过 @Node 注解显式定义的标签都是静态的。它们不能在运行时更改。如果你需要可以在运行时操作的额外标签,可以使用 @DynamicLabels@DynamicLabels 是一个字段级别的注解,它将 java.util.Collection<String> 类型的属性(例如 ListSet)标记为动态标签的来源。

如果存在此注解,节点上所有未通过 @Node 和类名静态映射的标签将在加载时被收集到该集合中。在写入时,节点的所有标签将被替换为静态定义的标签加上集合中的内容。

注意

如果你有其他应用程序为节点添加额外的标签,请不要使用 @DynamicLabels。如果在托管实体上存在 @DynamicLabels,写入数据库的标签集将被视为“唯一正确”的标签集。

识别实例:@Id

虽然 @Node 创建了一个类与具有特定标签的节点之间的映射,但我们还需要将该类的各个实例(对象)与节点的实例之间建立连接。

这里就是 @Id 发挥作用的地方。@Id 标记了类的一个属性作为对象的唯一标识符。在理想情况下,这个唯一标识符是一个唯一的业务键,换句话说,就是自然键。@Id 可以用于所有支持简单类型的属性上。

然而,自然键往往非常难以找到。以人名为例,它们很少是唯一的,会随时间变化,或者更糟的是,并非每个人都有名和姓。

因此,我们支持两种不同类型的代理键

在类型为 StringlongLong 的属性上,可以使用 @Id@GeneratedValueLonglong 会映射到 Neo4j 的内部 ID。String 会映射到 elementId,该 ID 自 Neo4j 5 起可用。这两者都不是节点或关系上的属性,通常对属性不可见,但它允许 SDN 检索类的单个实例。

@GeneratedValue 提供了 generatorClass 属性。generatorClass 可用于指定一个实现 IdGenerator 的类。IdGenerator 是一个函数式接口,其 generateId 方法接收主标签和实例来生成一个 Id。我们支持 UUIDStringGenerator 作为开箱即用的一个实现。

你也可以通过 generatorRef@GeneratedValue 上指定应用上下文中的 Spring Bean。该 Bean 也需要实现 IdGenerator 接口,但可以利用上下文中的所有内容,包括与数据库交互的 Neo4j 客户端或模板。

备注

不要跳过有关 ID 处理的重要说明,详见 唯一 ID 的处理与配置

乐观锁:@Version

Spring Data Neo4j 通过在 Long 类型的字段上使用 @Version 注解来支持乐观锁。该属性在更新期间会自动递增,且不得手动修改。

例如,如果两个不同线程中的事务想要修改同一个版本为 x 的对象,第一个操作将成功持久化到数据库中。此时,版本字段将会递增,因此变为 x+1。第二个操作将失败,并抛出 OptimisticLockingFailureException 异常,因为它试图修改的对象的版本 x 在数据库中已经不存在了。在这种情况下,操作需要重新尝试,首先从数据库中获取当前版本的对象。

如果使用了业务 ID@Version 属性也是必需的。Spring Data Neo4j 会检查这个字段来确定实体是新的还是已经持久化过的。

映射属性:@Property

@Node 注解的类的所有属性都将作为 Neo4j 节点和关系的属性进行持久化。在没有进一步配置的情况下,Java 或 Kotlin 类中的属性名称将被用作 Neo4j 的属性名称。

如果你正在使用现有的 Neo4j 模式,或者只是想根据你的需求调整映射,你需要使用 @Propertyname 用于指定数据库中属性的名称。

连接节点:@Relationship

@Relationship 注解可以用于所有非简单类型的属性上。它适用于那些被 @Node 注解标注的其他类型的属性,或者这些类型的集合和映射。

typevalue 属性允许配置关系的类型,direction 允许指定方向。在 SDN 中,默认方向是 Relationship.Direction#OUTGOING

我们支持动态关系。动态关系表示为 Map<String, AnnotatedDomainClass>Map<Enum, AnnotatedDomainClass>。在这种情况下,与其他域类的关系类型由映射的键给出,并且不能通过 @Relationship 进行配置。

映射关系属性

Neo4j 不仅支持在节点上定义属性,还支持在关系上定义属性。为了在模型中表达这些属性,SDN 提供了 @RelationshipProperties 注解,可以应用在一个简单的 Java 类上。在属性类中,必须有一个字段标记为 @TargetNode,以定义关系指向的实体。或者,在 INCOMING 关系的上下文中,表示关系的来源。

关系属性类及其用法可能如下所示:

@RelationshipProperties
public class Roles {

@RelationshipId
private Long id;

private final List<String> roles;

@TargetNode
private final PersonEntity person;

public Roles(PersonEntity person, List<String> roles) {
this.person = person;
this.roles = roles;
}

public List<String> getRoles() {
return roles;
}

@Override
public String toString() {
return "Roles{" +
"id=" + id +
'}' + this.hashCode();
}
}
java

你必须为生成的内部 ID(@RelationshipId)定义一个属性,以便 SDN 在保存时能够确定哪些关系可以安全地覆盖而不会丢失属性。如果 SDN 找不到用于存储内部节点 ID 的字段,它将在启动时失败。

@Relationship(type = "ACTED_IN", direction = Direction.INCOMING) 1
private List<Roles> actorsAndRoles = new ArrayList<>();
java

关系查询备注

一般来说,创建查询时没有关系/跳数的限制。SDN 会从你建模的节点开始解析整个可到达的图。

也就是说,当涉及到双向映射关系时,即你在实体的两端都定义了关系,可能会得到超出预期的结果。

考虑一个例子,其中一部电影演员,并且你想获取某部电影及其所有演员。如果从电影演员的关系只是单向的,这不会成为问题。在双向关系的情况下,SDN 会获取特定的电影及其演员,但也会根据关系的定义获取该演员定义的其他电影。在最坏的情况下,这将导致为单个实体获取整个图。

完整示例

将所有这些整合起来,我们可以创建一个简单的领域。我们使用电影和具有不同角色的人物:

示例 1. MovieEntity

import java.util.ArrayList;
import java.util.List;

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.Relationship.Direction;

@Node("Movie") 1
public class MovieEntity {

@Id 2
private final String title;

@Property("tagline") 3
private final String description;

@Relationship(type = "ACTED_IN", direction = Direction.INCOMING) 4
private List<Roles> actorsAndRoles = new ArrayList<>();

@Relationship(type = "DIRECTED", direction = Direction.INCOMING)
private List<PersonEntity> directors = new ArrayList<>();

public MovieEntity(String title, String description) { 5
this.title = title;
this.description = description;
}

// Getters omitted for brevity
}
java
  • @Node 用于将此类标记为托管实体。它还用于配置 Neo4j 标签。如果你只是使用普通的 @Node,标签默认是类的名称。

  • 每个实体都必须有一个 ID。我们使用电影的名称作为唯一标识符。

  • 这里展示了 @Property 作为字段与图属性名称不同的方式。

  • 这里配置了一个指向某人的传入关系。

  • 这是由你的应用代码以及 SDN 使用的构造函数。

在这里,人们被映射到两个角色中,actors(演员)和 directors(导演)。领域类是相同的:

示例 2. PersonEntity

import org.springframework.data.neo4j.core.schema.Id;
import org.springframework.data.neo4j.core.schema.Node;

@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;
}

public Integer getBorn() {
return born;
}

public String getName() {
return name;
}

}
java
备注

我们尚未在双向方向上对电影和人物之间的关系进行建模。为什么这样做?我们将 MovieEntity 视为聚合根,拥有这些关系。另一方面,我们希望能够在无需选择与之关联的所有电影的情况下,从数据库中提取所有人物。在尝试在数据库中为每个方向映射每个关系之前,请考虑您的应用程序的用例。虽然您可以这样做,但您可能会在对象图中重建一个图数据库,而这并不是映射框架的初衷。如果您必须建模循环或双向的领域,并且不想获取整个图,您可以通过使用投影来定义您想要获取的数据的细粒度描述。