资讯动态

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

发布时间:2026/10/14 19:46:21 来源:尧图企业网站定制
先说点实际的干过几年企业级项目的兄弟应该都有体会单体应用在业务初期是真香部署简单、调试方便、没那么多弯弯绕。但业务一旦堆起来代码就慢慢失控了。包结构光靠约定维护是不行的——今天你调我的 Service明天我改你的 Repository同一个订单表被三个模块的 SQL 轮番蹂躏。到这种时候团队里总会有人提一句要不要拆分微服务我的态度一直是先别急。微服务不是银弹尤其在业务边界还没摸清、运维能力又撑不住的时候拆了只会死得更快。但单体的混乱也确实要解决。Spring Modulith 解决的就是这个中间地带的问题——先不拆但把单体内的边界划清楚、用工具守住等哪天真要拆了心里也有底。Spring Modulith 是什么思路简单说它就是把单体应用看成一组模块的集合。每个模块对应一个业务能力模块之间只能通过公开接口或者领域事件打交道不能直接穿透到别人的内部实现里去。它给开发者提供的东西可以归纳成三个用包结构定义模块。根包下的每个子包就是一个模块模块内的类默认都被视为内部实现只有显式暴露出来的才能被其他模块碰。自动化边界校验。写个测试类Spring Modulith 会自动去扫代码依赖关系。谁违规引用了别人的 internal 类测试直接红给你看。不用靠 Code Review 去人肉守规矩。事件驱动模块通信。在 Spring 事件机制基础上做了一层增强让事件可以跟事务绑定、支持异步处理、还能持久化记录给最终一致性兜底。有了这三个能力模块化就不再只是一句口号而是能在 CI 里自动执行、在代码里体现的硬约束。集成到 Spring Boot引入依赖在pom.xml里加就行dependencygroupIdorg.springframework.modulith/groupIdartifactIdspring-modulith-starter-core/artifactId/dependencydependencygroupIdorg.springframework.modulith/groupIdartifactIdspring-modulith-starter-test/artifactIdscopetest/scope/dependency建议配合 Spring Boot 的 BOM 一起用这样不用自己操心版本。事件持久化后面会讲到用得上的话再引入spring-modulith-starter-jpa。包结构就是模块边界Spring Modulith 的规则很简单根包下的直接子包就是一个模块。拿电商举例比如根包是com.example.shopcom.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包里的东西是模块的私密内容其他模块绝对不能碰。想给模块加限制可以在包上写注解org.springframework.modulith.ApplicationModule(nameorder,allowedDependencies{shared})packagecom.example.shop.order;这么一写就相当于声明了order模块只能依赖shared要敢去碰report测试直接失败。模块之间怎么通信模块间通信主要就两种方式直接调公开 Service或者发领域事件。Spring Modulith 在 Spring 原生的ApplicationEventPublisher和EventListener之上做了增强事件可以跟事务绑定还能异步处理。事务绑定是重点ApplicationModuleListener是 Modulith 提供的事件监听注解它的关键特性是事务绑定。默认情况下事件监听器在事件发布事务提交之后才会执行。这意味着如果主事务回滚了事件不会被处理。这一点对有数据一致性要求的业务场景特别有用。需要说明的是ApplicationModuleListener本身不是异步的。Spring 的默认事件发布机制是同步阻塞的Modulith 的事件处理器默认也是同步执行的除非你同时加Async或者在配置里专门开启异步。但不管同步还是异步事务绑定和事务边界与事件发布事务一致的机制是它和原生 Spring 事件的最大区别。也就是说如果监听器抛异常默认情况下发布方的事务也会回滚。举个例子。订单模块里定义事件publicrecordOrderCompletedEvent(LongorderId,doubleamount){}订单服务里发布ServicepublicclassOrderService{privatefinalApplicationEventPublisherevents;publicOrderService(ApplicationEventPublisherevents){this.eventsevents;}TransactionalpublicvoidcompleteOrder(LongorderId){// 业务逻辑...events.publishEvent(newOrderCompletedEvent(orderId,100.0));}}报表模块监听ServicepublicclassReportService{ApplicationModuleListenerpublicvoidonOrderCompleted(OrderCompletedEventevent){// 更新报表数据...}}这个监听器会在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不要直接把实体类丢出去关键状态变化用领域事件广播出去。示例publicinterfaceOrderQueryService{OrderSummarygetOrderSummary(LongorderId);}实现类放internal包里外部模块想用只能面向接口编程。配合ApplicationModule的allowedDependencies声明模块之间的访问路径就被双重锁死了。自动化验证模块结构Spring Modulith 最打动我的一点就是它把架构规则做成了自动化测试。不需要组织一堆人天天盯着代码审查机器会替你做这件事。在测试目录下建一个测试类放在和根包同名的包下org.springframework.modulith.test.ApplicationModuleTestclassShopApplicationModuleTest{// 测试方法可以为空}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。报表模块不直接依赖订单模块只监听订单模块发出来的事件。定义领域事件publicrecordOrderCompletedEvent(LongorderId,doubletotalAmount){}订单服务里发布事件ServicepublicclassOrderService{privatefinalApplicationEventPublisherevents;publicOrderService(ApplicationEventPublisherevents){this.eventsevents;}TransactionalpublicvoidcompleteOrder(LongorderId){// ... 完成订单业务逻辑events.publishEvent(newOrderCompletedEvent(orderId,totalAmount));}}报表模块异步更新ServicepublicclassSalesReportService{privatefinalSalesReportRepositoryreportRepository;SalesReportService(SalesReportRepositoryreportRepository){this.reportRepositoryreportRepository;}ApplicationModuleListenerpublicvoidhandleOrderCompleted(OrderCompletedEventevent){SalesReportreportnewSalesReport();report.setOrderId(event.orderId());report.setAmount(event.totalAmount());report.setSalesDate(LocalDate.now());reportRepository.save(report);}}因为事件处理器和主事务绑定所以报表保存失败的话订单完成操作也会回滚。数据一致性由框架保证不用自己写补偿逻辑。集成测试ApplicationModuleTestclassOrderReportIntegrationTest{AutowiredApplicationEventPublisherevents;AutowiredSalesReportRepositoryreportRepository;TestvoidorderCompletedEventUpdatesReport(){events.publishEvent(newOrderCompletedEvent(1L,99.99));// 等待异步处理完成assertThat(reportRepository.findByOrderId(1L)).isPresent();}}Spring Modulith 还给事件测试提供了ScenarioAPI能精确等待某个事件处理完成。用起来大概是这样的ApplicationModuleTestclassOrderReportScenarioTest{AutowiredScenarioscenario;TestvoidorderCompletionTriggersReport(){scenario.stimulate(()-events.publishEvent(newOrderCompletedEvent(1L,10.0))).andWaitForEventOfType(OrderCompletedEvent.class).toArrive();assertThat(reportRepository.findByOrderId(1L)).isPresent();}}比自己在测试里写Thread.sleep()靠谱多了。从模块化单体到微服务有人说模块化单体是为不拆微服务找借口。我不这么看。做模块化单体的目的恰恰是为了让微服务的拆分风险更可控。拆分的路线可以按下面这样走先定模块把代码按业务能力分组用 Modulith 把依赖规则立起来切断不合理的跨模块调用。事件驱动替代直接调用。再隔离数据让每个模块只碰自己的表。跨模块数据交换走公开 API 或事件数据库物理拆不拆暂时不做。拆出独立部署单元把某个模块的代码、表结构、资源全部拷出去单独建一个 Spring Boot 应用跑起来。暴露远程接口在新服务里提供 REST 或者 gRPC 接口原来用事件通信的部分切换成 Kafka 或者 RabbitMQ。流量逐步切通过网关把对旧模块的请求转发到新服务最后在单体里删掉该模块的实现。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/

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑