前端領域的 “乾淨架構”

語言: CN / TW / HK

大家好,我是 ConardLi ,前端有架構嗎?這可能是很多人心裡的疑惑,因為在實際業務開發裡我們很少為前端去設計標準規範的程式碼架構,可能更多的去關注的是工程化、目錄層級、以及業務程式碼的實現。

今天我們來看一種前端架構的模式,原作者稱它為“乾淨架構( Clean Architecture )”,文章很長,講的也很詳細,我花了很長時間去讀完了它,看完很有收穫,翻譯給大家,文中也融入了很多我自己的思考,推薦大家看完。

  • https://dev.to/bespoyasov/clean-architecture-on-frontend-4311
  • 本文中示例的原始碼: https://github.com/bespoyasov/frontend-clean-architecture/

首先,我們會簡單介紹一下什麼是乾淨架構( Clean architecture ),比如領域、用例和應用層這些概念。然後就是怎麼把乾淨架構應用於前端,以及值不值得這麼做。

接下來,我們會用乾淨架構的原則來設計一個商店應用,並從頭實現一下,看看它能不能執行起來。

這個應用將使用 React 作為它的 UI 框架,這只是為了表明這種開發方式是可以和 React 一起使用的。你也可以選擇其他任何一種 UI 庫去實現它。

程式碼中會用到一些 TypeScript ,這只是為了展示怎麼使用型別和介面來描述實體。其實所有的程式碼都可以不用 TypeScript 實現,只是程式碼不會看起來那麼富有表現力。

架構和設計

設計本質上就是以一種可以將它們重新組合在一起的方式將事物拆開…… 將事物拆分成可以重新組合的事物,這就是設計。— Rich Hickey《設計、重構和效能》

系統設計其實就是系統的拆分,最重要的是我們可以在不耗費太多時間的情況下重新把它們組起來。

我同意上面這個觀點,但我認為系統架構的另一個主要目標是系統的可擴充套件性。我們應用的需求是不斷變化的。我們希望我們的程式可以非常易於更新和修改以滿足持續變化的新需求。乾淨的架構就可以幫助我們實現這一目標。

什麼是乾淨的架構?

乾淨架構是一種根據應用程式的領域( domain )的相似程度來拆分職責和功能的方法。

領域( domain )是由真實世界抽象而來的程式模型。可以反映現實世界和程式中資料的對映。比如,如果我們更新了一個產品的名稱,用新名稱來替換舊名稱就是領域轉換。

乾淨架構的功能通常被分為三層,我們可以看下面這張圖:

領域層

在在中心的是領域層層,這裡會描述應用程式主題區域的實體和資料,以及轉換該資料的程式碼。領域是區分不同程式的核心。

你可以把領域理解為當我們從 React 遷移到 Angular ,或者改變某些用例的時候不會變的那一部分。在商店這個應用中,領域就是產品、訂單、使用者、購物車以及更新這些資料的方法。

資料結構和他們之間的轉化與外部世界是相互隔離的。外部的事件呼叫會觸發領域的轉換,但是並不會決定他們如何執行。

比如:將商品新增到購物車的功能並不關心商品新增到購物車的方式:

  • 使用者自己通過點選“購買”按鈕新增

  • 使用者使用了優惠券自動新增。

在這兩種情況下,都會返回一個更新之後的購物車物件。

應用層

圍在領域外面的是應用層,這一層描述了用例。

例如,“新增到購物車”這個場景就是一個用例。它描述了單擊按鈕後應執行的具體操作,像是一種“協調者”:

  • 向伺服器傳送一個請求;

  • 執行領域轉換;

  • 使用響應的資料更新 UI。

此外,在應用層中還有埠 — 它描述了應用層如何和外部通訊。通常一個埠就是一個介面( interface ),一個行為契約。

埠也可以被認為是一個現實世界和應用程式之間的“緩衝區”。輸入埠會告訴我們應用要如何接受外部的輸入,同樣輸出埠會說明如何與外部通訊做好準備。

介面卡層

最外層包含了外部服務的介面卡,我們通過介面卡來轉換外部服務的不相容 API

介面卡可以降低我們的程式碼和外部第三方服務的耦合,介面卡一般分為:

  • 驅動型 - 向我們的應用發訊息;

  • 被動型 - 接受我們的應用所傳送的訊息。

一般使用者最常和驅動型介面卡進行互動,例如,處理UI框架傳送的點選事件就是一個驅動型介面卡。它與瀏覽器 API 一起將事件轉換為我們的應用程式可以理解的訊號。

驅動型會和我們的基礎設施互動。在前端,大部分的基礎設施就是後端伺服器,但有時我們也可能會直接與其他的一些服務互動,例如搜尋引擎。

注意,離中心越遠,程式碼的功能就越 “面向服務”,離應用的領域就越遠,這在後面我們要決定一個模組是哪一層的時候是非常重要的。

依賴規則

三層架構有一個依賴規則:只有外層可以依賴內層。這意味著:

  • 領域必須獨立

  • 應用層可以依賴領域

  • 最外層可以依賴任何東西

當然有些特殊的情況可能會違反這個規則,但最好不要濫用它。例如,在領域中也有可能會用到一些第三方庫,即使不應該存在這樣的依賴關係。下面看程式碼時會有這樣一個例子。

不控制依賴方向的程式碼可能會變得非常複雜和難以維護。比如:

  • 迴圈依賴,模組 A 依賴於 B,B 依賴於 C,C 依賴於 A。

  • 可測試性差,即使測試一小塊功能也不得不模擬整個系統。

  • 耦合度太高,因此模組之間的互動會很脆弱。

乾淨架構的優勢

獨立領域

所有應用的核心功能都被拆分並統一維護在一個地方—領域

領域中的功能是獨立的,這意味著它更容易測試。模組的依賴越少,測試所需的基礎設施就越少。

獨立的領域也更容易根據業務的期望進行測試。這有助於讓新手理解起來更容易。此外,獨立的域也讓從需求到程式碼實現中出現的錯誤更容易排除。

獨立用例

應用的使用場景和用例都是獨立描述的。它決定了我們所需要哪些第三方服務。我們讓外部服務更適應我們的需求,這讓我們有更多的空間可以選擇合適的第三方服務。比如,現在我們呼叫的支付系統漲價了,我們可以很快的換掉它。

用例的程式碼也是扁平的,並且容易測試,擴充套件性強。我們會在後面的示例中看到這一點。

可替換的第三方服務

介面卡讓外部第三方服務更容易替換。只要我們不改介面,那麼實現這個介面的是哪個第三方服務都沒關係。

這樣如果其他人改動了程式碼,不會直接影響我們。介面卡也會減少應用執行時錯誤的傳播。

實現乾淨架構的成本

架構首先是一種工具。像任何其他工具一樣,乾淨的架構除了好處之外還會帶來額外的成本。

需要更多時間

首先是時間,設計、實現都需要更多的時間,因為直接呼叫第三方服務總是比寫介面卡簡單。

我們很難在一開始就把模組所有的互動和需求都想的很明白,我們設計的時候需要時刻留意哪些地方可能發生變化,所以要考慮更多的可擴充套件性。

有時會顯得多餘

一般來說,乾淨架構並不適用於所有場景、甚至有的時候是有害的。如果本身就是一個很小的專案,你還要按照乾淨架構進行設計,這會大大增加上手門檻。

上手更困難

完全按照乾淨架構進行設計和實現會讓新手上手更加困難,因為他首先要了解清楚應用是怎麼執行起來的。

程式碼量增加

這是前端會特有的一個問題,乾淨架構會增加最終打包的產物體積。產物越大,瀏覽器下載和解釋的時間越長,所以程式碼量一定要把控好,適當刪減程式碼:

  • 將用例描述的得更簡單一些;

  • 直接從介面卡和領域互動,繞過用例;

  • 進行程式碼拆分

如何降低這些成本

你可以通過適當的偷工減料和犧牲架構的“乾淨度”來減少一些實現時間和程式碼量。如果捨棄一些東西會獲得更大的收益,我會毫不猶豫的去做。

所以,不必在所有方面走遵守乾淨架構的設計準則,把核心準則遵守好即可。

抽象領域

對領域的抽象可以幫助我們理解整體的設計,以及它們是怎麼工作的,同時也會讓其他開發人員更容易理解程式、實體以及它們之間的關係。

即使我們直接跳過其他層,抽象的領域也更加容易重構。因為它們的程式碼是集中封裝在一個地方的,其他層需要的時候可以方便新增。

遵守依賴規則

第二條不應該放棄的規則是依賴規則,或者說是它們的依賴方向。外部的服務需要適配內部,而不是反方向的。

如果你嘗試直接去呼叫一個外部 API,這就是有問題的,最好在還沒出問題之前寫個介面卡。

商店應用的設計

說完了理論,我們就可以開始實踐了,下面我們來實際設計一個商店應用的。

商店會出售不同種類的餅乾,使用者可以自己選擇要購買的餅乾,並通過三方支付服務進行付款。

使用者可以在首頁看到所有餅乾,但是隻有登入後才能購買,點選登入按鈕可以跳轉到登入頁。

登入成功後,使用者就可以把餅乾加進購物車了。

把餅乾加進購物車後,使用者就可以付款了。付款後,購物車會清空,併產生一個新的訂單。

首先,我們來對實體、用例和功能進行定義,並對它們進行分層。

設計領域

程式設計中最重要的就是領域設計,它們表示了實體到資料的轉換。

商店的領域可能包括:

  • 每個實體的資料型別:使用者、餅乾、購物車和訂單;

  • 如果你是用OOP(面向物件思想)實現的,那麼也要設計生成實體的工廠和類;

  • 資料轉換的函式。

領域中的轉換方法應該只依賴於領域的規則,而不依賴於其他任何東西。比如方法應該是這樣的:

  • 計算總價的方法

  • 檢測使用者口味的方法

  • 檢測商品是否在購物車的方法

設計應用層

應用層包含用例,一個用包含一個參與者、一個動作和一個結果。

在商店應用裡,我們可以這樣區分:

  • 一個產品購買場景;

  • 支付,呼叫第三方支付系統;

  • 與產品和訂單的互動:更新、查詢;

  • 根據角色訪問不同頁面。

我們一般都是用主題領域來描述用例,比如“購買”包括下面的步驟:

  • 從購物車中查詢商品並建立新訂單;

  • 建立支付訂單;

  • 支付失敗時通知使用者;

  • 支付成功,清空購物車,顯示訂單。

用例方法就是描述這個場景的程式碼。

此外,在應用層中還有埠—用於與外界通訊的介面。

設計介面卡層

在介面卡層,我們為外部服務宣告介面卡。介面卡可以為我們的系統相容各種不相容的外部服務。

在前端,介面卡一般是UI框架和對後端的API請求模組。比如在我們的商店程式中會用到:

  • 使用者介面;

  • API請求模組;

  • 本地儲存的介面卡;

  • API返回到應用層的介面卡。

對比 MVC 架構

有時我們很難判斷某些資料屬於哪一層,這裡可以和 MVC 架構做個小對比:

  • Model 一般都是領域實體

  • Controller 一般是與轉換或者應用層

  • 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 目錄下,介面卡都定義在 service 目錄下。最後我們還會討論目錄結構是否會有其他的替代方案。

建立領域實體

我們在領域中有 4 個實體:

  • product(產品)

  • user(使用者)

  • order(訂單)

  • cart(購物車)

其中最重要的就是 user ,在回話中,我們會把使用者資訊存起來,所以我們單獨在領域中設計一個使用者型別,使用者型別包括以下資料:

// domain/user.ts

export type UserName = string;
export type User = {
id: UniqueId;
name: UserName;
email: Email;
preferences: Ingredient[];
allergies: Ingredient[];
};

使用者可以把餅乾放進購物車,我們也給購物車和餅乾加上型別。

// 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[];
};

付款成功後,將建立一個新訂單,我們再來新增一個訂單實體型別。

// domain/order.ts  — ConardLi

export type OrderStatus = "new" | "delivery" | "completed";

export type Order = {
user: UniqueId;
cart: Cart;
created: DateTimeString;
status: OrderStatus;
total: PriceCents;
};

理解實體之間的關係

以這種方式設計實體型別的好處是我們可以檢查它們的關係圖是否和符合實際情況:

我們可以檢查以下幾點:

  • 參與者是否是一個使用者

  • 訂單裡是否有足夠的資訊

  • 有些實體是否需要擴充套件

  • 在未來是否有足夠的可擴充套件性

此外,在這個階段,型別可以幫助識別實體之間的相容性和呼叫方向的錯誤。

如果一切都符合我們預期的,我們就可以開始設計領域轉換了。

建立資料轉換

我們剛剛設計的這些型別資料會發生各種各樣的事情。我們可以新增商品到購物車、清空購物車、更新商品和使用者名稱等。下面我們分別來為這些資料轉換建立對應的函式:

比如,為了判斷某個使用者是喜歡還是厭惡某個口味,我們可以建立兩個函式:

// 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);
}

將商品新增到購物車並檢查商品是否在購物車中:

// domain/cart.ts  — ConardLi

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);
}

下面是計算總價(如果需要的話我們還可以設計更多的功能,比如配打折、優惠券等場景):

// domain/product.ts

export function totalPrice(products: Product[]): PriceCents {
return products.reduce((total, { price }) => total + price, 0);
}

建立新訂單,並和對應使用者以及他的購物車建立關聯。

// 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),
};
}

詳細設計—共享核心

你可能已經注意到我們在描述領域型別的時候使用的一些型別。例如 EmailUniqueIdDateTimeString 。這些其實都是類型別名:

// shared-kernel.d.ts

type Email = string;
type UniqueId = string;
type DateTimeString = string;
type PriceCents = number;

我用 DateTimeString 代替 string 來更清晰的表明這個字串是用來做什麼的。這些型別越貼近實際,就更容易排查問題。

這些型別都定義在 shared-kernel.d.ts 檔案裡。共享核心指的是一些程式碼和資料,對他們的依賴不會增加模組之間的耦合度。

在實踐中,共享核心可以這樣解釋:我們用到 TypeScript ,使用它的標準型別庫,但我們不會把它們看作是一個依賴項。這是因為使用它們的模組互相不會產生影響並且可以保持解耦。

並不是所有程式碼都可以被看作是共享核心,最主要的原則是這樣的程式碼必須和系統處處都是相容的。如果程式的一部分是用 TypeScript 編寫的,而另一部分是用另一種語言編寫的,共享核心只可以包含兩種語言都可以工作的部分。

在我們的例子中,整個應用程式都是用 TypeScript 編寫的,所以內建型別的別名完全可以當做共享核心的一部分。這種全域性都可用的型別不會增加模組之間的耦合,並且在程式的任何部分都可以使用到。

實現細節—應用層

我們已經完成了領域的設計,下面可以設計應用層了。

這一層會包含具體的用例設計,比如一個用例是將商品新增到購物車並支付的完整過程。

用例會涉及應用和外部服務的互動,與外部服務的互動都是副作用。我們都知道呼叫或者除錯沒有副作用的方法會更簡單一些,所以大部分領域函式都實現為成純函數了。

為了將無副作用的純函式和與有副作用的互動結合起來,我們可以將應用層用作有副作用的非純上下文。

非純上下文純資料轉換

一個包含副作用的非純上下文和純資料轉換是這樣一種程式碼組織方式:

  • 首先執行一個副作用來獲取一些資料;

  • 然後對資料執行純函式進行資料處理;

  • 最後再執行一個副作用,儲存或傳遞這個結果。

比如,“將商品放入購物車”這個用例:

  • 首先,從資料庫裡獲取購物車的狀態;

  • 然後呼叫購物車更新函式,把要新增的商品資訊傳進去;

  • 最後將更新的購物車儲存到資料庫中。

這個過程就像一個“三明治”:副作用、純函式、副作用。所有主要的邏輯處理都在呼叫純函式進行資料轉換上,所有與外部的通訊都隔離在一個命令式的外殼中。

設計用例

我們選擇結賬這個場景來做用例設計,它更具代表性,因為它是非同步的,而且會與很多第三方服務進行互動。

我們可以想一想,通過整個用例我們要表達什麼。使用者的購物車裡有一些餅乾,當用戶點選購買按鈕的時候:

  • 要建立一個新訂單;

  • 在第三方支付系統中支付;

  • 如果支付失敗,通知使用者;

  • 如果支付成功,將訂單儲存在伺服器上;

  • 在本地儲存儲存訂單資料,並在頁面上顯示;

設計函式的時候,我們會把使用者和購物車都作為引數,然後讓這個方法完成整個過程。

type OrderProducts = (user: User, cart: Cart) => Promise<void>;

當然,理想情況下,用例不應該接收兩個單獨的引數,而是接收一個封裝後的物件,為了精簡程式碼,我們先這樣處理。

編寫應用層的介面

我們再來仔細看看用例的步驟:訂單建立本身就是一個領域函式,其他一切操作我們都要呼叫外部服務。

我們要牢記,外部方法永遠要適配我們的需求。所以,在應用層,我們不僅要描述用例本身,也要定義呼叫外部服務的通訊方式—埠。

想一想我們可能會用到的服務:

  • 第三方支付服務;

  • 通知使用者事件和錯誤的服務;

  • 將資料儲存到本地儲存的服務。

注意,我們現在討論的是這些服務的 interface ,而不是它們的具體實現。在這個階段,描述必要的行為對我們來說很重要,因為這是我們在描述場景時在應用層所依賴的行為。

如何實現現在不是重點,我們可以在最後再考慮呼叫哪些外部服務,這樣程式碼才能儘量保證低耦合。

另外還要注意,我們按功能拆分介面。與支付相關的一切都在同一個模組中,與儲存相關的都在另一個模組中。這樣更容易確保不的同第三方服務的功能不會混在一起。

支付系統介面

我們這個商店應用只是個小 Demo ,所以支付系統會很簡單。它會有一個 tryPay 方法,這個方法將接受需要支付的金額,然後返回一個布林值來表明支付的結果。

// application/ports.ts  — ConardLi

export interface PaymentService {
tryPay(amount: PriceCents): Promise<boolean>;
}

一般來說,付款的處理是在服務端。但我們只是簡單演示一下,所以在前端就直接處理了。後面我們也會呼叫一些簡單的API,而不是直接和支付系統進行通訊。

通知服務介面

如果出現一些問題,我們必須通知到使用者。

我們可以用各種不同的方式通知使用者。比如使用 UI,傳送郵件,甚至可以讓使用者的手機振動。

一般來說,通知服務最好也抽象出來,這樣我們現在就不用考慮實現了。

給使用者傳送一條通知:

// application/ports.ts

export interface NotificationService {
notify(message: string): void;
}

本地儲存介面

我們會將新的訂單儲存在本地的儲存庫中。

這個儲存可以是任何東西:Redux、MobX、任何儲存都可以。儲存庫可以在不同實體上進行拆分,也可以是整個應用程式的資料都維護在一起。不過現在都不重要,因為這些都是實現細節。

我習慣的做法是為每個實體都建立一個單獨的儲存介面:一個單獨的介面儲存使用者資料,一個儲存購物車,一個儲存訂單:

// application/ports.ts    — ConardLi

export interface OrdersStorageService {
orders: Order[];
updateOrders(orders: Order[]): void;
}

用例方法

下面我們來看看能不能用現有的領域方法和剛剛建的介面來構建一個用例。指令碼將包含如下步驟:

  • 驗證資料;

  • 建立訂單;

  • 支付訂單;

  • 通知問題;

  • 儲存結果。

首先,我們宣告出來我們要呼叫的服務的模組。 TypeScript 會提示我們沒有給出介面的實現,先不要管他。

// application/orderProducts.ts — ConardLi

const payment: PaymentService = {};
const notifier: NotificationService = {};
const orderStorage: OrdersStorageService = {};

現在我們可以像使用真正的服務一樣使用這些模組。我們可以訪問他們的欄位,呼叫他們的方法。這在把用例轉換為程式碼的時候非常有用。

現在,我們建立一個名為 orderProducts 的方法來建立一個訂單:

// application/orderProducts.ts  — ConardLi
//...

async function orderProducts(user: User, cart: Cart) {
const order = createOrder(user, cart);
}

這裡,我們把介面當作是行為的約定。也就是說以模組示例會真正執行我們期望的操作:

// application/orderProducts.ts  — ConardLi
//...

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  — ConardLi

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>

);
}

我們可以通過一個 Hook 來封裝用例,建議把所有的服務都封裝到裡面,最後返回用例的方法:

// application/orderProducts.ts  — ConardLi

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  — ConardLi

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  — ConardLi

export function fakeApi<TResponse>(response: TResponse): Promise<TResponse> {
return new Promise((res) => setTimeout(() => res(response), 450));
}

通知服務實現

我們就簡單使用 alert 來實現通知,因為程式碼是解耦的,以後再來重寫這個服務也不成問題。

// services/notificationAdapter.ts  — ConardLi

import { NotificationService } from "../application/ports";

export function useNotifier(): NotificationService {
return {
notify: (message: string) => window.alert(message),
};
}

本地儲存實現

我們就通過 React.ContextHooks 來實現本地儲存。

我們建立一個新的 context ,然後把它傳給 provider ,然後匯出讓其他的模組可以通過 Hooks 使用。

// store.tsx  — ConardLi

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>
);
};

我們可以給每一個功能點都實現一個 Hook 。這樣我們就不會破壞服務介面和儲存,至少在介面的角度來說他們是分離的。

// services/storageAdapter.ts

export function useOrdersStorage(): OrdersStorageService {
return useStore();
}

此外,這種方法還可以使我們能夠為每個商店定製額外的優化:建立選擇器、快取等。

驗證資料流程圖

現在讓我們驗證一下使用者是怎麼和應用程式通訊的。

使用者與 UI 層互動,但是 UI 只能通過埠訪問服務介面。也就是說,我們可以隨時替換 UI。

用例是在應用層處理的,它可以準確地告訴我們需要哪些外部服務。所有主要的邏輯和資料都封裝在領域中。

所有外部服務都隱藏在基礎設施中,並且遵守我們的規範。如果我們需要更改傳送訊息的服務,只需要修改傳送訊息服務的介面卡。

這樣的方案讓程式碼更方便替換、更容易測試、擴充套件性更強,以適應不斷變化的需求。

有什麼可以改進的

上面介紹的這些已經可以讓你開始並初步瞭解乾淨的架構了,但是我想指出上面我為了讓示例更簡單做的一些偷工減料的事情。

讀完下面的內容,大家可以理解 “沒有偷工減料”的乾淨架構是什麼樣子的。

使用物件而不是數字來表示價格

你可能已經注意到我用一個數字來描述價格,這不是一個好習慣。

// 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 {
user: user.id,
cart,
created: new Date().toISOString(),
status: "new",
total: totalPrice(products),
};
}

這個函式用到了從另一個 Product 模組引入的 totalPrice 方法。這樣使用本身沒有什麼問題,但是如果我們要考慮把程式碼拆分到獨立的功能的時候,我們不能直接訪問其他模組的程式碼。

使用 ts-brand ,而不是類型別名

在共享核心的編寫中,我使用了類型別名。它們很容易實現,但缺點是 TypeScript 沒有監控並強制執行它們的機制。

這看起來也不是個問題:你是用 string 型別去替代 DateTimeString 也不會怎麼樣,程式碼還是會編譯成功。但是,這樣會讓程式碼變得脆弱、可讀性也很差,因為這樣你可以用任意的字串,導致錯誤的可能性會增加。

有一種方法可以讓 TypeScript 理解我們想要一個特定的型別 — ts-brandhttps://github.com/kourge/ts-brand )。它可以準確的跟蹤型別的使用方式,但會使程式碼更復雜一些。

注意領域中可能的依賴

接下來的問題是我們在 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() 這樣的函式可能會被重複呼叫很多次,我們可以把它封裝到一個 hleper 裡面:

// lib/datetime.ts  — ConardLi

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  — ConardLi

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);
}

這會讓領域保持獨立,也使測試更容易。

在前面的示例中,我不這樣做有兩個原因:它會分散我們的重點,如果它只使用語言本身的特性,我認為依賴你自己的 Helper 沒有任何問題。這樣的 Helper 甚至可以被視為共享核心,因為它們只會減少程式碼的重複度。

注意購物車與訂單的關係

在這個小例子中, Order 會包含 Cart , 因為購物車只表示 Product 列表:

export type Cart = {
products: Product[];
};

export type Order = {
user: UniqueId;
cart: Cart;
created: DateTimeString;
status: OrderStatus;
total: PriceCents;
};

如果購物車有其他的和訂單沒有關聯的屬性,可能會出問題,所以直接用 ProductList 會更合理:

type ProductList = Product[];

type Cart = {
products: ProductList;
};

type Order = {
user: UniqueId;
products: ProductList;
created: DateTimeString;
status: OrderStatus;
total: PriceCents;
};

讓用例更方便測試

用例也有很多要討論的地方。比如, orderProducts 函式很難獨立於 React 來測試,這不太好。理想情況下,測試不應該消耗太多的成本。

問題的根本原因我們使用 Hooks 來實現了用例:

// application/orderProducts.ts  — ConardLi

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 };
}

在規範的實現中,用例方法可以放在 Hooks 的外面,服務通過引數或者使用依賴注入傳入用例:

type Dependencies = {
notifier?: NotificationService;
payment?: PaymentService;
orderStorage?: OrderStorageService;
};

async function orderProducts(
user: User,
cart: Cart,
dependencies: Dependencies = defaultDependencies
)
{
const { notifier, payment, orderStorage } = dependencies;

// ...
}

然後 Hooks 的程式碼就可以當做一個介面卡,只有用例會留在應用層。 orderProdeucts 方法很容易就可以被測試了。

function useOrderProducts() {
const notifier = useNotifier();
const payment = usePayment();
const orderStorage = useOrdersStorage();

return (user: User, cart: Cart) =>
orderProducts(user, cart, {
notifier,
payment,
orderStorage,
});
}

配置自動依賴注入

在應用層,我們是手動將依賴注入服務的:

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 };
}

當然還有更好的做法,依賴注入可以自動完成。我們前面已經通過最後一個引數實現了最簡單的注入版本,下面可以進一步配置自動依賴注入。

在這個特定的應用程式中,我認為設定依賴注入沒有多大意義。它會分散我們的注意力並使程式碼過於複雜。在使用了 Reacthooks 的情況下,我們可以將它們用作“容器”,返回指定介面的實現。是的,雖然還是手動實現的,但它不會增加上手門檻,並且對於新手開發人員來說閱讀速度更快。

實際專案中的情況可能更復雜

文章中的示例是經過精簡的而且需求也比較簡單。很明顯,我們實際開發中比這個例子要複雜的多。所以我還想談談實際開發中使用乾淨架構時可能出現的常見問題。

分支業務邏輯

最重要的問題是我們對需求的實際場景研究不夠深入。想象一下,一家商店有一個產品、一個打折產品和一種已經登出的產品。我們怎麼準確描述這些實體?

是不是應該有一個可擴充套件的“基礎”實體呢?這個實體究竟應該怎麼擴充套件?應該有額外的欄位嗎?這些實體是否應該互斥?

可能有太多的問題和太多的答案,如果只是假設,我們不可能考慮到所有的情況。

具體解決方法還要視具體情況而定,我只能推薦幾個我的經驗。

不建議使用繼承,即使它看起來可“擴充套件”。

複製貼上的程式碼並不一定都不好,有時候甚至能發揮更大的作用。建立兩個幾乎相同的實體,觀察它們在現實中的行為。在某些時候,它們的行為可能區別很大,有時候也可能只有一兩個欄位的區別。合併兩個非常相似的實體比寫大量的的檢查要容易很多。

如果你一定要擴充套件一些內容的話。。

記住協變、逆變和不變,這樣你就不會多出一些意想不到的工作。

在不同的實體和可擴充套件之間選擇,推薦使用類似於 BEM 中的塊和修飾符概念來幫助你思考,如果我在 BEM 的上下文中考慮它,它可以幫助我確定我是否有一個單獨的實體或程式碼的“修飾符擴充套件”。

BEM - Block Element Modfier(塊元素編輯器)是一個很有用的方法,它可以幫助你創建出可以複用的前端元件和前端程式碼。

相互依賴的用例

第二個問題是用例相關的,通過一個用例的事件觸發另一個用例。

我知道並且對我有幫助的處理這個問題的唯一方法是將用例分解為更小的原子用例。它們將更容易組合在一起。

通常,出現這個問題是程式設計中另外一個大問題的結果。這就是實體組合。

最後

在本文裡,我們介紹了前端的“乾淨架構”。

這不是一個黃金標準,而是一個在很多的專案、規範和語言上積累的經驗彙總。

我發現它是一種非常方便的方案,可以幫助你解耦你的程式碼。讓層、模組和服務儘量獨立。不僅可以獨立釋出、部署,還可以讓你從一個專案遷移另一個專案的時候也更加容易。

你理想下的前端架構是什麼樣的呢?

抖音前端正急缺人才,如果你想加入我們,歡迎加我微信和我聯絡。另外如果你想加入高質量前端交流群,或者你有任何其他事情想和我交流也可以新增我的個人微信 ConardLi 。

文中如有錯誤,歡迎在留言區和我留言,如果這篇文章幫助到了你,歡迎點贊、在看和關注。你的點贊、在看和關注是對我最大的支援!