本文提出了一种使用包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之间的这种包装分离为我们带来了两个优点:
我们没有依靠Java对包私有可见性的不足支持,而是创建了一种结构上可表达的包结构,可以很容易地通过工具来实施。
现在,让我们看一下这些软件包。
反转依赖关系以公开包专用功能
让我们从database子组件开始:
database ├── api | ├── + LineItem | ├── + ReadLineItems | └── + WriteLineItems └── internal └── o BillingDatabase
+表示一个类是公共的,o意味着它是包私有的。
该database组件公开了具有两个接口的API ReadLineItems和WriteLineItems,这两个接口分别允许从客户订单读取和写入订单项到数据库以及向数据库写入订单项。所述LineItem域类型也是API的一部分。
在内部,database子组件具有一个BillingDatabase实现两个接口的类:
@Component <b>class</b> BillingDatabase implements WriteLineItems, ReadLineItems { ... }
此实现可能有一些帮助程序类,但与本讨论无关。
请注意,这是依赖倒置原则的应用。对于database子组件,我们不在乎使用哪种数据库技术来查询数据库。
我们也来看看batchjob子组件:
batchjob └── internal └── o LoadInvoiceDataBatchJob
这个batchjob子组件完全不暴露给其他组件的API。它仅具有一个类LoadInvoiceDataBatchJob(可能还有一些帮助器类),该类每天从外部源加载数据,进行转换并将其通过WriteLineItems接口输入到计费组件的数据库中:
@Component @RequiredArgsConstructor <b>class</b> LoadInvoiceDataBatchJob { <b>private</b> <b>final</b> WriteLineItems writeLineItems; @Scheduled(fixedRate = 5000) <b>void</b> 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 <b>class</b> BillingService implements InvoiceCalculator { <b>private</b> <b>final</b> ReadLineItems readLineItems; @Override <b>public</b> 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 <b>class</b> BillingDatabaseConfiguration { }
通过@Configuration注释,我们告诉Spring这是一个配置类,它将Spring Bean发布到应用程序上下文。
@ComponentScan注解告诉Spring与Configuration配置类在通一个包下面(或子包)还有@Component所有类都要扫描包含,这些都要发布到应用程序上下文。如果不使用@ComponentScan,我们还可以在@Configuration类中使用带@Bean注释的工厂方法。
在后台,该database模块使用Spring Data JPA存储库来连接数据库。我们通过@EnableJpaRepositories注释启用这些功能。
batchjob的Configuration配置类也非常类似上述数据库组件:
@Configuration @EnableScheduling @ComponentScan <b>class</b> BillingBatchJobConfiguration { }
只有@EnableScheduling注释是不同的。我们需要这个注释来激活我们在LoadInvoiceDataBatchJobbean中的注释@Scheduled。
最后,顶级billing组件的Configuration配置类看起来很平常了:
@Configuration @ComponentScan <b>class</b> BillingConfiguration { }
通过@ComponentScan注释,此配置可确保@ConfigurationSpring拾取子组件并将其与发布的Bean一起加载到应用程序上下文中。
这样,我们不仅在包尺寸设计方面,而且在Spring配置的方面也将边界清晰地分开了。这意味着我们可以通过解决其@Configuration类别来分别定位每个组件和子组件。
例如,我们可以:
但是,我们仍然有一个问题: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 <b>public</b> @<b>interface</b> InternalPackage { }
然后,在所有内部包中,添加package-info.java带有以下注释的文件:
@InternalPackage <b>package</b> io.reflectoring.boundaries.billing.internal.database.internal; <b>import</b> io.reflectoring.boundaries.InternalPackage;
这样,所有内部包都被标记了,我们可以围绕它创建规则。
2. 验证是否无法从外部访问内部软件包
现在,我们创建一个测试,以验证内部包中的类不是从外部访问的:
<b>class</b> InternalPackageTests { <b>private</b> <b>static</b> <b>final</b> String BASE_PACKAGE = <font>"io.reflectoring"</font><font>; <b>private</b> <b>final</b> JavaClasses analyzedClasses = <b>new</b> ClassFileImporter().importPackages(BASE_PACKAGE); @Test <b>void</b> internalPackagesAreNotAccessedFromOutside() throws IOException { List<String> internalPackages = internalPackages(BASE_PACKAGE); <b>for</b> (String internalPackage : internalPackages) { assertPackageIsNotAccessedFromOutside(internalPackage); } } <b>private</b> List<String> internalPackages(String basePackage) { Reflections reflections = <b>new</b> Reflections(basePackage); <b>return</b> reflections.getTypesAnnotatedWith(InternalPackage.<b>class</b>).stream() .map(c -> c.getPackage().getName()) .collect(Collectors.toList()); } <b>void</b> assertPackageIsNotAccessedFromOutside(String internalPackage) { noClasses() .that() .resideOutsideOfPackage(packageMatcher(internalPackage)) .should() .dependOnClassesThat() .resideInAPackage(packageMatcher(internalPackage)) .check(analyzedClasses); } <b>private</b> String packageMatcher(String fullyQualifiedPackage) { <b>return</b> fullyQualifiedPackage + </font><font>".."</font><font>; } } </font>
在中internalPackages(),我们利用了反射库来收集所有带有@InternalPackage注释的软件包。
对于这些包中的每一个,我们然后调用assertPackageIsNotAccessedFromOutside()。此方法使用ArchUnit的类似DSL的API来确保“位于包外部的类不应依赖于位于包内部的类”。
如果有人在内部软件包中向公共类添加了不必要的依赖关系,则该测试现在将失败。
但是我们仍然有一个问题:如果在重构中重命名这个io.reflectoring基本包该怎么办?该测试仍将通过,因为它将在(现在不存在)io.reflectoring软件包中找不到任何软件包。如果没有要检查的软件包,它就不会失败。因此,我们需要一种使该测试重构安全的方法。
2.使架构规则重构安全
为了使我们的测试重构安全,我们验证软件包是否存在:
<b>class</b> InternalPackageTests { <b>private</b> <b>static</b> <b>final</b> String BASE_PACKAGE = <font>"io.reflectoring"</font><font>; @Test <b>void</b> internalPackagesAreNotAccessedFromOutside() throws IOException { </font><font><i>// make it refactoring-safe in case we're renaming the base package</i></font><font> assertPackageExists(BASE_PACKAGE); List<String> internalPackages = internalPackages(BASE_PACKAGE); <b>for</b> (String internalPackage : internalPackages) { </font><font><i>// make it refactoring-safe in case we're renaming the internal package</i></font><font> assertPackageExists(internalPackage); assertPackageIsNotAccessedFromOutside(internalPackage); } } <b>void</b> assertPackageExists(String packageName) { assertThat(analyzedClasses.containPackage(packageName)) .as(</font><font>"package %s exists"</font><font>, packageName) .isTrue(); } <b>private</b> List<String> internalPackages(String basePackage) { ... } <b>void</b> assertPackageIsNotAccessedFromOutside(String internalPackage) { ... } } </font>
新方法assertPackageExists()使用ArchUnit来确保所讨论的包包含在我们正在分析的类中。我们对基本包调用一次此方法,对每个内部包调用一次。现在该测试是重构安全的,并且如果我们按原样重命名软件包,它将失败。
结论
本文提出了一种使用包Package设计对Java应用程序进行模块化的有效方法,并将此方法与Spring Boot作为依赖项注入机制结合使用,与ArchUnit结合使用,以在有人添加了不允许的模块间依赖项时使测试失败。这使我们能够开发具有清晰的API和清晰的边界的组件,从而避免了很多麻烦。