[AbpvNext源码分析]-5.DDD的领域层⽀持(仓储、实体、值对象)
⼀、简要介绍
ABP vNext 框架本⾝就是围绕着 DDD 理念进⾏设计的,所以在 DDD ⾥⾯我们能够见到的实体、仓储、值对象、领域服务,ABP vNext 框架都为我们进⾏了实现,这些基础设施都存放在 Volo.Abp.Ddd.Domain 项⽬当中。
本篇⽂章将会侧重于理论讲解,但也只是⼀个抛砖引⽟的作⽤,关于 DDD 相关的知识可以阅读 Eric Evans 所编写的 《领域驱动设计:软件核⼼复杂性应对之道》。
PS:
该书也是⽬前我正在阅读的 DDD 理论书籍,因为基于 DDD 理论,我们能够精准地划分微服务的业务边界,为后续微服务架构的可扩展性提供坚实的基础。
⼆、源码分析
Volo.Abp.Ddd.Domain 分为 Volo 和 Microsoft 两个⽂件夹,在 Microsoft ⽂件夹当中主要是针对仓储和实体进⾏⾃动注⼊。
2.1 实体 (Entity)
2.1.1 基本概念
只要⽤过 EF Core 框架的⼈,基本都知道什么是实体。不过很多⼈就跟我⼀样,只是将实体作为数据库表在 C# 语⾔当中的另⼀种展现⽅式,认为它跟普通的对象没什么不⼀样。
PS:虽然每个对象都会有⼀个内在的对象引⽤指针来作为唯⼀标识。
在 DDD 的概念当中,通过标识定义的对象被称为实体(Entity)。虽然它们的属性可能因为不同的操作⽽被改变(多种⽣命周期),但必须保证⼀种内在的连续性。为了保证这种内在的连续性,就需要⼀个有意义并且唯⼀的属性。
标识是否重要则完全取决于它是否有⽤,例如有个演唱会订票程序,你可以将座位与观众都当作⼀个实体处理。那么在分配座位时,每个座位肯定都会有⼀个唯⼀的座位号(唯⼀标识),可也能拥有其他描述属性(是否是 VIP 座位、价格等...)。
那么座位是否需要唯⼀标识,是否为⼀个实体,就取决于不同的⼊场⽅式。假如说是⼀⼈⼀票制,并且每张门票上⾯都有固定的座位号,这个时候座位就是⼀个实体,因为它需要座位号来区分不同的座位。
另⼀种⽅式就是⼊场卷⽅式,门票上没有座位号,你想坐哪⼉就坐哪⼉。这个时候座位号就不需要与门票建⽴关联,在这种情况下座位就不是⼀个实体,所以不需要唯⼀标识。
* 上述例⼦与描述改编⾃《领域驱动设计:软件核⼼复杂性应对之道》的 ENTITY ⼀节。
2.1.2 如何实现
了解了 DDD 概念⾥⾯的实体描述之后,我们就来看⼀下 ABP vNext 为我们准备了怎样的基础设施。
⾸先看 Entities ⽂件夹下关于实体的基础定义,在实体的基础定义类⾥⾯,为每个实体定义了唯⼀标识。并且在某些情况下,我们需要确保 ID 在多个计算机系统之间具有唯⼀性。尤其是在多个系统/平台进⾏对接的时候,如果每个系统针对于 “张三” 这个⽤户的 ID 不是⼀致的,都是⾃⼰⽣成 ID ,那么就需要介⼊⼀个新的抽象层进⾏关系映射。
在 IEntity<TKey> 的默认实现 Entity<TKey> 中,不仅提供了标识定义,也重写了 Equals() ⽐较⽅法和 == \ != 操作符,⽤于区别不同实体。它为对象统⼀定义了⼀个 TKey 属性,该属性将会作为实体的唯⼀标识字段。
public override bool Equals(object obj)
{
// ⽐较的对象为 NULL 或者对象不是派⽣⾃ Entity<T> 都视为不相等。
if (obj == null || !(obj is Entity<TKey>))
{
return false;
}
// ⽐较的对象与当前对象属于同⼀个引⽤,视为相等的。
if (ReferenceEquals(this, obj))
{
return true;
}
// 当前⽐较主要适⽤于 EF Core,如果任意对象是使⽤的默认 Id,即临时对象,则其默认 ID 都为负数,视为不相等。
var other = (Entity<TKey>)obj;
if (EntityHelper.HasDefaultId(this) && EntityHelper.HasDefaultId(other))
{
return false;
}
// 主要判断当前对象与⽐较对象的类型信息,看他们两个是否属于 IS-A 关系,如果不是,则视为不相等。
var typeOfThis = GetType().GetTypeInfo();
var typeOfOther = other.GetType().GetTypeInfo();
if (!typeOfThis.IsAssignableFrom(typeOfOther) && !typeOfOther.IsAssignableFrom(typeOfThis))
{
return false;
}
// 如果两个实体他们的租户 Id 不同,也视为不相等。
if (this is IMultiTenant && other is IMultiTenant &&
this.As<IMultiTenant>().TenantId != other.As<IMultiTenant>().TenantId)
{
return false;
}
// 通过泛型的 Equals ⽅法进⾏最后的⽐较。
return Id.Equals(other.Id);
}
实体本⾝是⽀持序列化的,所以特别标注了 [Serializable] 特性。
[Serializable]
public abstract class Entity<TKey> : Entity, IEntity<TKey>
{
// ... 其他代码。
}
针对于某些实体可能是 复合主键 的情况,ABP vNext 则推荐使⽤ IEntity 和 Entity 进⾏处理。
/// <summary>
/// 定义⼀个实体,但它的主键可能不是 “Id”,也有可能是否复合主键。
/// 开发⼈员应该尽可能使⽤ <see cref="IEntity{TKey}"/> 来定义实体,以便更好的与其他框架/结构进⾏集成。
/// </summary>
public interface IEntity
{
/// <summary>
/// 返回当前实体的标识数组。
/// </summary>
object[] GetKeys();
}
2.2 ⾃动审计
在 Entities ⽂件夹⾥⾯,还有⼀个 Auditing ⽂件夹。在这个⽂件夹⾥⾯定义了很多对象,我们最为常⽤的就是 FullAuditiedEntity 对象了。从字⾯意思来看,它是⼀个包含了所有审计属性的实体。
[Serializable]
public abstract class FullAuditedEntity<TKey> : AuditedEntity<TKey>, IFullAuditedObject
{
// 软删除标记,为 true 时说明实体已经被删除,反之亦然。
public virtual bool IsDeleted { get; set; }
// 删除实体的⽤户 Id。
public virtual Guid? DeleterId { get; set; }
// 实体被删除的时间。
public virtual DateTime? DeletionTime { get; set; }
}
那么,什么是审计属性呢?在 ABP vNext 内部将以下属性定义为审计属性:创建⼈、创建时间、修改⼈、修改时间、删除⼈、删除时间、软删除标记。这些属性不需要开发⼈员⼿动去书写/控制,ABP vNext 框架将会⾃动跟踪这些属性并设置其值。
开发⼈员除了可以直接继承 FullAuditedEntity 以外,也可以考虑集成其他的审计实例,例如只包含创建⼈与创建时间的 CreationAuditedEntity。如果你觉得你只想要创建⼈、软删除标记、修改时间的话,也可以直接继承相应的接⼝。
public class TestEntity : Entity<int>,IMayHaveCreator,ISoftDelete,IHasModificationTime
{
/// <summary>
/// 创建⼈的 Id。
/// </summary>
public Guid? CreatorId { get; set; }
/// <summary>
/// 软删除标记。
/// </summary>
public bool IsDeleted { get; set; }
/// <summary>
/
// 最后的修改时间。
/// </summary>
public DateTime? LastModificationTime { get; set; }
}
这⾥我只重点提⼀下关于审计实体相关的内容,对于聚合的根对象的审计实体,内容也是相似的,就不再赘述。
2.3 值对象 (ValueObject)
2.3.1 基本概念
DDD 关于值对象某⼀个概念来说,每个值对象都是单⼀的副本,这个概念你可以类⽐ C# ⾥⾯关于值对象和引⽤对象的区别。
值对象与实体最⼤的区别就在于,值对象是没有概念标识的,还有⽐较重要的⼀点就是值对象是不可变的,所谓的不可变,就是值对象产⽣任何变化应该直接替换掉原有副本,⽽不是在原有副本上进⾏修改。如果值对象是可变的,那么它⼀定不能被共享。值对象可以引⽤实体或者其他的值对象。
这⾥仍然以书中的例⼦进⾏说明值对象的标识问题,例如 “地址” 这个概念。
如果我在淘宝买了⼀个键盘,我的室友也从淘宝买了同款键盘。对于淘宝系统来说,我们两个是否处于同⼀个地址并不重要,所以这⾥ “地址” 就是⼀个值对象。因为系统不需要关⼼两个地址的唯⼀标识是否⼀致,在业务上来说也没有这个需要。
另⼀个情况就是家⾥停电了,我和我的室友同时在电⼒服务系统提交了⼯单。这个时候对于电⼒系统来说,如果两个⼯单的地址是在同⼀个地⽅,那么只需要派⼀个⼈去进⾏维修即可。这种情况下,地址就是⼀个实体,因为地址涉及到⽐较,⽽⽐较的依据则是地址的唯⼀标识。
上述情况还有的另⼀种实现⽅式,即我们将住处抽象为⼀个实体,电⼒系统与住处进⾏关联。住处⾥⾯包含地址,这个时候地址就是⼀个值对象。因为这个时候电⼒系统关⼼的是住处是否⼀致,⽽地址则作为⼀个普通的属性⽽已。
关于值对象的另⼀个⽤法则更加通俗,例如⼀个 Person 类,他原来的定义是拥有⼀个 Id、姓名、街道、社区、城市。那么我们可以将街道、社区、城市抽象到⼀个值对象 Address 类⾥⾯,每个值对象内部包含的属性应该形成⼀个概念上的整体。
2.3.2 如何实现
ABP vNext 对于值对象的实现是⽐较粗糙的,他仅参考 MSDN 定义了⼀个简单的 ValueObject 类型,具体的⽤法开发⼈员可以参考 实现值对象的细节,下⽂仅是摘抄部分内容进⾏简要描述。
MSDN 也是以地址为例,他将 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; }
private 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> GetAtomicValues()
{
// 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;
}
}
不过我们知道,如果⼀个值对象需要持久化到数据库,没有 Id 标识咋办?MSDN 上⾯也说明了在 EF Core 1.1 和 EF Core 2.0 的处理⽅法,这⾥我们只着重说明 EF Core 2.0 的处理⽅法。
EF Core 2.0 可以使⽤ owned entity(固有实体类型) 来实现值对象,固有实体的以下特征可以帮助我们实现值对象。
固有对象可以⽤作属性,并且没有⾃⼰的标识。
在查询所有实体时,固有实体将会包含进去。例如我查询订单 A,那么就会将地址这个值对象包含到
订单 A 的结果当中。
但⼀个类型不管怎样都是会拥有它⾃⼰的标识的,这⾥不再详细叙述,更加详细的可以参考 MSDN 英⽂原版说明。(中⽂版翻译有问题)
The identity of the owner
The navigation property pointing to them
In the case of collections of owned types, an independent component (not yet supported in EF Core 2.0, coming up on 2.2).
typeof的用法EF Core 不会⾃动发现固有实体类型,需要显⽰声明,这⾥以 MSDN 官⽅的 eShopOnContainers DEMO 为例。
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
}
接着我们来到 OrderEntityTypeConfiguration 类型的 Configure() ⽅法中。
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 属性是 Order 类型的固有实体。
orderConfiguration.OwnsOne(o => o.Address);
orderConfiguration.Property<DateTime>("OrderDate").IsRequired();
//...Additional validations, constraints
//...
}
默认情况下,EF Core 会将固有实体的数据库列名,以 <;实体的属性名>_<;固有实体的属性>。以上⾯的 Address 类型字段为例,将会⽣成 Address_Street 、Address_City 这样的名称。你也可以通过流畅接⼝来重命名这些列,代码如下:
orderConfiguration.OwnsOne(p => p.Address)
.Property(p=>p.Street).HasColumnName("ShippingStreet");
orderConfiguration.OwnsOne(p => p.Address)
.Property(p=>p.City).HasColumnName("ShippingCity");
2.4 聚合
如果说实体的概念还⽐较好理解的话,那么聚合则是在实体之上新的抽象。聚合就是⼀组相关对象的集合,他会有⼀个根对象(root),和它的⼀个边界(boundary)。对于聚合外部来说,只能够引⽤它的根对象,⽽在聚合内部的其他对象则可以相互引⽤。
⼀个简单的例⼦(《领域驱动设计》)来说,汽车是⼀个具有全局标识的实体,每⼀辆汽车都拥有⾃⼰唯⼀的标识。在某些时候,我们可能会需要知道轮胎的磨损情况与公⾥数,因为汽车有四个轮胎,所以我们也需要将轮胎视为实体,为其分配唯⼀本地的标识,这个标识是聚合内唯⼀的。但是在脱离了汽车这个边界之后,我们就不需要关⼼这些轮胎的标识。
所以在上述例⼦当中,汽车是⼀个聚合的根实体,⽽轮胎处于这个聚合的边界之内。
那么⼀个聚合应该怎样进⾏设计呢?这⾥我引⽤汤雪华⼤神的 和 说明⼀下聚合根要怎么设计才合理。
聚合的⼏⼤设计原则:
聚合是⽤来封装不变性(即固定规则),⽽不是将领域对象简单组合到⼀起。
聚合应该尽量设计成⼩聚合。
聚合与聚合之间的关系应该通过 Id 进⾏引⽤。
聚合内部应该是强⼀致性(同⼀事务),聚合之间只需要追求最终⼀致性即可。
以上内容我们还是以经典的订单系统来举例⼦,说明我们的实体与聚合应该怎样进⾏划分。我们有⼀个订单系统,其结构如下图:
其中有⼀个固定规则,就是采购项(Line Item)的总量不能够超过 PO 总额(approved limit)的限制,这⾥的 Part 是具体采购的部件(产品),它拥有⼀个 price 属性作为它的⾦额。从上述业务场景我们就可以得出以下问题:
固定规则的实施,即添加新的采购项时,PO 需要检查总额,如果超出限制视为⽆效。
当 PO 被删除或者存档时,采购项也应该⼀并处理。(同⽣共死原则)
多⽤户的竞争问题,如果在采购过程中,采购项与部件都被⽤户修改,会产⽣问题。
场景 1:
当⽤户编辑任何⼀个对象时,锁定该对象,直到编辑完成提交事务。这样就会造成 George 编辑订单 #0001 的采购项 001 时,Amanda ⽆法修改该采购项。但是 Amanda 可以修改其他的采购项,这样最后提交的时候就会导致 #0001 订单破坏了固定规则。
场景 2:
如果锁定单⾏对象不⾏,那么我们直接锁定 PO 对象,并且为了防⽌ Part 的价格被修改,Part 对象也需要被锁定。这样就会造成太多的数据争⽤,现在 3 个⼈都需要等待。
从上述场景来看,我们可以得出以下结论:
Part 在很多 PO 当中被使⽤。
对 Part 的修改少于对 PO 的修改。
PO 与采购项不能分开,后者独⽴存在没有意义。
对 Part 的价格修改不⼀定要实时传播给 PO,仅取决于修改价格时 PO 处于什么状态。
有以上结论可以知道,我们可以将 Part 的价格冗余到采购项,PO 和采购项的创建与删除是很⾃然的业务规则,⽽ Part 的创建与删除是独⽴的,所以将 PO 与采购项能划为⼀个聚合。
Abp vNext 框架也为我们提供了聚合的定义与具体实现,即 AggregateRoot 类型。该类型也继承⾃ Entity 类型,并且内部提供了⼀个并发令牌防⽌并发冲突。
并且在其内部也提供了领域事件的快速增删⽅法,其他的与常规实体基本⼀致。通过领域事件,我们可以完成对事务的拆分。例如上述的例⼦当中,我们也可以为 Part 增加⼀个领域事件,当价格被更新时,PO 可以订阅这个事件,实现对应的采购项更新。
只是这⾥你会奇怪,增加的事件到哪⼉去了呢?他们这些事件最终会被添加到 EntityChangeReport 类型的 DomainEvents 集合⾥⾯,并且在实体变更时进⾏触发。
关于聚合的 ,在 ABP vNext 官⽹已经有⼗分详细的描述,这⾥我贴上代码供⼤家理解以下,官⽅的例⼦仍然是以订单和采购项来说的。
public class Order : AggregateRoot<Guid>
{
public virtual string ReferenceNo { get; protected set; }
public virtual int TotalItemCount { get; protected set; }
public virtual DateTime CreationTime { get; protected set; }
public virtual List<OrderLine> OrderLines { get; protected set; }
protected Order()
{
}
public Order(Guid id, string referenceNo)
{
Check.NotNull(referenceNo, nameof(referenceNo));
Id = id;
ReferenceNo = referenceNo;
OrderLines = new List<OrderLine>();
}
public void AddProduct(Guid productId, int count)
{
if (count <= 0)
{
throw new ArgumentException(
"You can not add zero or negative count of products!",
nameof(count)
);
}
var existingLine = OrderLines.FirstOrDefault(ol => ol.ProductId == productId);
if (existingLine == null)
{
OrderLines.Add(new OrderLine(this.Id, productId, count));
}
else
{
existingLine.ChangeCount(existingLine.Count + count);
}
TotalItemCount += count;
}
}
public class OrderLine : Entity
{
public virtual Guid OrderId { get; protected set; }
public virtual Guid ProductId { get; protected set; }

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。