寫在前面
首先,這篇博文是用博客園新發布的 MarkDown編輯器 編寫的,這也是我第一次使用,語法也不是很熟悉,但我覺得應該會很爽,博文后面再記錄下用過的感受,這邊就不多說。
閱讀目錄:
- 上一篇回顧-設計誤區
- 值對象映射探討
- 走過的坑-正確配置
- 后記-附帶(CNBlogs 使用 Mardown 小記)
領域驅動設計中,關于領域模型和 EntityFramework 之間的映射配置,其實之前寫過一篇《死去活來,而不變質:Domain Model(領域模型) 和 EntityFramework 如何正確進行對象關系映射?》博文,因為當時主要精力是在領域模型的設計中,持久化問題考慮的太早,所以在當時領域驅動設計的道路上跑偏了。現在領域模型設計的差不多了,因為之前都是在 Repository(倉儲)中使用靜態集合跑程序,現在持久化的問題是該考慮了。
說真的,其實現在來看,上一篇探討的內容還是蠻有價值的,如果你對領域模型和 EntityFramework 之間映射配置感興趣,最好還是閱讀下上一篇博文,如果沒時間閱讀也沒關系,我來帶你簡單回顧一下。
上一篇回顧-設計誤區
上一篇博文的關鍵字是:死去活來,而不變質,也就是:如何把活的變成死的?又如何把死的變成活的?更重要的是如何保證在這個“死去活來”的過程中,死的和活的是同一個?
活的:Domain Model(領域模型),主要是領域模型中的 Entity(實體)對象。
死的:使用 ORM 工具映射,把領域模型映射到關系型數據庫的表數據。
在領域驅動設計中,數據庫設計的概念是被我們所拋棄的,也就是說,在你領域模型設計的過程中,不應該考慮數據庫的因素,這個過程應該放到最后,也就是我現在所考慮的,這也就是為什么之前探討持久化問題是跑偏的原因了。還有一個重要概念,就是數據庫不是被設計的,而是應該被生成的,當你應用程序設計完成的時候,你只需要配置下倉儲的持久化實現,這樣數據庫就可以使用 Code First 進行生成了。
過程雖然說起來簡單,實現起來卻不是那么容易,因為我們長久以往受數據庫驅動模式的影響,在應用程序開發的時候,就會不自覺的去考慮數據庫。比如一個用戶模塊,按照我們傳統的開發模式,應該是先設計用戶模塊的表結構(用戶表、用戶部門表、用戶權限表等等),然后根據表結構去設計一大堆的 SQL 語句(左關聯、右關聯、自己關聯等等),數據庫訪問層(DAL)就充斥著大量的 SQL 代碼,其實這些代碼就反應了業務需求,以至于我們的業務邏輯層(BLL)變成了一個方法調用者(dal.GetUser....
),它確實很薄,薄到可以直接忽略掉,客戶端代碼是怎樣的呢?簡單的來說就是從界面上獲取值,然后 new
一個 bll
對象,調用方法傳入值,沒錯,就是這樣。
那這樣致使的結果是怎樣的呢?比如要該一個需求,麻煩一點的就是,我們需要改表結構,改完表結構,我們需要改數據訪問層的 SQL 代碼,改完 SQL 代碼,我們需要改業務邏輯層中的方法參數,改完方法參數,我們需要改客戶端的調用....沒完沒了,這還只是一個需求的變更,我相信我們每天遇到的不只是一個吧,想想真是太痛苦了。
好像有點偏離主題了,但是體會這個傳統開發模式是很重要的,因為只有體會到它的痛苦,你才會想辦法去改變它,當然除非你是處在一個“溫水煮青蛙”的環境中,這個就沒辦法了。
回到領域驅動設計上來,領域模型(主要是實體,后面用實體表示)如何使用 EntityFramework 進行映射配置?簡單一點,這個實體沒有任何對象的關聯,那我們根根不需要什么映射配置,只需要配置一下主鍵和字段長度就行了。但是如果存在對象關聯,我們怎么配置呢?按照之前數據庫驅動模式的開發,肯定要在相應的關聯表中加入外鍵,那我們的實體就會變成這樣:
namespace MessageManager.Domain.DomainModel
{
public class Message : IAggregateRoot
{
#region 構造方法
public Message()
{
this.ID = Guid.NewGuid().ToString();
}
#endregion
#region 實體成員
public string FromUserID { get; set; }
public string FromUserName { get; set; }
public string ToUserID { get; set; }
public string ToUserName { get; set; }
public string Title { get; set; }
public string Content { get; set; }
public DateTime SendTime { get; set; }
public bool IsRead { get; set; }
public virtual User FromUser { get; set; }
public virtual User ToUser { get; set; }
#endregion
#region IEntity成員
/// <summary>
/// 獲取或設置當前實體對象的全局唯一標識。
/// </summary>
public string ID { get; set; }
#endregion
}
}
按照我們之前數據庫模式,會覺得這樣設計沒錯啊,但是現在是基于領域驅動設計,你會那發現 FromUserID
、ToUserID
這兩個是什么東西啊?只是為了方便數據庫映射,就加入這兩個“外鍵”,很顯然,這種設計是不合理的。
還有一種設計也是不合理的,就是在實體屬性上面加入 EntityFramework 屬性配置,領域模型中應該是和技術無關的,如果加入技術實現,那這個領域模型就被污染了,像 EntityFramework 的 Attribute 配置應該放在基礎層去實現,當然我個人覺得,這是 EntityFramework 有點誤導人的感覺,因為在實體屬性上面進行配置更方便,但是在領域驅動設計中,這樣實現并不合理,比如下面這段代碼:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace DemoTag.Domain.Entities
{
[Table("TagUseCount")]
public class TagUseCount
{
[Key]
[Column(Order = 1)]
public Guid AppGuid { get; set; }
[Key]
[Column(Order = 2)]
[ForeignKey("Tag")]
public int TagId { get; set; }
public int UseCount { get; set; }
public virtual Tag Tag { get; set; }
}
}
如果我們不這樣進行實現,那我們如何進行映射配置呢?這個實現在后面有講解,在實現之前,要先明確幾個重要概念:
1,領域模型不參雜任何的技術實現。
2,數據庫的映射配置,不影響領域模型(比如上面的FromUserID
、ToUserID
,就是很不合理)。
3,數據庫的映射配置,屬于技術實現,應該放在基礎層中。
因為第二點相對比較難理解一點,這邊我就再簡單說明下,數據庫是領域模型存儲數據的一種方式(我們也可以使用其他方式進行存儲),現在的關系型數據庫都是“扁平化”存儲,所以像對象之中關聯對象,我們一般都是要進行外鍵配置,這因為有了 ORM 工具,所以我們可以很方便的進行對象關系映射(ORM 的中文意思),對象指的就是領域模型,關系就是關系型數據庫。所以我們映射配置不應該影響領域模型,具體怎么進行配置?這是 ORM 工具所考慮的問題,上一篇的內容是主要是關于實體映射配置,下面簡單說下領域模型中值對象的映射配置。
值對象映射探討
有人可能有些疑問,值對象需要映射配置嗎?當然,簡單一點的枚舉類型的值對象,是不需要進行映射配置的,比如下面 MessageState
這個值對象:
/**
* author:xishuai
* address:https://www.github.com/yuezhongxin/MessageManager
**/
namespace MessageManager.Domain.ValueObject
{
public enum MessageState
{
Unread,
Read,
}
}
在 Message 實體中對應的關聯:
public MessageState State { get; private set; }
上面這段代碼,如果我們使用 EntityFramework,是不需要任何映射配置的,枚舉類型的值對象會自動映射為 int
類型,比如上面 MessageState
的映射結果為:0 代表 Unread
,1 代表 Read
。這個映射過程,在領域驅動設計中是不關心的,在應用層,我只關心從倉儲中持久化的對象或者獲取的對象,是不是正確的實體對象?是不是正確的值對象?也就是說我現在在應用層中去編寫下面這段代碼:
using (IRepositoryContext repositoryContext = new EntityFrameworkRepositoryContext())
{
IMessageRepository messageRepository = new MessageRepository(repositoryContext);
Message message = messageRepository.GetByKey(1);
if (message.State == MessageState.Unread)
{
//默認是未讀
}
}
message.State == MessageState.Unread
這是我所關心的,我從倉儲中取的是不是我所存儲的正確值對象。其實這也是 EntityFramework 這一類 ORM 工具的強大之處,在領域驅動設計中更能得到體現,它讓我們更專注于領域模型的設計,而不考慮數據是怎樣進行存儲的,那如何進行隔離他們兩者呢?答案就是 Repository(倉儲),很多時候,都是由問題引出概念,這樣理解的才會更加深刻。
如果我們映射的不是枚舉類型的值對象,而是其他類型的值對象,我們怎么進行映射配置呢?比如下面 Contact 值對象:
/**
* author:xishuai
* address:https://www.github.com/yuezhongxin/MessageManager
**/
namespace MessageManager.Domain.ValueObject
{
public class Contact
{
public Contact(string name)
{
this.Name = name;
}
public Contact(string name, string displayName)
{
this.Name = name;
this.DisplayName = displayName;
}
public string Name { get; private set; }
public string DisplayName { get; private set; }
}
}
先說一下 Contact
值對象的意思,表示 Message
實體中的抽象“聯系人”標識,說白了就是發送人和接收人的意思,但這個發送人或接收人不一定是“人”,也可能是郵箱等,就是一個標識的意思,這個“標識”從是外部取得的,也就是說在消息這個系統中是不存儲的,我只知道這個標識是什么?那不需要知道它是哪個?這也就是為什么設計成值對象的原因了。
Contact
值對象就不像 MessageState
值對象不需要那樣了,這個就必須在 EntityFramework 進行配置的,具體如何進行映射配置,請看下面,走過的坑。
走過的坑-正確配置
首先,我試了下,如果不進行映射配置會是怎樣的結果,比如我們在 MessageConfiguration 映射配置類中(實現在基礎層)配置如下:
using MessageManager.Domain.Entity;
using System.ComponentModel.DataAnnotations.Schema;
using System.Data.Entity.ModelConfiguration;
namespace MessageManager.Repositories.EntityFramework.ModelConfigurations
{
public class MessageConfiguration : EntityTypeConfiguration<Message>
{
/// <summary>
/// Initializes a new instance of <c>MessageConfiguration</c> class.
/// </summary>
public MessageConfiguration()
{
HasKey(c => c.ID);
Property(c => c.ID)
.IsRequired()
.HasDatabaseGeneratedOption(DatabaseGeneratedOption.Identity);
Property(c => c.Title)
.IsRequired()
.HasMaxLength(50);
Property(c => c.Content)
.IsRequired()
.HasMaxLength(2000);
Property(c => c.SendTime)
.IsRequired();
}
}
}
可以看到,我們只對一些簡單屬性進行了簡單配置,并沒有對 Contact
進行任何的映射配置,那 EntityFramework 生成數據庫會是怎樣呢(使用 Code First 模式)?答案就是:報錯。
RepositoryTest_AddMessage 單元測試代碼(一定要先進行單元測試,在領域驅動設計開發過程中,非常重要):
/**
* author:xishuai
* address:https://www.github.com/yuezhongxin/MessageManager
**/
using MessageManager.Domain.Entity;
using MessageManager.Domain.Repositories;
using MessageManager.Domain.ValueObject;
using MessageManager.Repositories.EntityFramework;
using Xunit;
namespace MessageManager.Repositories.Tests
{
public class MessageRepositoryTest
{
[Fact]
public void RepositoryTest_AddMessage()
{
IMessageRepository messsageRepository = new MessageRepository(new EntityFrameworkRepositoryContext());
messsageRepository.Add(new Message("title", "content", new Sender("1", "小菜"), new Recipient("2", "大神")));
messsageRepository.Context.Commit();
}
}
}
異常信息:
注意紅圈里面的信息,因為我只找到這個異常信息(第一段):在 System.Data.Entity.Utilities.Check.NotNull T (T value, String parameterName),完全不知道是什么原因,NotNull
也就是有一個參數為 NULL
,具體是什么,并不知道,怎么辦呢?難道讓我去調試 EntityFramework 源碼?把 Google 給忘了,搜索了一下,在 stackoverflow 中找到了類似問題,解決方案就是:
[NotMapped]
public HttpPostedFileBase Photo { get; set; }
NotMapped
顧名思義,就是忽略映射的意思,也就是說在 EntityFramework 生成數據庫的時候,Photo
這個屬性并不映射。NotMapped
是直接在實體中定義屬性配置,這個我們在上面強調過,這樣設計不是合理的,我們應該在 MessageConfiguration 中進行配置,那就不能使用 NotMapped
屬性了,在 EntityTypeConfiguration 配置中,找到 Ignore
方法,配置如下:
Ignore(c => c.Sender);
Ignore(c => c.Recipient);
配置好了,我們再生成數據庫:
可以看到我們是生成成功的,Message
實體對象的 Sender
和 Recipient
是被忽略的,但是這并不是我們想要的結果,因為我們是要映射配置 Contact
,這才是我們的目的,怎么把它給忽略了啊。雖然走了彎路,但是讓我們發現異常問題,確實是 Contact
映射引起的(我之前還懷疑是不是 EntityFramework 配置有什么問題)。
確定了問題的原因,就要找相應的解決辦法。因為值對象強調的是“值”的概念,也就是說映射到數據庫的時候,要把值對象進行“扁平化”處理,Contact
值對象包含 Name
和 DisplayName
兩個屬性(之前還有一個 LoginName
屬性,后來考慮了一下,其實并不需要),也就是說,這兩個屬性都必須映射到 Message
實體中,然后 EntityFramework 進行數據到對象的轉化,我們就可以通過 message.Sender
訪問到 Contact
值對象了,這是我們想要的效果,在倉儲中只需要 Add 和
Get`Message
對象,并不需要 Contact
值對象的任何操作,因為 Contact
值對象是依附于 Message
實體的,所以必須通過 Message
實體進行操作。
Google 中搜索“entitytypeconfiguration value object”,在 stackoverflow 中找到相似的解決方法,配置如下:
Property(c => c.Sender.Name)
.HasColumnName("SenderName")
.IsRequired()
.HasMaxLength(36);
Property(c => c.Recipient.Name)
.HasColumnName("RecipientName")
.IsRequired()
.HasMaxLength(36);
Property(c => c.Sender.DisplayName)
.HasColumnName("SenderDisplayName")
.HasMaxLength(50);
Property(c => c.Recipient.DisplayName)
.HasColumnName("RecipientDisplayName")
.HasMaxLength(50);
生成相應數據庫:
單元測試:
其實在 entitytypeconfiguration 的配置中,不止上面的一些坑,還有很多沒有記錄到,關于 entitytypeconfiguration 的正確配置,請參考 MSDN 中的相關內容。
后記-附帶(CNBlogs 使用 Mardown 小記)
CNBlogs 使用 Mardown 使用感受
- 寫代碼,寫博文,這種方式很爽。
- 以前用其他編輯器寫博文,會有很多樣式干擾,比如復制編輯器中的內容,會把格式也復制進來,造成 html 的臃腫(看著很多重復的 span 標記,就是不爽)。
- 修改起來很方便,比如修改插入的代碼,直接在里面修改就可以了。
- 方便統一博文內容整體的樣式。
- 寫起來超迅速,流暢,這篇博文內容也不是很少,歷時幾個小時(平常會多點),寫起來的“手感”很好。
- 當然是簡約了,但不失簡單。
- 。。。。。
CNBlogs 使用 Mardown 使用小技巧
- 如果博文是使用 Mardown 編寫的,正文的 div 會添加一個 cnblogs-markdown class 樣式,這樣方便我們修改用 Mardown 寫的博文樣式,比如修改字體,就可以添加如下樣式:.cnblogs-markdown p { font-size: 15px; }。
- 可以使用 Mardown 在線編輯器,這樣可以一邊寫,一邊查看樣式,然后再復制到 CNBlogs 中。
- 暫時發現這么多,后面再補充。。。
回到正題,關于 Value Object(值對象)如何使用 EF 進行正確映射?你會發現,其實也就是這一點內容,但都是踩著坑走過來的,需要注意的是,在進行映射配置的時候,要始終記得:映射配置不能影響到領域模型,也就是說,如果映射配置出現了問題,不能從領域模型中去找解決方案,這是技術問題,不能污染到領域模型。
關于領域驅動設計的實踐-MessageManager,也開發不少時間了,同時也整理了幾篇博文,如果你對領域驅動設計感興趣,可以訪問下 DDD 標簽 進行了解,后面有時間再做個詳細總結,這篇內容就到這里,也感謝你可以看到這。
文章列表