什么是前端簡潔架構
原文鏈接:https://dev.to/bespoyasov/clean-architecture-on-frontend-4311
譯者: Goodme前端團隊 陸晨杰
不久前,我做了一個關于前端簡潔架構(clean architecture on frontend)的演講。在這篇文章中,我將概述那次演講,并對其進行了一些擴展。
我在這里附了一些含有不錯的內容的鏈接,這些對后續(xù)的閱讀會有一些幫助。
-
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)等概念。然后,我們將討論這如何適用于前端,并探討其是否值得嘗試。
接下來,我們將按照簡潔架構的規(guī)則來設計一個餅干商店的前端。最后,我們將從頭開始實現一個用例,來驗證其是否可用。
這個示例將使用React作為UI框架,這樣可以展示這種方法也可以與React一起使用。(因為這篇文章主要面向React的開發(fā)者 )React不是必須的,可以將本文中展示的所有內容結合其他UI庫或框架一起使用
代碼中會有一點TypeScript,但只是為了展示如何使用類型和接口來描述實體。我們今天要看的所有東西都可以在沒有TypeScript的情況下使用,只是代碼的可讀性會差一點。
我們今天幾乎不會談及面向對象編程(OOP),所以這篇文章不會引起一些爭論。我們只會在最后提到一次OOP,但它不會影響我們設計一個應用程序。
另外,我們今天會跳過測試,因為它們不是這篇文章的主要話題。但我會考慮到可測試性,并在過程中提到如何改進它。
最后,這篇文章主要是讓你掌握簡潔架構的概念。帖子中的例子是簡化的,所以它不是關于如何寫代碼的具體指導。請理解這個概念并思考如何在你的項目中應用這些原則。
在帖子的末尾,你可以找到與簡潔架構相關,且在前端更廣泛使用的一些方法論。所以你可以根據你的項目規(guī)模找到一個最適合的方法。
現在,讓我們開始實驗吧
架構與設計
設計的基本原則是將事物拆分......以便能夠重新組合起來。......將事物分成可以組合的部分,這就是設計。— Rich Hickey. Design Composition and Performance
如上所言,系統(tǒng)設計就是將系統(tǒng)分離,以便以后可以重新整合。而且最重要的是,不需要太多成本。
我同意這個觀點。但我認為架構的另一個目標是系統(tǒng)的可擴展性。需求是不斷變化的。我們希望程序易于更新和修改以滿足新的需求。簡潔架構可以幫助實現這一目標。
簡潔架構
簡潔架構(The clean architecture)是一種根據其與應用領域的密切程度來分離職責和部分功能的方式。
通過領域(the domain),我們指的是我們用程序建模的現實世界的部分。這就是反映現實世界中變化(transformations)的數據轉換。例如,如果我們更新了一個產品的名稱,用新的名稱替換舊的名稱就是一個領域轉換(domain transformation)。
簡潔架構通常被稱為三層架構,因為其中的功能被分成了幾層。簡潔架構的原始帖子提供了一個突出顯示各層的圖表:
圖片來源:cleancoder.com。
領域層(Domain Layer)
在中心位置的是領域層(the domain layer)。它是描述應用程序主題領域(subject area)的實體和數據,以及用于轉換數據的代碼。領域是區(qū)分一個應用程序與另一個應用程序的核心。
你可以認為領域是在我們從React轉到Angular時或者我們改變了一些用例時不會改變的東西。在商店的案例中,這些是產品、訂單、用戶、購物車,以及更新其數據的功能。
領域實體(domain entities)的數據結構和其轉換的本質與外部世界無關。外部事件(External events)觸發(fā)了領域轉換(omain transformations),但并不決定它們將如何發(fā)生。
將物品添加到購物車的函數并不關心該物品到底是如何添加的:是由用戶自己通過 "購買"按鈕添加的,還是通過促銷代碼自動添加的。在這兩種情況下,它都會接受該物品,并返回一個帶有新增物品的更新后的購物車。
應用層(Application Layer)
圍繞這個領域的是應用層(the application layer)。這一層描述了用例,即用戶場景。他們負責一些事件發(fā)生后的情況。
例如,"添加到購物車 "場景是一個用例。它描述了按鈕被點擊后應該采取的操作。這是一種 "協(xié)調器"(orchestrator),它將:
-
向服務器發(fā)送一個請求。 -
執(zhí)行這個領域的轉換。 -
使用響應數據重新繪制用戶界面。
另外,在應用層中,還有端口(ports),即應用希望外部世界如何與之通信的規(guī)范。通常,一個端口是一個接口(interface),一個行為契約(behavior contract)。
端口作為應用程序的期望和現實之間的 "緩沖區(qū)"(buffer zone)。輸入端口(Input Ports)表明應用程序希望如何被外部世界聯系;輸出端口(Output Ports)表明應用程序要如何與外部世界溝通,使其做好準備。
我們將在后面更詳細地了解端口。
適配器層(Adapters Layer)
最外層包含對外部服務的適配器(adapters)。適配器需要把不兼容的外部服務的API變成與應用程序兼容的API。
適配器是降低代碼和三方服務代碼之間耦合度(coupling)的一個好方法。低耦合度減少了在更改其他模塊時需要更改一個模塊的需求。適配器通常被分為:
-
驅動型(driving)--向應用程序發(fā)送信號。 -
被驅動型(driven)--接收來自應用程序的信號。
用戶最常與驅動型適配器進行交互。例如,UI框架對按鈕點擊的處理就是一個驅動型適配器的工作。它與瀏覽器的API(第三方服務)一起工作,并將事件轉換為我們的應用程序可以理解的信號。
被驅動型適配器與基礎設施(infrastructure)進行交互。在前端,大部分的基礎設施是后臺服務器,但有時我們可能會與其他一些服務直接交互,如搜索引擎。
請注意,我們離中心越遠,代碼功能就越 "面向服務"(service-oriented),離我們應用程序的領域知識(domain knowledge)就越遠。這一點在判斷模塊應該屬于哪一層的時候會很重要。
依賴規(guī)則(Dependency Rule)
三層架構有一個依賴規(guī)則:只有外層可以依賴內層。這意味著
-
領域必須是獨立的。 -
應用層可以依賴領域。 -
外層可以依賴任何東西。
圖片來源:herbertograca.com。
有時可以違反這個規(guī)則,盡管最好不要濫用它。例如,有時在領域中使用一些 "類似庫"(library-like)的代碼是很方便的,盡管不應該有任何依賴關系。我們在看源代碼的時候會看到這樣的例子。
不受控制的依賴關系方向會導致復雜和混亂的代碼。例如,打破依賴性規(guī)則會導致:
-
循環(huán)依賴(Cyclic dependencies),模塊A依賴B,B依賴C,C又依賴A。 -
測試性差(Poor testability),必須模擬整個系統(tǒng)來測試一個小部分。 -
耦合度太高(Too high coupling),模塊之間的交互變得和容易出錯。
簡潔架構的優(yōu)勢
現在讓我們來談談這種代碼分離給我們帶來了什么,它有什么優(yōu)點。
-
領域分離(Separate domain)
所有的主要應用功能都被隔離并收集在一個地方--領域(domain)。領域中的功能是獨立的,它更容易測試。模塊的依賴性越少,測試所需的基礎設施就越少,需要的 mocks 和 stubs 就越少。獨立的領域也更容易針對業(yè)務預期(business expectations)進行測試,這有助于新開發(fā)人員掌握應用程序功能,有助于更快地尋找從業(yè)務語言到編程語言的 "翻譯"中的錯誤和不準確之處。
-
獨立用例(Independent Use Cases)
應用場景即用例是單獨描述的。它們決定了需要哪些第三方服務,我們使外部世界適應我們的需求,這給了我們更多選擇第三方服務的自由。例如,如果當前的支付系統(tǒng)開始收費過高,我們可以迅速改變支付系統(tǒng)。
用例代碼也變得扁平、可測試和可擴展。我們將在后面的一個例子中看到這一點。
-
可替換的三方服務(Replaceable Third-Party Services)
由于適配器的存在,外部服務變得可替換。只要我們不改變接口,哪個外部服務實現了這個接口并不重要。
這樣一來,我們就為變化的傳播建立了一個屏障:別人的代碼的變化不會直接影響到我們自己的。適配器也限制了應用程序運行時的錯誤傳播。
簡潔架構的成本
架構首先是一種工具。像任何工具一樣,簡潔架構除了好處之外,還有它的成本。
-
時間成本(Takes Time)
主要的成本是時間成本。它不僅在設計上需要,而且在實現上也需要,因為直接調用第三方服務總是比編寫適配器要容易。
要事先考慮好系統(tǒng)中所有模塊的交互也是很困難的,因為我們可能事先不知道所有的需求和約束(requirements and constraints)。在設計時,我們需要牢記系統(tǒng)如何變化,并為擴展留出空間。
-
冗余成本(Overly Verbose)
一般來說,簡潔架構的典范實現并不總是有利的,有時甚至是有害的。如果項目很小,完整的實現將是一種矯枉過正,會增加新人的入門門檻。
你可能需要做出設計上的權衡,以保持在預算(budget)或期限(deadline)內。我將通過實例向你展示我所說的這種權衡的確切含義。
-
更高的門檻
全面實施簡潔架構會使實施更加困難,因為任何工具都需要了解如何使用它。如果你在項目開始時過度設計,那么以后就更難讓新的開發(fā)人員掌握了。你必須牢記這一點,并保持你的代碼簡單。
-
增加代碼的數量
簡潔架構會增加前端項目最終打包的代碼量。我們給瀏覽器的代碼越多,它需要下載、解析和解釋的就越多。我們必須注意代碼的數量,并決定在哪些方面做優(yōu)化:
-
對用例的描述要簡單一些。 -
直接從適配器訪問域的功能,繞過用例。 -
我們必須調整代碼分割(code splitting),等等。
成本優(yōu)化
你可以通過”偷工減料“和犧牲架構的 "清潔度"(cleanliness)來減少時間和代碼量成本。我一般不喜歡激進的方法:如果打破一個規(guī)則更高效(例如,收益將高于潛在的成本),我就會打破它。
所以,你可以在簡潔架構的某些方面”周旋“,這完全沒有問題。然而,這與絕對值得投入的最低要求的資源量是兩件事。
-
提取領域(Extract Domain)
提取領域有助于理解我們正在設計工程的的總體內容以及它應該如何工作。提取的領域使新的開發(fā)者更容易理解應用程序、其實體和應用之間的關系。
即使我們跳過了其他的層,也會更容易使用提取出來的領域進行工作和重構,因為它并沒有分布在代碼庫中。其他層可以根據需要添加。
-
服從依賴規(guī)則(Obey Dependency Rule)
第二個不能被拋棄的規(guī)則是依賴規(guī)則,或者說是它們的方向(direction)。外部服務必須適應我們的需要。
如果你覺得你正在 "微調"你的代碼,以便它能夠調用搜索API,那就有問題了。最好在問題蔓延之前寫一個適配器。
設計應用
現在我們已經談論了理論,我們可以開始實踐了。讓我們來設計餅干店的架構。
該商店將出售不同種類的餅干,它們可能有不同的成分。用戶將選擇餅干并訂購,并在第三方支付服務中支付訂單。在主頁上將會有一個可以購買餅干的展示。只有在經過認證(authenticated)后才能購買餅干。登錄按鈕將跳轉到登錄頁面以進行登錄。
登錄成功后,我們就可以把一些餅干放進購物車。
當我們把餅干放進購物車后,我們就可以下單了。付款后,我們在列表中得到一個新的訂單,并得到一個清空的購物車。我們將實現結賬用例。你可以在源代碼中找到其他的用例。
首先,我們將定義所擁有所有這些實體、用例和廣義上的功能,然后決定它們應該屬于哪一層。
設計領域
一個應用程序中最重要的是領域,它包含了應用程序的主要實體和數據轉換。我建議你從領域開始,以便在你的代碼中準確地表達應用程序的領域知識。
商店領域可能包括:
-
實體的數據類型:用戶、cookie、購物車和訂單。 -
創(chuàng)建實體的工廠,如果你用OOP編寫,則是類。 -
數據的轉換函數(transformation functions)。
領域中的轉換函數應該只依賴于領域的規(guī)則。例如,這樣的函數將是:
-
一個計算總成本的函數。 -
用戶的口味偏好檢測。 -
確定一件商品是否在購物車中,等等。
設計應用層
應用層包含用例(use cases)。一個用例總是有一個行為者(actor)、一個動作(action)和一個結果(result)。
在商店里,我們可以區(qū)分:
-
產品購買場景。 -
支付,調用第三方支付系統(tǒng)。 -
與產品和訂單的互動:更新、瀏覽。 -
根據角色訪問頁面。
用例通常以主題領域(subject area)的方式描述。例如,"結賬 "場景實際上由幾個步驟組成:
-
從購物車中檢索商品并創(chuàng)建一個新的訂單。 -
為訂單付款。 -
如果支付失敗,通知用戶。 -
清除購物車并顯示訂單。
用例函數將是描述這種情況的代碼。
另外,在應用層中,有一些端口-接口(ports—interfaces)用于與外部世界進行通信。
設計適配器層
在適配器層,我們聲明對外部服務的適配器。適配器使第三方服務的不兼容的API與我們的系統(tǒng)兼容。
在前端,適配器通常是UI框架和API服務器請求模塊。在我們的案例中,我們將使用:
-
UI框架; -
API請求模塊。 -
本地存儲的適配器。 -
API回應到應用層的適配器和轉換器。
請注意,功能越是 "類似服務"(service-like),就越是遠離圖表的中心。
MVC類比
有時候,我們很難知道某些數據屬于哪一層。用MVC的類比可能對這里有幫助。
-
模型(models)通常是領域實體(domain entities)。 -
控制器(controllers)是領域轉換(domain transformations)和應用層(application layer)。 -
視圖(view)是驅動適配器(driving adapters)。
這些概念在細節(jié)上有所不同,但相當相似,這種類比可以用來定義領域和應用的代碼。
深入細節(jié):領域
一旦我們確定了我們需要哪些實體,我們就可以開始定義它們的行為方式。
我會向你展示項目中的代碼結構如下:
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/。我們將在最后討論這種代碼結構的替代方案。
創(chuàng)建領域實體
領域中有4個模塊:
-
產品。 -
用戶。 -
訂單。 -
購物車。
主要行為者是用戶。在會話期間,我們將把關于用戶的數據存儲在存儲器中。我們想把這些數據打出來,所以我們將創(chuàng)建一個用戶類型實體。該用戶類型將包含ID、姓名、郵件以及偏好列表。
// domain/user.ts
export type UserName = string;
export type User = {
id: UniqueId;
name: UserName;
email: Email;
preferences: Ingredient[];
allergies: Ingredient[];
};
用戶會把餅干放在購物車里。讓我們?yōu)橘徫镘嚭彤a品添加類型。產品將包含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[];
};
在成功付款后,一個新的訂單被創(chuàng)建。讓我們添加一個訂單實體類型。該訂單類型將包含用戶ID、訂購產品列表、創(chuàng)建日期和時間、狀態(tài)和整個訂單的總價格。
// domain/order.ts
export type OrderStatus = "new" | "delivery" | "completed";
export type Order = {
user: UniqueId;
cart: Cart;
created: DateTimeString;
status: OrderStatus;
total: PriceCents;
};
檢查實體之間的關系
以這種方式設計實體類型的好處是,我們已經可以檢查它們的關系圖是否與現實相符。
我們可以檢查如下:
-
是否actor確實是一個用戶。 -
訂單中是否有足夠的信息。 -
某些實體是否需要被擴展。 -
未來是否會出現可擴展性的問題。
另外,在這個階段,類型將有助于突出實體之間的兼容性和它們之間的信號方向的錯誤。
如果一切符合我們的期望,我們就可以開始設計領域轉換。
創(chuàng)建數據轉換
各種各樣的事情都會發(fā)生在我們剛剛設計好的數據類型上。我們將向購物車添加物品,清除購物車,更新物品和用戶名,等等。我們將為所有這些轉換創(chuàng)建單獨的函數。
例如,為了確定一個用戶是否對某種成分偏好或者過敏,我們可以編寫函數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。如果需要,我們可以在這個函數中加入各種條件,如促銷代碼或季節(jié)性折扣。
// domain/product.ts
export function totalPrice(products: Product[]): PriceCents {
return products.reduce((total, { price }) => total + price, 0);
}
為了使用戶能夠創(chuàng)建訂單,我們將添加函數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;
我通常使用類型別名來擺脫原子類型偏執(zhí)(primitive obsession)。
原子類型偏執(zhí):創(chuàng)建一個基本類型字段比創(chuàng)建一個全新的結構類型的類要容易得多,新手通常不愿意在小任務上運用小對象,從而忽略了面向對象帶來的各種好處。一些列參數或域有時候可以用一個更有意義的小對象取代之。
Primitive Obsession 是指代碼過于依賴原語(primitives)。這意味著原子值(primitive value)控制類中的邏輯,并且該值不是類型安全的。因此,原子類型偏執(zhí)是指使用原子類型來表示域中的對象這種不好的做法。參考 Primitive Obsession — A Code Smell that Hurts People the Most
我使用DateTimeString而不是僅僅使用string,以便更清楚地說明使用的是什么類型的字符串。類型與主題領域越接近,當錯誤發(fā)生時就越容易處理。
指定的類型在文件 shared-kernel.d.ts 中。共享核心(*Shared kernel*)是代碼和數據,對它的依賴不會增加模塊間的耦合。關于這個概念的更多信息,你可以在 "DDD, Hexagonal, Onion, Clean, CQRS, ...How I put it all together" 中找到。
在實踐中,共享核心可以這樣解釋:我們使用TypeScript,我們使用它的標準類型庫,但我們不認為它們是依賴關系。這是因為它們的模塊對彼此一無所知,并保持解耦。
不是所有的代碼都可以被歸類為共享內核。主要的和最重要的限制是,這種代碼必須與系統(tǒng)的任何部分兼容。如果應用程序的一部分是用TypeScript編寫的,而另一部分是用另一種語言編寫的,那么共享內核可能只包含可以在兩部分中使用的代碼。例如,JSON格式的實體規(guī)范是可以的,TypeScript的helpers就不行。
在我們的案例中,整個應用程序是用TypeScript編寫的,所以內置類型上的類型別名也可以歸類為共享核心。這種全局可用的類型不會增加模塊之間的耦合性,可以在應用程序的任何部分使用。
深入細節(jié):應用層
現在我們已經弄清楚了領域,接下來我們可以研究應用層了。這層包含了用例。
在代碼中我們描述了場景的技術細節(jié)。用例是對將商品添加到購物車或繼續(xù)結帳后數據變更情況的描述。
用例涉及與外界的交互,進而涉及外部服務的使用。與外界的進行交互是存在副作用的。眾所周知,沒有副作用的函數和系統(tǒng)更容易工作和調試。并且我們的大多數域函數已經被編寫為純函數。
未了讓整潔的轉換層和帶有副作用的外界交互可以整合起來,我們可以將應用層作為一個非純凈的上下文來使用。
在純轉換層中使用非純凈上下文
為一個純凈的轉換層提供一個非純凈的上下文是代碼組織方式的一種,其中:純轉換的不純上下文是一種代碼組織,其中:
-
我們首先執(zhí)行副作用操作來獲取一些數據; -
然后我們對該數據進行無副作用的轉換; -
然后再次執(zhí)行副作用操作來存儲或傳遞結果。
在“將商品放入購物車”用例中,這看起來像:
-
首先,處理程序將從存儲中檢索購物車狀態(tài); -
然后它會調用購物車更新函數,將要添加的商品傳遞給它; -
然后它會將更新后的購物車保存在存儲中。
整個過程就是一個“三明治”:副作用、純函數、副作用。主要邏輯體現在數據轉換上,所有與外界的通信都被隔離在一個命令式的外殼中。
不純上下文有時被稱為函數式的核心,命令式的殼(單向數據流)。Mark Seemann在他的博客中寫到了這一點。這是我們在編寫用例函數時將使用的方法。
設計用例
我們將選擇和設計結賬用例。它是最具代表性的的一個案例,因為它是一個異步的行為并且與很多第三方服務存在交互。其余場景和整個應用程序的代碼您可以在GitHub上找到。
讓我們考慮一下我們想要在這個用例中實現什么。用戶有一個帶有餅干的購物車,當用戶單擊結帳按鈕時:
-
我們想要創(chuàng)建一個新訂單; -
通過第三方支付系統(tǒng)進行支付; -
如果支付失敗,通知用戶; -
如果通過,則將訂單保存到服務器上; -
將訂單添加到本地數據存儲以顯示在屏幕上。
在 API 和函數簽名方面,我們希望將用戶和購物車作為參數傳遞,并讓函數自行完成其他所有操作。
type OrderProducts = (user: User, cart: Cart) => Promise<void>;
當然,理想情況下,用例不應采用兩個單獨的參數,而應采用一個將所有輸入數據封裝在其內部的方式。但我們不想增加代碼量,所以我們就先這樣實現。
編寫應用層接口
讓我們仔細看看用例的步驟:訂單創(chuàng)建本身就是一個域函數。其他一切都是我們想要使用的外部服務。
重要的是要記住,外部服務必須適應我們的需求,而不是其他服務。因此,在應用程序層中,我們不僅要描述用例本身,還要描述這些外部服務。
首先,接口應該方便我們的應用程序使用。如果外部服務的API不符合我們的需求,我們需要編寫一個適配器。
讓我們想想我們需要的服務:
-
支付系統(tǒng); -
通知用戶有關事件和錯誤的服務; -
將數據保存到本地存儲的服務。
請注意,我們現在討論的是這些服務的接口定義,而不是它們的實現。在這個階段,對我們來說描述所需的行為很重要,因為這是我們在描述場景時將在應用層依賴的行為。
這種行為具體如何實現目前還不重要。這讓我們可以在最后再決定使用哪些外部服務從而降低代碼的耦合度。我們稍后會處理實現。
另請注意,我們按功能劃分界面。所有與支付相關的內容都在一個模塊中,與存儲相關的內容在另一個模塊中。這樣可以更輕松地保證不同第三方服務的功能不會混淆。
支付系統(tǒng)接口
餅干商店是一個示例應用程序,因此支付系統(tǒng)將非常簡單。它將有一個tryPay方法,該方法將接受需要支付的金額,并且我們會返回結果從而得知流程正常。
// application/ports.ts
export interface PaymentService {
tryPay(amount: PriceCents): Promise<boolean>;
}
我們不會處理錯誤,因為錯誤處理又是一個大主題??
是的,通常付款是在服務器上完成的,但這是一個示例,讓我們在客戶端上完成所有操作。我們可以輕松地與我們的 API 進行通信,而不是直接與支付系統(tǒng)進行通信。順便說一句,此更改只會影響此用例,其余代碼將保持不變。
通知服務接口
如果出現問題,我們必須告訴用戶。
可以通過不同的方式通知用戶。我們可以在使用界面進行通知,我們可以發(fā)送信件,我們可以讓用戶的手機振動(請不要這樣做)。
一般來說,通知服務也最好是抽象的,這樣我們現在就不必考慮實現了。
讓它接收消息并以某種方式通知用戶:
// application/ports.ts
export interface NotificationService {
notify(message: string): void;
}
本地存儲接口
我們將把新訂單保存在本地存儲庫中。
該存儲可以是任何東西:Redux、MobX、whatever-floats-your-boat-js。該存儲庫可以分為不同實體的微型存儲庫,也可以成為所有應用程序數據的一個大存儲庫。現在也不重要,因為這些是實現細節(jié)。
我喜歡將存儲接口劃分為每個實體的單獨存儲接口。用于用戶數據存儲的單獨接口、用于購物車的單獨接口、用于訂單存儲的單獨接口:
// application/ports.ts
export interface OrdersStorageService {
orders: Order[];
updateOrders(orders: Order[]): void;
}
在這里的例子中我只做了訂單存儲接口,其余的你可以在源代碼中看到。
用例功能
讓我們看看是否可以使用創(chuàng)建的接口和現有的域功能來構建用例。正如我們之前所描述的,該腳本將包含以下步驟:
-
驗證數據; -
創(chuàng)建訂單; -
支付訂單費用; -
通知問題; -
保存結果。
首先,讓我們聲明我們要使用的服務的Stub。TypeScript 會提示我們沒有實現接口中的變量,但是現在不重要。
// application/orderProducts.ts
const payment: PaymentService = {};
const notifier: NotificationService = {};
const orderStorage: OrdersStorageService = {};
我們現在可以像使用真正的服務一樣使用這些 Stub 。我們可以訪問他們的字段,調用他們的方法。當需要將用例從業(yè)務語言“翻譯”為軟件語言時,這非常方便。
現在,創(chuàng)建一個名為 的函數orderProducts。在內部,我們要做的第一件事是創(chuàng)建一個新訂單:
// application/orderProducts.ts
//...
async function orderProducts(user: User, cart: Cart) {
const order = createOrder(user, cart);
}
在這里,我們利用了接口會定義代碼行為的特性。這意味著將來 Stub 將實際執(zhí)行我們現在期望的操作:
// 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();
}
請注意,該用例不會直接調用第三方服務。它取決于接口中描述的行為,因此只要接口保持不變,我們并不關心哪個模塊實現它以及如何實現。這使得模塊可以更換。
深入細節(jié):適配器層
我們已將用例“翻譯”為 TypeScript代碼。現在我們必須檢查現實是否符合我們的需求。
通常情況下都不會滿足需求。因此,我們使用適配器來調整外部接口以滿足我們的需求。
綁定 UI 和用例
第一個適配器是一個 UI 框架。它將瀏覽器 API 與應用程序連接起來。在訂單創(chuàng)建的場景中,點擊“結賬”按鈕就會觸發(fā)用例方法。
// 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 后觸發(fā),模擬服務器的延遲響應。它返回我們作為參數傳遞給它的內容。
// 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來實現本地存儲,我們創(chuàng)建一個新的上下文,將值傳遞給提供者(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)的額外優(yōu)化能力:我們可以創(chuàng)建選擇器、記憶(memoization)等等。
驗證數據流程圖
現在讓我們驗證一下在創(chuàng)建的用例中用戶將如何與應用程序進行通信。
用戶通過UI層與應用程序進行交互,UI層只能通過接口訪問應用程序。也就是說我們可以根據需要更改 UI。
用例在應用層中進行處理,該層告訴我們需要哪些外部服務。所有的主要邏輯和數據都在領域層中。
所有外部服務都隱藏在基礎設施中,并受到我們的規(guī)范約束。如果我們需要更改發(fā)送消息的服務,我們只需在代碼中修改適配器以適應新的服務。
這種架構使得代碼具有可替換性、可測試性,并且可以根據不斷變化的需求進行擴展。
哪些方面可以改進
總而言之,這已經足夠讓您開始并對清晰架構有一個初步的理解。但是,我想指出為了簡化示例而簡化的一些內容。
本節(jié)是可選的,但它將讓您對“沒有偷懶的”清晰架構可能是什么樣子有更深入的理解。
我想強調幾點可以做的事情。
使用對象而不是數字作為價格
您可能已經注意到我用數字來描述價格。這不是一個好的做法。
// shared-kernel.d.ts
type PriceCents = number;
數字只表示數量,不表示貨幣,沒有貨幣的價格是沒有意義的。理想情況下,價格應該被設計為一個對象,包含兩個字段:值(value)和貨幣(currency)。
type Currency = "RUB" | "USD" | "EUR" | "SEK";
type AmountCents = number;
type Price = {
value: AmountCents;
currency: Currency;
};
這將解決存儲貨幣以及在更改或添加貨幣到存儲中時節(jié)省大量工作和精力的問題。我在示例中沒有使用這種類型是為了不使其過于復雜。然而,在實際代碼中,價格將更接近這種類型。
另外,值得一提的是價格的值。我始終將貨幣金額保存為該貨幣流通中最小的單位。例如,對于美元來說,它是以美分(cents)為單位。
以這種方式顯示價格可以避免考慮除法和小數值。對于貨幣來說,如果我們想要避免浮點數運算的問題,這尤其重要。
按功能而不是層拆分代碼
代碼可以按“功能”而不是“層”進行分組。每個功能可以看作是下面示意圖中的一個部分。
這種結構更加可取,因為它允許您單獨部署特定的功能,這通常是很有用的。
圖片來源:herbertograca.com。
我建議您閱讀“DDD、六角形、洋蔥、清潔、CQRS,...我如何將它們組合在一起”中的相關內容。
我還建議查看Feature Sliced,它在概念上與組件代碼劃分非常相似,但更容易理解。
注意跨組件使用
如果我們談論將系統(tǒng)拆分為組件,那么值得一提的是代碼的跨組件使用。讓我們記住訂單創(chuàng)建函數:
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)。它們很容易操作:只需創(chuàng)建一個新的類型并引用,例如字符串。但它們的缺點是 TypeScript 沒有機制來監(jiān)視它們的使用并強制使用。
這似乎不是一個問題:如果有人使用 String 而不是 DateTimeString,那又怎樣呢?代碼會編譯通過。
問題在于,即使使用了更寬泛的類型(在技術術語中稱為弱化的前提條件),代碼也會編譯通過。這首先使得代碼更加脆弱,因為它允許使用任何字符串,而不僅僅是特定類型的字符串,這可能導致錯誤。
其次,這種寫法很令人困惑,因為它創(chuàng)建了兩個數據來源。不清楚您是否真的只需要使用日期,或者基本上可以使用任何字符串。
有一種方法可以讓 TypeScript 理解我們想要的特定類型,那就是使用品牌化類型(Branded Types)。品牌化類型可以跟蹤確切的類型使用方式,但會使代碼稍微復雜一些。
注意領域中可能存在的依賴關系
接下來令人不悅的是在 createOrder 函數中在領域中創(chuàng)建日期:
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),
};
}
這樣也可以避免在創(chuàng)建日期依賴于庫的情況下違反依賴規(guī)則。如果我們在領域函數之外創(chuàng)建日期,那么很可能日期將在用例內部創(chuàng)建并作為參數傳遞:
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和鉤子函數的情況下,我們可以將它們用作返回指定接口實現的“容器”。是的,這是手動工作,但它不會增加入門門檻,并且對于新開發(fā)人員來說閱讀更快。
實際項目中可能會更復雜
帖子中的示例經過精煉并且故意簡單化。顯然,真實場景比這個例子更加復雜。所以我還想談談使用簡潔架構時可能出現的常見問題。
分支業(yè)務邏輯
最重要的問題是我們對于主題領域的了解不足。想象一家商店有產品、折扣產品和報廢產品。我們如何正確描述這些實體?
是否應該有一個“基礎”實體進行擴展?這個實體應該如何擴展?是否需要額外的字段?這些實體是否應該互斥?如果簡單的實體變成了其他實體,用例應該如何行為?是否應該立即減少重復?
可能會有太多的問題和太多的答案,因為團隊和利益相關者還不知道系統(tǒng)應該如何實際運行。如果只有假設,你可能會陷入分析麻痹。
具體的解決方案取決于具體的情況,我只能提供一些建議。
不要使用繼承,即使它被稱為“擴展”。即使它看起來確實是繼承了接口。即使它看起來像是“這里顯然有一個層次結構”。多考慮一下。
代碼中的復制粘貼并不總是差勁的,它是一種工具。創(chuàng)建兩個幾乎相同的實體,觀察它們在實際中的行為。在某個時候,你會注意到它們要么變得非常不同,要么只是在一個字段上有所不同。將兩個相似的實體合并成一個比為每個可能的條件和變量創(chuàng)建檢查要容易得多。
如果你仍然需要擴展一些東西...
牢記協(xié)變性、逆變性和不變性,以免意外地增加不必要的工作量。
在選擇不同的實體和擴展時,使用BEM中的塊和修飾符類比。當我在BEM的上下文中考慮時,它對我在確定是否有一個單獨的實體或者一個“修飾符擴展”代碼時非常有幫助。
相互依賴的用例
第二個重要的問題涉及到使用用例,其中一個用例的事件觸發(fā)另一個用例。
我所知道和幫助我的處理方式是將用例拆分為更小、原子化的用例。這樣它們將更容易組合在一起。
一般來說,這種腳本的問題是編程中另一個重大問題——實體組合的結果。
關于如何有效地組合實體已經有很多相關的文獻,甚至有一個完整的數學領域。我們不會深入討論,那是一個單獨的文章主題。
總結
在本文中,我概述并稍微擴展了我在前端領域關于清潔架構的演講。
這不是一個黃金標準,而是基于我在不同項目、范式和語言中的經驗總結而成。我認為這是一種方便的方案,可以將代碼解耦,并創(chuàng)建獨立的層、模塊和服務,這些不僅可以單獨部署和發(fā)布,而且在需要時還可以從項目轉移到項目。
我們沒有涉及面向對象編程(OOP),因為架構和OOP是正交的。是的,架構談論了實體組合,但它并沒有規(guī)定組合的單位應該是對象還是函數。你可以在不同的范式中使用這個方法,正如我們在示例中看到的那樣。
至于OOP,我最近寫了一篇關于如何在OOP中使用清潔架構的文章。在這篇文章中,我們使用畫布生成樹形圖片。
如果想了解如何將這種方法與其他內容(如片段切割、六邊形架構、CQS等)結合起來,我建議閱讀《DDD,Hexagonal,Onion,Clean,CQRS,... How I put it all together》以及該博客的整個系列文章。非常深入、簡潔和切中要點。
最后
關注公眾號「Goodme前端團隊」,獲取更多干貨實踐,歡迎交流分享~
參考文獻
-
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
系統(tǒng)設計
-
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
