寫在前面
閱讀目錄:
在上一篇《一縷陽光:DDD(領域驅動設計)應對具體業務場景,如何聚焦 Domain Model(領域模型)?》博文中,探討的是如何聚焦領域模型(拋開一些干擾因素,才能把精力集中在領域模型的設計上)?需要注意的是,上一篇我講的并不是如何設計領域模型(本篇也是)?而是如何聚焦領域模型,領域模型的設計是個迭代過程,不能一概而論,還在路上。
當有一個簡單的領域模型用例,完成一個從上而下過程的時候,就需要對領域模型和數據庫進行對象關系映射(ORM),首先,在領域驅動設計中,領域模型是活的(具有自己的行為和狀態),而映射到數據庫中所謂的表是死的(只是一些字段),如何把活的變成死的?又如何把死的變成活的?更重要的是如何保證在這個“死去活來”的過程中,死的和活的是同一個?
轉換過程很簡單,使用 ORM(對象關系映射)工具就很方便的完成這個“死去活來”的過程,但是有時候我們在這個轉換過程中,可能會失去轉換對象的本質,以致活的會變成死的,最后轉換過程就只有死的變成死的(反復循環)。
設計誤區
在 MessageManager 項目的上一個版本中,主要存在兩個領域模型:Messgae 和 User,他們數據庫之間的映射關系是一對多的關系,就是一個用戶擁有多個消息,但是一個消息只能對應一個用戶(發件人或收件人),我們看下領域模型的設計(暫不包含業務邏輯)。
Domain Model-Message:
1 namespace MessageManager.Domain.DomainModel 2 { 3 public class Message : IAggregateRoot 4 { 5 #region 構造方法 6 public Message() 7 { 8 this.ID = Guid.NewGuid().ToString(); 9 } 10 #endregion 11 12 #region 實體成員 13 public string FromUserID { get; set; } 14 public string FromUserName { get; set; } 15 public string ToUserID { get; set; } 16 public string ToUserName { get; set; } 17 public string Title { get; set; } 18 public string Content { get; set; } 19 public DateTime SendTime { get; set; } 20 public bool IsRead { get; set; } 21 public virtual User FromUser { get; set; } 22 public virtual User ToUser { get; set; } 23 #endregion 24 25 #region IEntity成員 26 /// <summary> 27 /// 獲取或設置當前實體對象的全局唯一標識。 28 /// </summary> 29 public string ID { get; set; } 30 #endregion 31 } 32 }
Domain Model-User:
1 namespace MessageManager.Domain.DomainModel 2 { 3 public class User : IAggregateRoot 4 { 5 #region 構造方法 6 public User() 7 { 8 this.ID = Guid.NewGuid().ToString(); 9 } 10 #endregion 11 12 #region 實體成員 13 public string Name { get; set; } 14 public virtual ICollection<Message> SendMessages { get; set; } 15 public virtual ICollection<Message> ReceiveMessages { get; set; } 16 #endregion 17 18 #region IEntity成員 19 /// <summary> 20 /// 獲取或設置當前實體對象的全局唯一標識。 21 /// </summary> 22 public string ID { get; set; } 23 #endregion 24 } 25 }
乍一看,Message 和 User 領域模型并沒有什么問題,只是設計的太貧血(只是包含一些屬性字段),拋開業務邏輯,我們看下 Message 和 User 之間的關聯,Message 模型中擁有 FromUserID,FromUserName,ToUserID,ToUserName 字段,用來表示和 User 模型的關聯,Navigation Properties(導航屬性)為:FromUser 和 ToUser,類型為 User,再看一下 User 模型的導航屬性:SendMessages 和 ReceiveMessages,類型為 ICollection<Message>,我們如果按照平常的開發模式(腳本驅動模式),這樣設計沒有一點問題,很方便對 ORM 進行配置:
1 /// <summary> 2 /// Initializes a new instance of <c>MessageConfiguration</c> class. 3 /// </summary> 4 public MessageConfiguration() 5 { 6 HasKey(c => c.ID); 7 Property(c => c.ID) 8 .IsRequired() 9 .HasMaxLength(36) 10 .HasDatabaseGeneratedOption(DatabaseGeneratedOption.None); 11 Property(c => c.FromUserID) 12 .IsRequired() 13 .HasMaxLength(36); 14 Property(c => c.ToUserID) 15 .IsRequired() 16 .HasMaxLength(36); 17 Property(c => c.Title) 18 .IsRequired() 19 .HasMaxLength(50); 20 Property(c => c.Content) 21 .IsRequired() 22 .HasMaxLength(2000); 23 Property(c => c.SendTime) 24 .IsRequired(); 25 Property(c => c.IsRead) 26 .IsRequired(); 27 ToTable("Messages"); 28 29 // Relationships 30 this.HasRequired(t => t.FromUser) 31 .WithMany(t => t.SendMessages) 32 .HasForeignKey(t => t.FromUserID) 33 .WillCascadeOnDelete(false); 34 this.HasRequired(t => t.ToUser) 35 .WithMany(t => t.ReceiveMessages) 36 .HasForeignKey(t => t.ToUserID) 37 .WillCascadeOnDelete(false); 38 }
上面代碼表示 Message 的映射配置,如果外鍵可以為 NULL,則使用 HasOptional 方法,多對對則使用 HasMany 和 WithMany,WillCascadeOnDelete 用來級聯刪除配置,EntityTypeConfiguration 的具體詳細配置,請參照:http://msdn.microsoft.com/zh-cn/data/jj591620.aspx。
上面的設計到底有沒有問題?我們來分析一下,首先 User 領域模型中的 SendMessages 和 ReceiveMessages 屬性,如果單獨作為導航屬性,這是沒有什么問題的,因為我們可以使用導航屬性很方便的進行映射配置(比如上面代碼),但是放在領域模型中就有點不倫不類了,User 是一個用戶對象,我們不能在它的身上來掛一些屬于它的東西,因為這些并不是用戶本身所具有的,這就好像我設計一個用戶模型,它擁有手機,電腦,背包,房子,車子等等,然后就必須在這個用戶模型中添加這個屬性,這樣設計就會很不合理,這個應該設計在它所擁有的物品上,因為只有這些物品擁有用戶,這些物品相對于用戶來說,才有真正的存在意義。
再來看 Message 領域模型,首先 FromUserID,FromUserName,ToUserID,ToUserName 這四個字段就讓我們看得很不順眼,因為這些都是已死的字段,Message 應該關聯的是活的 User,而并不是在它身上打上幾個 User 的標簽,這個表現應該在數據庫中(因為數據庫中就是存的這些已死的字段),而并不是在活的 Message 領域模型中,FromUser 和 ToUser 的設計是沒有問題的,因為關聯的就是活的 User 對象。
為什么有了 FromUser 和 ToUser 對象,Message 領域模型中還要添加上面那四個字段呢?主要原因還是受思維模式的影響(腳本驅動模式),雖然是基于領域模型設計,但是在設計過程中就會不自覺的往腳本驅動模式上套,為什么?因為我們要使用數據庫,不管怎么設計,這些對象都是要存在數據庫中的,而數據庫存的都是一些已死的對象(只是包含字段),對象死了,那怎么來表示 Message 和 User 對象之間的關聯呢?答案就是 FromUserID 和 ToUserID,因為只有通過這兩個字段,才能在數據庫中體現 Message 和 User 對象之間的關聯,數據庫存儲中確實是這么做的,但是我們把數據庫中的關聯表現在領域模型中就很不合適了,最后的結果就是 FromUser 和 ToUser 對象的作用只是用來映射配置,Message 領域模型變成和數據庫中的 Message 表一樣,狀態都是已死,轉換也就是死的對象轉換為死的對象。
那到底怎么設計?答案就是把 Message 領域模型中的 FromUserID,FromUserName,ToUserID,ToUserName 四個屬性去掉,User 領域模型中的 SendMessages 和 ReceiveMessages 屬性也去掉,讓領域模型變得干凈。那有人會問了,你把這些關聯字段去掉了,怎么去映射數據庫呢?天無絕人之路,使用 EntityFramework(ORM 工具之一)就很方便的進行映射配置,具體配置,可以看下枚舉映射和關聯映射兩個節點。
數據庫已死
本節點純屬扯淡,兄臺們不感興趣的話,可以直接略過。
“數據庫已死”的這個概念,并不是本人提出的,早在六年前在解道中就有人提出,具體可以參考:
- http://www.jdon.com/artichect/dbdead.htm
- http://www.jdon.com/artichect/dbover.htm
- http://www.jdon.com/34601
- http://www.jdon.com/35989
- http://www.jdon.com/mda/ddd.html
- http://www.jdon.com/35977/5
首先,強調一點,數據庫已死的概念,并不是說我們項目中不使用數據庫(想想應用程序不使用數據庫也不可能),只是說應用程序設計的核心不再是基于數據庫設計的,而應該是基于面向對象設計,數據庫只是存儲數據的一種方式,當然也可以配置文件存儲或者內存存儲。以往我們進行應用程序設計的時候,都是先根據業務需求定義表結構,然后根據表結構用“面向對象”的語言去傳遞 SQL 放到數據庫中執行,這樣面向對象語言就成了所謂的 SQL 搬運工,這樣造成的問題就是非常難維護,牽一發而動全身,而且性能瓶頸也主要體現在數據庫方面,想想應用程序的性能問題(排除代碼問題),我們可以使用負載均衡增加服務器,來分擔所帶來的壓力,而應對數據庫性能問題呢?從“MySpace”的經歷上就可以看出,那是相當的難處理,而且性能問題主要集中在數據庫方面,也是設計的不合理所造成的。
我們來看一下 MySqace 的信息系統發展歷程:
- 第一代架構—添置更多的Web服務器:因為用戶量小,所以我們一般部署應用程序的時候,都是應用程序和數據庫各部署一臺,當用戶暴增之后,我們就開始部署更多的應用程序服務器(數據庫服務器還是一臺),但是當用戶量達到一定的程度后,部署再多的應用程序服務器已沒有什么用,因為數據庫服務器就一臺。
- 第二代架構—增加數據庫服務器:與增加 Web 服務器不同,增加數據庫并沒那么簡單。如果一個站點由多個數據庫支持,設計者必須考慮的是,如何在保證數據一致性的前提下讓多個數據庫分擔壓力。MySpace 運行在三個 SQL Server 數據庫服務器上—一個為主,所有的新數據都向它提交,然后由它復制到其它兩個;另兩個數據庫服務器全力向用戶供給數據,用以在博客和個人資料欄顯示。這種方式在一段時間內效果很好—只要增加數據庫服務器,加大硬盤,就可以應對用戶數和訪問量的增加。說到低,這種方式就是拆分數據庫,不同的應用程序使用數據庫不同,然后部署再不同的服務器上,但是當用戶再次達到一定程度后,這種方案也不太適合了。
- 第三代架構—轉到分布式計算架構:MySpace 將目光移到分布式計算架構——它在物理上分布的眾多服務器,整體必須邏輯上等同于單臺機器。拿數據庫來說,就不能再像過去那樣將應用拆分,再以不同數據庫分別支持,而必須將整個站點看作一個應用。現在,數據庫模型里只有一個用戶表,支持博客、個人資料和其他核心功能的數據都存儲在相同數據庫。既然所有的核心數據邏輯上都組織到一個數據庫,那么 MySpace 必須找到新的辦法以分擔負荷——顯然,運行在普通硬件上的單個數據庫服務器是無能為力的。這次,不再按站點功能和應用分割數據庫,MySpace 開始將它的用戶按每百萬一組分割,然后將各組的全部數據分別存入獨立的SQL Server實例。可以看出這種方式顯然也不能滿足高用戶量的需求。
- 第四代架構—求助于微軟方案:2005年早期,賬戶達到九百萬,MySpace 開始用微軟的 C# 編寫 ASP.NET 程序。在收到一定成效后,MySpace 開始大規模遷移到 ASP.NET。用戶達到一千萬時,MySpace 再次遭遇存儲瓶頸問題。SAN 的引入解決了早期一些性能問題,但站點目前的要求已經開始周期性超越 SAN 的 I/O 容量——即它從磁盤存儲系統讀寫數據的極限速度。
- 第五代架構—增加數據緩存層并轉到支持 64 位處理器的 SQL Server 2005:MySpace 賬戶達到一千七百萬,MySpace 又啟用了新的策略以減輕存儲系統壓力,即增加數據緩存層——位于 Web 服務器和數據庫服務器之間,其唯一職能是在內存中建立被頻繁請求數據對象的副本,如此一來,不訪問數據庫也可以向 Web 應用供給數據。2005年中期,服務賬戶數達到兩千六百萬時,MySpace 因為我們對內存的渴求而切換到了還處于 beta 測試的支持 64 位處理器的 SQL Server 2005。升級到 SQL Server 2005 和 64 位 Windows Server 2003 后,MySpace 每臺服務器配備了 32G 內存,后于 2006 年再次將配置標準提升到 64G。
- 。。。。
總結:從 MySpace 看更加驗證,數據庫是軟件系統的瓶頸,而且最不可伸縮,一旦數據庫成為系統瓶頸,就得動大手術,實現架構上的變遷,這是傷筋動骨,變遷人員壓力巨大的。另外由于是社區,就是變遷數據丟失也沒什么大不了,如果是企業那就......
如果我們從軟件系統開始之初,就使用對象分析設計,不與數據庫沾邊,整個流程就完全 OO,分析設計直至代碼都擺脫了數據庫影響,這個流程如下:
- 分析建模(基于領域驅動設計的業務建模)
- 細化設計(基于領域驅動設計的架構設計)
- 代碼實現
- 調試測試
- 部署運行
那么數據庫在什么時候建立呢?數據庫表結構的創建可以延緩到部署運行時,這樣,整個上游環節就不涉及數據庫技術,而是使用更符合自然的表達 OO 方式,軟件質量就更高了。現在,很多人已經理解,分析設計要用 OO,但是數據庫是運行階段缺少不了的,確實,這是正確觀點,我們奪取數據庫的王位,不是將它打倒,只是理性和平移交權力重心而已,數據庫退出主角地位,讓位于中間件,也預示著過去數據庫為王的時代的結束, 但是數據庫會和操作系統一樣,成為我們現代軟件系統一個不可缺少重要的基礎環節。
了解了這么多,回到”設計誤區“這一節點,你會發現,造成設計誤區的主要原因還是,在設計的時候不自覺以數據庫為中心了,而并非領域模型。
枚舉映射
在 Message 領域模型中,有個 MessageState 枚舉類型,用來表示消息的狀態,當然我們也可以使用 Bool 類型的字段來表示,但是消息狀態是消息本身的一種狀態,用對象來表示更為合適,MessageState 枚舉定義如下:
1 namespace MessageManager.Domain.DomainModel 2 { 3 public enum MessageState 4 { 5 Read, 6 NoRead 7 } 8 }
我們使用單元測試對映射轉換進行測試,也就是 Code First 模式,測試代碼:
1 namespace MessageManager.Repositories.Tests 2 { 3 public class UserRepositoryTest 4 { 5 [Fact] 6 public void AddUserRepository() 7 { 8 IUserRepository userRepository = new UserRepository(new EntityFrameworkRepositoryContext()); 9 User user1 = new User("小菜"); 10 User user2 = new User("大神"); 11 userRepository.Add(user1); 12 userRepository.Add(user2); 13 userRepository.Context.Commit(); 14 } 15 } 16 }
生成數據庫發生異常:
這個主要原因是當前 EntityFramework 版本不支持枚舉類型映射,當前使用的 EntityFramework 版本為 4.3.1:
1 <?xml version="1.0" encoding="utf-8"?> 2 <packages> 3 <package id="EntityFramework" version="4.3.1" targetFramework="net40" /> 4 </packages>
EntityFramework 的版本太老了,更新版本為 6.1.1,NuGet 更新命令:update-package EntityFramework
EntityFramework 從 4.3.1 升級到 6.1.1 更改的地方(http://msdn.microsoft.com/en-us/data/upgradeef6.aspx):
- System.Data.EntityState.Modified; 更改為 System.Data.Entity.EntityState.Modified;
- DatabaseGeneratedOption 需要添加 System.ComponentModel.DataAnnotations.Schema 命名空間。
在升級完 EntityFramework 版本后,重新運行單元測試,但是發現又報如下錯誤:
解決方案:
- http://stackoverflow.com/questions/14033193/entity-framework-provider-type-could-not-be-loaded
- http://stackoverflow.com/questions/21641435/error-no-entity-framework-provider-found-for-the-ado-net-provider-with-invarian
在 MessageManagerDbContext 構造函數中添加如下代碼:
1 public MessageManagerDbContext() 2 : base("MessageManagerDB") 3 { 4 var ensureDLLIsCopied = System.Data.Entity.SqlServer.SqlProviderServices.Instance; 5 this.Configuration.LazyLoadingEnabled = true; 6 }
重新運行單元測試,測試成功,就會發現在 MessageManagerDB 數據庫的 Messages 表中已生成 State 字段,類型為 Int,當然也可以通過 EntityTypeConfiguration 中的 HasColumnType 進行自定義字段類型。
關聯映射
先看一下,如果我們沒有進行任何的 EntityTypeConfiguration 關聯設置,生成數據庫會是怎樣?MessageConfiguration 和 UserConfiguration 配置如下:
1 public class MessageConfiguration : EntityTypeConfiguration<Message> 2 { 3 /// <summary> 4 /// Initializes a new instance of <c>MessageConfiguration</c> class. 5 /// </summary> 6 public MessageConfiguration() 7 { 8 HasKey(c => c.ID); 9 Property(c => c.ID) 10 .IsRequired() 11 .HasMaxLength(36) 12 .HasDatabaseGeneratedOption(DatabaseGeneratedOption.None); 13 Property(c => c.Title) 14 .IsRequired() 15 .HasMaxLength(50); 16 Property(c => c.Content) 17 .IsRequired() 18 .HasMaxLength(2000); 19 Property(c => c.SendTime) 20 .IsRequired(); 21 } 22 }
1 /// <summary> 2 /// Represents the entity type configuration for the <see cref="Customer"/> entity. 3 /// </summary> 4 public class UserConfiguration : EntityTypeConfiguration<User> 5 { 6 #region Ctor 7 /// <summary> 8 /// Initializes a new instance of <c>UserConfiguration</c> class. 9 /// </summary> 10 public UserConfiguration() 11 { 12 HasKey(c => c.ID); 13 Property(c => c.ID) 14 .IsRequired() 15 .HasMaxLength(36) 16 .HasDatabaseGeneratedOption(DatabaseGeneratedOption.None); 17 Property(c => c.Name) 18 .IsRequired() 19 .HasMaxLength(20); 20 } 21 #endregion 22 }
上面代碼中我們并沒有進行關聯配置,生成 MessageManagerDB 數據庫中 Messages 表結構:
可以看到我們雖然沒有進行任何的關聯設置,Code First 會自動為我們創建外鍵關聯,僅僅是在 Message 領域模型中添加:
1 public virtual User SendUser { get; set; } 2 public virtual User ReceiveUser { get; set; }
以上效果是我們想要的,這也是 EntityFramework 的進步之處,符合領域驅動設計的思想,領域模型中沒有數據庫中所謂的主外鍵關聯,有的只是對象之間的關聯,而數據庫只是存儲數據的一種表現,這樣數據庫設計的概念就不存在了,也讓我們忘了數據庫的存在,而把更多的精力放在領域模型的設計上,這就是領域驅動設計關鍵所在。
除了 EntityFramework 默認生成關聯配置,我們也可以進行自定義配置,比如,上面生成外鍵字段為:SendUser_ID 和 ReceiveUser_ID,也可以自定義字段名稱:
1 HasRequired(x => x.SendUser) 2 .WithMany() 3 .Map(x => x.MapKey("SendUserID")) 4 .WillCascadeOnDelete(false); 5 HasRequired(x => x.ReceiveUser) 6 .WithMany() 7 .Map(x => x.MapKey("ReceiveUserID")) 8 .WillCascadeOnDelete(false);
上面就是自定義外鍵字段為:SendUserID 和 ReceiveUserID,關于 EntityTypeConfiguration 的配置,比如一對一,一對多,多對多,聯合主外鍵等等,可以參考:
- http://msdn.microsoft.com/zh-cn/data/jj591620
- http://msdn.microsoft.com/en-US/en%EF%BC%8Dch/data/jj713564
后記
- GitHub 開源地址:https://github.com/yuezhongxin/MessageManager
- ASP.NET MVC 發布地址:http://www.xishuaiblog.com:8081/
- ASP.NET WebAPI 發布地址:http://www.xishuaiblog.com:8082/api/Message/GetMessagesBySendUser/小菜
更新項目:MessageManager.Domain,MessageManager.Domain.Tests,MessageManager.Repositories 和 MessageManager.Repositories.Tests,其他未更新,獲取生成會報錯。
幻想下,如果存在對象性數據庫,存儲的都是“活生生”的對象,那是怎樣的一種情形?咳咳,只是幻想。
如果你覺得本篇文章對你有所幫助,請點擊右下部“推薦”,^_^
參考資料:
- http://stackoverflow.com/questions/14033193/entity-framework-provider-type-could-not-be-loaded
- http://stackoverflow.com/questions/21641435/error-no-entity-framework-provider-found-for-the-ado-net-provider-with-invarian
- http://msdn.microsoft.com/en-us/data/upgradeef6.aspx
- http://www.cnblogs.com/libingql/p/3351275.html
文章列表