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來產生這幅模型圖的。接下來,我們開始對所產生的模型進行優化。

jj883952.Lerman_Figure 2_hires(en-us,MSDN.10)

面向運輸領域的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類型:

jj883952.Lerman_Figure 4_hires(en-us,MSDN.10)

改進后的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的實踐案例程序。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM