怪怪設計論閑談篇:職責與解耦的矛盾
正式討論之前,先看看這兩個問題:當我們的對象所涉及的操作不斷增加時,我們是否應該:Book.Save,Book.Serialize, Book.Method1,Book.Method2這樣一直增加下去?或者在某個垂直的邏輯中增加其它邏輯時,不斷的擴充Book.Save,要么象有的朋友說的那樣分離 出去,再回調?但是Book.Save有理又有在,無論數據->對象,還是對象->數據,考慮到數據和對象經常一起變化,恢復對象的狀態這部分確實應該留在對象內部,同時,我也認可這本來就是對象的職責。
為了大家所謂的“低耦高聚”的目標,也為了保持職責的合理性,希望大家考慮一下,當Book沒有Save時,我們除了屬性賦值是不是就無路可走了?這就得 那些沒包含在這次討論中的習慣性做法(比如平時對.NET Framework和ADO.NET的使用方式)包含近來,看看很多同志指出的美女走光問題,除了給美女一個電棍讓美女負擔起警察打擊偷窺者的職責,能不 能通過換件裙子來解決。
我們平時用慣了IDataReader給對象賦值,所以很多人說的那種,從外部通過屬性賦值的情況就廣泛存在,比如CommunityServer。如果 換一個方式呢? 把IDataReader直接交給Book, 然后Book自己展開數據是不是好很多呢?于是有人又說了,這跟IDataReader耦合了,不利于移植等等。或者我根本沒用ADO,所以沒 IDataReader。后者可以通過給自己的數據操作層實現IDataReader搞定; 關鍵是前者,前者的非法性還表現在,把ADO的接口帶入了邏輯層;等等等等, 反正很多。那么為什么不能自己做一些類似IDataReader的接口, 然后把ADO.NET包含的概念作為變化封裝出去呢?
在保存數據的時候也一樣,不是把數據全部讀出來去保存,而是讓Book準備好需要保存的數據,總而言之歸它管的一分不落,然后實現或者返回出一個統 一的接口里面全都是要持久的數據。至于如何跑回書架上,或者被賊給偷跑了,那是別人的事。 畢竟某兄弟回復說的,今天加個Cache明天加個Log后天加個Permission最后數據庫都不用了的情況也確實有。這些都該Book負責嗎? 不是說只添加不修改嗎? 難道非得要求必須AOP? 我是Anders的忠實Fans,我不認為AOP解決了什么本質問題。怎么又說回職責問題了,總而言之,現在總不能有人說美女 走光了吧。
其實我們所說的方法,往往都有學習的對象,大家可以想想各種Control對于ViewState的使用, 它其實就是這么一個玩意(關于Control如何使用和持久化ViewState的文章,園子里就有)。那么我們也可以這樣,數據就是數據,要什么屬性, 而且IDataReader出來的不就是一砣類似字典的東西嗎?正好直接拿字符串和我們自己定義BL層與數據層之間對接的接口對應(與 IDataReader不同的是,我們定義的接口是在邏輯層中使用的,除了象一個數據集,不包含任何和數據層相關的內容),覺得不過癮,把對應關系保存到 配置文件里去。無論如何,希望讓對象自己負責恢復狀態,同時又不希望對象負責存取邏輯的矛盾,并非無法解決,ViewState的方式只是其中一種解決方 法。
我們不妨再看看.NET Framework如何讓Serializer和你的對象在沒有聯系的情況下,通過增加一個翟通道,即不介入對象內部邏輯,又負擔了對象持久的:首先實現 ISerializer這一接口,同時實現固定方法簽名的構造函數。保存數據時,他讓你自己打包然后從你的接口實現中獲得數據對象,恢復對象時,他調用你 那個特別的構造函數,把數據字典還給你,然后讓你自己填充。拿Book來說,如果增加了需求,難道非得讓Book自己重新實現一個 Book.Serialize方法嗎?鑒于一些朋友可能不清楚Serializer的工作機制,我借用一下MSDN的簡單例子,同時有所改動:
public class MyObject : ISerializable
{
public int N1 { get; private set;} //公開取,私有存
public int N2 { get; internal set;} //公開讀,內部存
public String Str { get; set;} //這些是VS 2008支持的寫法,不用自己定義私有變量了
public MyObject()
{
}
protected MyObject(SerializationInfo info, StreamingContext context)
{
//這個特殊的構造函數會被自動調用,如果是我們自己實現,就某Manager調用
//其實如果沒有復雜的構造函數初始化邏輯,比如給readonly變量賦值
//可以將SetObjectData直接實現于接口,由我們負責數據存取的部分來調用
SetObjectData(info);
}
protected virtual void SetObjectData(SerializationInfo info)
{
N1= info.GetInt32("i"); //由對象自己將數據字典展開
N2 = info.GetInt32("j"); //還原對象狀態
Str = info.GetString("k"); //這樣就可以把跟對象無關的存儲邏輯外包出去
}
[SecurityPermissionAttribute(SecurityAction.Demand,SerializationFormatter=true)]
public virtual void GetObjectData(SerializationInfo info, StreamingContext context)
{
info.AddValue("i", N1);//info可以理解為Serializer定義的數據字典格式
info.AddValue("j", N2);//這相當于持久化的概念進入了BL層
info.AddValue("k", Str);//所以當我們實現時,應該根據業務邏輯定義自己的數據字典
}
}
Serializer:
BinaryFormatter formatter = new BinaryFormatter();
保存至fs:
formatter.Serialize(fs, a1);
從fs讀取:
formatter.Deserialize(fs);
在上面的上下文中,作為Serializer的BinaryFormatter,相當于BookManager這樣的角色(也僅在該上下文中,它們 的作用有所重疊,因為BookManager不應負責對對象持久的具體實現),但是它更通用,且與任何對象沒有耦合關系,畢竟,這樣的方法不存在在咱們自 己定義的對象上。實際上,由于不需要變成二進制流,我們平時的保存和讀取的實現要比Serializer之類簡單一些(而且一般來說嚴格停留在數據層 中);同時由于我們的對象和數據往往有著一定的聯系,這樣我們的實際實現,就可以增加一些由業務層定義的數據字典作接口發揮類似SerializationInfo的作用,依靠于合理的設計,我們的接口一方面把BL層和數據層隔絕開來,另一方面強調業務層的需求的同時根據業務需要特殊化。
不過有人可能要說了,你也類似于ORM,我也不管我這是不是ORM,至少自己實現的靈活性,要比使用現成的ORM高,Book內部如何展開數據,既可以寫 個通用的,在有必要的地方也可以自己搞;效率想優化一下,也不用看/改別人的代碼,有啥不好呢?關鍵是這樣的方式,無論亞歷山大同志是我老板還是反對派是 我老板,挨罵也能少兩句不是。:)
估計大家也看出來了,以上方法對于復雜性不高的項目,完全可以取消BookManager,將數據存取職能歸并到一個集中的數據管理器中去(不是指數據層 內的那種)。這樣我們又有只有一個孤零零的Book玩Solo了。那么產生其它按職責來說不應該由Book來負擔,但又具有一定邏輯的操作怎么辦?比如 Log,做一個Log管理器,Book繼承下去實現Log管理器需要的接口,LoggableBook僅返回Log需要的東西。怕產生一個 LoggableCachablePermitableBook?Decorator就是干這個地... 不是強類型的?范型來了... 還有什么是可以變化的?比如到底套幾個Decorator,寫個Factory什么來直接返回套好的Book的..。需要注意,如果這些 Log/Permission/Cache,都是對數據存取而不是針對Book的,那么Decorator也好Factory也好,針對的目標就不再是 Book,而是BookManager或者數據管理器了。
如果有BookManager而不是集中對象存取邏輯和職責,那么數據來源和原來的做法沒有變化;但非數據層的數據管理器怎么知道Book的數據來自于哪 兒呢?雖然我們可以通過Provider和配置文件指定類與數據層跑腿的之間的對應關系,但答案也可以是"不知道"。比如,數據管理 器.Get(Query q),然后BookQuery去繼承Query就好了。這個Query及其子類是用來表示查詢邏輯的,而并非是用來拼SQL字串或直接數據存儲的那種 Query,最終通過BL層到數據層的接口與數據層對接。數據層根據它所提供的信息,自己決定如何動作,最后返回BL層所用的數據集接口。這樣我們就可以 防止數據層的概念侵入到BL層中去。
這種方式的BookManager(或沒有BookManager)與原來的方式區別在哪兒,讓它至少想象起來還挺順風順水呢?關鍵在于,BookManager和Book不再像亞歷山大同志批評的那樣,全方位互操作,它們的接觸從一個面變成了一個甚至完全可以消除任何特殊性點,只包含BookManager職責所涉及到的數據部分,至于數據從哪兒來打哪兒去,這不關別人的事。反過來對于Book呢,他的職責除了執行其他當仁不讓的業務邏輯,也保留了應該由他自己負責的如何展開和打包數據等天賦人權,從而將數據到對象狀態的轉化工作,封裝在了對象自身內部。
這種方式與Book.Save區別又在哪里呢?很明顯的是Book不再包括它不應關心的邏輯;這樣就實現了BookManager(或數據管理器以及各種 Query子類)的單獨替換和伸縮;在把他們分散到不同的包里時,這樣的好處是不言而喻的。另外,相對于Book.Method1/Method2 /Method3...這樣隨著需求變化增加下去,Book同時也保證了一定穩定性。實現這些,是通過對Book增加了一個面對外部世界的點做到的,它本質上更傾向于亞歷山大同志說的高聚低耦方式(雖然表現形式不同甚至截然相反),卻將存取邏輯通過這個點,轉移到了外部。
在我看來,以上手段折射到設計和編碼的最終產物,其表現形式就是類的大小和數量。密密麻麻的小類我個人也不習慣很難做到,但我仍然覺得,哪怕一個類只有 50行,我覺得只要有一點點理由應該這么做那就相當于說必須去做(虛心接受,堅決不改)。在這點上我有點認同Javaeye上前幾年鼓吹什么組合子編程的 那家伙,只實現很多很多的基礎組合子。問題是拆的太散,最后類之間的邏輯對一般人的腦力和項目的成本承受能力就形成了考驗。但是沒條件實現不是說不應該這 么實現:非常多的細棍一齊直沖云霄,只不過有些棍因為需要借助其它棍的支撐力不得不綁上跟繩子,但卻盡量保持不接觸,防止實際上變成了一根大粗棍。
關于貧血是不是一定就不合理,Book沒有Save是不是就一定貧血這兩個問題,我有幾點看法如下:
1. 如果真的堅持正確的職責,那么該貧血的模型必須貧血;如果充上別人的血,對于其他對象來說這個對象倒是高聚低耦了,但估計如果你不是AOP迷,這個對象本 身就難免成為膠水對象,比如你可以考慮把CommunityServer的Threads類的職責放到Thread類上去是個什么景象。
2. 若Book和BookManager都足夠小,那么這種劃分不會增加耦合;相反即使是相互操作,只要接口不變,反而將Book本身可能存在的職責和存取職責之間的耦合度降低了。這是CommunityServer和很多人的做法,但是這個做法的種種缺點也確實存在。
我重新編輯了本文把以上觀點單獨放在另外一個Post中了,見《貧血或職責的討論》。本文目前只討論一種不把持久放在Book中,同時又把恢復對象狀態保留,封裝于Book中的一種做法。關于1,本文中,我們已經把恢復對象狀態的邏輯重新還給了對象,不知道Book是否還是完全的貧血?
最后總結一下本文:
3. 密密麻麻的小類,是各種大嘴/大師們都推薦的做法,尤其是如果存在BookManager,那么BookManager的職責到底有哪些需要謹慎一些,否則比起只有Book的做法,就有可能真的增加了耦合。
4. 考慮到Book狀態和裝載可能分別變化,我們應該拿出一種它們解耦的方法,讓Book自己負責數據的展開和打包,同時由其它對象負責數據的存取(對于圖書 管理員來說讓它拿出來和放回去的這個東西已經可以忽略書本的特性了)。本文提出了一種其實大家都見過,且也許能行的辦法。
5. 關鍵問題是,要點接觸不要面接觸,要接觸不要不接觸,不接觸最后的結果,不是AOP(是不是一顆加林仙人的仙豆,暫時持保留態度),就是成了一個巨型的膠水類。當你解決膠水問題時,還是要使用以上的手段,只不過對象憑空多出了幾個方法而已。
最后說點題外話:我在想,其實問題的討論點如果變一變,就會有建設性。怎么變呢?比如大家群策群力,總結一下現實世界里的例子,哪些可以容忍貧血,哪些貧 血的代價在未來會很大;這樣久而久之就會形成一個指南,為后人也為咱們自己鋪路。比如顯然亞歷山大同志老兄,吃過充血(哪怕是過度充血)的甜頭,那么就把 你吃甜頭的例子拿出來分析,于是大家知道了,哦,當現實事物有這樣這樣的特征時,充血有好處。比如反對派吃了充血過度的苦頭,也把苦頭拿出來曬曬,那樣大 家就知道,這種情況下得小心不要充血充出問題。每個人都這么做了,逐漸的就可以通過統計得出一些較為有效的分類方式和在不同分類下的大致設計方法。
但是如果是目前的方式呢?你說你的,我說我的,把自己在現實中獲得經驗往一個共同的例子上套,可大家都沒有考慮自己已經因為過往的經驗有了成見。每 個開發者的經驗都是不同的(也正是因為經驗不同所以站位的角度不一樣),怎樣通過網絡讓每個人都或多或少得到他人的經驗在我看來是網絡比較重要的作用。
而不加限定的就某一個問題,非要得出大一統的結論,在我看來不但不是網絡的優勢和對我們的幫助,而且根本沒有那個可能。