持久化值对象

有很多种方式可以用于持久化值对象。通常来讲,持久化过程即是将对象序列化成文本格式或者二进制格式,然后将其保存到计算机磁盘中。然而,由于我们关注的并不是单个值对象自身的持久化,我不会将重点放在通用持久化机制上,而是如何持久化包含有值对象的聚合实例。在接下来的讨论中,我们假设一个父实体包含有多个值对象,这些值对象是需要被持久化的。另外,我们假设对聚合实体的读取和保存通过资源库(12)完成,聚合所包含的值对象随着该聚合的持久化而持久化。

对象-关系映射(ORM,比如Hibernate)持久化机制是流行的。但是,使用 ORM将每个类映射到一张数据库表,再将每个属性映射到数据库表中的列会增加程序的复杂性。当下,NoSQL数据库和键值对存储越来越受到人们的欢迎,因为它们具有高性能.可伸缩性、容错性和高可用性等优点。键值对存储可以在很大程度上简化对聚合的持久化。在本章中,我们依然使用ORM持久化机制。对于NoSQL和键值对存储,我们将在资源库(12)中做详细讲解。

但是,在讲解对值对象的ORM持久化之前,有一点我们需要好好理解。让我们先来看看当数据建模(相比领域建模而言)对你的领域模型造成不利影响时会发生什么情况,我们又可以做些什么。

拒绝由数据建模泄漏带来的不利影响

多数时候,在持久化值对象时(比如使用ORM和关系型数据库),我们都是通过一种非范式的方式完成的,即所有的属性和实体对象都保存在相同的数据库表中。这样可以优化对值对象的保存和读取,并且可以防止持久化逻辑泄漏到模型中。

但是,有时值对象需要以实体的身份进行持久化。换句话说,某个值对象实例会单独占据一张表中的某条记录,而该表也是专门为这个值对象类型而设计的,它甚至拥有自己的主键列。比如,当聚合中维持了一个值对象的集合时,便会发生这种情况。在这种情况下,一个值对象被当成了数据库实体而被持久化。

这是否意味着我们应该将领域对象建模成实体而不是值对象呢?当然不是。当你面临这种阻抗失配时,你应该从领域模型的角度,而不是持久化的角度去思考问题。要达到这样的目的,问问自己以下几个问题:

  1. 我当前所建模的概念表示领域中的一个东西呢,还是只是用于描述和度量其他东西?
  2. 如果该概念起描述作用,那么它是否满足先前所提到的值对象的几大特征?
  3. 将该概念建模成实体是不是只是持久化机制上的考虑?
  4. 将该概念建模成实体是不是因为它拥有唯一标识,我们关注的是对象实例的个体性,并且需要在其整个生命周期中跟踪其变化?

如果你的答案是“描述,是的,是的,不是”,那么此时你应该使用值对象。我们不应该使持久化机制影响到对值对象的建模。

数据建模是次要的

根据领域模型来设计数据模型,而不是根据数据模型来设计领域模型。

在可能的情况下,尽量根据领域模型来设计数据模型,而不是根据数据模型来设计领域模型。采用前者,我们是在从领域模型的角度看待问题。而采用后者,我们则是从持久化的角度看待问题,此时的领域模型只是对数据模型的映射而已。采用面向领域模型的思维方式一一DDD思维方式一一而不是数据模型思维方式,这样我们可以免除由数据模型泄漏所造成的影响。更多关于DDD思维方式的讨论,请参考实体(5)

当然,有时我们需要考虑数据库的引用一致性(比如外键关联)。毫无疑问地,你希望为键值列建立适当的索引。同时,你可能也需要使用一些商业智能化报表工具来操作业务数据。事实上,你是可以通过其他方式来完成这样的功能的。很多人都认为,报表和商业智能化应该由专门的数据模型进行处理,而不是生产环境中的数据。跟随这种战略层次的思维方式,我们根据领域模型所创建的数据模型将能更好地满足DDD原则。

无论你使用什么技术来完成数据建模,数据库实体、主键、引用完整性和索引都不能用来驱动你对领域概念的建模。DDD不是关于如何根据范式来组织数据的,而是在一个一致的限界上下文中建模一套通用语言。在这个过程中,你应该尽量地避免数据模型从你的领域模型中泄漏到客户端中,对此我们将在下一节中进行讲解。

ORM与单个值对象

向数据库中保存单个值对象是非常直接的。这里我们使用Hibernate和MySQL 为例,基本的思路是将值对象与其所在的实体对象保存在同一张表中,值对象的每一个属性保存为一列,换句话讲是通过一种非范式的方式将单个值对象与实体对象保存在相同的数据库记录中。此时采用标准的命名约定是有好处的。

在使用Hibernate保存值对象实例时,我们可以使用component映射元素,该元素可以用于将值对象直接映射到实体对象的数据库表中。这是一种优化的序列化技术,此时我们依然可以将值对象包含在SQL查询语句中。在下面的示例代码中,实体对象Backlogltem引用了一个BusinessPriority值对象:

<component name="businessPriority" class="com.saasovation.agilepm.domain.model.product.BusinessPriority">
    <component name="ratings" class="com.saasovation.agilepm.domain.model.product.BusinessPriorityRatings">
        <property name="benefit" column="business_priority_ratings_benefit" type="int" update="true" insert="true" lazy="false" />
        <property name="cost" column="business_priority_ratings_cost" type="int" update="true" insert="true" lazy="false" />
        <property name="penalty" column="business_priority_ratings_penalty" type="int" update="true" insert="true" lazy="false" />
        <property name="risk" column="business_priority_ratings_risk" type="int" update="true" insert="true" lazy="false” />
    </component>
</component>

以上的例子很好地演示了对单个值对象的映射,同时该值对象还包含了一个子值对象。BusinessPriority包含一个名为ratings的值对象,除此之外,并无其他属性。因此,在映射配置中,一个component元素嵌套了另一个component元素,夕卜层表示BusinessPriority,内层表示BusinessPriorityRatings。由于BusinessPriority不再包含其他属性,外层的component并没有映射配置。在内层component中,我们为ratings值对象进行映射配置。最终,我们将BusinessPriorityRatings的4个整数型属性分别保存在表tbl_backlog_item的4个列中。这里我们映射了两个component元素,一个没有属性映射,一个拥有4个属性映射。

请注意,以上各个property元素中的列名都使用了标准的命名约定。该命名约定表示了从最上层的值对象到下层单个属性的路径指向过程。比如,对于benefit属性,逻辑上的指向路径应该为:

businessPriority.ratings.benefit

为了使用单个列名来表示该路径,我们使用:

business_priority_ratings_benefit

当然,你也可以使用其他的方式来表示该路径,比如将“驼峰”式命名规范与下画线一同使用:

businessPriority_ratings一benefit

在本书中,我将全部采用下画线的方式,因为这种方式和传统的SQL列名相吻合,而不是对象名。上例所对应的MySQL数据库定义如下:

CREATE TABLE `tbl_backlog_item` (
    ...
    `business_priority_ratings_benefit` int NOT NULL,
    `business_priority_ratings_cost` int NOT NULL,
    `business_priority_ratings_penalty` int NOT NULL,
    `business_priority_ratings_risk` int NOT NULL,
    ...
) ENGINE=InnoDB;

Hibernate映射和数据库表定义一道向我们提供了优化的、可查询的持久化对象。由于值对象属性通过非范式的方式保存在与实体对象相同的记录中,我们没有必要使用联合查询来获取实体对象,即便对于存在深层嵌套的值对象也是如此。在使用HQL时,Hibernate可以简单地将对象属性表达式映射到SQL查询表达式,比如:

businessPriority.ratings-benefit

将变成:

business—priority一ratings—benefit

这样一来,虽然在对象和关系型数据库之间存在阻抗失配,我们依然可以在它们之间找到一种合适的映射方式。

多个值对象序列化到单个列中

使用ORM将多个值对象的集合映射到数据库中是困难的。我们这里所说的集合是指实体对象所引用的Lis域者Set,这些集合中可以包含零个、一个或多个值对象元素。当然困难也不是克服不了,但是这里的对象-关系阻抗失配表现得更加明显。

一种方式是将整个集合序列化成某种形式的文本,然后将此文本保存到单个数据库列中。这种方式是存在缺点的,但有时和它的好处相比起来,缺点就不那么突出了,此时我们便可以考虑采用这种方式。以下是这种方式的一些潜在缺点:

  • 列宽。有时我们不能决定集合中元素的最大数量,或者序列化后的最大数据量。比如,有些对象集合可以包含任意多个元素一一即没有数量上限。另外,集合中的每个元素序列化之后的字符宽度也是不可确定的。比如当值对象中存在String类型的属性时,便有可能发生这种情况,因为String类型并没有字符数量上限。因此,不管是以上的哪种情况,最终序列化后的数据都有可能超过数据库的列宽。对于那些最大列宽比较狭窄的数据库来说,这尤其是个问题。例如,对于MySQL的InnoDB引擎来说,VARCHAR类型的最大宽度为65,535个字符。另外,对于单条记录,InnoDB引擎所规定的最大宽度也是65,535个字符。因此,在保存整个实体时,我们需要保留足够大的空间。在Oracle数据库中,VARCHAR2/NVARCHAR2类型的最大宽度为4,000。如果我们不能预先确定序列化后文本的宽度,那么我们应该避免采用这种方案。

  • 必须查询。由于值对象集合被序列化到扁平化的文本中,此时值对象的属性便不能用于SQL查询语句了。如果其中的任何一个属性存在查询必要,那么我们也不能使用这种方案。有可能这并不是一个充分的理由,因为从一个集合中查询一个或多个属性是比较少见的情况。

  • 需要自定义类型。要采用这种方案,我们必须自定义一个Hibernate类型来处理对每个集合的序列化和反序列化。就我个人而言,这和其他缺点相比起来并不那么突出,因为我们只需要一种自定义类型的实现便可以支持集合中的每种值对象类型。

这里我并没有提供一个Hibernate自定义类型的例子,但是Hibernate社区向我们展示了很多这样的例子,感兴趣的读者可以参考一下。

使用数据库实体保存多个值对象

在使用诸如Hibernate这样的ORM工具来保存值对象集合时,一种直接方式便是使用数据模型。在“拒绝由数据建模泄漏带来的不利影响”一节中我们曾讲到,在采用这种方式时,我们不能因为某个概念非常符合数据库实体而将其建模成领域模型中的实体。有时,是对象-关系阻抗失配需要我们采用这种方法,但这种方法绝非DDD原则。如果存在更好的持久化风格,我们应该首先考虑将领域概念建模成值对象,而绝不是采用数据库实体。

要实现这种方案,我们可以采用层超类型[Fowler, P of EAA]。就我个人而言,我比较喜欢称之为委派身份标识(主键)。然而,由于Java中的每个对象在JVM中都已经存在一个唯一标识,因此你可能会倾向于直接使用该标识。而我认为不管我们采用哪种方法,在处理对象-关系阻抗失配时,我们都需要为自己做出的技术选择找到充足的理由。

下面是我所倾向于使用的一种委派主键的方式,其中使用了两层的层超类

型:

public abstract class IdentifiedDomainObject
        implements Serializable  {

    private long id = -1;

    public IdentifiedDomainObject() {
        super();
    }

    protected long id() {
        return this.id;
    }

    protected void setId(long anId) {
        this.id = anId;
    }
}

第一层层超类型是IdentifiedDomainObject。该抽象基类提供了一个基本的委派主键,该主键对客户端是不可见的。由于getter和settei* 方法都被声明为了protected,客户端根本就没有机会知道这些方法的存在。当然,你还可以进一步将这些方法声明为private。对于Hibernate而言,它可以通过反射机制直接访问到这里的委派主键。

接下来,我们定义另一层层超类型,该层超类型是值对象专属的:

public abstract class IdentifiedValueObject extends IdentifiedDomainObject  {
    public IdentifiedValueObject() {
        super();
    }
}

你可能会认为这里的IdentifiedValueObject类只是起标记作用,因为它并没有什么行为。我认为这有文档说明上的好处,因为它显式地指出了建模意图。IdentifiedDomainObject还应该有另外一个专属于实体的抽象子类Entity,请参考实体(5)。我是喜欢这种方法的,当然你也可以根据自己的喜好去除这里多余的类。

现在,每一个值对象类型都可以方便地获得一个隐藏的委派主键,示例代码如下:

public final class GroupMember extends IdentifiedValueObject  {
    private String name;
    private TenantId tenantId;
    private GroupMemberType type;

    public GroupMember(
            TenantId aTenantId,
            String aName,
            GroupMemberType aType) {
        this();
        this.setName(aName);
        this.setTenantId(aTenantId);
        this.setType(aType);
        this.initialize();
    }
    ...
}

GroupMember是一个值对象,聚合实体Group维护了一个GroupMember的集合。我们可以通过值对象的委派主键来标定某个GroupMember的实例,此时我们可以自由地将其映射成数据库实体,而同时在领域模型中将其建模成值对象。Group类的部分代码如下:

持久化值对象

public class Group extends Entity  {
    private String description;
    private Set<GroupMember> groupMembers;
    private String name;
    private TenantId tenantId;

    public Group(TenantId aTenantId, String aName, String aDescription) {
        this();
        this.setDescription(aDescription);
        this.setName(aName);
        this.setTenantId(aTenantId);
        this.initialize();
    }
    ...
    protected Group() {
        super();
        this.setGroupMembers(new HashSet<GroupMember>(0));
    }
    ...
}

一个Group类型的实例会逐渐向groupMembers集合中添加GroupMember值对象实例。请记住,在执行整个集合替换时,请记得在替换之前调用Collection类的clear()方法,这样做可以保证背后的Hibernate的Collection实现类及时地从数据库中删除那些过期的数据。下面的代码并不是Group类的一个方法,这里只是用于演示如何进行全集合替换:

public void replaceMembers(Set<GroupMember> aReplacementMembers) {
    this.groupMembers().clear();
    this.setGroupMembers(aReplacementMembers);
}

从以上代码中我们儿乎看不出ORM向领域模型的泄漏,因为我们使用了-一个通用的Collection类。另外,客户端也看不到这个类。此时,我们并不用过于关注集合内容与数据库的同步。要删除一个值对象,我们只需要调用Collection类上的remove()方法。这个过程中并不存在ORM泄漏。

下一步,我们来看看如何对集合进行映射:

<hibernate-mapping>
    <class name="com.saasovation.identityaccess.domain.model.identity.Group" “table="tbl_group" lazy="true">
        ...
        <set name="groupMembers" cascade="all,delete-orphan"
          inverse="false" lazy="true">
            <key column="group_id" not-null="true" />
            <one-to-many class="com.saasovation.[ccc]
              identityaccess.domain.model.identity.GroupMember" />
        </set>
        ...
    </class>
</hibernate-mapping>

这里的groupMembers集合与数据库实体精确地映射起来,以下是完整的 GroupMember 映射:

<hibernate-mapping>
    <class name="com.saasovation.identityaccess.domain.model.↵
           identity.GroupMember"
           table="tbl_group_member" lazy="true">
        <id
            name="id"
            type="long"
            column="id"
            unsaved-value="-1">

            <generator class="native"/>
        </id>
        <property
            name="name"
            column="name"
            type="java.lang.String"
            update="true"
            insert="true"
            lazy="false"
        />
        <component name="tenantId"
            class="com.saasovation.identityaccess.domain.model.↵
                identity.TenantId">
            <property
                name="id"
                column="tenant_id_id"
                type="java.lang.String"
                update="true"
                insert="true"
                lazy="false"
            />
        </component>
        <property
            name="type"
            column="type"
            type="com.saasovation.identityaccess.infrastructure.persistence.GroupMemberTypeUserType"
            update="true"
            insert="true"
            not-null="true"
        />
    </class>
</hibernate-mapping>

请注意表示持久化委派主键的<^>元素。最后是MySQL中数据表的定义:

CREATE TABLE `tbl_group_member` (
    `id` int(11) NOT NULL auto_increment,
    `name` varchar(100) NOT NULL,
    `tenant_id_id` varchar(36) NOT NULL,
    `type` varchar(5) NOT NULL,
    `group_id` int(11) NOT NULL,
    KEY `k_group_id` (`group_id`),
    KEY `k_tenant_id_id` (`tenant_id_id`),
    CONSTRAINT `fk_1_tbl_group_member_tbl_group`
         FOREIGN KEY (`group_id`) REFERENCES `tbl_group` (`id`),
    PRIMARY KEY (`id`)
) ENGINE=InnoDB;

这里的GroupMember映射和数据库表定义给人的印象是:我们的确是在处理实体。数据库表中存在id,并且存在一个单独的表与tbl_gr〇Up表联合,该表维持了一个到tbl_gr〇Up表的外键关联。但是,这里处理的实体只是出于数据模型的角度。在领域模型中,GroupMember显然是一个值对象。在领域模型中,我们采用了适当的方法将那些与持久化相关的信息隐藏起来。客户端是觉察不到任何持久化泄漏的,而即便是开发者,也很难从代码中找出持久化泄漏的痕迹。

使用联合表保存多个值对象

Hibernate还提供一种以联合表的方式持久化集合数据,这种方式并不需要值对象表现出数据模型的实体特征。这种方式简单地将值对象的集合元素保存到一个单独的数据库表中,然后在该表中维护一个到领域实体所对应表的外键关联,该外键指向实体的数据库id。这样一来,所有的集合元素都可以通过实体id进行查询,然后用于重建该值对象集合。这种方式的好处在于,我们不需要隐藏委派

主键便可以实现对数据库的联合查询。在Hibernate中,我们可以使用来达到这样的目的。

这种方式看似非常完美,或者正是我们所需要的。然而,这种方式也是存在缺点的,其中之一便是即便我们的值对象并不需要委派主键,我们依然会用到对数据库表的联合操作,因为我们需要在两张表之间满足数据库范式。诚然,在“使用数据库实体保存多个值对象”一节中采用的方式也需要表间联合,但是那种方式并不存在“使用联合表保存多个值对象”的第二个缺点,即……

如果保存值对象的集合是Set类型,那么值对象的每一个属性都不应该为 null。原因在于,为了从Set中删除某个值对象,所有用于标定该值对象唯一性的属性都会被用作联合主键的一部分,进而完成对值对象的查找和删除。当然,如果你知道所有属性都没有为null的时候,这种方式是可行的。

第三个缺点在于所映射的值对象可能还包含嵌套的集合,而这并不是 能处理的情况。如果你的值对象不包含嵌套的集合,并且满足这种映射风格的需求,那么可以考虑采用这种方式。

最后,我发现这种方式存在太大的限制性,因此我们应该尽量避免采用这种方式。相反,采用隐藏委派主键的方式将值对象集合映射成一对多的关系要简单得多。当然,你可能有不一样的看法,此时你需要选择最适合自己的方式。

ORM与枚举状态对象

如果你发现枚举是建模标准类型和状态对象的好方式,那么你应该找到一种方式来持久化该枚举。对于Hibernate来说,Java的枚举需要一项专门的持久化技术。不幸的是,Hibernate目前还不提供对枚举属性的直接支持。因此,要对我们自己模型中的枚举进行持久化,我们需要创建一个自定义的类型。

回想一下,前面的G roupMember有一个GroupMemberType:

public final class GroupMember extends IdentifiedValueObject  {
    private String name;
    private TenantId tenantId;
    private GroupMemberType type;

    public GroupMember(
            TenantId aTenantId,
            String aName,
            GroupMemberType aType) {
        this();
        this.setName(aName);
        this.setTenantId(aTenantId);
        this.setType(aType);
        this.initialize();
    }
    ...
}

这里的GroupMembrType枚举标准类型包括GROUP和USER,定义如下:

package com.saasovation.identityaccess.domain.model.identity;

public enum GroupMemberType {

    GROUP {
        public boolean isGroup() {
            return true;
        }
    },
    USER {
        public boolean isUser() {
            return true;
        }
    };

    public boolean isGroup() {
        return false;
    }

    public boolean isUser() {
        return false;
    }
}

持久化Java枚举的一种简单方式是保存枚举所对应的文本展现。然而,这种方式需要我们创建一个Hibernate的自定义类型。这里我并不演示Hibernate社区所提供的EnumUserType,而是向大家提供一个wiki链接:http://community.jboss.org/wiki/Java5EnumUserType

在我写本书时,该Wiki页向我们提供了多种方式,同时还包含了实现多种枚举类型的示例代码,包括使用Hibernate的参数化类型来避免为每一种枚举类型都创建一个自定义类型、分别使用字符串和数字来表示枚举类型,甚至还含有一种由Gavin King改进过后的实现方式,该方式允许将枚举作为类型鉴别器或者数据表id。

釆用以上方法的其中一种,我们可以对GroupMemberType做如下映射:

<hibernate-mapping>
    <class name="com.saasovation.identityaccess.domain.model.↵
            identity.GroupMember" table="tbl_group_member" lazy="true">
        ...
        <property
            name="type"
            column="type"
            type="com.saasovation.identityaccess.infrastructure.↵
                persistence.GroupMemberTypeUserType"
            update="true"
            insert="true"
            not-null="true"
        />
    </class>
</hibernate-mapping>

请注意,这里元素的类型被设成了GroupMemberTypeUserType的全路径名称。这只是一种选择,你也可以采用自己喜欢的方式。在对应的MySQL表定义中,包含有表示该枚举的列:

CREATE TABLE `tbl_group_member` (
    ...
    `type` varchar(5) NOT NULL,
    ...
) ENGINE=InnoDB;

这里的type列类型为VARCHAR,最大长度为5个字符,对于GROUP和USER来说,这已经足够了。

results matching ""

    No results matching ""