Spring Boot 集成 Spring Modulith:搭一个能拆微服务的模块化单体

先说点实际的

干过几年企业级项目的兄弟应该都有体会:单体应用在业务初期是真香,部署简单、调试方便、没那么多弯弯绕。但业务一旦堆起来,代码就慢慢失控了。包结构光靠约定维护是不行的------今天你调我的 Service,明天我改你的 Repository,同一个订单表被三个模块的 SQL 轮番蹂躏。到这种时候,团队里总会有人提一句:要不要拆分微服务?

我的态度一直是:先别急。

微服务不是银弹,尤其在业务边界还没摸清、运维能力又撑不住的时候,拆了只会死得更快。但单体的混乱也确实要解决。Spring Modulith 解决的就是这个中间地带的问题------先不拆,但把单体内的边界划清楚、用工具守住,等哪天真要拆了,心里也有底。

Spring Modulith 是什么思路

简单说,它就是把单体应用看成一组模块的集合。每个模块对应一个业务能力,模块之间只能通过公开接口或者领域事件打交道,不能直接穿透到别人的内部实现里去。

它给开发者提供的东西可以归纳成三个:

  • 用包结构定义模块。根包下的每个子包就是一个模块,模块内的类默认都被视为内部实现,只有显式暴露出来的才能被其他模块碰。
  • 自动化边界校验。写个测试类,Spring Modulith 会自动去扫代码依赖关系。谁违规引用了别人的 internal 类,测试直接红给你看。不用靠 Code Review 去人肉守规矩。
  • 事件驱动模块通信。在 Spring 事件机制基础上做了一层增强,让事件可以跟事务绑定、支持异步处理、还能持久化记录,给最终一致性兜底。

有了这三个能力,"模块化"就不再只是一句口号,而是能在 CI 里自动执行、在代码里体现的硬约束。

集成到 Spring Boot

引入依赖

在 pom.xml 里加就行:

xml 复制代码
<dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-starter-core</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-starter-test</artifactId>
    <scope>test</scope>
</dependency>

建议配合 Spring Boot 的 BOM 一起用,这样不用自己操心版本。事件持久化后面会讲到,用得上的话再引入 spring-modulith-starter-jpa。

包结构就是模块边界

Spring Modulith 的规则很简单:根包下的直接子包就是一个模块。拿电商举例,比如根包是 com.example.shop:

复制代码
com.example.shop
├── order
│   ├── Order.java
│   ├── OrderService.java
│   ├── OrderController.java
│   └── internal
│       └── OrderRepository.java
├── report
│   ├── ReportService.java
│   ├── ReportController.java
│   └── internal
│       └── ReportRepository.java
└── shared
    ├── Money.java
    └── ...

这里 order、report、shared 就是三个模块。internal 包里的东西是模块的私密内容,其他模块绝对不能碰。想给模块加限制,可以在包上写注解:

java 复制代码
@org.springframework.modulith.ApplicationModule(
    name = "order",
    allowedDependencies = {"shared"}
)
package com.example.shop.order;

这么一写,就相当于声明了:order 模块只能依赖 shared,要敢去碰 report,测试直接失败。

模块之间怎么通信

模块间通信主要就两种方式:直接调公开 Service,或者发领域事件。Spring Modulith 在 Spring 原生的 ApplicationEventPublisher 和 @EventListener 之上做了增强,事件可以跟事务绑定,还能异步处理。

事务绑定是重点

@ApplicationModuleListener 是 Modulith 提供的事件监听注解,它的关键特性是事务绑定。默认情况下,事件监听器在事件发布事务提交之后才会执行。这意味着如果主事务回滚了,事件不会被处理。这一点对有数据一致性要求的业务场景特别有用。

需要说明的是,@ApplicationModuleListener 本身不是 异步的。Spring 的默认事件发布机制是同步阻塞的,Modulith 的事件处理器默认也是同步执行的(除非你同时加 @Async 或者在配置里专门开启异步)。但不管同步还是异步,事务绑定和事务边界与事件发布事务一致的机制是它和原生 Spring 事件的最大区别。也就是说,如果监听器抛异常,默认情况下发布方的事务也会回滚。

举个例子。订单模块里定义事件:

java 复制代码
public record OrderCompletedEvent(Long orderId, double amount) {}

订单服务里发布:

java 复制代码
@Service
public class OrderService {

    private final ApplicationEventPublisher events;

    public OrderService(ApplicationEventPublisher events) {
        this.events = events;
    }

    @Transactional
    public void completeOrder(Long orderId) {
        // 业务逻辑...
        events.publishEvent(new OrderCompletedEvent(orderId, 100.0));
    }
}

报表模块监听:

java 复制代码
@Service
public class ReportService {

    @ApplicationModuleListener
    public void onOrderCompleted(OrderCompletedEvent event) {
        // 更新报表数据...
    }
}

这个监听器会在 completeOrder 的事务提交之后执行,而且和发布方共享同一个事务。如果不希望共享事务,可以显式改成 @Transactional(propagation = Propagation.REQUIRES_NEW)。

领域事件设计

跨模块交互,尽量走领域事件而不是直接调对方的 Service 或 Repository。比如:

  • OrderPlacedEvent------下单成功,触发库存扣减
  • PaymentReceivedEvent------支付完成,更新订单状态
  • OrderCompletedEvent------订单完成,通知报表模块

每个模块只管自己关心的那些事件,不用知道对方内部怎么实现的。这样两个模块之间就不存在编译期的强依赖了。

数据库怎么隔离

很多人问:模块化单体要不要做数据库隔离?我的答案是:必须做,但要循序渐进。

物理上,数据表依然可以在同一个数据库里。但逻辑上,每个模块应该只访问自己的表。比如订单模块碰 t_order、t_order_item,报表模块只碰 t_sales_report。跨模块的数据访问,一律走事件或公开 API,绝不能直接去查别人的表。

这样做最大的好处是:模块内部的数据结构想怎么改就怎么改,不用通知别的模块。这点在后期拆分微服务的时候尤其值钱------数据库都是各管各的,拆分时不需要做大规模的数据迁移。

当然,历史遗留系统往往已经有表被多个模块共享了。这种情况下,我建议先改代码,把跨模块的表访问从代码层面切断,然后再考虑物理拆表。不要一上来就搞大重构,容易出事故。

公开 API 怎么设计

Spring Modulith 里,模块对外可访问的部分就是包内非 internal 的类。实践中我一般这么做:

  • 对外暴露接口 ,比如 OrderQueryService,里面只定义业务方法;
  • 实现类放在 internal 包里,外部模块永远看不到;
  • 传输对象用 DTO,不要直接把实体类丢出去;
  • 关键状态变化用领域事件广播出去。

示例:

java 复制代码
public interface OrderQueryService {
    OrderSummary getOrderSummary(Long orderId);
}

实现类放 internal 包里,外部模块想用只能面向接口编程。配合 @ApplicationModule 的 allowedDependencies 声明,模块之间的访问路径就被双重锁死了。

自动化验证模块结构

Spring Modulith 最打动我的一点,就是它把架构规则做成了自动化测试。不需要组织一堆人天天盯着代码审查,机器会替你做这件事。

在测试目录下建一个测试类,放在和根包同名的包下:

java 复制代码
@org.springframework.modulith.test.ApplicationModuleTest
class ShopApplicationModuleTest {
    // 测试方法可以为空
}

@ApplicationModuleTest 会自动触发依赖分析,校验以下内容:

  • 模块间依赖是否符合 allowedDependencies 的声明
  • 有没有循环依赖
  • 有没有模块碰了别人的 internal 包
  • 有没有非法注入其他模块的 Bean

一旦违规,测试就失败并给出详细的违规报告。比如:

复制代码
Invalid dependency from 'order' to 'report':
  order.OrderService --> report.internal.ReportRepository

这种构建期的自动化守卫,比什么都管用。代码能不能合进主干,机器说了算。

一个实战例子:订单和报表解耦

前面说的比较多是概念和 API,走一个完整例子更能说明问题。

原来的一团乱麻

我们之前的项目里,报表模块为了统计每日销售额,直接去操作订单模块的 Repository,几个模块共享一张 t_order 表。后来订单表加了一个字段,报表模块的 SQL 直接崩了。就是因为这种强耦合,我们决定引入 Spring Modulith。

改造后的模块划分

根包 com.example.shop 下分三个模块:order、report、shared。订单模块用 @ApplicationModule 声明只允许依赖 shared。报表模块不直接依赖订单模块,只监听订单模块发出来的事件。

定义领域事件

java 复制代码
public record OrderCompletedEvent(Long orderId, double totalAmount) {}

订单服务里发布事件:

java 复制代码
@Service
public class OrderService {

    private final ApplicationEventPublisher events;

    public OrderService(ApplicationEventPublisher events) {
        this.events = events;
    }

    @Transactional
    public void completeOrder(Long orderId) {
        // ... 完成订单业务逻辑
        events.publishEvent(new OrderCompletedEvent(orderId, totalAmount));
    }
}

报表模块异步更新

java 复制代码
@Service
public class SalesReportService {

    private final SalesReportRepository reportRepository;

    SalesReportService(SalesReportRepository reportRepository) {
        this.reportRepository = reportRepository;
    }

    @ApplicationModuleListener
    public void handleOrderCompleted(OrderCompletedEvent event) {
        SalesReport report = new SalesReport();
        report.setOrderId(event.orderId());
        report.setAmount(event.totalAmount());
        report.setSalesDate(LocalDate.now());
        reportRepository.save(report);
    }
}

因为事件处理器和主事务绑定,所以报表保存失败的话,订单完成操作也会回滚。数据一致性由框架保证,不用自己写补偿逻辑。

集成测试

java 复制代码
@ApplicationModuleTest
class OrderReportIntegrationTest {

    @Autowired
    ApplicationEventPublisher events;

    @Autowired
    SalesReportRepository reportRepository;

    @Test
    void orderCompletedEventUpdatesReport() {
        events.publishEvent(new OrderCompletedEvent(1L, 99.99));

        // 等待异步处理完成
        assertThat(reportRepository.findByOrderId(1L)).isPresent();
    }
}

Spring Modulith 还给事件测试提供了 Scenario API,能精确等待某个事件处理完成。用起来大概是这样的:

java 复制代码
@ApplicationModuleTest
class OrderReportScenarioTest {

    @Autowired
    Scenario scenario;

    @Test
    void orderCompletionTriggersReport() {
        scenario.stimulate(() -> events.publishEvent(new OrderCompletedEvent(1L, 10.0)))
                .andWaitForEventOfType(OrderCompletedEvent.class)
                .toArrive();

        assertThat(reportRepository.findByOrderId(1L)).isPresent();
    }
}

比自己在测试里写 Thread.sleep() 靠谱多了。

从模块化单体到微服务

有人说,模块化单体是"为不拆微服务找借口"。我不这么看。做模块化单体的目的,恰恰是为了让微服务的拆分风险更可控。

拆分的路线可以按下面这样走:

  1. 先定模块,把代码按业务能力分组,用 Modulith 把依赖规则立起来,切断不合理的跨模块调用。事件驱动替代直接调用。
  2. 再隔离数据,让每个模块只碰自己的表。跨模块数据交换走公开 API 或事件,数据库物理拆不拆暂时不做。
  3. 拆出独立部署单元,把某个模块的代码、表结构、资源全部拷出去,单独建一个 Spring Boot 应用跑起来。
  4. 暴露远程接口,在新服务里提供 REST 或者 gRPC 接口,原来用事件通信的部分切换成 Kafka 或者 RabbitMQ。
  5. 流量逐步切,通过网关把对旧模块的请求转发到新服务,最后在单体里删掉该模块的实现。

Spring Modulith 的事件持久化功能在拆分过程中很有用------事件记录都存在数据库里,即使中间发生故障导致消息丢失,也能根据记录做恢复或回放。

跟 Maven 多模块的区别

有同事问过我:Maven 多模块不是也能做模块化吗?为什么还要整这玩意?

区别是本质性的。

Maven 多模块是物理隔离 ,每个模块是一个独立的编译单元。你在 pom.xml 里声明依赖 A 依赖 B,编译层面上确实能控制 A 不能直接引用 B 里的类------前提是你根本不声明那个依赖。但一旦声明了,A 就能访问 B 里所有 public 的类,包括那些你并不想对外暴露的内部实现类。架构约束在编译之后就不存在了。

Spring Modulith 是逻辑约束,它是在同一编译单元内,基于包结构和专门的测试工具来实现模块边界强制的。它不仅能管住 "谁能访问谁" 的类依赖,还能感知 Spring Bean 的注入关系、事件发布关系,这种层面的约束 Maven 多模块做不到。

当然,两者可以组合。你完全可以既用 Maven 多模块管理构建层次,又用它做更细的包边界控制和依赖校验。但从成本角度考虑,单体阶段一个简单的 Maven 构建加上 Spring Modulith 通常比一上来就拆成十几个 Maven 子模块要务实得多。

落地要踩的坑

最后聊几个我们实际干的时候踩过的坑。

模块别拆太细了。 我见过有人把 util 也当模块搞,里面放一堆静态工具类,最后得出来的就是模块满天飞、事件满天飞,绕来绕去比原来还难维护。模块还是要跟业务能力走,粒度尽量粗一点。

别在业务代码里偷懒直接注入别的模块的 Repository。 这在项目初期可能感受不到问题,等业务复杂起来了,想改数据模型的时候就会发现动弹不得,到处都被牵着走。

领域事件的设计要重视。 事件名字应该是"已经发生的过去",比如 OrderPlacedEvent、OrderCompletedEvent,而不是"命令式"的 PlaceOrderCommand------那是给命令总线用的。事件对象应该不可变,只携带必要的数据。

循环依赖不只出现在类层面,事件层面也会有。 A 模块的事件触发 B 模块,B 模块事件再触发 A 模块,这种环路如果不去梳理,最终会演变成"事件风暴",系统响应越来越慢。事件设计阶段就最好画个流向图,注意环路。

数据库隔离不要指望一步到位。 历史系统里已经存在共享表很正常,先切断代码访问,再想办法拆表。如果业务改造比较大,先让新代码走新表,旧表和旧逻辑暂时并存过渡,稳一点总没错。

最后

Spring Modulith 或者说模块化单体这条路,给大多数单体应用提供了一个更现实的演进方式。它特别适合那种还有救但已经开始混乱的单体项目------在一个合理的约束下,逐步把模块边界理清楚,把跨模块的耦合降下来。后续如果真要拆微服务,也能更加从容,因为边界已经画好了,说白了你就省了大半前期工作。

如果你的项目现在还不需要上分布式,但又不想继续忍受"一锅乱炖"的代码结构,那 Spring Modulith 就值得试试。


官网地址:https://farerboy.com/


相关推荐
今年下半年3 小时前
【Spring Boot】集成 Elasticsearch:中文分词与关键字高亮对接文档
spring boot·elasticsearch·中文分词
m0_587383004 小时前
GogoAI自助健身门店解决方案核心技术实现与落地部署全流程实操指南
运维·spring boot·信息可视化·架构·需求分析
爱编程的小白L4 小时前
基于 SpringBoot 的校园停车场收费管 理系统的设计与实现
spring boot
十年Java程序媛4 小时前
Redis 分布式锁 + GZIP 压缩缓存踩坑|高并发缓存真实线上复盘
java·开发语言·spring boot·redis·分布式·缓存·面试
凤山老林4 小时前
Spring Boot 集成 Redis ZSet 实现实时排行榜、动态排名、同分加权与多维度榜单实战
spring boot·redis·后端
流烟默5 小时前
Spring Cloud 微服务 Sentinel 热点参数与授权规则集成指南(附架构建议)
spring cloud·微服务·架构·sentinel
小陈phd5 小时前
深入理解agent学习笔记(五)——KV Cache友好的上下文设计
笔记·学习·spring
IT研究室5 小时前
最新计算机毕业设计选题推荐-基于spring boot的教育学习平台-网站-文档指导-Java-springboot
java·spring boot·课程设计
亚林瓜子5 小时前
Spring Boot 集成 ip2region 离线 IP 库实战指南
java·spring boot·后端·ip·ip2region