在軟體開發中,隨著應用程式規模的增長,程式碼的組織和測試變得至關重要。本文將探討如何設計和組織程式碼,特別是如何在服務層和領域模型之間建立清晰的界限,並利用依賴注入和測試策略來確保程式碼的品質和可維護性。我們將從服務層和領域服務的概念開始,逐步深入到程式碼的組織、依賴管理以及測試策略的演變。透過本文的闡述,讀者可以瞭解如何構建更具彈性、可測試性和可維護性的軟體系統。
為什麼一切都被稱為服務?
有些讀者可能正在疑惑,為什麼會把領域服務(domain service)和服務層(service layer)混淆。
在本章中,我們使用了兩種被稱為「服務」的東西。第一種是應用服務(application service),也就是我們的服務層。它的職責是處理來自外部世界的請求並協調操作。我們的服務層透過一系列簡單的步驟來驅動應用程式:
- 從資料函式庫取得資料
- 更新領域模型
- 持久化任何變更
這些是每個操作都必須發生的枯燥工作,將其與業務邏輯分開有助於保持程式碼整潔。
第二種服務是領域服務(domain service)。這是用於描述屬於領域模型但不自然地存在於有狀態實體(stateful entity)或值物件(value object)中的邏輯。例如,如果你正在建立一個購物車應用程式,你可能會選擇將稅務規則建模為領域服務。計算稅金是一項獨立的工作,與更新購物車不同,它是模型的重要組成部分,但似乎不應該為此持久化一個實體。相反,無狀態的 TaxCalculator 類別或 calculate_tax 函式可以完成這項工作。
將事物放入資料夾以檢視它們的歸屬
隨著我們的應用程式變得越來越大,我們需要不斷整理我們的目錄結構。專案的佈局為我們提供了有關每個檔案中會找到哪些型別的物件的有用提示。
以下是我們可以組織事物的一種方式:
.
├── config.py
├── domain
│ ├── __init__.py
│ └── model.py
├── service_layer
│ ├── __init__.py
│ └── services.py
├── adapters
│ ├── __init__.py
│ ├── orm.py
│ └── repository.py
├── entrypoints
│ ├── __init__.py
│ └── flask_app.py
└── tests
├── __init__.py
├── conftest.py
├── unit
│ ├── test_allocate.py
│ ├── test_batches.py
│ └── test_services.py
├── integration
│ ├── test_orm.py
│ └── test_repository.py
└── e2e
└── test_api.py
讓我們為領域模型建立一個資料夾。目前只有一個檔案,但對於更複雜的應用程式,你可能每個類別都有一個檔案;你可能有 Entity、ValueObject 和 Aggregate 的父類別,並且你可能會新增 exceptions.py 用於領域層的例外處理,以及在第二部分中,你將看到 commands.py 和 events.py。
我們將區分服務層。目前這只是一個名為 services.py 的檔案,用於我們的服務層函式。你可以在這裡新增服務層的例外處理,並且在第五章中,我們將新增 unit_of_work.py。
adapters 資料夾是向 ports and adapters 術語致敬。這將充滿圍繞外部 I/O 的其他抽象(例如,redis_client.py)。嚴格來說,你會將這些稱為次要的 adapters 或被驅動的 adapters,或者有時是導向內的 adapters。
entrypoints 是我們驅動應用程式的入口。在官方的 ports and adapters 術語中,這些也是 adapters,並且被稱為主要的、驅動的或導向外的 adapters。
那麼 ports 呢?正如你可能記得的那樣,它們是 adapters 實作的抽象介面。我們傾向於將它們與實作它們的 adapters 放在同一個檔案中。
依賴倒置原則(DIP)在行動中
圖 4-3 顯示了我們的服務層的依賴關係:領域模型和 AbstractRepository(在 ports and adapters 術語中的 port)。當我們執行測試時,圖 4-4 顯示瞭如何透過使用 FakeRepository(介面卡)來實作抽象依賴關係。
而當我們實際執行應用程式時,我們會換入如圖 4-5 所示的「真實」依賴關係。
圖表說明
圖 4-3. 服務層的抽象依賴關係
圖 4-4. 測試提供抽象依賴關係的實作
圖 4-5. 執行時的依賴關係
@startuml
skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle
title 服務層與領域模型解耦架構
package "領域層 Domain" {
component [領域模型 Model] as model
component [領域服務] as domain_svc
component [Entity/ValueObject] as entity
}
package "服務層 Service Layer" {
component [應用服務] as app_svc
component [AbstractRepository Port] as abstract_repo
component [Unit of Work] as uow
}
package "介面卡 Adapters" {
component [ORM 映射] as orm
component [真實 Repository] as real_repo
component [Fake Repository] as fake_repo
}
package "入口點 Entrypoints" {
component [Flask API] as flask
component [外部請求] as request
}
package "測試金字塔" {
component [單元測試 Unit] as unit_test
component [整合測試 Integration] as int_test
component [端對端測試 E2E] as e2e_test
}
request --> flask
flask --> app_svc
app_svc --> abstract_repo
app_svc --> model
abstract_repo --> real_repo : 生產環境
abstract_repo --> fake_repo : 測試環境
real_repo --> orm
fake_repo --> unit_test
model --> domain_svc
domain_svc --> entity
unit_test --> fake_repo
int_test --> orm
e2e_test --> flask
note right of abstract_repo
依賴倒置原則
Port 定義介面
end note
note bottom of fake_repo
測試時注入
FakeRepository
end note
@enduml此圖示說明瞭服務層、測試和應用程式之間的依賴關係,以及如何使用不同的介面卡來滿足這些依賴關係。
表格說明
表格 4-1. 服務層:權衡利弊
| 利弊 | 描述 |
|---|---|
| 優點 | - 我們有一個單一的地方來捕捉應用程式的所有使用案例。 - 我們已經將聰明的領域邏輯放在了一個 API 後面,這使我們能夠自由地重構。 - 我們已經乾淨地分離了「處理 HTTP 的東西」和「處理分配的東西」。 |
| 缺點 | - 又是一層抽象。 - 如果在服務層中放置太多邏輯,可能會導致貧血領域模型的反模式。最好是在發現控制器中有協調邏輯時再引入這一層。 |
內容解密:
表格 4-1 列出了引入服務層的優缺點。它幫助開發者權衡利弊,決定是否需要在自己的應用程式中使用服務層。
為什麼要使用服務層?
雖然引入服務層帶來了許多好處,但仍有一些棘手的問題需要解決:
- 服務層仍然與領域模型緊密耦合,因為它的 API 是根據
OrderLine物件來表達的。在第五章中,我們將修復這個問題,並討論服務層如何實作更高效的 TDD。 - 服務層與 session 物件緊密耦合。在第六章中,我們將介紹另一種與 Repository 和 Service Layer 模式緊密合作的模式——Unit of Work 模式,一切都將變得非常美好。你將會看到!
內容解密:
這段話指出,雖然服務層帶來了許多好處,但仍有一些問題需要解決,例如與領域模型的耦合和與 session 物件的耦合。接下來的章節將討論如何解決這些問題。
TDD 的高階與低階實踐
在前面的章節中,我們已經引入了服務層來捕捉工作應用程式所需的一些額外協調職責。服務層幫助我們清楚地定義使用案例和每個案例的工作流程:從儲存函式庫中取得什麼、進行哪些預檢查和當前狀態驗證,以及最終儲存什麼。
測試金字塔的現狀
首先,讓我們看看轉移到使用服務層及其相關測試後,我們的測試金字塔是什麼樣子:
$ grep -c test_ test_*.py
tests/unit/test_allocate.py:4
tests/unit/test_batches.py:8
tests/unit/test_services.py:3
tests/integration/test_orm.py:6
tests/integration/test_repository.py:2
tests/e2e/test_api.py:2
目前的測試分佈是:15 個單元測試、8 個整合測試和 2 個端對端測試。這已經是一個健康的測試金字塔。
領域層測試是否應該遷移到服務層?
讓我們進一步探討。如果我們可以針對服務層測試軟體,那麼就不需要針對領域模型的測試了。相反,我們可以將第 1 章中的所有領域層測試重寫為服務層的測試。
重寫領域層測試為服務層測試
# 領域層測試:
def test_prefers_current_stock_batches_to_shipments():
in_stock_batch = Batch("in-stock-batch", "RETRO-CLOCK", 100, eta=None)
shipment_batch = Batch("shipment-batch", "RETRO-CLOCK", 100, eta=tomorrow)
line = OrderLine("oref", "RETRO-CLOCK", 10)
allocate(line, [in_stock_batch, shipment_batch])
assert in_stock_batch.available_quantity == 90
assert shipment_batch.available_quantity == 100
# 服務層測試:
def test_prefers_warehouse_batches_to_shipments():
in_stock_batch = Batch("in-stock-batch", "RETRO-CLOCK", 100, eta=None)
shipment_batch = Batch("shipment-batch", "RETRO-CLOCK", 100, eta=tomorrow)
repo = FakeRepository([in_stock_batch, shipment_batch])
session = FakeSession()
line = OrderLine('oref', "RETRO-CLOCK", 10)
services.allocate(line, repo, session)
assert in_stock_batch.available_quantity == 90
assert shipment_batch.available_quantity == 100
為什麼要這樣做?
測試應該幫助我們無懼地更改系統。但是,當團隊針對領域模型編寫太多測試時,當他們需要更改程式碼函式庫時,就會遇到問題,需要更新數十甚至數百個單元測試。
#### 內容解密:
此段落主要闡述了將測試遷移到服務層的動機。由於直接針對領域模型進行測試會導致當領域模型變更時,大量的測試需要更新,因此將這些測試遷移到服務層可以減少這種耦合,提高程式碼的可維護性。
決定寫什麼樣的測試
你可能會問自己:「我應該重寫所有的單元測試嗎?針對領域模型寫測試是錯誤的嗎?」要回答這些問題,理解耦合和設計反饋之間的權衡是非常重要的。
耦合與設計反饋的權衡
極限程式設計(XP)鼓勵我們「傾聽程式碼」。當我們編寫測試時,可能會發現程式碼很難使用或注意到程式碼異味。這是促使我們重構和重新考慮設計的訊號。
#### 內容解密:
這裡討論了耦合和設計反饋之間的權衡。直接針對領域模型進行測試雖然能夠提供更詳細的設計反饋,但是會增加與實作的耦合。相反,針對服務層進行測試能夠降低耦合,但可能無法提供足夠的設計反饋。因此,需要根據具體情況選擇合適的測試策略。
#### 內容解密:
本章節主要討論了TDD的高階與低階實踐,並分析了將領域層測試遷移到服務層的利弊,最後提供了關於如何決定寫什麼樣的測試的指導原則。透過這些討論,我們可以更好地理解如何在實際專案中應用TDD,以及如何根據具體情況選擇合適的測試策略。
服務層測試與領域模型的解耦
在軟體開發中,測試是確保程式碼品質的重要環節。服務層測試是一種常見的測試方式,用於驗證服務層的邏輯是否正確。然而,當服務層測試與領域模型緊密耦合時,可能會導致測試難以維護和擴充套件。
高階與低階的測試策略
在開發過程中,我們通常需要在不同的抽象層級上進行測試。對於大多數新功能的新增或錯誤的修復,我們傾向於使用服務層測試,因為它們具有較低的耦合度和較高的覆寫率。然而,當遇到複雜的問題或需要深入理解領域模型時,我們會切換到領域模型層級的測試,以獲得更好的反饋和可執行的檔案。
切換測試策略的比喻
我們使用換擋的比喻來描述這種測試策略的切換。當開始一個新的專案或遇到複雜的問題時,我們需要使用「低檔」來克服初始的阻力。一旦專案順利進行,我們可以切換到「高檔」以提高效率。然而,當遇到新的挑戰或需要減速時,我們會再次切換到「低檔」。
完全解耦服務層測試與領域模型
為了實作服務層測試與領域模型的完全解耦,我們需要重寫服務層的API,使其僅使用基本型別。這樣,服務層測試就可以完全根據服務層的使用案例,而不依賴於領域模型。
重寫前的服務層函式
在重寫之前,allocate函式接受一個OrderLine領域物件:
def allocate(line: OrderLine, repo: AbstractRepository, session) -> str:
重寫後的服務層函式
重寫後,allocate函式接受基本型別的引數:
def allocate(
orderid: str, sku: str, qty: int, repo: AbstractRepository, session
) -> str:
#### 內容解密:
此處將 allocate 函式的引數從 OrderLine 領域物件改為基本型別(str、str、int),使得服務層測試可以獨立於領域模型進行。這種改變減少了測試對領域模型的依賴,提高了測試的可維護性。
測試的重寫
隨著服務層API的重寫,測試也需要相應地更新,以使用基本型別來呼叫服務層函式:
def test_returns_allocation():
batch = model.Batch("batch1", "COMPLICATED-LAMP", 100, eta=None)
repo = FakeRepository([batch])
result = services.allocate("o1", "COMPLICATED-LAMP", 10, repo, FakeSession())
assert result == "batch1"
#### 內容解密:
在這個測試案例中,我們建立了一個 Batch 物件並將其新增到 FakeRepository 中。然後,我們呼叫 services.allocate 函式,並驗證其傳回值是否正確。這個測試案例展示瞭如何使用基本型別來測試服務層函式。
將領域依賴集中在Fixture函式中
為了進一步減少測試對領域模型的依賴,我們可以將領域物件的建立集中在fixture函式中:
class FakeRepository(set):
@staticmethod
def for_batch(ref, sku, qty, eta=None):
return FakeRepository([
model.Batch(ref, sku, qty, eta),
])
#### 內容解密:
透過在 FakeRepository 中新增 for_batch 方法,我們可以將建立 Batch 物件的邏輯集中在一個地方,從而減少測試程式碼的重複,並提高可維護性。
新增新的服務以移除領域依賴
透過引入新的服務,如add_batch,我們可以進一步移除測試對領域模型的依賴:
def test_add_batch():
repo, session = FakeRepository([]), FakeSession()
services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, repo, session)
assert repo.get("b1") is not None
assert session.committed
#### 內容解密:
這個測試案例展示瞭如何使用 add_batch 服務來新增一個新的批次。我們驗證了批次是否被正確新增到倉函式庫中,並且session是否被提交。
新服務的實作
add_batch服務的實作如下:
def add_batch(
ref: str, sku: str, qty: int, eta: Optional[date],
repo: AbstractRepository, session,
):
repo.add(model.Batch(ref, sku, qty, eta))
session.commit()
#### 內容解密:
add_batch 服務負責建立一個新的 Batch 物件並將其新增到倉函式庫中,然後提交session。這個服務簡化了測試程式碼,並提高了可讀性。
使用新服務重寫測試
現在,我們可以使用新的服務來重寫測試,使其完全根據服務層的使用案例:
def test_allocate_returns_allocation():
repo, session = FakeRepository([]), FakeSession()
services.add_batch("batch1", "COMPLICATED-LAMP", 100, None, repo, session)
result = services.allocate("o1", "COMPLICATED-LAMP", 10, repo, session)
assert result == "batch1"
#### 內容解密:
這個測試案例展示瞭如何使用 add_batch 和 allocate 服務來測試分配邏輯。我們首先新增一個新的批次,然後呼叫 allocate 函式,並驗證其傳回值是否正確。