前端簡潔架構,你瞭解了嗎?
原文鏈接:https://dev.to/bespoyasov/clean-architecture-on-frontend-4311
譯者: Goodme 前端團隊 陸晨傑
不久前,我做了一個關於前端簡潔架構(clean architecture on frontend)的演講。在這篇文章中,我將概述那次演講,並對其進行了一些擴展。
我在這裏附了一些含有不錯的內容的鏈接,這些對後續的閱讀會有一些幫助。
-
The Public Talk https://youtu.be/ThgqBecaq_w
-
Slides for the Talk https://bespoyasov.ru/talks/podlodka-conf-clean-architecture/en.html
-
The source code for the application we're going to design https://github.com/bespoyasov/frontend-clean-architecture
-
Sample of a working application https://bespoyasov.ru/showcase/frontend-clean-architecture/en/
文章概要
首先,我們將談論什麼是簡潔架構,並熟悉諸如領域(domain)、用例(use case)和應用層(application layers)等概念。然後,我們將討論這如何適用於前端,並探討其是否值得嘗試。
接下來,我們將按照簡潔架構的規則來設計一個餅乾商店的前端。最後,我們將從頭開始實現一個用例,來驗證其是否可用。
這個示例將使用 React 作爲 UI 框架,這樣可以展示這種方法也可以與 React 一起使用。(因爲這篇文章主要面向 React 的開發者)React 不是必須的,可以將本文中展示的所有內容結合其他 UI 庫或框架一起使用
代碼中會有一點 TypeScript,但只是爲了展示如何使用類型和接口來描述實體。我們今天要看的所有東西都可以在沒有 TypeScript 的情況下使用,只是代碼的可讀性會差一點。
我們今天幾乎不會談及面向對象編程(OOP),所以這篇文章不會引起一些爭論。我們只會在最後提到一次 OOP,但它不會影響我們設計一個應用程序。
另外,我們今天會跳過測試,因爲它們不是這篇文章的主要話題。但我會考慮到可測試性,並在過程中提到如何改進它。
最後,這篇文章主要是讓你掌握簡潔架構的概念。帖子中的例子是簡化的,所以它不是關於如何寫代碼的具體指導。請理解這個概念並思考如何在你的項目中應用這些原則。
在帖子的末尾,你可以找到與簡潔架構相關,且在前端更廣泛使用的一些方法論。所以你可以根據你的項目規模找到一個最適合的方法。
現在,讓我們開始實驗吧
架構與設計
設計的基本原則是將事物拆分...... 以便能夠重新組合起來。...... 將事物分成可以組合的部分,這就是設計。— Rich Hickey. Design Composition and Performance
如上所言,系統設計就是將系統分離,以便以後可以重新整合。而且最重要的是,不需要太多成本。
我同意這個觀點。但我認爲架構的另一個目標是系統的可擴展性。需求是不斷變化的。我們希望程序易於更新和修改以滿足新的需求。簡潔架構可以幫助實現這一目標。
簡潔架構
簡潔架構(The clean architecture)是一種根據其與應用領域的密切程度來分離職責和部分功能的方式。
通過領域(the domain),我們指的是我們用程序建模的現實世界的部分。這就是反映現實世界中變化(transformations)的數據轉換。例如,如果我們更新了一個產品的名稱,用新的名稱替換舊的名稱就是一個領域轉換(domain transformation)。
簡潔架構通常被稱爲三層架構,因爲其中的功能被分成了幾層。簡潔架構的原始帖子提供了一個突出顯示各層的圖表:
領域層(Domain Layer)
在中心位置的是領域層(the domain layer)。它是描述應用程序主題領域(subject area)的實體和數據,以及用於轉換數據的代碼。領域是區分一個應用程序與另一個應用程序的核心。
你可以認爲領域是在我們從 React 轉到 Angular 時或者我們改變了一些用例時不會改變的東西。在商店的案例中,這些是產品、訂單、用戶、購物車,以及更新其數據的功能。
領域實體(domain entities)的數據結構和其轉換的本質與外部世界無關。外部事件(External events)觸發了領域轉換(omain transformations),但並不決定它們將如何發生。
將物品添加到購物車的函數並不關心該物品到底是如何添加的:是由用戶自己通過 "購買" 按鈕添加的,還是通過促銷代碼自動添加的。在這兩種情況下,它都會接受該物品,並返回一個帶有新增物品的更新後的購物車。
應用層(Application Layer)
圍繞這個領域的是應用層(the application layer)。這一層描述了用例,即用戶場景。他們負責一些事件發生後的情況。
例如,"添加到購物車" 場景是一個用例。它描述了按鈕被點擊後應該採取的操作。這是一種 "協調器"(orchestrator),它將:
-
向服務器發送一個請求。
-
執行這個領域的轉換。
-
使用響應數據重新繪製用戶界面。
另外,在應用層中,還有端口(ports),即應用希望外部世界如何與之通信的規範。通常,一個端口是一個接口(interface),一個行爲契約(behavior contract)。
端口作爲應用程序的期望和現實之間的 "緩衝區"(buffer zone)。輸入端口(Input Ports)表明應用程序希望如何被外部世界聯繫;輸出端口(Output Ports)表明應用程序要如何與外部世界溝通,使其做好準備。
我們將在後面更詳細地瞭解端口。
適配器層(Adapters Layer)
最外層包含對外部服務的適配器(adapters)。適配器需要把不兼容的外部服務的 API 變成與應用程序兼容的 API。
適配器是降低代碼和三方服務代碼之間耦合度(coupling)的一個好方法。低耦合度減少了在更改其他模塊時需要更改一個模塊的需求。適配器通常被分爲:
-
驅動型(driving)-- 嚮應用程序發送信號。
-
被驅動型(driven)-- 接收來自應用程序的信號。
用戶最常與驅動型適配器進行交互。例如,UI 框架對按鈕點擊的處理就是一個驅動型適配器的工作。它與瀏覽器的 API(第三方服務)一起工作,並將事件轉換爲我們的應用程序可以理解的信號。
被驅動型適配器與基礎設施(infrastructure)進行交互。在前端,大部分的基礎設施是後臺服務器,但有時我們可能會與其他一些服務直接交互,如搜索引擎。
請注意,我們離中心越遠,代碼功能就越 "面向服務"(service-oriented),離我們應用程序的領域知識(domain knowledge)就越遠。這一點在判斷模塊應該屬於哪一層的時候會很重要。
依賴規則(Dependency Rule)
三層架構有一個依賴規則:只有外層可以依賴內層。這意味着
-
領域必須是獨立的。
-
應用層可以依賴領域。
-
外層可以依賴任何東西。
圖片來源:herbertograca.com。
有時可以違反這個規則,儘管最好不要濫用它。例如,有時在領域中使用一些 "類似庫"(library-like)的代碼是很方便的,儘管不應該有任何依賴關係。我們在看源代碼的時候會看到這樣的例子。
不受控制的依賴關係方向會導致複雜和混亂的代碼。例如,打破依賴性規則會導致:
-
循環依賴(Cyclic dependencies),模塊 A 依賴 B,B 依賴 C,C 又依賴 A。
-
測試性差(Poor testability),必須模擬整個系統來測試一個小部分。
-
耦合度太高(Too high coupling),模塊之間的交互變得和容易出錯。
簡潔架構的優勢
現在讓我們來談談這種代碼分離給我們帶來了什麼,它有什麼優點。
- 領域分離(Separate domain)
所有的主要應用功能都被隔離並收集在一個地方 -- 領域(domain)。領域中的功能是獨立的,它更容易測試。模塊的依賴性越少,測試所需的基礎設施就越少,需要的 mocks 和 stubs 就越少。獨立的領域也更容易針對業務預期(business expectations)進行測試,這有助於新開發人員掌握應用程序功能,有助於更快地尋找從業務語言到編程語言的 "翻譯" 中的錯誤和不準確之處。
- 獨立用例(Independent Use Cases)
應用場景即用例是單獨描述的。它們決定了需要哪些第三方服務,我們使外部世界適應我們的需求,這給了我們更多選擇第三方服務的自由。例如,如果當前的支付系統開始收費過高,我們可以迅速改變支付系統。
用例代碼也變得扁平、可測試和可擴展。我們將在後面的一個例子中看到這一點。
- 可替換的三方服務(Replaceable Third-Party Services)
由於適配器的存在,外部服務變得可替換。只要我們不改變接口,哪個外部服務實現了這個接口並不重要。
這樣一來,我們就爲變化的傳播建立了一個屏障:別人的代碼的變化不會直接影響到我們自己的。適配器也限制了應用程序運行時的錯誤傳播。
簡潔架構的成本
架構首先是一種工具。像任何工具一樣,簡潔架構除了好處之外,還有它的成本。
- 時間成本(Takes Time)
主要的成本是時間成本。它不僅在設計上需要,而且在實現上也需要,因爲直接調用第三方服務總是比編寫適配器要容易。
要事先考慮好系統中所有模塊的交互也是很困難的,因爲我們可能事先不知道所有的需求和約束(requirements and constraints)。在設計時,我們需要牢記系統如何變化,併爲擴展留出空間。
- 冗餘成本(Overly Verbose)
一般來說,簡潔架構的典範實現並不總是有利的,有時甚至是有害的。如果項目很小,完整的實現將是一種矯枉過正,會增加新人的入門門檻。
你可能需要做出設計上的權衡,以保持在預算(budget)或期限(deadline)內。我將通過實例向你展示我所說的這種權衡的確切含義。
- 更高的門檻
全面實施簡潔架構會使實施更加困難,因爲任何工具都需要了解如何使用它。如果你在項目開始時過度設計,那麼以後就更難讓新的開發人員掌握了。你必須牢記這一點,並保持你的代碼簡單。
- 增加代碼的數量
簡潔架構會增加前端項目最終打包的代碼量。我們給瀏覽器的代碼越多,它需要下載、解析和解釋的就越多。我們必須注意代碼的數量,並決定在哪些方面做優化:
-
對用例的描述要簡單一些。
-
直接從適配器訪問域的功能,繞過用例。
-
我們必須調整代碼分割(code splitting),等等。
成本優化
你可以通過” 偷工減料 “和犧牲架構的 "清潔度"(cleanliness)來減少時間和代碼量成本。我一般不喜歡激進的方法:如果打破一個規則更高效(例如,收益將高於潛在的成本),我就會打破它。
所以,你可以在簡潔架構的某些方面” 周旋 “,這完全沒有問題。然而,這與絕對值得投入的最低要求的資源量是兩件事。
- 提取領域(Extract Domain)
提取領域有助於理解我們正在設計工程的的總體內容以及它應該如何工作。提取的領域使新的開發者更容易理解應用程序、其實體和應用之間的關係。
即使我們跳過了其他的層,也會更容易使用提取出來的領域進行工作和重構,因爲它並沒有分佈在代碼庫中。其他層可以根據需要添加。
- 服從依賴規則(Obey Dependency Rule)
第二個不能被拋棄的規則是依賴規則,或者說是它們的方向(direction)。外部服務必須適應我們的需要。
如果你覺得你正在 "微調" 你的代碼,以便它能夠調用搜索 API,那就有問題了。最好在問題蔓延之前寫一個適配器。
設計應用
現在我們已經談論了理論,我們可以開始實踐了。讓我們來設計餅乾店的架構。
該商店將出售不同種類的餅乾,它們可能有不同的成分。用戶將選擇餅乾並訂購,並在第三方支付服務中支付訂單。在主頁上將會有一個可以購買餅乾的展示。只有在經過認證(authenticated)後才能購買餅乾。登錄按鈕將跳轉到登錄頁面以進行登錄。
首先,我們將定義所擁有所有這些實體、用例和廣義上的功能,然後決定它們應該屬於哪一層。
設計領域
一個應用程序中最重要的是領域,它包含了應用程序的主要實體和數據轉換。我建議你從領域開始,以便在你的代碼中準確地表達應用程序的領域知識。
商店領域可能包括:
-
實體的數據類型:用戶、cookie、購物車和訂單。
-
創建實體的工廠,如果你用 OOP 編寫,則是類。
-
數據的轉換函數(transformation functions)。
領域中的轉換函數應該只依賴於領域的規則。例如,這樣的函數將是:
-
一個計算總成本的函數。
-
用戶的口味偏好檢測。
-
確定一件商品是否在購物車中,等等。
設計應用層
應用層包含用例(use cases)。一個用例總是有一個行爲者(actor)、一個動作(action)和一個結果(result)。
在商店裏,我們可以區分:
-
產品購買場景。
-
支付,調用第三方支付系統。
-
與產品和訂單的互動:更新、瀏覽。
-
根據角色訪問頁面。
用例通常以主題領域(subject area)的方式描述。例如,"結賬" 場景實際上由幾個步驟組成:
-
從購物車中檢索商品並創建一個新的訂單。
-
爲訂單付款。
-
如果支付失敗,通知用戶。
-
清除購物車並顯示訂單。
用例函數將是描述這種情況的代碼。
另外,在應用層中,有一些端口 - 接口(ports—interfaces)用於與外部世界進行通信。
設計適配器層
在適配器層,我們聲明對外部服務的適配器。適配器使第三方服務的不兼容的 API 與我們的系統兼容。
在前端,適配器通常是 UI 框架和 API 服務器請求模塊。在我們的案例中,我們將使用:
-
UI 框架;
-
API 請求模塊。
-
本地存儲的適配器。
-
API 迴應到應用層的適配器和轉換器。
請注意,功能越是 "類似服務"(service-like),就越是遠離圖表的中心。
MVC 類比
有時候,我們很難知道某些數據屬於哪一層。用 MVC 的類比可能對這裏有幫助。
-
模型(models)通常是領域實體(domain entities)。
-
控制器(controllers)是領域轉換(domain transformations)和應用層(application layer)。
-
視圖(view)是驅動適配器(driving adapters)。
這些概念在細節上有所不同,但相當相似,這種類比可以用來定義領域和應用的代碼。
深入細節:領域
一旦我們確定了我們需要哪些實體,我們就可以開始定義它們的行爲方式。
我會向你展示項目中的代碼結構如下:
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 個模塊:
-
產品。
-
用戶。
-
訂單。
-
購物車。
主要行爲者是用戶。在會話期間,我們將把關於用戶的數據存儲在存儲器中。我們想把這些數據打出來,所以我們將創建一個用戶類型實體。該用戶類型將包含 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[];
};
在成功付款後,一個新的訂單被創建。讓我們添加一個訂單實體類型。該訂單類型將包含用戶 ID、訂購產品列表、創建日期和時間、狀態和整個訂單的總價格。
// domain/order.ts
export type OrderStatus = "new" | "delivery" | "completed";
export type Order = {
user: UniqueId;
cart: Cart;
created: DateTimeString;
status: OrderStatus;
total: PriceCents;
};
檢查實體之間的關係
以這種方式設計實體類型的好處是,我們已經可以檢查它們的關係圖是否與現實相符。
-
是否 actor 確實是一個用戶。
-
訂單中是否有足夠的信息。
-
某些實體是否需要被擴展。
-
未來是否會出現可擴展性的問題。
另外,在這個階段,類型將有助於突出實體之間的兼容性和它們之間的信號方向的錯誤。
如果一切符合我們的期望,我們就可以開始設計領域轉換。
創建數據轉換
各種各樣的事情都會發生在我們剛剛設計好的數據類型上。我們將向購物車添加物品,清除購物車,更新物品和用戶名,等等。我們將爲所有這些轉換創建單獨的函數。
例如,爲了確定一個用戶是否對某種成分偏好或者過敏,我們可以編寫函數 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 {
user: user.id,
cart,
created: new Date().toISOString(),
status: "new",
total: totalPrice(products),
};
}
請注意,在每個函數中,我們都建立了 API,這樣我們就可以舒適地轉換數據。我們接受參數並按我們的要求給出結果。
在設計階段,還沒有任何外部約束。這使我們能夠儘可能地反映出與主題領域接近的數據轉換。而且,轉換越接近現實,檢查其工作就越容易。
詳細設計:共享核心
你可能已經注意到我們在描述領域類型時使用的一些類型。例如,Email、UniqueId 或 DateTimeString。這些都是類型的別名(type-alias)。
// shared-kernel.d.ts
type Email = string;
type UniqueId = string;
type DateTimeString = string;
type PriceCents = number;
我通常使用類型別名來擺脫原子類型偏執(primitive obsession)。
原子類型偏執:創建一個基本類型字段比創建一個全新的結構類型的類要容易得多,新手通常不願意在小任務上運用小對象,從而忽略了面向對象帶來的各種好處。一些列參數或域有時候可以用一個更有意義的小對象取代之。
Primitive Obsession 是指代碼過於依賴原語(primitives)。這意味着原子值(primitive value)控制類中的邏輯,並且該值不是類型安全的。因此,原子類型偏執是指使用原子類型來表示域中的對象這種不好的做法。參考 Primitive Obsession — A Code Smell that Hurts People the Most
我使用 DateTimeString 而不是僅僅使用 string,以便更清楚地說明使用的是什麼類型的字符串。類型與主題領域越接近,當錯誤發生時就越容易處理。
指定的類型在文件 shared-kernel.d.ts 中。共享核心(Shared kernel)是代碼和數據,對它的依賴不會增加模塊間的耦合。關於這個概念的更多信息,你可以在 "DDD, Hexagonal, Onion, Clean, CQRS, ...How I put it all together" 中找到。
在實踐中,共享核心可以這樣解釋:我們使用 TypeScript,我們使用它的標準類型庫,但我們不認爲它們是依賴關係。這是因爲它們的模塊對彼此一無所知,並保持解耦。
不是所有的代碼都可以被歸類爲共享內核。主要的和最重要的限制是,這種代碼必須與系統的任何部分兼容。如果應用程序的一部分是用 TypeScript 編寫的,而另一部分是用另一種語言編寫的,那麼共享內核可能只包含可以在兩部分中使用的代碼。例如,JSON 格式的實體規範是可以的,TypeScript 的 helpers 就不行。
在我們的案例中,整個應用程序是用 TypeScript 編寫的,所以內置類型上的類型別名也可以歸類爲共享核心。這種全局可用的類型不會增加模塊之間的耦合性,可以在應用程序的任何部分使用。
深入細節:應用層
現在我們已經弄清楚了領域,接下來我們可以研究應用層了。這層包含了用例。
在代碼中我們描述了場景的技術細節。用例是對將商品添加到購物車或繼續結帳後數據變更情況的描述。
用例涉及與外界的交互,進而涉及外部服務的使用。與外界的進行交互是存在副作用的。衆所周知,沒有副作用的函數和系統更容易工作和調試。並且我們的大多數域函數已經被編寫爲純函數。
未了讓整潔的轉換層和帶有副作用的外界交互可以整合起來,我們可以將應用層作爲一個非純淨的上下文來使用。
在純轉換層中使用非純淨上下文
爲一個純淨的轉換層提供一個非純淨的上下文是代碼組織方式的一種,其中:純轉換的不純上下文是一種代碼組織,其中:
-
我們首先執行副作用操作來獲取一些數據;
-
然後我們對該數據進行無副作用的轉換;
-
然後再次執行副作用操作來存儲或傳遞結果。
在 “將商品放入購物車” 用例中,這看起來像:
-
首先,處理程序將從存儲中檢索購物車狀態;
-
然後它會調用購物車更新函數,將要添加的商品傳遞給它;
-
然後它會將更新後的購物車保存在存儲中。
整個過程就是一個 “三明治”:副作用、純函數、副作用。主要邏輯體現在數據轉換上,所有與外界的通信都被隔離在一個命令式的外殼中。
設計用例
我們將選擇和設計結賬用例。它是最具代表性的的一個案例,因爲它是一個異步的行爲並且與很多第三方服務存在交互。其餘場景和整個應用程序的代碼您可以在 GitHub 上找到。
讓我們考慮一下我們想要在這個用例中實現什麼。用戶有一個帶有餅乾的購物車,當用戶單擊結帳按鈕時:
-
我們想要創建一個新訂單;
-
通過第三方支付系統進行支付;
-
如果支付失敗,通知用戶;
-
如果通過,則將訂單保存到服務器上;
-
將訂單添加到本地數據存儲以顯示在屏幕上。
在 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 = {};
我們現在可以像使用真正的服務一樣使用這些 Stub 。我們可以訪問他們的字段,調用他們的方法。當需要將用例從業務語言 “翻譯” 爲軟件語言時,這非常方便。
現在,創建一個名爲 的函數orderProducts
。在內部,我們要做的第一件事是創建一個新訂單:
// application/orderProducts.ts
//...
async function orderProducts(user: User, cart: Cart) {
const order = createOrder(user, cart);
}
在這裏,我們利用了接口會定義代碼行爲的特性。這意味着將來 Stub 將實際執行我們現在期望的操作:
// application/orderProducts.ts
//...
async function orderProducts(user: User, cart: Cart) {
const order = createOrder(user, cart);
// Try to pay for the order;
// Notify the user if something is wrong:
const paid = await payment.tryPay(order.total);
if (!paid) return notifier.notify("Oops! 🤷");
// Save the result and clear the cart:
const { orders } = orderStorage;
orderStorage.updateOrders([...orders, order]);
cartStorage.emptyCart();
}
請注意,該用例不會直接調用第三方服務。它取決於接口中描述的行爲,因此只要接口保持不變,我們並不關心哪個模塊實現它以及如何實現。這使得模塊可以更換。
深入細節:適配器層
我們已將用例 “翻譯” 爲 TypeScript 代碼。現在我們必須檢查現實是否符合我們的需求。
通常情況下都不會滿足需求。因此,我們使用適配器來調整外部接口以滿足我們的需求。
綁定 UI 和用例
第一個適配器是一個 UI 框架。它將瀏覽器 API 與應用程序連接起來。在訂單創建的場景中,點擊 “結賬” 按鈕就會觸發用例方法。
// ui/components/Buy.tsx
export function Buy() {
// Get access to the use case in the component:
const { orderProducts } = useOrderProducts();
async function handleSubmit(e: React.FormEvent) {
setLoading(true);
e.preventDefault();
// Call the use case function:
await orderProducts(user!, cart);
setLoading(false);
}
return (
<section>
<h2>Checkout</h2>
<form onSubmit={handleSubmit}>{/* ... */}</form>
</section>
);
}
讓我們通過鉤子提供用例。我們將獲取內部的所有服務,因此,我們也可以從鉤子中獲取用例方法本身。
// application/orderProducts.ts
export function useOrderProducts() {
const notifier = useNotifier();
const payment = usePayment();
const orderStorage = useOrdersStorage();
async function orderProducts(user: User, cookies: Cookie[]) {
// …
}
return { orderProducts };
}
我們使用鉤子作爲 “彎曲的依賴注入”。首先,我們使用 hooks useNotifier
、usePayment
、useOrdersStorage
來獲取服務實例,然後使用閉包函數useOrderProducts
來使它們在orderProducts
函數內可用。
需要注意的是,用例函數仍然與代碼的其餘部分分開,這對於測試很重要。在文章的最後,當我們進行代碼審查和重構時,我們會完全的剔除它來讓其更易於測試。
支付服務接口
用例使用接口PaymentService
。讓我們來實現它。
對於付款,我們將使用 fakeAPI
Stub。再說一次,我們沒有必要編寫整個服務,我們可以稍後編寫,主要的事情是定義必要的行爲:
// services/paymentAdapter.ts
import { fakeApi } from "./api";
import { PaymentService } from "../application/ports";
export function usePayment(): PaymentService {
return {
tryPay(amount: PriceCents) {
return fakeApi(true);
},
};
}
該fakeApi
函數是一個定時器,在 450ms 後觸發,模擬服務器的延遲響應。它返回我們作爲參數傳遞給它的內容。
// 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 和 hooks 來實現本地存儲,我們創建一個新的上下文,將值傳遞給提供者(provider),導出提供者並通過鉤子訪問存儲。
// store.tsx
const StoreContext = React.createContext<any>({});
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>
);
};
我們將爲每個功能編寫一個鉤子。這樣我們就不會破壞接口隔離原則(ISP), 並且存儲至少在接口方面是原子性的。
// services/storageAdapter.ts
export function useOrdersStorage(): OrdersStorageService {
return useStore();
}
此外,該方法還將爲我們提供自定義每個存儲(store)的額外優化能力:我們可以創建選擇器、記憶(memoization)等等。
驗證數據流程圖
現在讓我們驗證一下在創建的用例中用戶將如何與應用程序進行通信。
用戶通過 UI 層與應用程序進行交互,UI 層只能通過接口訪問應用程序。也就是說我們可以根據需要更改 UI。
用例在應用層中進行處理,該層告訴我們需要哪些外部服務。所有的主要邏輯和數據都在領域層中。
所有外部服務都隱藏在基礎設施中,並受到我們的規範約束。如果我們需要更改發送消息的服務,我們只需在代碼中修改適配器以適應新的服務。
這種架構使得代碼具有可替換性、可測試性,並且可以根據不斷變化的需求進行擴展。
哪些方面可以改進
總而言之,這已經足夠讓您開始並對清晰架構有一個初步的理解。但是,我想指出爲了簡化示例而簡化的一些內容。
本節是可選的,但它將讓您對 “沒有偷懶的” 清晰架構可能是什麼樣子有更深入的理解。
我想強調幾點可以做的事情。
使用對象而不是數字作爲價格
您可能已經注意到我用數字來描述價格。這不是一個好的做法。
// shared-kernel.d.ts
type PriceCents = number;
數字只表示數量,不表示貨幣,沒有貨幣的價格是沒有意義的。理想情況下,價格應該被設計爲一個對象,包含兩個字段:值(value)和貨幣(currency)。
type Currency = "RUB" | "USD" | "EUR" | "SEK";
type AmountCents = number;
type Price = {
value: AmountCents;
currency: Currency;
};
這將解決存儲貨幣以及在更改或添加貨幣到存儲中時節省大量工作和精力的問題。我在示例中沒有使用這種類型是爲了不使其過於複雜。然而,在實際代碼中,價格將更接近這種類型。
另外,值得一提的是價格的值。我始終將貨幣金額保存爲該貨幣流通中最小的單位。例如,對於美元來說,它是以美分(cents)爲單位。
以這種方式顯示價格可以避免考慮除法和小數值。對於貨幣來說,如果我們想要避免浮點數運算的問題,這尤其重要。
按功能而不是層拆分代碼
代碼可以按 “功能” 而不是 “層” 進行分組。每個功能可以看作是下面示意圖中的一個部分。
這種結構更加可取,因爲它允許您單獨部署特定的功能,這通常是很有用的。
圖片來源:herbertograca.com。
我建議您閱讀 “DDD、六角形、洋蔥、清潔、CQRS,... 我如何將它們組合在一起” 中的相關內容。
我還建議查看 Feature Sliced,它在概念上與組件代碼劃分非常相似,但更容易理解。
注意跨組件使用
如果我們談論將系統拆分爲組件,那麼值得一提的是代碼的跨組件使用。讓我們記住訂單創建函數:
import { Product, totalPrice } from "./product";
export function createOrder(user: User, cart: Cart): Order {
return {
user: user.id,
cart,
created: new Date().toISOString(),
status: "new",
total: totalPrice(products),
};
}
這個函數使用了另一個組件(產品)中的 totalPrice。這種使用本身是可以的,但如果我們想將代碼分成獨立的功能模塊,我們就不能直接訪問其他功能模塊的功能。
您還可以在 “DDD、Hexagonal、Onion、Clean、CQRS,... 我如何將它們放在一起” 和 Feature Sliced 中看到解決此限制的方法。
使用品牌類型(Branded Types),而不是類型別名(Aliases)
對於共享核心(Shared Kernel),我使用了類型別名(Type Aliases)。它們很容易操作:只需創建一個新的類型並引用,例如字符串。但它們的缺點是 TypeScript 沒有機制來監視它們的使用並強制使用。
這似乎不是一個問題:如果有人使用 String
而不是 DateTimeString
,那又怎樣呢?代碼會編譯通過。
問題在於,即使使用了更寬泛的類型(在技術術語中稱爲弱化的前提條件),代碼也會編譯通過。這首先使得代碼更加脆弱,因爲它允許使用任何字符串,而不僅僅是特定類型的字符串,這可能導致錯誤。
其次,這種寫法很令人困惑,因爲它創建了兩個數據來源。不清楚您是否真的只需要使用日期,或者基本上可以使用任何字符串。
有一種方法可以讓 TypeScript 理解我們想要的特定類型,那就是使用品牌化類型(Branded Types)。品牌化類型可以跟蹤確切的類型使用方式,但會使代碼稍微複雜一些。
注意領域中可能存在的依賴關係
接下來令人不悅的是在 createOrder
函數中在領域中創建日期:
import { Product, totalPrice } from "./product";
export function createOrder(user: User, cart: Cart): Order {
return {
user: user.id,
cart,
// Вот эта строка:
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 {
user: user.id,
cart,
created: currentDatetime(),
status: "new",
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() {
// Use the `dateTimeSource` adapter,
// to get the current date in the desired format:
const createdOn = dateTimeSource.currentDatetime();
// Pass already created date to the domain function:
createOrder(user, cart, createdOn);
}
這將保持領域的獨立性,並且使測試更加容易。
在這些示例中,我們不過多關注這一點,有兩個原因:它會分散主要觀點的注意力,並且如果自己的輔助函數僅使用語言特性,依賴於它們並沒有什麼問題。這樣的輔助函數甚至可以被視爲共享核心,因爲它們只減少了代碼重複。
注意購物車和訂單的關係
在這個小示例中,Order
包括Cart
,因爲購物車僅代表產品列表:
export type Cart = {
products: Product[];
};
export type Order = {
user: UniqueId;
cart: Cart;
created: DateTimeString;
status: OrderStatus;
total: PriceCents;
};
如果購物車(Cart)中有與訂單(Order)無關的其他屬性,那麼這種方式可能不適用。在這種情況下,最好使用數據投影(data projections)或中間數據傳輸對象(DTO)。
作爲一個選項,我們可以使用 “產品列表” 實體(Product List):
type ProductList = Product[];
type Cart = {
products: ProductList;
};
type Order = {
user: UniqueId;
products: ProductList;
created: DateTimeString;
status: OrderStatus;
total: PriceCents;
};
使用戶案例更具可測試性
同樣,用例也有很多討論的地方。目前,orderProducts
函數很難在與 React 分離的情況下進行測試,這是不好的。理想情況下,應該能夠以最小的工作量進行測試。
當前實現的問題在於提供用例訪問 UI 的鉤子函數:
// 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 };
}
在典型的實現中,用例函數將位於鉤子函數之外,服務將通過最後一個參數或通過依賴注入(DI)傳遞給用例函數:
type Dependencies = {
notifier?: NotificationService;
payment?: PaymentService;
orderStorage?: OrderStorageService;
};
async function orderProducts(
user: User,
cart: Cart,
dependencies: Dependencies = defaultDependencies
) {
const { notifier, payment, orderStorage } = dependencies;
// ...
}
然後鉤子將成爲一個適配器:
function useOrderProducts() {
const notifier = useNotifier();
const payment = usePayment();
const orderStorage = useOrdersStorage();
return (user: User, cart: Cart) =>
orderProducts(user, cart, {
notifier,
payment,
orderStorage,
});
}
然後,鉤子函數的代碼可以被視爲適配器,只有用例函數會保留在應用層。通過傳遞所需的服務模擬作爲依賴項,可以對 orderProducts 函數進行測試。
配置自動依賴注入
在應用程序層中,我們現在手動注入服務:
export function useOrderProducts() {
// Here we use hooks to get the instances of each service,
// which will be used inside the orderProducts use case:
const notifier = useNotifier();
const payment = usePayment();
const orderStorage = useOrdersStorage();
const cartStorage = useCartStorage();
async function orderProducts(user: User, cart: Cart) {
// ...Inside the use case we use those services.
}
return { orderProducts };
}
但總的來說,這可以通過依賴注入自動化完成。我們已經看過了通過最後一個參數進行簡單注入的版本,但你可以進一步配置自動注入。
在這個特定的應用程序中,我認爲設置依賴注入沒有太多意義。這會分散注意力並使代碼變得過於複雜。而且在 React 和鉤子函數的情況下,我們可以將它們用作返回指定接口實現的 “容器”。是的,這是手動工作,但它不會增加入門門檻,並且對於新開發人員來說閱讀更快。
實際項目中可能會更復雜
帖子中的示例經過精煉並且故意簡單化。顯然,真實場景比這個例子更加複雜。所以我還想談談使用簡潔架構時可能出現的常見問題。
分支業務邏輯
最重要的問題是我們對於主題領域的瞭解不足。想象一家商店有產品、折扣產品和報廢產品。我們如何正確描述這些實體?
是否應該有一個 “基礎” 實體進行擴展?這個實體應該如何擴展?是否需要額外的字段?這些實體是否應該互斥?如果簡單的實體變成了其他實體,用例應該如何行爲?是否應該立即減少重複?
可能會有太多的問題和太多的答案,因爲團隊和利益相關者還不知道系統應該如何實際運行。如果只有假設,你可能會陷入分析麻痹。
具體的解決方案取決於具體的情況,我只能提供一些建議。
不要使用繼承,即使它被稱爲 “擴展”。即使它看起來確實是繼承了接口。即使它看起來像是 “這裏顯然有一個層次結構”。多考慮一下。
代碼中的複製粘貼並不總是差勁的,它是一種工具。創建兩個幾乎相同的實體,觀察它們在實際中的行爲。在某個時候,你會注意到它們要麼變得非常不同,要麼只是在一個字段上有所不同。將兩個相似的實體合併成一個比爲每個可能的條件和變量創建檢查要容易得多。
如果你仍然需要擴展一些東西...
牢記協變性、逆變性和不變性,以免意外地增加不必要的工作量。
在選擇不同的實體和擴展時,使用 BEM 中的塊和修飾符類比。當我在 BEM 的上下文中考慮時,它對我在確定是否有一個單獨的實體或者一個 “修飾符擴展” 代碼時非常有幫助。
相互依賴的用例
第二個重要的問題涉及到使用用例,其中一個用例的事件觸發另一個用例。
我所知道和幫助我的處理方式是將用例拆分爲更小、原子化的用例。這樣它們將更容易組合在一起。
一般來說,這種腳本的問題是編程中另一個重大問題——實體組合的結果。
關於如何有效地組合實體已經有很多相關的文獻,甚至有一個完整的數學領域。我們不會深入討論,那是一個單獨的文章主題。
總結
在本文中,我概述並稍微擴展了我在前端領域關於清潔架構的演講。
這不是一個黃金標準,而是基於我在不同項目、範式和語言中的經驗總結而成。我認爲這是一種方便的方案,可以將代碼解耦,並創建獨立的層、模塊和服務,這些不僅可以單獨部署和發佈,而且在需要時還可以從項目轉移到項目。
我們沒有涉及面向對象編程(OOP),因爲架構和 OOP 是正交的。是的,架構談論了實體組合,但它並沒有規定組合的單位應該是對象還是函數。你可以在不同的範式中使用這個方法,正如我們在示例中看到的那樣。
至於 OOP,我最近寫了一篇關於如何在 OOP 中使用清潔架構的文章。在這篇文章中,我們使用畫布生成樹形圖片。
如果想了解如何將這種方法與其他內容(如片段切割、六邊形架構、CQS 等)結合起來,我建議閱讀《DDD,Hexagonal,Onion,Clean,CQRS,... How I put it all together》以及該博客的整個系列文章。非常深入、簡潔和切中要點。
參考文獻
-
Public Talk about Clean Architecture on Frontend
-
Slides for the Talk
-
The source code for the application we're going to design
-
Sample of a working application
設計實踐
-
The Clean Architecture
-
Model-View-Controller
-
DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together
-
Ports & Adapters Architecture
-
More than Concentric Layers
-
Generating Trees Using L-Systems, TypeScript, and OOP Series' Articles
系統設計
-
Domain Knowledge
-
Use Case
-
Coupling and cohesion
-
Shared Kernel
-
Analysis Paralysis
有關設計和編碼的書籍
-
Design Composition and Performance
-
Clean Architecture
-
Patterns for Fault Tolerant Software
有關 TypeScript、C# 和其他語言的概念
-
Interface
-
Closure
-
Set Theory
-
Type Aliases
-
Primitive Obsession
-
Floating Point Math
-
Branded Types и How to Use It
模式、方法論
-
Feature-Sliced
-
Adapter, pattern
-
SOLID Principles
-
Impureim Sandwich
-
Design by Contract
-
Covariance and contravariance
-
Law of Demeter
-
BEM Methodology
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/90wqft45KA2t8qdLQ8Heqg