使用Spring Boot的Configuration和ArchUnit實現元件模組化和清晰邊界 - reflectoring

banq發表於2020-03-24

本文提出了一種使用包Package設計對Java應用程式進行模組化的有效方法,並將此方法與Spring Boot作為依賴項注入機制結合使用,與ArchUnit結合使用,以在有人新增了不允許的模組間依賴項時使測試失敗。好於純粹基於Java9模組JPMS機制。

我們希望以在構建軟體時,擁有:可理解性、可維護性、可擴充套件性、以及-目前趨向於可分解性(因此,如果需要,我們可以將整體分解為微服務)。這些特性的英文後面都有“ -ility”字尾,它們合起來簡稱“ -ilities”。

這些特性大部分與在元件之間劃分清晰依賴有直接關係。

如果一個元件依賴於所有其他元件,我們將不知道操作這個元件會產生什麼副作用,這使得程式碼庫難以維護,甚至難以擴充套件和分解。

隨著時間的流逝,程式碼庫中的元件邊界趨於惡化。錯誤的依賴關係不斷湧入,使程式碼處理更加困難。這具有各種不良影響。最值得注意的是,發展速度變慢。

我們如何保護我們的程式碼庫免受不必要的依賴?精心設計並持續實施元件邊界。本文展示了一套在使用Spring Boot時對這兩個方面都有幫助的實踐。

 程式碼示例

本文隨附GitHub上的工作程式碼示例。

包私有可見性

什麼對加強元件邊界有幫助?降低可見度。

如果我們在包“內部”使用包私有的類,則只有同一包中的類才可以訪問。這使得從包外部新增不需要的依賴項變得更加困難。因此,只需將元件的所有類放入同一包中,並僅將元件外部我們需要的那些類公開即可。問題解決了?

我認為不是。

如果我們的元件中需要子包,那將不起作用。我們必須公開子包中的類,以便它們可以在其他子包中使用,從而將它們向全世界開放。

我不想侷限於我的元件使用單個軟體包!也許我的元件有一些子元件,我不想暴露給外界。或者,也許我只想將類分類到單獨的儲存桶中,以使程式碼庫更易於瀏覽。我需要那些分包!

因此,是的,程式包私有的可見性有助於避免不必要的依賴關係,但是就其本身而言,它充其量只是一個半成品的解決方案。

清晰邊界的方法

我們不能單靠包私有的可見性。讓我們看一下一種新方法,該方法使用智慧包結構,儘可能實現包私有可見性,無法實現的適用ArchUnit。保持我們的程式碼庫避免不必要的依賴關係。

示例用例

我們將在示例用例旁邊討論該方法。假設我們正在構建一個如下所示的計費元件:

使用Spring Boot的Configuration和ArchUnit實現元件模組化和清晰邊界 - reflectoring

具有外部和內部依賴性的模組。開票元件將發票計算器暴露在外面。發票計算器生成特定客戶和時間段的發票。為了使發票計算器正常工作,它需要在日常批處理作業中同步來自外部訂單系統的資料。此批處理作業從外部源中提取資料並將其放入資料庫中。

我們的元件包含三個子元件:發票計算器,批處理作業和資料庫程式碼。所有這些元件都可能包含幾個類。發票計算器是一個公共元件,而批處理作業和資料庫元件是內部元件,不應從計費元件外部進行訪問。

API類與內部類

讓我們看一下我為計費元件建議的打包結構:

billing
├── api
└── internal
    ├── batchjob
    |   └── internal
    └── database
        ├── api
        └── internal

每個元件和子元件都有一個internal包含內部類的包,以及一個可選api包,該包包含-您猜對了-其他元件將要使用的API類。

internal和api之間的這種包裝分離為我們帶來了兩個優點:

  • 我們可以輕鬆地將元件相互巢狀。
  • 很容易猜到,internal不應從包外部使用包中的類。
  • 很容易猜測一個internal包中的類可以在其子包中使用。
  • 該api和internal包裝給我們一個方式來執行相關性規則與ArchUnit。
  • 我們可以根據需要在api或internal包中使用盡可能多的類或子包,並且仍然可以清晰地定義元件邊界。
  • internal包中的類應儘可能是包私有的。但是,即使它們是公共的(如果我們使用子包,它們也必須是公共的),包結構也定義了乾淨且易於遵循的邊界。

我們沒有依靠Java對包私有可見性的不足支援,而是建立了一種結構上可表達的包結構,可以很容易地通過工具來實施。

現在,讓我們看一下這些軟體包。

反轉依賴關係以公開包專用功能

讓我們從database子元件開始:

database
├── api
|   ├── + LineItem
|   ├── + ReadLineItems
|   └── + WriteLineItems
└── internal
    └── o BillingDatabase

+表示一個類是公共的,o意味著它是包私有的。

該database元件公開了具有兩個介面的API ReadLineItems和WriteLineItems,這兩個介面分別允許從客戶訂單讀取和寫入訂單項到資料庫以及向資料庫寫入訂單項。所述LineItem域型別也是API的一部分。

在內部,database子元件具有一個BillingDatabase實現兩個介面的類:

@Component
class BillingDatabase implements WriteLineItems, ReadLineItems {
  ...
}

此實現可能有一些幫助程式類,但與本討論無關。

請注意,這是依賴倒置原則的應用。對於database子元件,我們不在乎使用哪種資料庫技術來查詢資料庫。

我們也來看看batchjob子元件:

batchjob
└── internal
    └── o LoadInvoiceDataBatchJob

這個batchjob子元件完全不暴露給其他元件的API。它僅具有一個類LoadInvoiceDataBatchJob(可能還有一些幫助器類),該類每天從外部源載入資料,進行轉換並將其通過WriteLineItems介面輸入到計費元件的資料庫中:

@Component
@RequiredArgsConstructor
class LoadInvoiceDataBatchJob {

  private final WriteLineItems writeLineItems;

  @Scheduled(fixedRate = 5000)
  void loadDataFromBillingSystem() {
    ...
    writeLineItems.saveLineItems(items);
  }

}

請注意,我們使用Spring的@Scheduled註釋來定期檢查計費系統中的新專案。

最後,頂級billing元件的內容:

billing
├── api
|   ├── + Invoice
|   └── + InvoiceCalculator
└── internal
    ├── batchjob
    ├── database
    └── o BillingService

該billing元件公開InvoiceCalculator介面和Invoice域型別。同樣,該InvoiceCalculator介面由內部類(BillingService在示例中稱為)實現。BillingService通過ReadLineItems資料庫API 訪問資料庫,以從多個訂單項建立客戶發票:

@Component
@RequiredArgsConstructor
class BillingService implements InvoiceCalculator {

  private final ReadLineItems readLineItems;

  @Override
  public Invoice calculateInvoice(
        Long userId, 
        LocalDate fromDate, 
        LocalDate toDate) {
    
    List<LineItem> items = readLineItems.getLineItemsForUser(
      userId, 
      fromDate, 
      toDate);
    ... 
  }

}

現在我們已經有了一個乾淨的結構,我們需要依賴注入將它們連線在一起。

與Spring Boot一起

要將所有內容連線到應用程式,我們利用Spring的Java Config功能,向每個模組的internal包中新增一個Configuration類:

billing
└── internal
    ├── batchjob
    |   └── internal
    |       └── o BillingBatchJobConfiguration
    ├── database
    |   └── internal
    |       └── o BillingDatabaseConfiguration
    └── o BillingConfiguration

這些Configuration類告訴Spring將Spring Bean釋出到應用程式上下文。

database子元件的Configuration類如下:

@Configuration
@EnableJpaRepositories
@ComponentScan
class BillingDatabaseConfiguration {

}

通過@Configuration註釋,我們告訴Spring這是一個配置類,它將Spring Bean釋出到應用程式上下文。

@ComponentScan註解告訴Spring與Configuration配置類在通一個包下面(或子包)還有@Component所有類都要掃描包含,這些都要釋出到應用程式上下文。如果不使用@ComponentScan,我們還可以在@Configuration類中使用帶@Bean註釋的工廠方法。

在後臺,該database模組使用Spring Data JPA儲存庫來連線資料庫。我們通過@EnableJpaRepositories註釋啟用這些功能。

batchjob的Configuration配置類也非常類似上述資料庫元件:

@Configuration
@EnableScheduling
@ComponentScan
class BillingBatchJobConfiguration {

}

只有@EnableScheduling註釋是不同的。我們需要這個註釋來啟用我們在LoadInvoiceDataBatchJobbean中的註釋@Scheduled。

最後,頂級billing元件的Configuration配置類看起來很平常了:

@Configuration
@ComponentScan
class BillingConfiguration {

}

通過@ComponentScan註釋,此配置可確保@ConfigurationSpring拾取子元件並將其與釋出的Bean一起載入到應用程式上下文中。

這樣,我們不僅在包尺寸設計方面,而且在Spring配置的方面也將邊界清晰地分開了。這意味著我們可以通過解決其@Configuration類別來分別定位每個元件和子元件。

例如,我們可以:

  • 在@SpringBootTest整合測試中,僅將一個(子)元件載入到應用程式上下文中。
  • 通過向該子元件的配置新增@Conditional...註釋來啟用或禁用特定(子)元件。
  • 用一個(子)元件替換對應用程式上下文有貢獻的bean,而不會影響其他(子)元件。

但是,我們仍然有一個問題:billing.internal.database.api包中的類是公共的,這意味著可以從billing元件外部訪問它們,這是我們不想要的。

讓我們通過向遊戲中新增ArchUnit來解決此問題。

使用ArchUnit加強邊界

ArchUnit是一個庫,允許我們在架構上執行斷言。這包括根據我們可以定義自己的規則檢查某些類之間的依賴項是否有效。

在我們的例子中,我們想定義一個規則,即internal不能從該包外部使用包中的所有類。該規則將確保billing.internal.*.api不能被從billing.internal包外部訪問其中的類。

1.標記內部包裝

為了internal在建立體系結構規則時對我們的程式包有所瞭解,我們需要以某種方式將它們標記為“內部”。我們可以按名稱進行操作(即,將所有名稱為“ internal”的軟體包都視為內部軟體包),但是我們也可能想用不同的名稱標記軟體包,因此我們建立了@InternalPackage註釋:

@Target(ElementType.PACKAGE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface InternalPackage {

}

然後,在所有內部包中,新增package-info.java帶有以下注釋的檔案:

@InternalPackage
package io.reflectoring.boundaries.billing.internal.database.internal;

import io.reflectoring.boundaries.InternalPackage;

這樣,所有內部包都被標記了,我們可以圍繞它建立規則。

2. 驗證是否無法從外部訪問內部軟體包

現在,我們建立一個測試,以驗證內部包中的類不是從外部訪問的:

class InternalPackageTests {

  private static final String BASE_PACKAGE = "io.reflectoring";
  private final JavaClasses analyzedClasses = 
      new ClassFileImporter().importPackages(BASE_PACKAGE);

  @Test
  void internalPackagesAreNotAccessedFromOutside() throws IOException {

    List<String> internalPackages = internalPackages(BASE_PACKAGE);

    for (String internalPackage : internalPackages) {
      assertPackageIsNotAccessedFromOutside(internalPackage);
    }

  }

  private List<String> internalPackages(String basePackage) {
    Reflections reflections = new Reflections(basePackage);
    return reflections.getTypesAnnotatedWith(InternalPackage.class).stream()
        .map(c -> c.getPackage().getName())
        .collect(Collectors.toList());
  }

  void assertPackageIsNotAccessedFromOutside(String internalPackage) {
    noClasses()
        .that()
        .resideOutsideOfPackage(packageMatcher(internalPackage))
        .should()
        .dependOnClassesThat()
        .resideInAPackage(packageMatcher(internalPackage))
        .check(analyzedClasses);
  }

  private String packageMatcher(String fullyQualifiedPackage) {
    return fullyQualifiedPackage + "..";
  }

}

在中internalPackages(),我們利用了反射庫來收集所有帶有@InternalPackage註釋的軟體包。

對於這些包中的每一個,我們然後呼叫assertPackageIsNotAccessedFromOutside()。此方法使用ArchUnit的類似DSL的API來確保“位於包外部的類不應依賴於位於包內部的類”。

如果有人在內部軟體包中向公共類新增了不必要的依賴關係,則該測試現在將失敗。

但是我們仍然有一個問題:如果在重構中重新命名這個io.reflectoring基本包該怎麼辦?該測試仍將通過,因為它將在(現在不存在)io.reflectoring軟體包中找不到任何軟體包。如果沒有要檢查的軟體包,它就不會失敗。因此,我們需要一種使該測試重構安全的方法。

2.使架構規則重構安全

為了使我們的測試重構安全,我們驗證軟體包是否存在:

class InternalPackageTests {

  private static final String BASE_PACKAGE = "io.reflectoring";

  @Test
  void internalPackagesAreNotAccessedFromOutside() throws IOException {

    // make it refactoring-safe in case we're renaming the base package
    assertPackageExists(BASE_PACKAGE);

    List<String> internalPackages = internalPackages(BASE_PACKAGE);

    for (String internalPackage : internalPackages) {
      // make it refactoring-safe in case we're renaming the internal package
      assertPackageExists(internalPackage);
      assertPackageIsNotAccessedFromOutside(internalPackage);
    }

  }

  void assertPackageExists(String packageName) {
    assertThat(analyzedClasses.containPackage(packageName))
        .as("package %s exists", packageName)
        .isTrue();
  }

  private List<String> internalPackages(String basePackage) {
    ...
  }

  void assertPackageIsNotAccessedFromOutside(String internalPackage) {
    ...
  }

}

新方法assertPackageExists()使用ArchUnit來確保所討論的包包含在我們正在分析的類中。我們對基本包呼叫一次此方法,對每個內部包呼叫一次。現在該測試是重構安全的,並且如果我們按原樣重新命名軟體包,它將失敗。

結論

本文提出了一種使用包Package設計對Java應用程式進行模組化的有效方法,並將此方法與Spring Boot作為依賴項注入機制結合使用,與ArchUnit結合使用,以在有人新增了不允許的模組間依賴項時使測試失敗。這使我們能夠開發具有清晰的API和清晰的邊界的元件,從而避免了很多麻煩。

 

相關文章