乾淨架構用例的複雜性

胡家維 Hu Kenneth
ATaiwanSoftwareDeveloperInSG
22 min readAug 2, 2024

source : https://medium.com/@VolodymyrSch/the-complexities-of-clean-architecture-use-cases-71ac89ea8b40 by 弗拉基米爾·謝爾比克

清潔架構有一套必須遵循的規則,但嚴格遵守它們可能會導致問題。在本文中,我們將具體討論嚴格遵守這些規則時可能遇到的一些問題,特別是用例和單一職責原則 (SRP)。
本文適合已經熟悉乾淨架構及其術語的人。

乾淨架構的主要基礎之一是分層。其核心思想是將軟體分成具有特定職責的不同層,確保依賴關係朝一個方向流動:從外層到內層。

Clean Architecture 中的規則之一是Dependency Rule,它規定原始碼依賴關係只能指向內部。這意味著,如果您想在資料層中取得某些內容或執行某些操作,則始終需要在中間建立一些內容來充當代理,通常它是一個 UseCase。使用案例封裝了系統必須執行的單一可重複使用任務的業務邏輯。

單行用例

即使我們除了「我們需要在 UI 上顯示資料」的規則之外沒有任何業務規則;如果遵循依賴關係規則,則需要建立一個從資料層取得資料的用例。

class GetUserUseCase( 
private val userRepository: UserRepository
) {

operator fun invoke(userConfig: UserConfig): Result<User> {
return userRepository.getUser()
}
}

GetUserUseCase 不包含任何規則,只是另一層的代理程式。如果您需要為每個使用者提供 CRUD 而無需任何額外邏輯怎麼辦?

class GetUserUseCase( 
private val userRepository: UserRepository
) {

operator fun invoke(userConfig: UserConfig): Result<User> {
return userRepository.getUser()
}

}

class AddUserUseCase(
private val userRepository: UserRepository
) {

operator fun invoke(user: User) {
return userRepository.addUser(user)
}

}

class DeleteUserUseCase(
private val userRepository: UserRepository
) {

operator fun invoke(user: User) {
return userRepository.deleteUser(user)
}

}

class UpdateUserUseCase(
private val userRepository: UserRepository
) {

operator fun invoke(user: User) {
return userRepository.updateUser(user)
}

}

單行用例的數量可以呈指數級增長,您將為單一資料類型建立多個用例,並且可以擁有多個用例。在現實世界中情況更糟,你可以使用 GetAllUsersUseCase、GetOldUserCase 等 UseCases。

這些用例除了充當資料層的代理之外什麼也不做。在清潔架構的某些實作中,也可能存在來回轉換模型的映射器,這會使情況進一步惡化,您沒有將任何業務規則轉換為行動。你不解決任何問題;您編寫額外的程式碼來滿足乾淨架構的規則。

讓我們稍微思考一下:

隨著時間的推移,用例的數量變得太大,部分原因是「單行用例」的貢獻。您的專案最終可能有 50、100、200、500 甚至更多用例。如此大量的用例帶來了問題。

例如,假設您正在一個新螢幕上工作,並且需要顯示應用程式中已使用的部分資料。已經有使用該資料類型的書面用例、儲存庫等。現在,在這數百個用例中,您必須找到一個可以重複使用的用例。這項任務可能很簡單,也可能很困難,這取決於專案中的各種因素。

您有嚴格的命名約定嗎?如果是,您的用例可能以「GetMyDataType」或類似的內容開頭,您可以開始按其名稱進行搜尋。然而,命名是困難的,即使有嚴格的約定,也可能無法準確地表達其意圖。

您有多倉庫架構嗎?當每個團隊使用其儲存庫處理不同的單獨專案時,所有這些項目都以庫的形式包含在主應用程式中。在這種情況下,您需要的用例可能位於另一個儲存庫中,這使得尋找和重複使用它們變得更加困難。您基本上需要搜尋各種項目只是為了找到一個用例,並且可能您將無法使用它,因為它可以從您那裡封裝出來。即使它沒有被封裝,如果他們改變了一些東西,他們可能會破壞你的程式碼,所以它應該被封裝。

與[功能模組化]( https://developer.android.com/topic/modularization )相同。功能代碼的變更不應影響其他功能或應用程式。您希望功能模組具有高內聚性,因此您使用內部修飾符建立用例,使其只能在其模組內訪問,從而故意降低它們的可重用性。

編寫它們並嚴格遵循依賴關係規則的主要論點是:「始終使用用例將保護程式碼免受未來變更的影響,例如,如果發送付款需要另一個步驟,您將需要建立一個新的用例,然後重構每個ViewModel使用該儲存庫函數來代替使用用例。但實際情況如何呢?

讓我們來看一個例子:我們有一個用例,它在我們的銀行應用程式中提供了信用卡清單。

class GetCreditCartsUseCase( 
private val creditCardRepository: CreditCardRepository
) {

operator fun invoke(): Result<List<CreditCard>> {
return creditCardRepository.getCreditCards()
}
}

我們需要在應用程式的多個位置顯示這些數據。

需求改變的那一天終於到來了。我們可以更改用例,而不必擔心更改不會應用到其他地方。因此,我們可以只在一個地方進行更改,並為一個類別添加新的單元測試,僅此而已。

但這裡有一個技巧:僅其中一個螢幕的要求發生了變化。在概覽畫面上,我需要顯示最近獲得的信用卡而不是所有信用卡。現在,我需要建立一個新用例並修改受影響的 viewModel,而不是重複使用現有用例。根據我的經驗,大多數時候需求和用例都是這種情況。 (除非你能預測未來,否則你就會知道什麼和在哪裡可以重複使用)

現在讓我們考慮一下用例的業務需求。我們需要取得信用卡清單並將其顯示在 UI 上。存在影響所有使用它的地方的附加邏輯的可能性有多大?我敢打賭“永遠不會”,而且我敢打賭,如果您考慮一下您當前正在開發的應用程序,您會發現許多用例永遠不會因為這些要求的性質而改變。

每個大型專案可能出現的另一個問題是建構函數過度注入:

class UserSettingsViewModel( 
private val getUserUserCase: GetUserUserCase,
private val getAllUsersUseCase: GetAllUsersUseCase,
private val addUserUseCase: AddUserUseCase,
private val deleteUserUseCase: DeleteUserUseCase,
private val updateUserUseCase: UpdateUserUseCase,
private val getPremiumUsersUseCase: GetPremiumUsersUseCase,
private val getFiltersUseCase: GetFiltersUseCase,
private val getAppSettingsUseCase: GetAppSettingsUseCase,
private val selectUserUseCase: SelectUserUseCase,
private val selectFilterUseCase: SelectFilterUseCase,
private val updateAppSettingsUseCase: UpdateAppSettingsUseCase
//…so on…
)

看起來很熟悉不是嗎?沒什麼好評論的。 (但是,公平地說,如果建構函數的 10–20–40 個以上參數對您來說是可以的,那麼這不是問題)。

現在,我們能做些什麼呢?

直接使用資料層
Google推薦的方法:
https://developer.android.com/topic/architecture/domain-layer#data-access-restriction

「然而,潛在的重大缺點是,它迫使您添加用例,即使它們只是對資料層的簡單函數調用,這會增加複雜性,但幾乎沒有好處。

一個好的方法是僅在需要時新增用例。如果您發現您的 UI 層幾乎完全透過用例存取數據,那麼僅以這種方式存取數據可能是有意義的。

他們建議在某些情況下違反依賴規則並直接在 UI 層使用資料層,這也值得深思。

正面

然而,如果您想保持關注點分離並完全遵循乾淨的架構分層原則,而不陷入過多的單行用例的陷阱,請考慮使用外觀模式。

。 ​​class UserFacade(
private val userRepository: UserRepository,
private val userMapper: UserMapper
) {

fun getUser(userConfig: UserConfig): Result<User> {
return userRepository.getUser(userConfig)
}

fun getAllUsers(): Result<List<User>> {
return userRepository.getUsers()
}

fun getAllPremiumUsers(): Result<List<User>> {
return userRepository.getPremiumUsers()
}

fun addUser(user: User): Result<User> {
return userRepository.addUser(user)
}

fun deleteUser(user: User): Result<User> {
return userRepository.deleteUser(user)
}

fun updateUser(user: User): Result<User> {
return userRepository.updateUser(user)
}
}
​ ​​
​ ​​
​ ​ ​​​​

外觀模式-透過將複雜性封裝在一個更具凝聚力的介面後面,使子系統更易於使用。外觀不是將多個單行用例分散在整個程式碼庫中,而是整合了這些操作。這減少了許多用例本質上做同樣的事情(與資料層互動)的冗餘。

您可以考慮許多其他選項,例如具有可與任何資料類型重用的泛型類型的外觀,並進行實驗以找到最適合您的選項。

單一責任原則

現在我們從SOLID原則來談SRP。請記住,您的用例應該是單一操作,並且根據其定義應該遵守 SRP。

我們有一個用例負責新用戶的註冊:

class UserRegistrationUseCase(  
private val userRepository: UserRepository,
private val appThemeRepository: AppThemeRepository,
private val emailService: EmailService,
private val securityService: SecurityService,
private val promotionsService: PromotionsService,
) {

operator fun invoke(userDetails: UserDetails): Result<User> {
if (securityService.weak(password = userDetails.password)) {
return Result.failure(Exception("Password is weak"))
}

val isPromotional = checkPromotionalEligibility(userDetails.email, userDetails.location)
val userSettings = UserSettings("en-US", receiveNewsletters = isPromotional)
val starterPack = if (isPromotional)
promotionsService.getPromotionalStarterPack(userDetails.location)
else promotionsService.getDefaultStarterPack(userDetails.location)

val user = User.fromUserDetails(userDetails, securityService.encryptPassword(userDetails.password), isPromotional, userSettings, starterPack)

userRepository.save(user)
emailService.sendWelcomeEmail(userDetails.email, isPromotional)

if (isPromotional)
appThemeRepository.save(user.id, "dark")
else appThemeRepository.save(user.id, "light")

promotionsService.schedulePersonalizedFollowUps(user.id, user.email, user.isPromotional)
return Result.success(user)
}

private fun checkPromotionalEligibility(email: String, location: String): Boolean {
val isEmailEligible = email.endsWith("@example.com")
val isLocationEligible = location == "USA" // Assume promotional eligibility for USA
return isEmailEligible && isLocationEligible
}
}

如果我們看一下這個用例,它是否違反了 SRP?可能是。有一些程式碼可以提取到單獨的用例中並重複使用,讓我們嘗試一下。

class UserRegistrationFlowUseCase(  
private val saveUserUseCase: SaveUserUseCase,
private val prepareNewUserUseCase: PrepareNewUserUseCase,
private val userFollowUpUseCase: UserFollowUpUseCase,
private val sendWelcomeEmailUseCase: SendWelcomeEmailUseCase,
private val setAppThemeUseCase: SetAppThemeUseCase,
) {

operator fun invoke(userDetails: UserDetails): Result<User> {
val userResult = prepareNewUserUseCase.prepareUser(userDetails)
if (userResult.isError()) {
return userResult
}
val user = userResult.get()

saveUserUseCase(user)
sendWelcomeEmailUseCase(user)
userFollowUpUseCase(user)
setAppThemeUseCase(user)
return Result.success(user)
}
}

class PromotionEligibilityUseCase(
private val emailPromotionEligibilityUseCase: EmailPromotionEligibilityUseCase,
) {

fun checkEligibility(userDetails: UserDetails): Boolean {
val isEmailEligible = emailPromotionEligibilityUseCase(userDetails.email)
val isLocationEligible = userDetails.location == "USA" // Assume promotional eligibility for USA
return isEmailEligible && isLocationEligible
}

}

class EmailPromotionEligibilityUseCase {

operator fun invoke(email: String): Boolean {
val emailRegex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$"
return email.endsWith("@example.com") && email.matches(emailRegex.toRegex())
}
}

class PrepareNewUserUseCase(
private val securityService: SecurityService,
private val getStarterPackUseCase: GetStarterPackUseCase,
private val promotionEligibilityUseCase: PromotionEligibilityUseCase,
) {

fun prepareUser(userDetails: UserDetails): Result<User> {
if (securityService.weak(password = userDetails.password)) {
return Result.failure(Exception("Password is weak"))
}
val isPromotional = promotionEligibilityUseCase.checkEligibility(userDetails)
val userSettings = UserSettings("en-US", receiveNewsletters = isPromotional)
val starterPack = getStarterPackUseCase(userDetails.location, isPromotional)
val encryptedPassword = securityService.encryptPassword(userDetails.password)
return Result.success(User(
id = 0,
name = userDetails.name,
email = userDetails.email,
password = encryptedPassword,
location = userDetails.location,
isPromotional = isPromotional,
settings = userSettings,
starterPack = starterPack
)
)
}
}

class GetStarterPackUseCase(private val promotionsService: PromotionsService) {

operator fun invoke(location: String, isPromotional: Boolean): StarterPack {
return if (isPromotional)
promotionsService.getPromotionalStarterPack(location)
else
promotionsService.getDefaultStarterPack(location)
}
}

class SaveUserUseCase(private val userRepository: UserRepository) {

operator fun invoke(user: User): User {
return userRepository.save(user)
}
}

class SendWelcomeEmailUseCase(
private val emailService: EmailService
) {

operator fun invoke(user: User) {
emailService.sendWelcomeEmail(user.email, user.isPromotional)
}
}

class SetAppThemeUseCase(
private val appThemeRepository: AppThemeRepository
) {

operator fun invoke(user: User) {
if (user.isPromotional)
appThemeRepository.save(user.id, "dark")
else
appThemeRepository.save(user.id, "light")
}
}

class UserFollowUpUseCase(
private val engagementTracker: EngagementTracker
) {

operator fun invoke(user: User) {
engagementTracker.schedulePersonalizedFollowUps(user.id, user.email, user.isPromotional)
}
}

我們創建了八個滿足單一職責原則 (SRP) 的新用例。它們都很小,看起來很漂亮,可以輕鬆重複使用。我不是說測試,因為在這兩種情況下我們都可以輕鬆測試所有內容。

然而,這種方法會產生巨大的**上下文開銷**。您擁有的功能越多,您需要執行的操作就越多才能了解正在發生的情況。在此範例中,您需要在九個檔案之間跳轉才能查看完整情況。想像閱讀一本書中的一個段落,然後必須轉到下一頁閱讀某個段落,然後再次轉到下一頁,然後返回原始頁面閱讀下一句話,最後到書的結尾閱讀另一段,所有這一切只是為了理解一本書的一句話中發生的事情。

您基本上需要在頭腦中保留一棵函數及其正在執行的操作樹,閱讀並記住每個用例中發生的情況。閱讀一個函數正在做什麼,繼續下一步 — 再次閱讀,繼續下一步 — 閱讀,然後撤消它返回, — 再次撤消它 — 直到原始用例,並繼續剩下的每個函數。因為它們都位於不同的文件中,所以您需要記住所有它們。

這對於偵錯來說也是痛苦的,需要在不同檔案的斷點之間跳轉,並且偵錯器中的資料通常只顯示當前類別的資料。對於程式碼審查來說同樣具有挑戰性,因為並非總是能夠在文件和函數之間輕鬆導航。

即使只有 2–3–4 個用例,程式碼也更難理解。範例中的功能簡單易行,但在實際專案中,通常更複雜,名稱並不總是顯而易見或具有代表性(命名很難)。

在第一種方法中,用例具有一個包含 20–25 行程式碼的函數,甚至適合最小的顯示器。您可以像閱讀一本書一樣閱讀它並查看整個工作,而不必讓自己陷入「我 20 秒前檢查的那三個函數中是什麼?」的負擔。另外,如果它不是在多個地方使用,我們是否需要這種「可重複使用性」?在明確需要可重複使用性之前將用例拆分為更小、更具體的元件是不成熟的最佳化。這種方法可能會不必要地使架構變得複雜,因為它引入了更多需要管理和維護的元素,但沒有實際好處。

問題是,我們是在試圖解決這裡的問題,還是只是因為我們認為這是編寫程式碼的唯一正確方法而遵守這些規則?

對我來說,當您不需要嚴格遵守所有規則(順便說一句,這些規則是多年前引入的並且沒有發展)時,使用和維護程式碼會容易得多。

概括

- 依賴關係規則:此規則規定原始碼依賴關係只能指向內部,通常會導致建立充當資料層代理的使用案例。

-單行用例:嚴格遵守清潔架構可能會產生大量單行用例,這些用例除了充當其他層的代理之外什麼也不做,從而導致用例數量呈指數級增長。

-指數增長:用例的數量可以快速增長,為維護和導航程式碼庫帶來挑戰。

-複雜性和開銷:嚴格遵守 SRP 可能會導致將單一用例分解為許多較小的用例,從而增加系統的複雜性和上下文開銷。

-平衡:設計用例時,考慮在維持單一職責原則(SRP)以實現理論純度和可重用性與創建實用的、可維護的系統以最小化運營開銷之間進行權衡。

感謝您的閱讀!

--

--

胡家維 Hu Kenneth
ATaiwanSoftwareDeveloperInSG

撰寫任何事情,O型水瓶混魔羯,咖啡愛好者,Full stack/blockchain Web3 developer,Founder of Blockchain&Dapps meetup ,Udemy teacher。 My Linktree: https://linktr.ee/kennethhutw