系列文章目录
一、简单的CQRS实现与原始SQL和DDD
二、使用EF的领域模型的封装和持久化透明(PI)
三、REST API数据验证
四、领域模型验证
五、如何发布和处理领域事件
六、处理领域事件:缺失的部分
七、发件箱模式(The Outbox Pattern)
八、.NET Core中的旁路缓存模式(Cache-Aside Pattern)
引言
在之前的文章中,我介绍了如何使用原始SQL(读模型)和领域驱动设计(写模型)实现简单的CQRS模式。我想继续介绍主要集中在DDD实施的例子。在这篇文章中,我将描述如何尽可能多地利用最新版本的Entity Framework 2.2(译者注:现在已不是最新版本)来支持纯领域建模。
我决定在GitHub上不断开发我的例子。我将尝试逐步添加新的功能和技术解决方案。我还将尝试扩展领域,使应用程序与实际应用程序相似。在琐碎的领域中很难解释DDD的某些方面。不过,我强烈建议您关注(follow)我的代码库。
目标
当我们创建领域模型时,必须考虑很多事情。在这一点上,我想聚焦于其中的两个:封装(Encapsulation)和持久化透明(Persistence Ignorance)。
封装(Encapsulation)
封装有两个主要的定义(来源-维基百科):
一种语言机制,用于限制直接访问对象的某些组件
以及
一种便于将数据与操作该数据的方法(或其他函数)绑定的语言结构
这对DDD聚合意味着什么?这仅仅意味着我们应该对外部世界隐藏我们聚合的所有内部信息。理想情况下,我们应该只公开满足业务需求所需的公共方法。
设想如下:
持久化透明(Persistence Ignorance)
持久化透明(Persistence Ignorance, PI)原则认为,领域模型应该不知道它的数据是如何保存或检索的。这是值得遵循的非常好的和重要的建议。然而,我们应该谨慎地遵循这个建议。我同意微软文档中的观点:
即使领域模型遵循PI原则非常重要,也不应该忽略持久化(persistence)问题。理解物理数据模型以及它如何映射到实体对象模型仍然非常重要。否则你就会创造出不可能的设计。
如上所述,很不幸,我们不能忘记持久化。尽管如此,我们应该尽可能地将领域模型与系统的其他部分分离。
示例领域
为了更好地理解所创建的领域模型,我准备了下面的图:
它是简单的电子商务领域。客户(Customer)可以下一个或多个订单(Orders)。订单是一组具有数量信息的产品 (ProductOrder)。每个产品(Product)都根据货币(Currency)定义了许多价格(ProductPrice)。
好了,我们知道了问题所在,现在我们可以开始转到解决方案部分了…
解决方案
1. 创建支持架构
首先,也是最重要的事情是创建应用程序架构,它同时支持领域模型的封装和持久化透明。最常见的例子有:
- 整洁架构(Clean Architecture)
- 洋葱架构(Onion Architecture)
- 端口和适配器/六边形架构(Ports And Adapters / Hexagonal Architecture)
所有这些体系结构在生产系统中都很好。对我来说,整洁架构和洋葱架构几乎是一样的。端口和适配器/六边形架构在命名方面略有不同,但一般原则是相同的。
在领域建模上下文中最重要的事情是,每个架构业务逻辑/业务层/实体/领域层 ①位于中心,②不依赖于其他组件/层/模块。在我的例子中也是一样的:
这在实践中对我们在领域模型中的代码意味着什么?
- 没有数据访问代码。
- 我们的实体没有数据标记(annotations)。
- 不继承任何框架类,实体应该是普通旧CLR对象(Plain Old CLR Object)
2. 只在基础设施层使用实体框架
任何与数据库的交互都应该在基础设施层实现。这意味着你必须添加实体框架上下文、实体映射和存储库的实现。领域模型中只能保存存储库的接口。
3. 使用影子属性(Shadow Properties)
影子属性是将实体与数据库模式解耦的好方法。它们是仅在EF模型中定义的属性。使用它们,我们通常不需要在领域模型中包含外键,这是一件很棒的事情。
让我们看看订单(Order)实体及其在CustomerEntityTypeConfiguration映射中定义的映射:
public class Order : Entity
{
internal Guid Id;
private bool _isRemoved;
private MoneyValue _value;
private List<OrderProduct> _orderProducts;
private Order()
{
this._orderProducts = new List<OrderProduct>();
this._isRemoved = false;
}
public Order(List<OrderProduct> orderProducts)
{
this.Id = Guid.NewGuid();
this._orderProducts = orderProducts;
this.CalculateOrderValue();
}
internal void Change(List<OrderProduct> orderProducts)
{
foreach (var orderProduct in orderProducts)
{
var existingOrderProduct = this._orderProducts.SingleOrDefault(x => x.Product == orderProduct.Product);
if (existingOrderProduct != null)
{
existingOrderProduct.ChangeQuantity(orderProduct.Quantity);
}
else
{
this._orderProducts.Add(orderProduct);
}
}
var existingProducts = this._orderProducts.ToList();
foreach (var existingProduct in existingProducts)
{
var product = orderProducts.SingleOrDefault(x => x.Product == existingProduct.Product);
if (product == null)
{
this._orderProducts.Remove(existingProduct);
}
}
this.CalculateOrderValue();
}
internal void Remove()
{
this._isRemoved = true;
}
private void CalculateOrderValue()
{
var value = this._orderProducts.Sum(x => x.Value.Value);
this._value = new MoneyValue(value, this._orderProducts.First().Value.Currency);
}
}
internal class CustomerEntityTypeConfiguration : IEntityTypeConfiguration<Customer>
{
internal const string OrdersList = "_orders";
internal const string OrderProducts = "_orderProducts";
public void Configure(EntityTypeBuilder<Customer> builder)
{
builder.ToTable("Customers", SchemaNames.Orders);
builder.HasKey(b => b.Id);
builder.OwnsMany<Order>(OrdersList, x =>
{
x.ToTable("Orders", SchemaNames.Orders);
x.HasForeignKey("CustomerId"); // Shadow property
x.Property<bool>("_isRemoved").HasColumnName("IsRemoved");
x.Property<Guid>("Id");
x.HasKey("Id");
x.OwnsMany<OrderProduct>(OrderProducts, y =>
{
y.ToTable("OrderProducts", SchemaNames.Orders);
y.Property<Guid>("OrderId"); // Shadow property
y.Property<Guid>("ProductId"); // Shadow property
y.HasForeignKey("OrderId");
y.HasKey("OrderId", "ProductId");
y.HasOne(p => p.Product);
y.OwnsOne<MoneyValue>("Value", mv =>
{
mv.Property(p => p.Currency).HasColumnName("Currency");
mv.Property(p => p.Value).HasColumnName("Value");
});
});
x.OwnsOne<MoneyValue>("_value", y =>
{
y.Property(p => p.Currency).HasColumnName("Currency");
y.Property(p => p.Value).HasColumnName("Value");
});
});
}
}
正如您在第15行所看到的,我们正在定义Order实体中不存在的属性。它仅为Customer和Order之间的关系配置而定义。Order和ProductOrder关系也是如此.
4. 使用自有实体类型(Owned Entity Types)
使用自有实体类型,我们可以创建更好的封装,因为我们可以直接映射到私有或内部字段:
public class Order : Entity
{
internal Guid Id;
private bool _isRemoved;
private MoneyValue _value;
private List<OrderProduct> _orderProducts;
private Order()
{
this._orderProducts = new List<OrderProduct>();
this._isRemoved = false;
}
x.OwnsMany<OrderProduct>(OrderProducts, y =>
{
y.ToTable("OrderProducts", SchemaNames.Orders);
y.Property<Guid>("OrderId"); // Shadow property
y.Property<Guid>("ProductId"); // Shadow property
y.HasForeignKey("OrderId");
y.HasKey("OrderId", "ProductId");
y.HasOne(p => p.Product);
y.OwnsOne<MoneyValue>("Value", mv =>
{
mv.Property(p => p.Currency).HasColumnName("Currency");
mv.Property(p => p.Value).HasColumnName("Value");
});
});
x.OwnsOne<MoneyValue>("_value", y =>
{
y.Property(p => p.Currency).HasColumnName("Currency");
y.Property(p => p.Value).HasColumnName("Value");
});
自由类型也是创建值对象的很好的解决方案。这是MoneyValue的样子:
public class MoneyValue
{
public decimal Value { get; }
public string Currency { get; }
public MoneyValue(decimal value, string currency)
{
this.Value = value;
this.Currency = currency;
}
}
5. 映射到私有字段
我们不仅可以使用EF拥有的类型映射到私有字段,还可以映射到内置类型。我们所要做的就是给出字段和列的名称:
x.Property<bool>("_isRemoved").HasColumnName("IsRemoved");
6. 使用值转换
值转换是实体属性和表列值之间的“桥梁”。如果类型之间不兼容,我们应该使用它们。EF有很多开箱即用的值转换器。此外,如果我们需要,我们可以实现自定义转换器。
public enum OrderStatus
{
Placed = 0,
InRealization = 1,
Canceled = 2,
Delivered = 3,
Sent = 4,
WaitingForPayment = 5
}
x.Property("_status").HasColumnName("StatusId").HasConversion(new EnumToNumberConverter<OrderStatus, byte>());
这个转换器简单地将“StatusId”列字节类型转换为OrderStatus类型的私有字段_status。
总结
在这篇文章中,我简要地描述了什么是封装和持久化透明(在领域建模的上下文中),以及我们如何通过以下方式实现这些方法:
- 创建支持架构
- 将所有数据访问代码放在领域模型实现之外
- 使用EF功能:影子属性,自有实体类型,私有字段映射,值转换