领先一步
VMware 提供培训和认证,助您加速进步。
了解更多当 Spring Boot 首次问世时,我会在讲座中告诉人们,Spring Boot 就像是与 Spring 团队结对编程。它提供了约定优于配置的能力,让你可以快速搭建基础设施并开始工作。但它并没有提供太多的架构指导。可以说,在如何组织应用程序方面,它没有提供“轨道”。我认为这没关系,因为 Spring Boot 不是一个万金油。你可以用它来开发 CLI、单体应用、Web 应用、批处理作业、流处理和集成处理器、微服务、GRPC 服务、Kubernetes Operator 等等。任何服务器端的东西都可以。它一直运作良好。而且在大多数情况下,用 Spring Boot 很难把自己搞得一团糟。CLI、微服务、流处理程序和 Kubernetes Operator 通常都非常专注,因此规模很小。我认为麻烦在于当你试图扩展单体应用时。在这种情况下,有很多选择,但指导很少。
Spring Modulith 应运而生,它是一个旨在在开发过程中提供架构指导的框架,指导形式是基于 ArchUnit 的测试,并在运行时提供基础设施来支持我们渴望的模块的清晰分解。如果你使用 Spring Modulith 编写代码,将很难得到一个结构不良且不利于扩展代码和工作团队的代码库。如果说哪个框架能让你“走上正轨”,我认为非它莫属了!
Spring Modulith 中有太多令人惊叹的新功能,我无法一一介绍,但简要概括如下:
我想在本版本中介绍我最喜欢的一个新功能:通过将事件发布到 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
的文件系统中。但这解释清楚了要点。
解耦总是有益的。