跳到主要内容
版本:8.1.1

基于元数据的映射

Doubao 中英对照 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 的抽象基类提供支持。此类类上定义的标签将与具体实现类的标签一起作为额外标签使用。

我们在某些场景下也支持领域类层次中的接口:

import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;

import org.springframework.data.neo4j.core.schema.GeneratedValue;
import org.springframework.data.neo4j.core.schema.Id;
import org.springframework.data.neo4j.core.schema.Node;
import org.springframework.data.neo4j.core.schema.Relationship;
import org.springframework.data.neo4j.core.schema.RelationshipId;
import org.springframework.data.neo4j.core.schema.RelationshipProperties;
import org.springframework.data.neo4j.core.schema.TargetNode;

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

public Long getId() {
return id;
}

public void setRelated(SomeInterface related) {
this.related = related;
}
}
  • 直接使用普通接口名,正如您为领域命名那样

  • 由于我们需要同步主标签,我们把 @Node 放在实现类上(它可能在另一个模块中)。请注意,其值与所实现接口的名称完全相同。不能重命名。

也可以使用与接口名不同的主标签:

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

String getName();

SomeInterface2 getRelated();
}

public static class SomeInterfaceEntity2 implements SomeInterface {

// Overrides omitted for brevity
}
  • 把 @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;
}
  • 在这种场景下,必须显式指定标识接口的标签

  • 适用于第一个……

  • 以及第二个实现

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

  • 未指定具体类型

所需的数据结构如下面的测试所示:

void mixedImplementationsRead(@Autowired Neo4jTemplate template) {

Long id;
try (Session session = this.driver.session(this.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 = this.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");
});
}
备注

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

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

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

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

注意

如果您有其他应用向节点添加额外标签,请不要使用 @DynamicLabels。如果受管实体上存在 @DynamicLabels,那么最终的标签集合将是写入数据库的“真相”。

标识实例:@Id​

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

这正是 @Id 的用武之地。@Id 将类的一个属性标记为对象的唯一标识符。在理想情况下,该唯一标识符是一个唯一业务键,换言之,一个自然键。@Id 可用于所有具有受支持简单类型的属性。

然而,自然键很难找到。例如,人名很少唯一、会随时间变化,更糟的是,并非每个人都有名和姓。

因此,我们支持两种不同的_代理键_。

在类型为 String、long 或 Long 的属性上,@Id 可与 @GeneratedValue 一起使用。Long 和 long 映射到 Neo4j 内部 id。String 映射到自 Neo4j 5 起可用的 elementId。二者都不是节点或关系上的属性,通常不可见,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 schema,或只是想按自己的需求调整映射,就需要使用 @Property。name 用于指定数据库中属性的名称。

连接节点:@Relationship​

@Relationship 注解可用于所有非简单类型的属性。它适用于标注了 @Node 的其他类型的属性,以及它们的集合和映射。

type 或 value 属性用于配置关系的类型,direction 用于指定方向。SDN 中的默认方向是 Relationship.Direction#OUTGOING。

我们支持动态关系。动态关系表示为 Map<String, AnnotatedDomainClass> 或 Map<Enum, AnnotatedDomainClass>。在这种情况下,到另一领域类的关系类型由 map 的键给出,不得通过 @Relationship 配置。

映射关系属性​

Neo4j 不仅支持在节点上定义属性,也支持在关系上定义属性。为在模型中表达这些属性,SDN 提供了可应用于一个普通 Java 类的 @RelationshipProperties。在该属性类中,必须恰好有一个字段被标记为 @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();
}
}

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

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

关系查询注意事项​

一般来说,创建查询时对关系/跳数没有限制。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

}
  • @Node 用于将该类标记为受管实体。它也用于配置 Neo4j 标签。如果您只使用普通的 @Node,标签默认为类名。

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

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

  • 这里配置了指向某个人的 incoming 关系。

  • 这是供您应用代码以及 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 this.born;
}

public String getName() {
return this.name;
}

}
备注

我们没有在两个方向上都对电影和人之间的关系建模。为什么呢?我们把 MovieEntity 视为聚合根,拥有这些关系。另一方面,我们希望能够从数据库中取出所有人,而不必选出与他们相关的所有电影。在您试图把数据库中的每个关系都在每个方向上映射之前,请考虑您应用的用例。虽然您可以这样做,但最终可能会在您的对象图中重建一个图数据库,而这并不是映射框架的本意。如果您必须为循环或双向领域建模,又不想取出整个图,可以通过使用投影来定义您想取出数据的细粒度描述。