Bootiful Spring Boot 3.4: Spring Modulith

工程 | Josh Long | 2024 年 11 月 24 日 | ...

当 Spring Boot 首次问世时,我会在讲座中告诉人们,Spring Boot 就像是与 Spring 团队结对编程。它提供了约定优于配置的能力,让你可以快速搭建基础设施并开始工作。但它并没有提供太多的架构指导。可以说,在如何组织应用程序方面,它没有提供“轨道”。我认为这没关系,因为 Spring Boot 不是一个万金油。你可以用它来开发 CLI、单体应用、Web 应用、批处理作业、流处理和集成处理器、微服务、GRPC 服务、Kubernetes Operator 等等。任何服务器端的东西都可以。它一直运作良好。而且在大多数情况下,用 Spring Boot 很难把自己搞得一团糟。CLI、微服务、流处理程序和 Kubernetes Operator 通常都非常专注,因此规模很小。我认为麻烦在于当你试图扩展单体应用时。在这种情况下,有很多选择,但指导很少。

Spring Modulith 应运而生,它是一个旨在在开发过程中提供架构指导的框架,指导形式是基于 ArchUnit 的测试,并在运行时提供基础设施来支持我们渴望的模块的清晰分解。如果你使用 Spring Modulith 编写代码,将很难得到一个结构不良且不利于扩展代码和工作团队的代码库。如果说哪个框架能让你“走上正轨”,我认为非它莫属了!

Spring Modulith 中有太多令人惊叹的新功能,我无法一一介绍,但简要概括如下:

  • 支持嵌套应用程序模块和外部应用程序模块贡献。
  • 通过 JUnit Jupiter 扩展优化集成测试执行。
  • 新的删除和归档事件发布完成模式。
  • 按 ID 完成事件发布,显著提高性能。
  • 在基于 JDBC 的事件发布注册表中支持 MariaDB、Oracle DB 和 Microsoft SQL Server。
  • 将事件外部化到 Spring 的 MessageChannel 抽象中,例如触发 Spring Integration 流。
  • 自动提取 Javadoc 以包含在生成的应用程序模块画布中。
  • 一个包含所有生成文档的聚合文档。

我想在本版本中介绍我最喜欢的一个新功能:通过将事件发布到 Spring Integration MessageChannel 来外部化事件的能力。充分披露:我这是在自卖自夸,因为我曾为此功能做出了贡献。但至少你知道我没撒谎:这是我最喜欢的功能之一 :D

想法是,在 Spring Modulith 中,你有一些约定来定义“模块”,实际上它们只是与 Spring Boot 应用程序类相邻的根包。因此,给定应用程序包 a.b.c,那么 a.b.c.foo 将是 foo 模块,a.b.c.bar 将是 bar 包。到目前为止,一切顺利?

目标是减少变更的影响。在一个地方做出变更,你的变更不应该像蜘蛛网里的苍蝇一样波及整个代码库。我们通过利用语言的私有修饰符来实现这一点,当这还不够时,就编写测试。

package com.example.bootiful_34;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.modulith.core.ApplicationModules;
import org.springframework.modulith.docs.Documenter;

@SpringBootTest
class Bootiful34ApplicationTests {

	@Test
	void contextLoads() {
		var am = ApplicationModules.of(Bootiful34Application.class);
		am.verify();

		System.out.println(am);

		new Documenter(am).writeDocumentation();
	}

}

运行此测试以确认我们没有缠绕,并且没有将一个模块的模块私有实现包中的内容泄露到另一个模块。(它还会打印出我们的模块的逻辑结构到 CLI,然后甚至会生成一些表示架构状态的 PlantUML 图,并将它们转储到 target/spring-modulith-docs 中,但这与此无关...)

当我运行测试时,我得到了以下输出:

2024-11-24T21:16:07.341-08:00  INFO 46642 --- [bootiful-34] [           main] com.tngtech.archunit.core.PluginLoader   : Detected Java version 23.0.1
# Ai
> Logical name: ai
> Base package: com.example.bootiful_34.ai
> Excluded packages: none
> Direct module dependencies: none
> Spring beans:
o ….AiConfiguration
o org.springframework.ai.chat.client.ChatClient
o org.springframework.ai.model.function.FunctionCallback

# Batch
> Logical name: batch
> Base package: com.example.bootiful_34.batch
> Excluded packages: none
> Direct module dependencies: none
> Spring beans:
o ….BatchConfiguration
o ….StepOneConfiguration
o ….StepTwoConfiguration
o org.springframework.batch.core.Job
o org.springframework.batch.core.Step
o org.springframework.batch.item.ItemWriter
o org.springframework.batch.item.database.JdbcCursorItemReader
o org.springframework.batch.item.file.FlatFileItemReader
o org.springframework.batch.item.queue.BlockingQueueItemReader
o org.springframework.batch.item.queue.BlockingQueueItemWriter
o org.springframework.batch.item.support.CompositeItemReader

# Boot
> Logical name: boot
> Base package: com.example.bootiful_34.boot
> Excluded packages: none
> Direct module dependencies: none
> Spring beans:
o ….GracefulController

# Data
> Logical name: data
> Base package: com.example.bootiful_34.data
> Excluded packages: none
> Direct module dependencies: none
> Spring beans:
o ….CustomerRepository
o ….LocaleEvaluationContextExtension

# Framework
> Logical name: framework
> Base package: com.example.bootiful_34.framework
> Excluded packages: none
> Direct module dependencies: none
> Spring beans:
o ….DefaultNoOpMessageProvider
o ….FallbackDemoConfiguration
o ….SimpleMessageProvider
o ….SophisticatedMessageProvider
o org.springframework.boot.ApplicationRunner

# Integration
> Logical name: integration
> Base package: com.example.bootiful_34.integration
> Excluded packages: none
> Direct module dependencies: none
> Spring beans:
o ….ControlBusConfiguration
+ ….ControlBusConfiguration$MyOperationsManagedResource
o org.springframework.integration.dsl.DirectChannelSpec
o org.springframework.integration.dsl.IntegrationFlow

# Modulith
> Logical name: modulith
> Base package: com.example.bootiful_34.modulith
> Excluded packages: none
> Direct module dependencies: none
> Spring beans:
o ….ChannelsConfiguration
o ….consumer.ConsumerConfiguration
o ….producer.MessagePublishingApplicationRunner
o org.springframework.integration.dsl.DirectChannelSpec
o org.springframework.integration.dsl.IntegrationFlow

# Security
> Logical name: security
> Base package: com.example.bootiful_34.security
> Excluded packages: none
> Direct module dependencies: none
> Spring beans:
o ….SecuredController
o ….SecurityConfiguration
o org.springframework.security.core.userdetails.UserDetailsService
o org.springframework.security.web.SecurityFilterChain

# Testing
> Logical name: testing
> Base package: com.example.bootiful_34.testing
> Excluded packages: none
> Direct module dependencies: framework
> Spring beans:
o ….GreetingsController

不错!一个模块中的类型可以引用和注入另一个模块中的类型(但不能引用另一个模块的嵌套包中的类型,因为那些被认为是模块私有的实现细节)。这可以工作,但请记住,每次将接口导出到另一个模块并使其公开时,都需要维护它。就我而言,我尽可能尝试使用事件处理来处理集成。消息传递和集成是我的爱好。这对架构有好处,对灵魂也有好处。有很多模式,所有这些都取决于这个不起眼的消息。看看 Martin Fowler 在 2017 年写的这篇博客文章,名为你说的事件驱动是什么意思? 它着眼于系统和服务中消息传递和集成的各种用法,所有这些都始于不起眼的事件或消息。Spring 有一个事件发布器,自 2000 年代早期的 Spring Framework 1.1 起就一直存在于 Spring 中了!

这是我们的事件

package com.example.bootiful_34.modulith;

import org.springframework.modulith.events.Externalized;

import java.time.Instant;

@Externalized("events")
public record CrossModuleEvent(Instant instant) {
}

这是事件产生的结果

package com.example.bootiful_34.modulith.producer;

import com.example.bootiful_34.modulith.CrossModuleEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.util.concurrent.TimeUnit;

@Service
@Transactional
class MessagePublishingApplicationRunner {

	private final ApplicationEventPublisher publisher;

	MessagePublishingApplicationRunner(ApplicationEventPublisher publisher) {
		this.publisher = publisher;
	}

	@Scheduled(initialDelay = 1, timeUnit = TimeUnit.SECONDS)
	public void run() {
		this.publisher.publishEvent(new CrossModuleEvent(Instant.now()));
	}

}

这是事件的消费者

package com.example.bootiful_34.modulith.consumer;

import com.example.bootiful_34.modulith.CrossModuleEvent;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.EventListener;

@Configuration
class ConsumerConfiguration {

	@EventListener
	void consume(CrossModuleEvent crossModuleEvent) {
		System.out.println("got the event " + crossModuleEvent);
	}

}

是不是很好?

您可以使用此事件发布器发布事件,它们将同步分派到应用程序上下文中的另一个 Bean。但是,以可伸缩的方式使用它存在一些问题。首先,它们是同步分派的,因此您需要使用 Spring 的 @Async 注解来在另一个线程中调用它们。其次,一旦您这样做了,您就不再与生产者在同一线程中,这意味着您不在同一事务中。如果您想要那样,没有简单的方法可以恢复相同的事务性。尽管如此,您可以确保即使消息因任何原因丢失或丢失(断电、数据库无法连接等等),它也会被记录并稍后进行协调。这称为outbox 模式。使用 Spring Modulith 设置起来很简单!只需将以下两个属性添加到您的属性文件中即可:

spring.modulith.events.republish-outstanding-events-on-restart=true
spring.modulith.events.jdbc.schema-initialization.enabled=true

当 Spring Modulith 启动时,它会安装一个表 event_publications,该表跟踪事件的分派以及它们是否完成。如果重新启动服务,并且 Spring Modulith 发现某些事件从未完成,它将再次运行它们!太棒了。

但如果我也想将这些事件发布给其他微服务和系统怎么办?很简单!只需设置您选择的分发结构——Spring for Apache Kafka、Spring AMQP 等,然后对您要发布的事件使用 @Externalized 注解。@Externalized 注解使用一个 schema 来告诉 Spring Modulith 如何将此事件路由到外部。对于 Apache Kafka,您只需指定 Apache Kafka Broker 中 topic 的字符串名称。对于 RabbitMQ,及其目的地和路由键,您需要指定 destination::routing-key。现在,该事件将分派到同一代码库中的其他模块,同时分派到以这种方式连接的其他系统和服务。但是,如果您想分发消息但未使用 Kafka 或 RabbitMQ 怎么办?(为什么不用呢?)嗯,不用担心,因为在 Spring Modulith 1.3 中,新增了支持将消息发布到 Spring Integration MessageChannel!一旦进入那里,如您所知,您可以使用 Spring Integration 将其发送到任何地方!当然可以发送到 Kafka 或 RabbitMQ,但也可以通过 TCP/IP、Apache Pulsar、FTP 服务器、本地文件系统、其他 SQL 数据库、NoSQL 数据库以及无数其他目的地发送。这就是重点。“集成专家喜欢这个奇怪的技巧……!”

确保您已定义 MessageChannel

package com.example.bootiful_34.modulith;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.integration.dsl.DirectChannelSpec;
import org.springframework.integration.dsl.MessageChannels;

@Configuration
class ChannelsConfiguration {

	@Bean
	DirectChannelSpec events() {
		return MessageChannels.direct();
	}

}

现在回想一下,事件上有一个 @Externalized 注解

package com.example.bootiful_34.modulith;

import org.springframework.modulith.events.Externalized;

import java.time.Instant;

@Externalized("events")
public record CrossModuleEvent(Instant instant) {
}

那是那里指定的通道名称。

所以,我们所要做的就是设置一个 Spring Integration IntegrationFlow,它消费来自该通道的消息。

package com.example.bootiful_34.modulith.consumer;

import com.example.bootiful_34.modulith.CrossModuleEvent;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.integration.core.GenericHandler;
import org.springframework.integration.dsl.IntegrationFlow;
import org.springframework.integration.file.dsl.Files;
import org.springframework.messaging.MessageChannel;

import java.io.File;

@Configuration
class IntegrationConsumerConfiguration {

	@Bean
	IntegrationFlow integrationFlow(@Value("file:${user.home}/Desktop/outbound") File destination,
			@Qualifier("events") MessageChannel incoming) {
		var destinationFolder = Files.outboundAdapter(destination).autoCreateDirectory(true);
		return IntegrationFlow.from(incoming)
			.handle((GenericHandler<CrossModuleEvent>) (payload, headers) -> payload.instant().toString())
			.handle(destinationFolder)
			.get();
	}

}

诚然,这是一个非常愚蠢的例子,因为它所做的就是将 Spring Modulith 分派到此通道中的传入事件取出,然后将消息写入用户 ~/Desktop 文件夹中的名为 outbound 的文件系统中。但这解释清楚了要点。

解耦总是有益的。

获取 Spring 新闻通讯

订阅 Spring 新闻通讯以保持联系

订阅

领先一步

VMware 提供培训和认证,助您加速进步。

了解更多

获取支持

Tanzu Spring 在一个简单的订阅中提供 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件。

了解更多

即将举行的活动

查看 Spring 社区即将举行的所有活动。

查看全部