本文來聊一聊前端整潔架構。
首先,總體了解什么是"整潔架構",并熟悉領域、用例和應用層等概念。然后,討論它如何應用于前端,以及它是否值得使用。然后,按照整潔架構的規則設計一個商店應用,并從頭開始設計一個用例,看看它是否可用。這個應用使用 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;
}
用例功能
根據之前描述的接口和現有領域功能,讓我們嘗試構建該用例的實現。正如之前所描述的,腳本將包含以下步驟:
-
驗證數據;
-
創建訂單;
-
支付訂單;
-
通知問題;
-
保存結果。
// 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("Оплата не прошла