前言
幾年前我偶然發(fā)現(xiàn)了Robert Martin的一個關(guān)于分離關(guān)注點的演講,在這個演講的啟發(fā)下,我嘗試把在ASP.NET MVC應(yīng)用中實踐Robert提到的觀點锦亦。
問題
PM:客戶需要可以在線上訂購產(chǎn)品。
我:好的,那么我需要把訂單存在某個數(shù)據(jù)庫里面阳准,并且需要在頁面上顯示出來
PM:對了,如果庫存沒了馏臭,是不允許下單的野蝇。
我:你應(yīng)該早點告訴我的... 我現(xiàn)在要添加新列用于過濾。
三個月后
PM:
為什么我們的bug那么多括儒?
為什么我們的網(wǎng)站那么慢绕沈?
為什么這個改動要花這么多時間?
我承認這樣的抱怨很公平帮寻,因為這些問題是我寫的代碼造成的乍狐。
項目總是從簡單開始,然后(由于需求變動)逐漸變得復(fù)雜固逗,最終變成一個“大泥球”(big ball of mud)澜躺。
我意識到蝉稳,(直接把業(yè)務(wù)邏輯映射為具體實現(xiàn)的做法,)數(shù)據(jù)庫通常會成為瓶頸掘鄙,表現(xiàn)層邏輯混雜著業(yè)務(wù)邏輯和數(shù)據(jù)訪問代碼耘戚,這使得(單元/集成)測試變得非常困難。
解決辦法
然后我看到了Uncle Bob的演講Agility and Architecture (youtube鏈接)操漠,在演講里面提到了另一種解決方法:"Clean Architecture"收津。在這種架構(gòu)下,業(yè)務(wù)邏輯處于架構(gòu)的中心位置浊伙,而web和數(shù)據(jù)庫作為實現(xiàn)細節(jié)(位于架構(gòu)外層)撞秋。
這個演講解釋了我之前碰到的問題,也給出了解決方案嚣鄙。但是吻贿,Uncle Bob并沒有提供任何源碼! 為了充分理解該架構(gòu)舅列,我需要一個具體的例子帐要。我接下去看了很多Uncle Bob的書和視頻課程,從中學(xué)到了很多赠橙。但還是在試圖把這些原則應(yīng)用到真實項目中時依然十分掙扎简烤。
隨后我嘗試在實踐中堅持其中的一些原則,發(fā)現(xiàn)效果確實不錯枉侧。在這篇博客中我會闡述這些實踐細節(jié),并解釋遵循的原則對實踐的幫助帜矾,同時也會分享相應(yīng)的代碼樣本。
Clean Architecture概述
在開始之前掸宛,我們先回顧一下Clean Architecture的關(guān)鍵元素唧瘾。我不會對架構(gòu)本身做詳細地解釋别凤,如果你之前沒有了解過规哪,請先觀看上面提到的視頻蝠嘉,或者閱讀Uncle Bob的博客The Clean Architecture(譯文)肚菠。
依賴規(guī)則
Clean Architecture的目的之一层扶,是通過一種清晰的方式,把應(yīng)用的業(yè)務(wù)邏輯封裝起來终抽。
上圖展示了該架構(gòu)昼伴,其中領(lǐng)域?qū)嶓w(Entity)和用例(Use case)處于“洋蔥”的中心匾旭。
Uncle Bob:
用一組同心圓表示不同層級的軟件代碼圃郊,總的來說,越往內(nèi)持舆,抽象層次越高伪窖。最外層的圈代表的是機制級別的系統(tǒng)。最內(nèi)層的代表的是策略級別的系統(tǒng)居兆。
注意圖中的依賴方向指出,代碼依賴只能使由外向內(nèi)史辙。在實踐中聊倔,這意味著你需要把描述業(yè)務(wù)邏輯的代碼從具體交付機制(UI是web還是app,數(shù)據(jù)存放在數(shù)據(jù)庫還是NoSql甸陌,等等)中分離出來耻卡。
這樣能確保你的業(yè)務(wù)規(guī)則能獨立存在,而不依賴于具體的框架選擇和實現(xiàn)牲尺。
這樣做有以下好處:
- 分離關(guān)注點卵酪。
- 業(yè)務(wù)規(guī)則代碼只描述業(yè)務(wù)領(lǐng)域,不關(guān)注其他谤碳。
- 在更換數(shù)據(jù)存儲或web框架時溃卡,不需要改變業(yè)務(wù)邏輯代碼。
- 業(yè)務(wù)規(guī)則易于測試蜒简。
- 業(yè)務(wù)規(guī)則易于重構(gòu)瘸羡。
向內(nèi)依賴規(guī)則不僅限制業(yè)務(wù)規(guī)則,其他各處也都需要遵循搓茬。但隔離業(yè)務(wù)規(guī)則是Clean Architecture提供的最有價值的一點犹赖。
在實踐中應(yīng)該怎樣做到呢?
練習(xí)
現(xiàn)在開始用一個具體例子來嘗試實踐這些原則卷仑。
業(yè)務(wù)需求
我們將從用例開始冷尉,這是很自然的做法。通常來說系枪,開始一個項目是為了解決用戶的具體問題雀哨。(在編寫用例的時候,我們考慮)軟件需要做什么事情,以及需要和用戶如何交互雾棺。
Funda (作者的雇主)是荷蘭的一個在線房地產(chǎn)平臺膊夹。想象我們在開發(fā)一個新的功能,讓某處特定房產(chǎn)的潛在客戶聯(lián)系對應(yīng)的房產(chǎn)代理捌浩。
在Funda我們使用scrum放刨。我們使用用戶故事(User story)描述待實現(xiàn)功能的業(yè)務(wù)價值。
用戶故事: 聯(lián)系房產(chǎn)代理
作為顧客尸饺,我希望聯(lián)系意向房產(chǎn)的代理进统,以了解該房產(chǎn)的更多細節(jié)。
然后我們會把User story展開為正式的用例浪听。
用例: 聯(lián)系房產(chǎn)代理
數(shù)據(jù):
- Email(必填螟碎,格式必須符合email規(guī)范)
- PhoneNumber(必填)
- HouseId(意向房產(chǎn)的Id)
主流程:
- 客戶發(fā)起 “聯(lián)系房產(chǎn)代理”請求并提供上述數(shù)據(jù)。
- 系統(tǒng)驗證所有輸入數(shù)據(jù)迹栓。
- 系統(tǒng)把該請求記錄下來掉分,并在稍后通知房產(chǎn)代理。
- 向用戶展示處理結(jié)果(記錄成功)克伊。
異常流程: 驗證失敗
- 終止處理該請求酥郭。
- 向用戶展示錯誤信息。
通常我們的開發(fā)人員并不需要把用例如此正規(guī)地形成文檔愿吹。在本案例中我寫成這樣不从,是因為這有助于說明Clean Architecture的某些概念。
注意在用例中犁跪,我們只描述用戶和系統(tǒng)的交互邏輯椿息,而不會說明任何實現(xiàn)細節(jié)(如聯(lián)系信息會被如何存儲,UI如何呈現(xiàn)等)耘拇。這個用例可能會實現(xiàn)為控制臺程序撵颊,電話程序或者網(wǎng)站宇攻,而用例并不負責(zé)確定實現(xiàn)方式惫叛。這正是問題的關(guān)鍵,用例用于描述“系統(tǒng)做什么”逞刷,但并不提及“系統(tǒng)怎么做”(Uncle Bob:業(yè)務(wù)邏輯不需要了解任何外部結(jié)構(gòu)嘉涌。)
交互器對象(Interactor)
在Clean Architecture中,每個用例的主流程通晨淝常可以映射為一個Uncle Bob稱之為Interactor(交互器)的對象仑最。Interactor以操作領(lǐng)域?qū)嶓w的方式描述用戶和系統(tǒng)的交互模型。領(lǐng)域?qū)嶓w指的是應(yīng)用里面的業(yè)務(wù)對象帆喇,例如本例中的Hourse警医。
根據(jù)以上創(chuàng)建的用例,我們寫出了以下Interactor。依賴的ContactAgentRequestMessage预皇,ContactAgentResponseMessage和ContactAgentRequestMessageValidator請參閱github源碼侈玄,其中Request/Response Message均為POCO類,Validator用于驗證參數(shù)正確性吟温。
public class ContactAgentInteractor :
IRequestHandler<ContactAgentRequestMessage,ContactAgentResponseMessage>
{
private readonly IRepository<int, House> _repository;
private readonly IValidator<ContactAgentRequestMessage> _validator;
public ContactAgentInteractor(
IValidator<ContactAgentRequestMessage> validator,
IRepository<int, House> repository)
{
_validator = validator;
_repository = repository;
}
public ContactAgentResponseMessage Handle(
ContactAgentRequestMessage request)
{
var validationResult = _validator.Validate(request);
if (validationResult.IsValid == false)
return new ContactAgentResponseMessage(validationResult);
var house = _repository.Get(request.HouseId);
house.RegisterInterest(new Interest
{
CustomerEmailAddress = request.CustomerEmailAddress,
CustomerPhoneNumber = request.CustomerPhoneNumber,
CreationDate = DateTime.Now
});
_repository.Save(house);
return new ContactAgentResponseMessage(
validationResult,
request.HouseId);
}
}
這個Interactor里面有幾個點需要注意:
- 幾乎是一對一地翻譯了之前定義的用例序仙。
- 使用了簡單消息(plain request/response message,plain指的是POCO對象鲁豪,區(qū)別于Http Request/Response這種為某種context定義的對象)作為輸入/輸出潘悼,并沒有任何ASP.NET的引用。
- 依賴于IRepository爬橡。
上述Interactor只是按功能規(guī)格治唤,簡明地描述了系統(tǒng)應(yīng)該如何運作(以支持該功能需求)。
Interactor通過plain message接收參數(shù)和返回結(jié)果堤尾,這樣做可以把Interactor從使用它的應(yīng)用中解耦出來肝劲。Interactor只需要知道Request/Response Message,不再需要知道其他系統(tǒng)實現(xiàn)細節(jié)郭宝。
Interactor通過Repository(在洋蔥圖中稱之為Gateway)獲得實體辞槐,然后對其進行操作,再通過Repository保存修改后的實體粘室。這個依賴順序是違反了向內(nèi)依賴規(guī)則的榄檬,但我們可以引入接口,并通過使用依賴倒置原則來處理這種情況衔统。
如上圖所示鹿榜,Interactor依賴定義在實體層的IRepository接口。該接口定義了實體的讀/寫功能锦爵,但不提供具體實現(xiàn)舱殿。Interactor對象并不依賴該接口在接口適配層(Interface Adapters)的具體實現(xiàn)。
領(lǐng)域?qū)嶓w(Entity)
上面我們提到了領(lǐng)域?qū)嶓w险掀,比如本例中的Hourse沪袭,現(xiàn)在我們對此做進一步討論。
Uncle Bob:
領(lǐng)域?qū)嶓w用于封裝公司的業(yè)務(wù)規(guī)則樟氢。 一個實體可以是有方法的對象冈绊,或者是一組數(shù)據(jù)結(jié)構(gòu)和函數(shù),這并不重要埠啃,只要確保實體可以被用在公司各個不同的應(yīng)用中死宣。
如果你只是寫一個獨立的應(yīng)用(而不需要考慮在企業(yè)內(nèi)共享),那么領(lǐng)域?qū)嶓w就是該應(yīng)用的業(yè)務(wù)對象碴开。領(lǐng)域?qū)嶓w封裝了上層業(yè)務(wù)規(guī)則毅该,通常來說博秫,在外部環(huán)境變動的時候,這些實體對象是不應(yīng)被改變的眶掌。
例如台盯,你不會期望實體會被頁面導(dǎo)航,或者安全方面的改動所影響畏线。在任何應(yīng)用中静盅,操作層的變更都不應(yīng)該影響領(lǐng)域?qū)嶓w層。
如何為業(yè)務(wù)領(lǐng)域建模是另一個大的話題寝殴,我在這里不做太多討論蒿叠。不過(在建模完成后),實現(xiàn)是很直接了當(dāng)?shù)模涸诒纠序汲#沂褂孟旅娴腜OCO類描述應(yīng)用的業(yè)務(wù)對象和業(yè)務(wù)規(guī)則市咽。
public class House
{
public int? Id { get; set; }
public string Address { get; set; }
public IList<Interest> Leads { get; }
public House()
{
Leads = new List<Interest>();
}
public void RegisterInterest(Interest interest)
{
Leads.Add(interest);
}
}
public class Interest
{
public string CustomerEmailAddress { get; set; }
public long CustomerPhoneNumber { get; set; }
public DateTime CreationDate { get; set; }
}
在Funda,我們公布房源信息抵蚊,而客戶可以關(guān)注對特定房源施绎,這就是我們的業(yè)務(wù)模型。而使用領(lǐng)域模型(而不是具體的web應(yīng)用實現(xiàn))來描述該業(yè)務(wù)模型贞绳,這樣的做法非常簡潔谷醉。保持這部分代碼簡單明了冈闭,在重構(gòu)系統(tǒng)或添加新功能的時候俱尼,能提供更高的靈活度。
我承認這是一個非常簡單甚至簡陋的領(lǐng)域模型的例子(有人稱之為貧血模型)萎攒,但我希望通過這個例子可以說明:
你確實可以通過POCO類來描述業(yè)務(wù)應(yīng)用的運行行為遇八,然后你可以在應(yīng)用中直接引用它們,從而避免把業(yè)務(wù)邏輯混淆到處理web耍休,數(shù)據(jù)庫刃永,消息隊列或其他“交付機制”(delivery mechanisms)的代碼中。
任務(wù)完成!
嗯羊精,真完成了斯够,某種意義上……
回顧一下,我們已經(jīng)實現(xiàn)了:
- Interactor對象
- Request/Response消息
- 業(yè)務(wù)領(lǐng)域Entity
依賴它們园匹,你已經(jīng)可以對項目待解決的問題進行建模雳刺,描述以及單元測試劫灶。雖然它還不是一個能工作的web應(yīng)用裸违。我并不是在開玩笑,在某種意義上本昏,業(yè)務(wù)邏輯確實是可以在實現(xiàn)任何交付機制(web供汛,database,etc.)之前完成的。
對我來說怔昨,一點關(guān)鍵的認知是雀久,在實現(xiàn)了這些基礎(chǔ)后,你可以為你的項目或者準備要解決的問題開始建模了趁舀。你可以為這些類寫單元測試或者console應(yīng)用赖捌,以證明這些業(yè)務(wù)邏輯是滿足需求的。事實上矮烹,我在源碼里面確實加入了一個用于測試的console應(yīng)用越庇。
下面我會簡略地談及如何把這些已有部分集成到web應(yīng)用中。但我希望現(xiàn)在已經(jīng)說服你相信奉狈,web應(yīng)用可以搭建在我已經(jīng)演示的Clean Architecture之上卤唉。
集成ASP.NET MVC應(yīng)用
如果你希望通過ASP.NET MVC應(yīng)用來呈現(xiàn)我們已經(jīng)實現(xiàn)的業(yè)務(wù)邏輯,接下去要寫的代碼將會落在Clean Architecture的接口適配層(Interface Adapter)仁期。
Uncle Bob:
這一層的軟件結(jié)構(gòu)的目的就是進行數(shù)據(jù)的轉(zhuǎn)換桑驱,將便于用戶實例和實體層操作的數(shù)據(jù)結(jié)構(gòu)變化成為最便于外部結(jié)構(gòu)(比如數(shù)據(jù)庫或者Web)操作的數(shù)據(jù)結(jié)構(gòu)。比如GUI的MVC結(jié)構(gòu)跛蛋,表現(xiàn)器熬的、視圖器、控制器都是屬于這個結(jié)構(gòu)的赊级。這層很可能是通過控制器將數(shù)據(jù)結(jié)構(gòu)傳給用戶實例層悦析,并且返回數(shù)據(jù)給表現(xiàn)器,視圖器此衅。
實際上大部分的web應(yīng)用具體實現(xiàn)能在這層找到强戴。
當(dāng)我們的客戶想通過網(wǎng)站聯(lián)系房產(chǎn)代理,通常他們需要填寫一些HTML表單挡鞍。然后模型綁定器(model binder)會把表單數(shù)據(jù)轉(zhuǎn)換為強類型的ContactAgentRequestMessage對象骑歹,并交由AgentController處理該請求:
public class AgentController
{
private readonly ContactAgentInteractor _interactor;
private readonly ContactAgentResponsePresenter _presenter;
public AgentController(ContactAgentInteractor interactor,
ContactAgentResponsePresenter presenter)
{
_interactor = interactor;
_presenter = presenter;
}
public void Contact(ContactAgentRequestMessage requestMessage)
{
var response = _interactor.Handle(requestMessage);
var viewModel = _presenter.Handle(response);
var view = new ConsoleView(viewModel);
view.Render();
}
}
現(xiàn)在我們必須越過邊界進入內(nèi)層,也就是業(yè)務(wù)邏輯的所在墨微。我們通過調(diào)用Interactor對象的Handle方法做到了這點道媚,在這里,我們需要先考慮清楚翘县,在方法調(diào)用中最域,我們應(yīng)該使用什么樣的數(shù)據(jù)結(jié)構(gòu)傳遞參數(shù)。
Uncle Bob:
一般跨層調(diào)用的數(shù)據(jù)是簡單的數(shù)據(jù)結(jié)構(gòu)锈麸。你可以使用數(shù)據(jù)結(jié)構(gòu)或者是簡單的數(shù)據(jù)傳輸流镀脂,又或者可以通過函數(shù)的參數(shù)來進行傳遞。你也可以將數(shù)據(jù)封裝到一個hashmap結(jié)構(gòu)忘伞,或者一個對象中薄翅。數(shù)據(jù)傳輸最重要的事情是無依賴沙兰,簡單。我們并不希望跨層傳遞的數(shù)據(jù)是實體翘魄,或者是數(shù)據(jù)表的行數(shù)據(jù)鼎天,理由是我們不希望數(shù)據(jù)有任何形式的違反依賴規(guī)則。
...
當(dāng)我們跨層傳遞數(shù)據(jù)的時候暑竟,我們應(yīng)該以便于內(nèi)圈使用的數(shù)據(jù)格式傳遞斋射。
最后一句是最關(guān)鍵的,重復(fù)一下:
“當(dāng)我們跨層傳遞數(shù)據(jù)的時候但荤,我們應(yīng)該以便于內(nèi)圈使用的數(shù)據(jù)格式傳遞绩鸣。”
在ASP.NET的世界里纱兑,如同Visual Studio的示例呀闻,人們傾向(基于設(shè)計好的數(shù)據(jù)庫)使用Entity Framework創(chuàng)建實體類,并且把它們用作view model潜慎,domain entity捡多,DTO等等。人們也會很自然地使用它們在Controller和Interactor之間傳遞數(shù)據(jù)铐炫,但這違反了向內(nèi)依賴規(guī)則垒手。
與之相反,內(nèi)圈應(yīng)該定義輸入?yún)?shù)的數(shù)據(jù)格式倒信。在我們的例子中科贬,作為Interactor和Controller交互的參數(shù),ContactAgentRequestMessage類同樣被定義在了業(yè)務(wù)邏輯層中鳖悠。這樣做就遵循了向內(nèi)依賴規(guī)則榜掌。
最后,Interactor對象完成它自己的工作乘综。執(zhí)行一系列的驗證邏輯憎账,然后把數(shù)據(jù)保存下來(保存動作通過IRepository接口執(zhí)行,而具體的保存工作由外圈的IRepository實現(xiàn)來完成)卡辰。
Uncle Bob:
在接口適配層胞皱,數(shù)據(jù)在這層會被轉(zhuǎn)換,將便于實體層和用戶實例層使用的數(shù)據(jù)轉(zhuǎn)化成為持久層能使用的數(shù)據(jù)九妈,比如數(shù)據(jù)庫反砌。當(dāng)一些外部的服務(wù)需要與用戶實例層和實體層進行交互的時候,這時候需要的數(shù)據(jù)轉(zhuǎn)換也理所當(dāng)然地放在這一層了萌朱。
在本例中我簡單地實現(xiàn)了一個假的Repository宴树,測試的時候你不需要安裝和配置任何數(shù)據(jù)庫。
public class InMemoryHouseRepository : IRepository<int, House>
{
private static readonly Dictionary<int, House> Store =
new Dictionary<int, House>();
House IRepository<int, House>.Get(int id)
{
return Store[id];
}
public House Save(House entity)
{
if (entity.Id.HasValue == false)
entity.Id = Store.Count;
Store[entity.Id.Value] = entity;
return entity;
}
}
在實際項目中嚷兔,通常會使用ORM框架來實現(xiàn)Repository森渐。我喜歡使用NHibernate,有時候也會考慮使用Dapper冒晰。
表現(xiàn)層
在業(yè)務(wù)邏輯處理完成后同衣,Interactor對象將返回一個ContactAgentResponseMessage消息給Controller,use case的任務(wù)到此結(jié)束壶运。消息對象只包含最終用戶必須的信息耐齐,它實際上是一種數(shù)據(jù)結(jié)構(gòu),符合use case描述的功能需求蒋情,適合被Interactor調(diào)用埠况。
public class ContactAgentResponseMessage
{
public ValidationResult ValidationResult { get; }
public long? HouseId { get; private set; }
public ContactAgentResponseMessage(
ValidationResult validationResult,
int? houseId =null)
{
HouseId = houseId;
ValidationResult = validationResult;
}
}
然后我們希望以友好的界面展示給用戶,我們會使用Presenter對象把原始的ResponseMessage換行為ViewModel棵癣。ViewModel也是一種簡單數(shù)據(jù)結(jié)構(gòu),但它的結(jié)構(gòu)是為適配View而設(shè)計的。
例如捐迫,我們希望展示友好的操作成功或失敗的消息:
public class ContactAgentResponsePresenter
{
public ContactAgentResponseViewModel Handle(ContactAgentResponseMessage responseMessage)
{
switch(responseMessage.ValidationResult.IsValid)
{
case true:
return new ContactAgentResponseViewModel(Texts.ThankYou);
case false:
var sb = new StringBuilder();
sb.AppendLine(Texts.ValidationError);
foreach (var error in responseMessage.ValidationResult.Errors)
{
sb.AppendLine(error.ErrorMessage);
}
return new ContactAgentResponseViewModel(sb.ToString());
}
return null;
}
}
public class ContactAgentResponseViewModel
{
public string Text { get; private set; }
public ContactAgentResponseViewModel(string text)
{
Text = text;
}
}
最后卢厂,Controller把ViewMode傳遞給視圖引擎,引擎將用于渲染視圖并展示給用戶河劝。
任務(wù)完成!
結(jié)論
遵循“向內(nèi)以來規(guī)則”可以避免業(yè)務(wù)邏輯被外包框架和實現(xiàn)的變更所影響壁榕。這樣做可以保持業(yè)務(wù)邏輯獨立整潔,易于測試和修改赎瞎。
在這個(業(yè)務(wù)實現(xiàn)的)基礎(chǔ)上牌里,你可以根據(jù)用戶的需要,選擇以web或者其他類型的應(yīng)用方式來交付系統(tǒng)务甥。
我提供了一個console應(yīng)用作為例子牡辽,展示在.Net中如何實踐這個理論。你可以在我的github上找到該例子敞临,其中包含本文展示的所有源碼催享。
感謝Uncle Bob,Rodi Evers和Michiel van Oosterhout提供靈感哟绊!
源文鏈接:Clean Architecture in .Net (翻譯:woodylic)