前面的话
我接触DDD大概有一年的时间了,期间断断续续的学习,也算有了一点了解,然而距离熟练掌握还有很远的距离,所以现在对DDD的知识要尽量多的汲取、吸收,更多的应用在项目中,方能更好的掌握。
我要翻译的这篇有关DDD的文章是《Implementing Domain-Driven Design》一书的作者Vaughn Vernon,这篇文章的标题是Modeling Aggregates with DDD and Entity Framework,Vaughn Vernon在写这篇文章之前写有《Effective Aggregate Design》的文档,内容比较多,翻译这篇文章的时候我只看了第一部分,待我完全理解之后会尝试着翻译。这篇文章也是作者对《Effective Aggregate Design》的实现,虽不是完整的代码,但已经告诉了具体如何实现。
以下是翻译内容。
聚合的定义
开始之前,先让我们回顾一下DDD聚合的基本定义。首先,聚合模式是关于事务的一致性。在提交的数据库事务结束时,单个聚合应该完全更新。这意味着任何业务规则必须满足数据一致性并且持久性存储应持有一致的状态,正确的离开聚合并准备被下一个用例使用。图1显示了两个这样的一致性边界,使用了两个不同的聚合。
图1. 两个聚合,表示两个事务的一致性边界
许多设计聚合的问题是他们不考虑真正的业务约束——要求数据的事务一致性,相反按照如图2所示设计聚合。如果你希望它们被成千上万的用户使用,有良好的性能并且能扩展到互联网的需求,那么这种方式设计聚合是非常错误的。
图2. 没有根据真正的业务一致性约束设计欠佳的聚合
使用《Implementing Domain-Driven Design》一书的例子,一组设计良好的聚合应该如图3所示。这些都是基于真正的业务规则的——要求特定的数据在一个成功的数据库事务提交结束被更新。这些都遵循这里列举的聚合规则,包括设计小的聚合。
图3. 一些遵循真正的一致性规则,设计良好的聚合
尽管如此,还是有问题出现,加入BacklogItem和Product有一些数据依赖关系,我们应该如何更新它们两个。这指向另一个聚合设计规则,使用最终一致性,如图4所示。当然,当你考虑整体的架构时,还会涉及更多的问题,但是,上述指出了聚合设计的指导原则。
图4. 两个或多个聚合在更新上有一些依赖关系时,使用最终一致性
简单回顾了聚合设计的基础知识,现在让我们看看如何使用Entity Framework将Product映射到数据库。
亲吻Entity Framework
在我们的Scrum项目管理程序中有4个聚合:Product,Backlog,Release以及Sprint。我们需要持久这4个小聚合的状态并且我们想使用Entity Framework来做到这一点。我不建议你一定要成为一个Entity Framework大师,恰恰相反,只要允许Entity Framework开发团队是大师就行了,你只需要专注于你具体的应用。毕竟,你要把创造力放到你的核心领域上,而不是成为Entity Framework专家。我的建议是允许Entity Framework控制它最擅长的,而我们只需要置身事外就可以了。Entity Framework具有映射实体到数据的特定方式,这就是它的工作原理。你只需要掌握Entity Framework的一些基础知识,如果你非要走极端去探索Entity Framework深奥的映射技术,你一定会经历很大的痛苦。
我们打算使用两种方式实现Product聚合。一种方式是使用分离的接口和实现类,另一种方式是使用状态对象支持的领域对象。
使用分离接口和实现类
对于第一个示例,我创建了一个分离的接口,它由一个具体的领域对象实现。图5显示了这种方法的基本意图。
图5. 名为IProduct的分离接口由一个具体的领域对象实现。客户端直接使用IProduct
C#和.NET习惯给接口加上“I”的前缀,所以我们使用IProduct:
interface IProduct { ICollection<IBacklogItem> AllBacklogItems(); IProductBacklogItem BacklogItem(BacklogItemId backlogItemId); string Description { get; } string Name { get; } IBacklogItem PlanBacklogItem(BacklogItemId newBacklogItemId, string summary, string story, string category, BacklogItemType type, StoryPoints storyPoints); void PlannedProductBacklogItem(IBacklogItem backlogItem); ... ProductId ProductId { get; } ProductOwnerId ProductOwnerId { get; } void ReorderFrom(BacklogItemId id, int ordering); TenantId TenantId { get; } }
针对接口,我们可以创建一个具体的实现类。我们称之为Product:
public class Product : IProduct { [Key] public string ProductKey { get; set; } ... }
具体类Product实现了IProduct声明的业务接口,同时提供Entity Framework映射对象到数据库需要的accessor。注意ProductKey属性,从技术上来讲它作为Entity Framework的主键。然而,它有别于ProductId,这里ProductId和TenantId组合是业务的标识。因此,内部的ProductKey必须设置为TenantId作为字符串和ProductId作为字符串的组合:
ProductKey = TenantId.Id + ":" + ProductId.Id;
我们创建了一个想让客户端看到的接口并且在实现类中隐藏了实现细节。我们使实现匹配Entity Framework的映射。我们还尽量少地保持特殊映射,比如ProductKey。这有助于通过注册实现类保持DbContext的简单。
public class AgilePMContext : DbContext { public DbSet<Product> Products { get; set; } public DbSet<ProductBacklogItem> ProductBacklogItems { get; set; } public DbSet<BacklogItem> BacklogItems { get; set; } public DbSet<Task> Tasks { get; set; } ... }
不用完全具体化这种方法的细节就已经足够做出一些判断了,我想讨论一下我认为的缺陷:
- 通过使用接口IProduct,IBacklogItem等等,并不会真正使通用语言得到加强。IProduct和IBacklogItem不在我们的通用语言中,但是Product和BacklogItem在。因此,客户端面对的名字应该是Product,BacklogItem诸如此类。我们可以简单的通过重命名接口为Product,BacklogItem,Release和Sprint来达到目的,但这意味着实现类不得不改成一些其他有意义的名字。
- 真的没有好的理由创建分离的接口。因为永远为IProduct或其他接口创建两个或多个实现是不太可能的。创建分离接口最好的理由就是可以创建它的多个实现,然而这在核心领域中是不会发生的。
基于以上两点,我打算放弃这种方法。当使用领域驱动设计时,最重要最首要的原则是遵守通用语言,但是从一开始这种方法就驱使我们远离业务术语,而不是靠近它。
状态对象支持的领域对象
第二种方法使用依靠状态对象的领域对象。如图6所示,领域对象定义并实现了使用通用语言的领域驱动模型,状态对象持有聚合的状态。
图6. 由持有对象状态的状态对象支持的建模聚合行为的领域模型
通过保持状态对象和领域驱动实现对象分开,使映射变得非常简单。我们让Entity Framework做它知道如何做的,默认是映射实体到数据库。考虑Product,它由ProductState对象支持。我们有两个Product构造函数,public的构造函数给客户端使用,internal的构造函数只由内部实现组件使用:
public class Product { public Product( TenantId tenantId, ProductId productId, ProductOwnerId productOwnerId, string name, string description) { State = new ProductState(); State.ProductKey = tenantId.Id + ":" + productId.Id; State.ProductOwnerId = productOwnerId; State.Name = name; State.Description = description; State.BacklogItems = new List<ProductBacklogItem>(); } internal Product(ProductState state) { State = state; } ... }
当构造函数调用时,我们就创建一个新的ProductState并初始化它。该状态对象有一个简单的字符标识:
public class ProductState { [Key] public string ProductKey { get; set; } public ProductOwnerId ProductOwnerId { get; set; } public string Name { get; set; } public string Description { get; set; } public List<ProductBacklogItemState> BacklogItems { get; set; } ... }
ProductKey实际上是两个属性的组合:TenantId作为字符串和ProductId作为字符串,两者中间在加上“:”字符。在ProductKey中包含TenantId是保证数据库中存储的数据各Tenant是隔离的。我们仍然必须支持从Product上对TenantId和ProductId的请求。
public class Product { ... public ProductId ProductId { get { return new ProductId(State.DecodeProductId()); } } ... public TenantId TenantId { get { return new TenantId(State.DecodeTenantId()); } } ... }
ProductState对象必须支持DecodeProductId()和DecodeTenantId()方法。我们也可以冗余的保持整个标识:
public class ProductState { [Key] public string ProductKey { get; set; } public ProductId ProductId { get; set; } public ProductOwnerId ProductOwnerId { get; set; } public string Name { get; set; } public string Description { get; set; } public List<ProductBacklogItemState> BacklogItems { get; set; } public TenantId TenantId { get; set; } ... }
如果转换标识有大的性能提升,轻微的内存占用是值得的。所有的标识类型,包括ProductOwnerId都是值类型,都被扁平的映射为映射为数据库ProductState占据的同一行:
[ComplexType] public class ProductOwnerId : Identity { public ProductOwnerId() : base() { } public ProductOwnerId(string id) : base(id) { } }
[ComplexType]特性标记值类型为复杂类型,它不同于实体。复杂类型是非标量值,没有键,只有包含它们的实体或值类型才能管理它们。使用Entity Framework的[ComplexType]标记值类型会将值类型的数据保存到数据库中与实体同一行中。在本例中,ProductOwnerId会保存成与ProductState实体同一数据库行。
下面是所有值对象的标识类型的基类型:
public abstract class Identity : IEquatable<Identity>, IIdentity { public Identity() { this.Id = Guid.NewGuid().ToString(); } public Identity(string id) { this.Id = id; } public string Id { get; set; } public bool Equals(Identity id) { if (object.ReferenceEquals(this, id)) return true; if (object.ReferenceEquals(null, id)) return false; return this.Id.Equals(id.Id); } public override bool Equals(object anotherObject) { return Equals(anotherObject as Identity); } public override int GetHashCode() { return (this.GetType().GetHashCode() * 907) + this.Id.GetHashCode(); } public override string ToString() { return this.GetType().Name + " [Id=" + Id + "]"; } } public interface IIdentity { string Id { get; set; } }
因此,当ProductState对象持久化Product的状态时依靠它自己。然而,ProductState也持有另外实体的集合,那就是ProductBacklogItemState列表:
public class ProductState { [Key] public string ProductKey { get; set; } ... public List<ProductBacklogItemState> BacklogItems { get; set; } ... }
一切都很好,因为我们保持数据库映射非常简单。然而我们如何得到允许客户端使用的一个ProductBacklogItemState对象或整个列表的格式呢?ProductBacklogItemState是内部实现细节,只是一个数据持有者。具体的转换在聚合根Product中进行:
public class Product { ... public ICollection AllBacklogItems() { List all = State.BacklogItems.ConvertAll( new Converter<ProductBacklogItemState, ProductBacklogItem>( ProductBacklogItemState.ToProductBacklogItem)); return new ReadOnlyCollection(all); } public ProductBacklogItem BacklogItem(BacklogItemId backlogItemId) { ProductBacklogItemState state = State.BacklogItems.FirstOrDefault( x => x.BacklogItemKey.Equals(backlogItemId.Id)); return new ProductBacklogItem(state); } ... }
这里我们转换一个ProductBacklogItemState实例的集合为ProductBacklogItem实例的集合。并且当客户端请求只是一个ProductBacklogItem时,我们转换一个匹配那个标识的ProductBacklogItemState。ProductBacklogItemState对象必须支持几种简单的转换方法:
public class ProductBacklogItemState { [Key] public string BacklogItemKey { get; set; } ... public ProductBacklogItem ToProductBacklogItem() { return new ProductBacklogItem(this); } public static ProductBacklogItem ToProductBacklogItem( ProductBacklogItemState state) { return new ProductBacklogItem(state); } ... }
如果客户端反复请求ProductBacklogItem实例的结合,Product可以再第一次生成后进行缓存。
最后,我们要做的就是对Entity Framework置身事外,使它超级简单的将状态对象和数据库进行映射。我认为,当你考虑解决方案的DbContext的时候,你会得出我们真的只有几个简单方法的结论:
public class AgilePMContext : DbContext { public DbSet<ProductState> Products { get; set; } public DbSet<ProductBacklogItemState> ProductBacklogItems { get; set; } public DbSet<BacklogItemState> BacklogItems { get; set; } public DbSet<TaskState> Tasks { get; set; } public DbSet<ReleaseState> Releases { get; set; } public DbSet<ScheduledBacklogItemState> ScheduledBacklogItems { get; set; } public DbSet<SprintState> Sprints { get; set; } public DbSet<CommittedBacklogItemState> CommittedBacklogItems { get; set; } ... }
创建并使用ProductRepository也非常简单:
public interface ProductRepository { void Add(Product product); Product ProductOfId(TenantId tenantId, ProductId productId); } public class EFProductRepository : ProductRepository { private AgilePMContext context; public EFProductRepository(AgilePMContext context) { this.context = context; } public void Add(Product product) { try { context.Products.Add(product.State); } catch (Exception e) { Console.WriteLine("Add() Unexpected: " + e); } } public Product ProductOfId(TenantId tenantId, ProductId productId) { string key = tenantId.Id + ":" + productId.Id; var state = default(ProductState); try { state = (from p in context.Products where p.ProductKey == key select p).FirstOrDefault(); } catch (Exception e) { Console.WriteLine("ProductOfId() Unexpected: " + e); } if (EqualityComparer<ProductState>.Default.Equals(state, default(ProductState))) { return null; } else { return new Product(state); } } } // Using the repository using (var context = new AgilePMContext()) { ProductRepository productRepository = new EFProductRepository(context); var product = new Product( new ProductId(), new ProductOwnerId(), "Test", "A test product."); productRepository.Add(product); context.SaveChanges(); ... var foundProduct = productRepository.ProductOfId(product.ProductId); }
使用此种方法会帮助我们将注意力集中在真正重要的地方:我们的核心领域和它的通用语言。
思维有点跳,不过是否可以有个全面的实例呢?
有时间就写写