1. 概述
當開發長期系統時,我們應該預期環境是可變的。
一般來説,我們的功能需求、框架、I/O 設備,甚至我們的代碼設計,可能會因為各種原因發生變化。考慮到這些不確定性,清潔架構 (Clean Architecture) 是一種指導原則,旨在實現高可維護性。
在本文中,我們將創建一個用户註冊 API 的示例,遵循 羅伯特·馬丁 (Robert C. Martin) 的清潔架構。我們將使用他的原始層級——實體、用例、適配器和框架/驅動程序。
2. 架構清潔概述
該架構彙集了許多代碼設計和原則,例如 SOLID、穩定抽象原則 等。但其核心思想在於 將系統劃分為基於業務價值的層級。因此,最高層級包含業務規則,而較低的每一層都更接近 I/O 設備。
此外,我們可以將這些層級翻譯為層。在這種情況下,它是相反的。內層與最高層級相同,以此類推:
考慮到這一點,我們可以根據業務需求擁有儘可能多的層級,但始終要遵循依賴規則——較高層級永遠不應依賴較低層級。
3. 規則
讓我們開始定義我們用户註冊 API 的系統規則。首先,業務規則:
- 用户的密碼必須包含超過五個字符
其次,我們有應用程序規則。它們可以採用不同的格式,例如用例或故事。我們將使用敍事短語:
- 系統接收用户名和密碼,驗證用户是否存在,並保存新用户以及創建時間
請注意,沒有提到數據庫、UI 或類似內容。因為我們的業務不關心這些細節,我們的代碼也不應該關心這些細節。
4. 實體層
正如清潔架構所建議的,我們首先從業務規則開始:
interface User {
boolean passwordIsValid();
String getName();
String getPassword();
}並且,一個 UserFactory:
interface UserFactory {
User create(String name, String password);
}我們創建了一個用户工廠方法,主要有兩個原因。為了遵循穩定的抽象原則(Stable Abstractions Principle),並隔離用户創建邏輯。
接下來,我們來實現這兩個方法。
class CommonUser implements User {
String name;
String password;
@Override
public boolean passwordIsValid() {
return password != null && password.length() > 5;
}
// Constructor and getters
}class CommonUserFactory implements UserFactory {
@Override
public User create(String name, String password) {
return new CommonUser(name, password);
}
}如果我們的業務比較複雜,那麼我們應該儘可能地清晰地構建領域代碼。
因此,這個層級是應用設計模式的絕佳場所。特別是,領域驅動設計應該予以考慮。
4.1. 單元測試
現在,讓我們測試我們的 CommonUser:
@Test
void given123Password_whenPasswordIsNotValid_thenIsFalse() {
User user = new CommonUser("Baeldung", "123");
assertThat(user.passwordIsValid()).isFalse();
}正如我們所見,單元測試非常清晰。畢竟,沒有使用 Mock 也是這一層的良好信號
一般來説,如果在這裏開始考慮 Mock,可能意味着我們正在將實體與用例混合在一起。
5. 用例層
用例是 與我們系統自動化相關的規則。在Clean Architecture中,我們稱它們為交互器。
5.1. UserRegisterInteractor
首先,我們將構建 UserRegisterInteractor,以便我們瞭解目標方向。然後,我們將創建並討論所有使用的部分:
class UserRegisterInteractor implements UserInputBoundary {
final UserRegisterDsGateway userDsGateway;
final UserPresenter userPresenter;
final UserFactory userFactory;
// Constructor
@Override
public UserResponseModel create(UserRequestModel requestModel) {
if (userDsGateway.existsByName(requestModel.getName())) {
return userPresenter.prepareFailView("User already exists.");
}
User user = userFactory.create(requestModel.getName(), requestModel.getPassword());
if (!user.passwordIsValid()) {
return userPresenter.prepareFailView("User password must have more than 5 characters.");
}
LocalDateTime now = LocalDateTime.now();
UserDsRequestModel userDsModel = new UserDsRequestModel(user.getName(), user.getPassword(), now);
userDsGateway.save(userDsModel);
UserResponseModel accountResponseModel = new UserResponseModel(user.getName(), now.toString());
return userPresenter.prepareSuccessView(accountResponseModel);
}
}如我們所見,我們正在執行所有用例步驟。此外,該層負責控制實體的舞蹈。然而,我們並沒有對UI或數據庫的工作方式做出任何假設。我們正在使用 UserDsGateway 和 UserPresenter。所以,我們怎麼會不瞭解它們呢?因為,與 UserInputBoundary 一起,它們是我們的輸入和輸出邊界。
5.2. 輸入與輸出邊界
邊界定義了組件如何交互的方式。 輸入邊界暴露了我們的用例給外層:
interface UserInputBoundary {
UserResponseModel create(UserRequestModel requestModel);
}接下來,我們有輸出邊界,用於利用外層。首先,讓我們定義數據源網關:
interface UserRegisterDsGateway {
boolean existsByName(String name);
void save(UserDsRequestModel requestModel);
}第二,視圖呈現器:
interface UserPresenter {
UserResponseModel prepareSuccessView(UserResponseModel user);
UserResponseModel prepareFailView(String error);
}
請注意,我們正在使用依賴倒置原則,以使我們的業務免受數據庫和 UI 等細節的影響。
5.3. 解耦模式
在繼續之前,請注意,邊界定義了系統自然劃分的範圍。但我們還需要決定我們的應用程序將如何交付:
- 單體架構 – 預計將使用某種包結構
- 通過使用模塊
- 通過使用服務/微服務
考慮到這些因素,任何解耦模式都可以實現清晰架構的目標。因此,我們應該根據當前的業務需求和未來的業務需求,隨時準備在這些策略之間切換。選擇解耦模式後,代碼劃分應基於我們的邊界。
5.4. 請求和響應模型
我們已經通過接口在各個層創建了操作。接下來,讓我們看看如何在這些邊界之間傳輸數據。
請注意,我們所有的邊界只處理 String 或 Model 對象:
class UserRequestModel {
String login;
String password;
// Getters, setters, and constructors
}基本上,只有簡單的數據結構才能跨越邊界。此外,所有模型僅包含字段和訪問器。
數據對象位於內部。因此,我們可以保持依賴規則。
但是,為什麼我們有這麼多相似的對象?當重複代碼出現時,它可以分為兩種類型:
- 虛假或意外的複製——代碼相似性是意外的,因為每個對象都有不同的更改原因。如果我們嘗試刪除它,我們可能會違反單一職責原則。
- 真實的複製——代碼更改的原因相同。因此,我們應該刪除它
由於每個模型都有不同的職責,因此產生了這些對象。
5.5. 測試 UserRegisterInteractor
現在,讓我們創建我們的單元測試:
@Test
void givenBaeldungUserAnd123456Password_whenCreate_thenSaveItAndPrepareSuccessView() {
User user = new CommonUser("baeldung", "123456");
UserRequestModel userRequestModel = new UserRequestModel(user.getName(), user.getPassword());
when(userFactory.create(anyString(), anyString()))
.thenReturn(new CommonUser(user.getName(), user.getPassword()));
interactor.create(userRequestModel);
verify(userDsGateway, times(1)).save(any(UserDsRequestModel.class));
verify(userPresenter, times(1)).prepareSuccessView(any(UserResponseModel.class));
}如我們所見,大多數用例測試都集中在控制實體和邊界請求上。 並且,我們的接口允許我們輕鬆地模擬細節。
6. 接口適配器
現在,我們已經完成了所有的業務邏輯。現在,讓我們開始將細節整合進來。
我們的業務應該只處理最適合它的數據格式,同樣,我們的外部代理(如數據庫或UI)也應該這樣做。 但通常,這個格式與業務需求不同。因此,接口適配器層負責將數據轉換。
6.1. 使用 UserRegisterDsGateway 進行 JPA 映射
首先,我們使用 JPA 將我們的 用户 表映射如下:
@Entity
@Table(name = "user")
class UserDataMapper {
@Id
String name;
String password;
LocalDateTime creationTime;
//Getters, setters, and constructors
}如我們所見,Mapper 目標是將我們的對象映射到數據庫格式。
接下來,我們使用 JpaRepository 訪問我們的實體:
@Repository
interface JpaUserRepository extends JpaRepository<UserDataMapper, String> {
}鑑於我們將使用 Spring Boot,以下是保存用户的全部步驟。
現在,是時候實現我們的 UserRegisterDsGateway:
class JpaUser implements UserRegisterDsGateway {
final JpaUserRepository repository;
// Constructor
@Override
public boolean existsByName(String name) {
return repository.existsById(name);
}
@Override
public void save(UserDsRequestModel requestModel) {
UserDataMapper accountDataMapper = new UserDataMapper(requestModel.getName(), requestModel.getPassword(), requestModel.getCreationTime());
repository.save(accountDataMapper);
}
}大部分代碼本身就能説明問題。除了我們的方法之外,請注意 UserRegisterDsGateway 的名稱。如果我們在那裏選擇 UserDsGateway,那麼其他的 User 使用場景可能會違反 接口隔離原則。
6.2. 用户註冊 API
現在,讓我們創建我們的 HTTP 適配器:
@RestController
class UserRegisterController {
final UserInputBoundary userInput;
// Constructor
@PostMapping("/user")
UserResponseModel create(@RequestBody UserRequestModel requestModel) {
return userInput.create(requestModel);
}
}如我們所見,這裏唯一的目的是接收請求並將響應發送給客户端。
6.3. 準備響應
在回覆之前,我們應該格式化我們的回覆:
class UserResponseFormatter implements UserPresenter {
@Override
public UserResponseModel prepareSuccessView(UserResponseModel response) {
LocalDateTime responseTime = LocalDateTime.parse(response.getCreationTime());
response.setCreationTime(responseTime.format(DateTimeFormatter.ofPattern("hh:mm:ss")));
return response;
}
@Override
public UserResponseModel prepareFailView(String error) {
throw new ResponseStatusException(HttpStatus.CONFLICT, error);
}
}我們的 UserRegisterInteractor 迫使我們創建了一個 presenter。 然而, presenter 的規則僅限於 adapter 內部。 此外,whenever something is hard to test, we should divide it into a testable and a humble object。 因此,UserResponseFormatter 很容易讓我們驗證 presentation 規則。
@Test
void givenDateAnd3HourTime_whenPrepareSuccessView_thenReturnOnly3HourTime() {
UserResponseModel modelResponse = new UserResponseModel("baeldung", "2020-12-20T03:00:00.000");
UserResponseModel formattedResponse = userResponseFormatter.prepareSuccessView(modelResponse);
assertThat(formattedResponse.getCreationTime()).isEqualTo("03:00:00");
}如我們所見,我們在發送給視圖之前已經測試了所有邏輯。因此,只有簡單的對象位於不太容易測試的部分。
7. 驅動程序和框架
實際上,我們通常不會在這裏編寫代碼。這是因為這一層代表了與外部代理建立的最底層連接。例如,H2 驅動程序用於連接到數據庫或 Web 框架。在這種情況下,我們將使用 Spring Boot 作為 Web 框架和依賴注入框架。因此,我們需要它的啓動點:
@SpringBootApplication
public class CleanArchitectureApplication {
public static void main(String[] args) {
SpringApplication.run(CleanArchitectureApplication.class);
}
}此前,我們從未使用過任何Spring註解,包括針對Spring的適配器,例如UserRegisterController。這是因為我們應該將Spring Boot視為任何其他細節。
8. 糟糕的主類
終於,最後一項!
此前,我們一直遵循了 穩定抽象原則。 此外,我們還通過控制反轉保護了內部層級,免受外部因素的影響。 最後,我們將所有對象創建與使用分離。 在此時,我們有責任創建剩餘的依賴項並將其注入到我們的項目中:
@Bean
BeanFactoryPostProcessor beanFactoryPostProcessor(ApplicationContext beanRegistry) {
return beanFactory -> {
genericApplicationContext(
(BeanDefinitionRegistry) ((AnnotationConfigServletWebServerApplicationContext) beanRegistry)
.getBeanFactory());
};
}
void genericApplicationContext(BeanDefinitionRegistry beanRegistry) {
ClassPathBeanDefinitionScanner beanDefinitionScanner = new ClassPathBeanDefinitionScanner(beanRegistry);
beanDefinitionScanner.addIncludeFilter(removeModelAndEntitiesFilter());
beanDefinitionScanner.scan("com.baeldung.pattern.cleanarchitecture");
}
static TypeFilter removeModelAndEntitiesFilter() {
return (MetadataReader mr, MetadataReaderFactory mrf) -> !mr.getClassMetadata()
.getClassName()
.endsWith("Model");
}在我們的方案中,我們使用 spring-boot dependency injection 來創建所有實例。由於我們不使用 @Component,我們掃描根包並僅忽略 Model 對象。
儘管這種策略看起來更復雜,但它將我們的業務與 DI 框架解耦。另一方面,主類掌握了我們整個系統的控制權。因此,清潔架構將其視為一個特殊層,擁抱所有其他層:
9. 結論
在本文中,我們學習了 Uncle Bob 的 Clean Architecture 如何建立在許多設計模式和原則之上。我們還使用 Spring Boot 創建了一個用例,應用了這些原則。
儘管如此,我們仍然放棄了一些原則。但所有這些原則都指向同一個方向。我們可以通過引用其創建者的話來總結它:“一位優秀的建築師必須最大化未做出的決定。” 我們通過使用邊界來保護我們的業務代碼,避免與細節的直接聯繫來實現這一點。