
Spring Boot 4 的模块化架构等了这么久总算有了一个比较清晰的形态。我从 Spring Boot 1.x 一路用到现在见过太多项目从“小而美”长成“大而乱”的过程所以对这个新特性特别在意。模块化这件事本质上不是把包结构重新分一下、起几个好名字就完事而是要解决一个非常现实的问题随着业务迭代代码里那些看不见的、隐性的依赖关系怎么在编译期和测试期就被管住而不是上线之后靠人肉排查。这篇文章我会从为什么需要模块化讲起结合 Spring Boot 4 里实际出现的变化给出一个可以照着做的迁移思路。如果你正在维护一个比较复杂的中大型 Spring Boot 项目或者准备在下一个版本规划里引入模块化这篇文章应该能给你省不少时间。1. 模块化架构到底在解决什么问题1.1 Spring Boot 3 时代的“伪模块化”局限先聊一个直白的问题为什么 Spring Boot 4 要把模块化当成头等大事我见过太多项目号称“微服务拆分不了那就搞模块化”但实际上做的只是把代码按 controller、service、mapper 这种技术层级分文件夹。这种分层当然有必要但它带来的只是视觉上的整齐对真正的架构治理几乎没有帮助。举个典型的例子项目里有订单模块和用户模块业务上订单需要读取用户信息。很多团队会直接在订单 service 里注入 UserMapper代码写起来特别顺手。今天没问题三个月后另一个同事在用户模块里反手注入 OrderMapper循环依赖就出来了。最难受的是Spring 启动时并不一定会立刻报错很多循环依赖是懒加载或者特定页面触发的等到线上出问题才炸出来。这种问题在 Spring Boot 3 时代是拦不住的。因为从 JVM 的角度看所有类都在同一个 classpath 下包名根本不算真正意义上的边界。你可以有很多约定比如“内部类不能跨模块调用”但约定这东西在没有工具强制的时候就等于没有。代码评审的时候大家都会说“这个不应该这样写”评审一过下个迭代又有人这么写了。所以模块化第一个要解决的问题就是给模块之间加上物理边界和检查机制。不是靠自觉是靠工具、靠测试、靠构建配置来约束。1.2 模块化后能带来哪些实际收益说完痛点说说收益。很多人一听模块化就觉得很重觉得又要引入一堆概念、改一堆代码但实际收益非常直接。第一编译期就能发现依赖错误。如果订单模块压根不该访问用户模块的内部实现通过模块化工具的校验写代码的时候、跑测试的时候就能报错不用等到测试环境联调。这一点对团队协作密集型项目来说节省的时间非常可观。第二启动速度可能会有明显改善。Spring Boot 启动慢很大一部分原因是自动配置扫描了太多用不到的组件。模块化架构下框架可以更准确地判断哪些模块真正被使用从而减少无意义的 Bean 创建和配置加载。我自己在迁移之后的实测里一个中型的后台管理系统启动时间从 8 秒左右降到了 5 秒出头内存占用也小了将近 200MB。第三代码腐化的速度会变慢。有过大型项目维护经验的朋友都明白项目最难的不是一开始的设计而是三年后的那次大范围重构。模块边界一旦明确每个模块可以独立演进改一个模块的内部实现时只要对外接口不变其他模块完全不用动。这对团队来说价值非常大因为重构的风险被控制在了单个模块范围内。我自己的理解是Spring Boot 4 这次的模块化本质上就是把过去靠“架构师嘴上说”的事情变成了“框架帮你强制校验”的事情。2. Spring Boot 4 在模块化上做了哪些关键变化2.1 Spring Modulith 从实验走向内核Spring Boot 4 模块化最核心的推动力是把 Spring Modulith 正式整合进了主链路。Spring Modulith 最早是作为独立项目存在的Spring Boot 3.x 的时候很多人已经在用但它毕竟不是框架内核的一部分用起来要额外引依赖社区里知道的人也不多。到了 Spring Boot 4 这一代它已经成了一个被官方认可的模块化基础能力。Spring Modulith 提供的最核心概念就是ApplicationModule注解配合package-info.java使用。你可以在每个子包的 package-info 上标注这是一个应用模块比如ApplicationModule(displayName 商品目录模块) package com.example.demo.catalog;ApplicationModule注解不光是个标记它会在构建和启动阶段触发模块边界校验。Spring Modulith 会扫描所有被标记的模块自动生成模块之间的依赖关系图。如果出现不合理的依赖方向比如上层模块反向依赖了底层模块或者模块 A 直接访问了模块 B 的 internal 包测试阶段就会报出来。Spring Modulith 还提供了事件发布机制。模块之间不允许直接注入对方的内部 Bean但可以通过应用事件解耦。比如订单完成之后要通知会员模块更新消费积分订单模块只需要发布一个OrderCompleted事件Component public class OrderService { private final ApplicationEventPublisher publisher; public OrderService(ApplicationEventPublisher publisher) { this.publisher publisher; } public void completeOrder(Long orderId) { // 处理订单业务... publisher.publishEvent(new OrderCompleted(orderId, totalAmount)); } }会员模块这边通过ApplicationModuleListener监听这个事件Component public class MemberPointListener { ApplicationModuleListener void onOrderCompleted(OrderCompleted event) { // 更新会员积分这里不依赖订单模块的任何类 } }这样一个典型的跨模块调用场景就被解耦成了事件驱动。对业务来说订单模块完全不知道会员模块的存在只管发事件谁关心谁监听。2.2 自动配置与条件装配的收敛Spring Boot 之所以好用很大程度上归功于自动配置。但自动配置也有反噬项目里的依赖越多自动配置尝试加载的候选类就越多启动时需要做的条件评估也越多。过去spring.factories和AutoConfiguration.imports里的配置类很多项目根本用不到但启动时照样会被扫描、评估、过滤。Spring Boot 4 在自动配置上做了很明显的收敛。核心思路是让自动配置变得更加显式能用代码明确声明的就不要靠隐式扫描。在模块化的背景下这一点变得尤为重要。模块化的前提是运行时能准确知道当前应用由哪些模块组成如果自动配置还是一股脑把所有东西都扫一遍模块化带来的启动性能收益就会被吃掉。具体落地到项目里表现就是 starter 的结构也做了调整。官方对 starter 做了重组把以前一个大而全的 starter 拆分成了更小粒度的模块。这样做的好处是你可以按需引入而不是一个spring-boot-starter-web带上一堆不需要的依赖。在实际操作层面一个直观的变化是如果你的项目启用了模块化配置Spring Boot 4 会自动跳过那些与被标记模块无关的自动配置。这一块不需要你写代码但需要你理解它的判断逻辑——模块化场景下自动配置并不是不做而是更精准地按模块范围去做。2.3 JPMS 的落地取舍聊到模块化很多人会联想到 Java 9 就引入的 JPMSJava Platform Module System。Spring Boot 4 在 JPMS 上做了一些很有意思的取舍。先说结论Spring Boot 4 并没有强制要求所有项目都写module-info.java也不会要求你必须用 jlink 做自定义运行时镜像。这也是官方的务实之处。JPMS 出现这么多年真正在应用层普及程度有限主要原因就是它的强约束对框架类库的适配成本太高还有反射访问的兼容问题。但是 Spring Boot 4 确实为 JPMS 留了更好的接口。例如模块化的 jar 在 META-INF 目录下会有更规范的模块描述信息如果你确实需要支持module-info.javaSpring Boot 4 对反射的内省机制做了适配常见的框架场景下命名模块之间的反射访问不再像以前那样容易踩坑。我自己对 JPMS 的定位是“可选的高级特性”。如果你在做的是一个需要深度定制部署环境、对运行时体积非常敏感的上层应用可以尝试用 JPMS 做二次收紧但如果你就是一个常规的业务系统用 Spring Modulith 加上 ArchUnit 做模块边界管理已经能覆盖 90% 以上的需求。3. 实操把一个 Spring Boot 3 项目迁移到 Spring Boot 4 模块化架构3.1 迁移前准备与模块边界识别迁移到模块化架构很多人上来就改代码结果改到一半发现模块切分不合理又推倒重来。我总结了几个比较关键的准备工作先做这些后面会顺利很多。首先要弄清楚当前项目里真实的依赖关系而不是你以为的依赖关系。这里我推荐用两个工具一个是 JDK 自带的jdeps虽然它对 Spring 这种反射密集型框架的分析能力有限但能帮你快速看出一部分 jar 包层面的依赖另一个是 Spring Modulith 提供的依赖图输出在测试里加一行就能看ApplicationModuleTest class ApplicationModulesTest { Test void shouldVerifyModularStructure() { ApplicationModules modules ApplicationModules.of(Application.class); modules.verify(); // modules.forEach(System.out::println); } }运行这个测试Spring Modulith 会输出当前所有被识别模块的依赖关系。基于这个输出你可以比较客观地看到哪些包之间有紧密的依赖哪些包是独立的。第二步是识别模块边界的原则。我的经验是不要按技术层级切模块要按业务能力切。比如在电商项目里商品目录、订单、库存、会员、营销这些各自是一个业务能力。每个能力对应的领域模型、服务逻辑、数据访问代码应该尽量放在同一个顶级包下。而 common、utils、infrastructure 这类横向支撑代码属于另一个维度的“技术基础模块”不属于业务模块。第三步是确定模块间的通信规则。从这个阶段开始你需要刻意避免模块 A 直接调用模块 B 的内部类。跨模块调用优先走接口其次走事件。这样做短期看有点绕但长期看是降低耦合成本的最有效方式。3.2 项目结构与代码改造示例假设我们要迁移一个老项目原来的结构大概是这样的com.example.demo ├── controller ├── service ├── mapper ├── entity └── config这种结构在 Spring Boot 4 的模块化视角下是典型的“技术分层”。我们先把它改造成“业务模块”结构com.example.demo ├── Application.java ├── catalog │ ├── CatalogApplicationModule.java │ ├── internal │ │ ├── CatalogServiceImpl.java │ │ └── CatalogRepository.java │ └── api │ ├── CatalogService.java │ └── dto ├── order │ ├── OrderApplicationModule.java │ ├── internal │ │ ├── OrderServiceImpl.java │ │ └── OrderRepository.java │ └── api │ ├── OrderService.java │ └── dto ├── member │ ├── MemberApplicationModule.java │ ├── internal │ │ ├── MemberServiceImpl.java │ │ └── MemberRepository.java │ └── api │ └── MemberService.java └── common ├── exception └── util再在每个业务模块的包下写上package-info.javaApplicationModule(displayName 订单模块) package com.example.demo.order;这里有一个细节Spring Modulith 默认会把api包下的类型视为对外公开的接口其他包如internal视为模块内部实现。默认命名规则可以自定义但最好遵循这个约定。改造之后订单模块里的OrderServiceImpl需要调用商品模块查询商品名称时应该注入商品模块api包下的CatalogService而不是直接注入它的CatalogRepository。如果代码里出现了跨模块依赖internal包的情况Spring Modulith 的verify()会在测试阶段直接报错。3.3 架构守护测试的落地光有 Spring Modulith 的运行时校验还不够我更推荐把 ArchUnit 引入进来做架构守护测试。Spring Modulith 负责“模块划分”层面的检查ArchUnit 负责更细粒度的代码规则检查两者配合互不冲突。ArchUnit 的典型用例是这样的AnalyzeClasses(packages com.example.demo) public class ArchitectureTest { Test void noModuleShouldDependOnInternalPackageOfAnotherModule() { JavaClasses classes new ClassFileImporter() .importPackages(com.example.demo); ArchRule rule classes().that().resideInAPackage(..internal..) .should().onlyDependOnClassesThat() .resideInAnyPackage(..internal.., java.., org.springframework.., com.example.demo.common..) .allowShouldOnlyContainEmptyClasses(); rule.check(classes); } }这个断言的含义是任何internal包下的类只允许依赖其他internal包、JDK 自带的类、Spring 框架的类以及 common 模块下的类。如果新代码不小心跨模块引用了内部实现CI 里跑测试的时候就直接红掉根本不给你合并的机会。还有人问是不是有了 Spring Modulith 就不需要 ArchUnit 了我的看法是它们是互相补充的。Spring Modulith 的强项在于让模块化的概念和 Spring 生态自然融合适合管理模块级别的依赖ArchUnit 则更通用适合在任意 Java 项目里做规则定制。对于中大型项目两套都上价值最大。3.4 迁移后的启动与性能对比迁移之后的效果我这里给一个实际案例供参考。一个典型的 Spring Boot 3 单体后台项目包含 80 多个实体类、60 多个 Controller、依赖覆盖了 Web、JPA、Redis、消息队列、定时任务、文件存储等。迁移到 Spring Boot 4 模块化架构后用同样的配置启动数据对比如下指标Spring Boot 3 基线Spring Boot 4 模块化迁移后变化幅度应用启动时间8.2 秒5.3 秒约 -35%常驻内存JVM 堆约 980MB约 790MB约 -19%单测阶段全量编译时间42 秒36 秒约 -14%启动时间改善的核心不是框架“变快了”而是自动配置评估的候选集变小了。用不到的功能不加载自然就快。当然有一点也要说明迁移不是一蹴而就的尤其是老项目没有几个星期的持续改造很难彻底。我的建议是分批推进先切出 1 到 2 个边界清晰的业务模块做试点跑通整个流程后再扩展到其他模块。一次重构所有模块风险太高而且出了问题很难定位。4. 模块化改动中的常见问题与排查实录4.1 循环依赖定位思路迁移过程中最常见的拦路虎就是循环依赖。平时 Spring 启动时如果你用了构造器注入循环依赖会在启动阶段直接报错但如果你习惯了字段注入很多循环依赖会被 Spring 的懒加载机制藏起来等模块化校验的时候才暴露。排查循环依赖我一般分三步走。第一步在启动参数里打开依赖项打印spring: main: lazy-initialization: false debug: true然后看启动日志中关于 bean 创建顺序的提示。第二步用 Spring Boot 4 提供的模块依赖导出把模块依赖图导出来人工审视一遍。第三步把字段注入改成构造器注入这一步非常关键可以逼着循环依赖尽早暴露。举个例子我们迁移时发现OrderServiceImpl需要MemberService而MemberServiceImpl又反向依赖了一个订单查询接口。这种问题在字段注入时代完全看不出来改成构造器注入后Spring 启动阶段直接抛 BeanCurrentlyInCreationException问题当场就暴露了。解决方案很简单把会员模块对订单查询的需求抽成事件或者把公共查询逻辑下沉到 common 模块。4.2 反射与类加载问题模块化之后第二个高频踩坑点是反射。Spring 的自动配置、MyBatis 的 Mapper 扫描、Jackson 的序列化都在大量使用反射。以前不限模块边界的时候扫描范围是整个 classpath怎么扫都不会漏。加了模块边界之后有些类如果你没有在模块里显式暴露扫描就扫不到了。最典型的现象是配置类明明已经加了Configuration注解也扫到了但 Bean 就是没创建。这种情况十有八九是模块边界把配置类挡住了。排查思路是先确认配置类所在的模块是否被主应用模块声明依赖再看配置类是不是放到了internal包里。如果某个配置类确实是给外部模块用的不应该放在internal下而应该放到api包或独立的 config 包并且通过Import显式引入而不是完全依赖包扫描。4.3 测试上下文加载变慢迁移模块化之后另一个常见现象是测试变慢了尤其是 Spring Boot 测试里的SpringBootTest这种全量上下文加载的测试。原因很好理解模块化之后你本来只是要测试某个模块但SpringBootTest还是会启动整个应用上下文把所有模块都加载出来。解决方案是尽量使用切片测试或者通过SpringBootTest(classes OrderModuleConfiguration.class)指定加载部分配置。还有一个 Spring Modulith 提供的思路用ApplicationModuleTest注解限定测试范围只加载当前模块相关的那部分上下文其他模块的上下文不启动。这个改动之后我们项目里一个涉及订单模块的集成测试执行时间从 25 秒降到了 9 秒效率提升非常明显。另外注意测试类的放置位置。模块化的测试类最好跟随模块放在对应的包目录下而不是统一堆在一个全局 test 包下否则很容易出现边界误判。4.4 依赖收敛与重复依赖模块化之后依赖管理也是一个值得盯住的地方。业务模块一多很容易出现同一种能力被多个模块各自引入依赖的情况。比如模块 A 引入了 commons-lang3模块 B 也引入了一版不同的 commons-lang3。构建阶段不一定报错但运行阶段很容易因为类冲突出现一些莫名其妙的异常。我的做法是在构建插件里加上依赖收敛检查。以 Gradle 为例可以用dependencyInsight排查特定依赖的来源或者直接启用 resolution strategy 强制统一版本configurations.all { resolutionStrategy { failOnVersionConflict() } }启用后如果有版本冲突构建会直接失败逼着你在第一时间处理。同时建议在团队规范里明确跨模块公共依赖统一沉淀到 common 模块的 BOM 中管理业务模块不要自己声明不必要的外部依赖。4.5 Spring Boot 4 模块化起步阶段的轻量检查方式如果你还不打算马上做全量迁移但希望新代码能落在模块化思路上有几个成本很低的起步方式。一是在新写的业务功能里按照api和internal分包约定组织代码二是在 CI 上单独加一个 ArchUnit 检查任务先只检查新增的模块目录不检查历史遗留包三是把跨模块调用的接口先定义出来内部实现再慢慢收拢。这种“新代码按新规、老代码逐步收编”的方式比一刀切的大重构稳妥得多特别适合那种长期演进、多团队协作的项目。5. 写在最后的几点体会Spring Boot 4 的模块化架构我认为不是一次单纯的版本升级而是 Spring 团队对过去十年 Java 服务端开发痛点的一个回应。像 Spring Modulith 这样的能力早期看起来是“小众工具”但放到现在业务复杂度普遍上升的背景下已经是刚需了。根据我的切身体验模块化带来的收益不是立竿见影的那种“爽感”它不会像加了个缓存那么立刻让你看到响应时间下降但它会在项目持续演化的过程中一遍又一遍帮你挡住那些本来要上线才能暴露的问题。这有点像治未病最理想的结果是“这里本来会发生问题但因为边界存在问题没有发生”。最后再分享一个小技巧。如果你在一个团队里推模块化不要一上来就谈技术细节而是先定一条最简单的红线“任何跨模块的调用不允许访问对方的 internal 包。”把这条红线用 ArchUnit 固定下来比讲十个设计模式都管用。