数据建模: 实体继承

本指南演示了在 CUBA 应用程序中如何使用实体继承。

宠物诊所示例中,有各种宠物在诊疗期间需要被照看,像猫、鸟及鼠类。应用程序应该在一个表里存储所有宠物的公共属性,使用独立的关联表去存储每种宠物特定的属性。

将要构建的内容

本指南对 CUBA 宠物诊所 示例进行了增强,以演示实体继承机制。特别是会覆盖以下用例:

  • Pet 实体成为所有宠物类型的超类

  • CatBirdRat 被创建为具体的宠物类型实体

  • 用户可以在创建宠物时选择不同的宠物类型。系统将显示相应的实体编辑器,用户可以输入特定宠物类型的信息。

开发环境要求

你的开发环境需要满足以下条件:

下载 并解压本指南的源码,或者使用 git 克隆下来:

示例: CUBA 宠物诊所

这个示例是以 CUBA 宠物诊所项目为基础,而这个项目的基础是众所周知的 Spring 宠物诊所项目。CUBA 宠物诊所应用程序涉及到了宠物诊所的领域模型及与管理一家宠物诊所相关的业务流程。

这个应用程序的领域模型看起来像这样:

领域模型

主要实体是 PetVisit。 一个 Pet 到一家诊所就诊,在诊疗过程(Vist) 会有一名兽医(Vet)负责照顾它。每个宠物都有主人,一个主人可以有多个宠物。一个诊疗过程(Vist)即是一个宠物在主人的帮助下到诊所就诊的活动。

JPA 中的实体继承

数据模型包含 Pet 实体,它是一个基类,数据存储在 PETCLINIC_PET 表。 这个实体使用的继承策略是 JOINED ,因此子类 CatBirdRat 存储在三个不同的表中。

PETCLINIC_PET 表中存储了继承自 Pet 实体的所有属性。额外定义在子类中的属性存储在专门的子类表中: PETCLINIC_CATPETCLINIC_BIRDPETCLINIC_RAT

两个实体表(例如 PETCLINIC_PETPETCLINIC_BIRD )之间通过外键关联。

另外,在超类中有一个名为 DTYPE 的特殊类型的列,称之为鉴别列 。这个列只在对象-关系映射机制(JPA)内部使用,用于在从数据库中加载数据时创建正确的实例。

对于继承层次中的每个类,都有一个特定的值来标识对应层次的类。在 Pet 类的子类中使用下列值:CATBIRDRATPET 用于 Pet 超类实例本身。

鉴别列的值通过加在实体上的 @DiscriminatorValue("BIRD") 注解来定义。 这个特殊的数据库列通常不暴露为实体属性。它只在持久化层内部使用,用于在区别不同的实体子类。

如果用户需要使用类型属性,有必要考虑在超类上创建另外一个枚举类型的 type 属性,枚举中包含所有可能的实体子类。这样就允许系统使使用实例的类型信息,比如在通过界面按类型过滤或排序 。

在宠物诊所示例中,Pet 实体继承关系看起来是这样:

pet inheritance

Pet 超类

超类上有三个注解:

Pet.java
@DiscriminatorColumn(name = "DTYPE", discriminatorType = DiscriminatorType.STRING) (1)
@Inheritance(strategy = InheritanceType.JOINED) (2)
@DiscriminatorValue("PET") (3)
public class Pet extends NamedEntity {


    @NotNull
    @Column(name = "IDENTIFICATION_NUMBER", nullable = false)
    protected String identificationNumber; (4)

    @Temporal(TemporalType.DATE)
    @Column(name = "BIRTH_DATE")
    protected Date birthDate;

    // ..

}
1 对鉴别列的描述,包括了列名和列类型
2 类层次的继承策略(可选项有: SINGLE_TABLETABLE_PER_CLASSJOINED
3 超类本身鉴别列的值,有的情况下超类的实例是有实际意义的。在 Pet 示例中 ,Pet 实例不能反映真实世界。
4 所有子类共享的公共属性

Cat 子类

Cat 子类作为一个具体宠物类型的示例,包含两个实体继承相关的注解。

Cat.java
@PrimaryKeyJoinColumn(name = "ID", referencedColumnName = "ID") (1)
@DiscriminatorValue("CAT") (2)
@Table(name = "PETCLINIC_CAT")
@Entity(name = "petclinic_Cat")
public class Cat extends Pet {

    @Column(name = "CLAW_LENGTH")
    protected Integer clawLength; (3)

    // ...
}
1 有关在两个表 PETCLINIC_PETPETCLINIC_CAT 之间如何关联数据的信息
2 在这个类实例上应该使用的鉴别值
3 只与猫(cat)相关的额外属性

有了这些配置后,就可以通过 JPA 层创建猫的实例,所有与 Pet 相关的属性会存储到 PETCLINIC_PET 表,Cat 相关的属性会存储到 PETCLINIC_CAT 表。

实体继承与 UI

创建基于实体继承的 UI 需要一些特殊的处理。在浏览界面,可以显示所有与具体子类无关的实体,或者只显示特定子类的实体。对于编辑界面,为了输入子类特定的实体信息就必须使用具体子类。

宠物(Pet)浏览界面

在宠物诊所示例中,有一个主菜单:Petclinic>Pets。在这个界面,应该显示了与具体宠物类型无关的所有宠物。在 JPL 查询中使用超类 Pet 时可以实现这个效果。

bet-browse.xml
<data readOnly="true">
    <collection id="petsCt"
                class="com.haulmont.sample.petclinic.entity.pet.Pet"
                view="pet-with-owner-and-type">
        <loader id="petsLd">
            <query><![CDATA[select e from petclinic_Pet e]]></query>
        </loader>
    </collection>
</data>

使用这个数据容器,所有的宠物(猫、鸟和鼠类)都会显示在浏览界面。

这种情况是显而易见的,在一个表格中是不可能显示出特定于子类的属性,因为子类属性对于超类(Pet) 是不可见的 ,比如 Cat.clawLength

用于创建宠物的 UI

要创建不同类型的宠物实例,需要区分具体类型界面之间的不同点。 同样,在选择一个具体的宠物实例的时候,有时候也要区分出不同的宠物类型。

用于创建新宠物的界面将基于宠物浏览界面进行调整、适应。在创建新的宠物时,必须选择具体宠物子类。

可显示所有宠物的浏览界面
PetBrowse.java
public class PetBrowse extends StandardLookup<Pet> {

    @Inject
    private ScreenBuilders screenBuilders;

    @Inject
    private GroupTable<Pet> petsTable;

    @Inject
    private Metadata metadata;

    @Subscribe("createBtn.createCat")
    protected void onCreateBtnCreateCat(Action.ActionPerformedEvent event) {
        Cat cat = metadata.create(Cat.class); (1)
        showCreateEditorForPet(cat);
    }

    @Subscribe("createBtn.createBird")
    protected void onCreateBtnCreateBird(Action.ActionPerformedEvent event) {
        Bird bird = metadata.create(Bird.class);
        showCreateEditorForPet(bird);
    }

    @Subscribe("createBtn.createRat")
    protected void onCreateBtnCreateRat(Action.ActionPerformedEvent event) {
        Rat rat = metadata.create(Rat.class);
        showCreateEditorForPet(rat);
    }

    private void showCreateEditorForPet(Pet pet) {
        screenBuilders.editor(petsTable)
                .editEntity(pet) (2)
                .build()
                .show();
    }

}
1 在打开界面前将一个具体子类实体( 这里是一个 Cat 实例)
2 ScreenBuilders API 允许为一个具体类实例创建一个自定义编辑界面

有了这些逻辑判断代码,将会为具体子类打开正确的编辑界面。在子类编辑器中可以编辑子类特定的属性。

cat-edit.xml
<data>
    <instance id="catDc"
              class="com.haulmont.sample.petclinic.entity.pet.Cat"
              view="pet-with-owner-and-type">
        <loader/>
    </instance>
    <!-- ... -->
</data>
<layout expand="editActions" spacing="true">
    <form id="form" dataContainer="catDc">
        <column width="250px">
            <textField id="nameField" property="name"/> (1)
            <textField id="identificationNumberField" property="identificationNumber"/>
            <dateField id="birthDateField" property="birthDate"/>

            <lookupPickerField property="type" optionsContainer="typesCt"/>
            <lookupPickerField property="owner" optionsContainer="ownersCt"/>

            <textField id="clawLengthField" property="clawLength"/> (2)
        </column>
    </form>
    <!-- ... -->
</layout>
1 Pet 超类的属性
2 Cat 子类上的属性

选择一个特定子类

在宠物诊所示例中,应该允许为针对猫创建特定的 Visit,这个 Visit 有一些针对猫的设置。 当在 Visit 编辑界面 为宠物选择了 Visit for Cats ,那么在这个界面上就应该限制只能选择 Cat 实体实体。此外,这个界面上应该只显示对于猫的就诊有必要的属性。

要实现这个效果,首先创建一个 CatVisit ,它继承了 Visit 实体。

CatVisit.java
@DiscriminatorValue("CATVISIT")
@Entity(name = "petclinic_CatVisit")
public class CatVisit extends Visit {

    @Column(name = "CAT_TREE_REQUIRED")
    protected Boolean catTreeRequired;

    // ...
}

在这里,使用了 SINGLE_TABLE 继承策略,这意味着超类和子类的所有属性都存储在一个表中。

当继承类型使用了 SINGLE_TABLE 时,子类的属性上不能有 NOT NULL DB 约束,这是因为继承层次上的其它实体不可能满足这个约束。 但是在实体层的 @NotNull bean 验证是允许的。

为了通过 UI 创建一个特定的 CatVisit 实例 ,这里使用了前面描述的模式。为了实现在猫的就诊(Visit)编辑器中过滤 Visit.pet 属性, 对 lookupPickerField 组件做了一些细微的调整。

cat-visit-edit.xml
<data>
    <instance id="catVisitDc"
              class="com.haulmont.sample.petclinic.entity.visit.CatVisit"
              view="visit-with-pet">
        <loader/>
    </instance>

    <collection id="catsDc" class="com.haulmont.sample.petclinic.entity.pet.Cat" view="_base"> (1)
        <loader>
            <query>
                select e from petclinic_Cat e
            </query>
        </loader>
    </collection>
</data>
<layout expand="editActions" spacing="true">
    <form id="form" dataContainer="catVisitDc">
        <column width="250px">

            <lookupPickerField id="petField" property="pet" optionsContainer="catsDc" caption="msg://cat"/> (2)

            <checkBox id="catTreeRequiredField" property="catTreeRequired"/> (3)

        </column>
    </form>
</layout>
1 数据容器 (catsDc) 只选择 Cat 实例
2 petField 控件使用 catsDc 作为选项数据源,这样就只显示猫
3 可以为 CatVisit 定义特定的属性

使用了这些配置,用于选择宠物的 lookup 组件将只显示猫。

用于猫的就诊编辑器,对可选择的宠物进行了过滤

超类映射

另外一种继承类型是 @MappedSuperclass 注解的使用。映射超类允许在实体级别共享行为和属性,但是在数据库层完全不会有继承关系出现。在宠物诊所示例中,有一个类叫做 NamedEntity 。一个 NamedEntity(命名实体) 实体是一个 CUBA StandardEntity(标准实体),只是添加了一个特殊字段: name

NamedEntity.java
@NamePattern("%s|name")
@MappedSuperclass
public class NamedEntity extends StandardEntity {

    @Column(name = "NAME")
    protected String name;

}

这个类使用了 @MappedSuperclass 注解,表示它可被继承,但是不会产生明确的数据库表关联。 继承自 NamedEntity 实体的类比如 Specialty ,会继承 NamedEntity 实体的所有属性,并且这些属性全部都存储在自身的表 PETCLINIC_SPECIALTY

CUBA 本身提供了一些映射超类,用于为 CUBA 应用程序中的所有表定义一些标准属性。 StandardEntity 是一个最常见的例子,它包含了时间戳相关的属性: createTsupdateTs

总结

在这个数据建模指南中,介绍了实体继承。 继承是面向对象编程中通过类层次共享数据和行为的常用方法。 在数据库级别,继承的概念不是直接可用的。 但是,JPA 作为 OR 映射工具,允许用不同的模式表达继承,以弥合两个世界之间的差距。

可以通过不同方式实现实体继承。 MappedSuperclass 允许只共享属性而不使用共享表。 当应该表示继承时,可以使用JPA @Inheritance 注解。 在这种情况下,其它实体可以直接与超类实体关联(比如 VisitPet 中,Pet 是一个超类实体)。

在UI层上,也必须能反映出实体的继承关系。 根据使用场景,用户可以调整界面,以使用户界面显示继承层次中的所有实体实例或者只显示实体继承中的一部分。