实现值对象

小窍门

此内容摘自电子书《适用于容器化 .NET 应用程序的 .NET 微服务体系结构》,可以在 .NET Docs 上获取,也可以下载免费的 PDF 以供离线阅读。

适用于容器化 .NET 应用程序的 .NET 微服务体系结构电子书封面缩略图。

如前面的有关实体和聚合的部分所述,标识是实体的基础。 但是,系统中有许多对象和数据项不需要标识和标识跟踪,例如值对象。

值对象可以引用其他实体。 例如,在生成描述如何从一个点到另一个点的路由的应用程序中,该路由将是一个值对象。 它将是特定路由上的点的快照,但此建议的路由将不具有标识,即使在内部它可能指 City、Road 等实体。

图 7-13 显示 Order 聚合中的 Address 值对象。

显示订单聚合中的 Address 值对象的关系图。

图 7-13. Order 聚合中的 Address 值对象

如图 7-13 所示,实体通常由多个属性组成。 例如, Order 可以将实体建模为具有标识的实体,并在内部由一组属性(如 OrderId、OrderDate、OrderItems 等)组成。但是,地址只是由国家/地区、街道、城市等组成的复杂值,并且在此域中没有标识,必须建模并被视为值对象。

值对象的重要特征

值对象有两个主要特征:

  • 他们没有身份。

  • 它们是不可变的。

第一个特征已经讨论过。 不可变性是一个重要要求。 创建对象后,值对象的值必须不可变。 因此,构造对象时,必须提供所需的值,但不得允许它们在对象的生存期内更改。

值对象允许你对性能执行某些技巧,这要归功于它们不可变的性质。 这在可能有数千个值对象实例的系统中尤其如此,其中许多实例具有相同的值。 它们的不可变性质允许重复使用它们;它们可以是可互换的对象,因为它们的值相同,并且没有标识。 这种优化有时可以在运行缓慢的软件和性能良好的软件之间产生差异。 当然,所有这些情况都取决于应用程序环境和部署上下文。

C# 中的值对象实现

在实现方面,可以有一个值对象基类,它提供基本的实用方法,比如基于所有属性比较的相等性(因为值对象不能基于标识)以及其他基本特征。 下面的示例演示了 eShopOnContainers 排序微服务中使用的值对象基类。

public abstract class ValueObject
{
    protected static bool EqualOperator(ValueObject left, ValueObject right)
    {
        if (ReferenceEquals(left, null) ^ ReferenceEquals(right, null))
        {
            return false;
        }
        return ReferenceEquals(left, right) || left.Equals(right);
    }

    protected static bool NotEqualOperator(ValueObject left, ValueObject right)
    {
        return !(EqualOperator(left, right));
    }

    protected abstract IEnumerable<object> GetEqualityComponents();

    public override bool Equals(object obj)
    {
        if (obj == null || obj.GetType() != GetType())
        {
            return false;
        }

        var other = (ValueObject)obj;

        return this.GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
    }

    public override int GetHashCode()
    {
        return GetEqualityComponents()
            .Select(x => x != null ? x.GetHashCode() : 0)
            .Aggregate((x, y) => x ^ y);
    }
    // Other utility methods
}

这是 ValueObject 一种 abstract class 类型,但在此示例中,它不会重载 ==!= 运算符。 可选择进行重载,将比较委托给 Equals 重写。 例如,请考虑下列对 ValueObject 类型的重载运算符:

public static bool operator ==(ValueObject one, ValueObject two)
{
    return EqualOperator(one, two);
}

public static bool operator !=(ValueObject one, ValueObject two)
{
    return NotEqualOperator(one, two);
}

实现实际值对象时可使用此类,如下例中显示的 Address 值对象:

public class Address : ValueObject
{
    public String Street { get; private set; }
    public String City { get; private set; }
    public String State { get; private set; }
    public String Country { get; private set; }
    public String ZipCode { get; private set; }

    public Address() { }

    public Address(string street, string city, string state, string country, string zipcode)
    {
        Street = street;
        City = city;
        State = state;
        Country = country;
        ZipCode = zipcode;
    }

    protected override IEnumerable<object> GetEqualityComponents()
    {
        // Using a yield return statement to return each element one at a time
        yield return Street;
        yield return City;
        yield return State;
        yield return Country;
        yield return ZipCode;
    }
}

此值对象实现Address没有标识,因此在Address类定义或ValueObject类定义中均未为其定义ID字段。

在 EF Core 2.0 之前,类中没有 ID 字段是不可能用于实体框架(EF)的。EF Core 2.0 极大地有助于实现没有 ID 的更好值对象。 这正是下一部分的解释。

也许有人会争辩说,由于值对象是不可变的,所以应该是只读的(即具有“只获取”属性),这是事实没错。 但是,值对象通常会被执行序列化和反序列化操作以遍历消息队列,并且由于是只读的,这阻止了反序列化器分配值,因此只需将其保留为 private set,且其只读程度让此机制成为可能。

值对象比较语义

可以使用以下所有方法比较类型 Address 的两个实例:

var one = new Address("1 Microsoft Way", "Redmond", "WA", "US", "98052");
var two = new Address("1 Microsoft Way", "Redmond", "WA", "US", "98052");

Console.WriteLine(EqualityComparer<Address>.Default.Equals(one, two)); // True
Console.WriteLine(object.Equals(one, two)); // True
Console.WriteLine(one.Equals(two)); // True
Console.WriteLine(one == two); // True

当所有值都相同时,比较结果正确为 true。 如果未选择重载 ==!= 运算符,则 one == two 的最后一次比较结果将评估为 false。 有关详细信息,请参阅 Overload ValueObject 相等运算符

如何使用 EF Core 2.0 及更高版本在数据库中保留值对象

你刚刚了解了如何在域模型中定义值对象。 但是,如何用 Entity Framework Core 将它持久保存到数据库中,因为它通常针对具有标识的实体?

背景和较早的方法(使用 EF Core 1.1)

作为背景,使用 EF Core 1.0 和 1.1 时的限制是,不能使用传统 .NET Framework 中 EF 6.x 中定义的 复杂类型 。 因此,如果使用 EF Core 1.0 或 1.1,则需要将值对象存储为具有 ID 字段的 EF 实体。 然后,它看起来更像是一个没有标识的值对象,因此可以隐藏其 ID,以便表明值对象的标识在域模型中并不重要。 可以通过将 ID 用作 阴影属性来隐藏该 ID。 由于用于在模型中隐藏 ID 的配置是在 EF 基础结构级别设置的,因此对于域模型而言,该配置是透明的。

在 eShopOnContainers (.NET Core 1.1) 的初始版本中,EF Core 基础结构所需的隐藏 ID 在 DbContext 级别上采用基础结构项目中的 Fluent API 通过以下方式来实现。 因此,ID 已从域模型的角度隐藏,但仍存在于基础结构中。

// Old approach with EF Core 1.1
// Fluent API within the OrderingContext:DbContext in the Infrastructure project
void ConfigureAddress(EntityTypeBuilder<Address> addressConfiguration)
{
    addressConfiguration.ToTable("address", DEFAULT_SCHEMA);

    addressConfiguration.Property<int>("Id")  // Id is a shadow property
        .IsRequired();
    addressConfiguration.HasKey("Id");   // Id is a shadow property
}

但是,将值对象持久保存在数据库中执行起来如同将常规实体持久保存在不同表中。

使用 EF Core 2.0 及更高版本,有一些新的更好的方法来持久保存值对象。

在 EF Core 2.0 及更高版本中将值对象保留为拥有的实体类型

即使 DDD 中的规范值对象模式与 EF Core 中拥有的实体类型之间存在一些差距,它目前也是使用 EF Core 2.0 及更高版本保存值对象的最佳方法。 可以在本部分末尾看到限制。

自 2.0 版以来,自有实体类型功能已添加到 EF Core。

固有实体类型允许在任何实体内映射具有以下特征的类型:用作属性且不具有在域模型中显式定义的自己的标识,如值对象。 拥有的实体类型与其他实体类型共享相同的 CLR 类型(也就是说,这只是常规类)。 包含定义导航的实体是所有者实体。 查询所有者时,固有类型将默认包含在内。

查看一下域模型,从属类型看上去似乎没有任何标识。 但是事实上,固有类型的确有标识,但所有者导航属性为此标识的一部分。

拥有类型的实例的标识并非完全属于他们自己。 它由三个组件组成:

  • 所有者的身份

  • 指向它们的导航属性

  • 对于固有类型的集合,一个独立的组成部分(在 EF Core 2.2 及更高版本中受支持)。

例如,在 eShopOnContainers 的 Ordering 域模型中,作为 Order 实体的一部分,Address 值对象作为所有者实体(即 Order 实体)中拥有的实体类型实现。 Address 是一种类型,在域模型中未定义标识属性。 它用作 Order 类型的属性来指定特定订单的发货地址。

依照约定,将为固有类型创建一个阴影主键,并通过表拆分将其映射到与所有者相同的表。 这允许使用自己的类型,类似于传统 .NET Framework 中的 EF6 中复杂类型的使用方式。

请务必注意,固有类型在 EF Core 中永远不会由约定发现,因此你必须显式声明它们。

在 eShopOnContainers 中的 OrderingContext.cs 文件内的 OnModelCreating() 方法中,应用了多个基础设施配置。 其中之一与 Order 实体相关。

// Part of the OrderingContext.cs class at the Ordering.Infrastructure project
//
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfiguration(new ClientRequestEntityTypeConfiguration());
    modelBuilder.ApplyConfiguration(new PaymentMethodEntityTypeConfiguration());
    modelBuilder.ApplyConfiguration(new OrderEntityTypeConfiguration());
    modelBuilder.ApplyConfiguration(new OrderItemEntityTypeConfiguration());
    //...Additional type configurations
}

在以下代码中,为 Order 实体定义了持久性基础结构:

// Part of the OrderEntityTypeConfiguration.cs class
//
public void Configure(EntityTypeBuilder<Order> orderConfiguration)
{
    orderConfiguration.ToTable("orders", OrderingContext.DEFAULT_SCHEMA);
    orderConfiguration.HasKey(o => o.Id);
    orderConfiguration.Ignore(b => b.DomainEvents);
    orderConfiguration.Property(o => o.Id)
        .ForSqlServerUseSequenceHiLo("orderseq", OrderingContext.DEFAULT_SCHEMA);

    //Address value object persisted as owned entity in EF Core 2.0
    orderConfiguration.OwnsOne(o => o.Address);

    orderConfiguration.Property<DateTime>("OrderDate").IsRequired();

    //...Additional validations, constraints and code...
    //...
}

在前面的代码中,orderConfiguration.OwnsOne(o => o.Address) 方法指定了 Address 属性是 Order 类型的固有实体。

默认情况下,EF Core 约定将所拥有实体类型的属性相关的数据库列命名为 EntityProperty_OwnedEntityProperty。 因此,Address 的内部属性将在 Orders 表中显示,名称包括 Address_StreetAddress_CityStateCountryZipCode

可以追加 Property().HasColumnName() fluent 方法以重命名这些列。 如果Address 是公共属性,则映射如下所示:

orderConfiguration.OwnsOne(p => p.Address)
                            .Property(p=>p.Street).HasColumnName("ShippingStreet");

orderConfiguration.OwnsOne(p => p.Address)
                            .Property(p=>p.City).HasColumnName("ShippingCity");

可以将 OwnsOne 方法链接到连贯性映射。 在以下假设示例中,OrderDetails 拥有 BillingAddressShippingAddress,它们都是 Address 类型。 然后 OrderDetailsOrder 类型所有。

orderConfiguration.OwnsOne(p => p.OrderDetails, cb =>
    {
        cb.OwnsOne(c => c.BillingAddress);
        cb.OwnsOne(c => c.ShippingAddress);
    });
//...
//...
public class Order
{
    public int Id { get; set; }
    public OrderDetails OrderDetails { get; set; }
}

public class OrderDetails
{
    public Address BillingAddress { get; set; }
    public Address ShippingAddress { get; set; }
}

public class Address
{
    public string Street { get; set; }
    public string City { get; set; }
}

有关拥有实体类型的其他详细信息

  • 使用OwnsOne fluent API将导航属性配置为特定类型时,会定义所属类型。

  • 在我们的元数据模型中,拥有类型的定义是由以下元素组成的组合:拥有者类型、导航属性,以及拥有类型的 CLR 类型。

  • 我们堆栈中固有类型实例的标识(键)即为所有者类型标识和固有类型定义的组合。

固有实体功能

  • 固有类型可以引用其他实体,固有(嵌套固有类型)或非固有(其他实体的常规引用导航属性)均可。

  • 可以通过单独的导航属性将同一 CLR 类型映射到同一所有者实体中的不同拥有类型。

  • 表拆分按约定进行设置,但可以通过使用 ToTable 将拥有的类型映射到其他表来选择退出。

  • 立即加载对于固有类型自动执行,即无需对查询调用 .Include()

  • 使用 EF Core 2.1 及更高版本可以通过属性 [Owned] 进行配置。

  • 能够处理自有类型集合(使用版本 2.2 及更高版本)。

固有实体限制

  • 不能创建固有类型的 DbSet<T>(按照设计)。

  • 不能对固有类型调用 ModelBuilder.Entity<T>()(目前按照设计)。

  • 不支持使用同一表格中所有者映射的可选(即为 null)固有类型(即使用表格拆分)。 这是因为对每个属性都进行了映射,因此总体而言,对于 null 复杂值,没有单独的 sentinel。

  • 没有对固有类型的继承映射支持,但应能够以不同固有类型的形式映射同一继承层次结构的两个叶类型。 EF Core 不会推理出它们属于同一个层次结构。

与 EF6 的复杂类型的主要差异

  • 表拆分是可选的,即可以根据需要将它们映射到单独的表并仍属固有类型。

其他资源