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