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 社群即將舉行的所有活動。

檢視全部