一:面向對象設計中最簡單的部分與最難的部分
如果說事務腳本是 面向過程 的,那么領域模型就是 面向對象 的。面向對象的一個很重要的點就是:“把事情交給最適合的類去做”,即:“你得在一個個領域類之間跳轉,才能找出他們如何交互”,Martin Flower 說這是面向對象中最難的部分,這具有誤導的成份。確切地說,我們作為程序員如果已經掌握了 OOD 和 OOP 中技術手段,那么如何尋找類之間的關系,可能就成了最難的部分。但在實際的情況中,即便我們不是程序員,也總能描述一件事情(即尋求關系),所以,找 對象之間的關系 還真的並不是程序員最關系的部分,從技術層面來講,尋找類之間的關系因為與具體的編碼技巧無關,所以它現在對於程序員的我們來說,應該是最簡單的部分,技術手段才是這里面的最難部分。
好,切入正題。
二:構築類之間的關系(最簡單部分)
先來完成最簡單的部分,即找關系。也就是說,按照所謂的關系,我們來重構 事務腳本 中的代碼。上篇“你在用什么思想編碼:事務腳本 OR 面向對象?”中同樣的需求,如果用領域模式來做的話,我們大概可以這樣設計:
(備注:Product 和 RecognitionStrategy 為 * –> 1 的關系是因為 一種確認算法可以被多個產品的實例對象使用)
從下面的示例代碼我們就可以看到這點:
class RevenueRecognition
{
private double amount;
private DateTime recognizedOn;
public RevenueRecognition(double amount, DateTime recognizedOn)
{
this.amount = amount;
this.recognizedOn = recognizedOn;
}
public double GetAmount()
{
return this.amount;
}
public bool IsRecognizedBy(DateTime asOf)
{
return asOf.CompareTo(this.recognizedOn) > 0 || asOf.CompareTo(this.recognizedOn) == 0;
}
}class Contract
{
// 多 對 1 的關系,* -> 1。即:一個產品可有多個合同訂單
private Product product;
private long id;
// 合同金額
private double revenue;
private DateTime whenSigned;
// 1 對 多 的關系, 1 -> *
private List<RevenueRecognition> revenueRecognitions = new List<RevenueRecognition>();
public Contract(Product product, double revenue, DateTime whenSigned)
{
this.product = product;
this.revenue = revenue;
this.whenSigned = whenSigned;
}
public void AddRevenueRecognition(RevenueRecognition r)
{
revenueRecognitions.Add(r);
}
public double GetRevenue()
{
return this.revenue;
}
public DateTime GetWhenSigned()
{
return this.whenSigned;
}
// 得到哪天前入賬了多少
public double RecognizedRevenue(DateTime asOf)
{
double re = 0.0;
foreach(var r in revenueRecognitions)
{
if(r.IsRecognizedBy(asOf))
{
re += r.GetAmount();
}
}
return re;
}
public void CalculateRecognitions()
{
product.CalculateRevenueRecognitions(this);
}
}class Product
{
private string name;
private RecognitionStrategy recognitionStrategy;
public Product(string name, RecognitionStrategy recognitionStrategy)
{
this.name = name;
this.recognitionStrategy = recognitionStrategy;
}
public void CalculateRevenueRecognitions(Contract contract)
{
recognitionStrategy.CalculateRevenueRecognitions(contract);
}
public static Product NewWordProcessor(string name)
{
return new Product(name, new CompleteRecognitionStrategy());
}
public static Product NewSpreadsheet(string name)
{
return new Product(name, new ThreeWayRecognitionStrategy(60, 90));
}
public static Product NewDatabase(string name)
{
return new Product(name, new ThreeWayRecognitionStrategy(30, 60));
}
}abstract class RecognitionStrategy
{
public abstract void CalculateRevenueRecognitions(Contract contract);
}class CompleteRecognitionStrategy : RecognitionStrategy
{
public override void CalculateRevenueRecognitions(Contract contract)
{
contract.AddRevenueRecognition(new RevenueRecognition(contract.GetRevenue(), contract.GetWhenSigned()));
}
}class ThreeWayRecognitionStrategy : RecognitionStrategy
{
private int firstRecognitionOffset;
private int secondRecognitionOffset;
public ThreeWayRecognitionStrategy(int firstRoff, int secondRoff)
{
this.firstRecognitionOffset = firstRoff;
this.secondRecognitionOffset = secondRoff;
}
public override void CalculateRevenueRecognitions(Contract contract)
{
contract.AddRevenueRecognition(
new RevenueRecognition(contract.GetRevenue() / 3, contract.GetWhenSigned()));
contract.AddRevenueRecognition(
new RevenueRecognition(contract.GetRevenue() / 3, contract.GetWhenSigned().AddDays(firstRecognitionOffset)));
contract.AddRevenueRecognition(
new RevenueRecognition(contract.GetRevenue() / 3, contract.GetWhenSigned().AddDays(secondRecognitionOffset)));
}
}
正像我說的,以上的代碼是最簡單部分,每個 OOP 的初學者都能寫出這樣的代碼來。但是我心想,即便我們能寫出這樣的代碼來,我們恐怕都不會心虛的告訴自己:是的,我正在進行領域驅動開發吧。
那么,真正難的部分是什么?
2.1 領域模型 對於程序員來說真正困難或者困惑的部分
是領域模型本身怎么和其它模塊(或者其它層)進行交互,這些交互或者說關系是:
1:領域模型 自身具備些什么語言層面的特性;
2:領域模型 和 領域模型 之間的關系;
3:領域模型 和 Repository 的關系;
4:工作單元 和 領域模型 及 Repository 的關系;
5:領域模型 的緩存;
6:領域模型 和 會話之間的關系;
三:那些交互與關系
3.1 領域模型 自身具備些什么語言層面的特性
先看代碼:
public class User2 : DomainObj
{
#region Field
#endregion#region Property
#endregion
#region 領域自身邏輯
#endregion
#region 領域服務
#endregion
}
對於一個領域模型來說,從語言層面來講,它具備 5 方面的特性:
1:有父類,放置公共的屬性之類的內容,同時,存在一個父類,也表示它不是一個 值對象(領域概念中的值對象);
2:有實例字段;
3:有實例屬性;
4:領域自身邏輯,非 static 方法,有 public 的和 非public;
5:領域服務,static 方法,可獨立出去放置到對應的 服務類 中;
現在,我們具體展開一下。不過,為了展開講,我們必須提供一個稍稍完整的 User2 的例子,它在真正的項目是這個樣子的:
public class User2 : DomainObj
{
#region Field
private Organization2 organization;private List<YhbjTest> myTests;
private List<YhbjClass> myClasses;
#endregion
#region Property
public override IRepository RootRep
{
get { return RepRegistory.UserRepository; }
}public string UserName { get; private set; }
public string Password { get; private set; }
/* 演示了同時存在 Organization 和 OrganizationId 兩個屬性的情況 */
public string OrganizationId { get; private set; }public Organization2 Organization
{
get
{
if (organization == null && !string.IsNullOrEmpty(OrganizationId))
{
organization = Organization2.FindById(OrganizationId);
}return organization;
}
}/* 演示了存在 列表 屬性的情況 */
public List<YhbjClass> MyClasses
{
get
{
if (myClasses == null)
{
myClasses = YhbjClass.GetClassesByUserId(this);
}return myClasses;
}
}public List<YhbjTest> MyTests
{
get
{
/* 我的考試來自兩個地方,1:班級、項目上的考試;2:選人的考試;
* 故,有兩種設計方法
* 1:選人的考試沒有疑議;
* 2:班級、項目考試,可以從本模型的 Classes -> Projects -> Tests 獲取;
* 3:也可以直接從數據庫得到獲取;
* 在這里的實際實現,采用第 2 種做法。因為:
* 1:數據本身是緩存的,第一獲取的時候,貌似存在多次查詢,但是一旦獲取就緩存了;
* 2:存在很多地方的數據一致性問題,采用方法 3 貌似快速了,但會帶來不可知 BUG ;
* 3:即便將來考試還有課程上的考試,可以很方便的獲取,不然還需要重改 SQL;
*/
if (myTests == null)
{
myTests = new List<YhbjTest>();/* 加指定人的考試,這些考試沒有對應的 項目 和 班級*/
myTests.AddRange(YhbjTest.GetTestsByUserId(this.Id));/* 加班級的考試,有對應的 班級 */
foreach (var c in MyClasses)
{
myTests.AddRange(c.Tests);
foreach (var t in c.Tests)
{
t.SetOwnerClass(c);
}/* 加項目的考試,有對應的 班級 和 項目,代碼略 */
}
}/* 其它邏輯 */
foreach (var test in myTests)
{
if (test.TestHistory == null)
{
test.SetHistory(MyTestHistories
.FirstOrDefault(p => p.TestId == test.Id && p.UserId == this.Id));
}
}return myTests;
}
}
#endregion#region 領域自身邏輯
public void InitWithOrganization(Organization2 o)
{
/* 在這個方法中不用 MakeDirty,因為相當於初始化分為兩步進行了
*/
this.organization = o;
}
/* 不需要對外開放的邏輯,使用 internal*/
internal virtual void UpdateOnline(string loginTime, string token, string loginIp, string loginPort)
{
/* 這樣做的好處是什么呢?
* createnew 方法用戶不負責自己的持久化,而是由事務代碼進行負責
* 但是 createnew 方法會標識自己為 new,即 makenew 方法調用
* 然后,由於 ut 用的都是同一個 ut,所以在事務這里 commit 了
* 就是 commit 了 根 和 非根
* 這里也同時演示了多個領域對象共用一個 ut
*/
this.UnitOfWork = new UnitOfWork();
UserOnline2 userOnline2Old = UserOnline2.GetUserOnline(this.UserName);
if (userOnline2Old != null)
{
userOnline2Old.UnitOfWork = this.UnitOfWork;
userOnline2Old.Delete();
}UserOnline2 = UserOnline2.CreateNew(UserName, loginIp, loginPort);
UnitOfWork.RegisterNew(UserOnline2);
UnitOfWork.Commit();
}/* 對外開放的邏輯,使用 public */
public List<YhbjTest> GetMyTest(string testName, int type, int page, int size, out int totalCount)
{
IEnumerable<YhbjTest> expName = from p in MyTests orderby p.CreateTime descending select p;
IEnumerable<YhbjTest> expState = null;switch (type)
{
case 0:
/* 未考
* 需要排除掉 有效期 之外
*/
expState =
from p in expName
where
p.StartTime <= DateTime.Now &&
p.EndTime >= DateTime.Now &&
(this.MyTestHistories.Exists(q => q.TestId == p.Id) == false
|| (this.MyTestHistories.Exists(q => q.TestId == p.Id) == true && this.myTestHistories.Find(h => h.TestId == p.Id).TestState != 1)) &&
p.AuditState == AuditState.Audited
select p;
break;
default:
throw new ArgumentOutOfRangeException();
}var re = expState.ToList();
totalCount = re.Count;
return re.Skip((page - 1) * size).Take(size).ToList();
}public YhbjTest StartTest(string testId)
{
// 邏輯略
}#endregion
/// <summary>
/// 1:服務是無狀態的,所以是 static 的
/// 2:服務是公開的,所以是 public 的
/// 3:服務實際是可以創建專門的服務類的,這里為了演示需要,就放在一起了
/// </summary>
#region 領域服務/* 這兩個字段演示其實服務部分的代碼是隨意的 */
private static readonly CookieWrapper CookieWrapper;private static readonly HttpWrapper HttpWrapper;
static User2()
{CookieWrapper = new CookieWrapper();
HttpWrapper = new HttpWrapper();
}/* 內部的方法當然是私有的 */
private static List<PaperQuestionStrategy3> GetUserPaperByUserAndTest(User2 user2, YhbjTest test)
{
var x = RepRegistory.UserRepository.FindTestUserPaper(user2, test);
return x;
}/* 獲取領域對象的方法,全部屬於領域服務部分,再次強調是靜態的 */
public static User2 GetUserByName(string username)
{
var user = (RepRegistory.UserRepository).FindByName(username);
return user as User2;
}
/* 領域對象的獲取和產生,還有另外的做法,就是在對象工廠中生成,但這不屬於本文要闡述的范疇 */
public static User2 CreateCreater(
string creatorOrganizationId, string creatorOrganizationName, string id, string name)
{
var user = new User2 { Id = id, Name = name, UnitOfWork = new UnitOfWork() };
user.MakeNew();
return user;
}
#endregion
}
請仔細查看上面代碼,為了本文接下來的闡述,上面的代碼幾乎都是有意義的,我已經很精簡了。好了,基於上面這個例子,我們展開講:
1:父類
public abstract class DomainObj
{
public Key Key { get; set; }/// <summary>
/// 根倉儲
/// TIP: 因為是充血模式,所以每個領域模型都有一個根倉儲
/// 用於提交自身的變動
/// </summary>
public abstract IRepository RootRep { get; }protected DomainObj()
{
}public UnitOfWork UnitOfWork { get; set; }
public string Id { get; protected set; }
public string Name { get; protected set; }
protected void MakeNew()
{
UnitOfWork.RegisterNew(this);
}protected void MakeDirty()
{
UnitOfWork.RegisterDirty(this);
}protected void MakeRemoved()
{
UnitOfWork.RegisterRemoved(this);
}}
父類包含了,讓一個 領域模型 成為 領域模型 所必備的那些特點,它有 標識映射(架構模式對象與關系結構模式之:標識域(Identity Field)),它持有 工作單元(),它負責調用 工作單元的API(換個角度說工作單元(Unit Of Work):創建、持有與API調用)。
如果我們的對象是一個 領域模型對象,那么它必定需要繼承之這個父類;
2:有實例字段
有人可能會有疑問,不是有屬性就可以了嗎,為什么要有字段,一個理由是,如果我們需要 延遲加載(),就需要使用字段來進行輔助。我們在上面的源碼中看到的 if XX == NULL ,這樣的屬性代碼,就是延遲加載,其中使用到了字段。注意,如果使用了延遲加載,你應該會遇到序列化的問題,這是你需要注意的《延遲加載與序列化》。
3:有實例屬性
屬性是必然的,沒有屬性的領域模型很稀少的。有幾個地方需要大家注意,
1:屬性的 get 方法,可以是很復雜的,其地位相當於是領域自身邏輯;
2:set 方法,都是 private 的,領域對象自身負責自身屬性的賦值;
3:在有必要的情況下,使用 延遲加載,這可能需要另外一個主題來講;
4:延遲加載的那些屬性,很多時候就是 導航屬性,即 Organization 和 MyClasses 這樣的屬性,就是導航屬性;
4:領域自身邏輯
領域自身邏輯,包含了應用系統大多數的業務邏輯,可以理解為:它就是傳統 3 層架構中的業務邏輯層的代碼。如果一段代碼,你不知道把它放到哪里,那么,它多半就屬於應該放在這里。注意,只有應該公開的那些方法,才 public;
5:領域服務
領域服務,可以獨立出去,成為領域服務類。那么,什么樣的代碼是領域服務代碼?第一種情況:
生成領域對象實例的方法,都應該是領域服務類。如 查詢 或者 Create New。
在實際場景中,我們可能使用對象工廠來生成它們,這里為了純粹的演示哪些是 領域自身邏輯,哪些是 領域服務,特意使用了領域類的 static 方法來生成領域對象。即:
領域對象,不能隨便被外界生成,要嚴格控制其生成。所以領域父類的構造器,我們看到是 protected 的。
那么,實際上,除了上面這種情況外,任何代碼都應該是 領域自身邏輯的。我在上面還演示了這樣的一段代碼:
private static List<PaperQuestionStrategy3> GetUserPaperByUserAndTest(User2 user2, YhbjTest test)
{
var x = RepRegistory.UserRepository.FindTestUserPaper(user2, test);
return x;
}
這段代碼,實際上作為領域服務部分,就是錯誤的,它應該被放置在 YhbjTest 這個領域類中。
3.2 領域模型 和 領域模型 之間的關系
也就是說那些導航屬性和領域模型有什么關系。導航屬性必須都是延遲加載的嗎?當然不是。比如, User 所在的 Organization,我們在在使用到用戶這個對象的時候,幾乎總是要使用到其組織信息,那么,我們在獲取用戶的時候,就應該立即獲取到組織對象,那么,我們的持久化代碼是這樣的:
public override DomainObj Find(Key key)
{
var user = base.Find(key) as User2;
if (user == null)
{
//
string sql = @"
DECLARE @ORGID VARCHAR(32)='';
SELECT @ORGID=OrganizationId FROM [EL_Organization].[USER] WHERE ID=@Id
SELECT * FROM [EL_Organization].[USER] WHERE ID=@Id
SELECT * FROM [EL_Organization].[ORGANIZATION] WHERE ID=@ORGID";
var pms = new SqlParameter[]
{
new SqlParameter("@Id", key.GetId())
};var ds = SqlHelper.ExecuteDataset(CommandType.Text, sql, pms);
user = DataTableHelper.ToList<User2>(ds.Tables[0]).FirstOrDefault();
var o = DataTableHelper.ToList<Organization2>(ds.Tables[1]).FirstOrDefault();
if (user == null)
{
return null;
}user = Load(user);
// 注意,除了 Load User 還需要 Load Organization
user.InitWithOrganization(o);
Load(user.Organization);return user;
}return user;
}
可以看到,我們在一次 sql 執行的時候,就得到了 organization,然后,User2 類型中,有個屬於領域自身邏輯方法:
public void InitWithOrganization(Organization2 o)
{
/* 在這個方法中不用 MakeDirty,因為相當於初始化分為兩步進行了
*/
this.organization = o;
}
在這里要多說一下,如果不是初始化時候的改屬性,如修改了用戶的組織信息,就應該 MakeDirty。
注意,還有一個比較重要的領域自身邏輯,就是 SetOwned,如下:
public void SetOwnerClass(YhbjClass yhbjClass)
{
this.OwnerClass = yhbjClass;
/* should not makeDirty, but if class repalced or removed, should makedirty*/
}
比如,領域模型 考試,就可能會有這個方法,考試本身需要知道:我屬於哪個班級。
3.3 領域模型 和 Repository 之間的關系
第一,如果我們在使用 領域模型,我們必須使用 Repository 模式嗎?答案是:當然不是,我們可以使用 活動記錄模式(什么是活動記錄,當前我們可以暫時理解為傳統3層架構中的DAL層)。如果我們在使用 Repository ,那么,領域模型和 Respository 之間是什么關系呢?這里,有兩點需要闡述:
第一點是,一般的做法,Repository 是被注入的,它可能被注入到系統的某個地方,示例代碼是被注入到了類型 RepRegistory中。
領域模型要不要使用 Repository,我的答案是:要。
為什么,因為我們要讓領域邏輯自己決定合適調用 Repository。
第二點是,每個領域模型都有一個 RootRep,用於自身以及把自身當成根的那些導航屬性對象的持久化操作;
3.4 工作單元 和 領域模型 及 Repository 的關系
這一點比較復雜,我們單獨在 《換個角度說工作單元(Unit Of Work):創建、持有與API調用》 進行了闡述。當然,跟 Repository 一樣,使用 領域模型,必須使用 工作單元 嗎?答案也是不是。只是,在使用 工作單元 后,更易於我們處理 領域模型 中的事務問題。
3.5 領域模型的緩存
緩存分為兩類,第一類我們可以稱之為 一級緩存,這對於客戶端程序員來說,不可見,它被放置在 AbstractRepository 中,往往在當前請求中有用:
public abstract class AbstractRepository : IRepository
{
/* LoadedDomains 在有些文獻中可以作為高速緩存,但是這個緩存可不是指的
* 業務上的那個緩存,而是 片段 的緩存,指在當前實例的生命周期中的緩存。
* 業務上的緩存在我們的系統中,由每個領域模型的服務部分自身持有。
*/
protected Dictionary<Key, DomainObj> LoadedDomains =
new Dictionary<Key, DomainObj>();public virtual DomainObj Find(Key key)
{
if (LoadedDomains.ContainsKey(key))
{
return LoadedDomains[key] as DomainObj;
}
else
{
return null;
}//return null;
}public abstract void Insert(DomainObj t);
public abstract void Update(DomainObj t);
public abstract void Delete(DomainObj t);
public void CheckLoaedDomains()
{
foreach (var m in LoadedDomains)
{
Console.WriteLine(m.Value);
}
}
/// <summary>
/// 當緩存內容發生變動時進行重置
/// </summary>
/// <param name="keyField">緩存key的id</param>
/// <param name="type">緩存的對象類型</param>
public void ResetLoadedDomainByKey(string keyId,Type type)
{
var key=new Key(keyId,type);
if (LoadedDomains.ContainsKey(key))
{
LoadedDomains.Remove(key);
}
}protected T Load<T>(T t) where T : DomainObj
{
var key = new Key(t.Id, typeof (T));
/* 1:這一句很重要,因為我們不會想要放到每個子類里去賦值
* 2:其次,如果子類沒有調用 Load ,則永遠沒有 Key,不過這說得過去
*/
t.Key = key;if (LoadedDomains.ContainsKey(key))
{
return LoadedDomains[key] as T;
}
else
{
LoadedDomains.Add(key, t);
return t;
}//return t;
}protected List<T> LoadAll<T>(List<T> ts) where T : DomainObj
{
for (int i = 0; i < ts.Count; i++)
{
ts[i] = Load(ts[i]);
}return ts;
}
}
業務系統中的緩存,需要我們隨着業務系統自身的特點,自己來創建,比如,如果我們針對 User2 這個領域模型建立緩存,就應該把這個緩存掛接到當前會話中。此處不表。
3.6 領域模型 與 會話之間的關系
這是一個有意思的話題,無論是理論上還是實際中,在一次會話當中(如果我們會話的參照中,可以回味下 ASP.NET 中的 Session,它們所表達的概念是一致的),只要會話不失效,那么 領域對象 的狀態,就應該是被保持的。這里難的是,我們怎么來創建這個 Session。Session 回到語言層面,就是一個類,它可能會將領域對象保持在 內存中,或者文件中,或者數據庫中,或者在一個分布式系統中(如 Memcached,《ASP.NET性能優化之分布式Session》)。
最簡單的,我們可以使用 ASP.NET 的 Session 來保存我們的會話,然后把領域對象存儲到這里。
四:總結
以上描述了讓領域模型成為領域模型的一些最基本的技術手段。解決了這些技術手段,我們的開發才基本算是 DDD 的,才是面向領域模型的。解決了這些技術問題,接下來,我們才能毫無后顧之憂地去解決 Martin Flower 所說的最難的部分:“你得在一個個領域類之間跳轉,才能找出他們如何交互”。