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 来注解它们,以便在另一个线程中调用它们。其次,一旦你这样做了,你就不在生产者所在的同一个线程中,这意味着你也不在同一个事务中。如果你想要它,就没有简单的方法来获得相同的事务性。不过,你可以确保,至少如果消息因为任何原因丢失了(停电、数据库连接不上,等等),它会被记录下来并在之后进行协调。这被称为外寄模式。使用 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 注解使用一个模式来告诉 Spring Modulith 如何在外部路由此事件。对于 Apache Kafka,你只需在 Apache Kafka 代理中指定主题的字符串名称。对于 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 社区所有即将举行的活动。

查看所有