知識庫 / Spring / Spring Boot RSS 訂閱

SpringBoot 純淨架構

Architecture,Spring Boot
HongKong
6
12:43 PM · Dec 06 ,2025

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或數據庫的工作方式做出任何假設。我們正在使用 UserDsGatewayUserPresenter。所以,我們怎麼會不瞭解它們呢?因為,與 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. 請求和響應模型

我們已經通過接口在各個層創建了操作。接下來,讓我們看看如何在這些邊界之間傳輸數據。

請注意,我們所有的邊界只處理 StringModel 對象:

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 創建了一個用例,應用了這些原則。

儘管如此,我們仍然放棄了一些原則。但所有這些原則都指向同一個方向。我們可以通過引用其創建者的話來總結它:“一位優秀的建築師必須最大化未做出的決定。” 我們通過使用邊界來保護我們的業務代碼,避免與細節的直接聯繫來實現這一點。

user avatar
0 位用戶收藏了這個故事!
收藏

發佈 評論

Some HTML is okay.