使用Spring Boot的Configuration和ArchUnit实现组件模块化和清晰边界 - reflectoring

20-03-24 banq

本文提出了一种使用包Package设计对Java应用程序进行模块化的有效方法,并将此方法与Spring Boot作为依赖项注入机制结合使用,与ArchUnit结合使用,以在有人添加了不允许的模块间依赖项时使测试失败。好于纯粹基于Java9模块JPMS机制。

我们希望以在构建软件时,拥有:可理解性、可维护性、可扩展性、以及-目前趋向于可分解性(因此,如果需要,我们可以将整体分解为微服务)。这些特性的英文后面都有“ -ility”后缀,它们合起来简称“ -ilities”。

这些特性大部分与在组件之间划分清晰依赖有直接关系。

如果一个组件依赖于所有其他组件,我们将不知道操作这个组件会产生什么副作用,这使得代码库难以维护,甚至难以扩展和分解。

随着时间的流逝,代码库中的组件边界趋于恶化。错误的依赖关系不断涌入,使代码处理更加困难。这具有各种不良影响。最值得注意的是,发展速度变慢。

我们如何保护我们的代码库免受不必要的依赖?精心设计并持续实施组件边界。本文展示了一套在使用Spring Boot时对这两个方面都有帮助的实践。

 代码示例

本文随附GitHub上的工作代码示例。

包私有可见性

什么对加强组件边界有帮助?降低可见度。

如果我们在包“内部”使用包私有的类,则只有同一包中的类才可以访问。这使得从包外部添加不需要的依赖项变得更加困难。因此,只需将组件的所有类放入同一包中,并仅将组件外部我们需要的那些类公开即可。问题解决了?

我认为不是。

如果我们的组件中需要子包,那将不起作用。我们必须公开子包中的类,以便它们可以在其他子包中使用,从而将它们向全世界开放。

我不想局限于我的组件使用单个软件包!也许我的组件有一些子组件,我不想暴露给外界。或者,也许我只想将类分类到单独的存储桶中,以使代码库更易于浏览。我需要那些分包!

因此,是的,程序包私有的可见性有助于避免不必要的依赖关系,但是就其本身而言,它充其量只是一个半成品的解决方案。

清晰边界的方法

我们不能单靠包私有的可见性。让我们看一下一种新方法,该方法使用智能包结构,尽可能实现包私有可见性,无法实现的适用ArchUnit。保持我们的代码库避免不必要的依赖关系。

示例用例

我们将在示例用例旁边讨论该方法。假设我们正在构建一个如下所示的计费组件:

具有外部和内部依赖性的模块。开票组件将发票计算器暴露在外面。发票计算器生成特定客户和时间段的发票。为了使发票计算器正常工作,它需要在日常批处理作业中同步来自外部订单系统的数据。此批处理作业从外部源中提取数据并将其放入数据库中。

我们的组件包含三个子组件:发票计算器,批处理作业和数据库代码。所有这些组件都可能包含几个类。发票计算器是一个公共组件,而批处理作业和数据库组件是内部组件,不应从计费组件外部进行访问。

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和清晰的边界的组件,从而避免了很多麻烦。

 

                   

1
猜你喜欢