Entity Framework模型在领域驱动设计界定上下文中的应用
【本文翻译自Julie Lerman发表在MSDN Magazine上的一篇技术文章,原文题为《Shrink EF Models with DDD Bounded Contexts》。对自己英语比较自信的朋友可以直接在MSDN Magazine的在线文章收录中阅读原文。】
在使用Entity Framework(以下简称EF)来定义模型(Model)时,开发人员往往喜欢把应用程序中的所有模型对象都一股脑地塞进一个模型中。这种开发习惯估计是源于Database First的开发方式,在这种方式下,开发人员可以很方便地将数据库中的表和视图直接拖拽到EF模型设计器中,于是一个模型也就包含了由这些表或视图所映射的所有对象。当然,不正确的Code First的实践方式,同样也会造成这样的局面:在一个DbContext中为模型中的每一个实体都定义DbSet属性,甚至会不知不觉地将与这些实体关联的所有类型全部包含进去。
当开发一个具有大型领域模型的超大规模的应用程序时,与设计一个单一的大领域模型相比,将大领域模型根据应用程序的业务需要“切割”成一系列较小的模型是非常重要的,我们也往往能够从中获得更多的好处。在本文中我将向大家介绍领域驱动设计(DDD)中的一个重要概念:界定上下文(Bounded Context),并向大家展示如何根据界定上下文来设计基于Entity Framework Code First的模型。如果您是第一次接触DDD,那么本文将会为您提供一个很好的了解和学习DDD的机会;如果您已经开始在项目中使用DDD,那么本文或许能够为您提供一些Entity Framework与DDD的实践启示。
领域驱动设计与界定上下文
DDD是一个相当广泛的话题,它囊括了软件设计的所有方面。作为Domain Language(DomainLanguage.com)中DDD workshop的讲师,Paul Rayner是这样概括DDD的:
“DDD主张一种更为实用的、覆盖面更广的以及可持续的软件设计方式:通过与领域专家的沟通,将领域模型适配到软件系统中,而正是领域模型帮助我们解决了那些重要的、复杂的业务问题”
DDD包括了很多软件设计模式,在这些众多的模式之中,界定上下文使我们能够很自然地在软件设计中使用Entity Framework。界定上下文主张根据特定的业务领域设计和开发一些较小的模型。Eric Evans在他的《领域驱动设计:软件核心复杂性应对之道》一书中,对界定上下文是这样描述的:“明确定义模型应用的上下文。根据团队组织、应用程序各个部分的使用率、物理显现(如代码库和数据库方案)明确设置界限。在这些界限中要保持模型严格的一致性,但不要被外界问题干扰和迷惑。”(选自《领域驱动设计:软件核心复杂性应对之道》一书,陈大峰、张泽鑫等译,2006年3月版)
更小的模型为我们的软件设计和开发带来了更多的好处,它使得团队能够根据自己的设计和开发职责确定更为明确的工作边界。小的模型也为项目带来了更好的可维护性:由于上下文由边界确定,因此对其的修改也不会给整个模型的其它部分造成影响。更进一步,就Entity Framework而言,相比大模型的读取和加载,小模型不仅加载速度快,而且内存占用也会相对较小,在一定程度上提升了应用程序的性能。
由于我是使用EF的DbContext来设计和开发界定上下文,我原本打算使用“界定的DbContext”这一词语来描述我们的上下文。然而,DbContext与界定上下文之间并不是完全等同的:DbContext是一个技术架构的类型实现,而界定上下文则是在描述一个完整的软件设计过程中的一个更为广泛的概念。因此,使用“限定的”或者“专注的”来描述本文中出现的DbContext或许更为准确。
经典的EF DbContext与界定上下文之间的对比
虽说DDD通常会被用在具有复杂领域业务的大型应用程序的开发之中,中小型应用程序的开发同样也能从DDD的理论和实践中获益。下面,我将以一个具有特定领域业务的应用程序为例,向大家介绍Entity Framework在界定上下文中的应用。该应用程序提供这样一种业务:它能为公司提供跟踪销售和市场信息的业务。通过分析不难发现,整个应用程序将包含多种对象,比如:客户(Customers)、订单(Orders)、订单行项目(Line Items)、产品(Products)、市场(Marketing)、销售人员(SalesPeoples),甚至还会包括公司雇员(Employees)。通常,我们都是在DbContext中定义包含了所有这些对象的DbSet属性,就像下面的代码所示:
public class CompanyContext : DbContext { public DbSet<Customer> Customers { get; set; } public DbSet<Employee> Employees { get; set; } public DbSet<SalaryHistory> SalaryHistories { get; set; } public DbSet<Order> Orders { get; set; } public DbSet<LineItem> LineItems { get; set; } public DbSet<Product> Products { get; set; } public DbSet<Shipment> Shipments { get; set; } public DbSet<Shipper> Shippers { get; set; } public DbSet<ShippingAddress> ShippingAddresses { get; set; } public DbSet<Payment> Payments { get; set; } public DbSet<Category> Categories { get; set; } public DbSet<Promotion> Promotions { get; set; } public DbSet<Return> Returns { get; set; } protected override void OnModelCreating(DbModelBuilder modelBuilder) { // Config specifies a 1:0..1 relationship between Customer and ShippingAddress modelBuilder.Configurations.Add(new ShippingAddressMap()); } }
想象一下,如果你所要开发的应用程序规模很大,包含了上百个类似Customer、Employee等这样的类型,那么在一个单独的DbContext类型中针对每个类型定义一个DbSet的属性将是一个多么繁琐的工作,更何况你还需要通过Fluent Interface对其中的某些类型进行配置。通常情况下,当应用程序的规模达到一定程度时,我们都会将其分割成多个子系统,项目中的每个团队都会负责其中一个子系统的开发任务。如果我们继续使用这样一个庞大的、全局的DbContext类型,那么团队之间的工作就会互相干扰,因此,我们同样也需要根据每个团队所面对的领域问题对这个DbContext类型进行切分。
另一方面,对于这个庞大的DbContext类型,我们还会提出这样一些疑问,比如:公司市场部所使用的应用程序部分中,用户是否真的有必要去查询雇员的工资历史信息?运输部是否也需要像客服专员那样,通过应用程序去访问客户的详细数据,甚至是对这些数据进行修改?通常情况下,针对这类问题的答案都是:No。所以您也能够看到,将一个庞大的DbContext类型根据子系统所面对的子领域切分成更小的DbContext类型是完全可行的。
面向运输领域的DbContext的实现
DDD推荐使用具有明确上下文边界的、能够满足特定子领域的更为轻巧的领域模型,现在就让我们一起看看如何将DbContext类型的设计限定到运输领域。于是,你可以从原来那个庞大的DbContext中去除那些与运输领域无关的DbSet,而仅仅包含那些在运输领域中需要用到的对象。在此,我将Returns、Promotions、Categories、Payments、Employees以及SalaryHistories等从原来的DbContext中移除,于是便得到了下面的针对运输领域的DbContext的实现:
public class ShippingDeptContext : DbContext { public DbSet<Shipment> Shipments { get; set; } public DbSet<Shipper> Shippers { get; set; } public DbSet<Customer> Customers { get; set; } public DbSet<ShippingAddress> ShippingAddresses { get; set; } public DbSet<Order> Order { get; set; } public DbSet<LineItem> LineItems { get; set; } public DbSet<Product> Products { get; set; } }
EF Code First能够根据ShippingDeptContext类型自动推导出对象模型,下图就对这个模型进行了展示。我是使用Entity Framework Power Tools Beta 2来产生这幅模型图的。接下来,我们开始对所产生的模型进行优化。
面向运输领域的DbContext的优化:建立更为专注的模型
从上图中我们可以看到,虽然我们已经将与运输领域无关的DbSet属性从DbContext中移除掉了,但所产生的EF模型仍然包含了一些我们所不需要的对象。这是EF Code First根据DbContext产生模型的一种特点:它会自动地分析对象之间的关系,从而将关联对象也一并加入到模型之中。这就是为什么我们已经将Category和Payment的DbSet属性去除之后,这些对象仍然存在于模型中的原因。因此,我们需要重写DbContext的OnModelCreating方法,使得在模型产生的过程中忽略这些对象:
protected override void OnModelCreating(DbModelBuilder modelBuilder) { modelBuilder.Ignore<Category>(); modelBuilder.Ignore<Payment>(); modelBuilder.Configurations.Add(new ShippingAddressMap()); }
这样就确保了Category和Payment对象不会在模型中出现,因为他们本身是与产品领域和订单领域相关的概念,而与我们的运输领域无关。
接下来,我们还能进一步地优化我们的DbContext类,并能同时确保不会影响到产生的模型。现在,我们已经可以很直接地通过DbContext中已有的7个DbSet属性来访问我们需要的对象。但通过对这些对象及其之间关系的分析,我们不难发现,我们几乎不会直接去访问ShippingAddress的信息,因为我们可以通过Customer对象来获得这一信息。由于Customer保持着对ShippingAddress的引用,当EF Code First通过DbContext来生成模型的时候,它也会一并将ShippingAddress带入到模型当中,就像之前Category和Payment那样,所以,我们可以在DbContext中直接将ShippingAddress的DbSet属性移除。当然,在进行了细致的分析之后,或许还能移除其它的DbSet属性,不过为了简化描述,在本案例中我们只针对ShippingAddress的DbSet属性作优化。
public class ShippingContext : DbContext { public DbSet<Shipment> Shipments { get; set; } public DbSet<Shipper> Shippers { get; set; } public DbSet<Customer> Customers { get; set; } // Public DbSet<ShippingAddress> ShippingAddresses { get; set; } public DbSet<Order> Order { get; set; } public DbSet<LineItem> LineItems { get; set; } public DbSet<Product> Products { get; set; } protected override void OnModelCreating(DbModelBuilder modelBuilder) { ... } }
更进一步,对于我们的运输领域,我们并不需要Customer、Order、LineItem这些对象所包含的所有信息。我所需要的只有需要运输的Product的信息、运输的产品数量(来自于LineItem)、客户的名称和收货地址(来自于Customer),以及与客户和订单相关的备注信息。为此,我让DBA帮我在数据库中创建了一个视图(View),用来返回所有没有发货的订单详情(也就是所有那些ShipmentId为0或者为null的订单)。与此同时,我根据自己的具体情况定义了下面这个类型:
[Table("ItemsToBeShipped")] public class ItemToBeShipped { [Key] public int LineItemId { get; set; } public int OrderId { get; set; } public int ProductId { get; set; } public int OrderQty { get; private set; } public OrderShippingDetail OrderShippingDetails { get; set; } }
运输业务的处理过程需要通过ItemToBeShipped类型来查询所需的订单信息,以及客户信息和收货地址。因此,我可以进一步简化DbContext,使其只包含ItemToBeShipped类型,以及Order、Customer和ShippingAddress等我关心的信息。然而,我知道EF会通过生成一条SQL查询语句然后重复地将这些信息以一种平展的方式返回给我,因此我可以让我的开发人员来实现这一查询,并将获得的数据集返回给我。同样,我并不需要Order数据表中的所有字段的内容,因此,我需要设计一个更为简洁、更专注于运输领域的Order对象,使得这个对象只包含与运输相关的信息。这也就是下面的OrderShippingDetail类:
[Table("Orders")] public class OrderShippingDetail { [Key] public int OrderId { get; set; } public DateTime OrderDate { get; set; } public Nullable<DateTime> DueDate { get; set; } public string SalesOrderNumber { get; set; } public string PurchaseOrderNumber { get; set; } public Customer Customer { get; set; } public int CustomerId { get; set; } public string Comment { get; set; } public ICollection<ItemToBeShipped> OpenLineItems { get; set; } }
值得注意的是,ItemToBeShipped类型中包含了针对OrderShippingDetail的导航属性,而OrderShippingDetail又包含了针对Customer的导航属性,因此在进行查询和保存操作的时候,这些导航属性就能够保证所有相关的信息都能被正确处理。
另外还有件事情,就是每一条Line Item的数据都会包含一个ShipmentId,在运输领域中,每当一条Line Item完成发货之后,系统都会通过设置ShipmentId来标识当前的Line Item已经发货。因此,与直接使用LineItem这一类型相比,我会另外单独创建一个新的类型来处理这件事情:
[Table("LineItems")] public class LineItemShipment { [Key] public int LineItemId { get; set; } public int ShipmentId { get; set; } }
于是,每当一条记录已经发货之后,你就可以直接创建该类的实例,在完成属性值的正确设置后,让应用程序根据这个新建的实例将数据更新到数据库中。当然,你需要确保该类型只会在目前这个场景中使用,否则很可能会产生错误。比如如果你试图使用该类的实例来向LineItem插入数据,那就很有可能因为该数据表的其它一些非空类型字段(例如OrderId)被强制设置成空值而引发数据库异常。
通过更进一步的优化,我们的ShippingContext已经变成了下面这幅模样:
public class ShippingContext : DbContext { public DbSet<Shipment> Shipments { get; set; } public DbSet<Shipper> Shippers { get; set; } public DbSet<OrderShippingDetail> Order { get; set; } public DbSet<ItemToBeShipped> ItemsToBeShipped { get; set; } protected override void OnModelCreating(DbModelBuilder modelBuilder) { modelBuilder.Ignore<LineItem>(); modelBuilder.Ignore<Order>(); modelBuilder.Configurations.Add(new ShippingAddressMap()); } }
现在,我们使用Entity Framework Power Tools Beta 2再次生成EDMX,我们可以看到,在模型浏览器(Model Browser)窗口中,EF Code First感知到整个模型将包含由ShippingContext中DbSet属性所定义的四个类型,以及通过导航属性找到的Customer和ShippingAddress类型:
改进后的DbContext与数据库初始化
在应用程序中使用面向某个界定上下文的DbContext时,需要注意EF Code First在根据模型进行数据库初始化的两个默认行为。
第一个就是Code First会默认地将DbContext的名称用作数据库名。当我们采用面向界定上下文的DbContext时,比如应用程序中存在ShippingContext、CustomerContext以及SalesContext等等时,我们就需要规避Code First的这一行为,我们需要让这些DbContext公用同一个数据库。
EF的另一个默认行为就是,Code First会根据DbContext所创建的模型来建立数据库结构(database schema)。然而现在我们的DbContext只包含了数据库中某一个部分的定义。因此,我们不需要EF通过DbContext类型来初始化数据库。
我们可以通过每个DbContext类的构造函数来解决这些问题。例如,在ShippingContext类中,你可以在构造函数中明确指定使用DPSalesDatabase数据库,并且禁用数据库初始化:
public ShippingContext() : base("DPSalesDatabase") { Database.SetInitializer<ShippingContext>(null); }
然而,如果在系统中存在很多DbContext类,那么这种重复性的设置会带来一定的维护问题。一种更好的解决方案是,提供一个基类型,在这个基类型中设置所使用的数据库,并禁用数据库初始化,比如:
public class BaseContext<TContext> DbContext where TContext : DbContext { static BaseContext() { Database.SetInitializer<TContext>(null); } protected BaseContext() : base("DPSalesDatabase") {} }
于是,之前我所定义的context类型就可以直接继承于这个基类型,而无需做任何过多的设置:
public class ShippingContext:BaseContext<ShippingContext>
如果你是开发的一个新的应用程序,并希望EF Code First能够根据你的类定义,帮你创建或者迁移数据库,你可以创建一种“完善的模型(uber-model)”,在这个模型中,DbContext将包含应用程序中所有的类以及类之间的关联关系,以便能够建立一个完整的数据库结构。然而,这个DbContext不能从BaseContext继承。当你修改了类的结构后,你可以编写一些工具代码,使其能够使用这个“完善的模型”实现数据库的重建和初始化。这将有助于简化数据库开发过程以及更好地在应用程序中使用EF Code First。
面向特定领域DbContext的实践
在完成了以上一系列工作之后,我写了一些自动化集成测试脚本,对以下应用场景进行了测试:
- 获取所有未发货的项目
- 获取有着未发货项目的订单相关的OrderShippingDetails信息,这些信息还包含了Customer和Shipping的信息
- 获取一条未发货的项目,并创建一个发货对象。然后将这个发货对象设置到Line Item上,同时在数据库中新建一条发货的记录,并在数据库中将发货对象的键值更新到Line Item上
这些应用场景基本上覆盖了运输领域绝大部分的业务功能。在执行了测试脚本后,所有的测试都很成功,这也证明了我们的DbContext能够正常运行。这些测试也同样包含在了与本文相关的案例下载中。
总结
我们不仅针对运输领域创建了一个面向特定领域(界定上下文)的DbContext,而且通过这个思考过程,我们也创建了一些更为高效的领域对象(比如ItemToBeShipped等)。将DDD中的界定上下文定位在运输领域以及在这个上下文中使用DbContext,这就意味着我们不需要在应用程序的运输领域部分涉及过多的无关对象,因此我可以在不影响其它子领域或者说其它团队工作的前提下,在我自己所关注的领域中使用这些有限的相关的对象。
你可以通过阅读我和Rowan Miller合作的《Programming Entity Framework: DbContext》((O’Reilly Media, 2011))一书来更多地了解DDD中的界定上下文,以及在界定上下文中应用面向特定领域DbContext的实践案例程序。