如先前各節所討論的實體與聚合,身份對於實體而言是至關重要的。 不過,系統中有許多對象和數據項不需要身分識別和身分識別追蹤,例如值物件。
值物件可以參考其他實體。 例如,在生成路由的應用程式中,該路由描述如何從一個點到達另一個點,並作為值物件。 這將會是特定路線上的點的快照,但建議的路線將不具獨特性,即使內部可能會參考像城市、道路等實體。
圖 7-13 顯示 Order 匯總內的 Address 值對象。
圖 7-13。 Order 匯總內的位址值物件
如圖 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
類別定義中,都沒有為它定義標識符字段。
在 EF Core 2.0 之前,無法在 Entity Framework (EF) 使用的類別中沒有標識符字段,這可大幅協助實作沒有標識符的較佳值物件。 這正是下一節的說明。
可以說,由於價值物件不可變,因此它們應該是只讀的(即,具有僅讀屬性),這確實是真的。 不過,值物件通常會被序列化和反序列化以透過消息佇列傳遞,而只讀限制會使解序列化器無法指派值,因此您只需將它們保留為 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
。 如需詳細資訊,請參閱 多載 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,您必須將值物件儲存為具有標識符欄位的 EF 實體。 然後,它看起來更像是沒有身分識別的值物件,所以您可以隱藏其標識碼,以便清楚指出值物件的身分識別在定義域模型中並不重要。 您可以使用識別碼做為 陰影屬性來隱藏該識別碼。 由於隱藏模型中標識符的設定是在 EF 基礎結構層級中設定的,所以網域模型會是透明的。
在 eShopOnContainers (.NET Core 1.1) 的初始版本中,EF Core 基礎結構所需的隱藏標識符是以下列方式在 DbContext 層級實作,並在基礎結構專案中使用 Fluent API。 因此,標識碼已從領域模型的觀點隱藏,但仍存在於基礎結構中。
// 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
是一種類型,沒有定義於定義域模型中的識別屬性。 它用做訂單類型的屬性,指定特定訂單的送貨地址。
依照慣例,會針對被擁有的類型建立隱藏主鍵,並且會使用資料表拆分對應至與擁有者相同的資料表。 這可讓您使用自有的類型,類似於傳統 .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_Street
(以及Address_City
、State
和Country
)出現在ZipCode
資料表中。
您可以附加 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
擁有 BillingAddress
和 ShippingAddress
,它們都是屬於 Address
類型。 然後 OrderDetails
類型擁有 Order
。
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 複雜值沒有單獨的指標。
自有類型沒有提供繼承對應的支援,但您應該能夠將兩個相同繼承階層的分葉類型對應為不同的自有類型。 EF Core 不會因為它們是相同階層的一部分而有理由的。
與 EF6 複雜類型的主要差異
- 數據表分割是選擇性的,也就是說,它們可以選擇性地對應至個別的數據表,而且仍然擁有類型。
其他資源
馬丁·福勒 ValueObject 模式
https://martinfowler.com/bliki/ValueObject.html埃裡克·埃文斯 Domain-Driven 設計:解決軟體核心的複雜性。 (書:包括價值對象的討論)
https://www.amazon.com/Domain-Driven-Design-Tackling-Complexity-Software/dp/0321125215/Vaughn Vernon: 實作 Domain-Driven 設計。 (書:包括價值對象的討論)
https://www.amazon.com/Implementing-Domain-Driven-Design-Vaughn-Vernon/dp/0321834577/擁有的實體類型
https://learn.microsoft.com/ef/core/modeling/owned-entities陰影屬性
https://learn.microsoft.com/ef/core/modeling/shadow-properties複雜類型和/或值物件。 EF Core GitHub 存放庫中的討論(問題標籤)
https://github.com/dotnet/efcore/issues/246ValueObject.cs。 eShopOnContainers 中的基底值對象類別。
https://github.com/dotnet-architecture/eShopOnContainers/blob/dev/src/Services/Ordering/Ordering.Domain/SeedWork/ValueObject.csValueObject.cs。 CSharpFunctionalExtensions 中的基底值物件類別。
https://github.com/vkhorikov/CSharpFunctionalExtensions/blob/master/CSharpFunctionalExtensions/ValueObject/ValueObject.cs地址類別。 eShopOnContainers 中的範例值對象類別。
https://github.com/dotnet-architecture/eShopOnContainers/blob/dev/src/Services/Ordering/Ordering.Domain/AggregatesModel/OrderAggregate/Address.cs