前端整潔架構(gòu),你了解多少?
本文來(lái)聊一聊前端整潔架構(gòu)。
首先,總體了解什么是"整潔架構(gòu)",并熟悉領(lǐng)域、用例和應(yīng)用層等概念。然后,討論它如何應(yīng)用于前端,以及它是否值得使用。然后,按照整潔架構(gòu)的規(guī)則設(shè)計(jì)一個(gè)商店應(yīng)用,并從頭開(kāi)始設(shè)計(jì)一個(gè)用例,看看它是否可用。這個(gè)應(yīng)用使用 React、TypeScript 編寫(xiě),編寫(xiě)過(guò)程中會(huì)考慮可測(cè)試性,并對(duì)其進(jìn)行改進(jìn)。
架構(gòu)與設(shè)計(jì)
設(shè)計(jì)的基本目標(biāo)是以一種能夠重新組合的方式將事物分解開(kāi)來(lái)...將事物分成可以組合的部分,這就是設(shè)計(jì)?!?Rich Hickey,《Design Composition and Performance》
正如上述引言中所說(shuō),系統(tǒng)設(shè)計(jì)是將系統(tǒng)分開(kāi)以便以后重新組裝。最重要的是,能夠輕松地重新組裝,而不需要太多的工作。
我同意這個(gè)觀點(diǎn)。但我認(rèn)為架構(gòu)的另一個(gè)目標(biāo)是系統(tǒng)的可擴(kuò)展性。對(duì)程序的需求不斷變化。我們希望程序能夠輕松更新和修改以滿足新的需求,整潔架構(gòu)可以幫助實(shí)現(xiàn)這個(gè)目標(biāo)。
整潔架構(gòu)
整潔架構(gòu)是一種根據(jù)職責(zé)和功能部分與應(yīng)用程序域的接近程度來(lái)分離它們的方法。
所謂領(lǐng)域,是指用程序建模的現(xiàn)實(shí)世界的一部分。這是反映現(xiàn)實(shí)世界變化的數(shù)據(jù)轉(zhuǎn)換。例如,如果我們更新了產(chǎn)品的名稱,用新名稱替換舊名稱就是一個(gè)領(lǐng)域轉(zhuǎn)換。
整潔架構(gòu)通常被分為三層,如下圖所示:
層次圖:領(lǐng)域?qū)釉谥行?,?yīng)用層在周圍,適配器層在外側(cè)
領(lǐng)域?qū)?/h3>
整潔架構(gòu)的中心是領(lǐng)域?qū)印K敲枋鰬?yīng)用主題區(qū)域的實(shí)體和數(shù)據(jù),以及轉(zhuǎn)換該數(shù)據(jù)的代碼。領(lǐng)域是區(qū)分不同應(yīng)用的核心。
我們可以將領(lǐng)域視為當(dāng)我們從 React 遷移到 Angular,或者更改某些用例,那些不會(huì)改變的東西。就商店而言,領(lǐng)域就是產(chǎn)品、訂單、用戶、購(gòu)物車和更新數(shù)據(jù)的方法。
領(lǐng)域?qū)嶓w的數(shù)據(jù)結(jié)構(gòu)及其轉(zhuǎn)換的本質(zhì)是獨(dú)立于外部世界的。外部事件觸發(fā)領(lǐng)域的轉(zhuǎn)換,但并不決定轉(zhuǎn)換將如何發(fā)生。
將商品添加到購(gòu)物車的功能并不關(guān)心商品的添加方式:用戶自己通過(guò)“購(gòu)買”按鈕添加或使用促銷碼自動(dòng)添加。在這兩種情況下,它都會(huì)接受該商品并返回包含添加商品的更新后的購(gòu)物車。
應(yīng)用層
在領(lǐng)域?qū)拥闹車菓?yīng)用層。這一層描述了用例,即用戶場(chǎng)景。它們負(fù)責(zé)在某個(gè)事件發(fā)生后發(fā)生的事情。
例如,“添加到購(gòu)物車”場(chǎng)景就是一個(gè)用例。它描述了單擊按鈕后應(yīng)執(zhí)行的操作。它會(huì)告訴應(yīng)用:
- 發(fā)送請(qǐng)求。
- 執(zhí)行這個(gè)領(lǐng)域轉(zhuǎn)換。
- 使用響應(yīng)數(shù)據(jù)重新繪制 UI。
此外,在應(yīng)用層中還有端口——應(yīng)用程序希望與外界進(jìn)行通信的規(guī)范。通常,端口是一個(gè)接口,表示行為契約。
端口充當(dāng)我們的應(yīng)用期望和現(xiàn)實(shí)之間的“緩沖區(qū)”。輸入端口告訴應(yīng)用希望如何與外界通信。輸出端口說(shuō)明應(yīng)用將如何與外界進(jìn)行通信以使其做好準(zhǔn)備。
適配器層
最外層包含外部服務(wù)的適配器。需要適配器將外部服務(wù)的不兼容 API 轉(zhuǎn)換為與應(yīng)用的可以兼容的 API。
適配器是降低代碼與第三方服務(wù)代碼耦合度的好方法。低耦合度可以減少在其他模塊發(fā)生變化時(shí)需要修改一個(gè)模塊的情況。
適配器通常分為兩類:
- 驅(qū)動(dòng)型適配器:向應(yīng)用發(fā)送信號(hào)。
- 被驅(qū)動(dòng)型適配器:接收來(lái)自應(yīng)用的信號(hào)。
用戶通常與驅(qū)動(dòng)型適配器進(jìn)行交互。例如,UI框架處理按鈕點(diǎn)擊的工作就是驅(qū)動(dòng)型適配器的工作。它與瀏覽器API(基本上是第三方服務(wù))進(jìn)行交互,并將事件轉(zhuǎn)換為應(yīng)用能夠理解的信號(hào)。
被驅(qū)動(dòng)型適配器與基礎(chǔ)設(shè)施進(jìn)行交互。在前端,大部分基礎(chǔ)設(shè)施都是后端服務(wù)器,但有時(shí)也可能直接與其他服務(wù)進(jìn)行交互,如搜索引擎。
注意,離中心越遠(yuǎn),代碼功能越“面向服務(wù)”,離應(yīng)用的領(lǐng)域知識(shí)越遠(yuǎn)。當(dāng)決定每個(gè)模塊屬于哪個(gè)層時(shí),這將很重要。
依賴規(guī)則
三層架構(gòu)有一個(gè)依賴規(guī)則:只有外層可以依賴內(nèi)層。 這意味著:
- 領(lǐng)域?qū)颖仨毆?dú)立于其他層;
- 應(yīng)用層可以依賴于領(lǐng)域?qū)樱?/li>
- 外層可以依賴于任何東西。
按照這個(gè)規(guī)則,內(nèi)層的模塊或組件不應(yīng)該直接依賴于外層的模塊或組件。只有外層可以通過(guò)依賴來(lái)訪問(wèn)內(nèi)層的功能。這種依賴關(guān)系的限制可以幫助我們保持代碼的可維護(hù)性和靈活性。同時(shí),它也確保了系統(tǒng)的高內(nèi)聚性和低耦合性。
通過(guò)遵循依賴規(guī)則,我們可以更好地組織和管理代碼,使其更易于測(cè)試、擴(kuò)展和重用。此外,它還能夠促進(jìn)團(tuán)隊(duì)協(xié)作,因?yàn)槊總€(gè)層次可以獨(dú)立開(kāi)發(fā)和演化,而無(wú)需過(guò)多關(guān)注其他層次的具體實(shí)現(xiàn)。
只有外層可以依賴內(nèi)層
有時(shí)這條規(guī)則可能會(huì)被違反,盡管最好不要濫用它。例如,有時(shí)在域中使用一些“類似庫(kù)”的代碼很方便,即使不應(yīng)該存在依賴關(guān)系。
不受控制的依賴方向可能會(huì)導(dǎo)致代碼復(fù)雜且混亂。例如,違反依賴性規(guī)則可能會(huì)導(dǎo)致:
- 循環(huán)依賴,其中模塊A依賴于B,B依賴于C,C又依賴于A。
- 測(cè)試可測(cè)試性差,需要模擬整個(gè)系統(tǒng)來(lái)測(cè)試一個(gè)小部分。
- 耦合度過(guò)高,因此模塊之間的交互脆弱。
在設(shè)計(jì)系統(tǒng)架構(gòu)時(shí),應(yīng)該盡量避免違反依賴規(guī)則。遵循依賴規(guī)則可以讓代碼更容易理解、測(cè)試和擴(kuò)展,并提高代碼的靈活性和可維護(hù)性。
整潔架構(gòu)的優(yōu)點(diǎn)
整潔架構(gòu)的優(yōu)點(diǎn)主要體現(xiàn)在以下方面。
領(lǐng)域獨(dú)立性
主要應(yīng)用功能被隔離并集中在一個(gè)地方,即領(lǐng)域?qū)印?/p>
領(lǐng)域?qū)拥墓δ芟嗷オ?dú)立,這意味著更容易進(jìn)行測(cè)試。模塊的依賴越少,測(cè)試所需的基礎(chǔ)設(shè)施、模擬和存根就越少。
獨(dú)立的領(lǐng)域?qū)右哺菀赘鶕?jù)業(yè)務(wù)預(yù)期進(jìn)行測(cè)試。這有助于新開(kāi)發(fā)人員理解應(yīng)用程序應(yīng)該做什么。此外,獨(dú)立的領(lǐng)域?qū)佑兄诟斓夭檎覐臉I(yè)務(wù)語(yǔ)言到編程語(yǔ)言的"轉(zhuǎn)換"中的錯(cuò)誤和不準(zhǔn)確之處。
獨(dú)立的用例
應(yīng)用場(chǎng)景和使用案例分別進(jìn)行描述,它們決定了我們需要哪些第三方服務(wù)。使外部世界適應(yīng)我們的需要。這讓我們可以更自由地選擇第三方服務(wù)。例如,如果當(dāng)前的支付系統(tǒng)開(kāi)始收費(fèi)過(guò)高,可以快速更改支付系統(tǒng)。
用例的代碼也變得扁平化、可測(cè)試和可擴(kuò)展。
可替代的第三方服務(wù)
由于適配器的存在,外部服務(wù)變得可替換。只要不改變接口,那么實(shí)現(xiàn)該接口的外部服務(wù)可以是任意一個(gè)。
這樣就為更改傳播設(shè)置了障礙:其他人代碼的更改不會(huì)直接影響自己的代碼。適配器還限制了應(yīng)用運(yùn)行時(shí)中錯(cuò)誤的傳播。
整潔架構(gòu)的成本
整潔架構(gòu)除了好處之外,也有一些成本需要考慮。
時(shí)間成本
主要的成本是時(shí)間。它不僅需要設(shè)計(jì)的時(shí)間,還需要實(shí)現(xiàn)的時(shí)間,因?yàn)橹苯诱{(diào)用第三方服務(wù)比編寫(xiě)適配器要簡(jiǎn)單得多。事先完全思考系統(tǒng)所有模塊的交互是困難的,因?yàn)槲覀兛赡軣o(wú)法預(yù)先了解所有的需求和限制。在設(shè)計(jì)過(guò)程中,需要考慮系統(tǒng)如何可能會(huì)變化,并留出擴(kuò)展的余地。
有時(shí)過(guò)于冗長(zhǎng)
一般來(lái)說(shuō),整潔架構(gòu)的規(guī)范實(shí)現(xiàn)并不總是方便,有時(shí)甚至是有害的。如果項(xiàng)目很小,完全實(shí)現(xiàn)整潔架構(gòu)可能會(huì)過(guò)度復(fù)雜,增加新人入門的門檻。
為了在預(yù)算或截止日期內(nèi)完成項(xiàng)目,可能需要進(jìn)行設(shè)計(jì)上的妥協(xié)。
增加代碼量
前端特有的一個(gè)問(wèn)題是,整潔架構(gòu)會(huì)增加最終包中的代碼量。我們提供給瀏覽器的代碼越多,它需要下載、解析和解釋的代碼就越多。
我們需要關(guān)注代碼量,并且需要決策何處進(jìn)行簡(jiǎn)化:
- 也許可以簡(jiǎn)化用例的描述;
- 也許可以直接從適配器中訪問(wèn)領(lǐng)域功能,繞過(guò)用例;
- 也許需要調(diào)整代碼拆分等。
如何降低成本?
可以通過(guò)簡(jiǎn)化架構(gòu)并犧牲“整潔”的程度來(lái)減少時(shí)間和代碼量。我通常不喜歡激進(jìn)的方法:如果打破某個(gè)規(guī)則更加實(shí)際(例如,收益將超過(guò)潛在成本),我會(huì)打破它。
因此,可以在一段時(shí)間內(nèi)整潔架構(gòu)的某些方面持保留態(tài)度,這沒(méi)有任何問(wèn)題。但是,以下兩個(gè)方面是絕對(duì)值得投入的最低資源。
提取領(lǐng)域邏輯
提取領(lǐng)域邏輯有助于理解正在設(shè)計(jì)的內(nèi)容以及它應(yīng)該如何工作。提取領(lǐng)域邏輯使新開(kāi)發(fā)人員更容易理解應(yīng)用、實(shí)體及其之間的關(guān)系。
即使跳過(guò)其他層次,仍然可以更輕松地處理和重構(gòu)未分散在代碼庫(kù)中的提取的領(lǐng)域邏輯。其他層次可以根據(jù)需要添加。
遵循依賴規(guī)則
第二個(gè)不可丟棄的規(guī)則是依賴關(guān)系的規(guī)則,或者更確切地說(shuō),它們的方向。外部服務(wù)必須適應(yīng)我們的需求。
如果你覺(jué)得自己在“微調(diào)”代碼以便其調(diào)用搜索 API,那么可能存在問(wèn)題。最好在問(wèn)題擴(kuò)散之前編寫(xiě)適配器。
設(shè)計(jì)商店應(yīng)用
談完了理論,接下來(lái)就可以開(kāi)始實(shí)踐了。下面來(lái)設(shè)計(jì)一個(gè)餅干商店的架構(gòu)。
商店會(huì)出手不同類型的餅干,可能包含不同的成分,用戶將選擇餅干并進(jìn)行訂購(gòu),并通過(guò)第三方支付服務(wù)支付訂單費(fèi)用。
我們將在主頁(yè)上展示可以購(gòu)買的餅干。只有通過(guò)身份驗(yàn)證,才能購(gòu)買餅干。點(diǎn)擊登錄按鈕就會(huì)進(jìn)入登錄頁(yè)面。
商店主頁(yè)登錄成功之后,就可以在購(gòu)物車中添加一些餅干了。
裝有選定餅干的購(gòu)物車當(dāng)我們將餅干放入購(gòu)物車后,就可以下訂單了。付款后,會(huì)在列表中看到一個(gè)新的訂單以及一個(gè)已清空的購(gòu)物車。
這里我們將實(shí)現(xiàn)結(jié)賬用例。
在實(shí)現(xiàn)購(gòu)物車和結(jié)算功能之前,我們需要確定在整體上將擁有哪些實(shí)體、用例和功能,并決定它們應(yīng)該屬于哪個(gè)層次結(jié)構(gòu)。
設(shè)計(jì)領(lǐng)域
在應(yīng)用中,最重要的是領(lǐng)域。領(lǐng)域是應(yīng)用的主要實(shí)體及其數(shù)據(jù)轉(zhuǎn)換所在的地方。建議從領(lǐng)域開(kāi)始,以便在代碼中準(zhǔn)確表示應(yīng)用的領(lǐng)域知識(shí)。
商店的領(lǐng)域可以包括以下內(nèi)容:
- 每個(gè)實(shí)體的數(shù)據(jù)類型:用戶(user)、餅干(cookie)、購(gòu)物車(cart)和訂單(order);
- 用于創(chuàng)建每個(gè)實(shí)體的工廠或類(如果使用面向?qū)ο缶幊蹋?/li>
- 該數(shù)據(jù)的轉(zhuǎn)換函數(shù)。
領(lǐng)域中的轉(zhuǎn)換函數(shù)應(yīng)僅依賴于領(lǐng)域規(guī)則,不涉及其他內(nèi)容。例如,這樣的函數(shù)可能包括:
- 計(jì)算總費(fèi)用的函數(shù);
- 檢測(cè)用戶口味偏好的函數(shù);
- 確定物品是否在購(gòu)物車中的函數(shù)等。
領(lǐng)域?qū)嶓w圖
設(shè)計(jì)應(yīng)用層
應(yīng)用程序?qū)影擞美?。一個(gè)用例通常包括一個(gè)參與者、一個(gè)動(dòng)作和一個(gè)結(jié)果。
在商店中,可以區(qū)分以下用例:
- 產(chǎn)品購(gòu)買場(chǎng)景;
- 支付,包括與第三方支付系統(tǒng)的交互。
- 與產(chǎn)品和訂單的交互:更新、瀏覽等。
- 根據(jù)角色訪問(wèn)不同頁(yè)面。
用例通常根據(jù)主題領(lǐng)域進(jìn)行描述。例如,“結(jié)帳”場(chǎng)景實(shí)際上包含幾個(gè)步驟:
- 從購(gòu)物車中獲取商品并創(chuàng)建新訂單。
- 支付訂單。
- 如果支付失敗,通知用戶。
- 清空購(gòu)物車并顯示訂單信息。
用例函數(shù)將是描述這個(gè)場(chǎng)景的代碼。此外,在應(yīng)用層中還存在端口——與外部進(jìn)行通信的接口。這些端口可以用于與數(shù)據(jù)庫(kù)、第三方服務(wù)、UI 界面等進(jìn)行交互。
用例和端口圖
設(shè)計(jì)適配層
在適配器層中聲明與外部服務(wù)的適配器。適配器用于將第三方服務(wù)的不兼容API與我們的系統(tǒng)兼容。
在前端,適配器通常是 UI 框架和 API 服務(wù)器請(qǐng)求模塊。在這個(gè)案例中,將使用以下內(nèi)容:
- UI框架。
- API請(qǐng)求模塊。
- 本地存儲(chǔ)適配器。
- 將API響應(yīng)適配到應(yīng)用層的適配器和轉(zhuǎn)換器。
適配器圖,按照驅(qū)動(dòng)和被驅(qū)動(dòng)適配器拆分注意,“類似服務(wù)”的功能越多,離圖表中心越遠(yuǎn)。
使用 MVC 類比
有時(shí)候很難確定某些數(shù)據(jù)屬于哪一層。以下是一個(gè)簡(jiǎn)單的MVC類比:
- 模型(Models)通常是領(lǐng)域?qū)嶓w。
- 控制器(Controllers)是領(lǐng)域轉(zhuǎn)換和應(yīng)用層。
- 視圖(View)是驅(qū)動(dòng)適配器。
雖然細(xì)節(jié)上這些概念是不同的,但它們非常相似,這種類比可以用來(lái)定義領(lǐng)域和應(yīng)用代碼。
實(shí)現(xiàn)細(xì)節(jié):領(lǐng)域?qū)?/h3>
一旦確定了需要的實(shí)體,就可以開(kāi)始定義它們的行為。
接下來(lái)將展示項(xiàng)目中的代碼結(jié)構(gòu)。為了清晰起見(jiàn),將代碼分成了不同的文件夾-層級(jí)進(jìn)行組織:
src/
|_domain/
|_user.ts
|_product.ts
|_order.ts
|_cart.ts
|_application/
|_addToCart.ts
|_authenticate.ts
|_orderProducts.ts
|_ports.ts
|_services/
|_authAdapter.ts
|_notificationAdapter.ts
|_paymentAdapter.ts
|_storageAdapter.ts
|_api.ts
|_store.tsx
|_lib/
|_ui/
領(lǐng)域?qū)游挥赿omain/目錄,應(yīng)用層位于application/目錄,適配器位于services/目錄。我們將在最后討論該代碼結(jié)構(gòu)的替代方案。
創(chuàng)建領(lǐng)域?qū)嶓w
在領(lǐng)域中有4個(gè)模塊:
- 產(chǎn)品
- 用戶
- 訂單
- 購(gòu)物車
主要的參與者是用戶,想要在會(huì)話期間將用戶數(shù)據(jù)存儲(chǔ)在storage
中。為了對(duì)這些數(shù)據(jù)進(jìn)行類型化,需要?jiǎng)?chuàng)建一個(gè)名為"User"的領(lǐng)域?qū)嶓w。
User 實(shí)體將包含ID、姓名、郵箱以及喜好和過(guò)敏列表。
// domain/user.ts
export type UserName = string;
export type User = {
id: UniqueId;
name: UserName;
email: Email;
preferences: Ingredient[];
allergies: Ingredient[];
};
用戶將商品放入購(gòu)物車中。下面來(lái)為購(gòu)物車和產(chǎn)品添加類型。購(gòu)物車項(xiàng)將包含ID、名稱、以分為單位的價(jià)格和成分列表。
// domain/product.ts
export type ProductTitle = string;
export type Product = {
id: UniqueId;
title: ProductTitle;
price: PriceCents;
toppings: Ingredient[];
};
在購(gòu)物車中會(huì)保留用戶放入其中的產(chǎn)品列表:
// domain/cart.ts
import { Product } from "./product";
export type Cart = {
products: Product[];
};
在成功支付后,會(huì)創(chuàng)建一個(gè)新的訂單??梢蕴砑右粋€(gè)名為Order的實(shí)體類型。Order類型將包含用戶ID、已訂購(gòu)產(chǎn)品列表、創(chuàng)建日期和時(shí)間、訂單狀態(tài)以及整個(gè)訂單的總價(jià)格。
// domain/order.ts
export type OrderStatus = "new" | "delivery" | "completed";
export type Order = {
user: UniqueId;
cart: Cart;
created: DateTimeString;
status: OrderStatus;
total: PriceCents;
};
檢查實(shí)體之間的關(guān)系
以這種方式設(shè)計(jì)實(shí)體類型的好處是可以檢查它們的關(guān)系圖是否符合實(shí)際情況:
實(shí)體關(guān)系圖我們可以查看和檢查以下內(nèi)容:
- 主要參與者是否真的是用戶。
- 訂單中是否包含足夠的信息。
- 是否需要擴(kuò)展某些實(shí)體。
- 將來(lái)是否會(huì)出現(xiàn)可擴(kuò)展性問(wèn)題。
此外,在這個(gè)階段類型將有助于突出顯示實(shí)體之間的兼容性以及實(shí)體之間信號(hào)方向的錯(cuò)誤。如果一切符合期望,就可以開(kāi)始設(shè)計(jì)領(lǐng)域變換了。
創(chuàng)建數(shù)據(jù)轉(zhuǎn)化
上面設(shè)計(jì)的類型的數(shù)據(jù)將經(jīng)歷各種各樣的處理。我們將向購(gòu)物車中添加商品、清空購(gòu)物車、更新商品和用戶名稱等。我們將為所有這些轉(zhuǎn)換創(chuàng)建單獨(dú)的函數(shù)。
例如,要確定用戶是否對(duì)某個(gè)成分或喜好過(guò)敏,可以編寫(xiě)函數(shù)hasAllergy和hasPreference:
// domain/user.ts
export function hasAllergy(user: User, ingredient: Ingredient): boolean {
return user.allergies.includes(ingredient);
}
export function hasPreference(user: User, ingredient: Ingredient): boolean {
return user.preferences.includes(ingredient);
}
函數(shù) addProduct 和 contains 用于將商品添加到購(gòu)物車并檢查商品是否在購(gòu)物車中:
// domain/cart.ts
export function addProduct(cart: Cart, product: Product): Cart {
return { ...cart, products: [...cart.products, product] };
}
export function contains(cart: Cart, product: Product): boolean {
return cart.products.some(({ id }) => id === product.id);
}
我們還需要計(jì)算產(chǎn)品列表的總價(jià)格,為此需要編寫(xiě)函數(shù)totalPrice。如果需要,可以在這個(gè)函數(shù)中添加各種條件來(lái)考慮促銷碼或季節(jié)性折扣等。
// domain/product.ts
export function totalPrice(products: Product[]): PriceCents {
return products.reduce((total, { price }) => total + price, 0);
}
為了讓用戶能夠創(chuàng)建訂單,我們需要編寫(xiě)函數(shù)createOrder。它將返回與指定用戶和其購(gòu)物車關(guān)聯(lián)的新訂單。
// domain/order.ts
export function createOrder(user: User, cart: Cart): Order {
return {
cart,
user: user.id,
status: "new",
created: new Date().toISOString(),
total: totalPrice(products),
};
}
注意,在每個(gè)函數(shù)中,我們都構(gòu)建了 API,以便我們可以輕松地轉(zhuǎn)換數(shù)據(jù),函數(shù)接受參數(shù)并按照希望的方式給出結(jié)果。
在設(shè)計(jì)階段,還沒(méi)有外部限制。這使我們能夠盡可能地反映主題領(lǐng)域的數(shù)據(jù)轉(zhuǎn)換。轉(zhuǎn)換越接近現(xiàn)實(shí),檢查其工作就會(huì)更容易。
實(shí)現(xiàn)細(xì)節(jié):共享內(nèi)核
你可能已經(jīng)注意到,在描述領(lǐng)域類型時(shí)使用了一些類型,例如Email、UniqueId或DateTimeString。這些都是類型別名:
// shared-kernel.d.ts
type Email = string;
type UniqueId = string;
type DateTimeString = string;
type PriceCents = number;
我通常使用類型別名來(lái)擺脫基本類型過(guò)度使用的問(wèn)題。
這里使用DateTimeString而不僅僅是string,是為了清楚地表明使用了哪種類型的字符串。類型與主題領(lǐng)域越接近,處理錯(cuò)誤時(shí)就越容易。
指定的類型位于shared-kernel.d.ts文件中。共享內(nèi)核是代碼和數(shù)據(jù),其依賴關(guān)系不會(huì)增加模塊之間的耦合。
實(shí)際上,共享內(nèi)核可以這樣解釋。我們使用TypeScript,使用它的標(biāo)準(zhǔn)類型庫(kù),但不認(rèn)為它們是依賴關(guān)系。這是因?yàn)槭褂盟鼈兊哪K可能不了解彼此,并保持解耦狀態(tài)。
并非所有的代碼都可以歸類為共享內(nèi)核。最主要且最重要的限制是這類代碼必須與系統(tǒng)的任何部分兼容。如果應(yīng)用的一部分是用TypeScript編寫(xiě)的,另一部分是用其他語(yǔ)言編寫(xiě)的,共享內(nèi)核只能包含可用于這兩部分的代碼。例如,JSON 格式的實(shí)體規(guī)范是可以的,但TypeScript的幫助類就不行。
在我們的例子中,整個(gè)應(yīng)用程序都是用 TypeScript 編寫(xiě)的,所以對(duì)內(nèi)置類型的類型別名也可以歸類為共享內(nèi)核。這樣的全局可用類型不增加模塊之間的耦合,可以在應(yīng)用的任何部分使用。
實(shí)現(xiàn)細(xì)節(jié):應(yīng)用層
既然已經(jīng)搞清楚了領(lǐng)域,下面來(lái)繼續(xù)介紹應(yīng)用層,這一層包含了用例。
在代碼中,我們描述了場(chǎng)景的技術(shù)細(xì)節(jié)。用例是對(duì)在將商品添加到購(gòu)物車或進(jìn)行結(jié)賬后數(shù)據(jù)應(yīng)該發(fā)生的情況的描述。
用例涉及與外部的交互,因此需要使用外部服務(wù)。與外部的交互是副作用。我們知道,在沒(méi)有副作用的情況下,更容易處理和調(diào)試函數(shù)和系統(tǒng)。而且,我們的大部分領(lǐng)域函數(shù)被編寫(xiě)為了純函數(shù)。
了將純凈的轉(zhuǎn)換和與非純的交互結(jié)合起來(lái),可以使用應(yīng)用層作為非純的上下文。
純轉(zhuǎn)換的非純上下文
純轉(zhuǎn)換的非純凈上下文是一種代碼組織方式,其中:
- 首先執(zhí)行一個(gè)副作用來(lái)獲取數(shù)據(jù)。
- 然后對(duì)該數(shù)據(jù)進(jìn)行純轉(zhuǎn)換。
- 最后再次執(zhí)行一個(gè)副作用來(lái)存儲(chǔ)或傳遞結(jié)果。
在“將商品放入購(gòu)物車”用例中,這看起來(lái)像是:
- 首先,處理程序?qū)拇鎯?chǔ)中檢索購(gòu)物車狀態(tài)。
- 然后,它將調(diào)用購(gòu)物車更新函數(shù),并傳遞要添加的商品。
- 最后將更新后的購(gòu)物車保存在存儲(chǔ)中。
整個(gè)過(guò)程是一個(gè)“三明治”:副作用,純函數(shù),副作用。主要邏輯體現(xiàn)在數(shù)據(jù)轉(zhuǎn)換中,與外部的所有通信都被隔離在一個(gè)命令式的外殼中。
函數(shù)式架構(gòu):副作用,純函數(shù),副作用
非純上下文有時(shí)被稱為命令式外殼中的函數(shù)式核心。這就是我們?cè)诰帉?xiě)用例函數(shù)時(shí)將使用的方法。
設(shè)計(jì)用例
這里我們將選擇和設(shè)計(jì)結(jié)賬用例。這是最具代表性的一個(gè),因?yàn)樗钱惒降?,并與許多第三方服務(wù)進(jìn)行交互。
先來(lái)思考一下在這個(gè)用例中想要達(dá)到什么目標(biāo)。用戶有一個(gè)帶有商品的購(gòu)物車,當(dāng)用戶點(diǎn)擊結(jié)賬按鈕時(shí):
- 想要?jiǎng)?chuàng)建一個(gè)新的訂單。
- 在第三方支付系統(tǒng)中支付訂單。
- 如果付款失敗,向用戶通知。
- 如果成功,將訂單保存在服務(wù)器上。
- 將訂單添加到本地?cái)?shù)據(jù)存儲(chǔ)中以顯示在屏幕上。
從 API 和函數(shù)簽名的角度來(lái)看,我們希望將用戶和購(gòu)物車作為參數(shù)傳遞,并讓函數(shù)自行處理其他所有事情。
type OrderProducts = (user: User, cart: Cart) => Promise<void>;
理想情況下,用例不應(yīng)該采用兩個(gè)單獨(dú)的參數(shù),而是一個(gè)命令,它將在自身內(nèi)部封裝所有的輸入數(shù)據(jù)。但我們不希望讓代碼變得臃腫,所以將使用這種方式。
編寫(xiě)應(yīng)用層端口
讓我們來(lái)仔細(xì)看看用例的步驟:訂單創(chuàng)建本身就是一個(gè)領(lǐng)域函數(shù),其他都是想要使用的外部服務(wù)。
重要的是要記住,外部服務(wù)必須適應(yīng)我們的需求。因此,在應(yīng)用層中,我們將描述不僅僅是用例本身,還包括與這些外部服務(wù)進(jìn)行交互的接口:端口。
首先,端口應(yīng)該方便我們的應(yīng)用。如果外部服務(wù)的API不符合我們的需求,就需要編寫(xiě)一個(gè)適配器。
考慮一下將需要的服務(wù):
- 一個(gè)支付系統(tǒng);
- 一個(gè)用于通知用戶有關(guān)事件和錯(cuò)誤的服務(wù);
- 一個(gè)用于將數(shù)據(jù)保存到本地存儲(chǔ)的服務(wù)。
需要的服務(wù)現(xiàn)在討論的是這些服務(wù)的接口,而不是它們的實(shí)現(xiàn)。在這個(gè)階段,對(duì)于我們來(lái)說(shuō)描述所需行為非常重要,因?yàn)檫@是我們?cè)趹?yīng)用層中描述場(chǎng)景時(shí)所依賴的行為。
如何實(shí)現(xiàn)這個(gè)行為暫時(shí)還不重要。這使得我們可以將關(guān)于使用哪些外部服務(wù)的決策推遲到最后,從而使代碼的耦合度最小化。我們稍后會(huì)處理實(shí)現(xiàn)部分。
還要注意,我們將接口按功能拆分。與支付相關(guān)的所有內(nèi)容都在一個(gè)模塊中,與存儲(chǔ)相關(guān)的內(nèi)容在另一個(gè)模塊中。這樣做將更容易確保不混淆不同第三方服務(wù)的功能。
支付系統(tǒng)接口
餅干商店是一個(gè)簡(jiǎn)單的示例,因此支付系統(tǒng)將很簡(jiǎn)單。它有一個(gè) tryPay 方法,該方法將接受需要支付的金額,并作為響應(yīng)發(fā)送確認(rèn)。
// application/ports.ts
export interface PaymentService {
tryPay(amount: PriceCents): Promise<boolean>;
}
這里沒(méi)有進(jìn)行錯(cuò)誤處理,因?yàn)殄e(cuò)誤處理是一個(gè)獨(dú)立的大型主題,不是本次的討論范圍。
通常支付是在服務(wù)器上進(jìn)行的,但這只是一個(gè)示例,所以在客戶端上完成所有操作。可以輕松地通過(guò)與 API 通信而不是直接與支付系統(tǒng)通信。這種更改只會(huì)影響到這個(gè)用例,其余的代碼將保持不變。
通知服務(wù)接口
如果出現(xiàn)問(wèn)題,我們必須告訴用戶。可以通過(guò)不同的方式通知用戶。可以使用用戶界面,可以發(fā)送電子郵件,可以用手機(jī)短信來(lái)提醒用戶。
一般來(lái)說(shuō),通知服務(wù)最好也是抽象的,這樣就不必考慮具體實(shí)現(xiàn)的細(xì)節(jié)。
讓它接收消息并以某種方式通知用戶:
// application/ports.ts
export interface NotificationService {
notify(message: string): void;
}
本地存儲(chǔ)接口
我們將把新訂單保存在本地存儲(chǔ)庫(kù)中。
該存儲(chǔ)可以是任何東西:Redux、MobX、whatever-floats-your-boat-js。該存儲(chǔ)庫(kù)可以分為不同實(shí)體的微型存儲(chǔ)庫(kù),也可以成為所有應(yīng)用數(shù)據(jù)的一個(gè)大存儲(chǔ)庫(kù)?,F(xiàn)在也不重要,因?yàn)檫@些是實(shí)現(xiàn)細(xì)節(jié)。
我喜歡將存儲(chǔ)接口劃分為每個(gè)實(shí)體的單獨(dú)存儲(chǔ)接口。用于用戶數(shù)據(jù)存儲(chǔ)的單獨(dú)接口、用于購(gòu)物車的單獨(dú)接口、用于訂單存儲(chǔ)的單獨(dú)接口:
// application/ports.ts
export interface OrdersStorageService {
orders: Order[];
updateOrders(orders: Order[]): void;
}
用例功能
根據(jù)之前描述的接口和現(xiàn)有領(lǐng)域功能,讓我們嘗試構(gòu)建該用例的實(shí)現(xiàn)。正如之前所描述的,腳本將包含以下步驟:
- 驗(yàn)證數(shù)據(jù)。
- 創(chuàng)建訂單。
- 支付訂單。
- 通知問(wèn)題。
- 保存結(jié)果。
圖中自定義腳本的所有步驟首先,聲明要調(diào)用的服務(wù)模塊。TypeScript 會(huì)提示我們沒(méi)有在適當(dāng)?shù)淖兞恐袑?shí)現(xiàn)接口,但現(xiàn)在沒(méi)關(guān)系。
// application/orderProducts.ts
const payment: PaymentService = {};
const notifier: NotificationService = {};
const orderStorage: OrdersStorageService = {};
現(xiàn)在,我們可以將其視為真實(shí)的服務(wù)??梢栽L問(wèn)它們的字段,調(diào)用它們的方法。當(dāng)將用例轉(zhuǎn)換為代碼時(shí),這非常方便。
現(xiàn)在,我們創(chuàng)建一個(gè)名為orderProducts的函數(shù)。在函數(shù)內(nèi)部,首先創(chuàng)建一個(gè)新訂單:
// application/orderProducts.ts
//...
async function orderProducts(user: User, cart: Cart) {
const order = createOrder(user, cart);
}
這里我們把接口作為行為的契約。這意味著模塊實(shí)際上會(huì)執(zhí)行我們期望的操作:
// application/orderProducts.ts
//...
async function orderProducts(user: User, cart: Cart) {
const order = createOrder(user, cart);
// 嘗試支付訂單,如果出現(xiàn)問(wèn)題,通知用戶:
const paid = await payment.tryPay(order.total);
if (!paid) return notifier.notify("Оплата не прошла ??");
// 保存結(jié)果并清除購(gòu)物車:
const { orders } = orderStorage;
orderStorage.updateOrders([...orders, order]);
cartStorage.emptyCart();
}
注意,該用例不會(huì)直接調(diào)用第三方服務(wù)。它依賴于接口中描述的行為,因此只要接口保持不變,我們并不關(guān)心哪個(gè)模塊實(shí)現(xiàn)它以及如何實(shí)現(xiàn),這使得模塊可以更換。
實(shí)現(xiàn)細(xì)節(jié):適配器層
我們已經(jīng)將用例轉(zhuǎn)換成了TypeScript代碼?,F(xiàn)在需要檢查現(xiàn)實(shí)是否符合我們的需求。
通常情況下是不符合的。因此,可以通過(guò)適配器來(lái)調(diào)整外部以滿足我們的需求。
綁定UI和用例
第一個(gè)適配器是一個(gè)UI框架。它連接原生瀏覽器API與應(yīng)用。在訂單創(chuàng)建的情況下,它是“結(jié)算”按鈕和點(diǎn)擊事件的處理方法,它將調(diào)用該用例函數(shù)。
// ui/components/Buy.tsx
export function Buy() {
// 訪問(wèn)組件中的用例:
const { orderProducts } = useOrderProducts();
async function handleSubmit(e: React.FormEvent) {
setLoading(true);
e.preventDefault();
// 調(diào)用用例函數(shù):
await orderProducts(user!, cart);
setLoading(false);
}
return (
<section>
<h2>Checkout</h2>
<form onSubmit={handleSubmit}>{/* ... */}</form>
</section>
);
}
我們可以通過(guò)一個(gè) Hook 來(lái)封裝這個(gè)用例。我們將在鉤子函數(shù)內(nèi)部獲取所有的服務(wù),并將用例函數(shù)本身作為結(jié)果從 Hook 中返回。
// application/orderProducts.ts
export function useOrderProducts() {
const notifier = useNotifier();
const payment = usePayment();
const orderStorage = useOrdersStorage();
async function orderProducts(user: User, cookies: Cookie[]) {
// …
}
return { orderProducts };
}
我們使用 Hook 作為依賴注入。首先,我們使用 useNotifier、usePayment 和 useOrdersStorage 這些 Hook 獲取服務(wù)實(shí)例,然后使用 useOrderProducts 函數(shù)的閉包將它們提供給 orderProducts 函數(shù)使用。
注意,用例函數(shù)仍然與其余代碼分離,這對(duì)于測(cè)試是很重要的。在本文的最后,當(dāng)我們進(jìn)行審查和重構(gòu)時(shí),我們將完全提取它,使其更易于測(cè)試。
支付服務(wù)的實(shí)現(xiàn)
該用例使用 PaymentService 接口。下面就來(lái)實(shí)現(xiàn)它。
對(duì)于支付,我們將使用假的 API。同樣,我們現(xiàn)在沒(méi)必要編寫(xiě)整個(gè)服務(wù),可以稍后再編寫(xiě),重要的是實(shí)現(xiàn)指定的行為:
// services/paymentAdapter.ts
import { fakeApi } from "./api";
import { PaymentService } from "../application/ports";
export function usePayment(): PaymentService {
return {
tryPay(amount: PriceCents) {
return fakeApi(true);
},
};
}
fakeApi 是一個(gè)延遲觸發(fā)的超時(shí)函數(shù),延遲時(shí)間為450毫秒,模擬服務(wù)器的延遲響應(yīng)。它會(huì)返回我們作為參數(shù)傳遞給它的內(nèi)容。
// services/api.ts
export function fakeApi<TResponse>(response: TResponse): Promise<TResponse> {
return new Promise((res) => setTimeout(() => res(response), 450));
}
我們明確給 usePayment 函數(shù)的返回值進(jìn)行了類型聲明。這樣,TypeScript 將檢查該函數(shù)是否實(shí)際上返回一個(gè)包含接口中聲明的所有方法的對(duì)象。
通知服務(wù)的實(shí)現(xiàn)
通知使用簡(jiǎn)單的彈窗(alert)來(lái)實(shí)現(xiàn)。由于代碼是解耦的,稍后再來(lái)重寫(xiě)這個(gè)服務(wù)也問(wèn)題不大。
// services/notificationAdapter.ts
import { NotificationService } from "../application/ports";
export function useNotifier(): NotificationService {
return {
notify: (message: string) => window.alert(message),
};
}
本地存儲(chǔ)的實(shí)現(xiàn)
本地存儲(chǔ)使用 React 的 Context 和 Hook 來(lái)實(shí)現(xiàn)。創(chuàng)建一個(gè)新的上下文(Context),將值傳遞給 provider,導(dǎo)出該 provider,并通過(guò) Hook 訪問(wèn)存儲(chǔ)。
// store.tsx
const StoreContext = React.createContext({});
export const useStore = () => useContext(StoreContext);
export const Provider: React.FC = ({ children }) => {
// ...Other entities...
const [orders, setOrders] = useState([]);
const value = {
// ...
orders,
updateOrders: setOrders,
};
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>;
};
我們將為每個(gè)功能編寫(xiě)一個(gè) Hook,這樣一來(lái),我們就不會(huì)破壞接口隔離原則(ISP),并且存儲(chǔ)在接口層面上也是原子的。
// services/storageAdapter.ts
export function useOrdersStorage(): OrdersStorageService {
return useStore();
}
此方法還使我們能夠針對(duì)每個(gè)存儲(chǔ)實(shí)現(xiàn)自定義的額外優(yōu)化。可以創(chuàng)建選擇器、記憶等。
驗(yàn)證數(shù)據(jù)流程圖
接下來(lái)驗(yàn)證用戶在創(chuàng)建的用例期間如何與應(yīng)用通信。
用戶通過(guò)UI層與應(yīng)用進(jìn)行交互,UI層只能通過(guò)端口訪問(wèn)應(yīng)用。也就是說(shuō),如果需要,可以更改UI。
用例在應(yīng)用層中處理,應(yīng)用層告訴我們需要哪些外部服務(wù)。所有的主要邏輯和數(shù)據(jù)都在領(lǐng)域?qū)又小?/p>
所有的外部服務(wù)都被隱藏在基礎(chǔ)設(shè)施中,并且受到規(guī)范的限制。如果需要更改發(fā)送消息的服務(wù),只需要在代碼中修改適配器以適應(yīng)新服務(wù)即可。
這種架構(gòu)使代碼具有可替換性、可測(cè)試性,并且能夠根據(jù)不斷變化的需求進(jìn)行擴(kuò)展。
改進(jìn)
接下來(lái)看看如何改進(jìn)上面的實(shí)現(xiàn)。
使用對(duì)象而不是數(shù)字來(lái)表示價(jià)格
使用 number來(lái)描述價(jià)格并不是一個(gè)好的做法:
// shared-kernel.d.ts
type PriceCents = number;
數(shù)字只表示數(shù)量,不表示幣種,沒(méi)有幣種的價(jià)格是沒(méi)有意義的。理想情況下,價(jià)格應(yīng)作為具有兩個(gè)字段的對(duì)象:價(jià)值和貨幣。
type Currency = "RUB" | "USD" | "EUR" | "SEK";
type AmountCents = number;
type Price = {
value: AmountCents;
currency: Currency;
};
這將解決存儲(chǔ)貨幣的問(wèn)題,并在向商店更改或添加貨幣時(shí)節(jié)省大量精力。在示例中沒(méi)有使用這種類型,以免使其復(fù)雜化。然而,在實(shí)際代碼中,價(jià)格會(huì)更類似于這種類型。
另外,值得一提的是價(jià)格的價(jià)值,將金額使用貨幣流通中最小的分?jǐn)?shù)。例如,對(duì)于美元,它是美分。
以這種方式顯示價(jià)格可以不考慮除法和小數(shù)值。對(duì)于貨幣來(lái)說(shuō),如果想要避免浮點(diǎn)數(shù)計(jì)算問(wèn)題,這尤其重要。
按功能而不是按層次拆分代碼
代碼可以按照"功能"而不是按"層次"的方式進(jìn)行拆分。一個(gè)功能將是下面示意圖中的一塊。這種結(jié)構(gòu)更可取,因?yàn)檫@樣就獨(dú)立部署特定的功能。
注意跨組件使用
如果將系統(tǒng)拆分為組件,那么要注意代碼的跨組件使用。下面來(lái)看看訂單創(chuàng)建函數(shù):
import { Product, totalPrice } from "./product";
export function createOrder(user: User, cart: Cart): Order {
return {
cart,
user: user.id,
status: "new",
created: new Date().toISOString(),
total: totalPrice(products),
};
}
這個(gè)函數(shù)使用了另一個(gè)組件——產(chǎn)品的totalPrice。這種用法本身沒(méi)問(wèn)題,但如果要將代碼劃分為獨(dú)立的功能,則不能直接訪問(wèn)其他模塊的代碼。
注意領(lǐng)域中可能存在的依賴關(guān)系
接下來(lái)優(yōu)化在createOrder函數(shù)中在領(lǐng)域內(nèi)創(chuàng)建日期的做法。
import { Product, totalPrice } from "./product";
export function createOrder(user: User, cart: Cart): Order {
return {
cart,
user: user.id,
created: new Date().toISOString(),
status: "new",
total: totalPrice(products),
};
}
在項(xiàng)目中new Date().toISOString()可能會(huì)被頻繁重復(fù)使用,因此可以將其放在某種輔助函數(shù)中:
// lib/datetime.ts
export function currentDatetime(): DateTimeString {
return new Date().toISOString();
}
然后在領(lǐng)域中使用它:
// domain/order.ts
import { currentDatetime } from "../lib/datetime";
import { Product, totalPrice } from "./product";
export function createOrder(user: User, cart: Cart): Order {
return {
cart,
user: user.id,
status: "new",
created: currentDatetime(),
total: totalPrice(products),
};
}
然而,我們?cè)陬I(lǐng)域中不能依賴于任何東西,那該怎么辦呢?可以讓 createOrder 以完整的形式接收訂單的所有數(shù)據(jù)。日期可以作為最后一個(gè)參數(shù)傳遞進(jìn)來(lái):
// domain/order.ts
export function createOrder(user: User, cart: Cart, created: DateTimeString): Order {
return {
user: user.id,
products,
created,
status: "new",
total: totalPrice(products),
};
}
這樣就能夠在創(chuàng)建日期依賴于庫(kù)的情況下不違反依賴規(guī)則。如果在領(lǐng)域函數(shù)之外創(chuàng)建日期,那么很可能會(huì)在用例內(nèi)部創(chuàng)建日期,并將其作為參數(shù)傳遞:
function someUserCase() {
// 使用“dateTimeSource”適配器,以所需的格式獲取當(dāng)前日期::
const createdOn = dateTimeSource.currentDatetime();
// 將已創(chuàng)建的日期傳遞給領(lǐng)域函數(shù):
createOrder(user, cart, createdOn);
}
這樣做既保持了領(lǐng)域的獨(dú)立性,也使得測(cè)試更加容易。
在上面的示例中,沒(méi)有專注于此有兩個(gè)原因:一是會(huì)分散主要觀點(diǎn)的注意力,二是如果輔助函數(shù)僅使用語(yǔ)言特性,依賴自己的輔助函數(shù)并沒(méi)有什么問(wèn)題。這樣的輔助函數(shù)甚至可以被視為共享內(nèi)核,因?yàn)樗鼈冎皇菧p少了代碼重復(fù)。
因此,在這種情況下,使用自己的輔助函數(shù)來(lái)創(chuàng)建日期是可以接受的。它們只是為了簡(jiǎn)化代碼而引入,不引入外部依賴。如果這些輔助函數(shù)經(jīng)過(guò)良好的測(cè)試并且可靠,它們確實(shí)可以被視為共享核心的一部分。然而,在設(shè)計(jì)和實(shí)現(xiàn)共享核心時(shí),我們?nèi)匀恍枰?jǐn)慎考慮,并確保它們不引入與領(lǐng)域邏輯相關(guān)的外部依賴。
將領(lǐng)域?qū)嶓w和轉(zhuǎn)換保持純凈
在createOrder函數(shù)內(nèi)部創(chuàng)建日期的真正問(wèn)題在于副作用。副作用的問(wèn)題在于它們使系統(tǒng)的可預(yù)測(cè)性降低。為了解決這個(gè)問(wèn)題,有助于在領(lǐng)域中使用純的數(shù)據(jù)轉(zhuǎn)換,即不產(chǎn)生副作用的轉(zhuǎn)換。
創(chuàng)建日期是一種副作用,因?yàn)檎{(diào)用Date.now()的結(jié)果在不同的時(shí)間點(diǎn)是不同的。而純函數(shù),則是在給定相同參數(shù)的情況下始終返回相同的結(jié)果。
我們要盡可能保持領(lǐng)域的清晰性更好。這樣做更易于測(cè)試、移植和更新,并且更易于閱讀。副作用在調(diào)試時(shí)會(huì)大大增加認(rèn)知負(fù)荷,而領(lǐng)域不是保留復(fù)雜和混亂代碼的地方。
注意購(gòu)物車和訂單的關(guān)系
在這個(gè)例子中,訂單包括購(gòu)物車,因?yàn)橘?gòu)物車僅代表一個(gè)產(chǎn)品列表:
export type Cart = {
products: Product[];
};
export type Order = {
user: UniqueId;
cart: Cart;
created: DateTimeString;
status: OrderStatus;
total: PriceCents;
};
如果購(gòu)物車中存在與訂單無(wú)關(guān)的其他屬性,這種做法可能不適用。在這種情況下,最好使用數(shù)據(jù)投影或中間的數(shù)據(jù)傳輸對(duì)象(DTO)。
作為一種選擇,可以使用"產(chǎn)品列表"實(shí)體:
type ProductList = Product[];
type Cart = {
products: ProductList;
};
type Order = {
user: UniqueId;
products: ProductList;
created: DateTimeString;
status: OrderStatus;
total: PriceCents;
};
使用例更具可測(cè)試性
用例也有很多值得討論的地方。目前,orderProducts 函數(shù)很難脫離 React 進(jìn)行測(cè)試——這很糟糕。理想情況下,應(yīng)該能夠輕松地進(jìn)行測(cè)試。
當(dāng)前實(shí)現(xiàn)的問(wèn)題在于提供用例訪問(wèn)UI的 Hook:
// application/orderProducts.ts
export function useOrderProducts() {
const notifier = useNotifier();
const payment = usePayment();
const orderStorage = useOrdersStorage();
const cartStorage = useCartStorage();
async function orderProducts(user: User, cart: Cart) {
const order = createOrder(user, cart);
const paid = await payment.tryPay(order.total);
if (!paid) return notifier.notify("Oops! ??");
const { orders } = orderStorage;
orderStorage.updateOrders([...orders, order]);
cartStorage.emptyCart();
}
return { orderProducts };
}
在規(guī)范的實(shí)現(xiàn)中,用例函數(shù)將位于 hook 之外,并且服務(wù)將通過(guò)最后一個(gè)參數(shù)或通過(guò)依賴注入(DI)傳遞給用例:
type Dependencies = {
notifier?: NotificationService;
payment?: PaymentService;
orderStorage?: OrderStorageService;
};
async function orderProducts(
user: User,
cart: Cart,
dependencies: Dependencies = defaultDependencies,
) {
const { notifier, payment, orderStorage } = dependencies;
// ...
}
然后 hook 將成為一個(gè)適配器:
function useOrderProducts() {
const notifier = useNotifier();
const payment = usePayment();
const orderStorage = useOrdersStorage();
return (user: User, cart: Cart) =>
orderProducts(user, cart, {
notifier,
payment,
orderStorage,
});
}
然后,Hook 代碼可以被視為適配器,只有用例函數(shù)會(huì)保留在應(yīng)用層中。通過(guò)將所需的服務(wù)模擬作為依賴項(xiàng)傳遞,可以測(cè)試orderProducts函數(shù)。
配置自動(dòng)依賴注入
在應(yīng)用層中,我們現(xiàn)在手動(dòng)注入服務(wù):
export function useOrderProducts() {
// 這里使用hook來(lái)獲取每個(gè)服務(wù)的實(shí)例,
// 將在 orderProducts 用例中使用:
const notifier = useNotifier();
const payment = usePayment();
const orderStorage = useOrdersStorage();
const cartStorage = useCartStorage();
async function orderProducts(user: User, cart: Cart) {
// ...在用例中使用這些服務(wù)
}
return { orderProducts };
}
通常情況下,我們可以通過(guò)依賴注入來(lái)自動(dòng)化配置并進(jìn)行注入。我們已經(jīng)了解了通過(guò)最后一個(gè)參數(shù)實(shí)現(xiàn)的最簡(jiǎn)單的注入方式,但也可以進(jìn)一步配置自動(dòng)注入。
在這個(gè)特定的應(yīng)用中,設(shè)置依賴注入并不合理。這會(huì)分散注意力并使代碼過(guò)于復(fù)雜化。而且在React和hooks的情況下,可以將它們作為一個(gè) "容器",返回指定接口的實(shí)現(xiàn)。這雖然是手動(dòng)實(shí)現(xiàn)的,但它不會(huì)增加上手門檻,并且對(duì)于新的開(kāi)發(fā)人員來(lái)說(shuō)更容易閱讀。
實(shí)際項(xiàng)目可能更復(fù)雜
實(shí)際項(xiàng)目中可能會(huì)面臨更多復(fù)雜的問(wèn)題。這篇文章中的示例經(jīng)過(guò)了精心處理并且故意保持簡(jiǎn)單?,F(xiàn)實(shí)應(yīng)用比這個(gè)示例復(fù)雜得多。因此,接下來(lái)就談?wù)勗谑褂们逦軜?gòu)時(shí)可能出現(xiàn)的常見(jiàn)問(wèn)題。
業(yè)務(wù)邏輯的分支
最重要的問(wèn)題是我們?nèi)狈﹃P(guān)于主題領(lǐng)域的知識(shí)。想象一家商店有商品、折扣商品和核銷商品。我們應(yīng)該如何正確描述這些實(shí)體?
是否應(yīng)該有一個(gè)“基礎(chǔ)”實(shí)體來(lái)進(jìn)行擴(kuò)展?該實(shí)體應(yīng)如何擴(kuò)展?是否應(yīng)該有額外的字段?這些實(shí)體是否應(yīng)該是互斥的?是否應(yīng)立即減少重復(fù)?
可能會(huì)有太多的問(wèn)題和太多的答案,我們很難考慮到所有的情況。具體的解決方案取決于具體的情況,這里只能推薦一些通用的方法。
- 不要使用繼承,即使它看起來(lái)可以“擴(kuò)展”。
- 復(fù)制粘貼代碼中并不是都不好。制作兩個(gè)幾乎相同的實(shí)體,看看它們?cè)诂F(xiàn)實(shí)中的行為方式,觀察它們。在某些時(shí)候,它們要么變得非常不同,要么實(shí)際上僅在一個(gè)領(lǐng)域有所不同。將兩個(gè)相似的實(shí)體合并為一個(gè)比為每個(gè)可能的條件和變體創(chuàng)建檢查更容易。
- 記住協(xié)變性、逆變性和不變性,以免意外增加不必要的工作量。
- 在選擇不同的實(shí)體和擴(kuò)展之間時(shí),使用 BEM 中的塊和修飾符類比。當(dāng)在 BEM 的上下文中考慮時(shí),它非常有助于確定是否有一個(gè)獨(dú)立的實(shí)體或一個(gè)“修飾符擴(kuò)展”。
相互依賴的用例
第二個(gè)大問(wèn)題就是相關(guān)的用例,其中一個(gè)用例的事件觸發(fā)另一個(gè)用例。唯一的處理方式就是將用例拆分成更小、更原子的用例,然后再組合它們。一般而言,這種問(wèn)題是編程中另一個(gè)大問(wèn)題的結(jié)果,即實(shí)體組合,這里不再贅述。
總結(jié)
本文介紹了前端整潔架構(gòu)。這不是一個(gè)標(biāo)準(zhǔn),而是對(duì)不同項(xiàng)目、范例和語(yǔ)言的經(jīng)驗(yàn)總結(jié)。它是一個(gè)非常方便的方案,可以將代碼解耦,并創(chuàng)建獨(dú)立的層、模塊和服務(wù),不僅可以分開(kāi)部署和發(fā)布,還可以在需要時(shí)從項(xiàng)目轉(zhuǎn)移到項(xiàng)目。