值对象的层次结构


有两个类:Person  和Document,具有以下业务规则:

  • 一个Person  可以拥有零个或一个Document。
  • Document只能分配给一个Person  。
  • 没有Person就没有Document。

此领域模型中有两种类型的文档:IdentityCard和Passport。以下是领域模型在图表上的外观:
就底层数据库而言,第一个想法是:大多数人(包括我)都会为Person引入一个表,为Document引入另一个表。ER模型表如下:

这里是代码中的领域模型(公共setter是为了简洁):

public class Person : Entity
{
    public virtual string Name { get; set; }
    public virtual Document Document { get; set; }
}

public class Document : Entity
{
}

public class IdentityCard : Document
{
    public virtual DateTime IssueDate { get; set; }
}

public class Passport : Document
{
    public virtual string SerialNumber { get; set; }
}

virtual 关键字是使用了NHibernate.

该解决方案看起来不错,但此实现的问题是,如果您尝试为一个人分配一个新文档,旧文档仍将保留在数据库中,它将不会自动删除。所以,像这样:

var person = new Person { Name = "Name" };
person.Document = new IdentityCard(DateTime.Now);
Flush();
// both the person and the document are created

person.Document = new Passport(
"Serial Number");
Flush();
// the new document is assigned but the old one remains in the database

导致在数据库中有两个Document。其中只有一个会被一个人引用,另一个会成为孤儿。您必须使用存储库或直接调用NHibernate会话手动删除第二个Document。

NHibernate级联选项
如何解决这个问题?
NHibernate具有丰富的级联功能。您可以使用它们来通知NHibernate在创建,更新或删除父实体时如何处理子实体。以下是最受欢迎的级联选项:

  • None 什么也不做.
  • Save-update 在创建或更新父实体时创建或更新子实体。
  • All — 类似save-update 加deletion. 意味着如果删除父实体,则会删除子实体。
  • All-delete-orphans 全删除孤儿 — t与所有  孤立实体的删除相同

All-delete-orphans 对Childern实体集合有用,Person类如果有Document列表这个子集合,所有的Document只会被与Person本身被删除; 使用all-delete-orphans,你也可以从Person的Document集合中删除一个document ,达到数据表里面的document被删除,无需删除Person!

All-delete-orphans 这个选项看起来像是我们问题的完美匹配,但这就是问题所在。它只适用于一对多关系。如果Person  有一个文档集合,那就是它 - 只需修改映射文件并完成它。但在我们的案例中,一个Person只能拥有一个Document,这是一对多(或一对一)的关系,而不是一对多关系。

值对象的层次结构
那么该怎么办?
在继续解决之前,让我们再看一下领域模型:

什么是Document,实体或价值对象?
实体与价值对象不同的主要判断依据是:是否有必要跟踪它。(banq注:如果需要跟踪,就需要唯一标识,没有唯一标识就无法跟踪):
如果您需要知道领域模型中特定对象发生了什么,并且即使在修改它之后也将该对象与其他对象区分开来,那么这就是一个实体。如果您可以将另一个替换为另一个,那么这是一个值对象。

在我们的领域模型中,Document只能分配给单个Person。此外,Document本身不能单独存在!这是一个强有力的指标,表明Document  是一个值对象。当您替换旧Document时,不必关心旧Document会发生什么。此外,如果需要删除旧的Document,以免它遗弃在数据库。这就是值对象的属性。

顺便说一下,Document是值对象,这是一个好的案例,因为它表明概念本身不能说明它是一个实体或一个值对象,而是取决于特定应用/有界背景(限定上下文/有界上下文)的要求。在另一个有限的上下文中,Document  很可能是一个实体。

在数据库中存储值对象的最佳方法是将它们包含在父表中。这就是我们在这里需要做的事情。

将Document的数据与Person合并 是一种自然的解决方案,它允许您使Document  遵循值对象语义。就像您可以用 您想要的任何其他名称替换Person的名称,并且不必担心从数据库中删除旧名称,您也可以轻松地用新的名称替换Person的文档,忘记现有实施的所有令人头疼的问题。

请记住:因为单独的表用来实现实体,如果Document  是一个实体,就需要单独表来实现(banq注:实体可用来修改,因为可以通过唯一标识ID查询到定位到这个实体,这种根据ID查询定位本身就是一种跟踪,所以,实体一般可以修改,而值对象只需要插入和删除两个动作,只有实体才需要CRUD四个动作,如果你希望将值对象放在单独表,那么就要给予其唯一标识ID,那么这个表的操作只有插入和删除两个动作,这也是一种变通方式)。

好吧,所以我们需要将所有数据从Document表移动到Person中。这就是它的样子:

但是有一个问题:文档的层次结构。如果不再有单独的文档表,那么如何将该层次结构映射到数据库?内置的ORM继承功能仅适用于实体。更具体地说,此功能需要单独的表或几个单独的表。

我们在这里需要一个自定义继承实现 - 一个包含文档层次结构的包装类。我们将使用此包装容器代替Person中的文档,如下所示:

public class DocumentContainer
{
    private DocumentType _type;
    private DateTime? _issueDate;
    private string _serialNumber;

    public Document Document
    {
        get
        {
            switch (_type)
            {
                case DocumentType.IdentityCard:
                    return new IdentityCard(_issueDate.Value);

                case DocumentType.Passport:
                    return new Passport(_serialNumber);

                default:
                    throw new ArgumentOutOfRangeException();
            }
        }
        set
        {
            switch (value)
            {
                case IdentityCard identityCard:
                    _issueDate = identityCard.IssueDate;
                    _serialNumber = null;
                    _type = DocumentType.IdentityCard;
                    break;

                case Passport passport:
                    _issueDate = null;
                    _serialNumber = passport.SerialNumber;
                    _type = DocumentType.Passport;
                    break;
            }
        }
    }
}

public class Person : Entity
{
    public virtual string Name { get; set; }

    private readonly DocumentContainer _document;
    public virtual Document Document
    {
        get => _document.Document;
        set => _document.Document = value;
    }
}

所以基本上我在这里做的是我重新实现了开箱即用的ORM中可用的每表类层次映射。包装容器包含层次结构中所有类的所有字段,并根据值对象的类型更新或取消它们。
这种手动映射看起来很笨拙但很好的部分是它隐藏在Person类的客户端之外。Person 的表达式好且干净的,Document 实例是一个正确的值对象 - 没有身份标识,而且其生命周期完全由父实体控制。

完整代码:https://gist.github.com/vkhorikov/61f873671630db5a4e0234f9912c660e

这是我在很多复杂场景中遇到的重现主题。如果您无法弄清楚如何使用普通ORM映射某些内容,请创建一个包装器并映射该包装器。只需确保不要使用类的公共API公开它。

总结

  • NHibernate不支持 多对一或一对一关系中的all-delete-orphans级联功能。只有一对多关系才有资格。
  • 在数据库中存储值对象的最佳方法是将它们嵌入到父实体的表中,即使它不仅仅是单个值对象而是它们的层次结构。
  • 要实现值对象的层次结构,请使用层次结构中所有值对象的所有数据字段创建包装类。将该类映射为常规值对象。
  • 不要将包装器暴露给类的客户端。