前端的Clean Architecture

小紅星閃啊閃發表於2022-01-08

在開始之前:

計劃是什麼

首先,我們要大概的介紹一下什麼是clean architecture,然後熟悉一下domain,用例和分層的概念。然後我們來討論一下這些怎麼用在前端,和是否有這個必要。

接下來,我們用clean architecture的原則來設計一個餅乾商店。並從頭實現某個用例來看看可行性。

這個商店的介面部分會使用React,這樣我們可以看看這個原則是否可以和React公用,雖然React不是必須的,其實什麼UI框架、庫都是可以的。

在程式碼裡會有一部分TypeScript,只是用來展示如何使用型別和介面來描述實體。我們今天看到的程式碼都可以不用TypeScript,除非無法表達。

本文基本不會談論OOP,所以這個文章應該不會觸動某些人的神經。只會在文末提一次,但是和實際我們的應用沒什麼太大關係。

而且本文也不會提及測試,因為這不是本文的重點。

機構和設計

設計就是把事物拆分。。。用一種之後可以拼接在一起的方法。。。把事物拆分成可以組合在一起的事物就是設計 -- Rich Hickey《設計、重組和效能》

系統設計,如上引用,就是為了日後可以重組而進行的系統分割。很重要的一點就是日後的重組不會耗費太多資源。

我(作者)很同意,但是架構的另外一個目標也是不得不考慮的,那就是可擴充套件性。對應用的需求是不斷變更的。我們需要我們的程式可以快速的更新或者修改以滿足新的需求。Clean architecture在這方面可以一顯身手。

Clean Architecture

Clean architecture是一種根據應用的域(domain)的相似性來分割職責和功能塊的方法。

域(domain)是由真實世界抽象而來的程式模型。是真實世界資料轉化在程式的對映。

Clean architecture總是會用到一個三層架構,在這裡功能被分層。原始的clean architecture則提供了一張如下的圖:
image.png

圖片來自這裡

域(domain)分層

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

你可以把它理解為當我們從React換到Angular,或者改變某些用例的時候不會變的那一部分。在餅乾商店這個例子裡就是產品、訂單、使用者、購物車和更新這些資料的方法。

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

給購物車增加物品的方法並不關心這個資料是如何加上去的:使用者點選“購買”按鈕或者使用了促銷卡之類的。兩種情況都會增加一個物品,並且返回一個更新之後的購物車物件。

應用層(Application Layer)

圍在域(domain)外面的是應用層。這一層描述了用例(use cases),比如某些使用者場景。他們描述了某些事件發生後會發生什麼。

比如,“新增到購物車”這個場景是一個用例,它描述了再點選這個按鈕之後會發生什麼。就像是某種“指揮家”:

  • 向server傳送一個請求
  • 執行域(domain)轉換
  • 根據返回的資料更新UI

同時,在應用層還會有一些介面(port)--它描述了外部世界如何和應用層溝通。一般來說一個介面就是一個interface,一個行為契約。

介面(port)也可以被認為是一個現實世界和應用程式的“緩衝區”。輸入Port告訴我們應用要如何接受外部的輸入,同樣輸出Port告訴我們會如何告知外部直接應用的資訊。

下面來看一些細節:

適配層

最外層包含了對外部的各種介面卡。這些介面卡要把外面不相容的API轉換成應用需要的樣子。

這些介面卡可以極大的降低我們和外部第三方程式碼的耦合。降低耦合意味著只要很好的程式碼修改就可以適配其他模組的變化。

介面卡一般分為:

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

使用者最長接觸的是驅動型介面卡。比如,處理UI層傳送的點選事件就是一個驅動型介面卡。它會根據瀏覽器API把一個事件轉換為一個我們的應用可以理解的訊號。

驅動型介面卡和我們的基礎設施相互動。在前端,最常見的基礎設施就是後端。當然也會和其他的服務直接互動。

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

依賴規則

三層架構有一個以來規則:只有外層的可以依賴內層。也就是:

  • 域(domain)必須獨立
  • 應用層可以依賴於域(domain)
  • 最外層可以依賴於任何東西

image.png

某些時候這條鐵律可以違反,不過儘量不要這麼做。比如:有時在域的範圍內可以使用一些外部的“庫”一樣的程式碼,即使這個時候其實應該是沒有依賴的。在討論原始碼的時候我們會看到這個例子。

依賴方向不受控的程式碼會變得非常複雜和難以維護。比如,不遵守依賴規則會:

  • 迴圈依賴,A模組依賴B模組,B模組依賴C模組,然後C模組又依賴於A模組
  • 低可測,即使測試一小塊功能也不得不模擬整個系統
  • 高耦合,模組之間的呼叫極易出問題

Clean Architecture的優勢

現在我們來討論下程式碼分割可以給我們帶來怎樣的好處。

分割域(domain)

所有的應用的功能都是獨立的,並且集中在一個地方 -- 域。

域的功能是獨立的也就是說它更容易測試。模組的依賴越少,測試的時候需要的基礎設施就越少,mock和樁模組也就越少。

一個相對獨立的域也很容易測試它是否滿足需求。這讓新手更容易理解應用是做什麼的。另外,一個獨立的域也讓從需求到程式碼實現中出現的錯誤和不準確更容易排除。

獨立的用例(Use Case)

應用的使用場景和用例都是獨立描述的。它表明了我們所需要的第三方服務。我們讓外部服務為我們所用,而不是削足適履。這讓我們有更多的空間可以選擇合適的第三方服務。比如,一旦一個收費服務收取更高的佣金的時候我們可以很快的換掉它。

用例的實現程式碼也是扁平的,已測試,有足夠的擴充套件性。我們會在後面的程式碼看到這一點。

可更換的第三方服務

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

這樣可以建立一個修改傳播的屏障:修改是某個人的修改,不會直接影響我們。介面卡也會在應用執行時減少bug的傳播。

Clean Architecture的代價

架構首先是一個工具。和所有工具一樣,clean architecture帶來好處的同時並不是沒有代價的。

時間

消耗最多的是時間。設計和實現都需要消耗額外的時間,因為我們在開始的時候就知道所有的需求和約束。在設計的時候我們就需要留意哪些地方會發生修改,併為此留下修改的空間。

有時會過度冗餘

一般來說,經典的clean architecture實現會帶來不便,有時甚至有害。如果是一個小專案的,完全照本宣科的實現會抬高實現門檻,勸退新手。

為了滿足資金或者交付日期,不得不做一些取捨。後面會用程式碼來說明這些取捨都是什麼。

上手更難

完全的按照clean architecture的實現會讓新手上路更難。任何的工具都需要先了解這個工具是如何執行的。

如果你在專案初期就過度設計,後面就會增加新同學的上手難度。記住這一點,儘量保持程式碼的簡單。

增加程式碼量

對於前端來說,實踐clean architecture會增加打包後的體積。我們給瀏覽器的程式碼越多,它就不得不花更多的下載和解析的時間。

程式碼量的問題需要從一開始就把控好,能少的地方儘量少:

  • 讓用例更加簡單
  • 直接和介面卡互動,繞開用例
  • 使用程式碼分割

如何減少代價

可以適度損失程式碼的純潔性來減少上面說到的損失。我(作者)不是一個激進的人,如果可以獲得更大的好處要破壞一些程式碼的純潔性,那也是可以的。

所以,不必方方面面都遵守clean architecture的條條框框。但是最低限度的兩條需要認真對待:

抽離域(Domain)

對域的抽離可以幫助我們理解我們正在設計的是什麼,它是如何工作的。抽離出來的域也會讓其他的開發同學更容易理解應用是如何運作的。

即使拋開其他幾層不談,分離的域也更加容易重構。因為它的程式碼沒有分散在應用的各個地方。其他層可以更具需要新增。

遵守依賴規則

第二個不能拋棄的規則是依賴規則,或者說是他們的方向。外部的服務需要適配到內部的服務,而不是反方向。

如果你覺得直接呼叫一個搜尋API也沒什麼問題,那麼這就是問題所在了。最好在問題沒有擴散之前寫一個介面卡。

設計應用

之前都是務虛,現在我們來寫一些程式碼。我們來設計一下這個餅乾店的架構吧。

這個餅乾店要售賣不同種類、不同配方的餅乾。使用者可以選擇餅乾並下單,之後使用第三方的支付服務來下單。

還首頁有可以買的餅乾展示。我們只能在認證之後才可以購買餅乾。登入按鈕會把我們帶到登入頁。

image.png

(介面有點醜,因為沒有設計師幫忙)

成功登入之後,我們就可以往購物車裡加餅乾了。

image.png

當購物車裡有餅乾之後就可以下單了。支付之後,生成訂單並清空購物車。

我們會實現上面說的功能,其他的用例可以在原始碼中找到。

首先我們定義廣義上的實體、用例和功能。之後把他們劃分到不同的層裡。

設計域(domain)

程式開發中最重要的是就是域的處理。這是實體和資料轉換的所在。我建議從域開始在程式碼中可以精確的展現域知識(domain knowledge)。

店鋪的域包括:

  • 不同實體的型別:User、Cookie、Cart和Order
  • 如果你是用OOP實現的,那麼也包括生成實體的工廠和類
  • 以及這些資料轉換的方法

域(domain)裡的資料轉換方法應該是隻依賴於域的規則,而不是其他。比如方法應該是:

  • 計算總價的方法
  • 檢測使用者口味的方法
  • 檢測一個物品是否在購物車的方法

image.png

設計應用層

應用層包含用例,一個用例包含一個執行人、一個動作和一個結果。

在餅乾店這個例子裡:

  • 一個產品購買場景
  • 支付,呼叫第三方支付系統
  • 與產品和訂單的互動,更新和搜尋
  • 根據角色不同訪問不同頁面

用例一般都是用主題領域描述,比如購買流程有以下步驟:

  • 獲取購物車裡的物品,並新建一個訂單
  • 支付訂單
  • 如果支付失敗,通知使用者
  • 支付成功,清空購物車,顯示訂單

這個用例最後會變成完成這個功能的程式碼。

同時,在應用層還有各種和外界溝通是需要的介面。
image.png

設計應用層

在介面卡層,我們宣告連線外部服務的介面卡。介面卡讓不相容的外部服務和我們的系統相容。

在前端,介面卡一般是UI框架和對後端的API請求模組。在本例中我們會用到:

  • UI框架
  • API請求模組
  • 對本地儲存的介面卡
  • API返回到應用層的介面卡

image.png

使用MVC做類比

有時我們資料是屬於哪一層的。一個小的(也許不完整)的MVC的類比可以用的上:

  • Model一般都是域實體
  • 控制器(Controller)一般是與轉換或者應用層
  • 試圖是驅動介面卡

這些概念在細節上不盡相同但是內行非常相似,這個類比可以用在定義域和應用程式碼。

深入細節:域

一旦我們決定了我們所需要的實體,我們就可以定義相關的行為了。

我現在就會給你看專案的程式碼細節。為了容易理解我把程式碼分為不同的目錄:

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目錄下。我們會討論目錄結構是否會有其他的可行方案。

新建域實體

在域內包含了四個實體:

  • product
  • user
  • order
  • shopping cart

這些實體最重要的是user。在會話中,我們會把使用者實體儲存起來。同時我們會給user增加型別。

使用者實體包含IDnamemail以及preferencesallergies陣列。

// 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

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

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

理解實體之間的關係

給實體新增型別之後,可以檢查實體關係圖和實際情況是否符合
image.png

我們可以檢查的點:

  • 主要的參與者是否是一個user
  • 在訂單裡是否有足夠的資訊
  • 是否有些實體需要擴充套件
  • 在未來是否有足夠的可擴充套件性

同時,在這個階段,型別可以幫助識別實體之間的資訊和呼叫的錯誤。

建立資料轉換

用例的行為會發生在不同的實體之間。我們可以給購物車新增物品、清空購物車、更新物品和使用者名稱稱,等。我們會分別新建方法來完成上述功能。

比如,為了判斷某個使用者對不同的口味是喜歡還是厭惡。我們可以定義hasAllergyhasPreference

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

方法addProductcontains用來給購物車新增物品和檢查一個物品是否在購物車裡

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

在設計階段是包含外部約束的。這讓我們的資料轉換儘量貼近主題域。而且轉換越貼近實際,就越容易檢查程式碼是否可靠。

細節設計:共享的核心

你也許已經注意到我們在描述域的時候的一些型別,比如:EmailUniqueId或者DateTimeString。這些都是型別別名:

// shared-kernel.d.ts

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

我一般使用型別別名避免原始型別偏執

我用DateTimeString而不是string來更加清晰的表明這個字串是用來做什麼的。這些型別越貼近實際,以後排除就越容易。

這些型別都在shared-kernel.d.ts檔案裡。Shared Kernel是一些程式碼和資料,他們不會增加模組之間的耦合度。更多關於這個話題的內容,你可以在DDD, Hexagonal, Onion, Clean, CQRS, ...How I put it all together找到。

在實踐中,共享核心可以這樣解釋。我們用了typescript,用了它的標準型別庫,但是我們不認為這是依賴。這是因為使用了它的模組相互之間一樣維持了“知識最少"原則,一樣是低耦合的。

並不是所有的程式碼都可以作為共享核心。最主要的原則是這樣的程式碼必須和系統處處相容。如果應用一部分是用typescript開發的,一部分是其他語言。那麼,共享核心只可以包含兩種語言都可以工作的部分。比如,實體說明用JSON是沒問題的,但是用typescript就不行。

在我們的例子裡,整個應用都是用typescript寫的,所以型別別名完全可以當做共享核心的一部分。這樣的全域性可用的型別並不會增加模組之間的耦合,並且可以在應用的任何地方使用。

深入細節: 應用層

現在我們已經完成了域這一部分的設計,我們以考慮應用層了。這一層包含i了用例

在程式碼裡會包括每個場景的細節。一個用例描述了新增一個物品到購物車或者購買的時候包括的一系列步驟。

用例包含了應用和外部服務的互動。與外部服務的互動都是副作用。我們知道呼叫或者除錯沒有副作用的方法更簡單一些。我們的域的方法都是純方法。

為了集合內部的純方法和外部的非純世界,我們可以把應用層當做非純的上下文。

非純上下文域純資料轉換

一個非純上下文和純資料轉換是這樣一種程式碼組合:

  • 首先執行副作用獲取資料
  • 之後對資料執行純資料轉化
  • 最後執行一個副作用,儲存或者傳遞資料

在”往購物車新增物品“這個用例,看起來是這樣的:

  • 首先,可以從資料庫裡獲取購物車的狀態
  • 然後呼叫方法把可以存進購物車的物品更新到購物車裡
  • 之後把更新的購物車存到資料庫裡

整個過程就是一個三明治:副作用、純方法、副作用。
image.png

非純上下文有時叫做功能核心,因為它是非同步的,和第三方服務有很多互動,這樣的稱呼也很有代表性的。其他的場景和程式碼都在github上。

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

  • 我們要新建一個訂單
  • 使用第三方支付系統支付
  • 支付失敗,通知使用者
  • 支付成功,把訂單儲存到後端
  • 在本地儲存儲存訂單資料,並在頁面上顯示

在API或者方法的簽名上,我們會把使用者和購物車都作為引數,然後讓這個方法把其他的都完成了。

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

最好的情況,當然不是分別接收兩個引數,而是把他們封裝一下。但是,目前我們先保持現狀。

編寫應用層的介面

我們再來看看用例的細節:新建訂單本身是域的方法。其他的都是我們用到的外部方法。

謹記一點,外部方法要適配我們的需要而不是反過來。所以,在應用層,我們不僅要描述用例本身,也要定義呼叫外部服務的介面。

我們來看看需要的服務:

  • 支付服務
  • 通知使用者事件、錯誤的服務
  • 把資料儲存在本地儲存的服務

image.png

注意我們討論的是這些服務的介面,不是他們的實現。在這一階段,描述必要的步驟非常重要。因為,在應用層的某些場景裡,這些都是必要的。

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

同時需要注意,我們會根據功能點分割介面。支付相關的都在一個模組下,儲存相關的在另外一個。這樣可以確保不同的第三方服務不會混在一起。

支付系統介面

餅乾點是一個簡單的應用,所以支付系統也很簡單。它會包含一個tryPay的方法,它會接收需要支付的金額作為引數,然後返回一個表明支付結果值。

// application/ports.ts

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

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

這個例子裡只有訂單儲存的介面,其他的在GitHub。

用例方法

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

  • 驗證資料
  • 新建訂單
  • 支付訂單
  • 通知問題
  • 儲存結果

image.png

首先,我們定義出來我們要呼叫的樁模組。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);

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

我們通過一個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來當作一個依賴注入。首先我們使用hooksuseNotifierusePaymentuseOrdersStorage來獲取服務例項,然後我們用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方法是一個定時方法,將在450ms之後執行。這樣來模擬一個從後端返回的請求。它會把我們傳入的引數返回。

// services/api.ts

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

通知服務的實現

通知在本例中將是一個簡單的alert。只要程式碼是解耦的,以後重新實現不會是一個問題。

// services/notificationAdapter.ts

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

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

本地儲存的實現

本例中本地儲存就是React.Context或者hooks。我們新建一個context,然後把值傳給provider,再export出去讓其他的模組可以通過hooks使用。

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

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

// services/storageAdapter.ts

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

同時這樣的方法讓我們可以給每個儲存額外的優化,我們可以新建selector、快取等。

驗證資料流圖

現在我們來驗證一下使用者可以如何與應用互動。

image.png

使用者通過UI層與應用互動,但是UI層也是通過特定的介面與應用互動。所以,想換UI就可以換。

用例是在應用層處理的,這讓我們很清楚需要什麼外部服務。所有的主資料和邏輯都在域層。

所有的外部服務都放在基礎架構,並且遵守我們的規範。如果我們需要更換髮送訊息服務,只需要修改外部服務的介面卡。

這樣的模式讓程式碼更加容易隨著需求的變更而替換、擴充套件、測試。

什麼可以更好

總體來說,這些已經足夠讓你瞭解什麼是clean architecture了。但是我得指出那些地方為了讓demo簡單而做了簡化。

這一節不是必須的,但是會給出一個擴充套件,讓大家瞭解一個沒有縮水的clean architecture是什麼樣子的。

我會著重說明還有哪些事情可以做:

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

你應該注意到了,我使用數字表示了價格。這不是一個好方法:

// shared-kernel.d.ts

type PriceCents = number;

一個數字只表明了數量而沒有表明貨幣種類,一個沒有貨幣的價格是沒有意義的。理想狀況下是價格有兩個欄位表示:值和貨幣。

type Currency = "RUB" | "USD" | "EUR" | "SEK";
type AmountCents = number;

type Price = {
  value: AmountCents;
  currency: Currency;
};

這樣就可以省去大量的儲存和處理貨幣的精力了。在例項中沒有這麼做是為了讓這個例子儘量簡單。在真實的情況裡,價格的結構會更加接近上面的寫法。

另外,價格的單位也很重要。比如美元的最小單位是分。這樣顯示價格就可以避免計算小數點後面的數字,也就可以避免浮點數計算了。

使用功能點分割程式碼,而不是按照層

程式碼建在那個目錄下是按照功能點分割的,而不是按照層分割。一個功能點就是下面餅圖的一部分。

下圖的這個結構更加清晰。你可以分別部署不同的功能點,這在開發中很有用。

image.png

圖片來自這裡

同時強烈建議讀一下上圖所在的文章。

同時建議閱讀功能拆分,概念上和元件程式碼拆分很相似,但是更容易理解。

注意跨元件程式碼

在我們討論系統拆分的時候,就不得不說道跨元件程式碼使用的問題。我們再來看看新建訂單的程式碼:

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。這樣使用本身沒有什麼問題,但是如果我們要把程式碼劃分到獨立的功能的時候,我們不能直接訪問其他功能的程式碼。

你也可以在這裡這裡這找到方法。

使用型別標籤,而不是型別別名

在核心程式碼裡我用了型別別名。這樣很容易操作,但是缺點也很明顯,TypeScript沒辦法更多的發揮它的強項。

這似乎不是個問題,即使用了string而不是DateTimeString型別也不會怎麼樣,程式碼還是可以編譯成功。

問題是約束鬆的型別是可以編譯的(另一個說法是前置條件削弱)。首先這樣會讓程式碼變得脆弱,因為這樣你可以用任意的字串,顯然會導致錯誤。

有個辦法可以讓TypeScript理解,我們想要一個特定的型別 -- 使用型別標籤。這些標籤會讓型別更加安全,但是也增加了程式碼複雜度。

注意域裡的可能的依賴

下一個要注意的在新建訂單的時候會新建一個日期:

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()會在專案裡重複很多次,我們最好把它放進一個helper裡。

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

這讓域更加獨立,更容易測試。

在這個例子裡因為兩點原因我不會主要關注這一點:偏離主線。而且只是依賴自己的helper也沒什麼問題。尤其這個helper只是用了語言特性。這樣的helper甚至可以作為共享的核心(kernel),還能減少重複的程式碼。

注意購物車和訂單的關係

在這個例子裡,訂單包含了購物車,因為購物車只是代表了一列產品:

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

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

如果購物車有其他的和訂單沒有關聯的屬性,恐怕會出問題。比如最好使用資料對映或者中間DTO

作為一個選項,我們可以用ProductList實體。

type ProductList = Product[];

type Cart = {
  products: ProductList;
};

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

讓用例更容易測試

用例有很多可以討論的。現在orderProducts方法脫離開React之後很難測試,這就很不好了。理想狀態下,它應該可以在最小代價下測試。

問題是現在用了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的程式碼就可以當做一個介面卡,只有用例還留在應用層。orderProdeucts方法很容易就可以被測試了。

配置自動依賴注入

在應用層我們都是手動注入依賴的:

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和hooks的情況下,他們可以當作返回某個介面實現的容器。是的,這樣是手動實現,但是這樣不會增加入門的門檻,也讓新加入的開發者更容易理解程式碼。

哪些在實際開發中更復雜

本文使用的程式碼專門提煉簡化過了。事實上實際的開發要複雜很多。所以我也是想討論一下在實際使用clean architecture的時候有哪些問題會變得棘手。

分支業務邏輯

最重要的問題是我們對主題域所知不多。假設店鋪裡有一個產品,一個打折的產品,一個登出的產品。我們怎麼能準確的描述這些實體呢?

需要一個可以被擴充套件的實體基類麼?這個實體應該如何被擴充套件呢?要不要額外的欄位?這些實體需要保持互斥關係麼?用例要如何處理更加複雜的實體呢?

業務有太多的問題,有太多的答案。因為開發者和相關人都不知道系統執行的每個細節。如果只有假設,你會發現你已經掉入分析無力的陷阱。

每種情況都有特定的解決方法,我只能推薦幾種概略的方法。

不要使用繼承,即使它有時候被叫做"擴充套件"。即使是看起來像介面,其實是繼承。

複製貼上的程式碼服用並非完全不可以。建兩個一樣的實體,然後觀察他們。有時候他們的行為會很有達的不同,有時候也只有一兩個欄位的區別。合併兩個非常相近的實體比寫一大堆的檢查、校驗好很多。

如果你一定要擴充套件什麼的話。。

記住協變、逆變和不變,這樣你就不會遇到工作量突然增加的事情了。

使用類似於BEM概念來選擇不同的實體和擴充套件。使用BEM的上下文來思考,讓我受益很大。

相互依賴的用例

第二個問題是用例相關的。當一個用例需要另外的一個用例來出發的時候會引發的問題。

我唯一知道,也是對我幫助很大的一個方法就是把用例切分成更小的,更加原子的用例。這樣他們更加容易組合在一起。

一般來說,出現這個問題是另外一個大問題的結果。這就是實體組合。

已經有很多人寫過這個專題的文章了。比如這裡有一整章關於這個話題的方法論。但是這裡我們就不深入了,這個話題足夠寫一篇長文的。

結尾

在本文裡,我們介紹了前段的clean architecture。

這不是一個黃金準則,更是一個在很多的專案、圖示和語言上積累的經驗。我發現一個非常方便的方法可以幫助你解耦你的程式碼。讓層、模組和服務儘量獨立。不僅在釋出、部署上也變得獨立,更是讓你從一個專案到另一個專案到時候也更加容易。

我們沒有多講OOP,因為OOP和clean architecture是正交到。是的,架構討論的是實體的組合,它不會強制開發者用類或者是方法作為單位。

至於OOP,我寫了一篇如何在clean architecture中使用OOP

相關文章