早期我把一个 SpringCloud 单体工程拆成 Maven 多模块时,前后折腾了两三天。拆之前我以为多模块就是多建几个子工程、把代码挪一挪就完事,真动手才发现,parent 怎么配、版本号怎么统一、打包后服务为什么起不来,全是坑。如果你也准备用 Maven 多模块来组织 SpringCloud 项目,或者已经拆到一半被依赖和打包折磨,这篇笔记应该能让你少走不少弯路。
这篇内容不是官方文档那种逐条罗列,而是我按实际搭建顺序整理出来的经验和记录。从为什么要拆、目录怎么规划,到顶层 pom 的版本管理、子模块依赖设计,再到打包多环境配置,最后是几段实打实的排障过程。适合正在做微服务改造的开发者,也适合刚入门 SpringCloud 但被 Maven 工程结构搞晕的同学。
1. 为什么SpringCloud项目非要拆成多模块?先想清楚收益再动手
1.1 从"按包分包"到"按模块分家"
早期做单体 SpringBoot 服务,我们习惯在一个工程里用 controller、service、mapper 分包来组织业务。代码少的时候确实方便,但服务一多、团队一扩,问题就来了:所有人都往同一个工程里加代码,模块之间的边界靠自觉,接口互相调用说不清谁依赖谁。更麻烦的是,你想把某个业务单独部署出去变成微服务,得先把这部分代码从工程里物理拆出来,而这个拆的动作在一个大工程里几乎等于重构一遍。
Maven 多模块解决的恰恰是这个"边界"问题。它把一个逻辑上的大工程拆成多个物理上独立、但又能共享父级配置的子工程。每个子模块只对暴露的接口负责,模块之间的依赖关系直接在 pom 里体现,编译期就能发现谁写乱了。对 SpringCloud 来说,一个典型的映射关系是:一个子模块 = 一个可独立部署的服务 + 它的本地依赖,而公共的部分抽到单独的模块里被大家引用。
1.2 多模块能带来的三个实际收益
拆成多模块之后,你会明显感受到三个变化:
第一,依赖关系显式化。以前"这个类依赖了那个类"靠代码扫描才能发现,现在模块 A 的 pom 里没有写依赖模块 B,那你就是没法在 A 里 import B 的类,编译直接报错。团队协作时,大家只需看模块的 pom 就知道边界,不用逐层翻代码。
第二,公共代码有了统一归宿。前后端要用的统一返回结构、DTO、异常定义、工具类,以前散落在各个服务里,改一个字段要全局搜索替换。抽成公共模块后,只维护一处,所有引用它的服务自动更新。
第三,构建粒度可控。微服务场景下,我改一个服务和改整个平台往往是两码事。多模块配合 Maven 的 -pl 参数可以只构建某几个模块,全量构建和部分构建随意切换,配合 CI 时优势特别明显。
1.3 并不是所有项目都适合拆
这里我要泼一盆冷水:如果你的项目只是给朋友做个 demo,或者总共就两三个简单接口,别拆。多模块带来的复杂度是真实的——子模块多了,pom 文件也多了,构建链路变长,IDE 索引变慢。我见过团队为了拆而拆,拆完以后公共模块抽象得过于激进,最后所有人都在改公共模块,比单体还痛苦。
适合拆的信号很简单:服务之间需要独立部署、团队需要独立开发互不阻塞、公共部分的变更频率较高。只要命中其中两条,多模块就是值得的。否则先保持单体,等痛点足够清晰再动手,不丢人。
2. 目录规划与顶层pom:把一个工程拆成"聚合+继承"两件事
2.1 一份适合SpringCloud的目录结构
我习惯的目录结构长这样:
springcloud-multi-demo/ ├── pom.xml ├── demo-common/ // 公共模块:工具类、DTO、统一返回结果 ├── demo-gateway/ // 网关模块 ├── demo-auth/ // 认证模块 ├── demo-user/ // 用户服务 └── demo-order/ // 订单服务根目录下只有一个顶层 pom.xml,打包方式必须是 pom。所有子模块在根目录下平铺,每层之间的层级关系不要太深,我见过三层的,维护起来很累。你只需要记住:模块之间靠 Maven 坐标引用,不靠文件夹嵌套,所以目录平铺是最合理的姿态。
2.2 顶层pom的关键配置:一个例子讲透
顶层 pom 承担两个角色:作为 parent 被子模块继承,作为聚合 pom 被 Maven 用來批量管理模块。看一段最基础的配置:
<project> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.6.13</version> <relativePath/> </parent> <groupId>com.example</groupId> <artifactId>springcloud-multi-demo</artifactId> <version>1.0.0-SNAPSHOT</version> <packaging>pom</packaging> <modules> <module>demo-common</module> <module>demo-gateway</module> <module>demo-auth</module> <module>demo-user</module> <module>demo-order</module> </modules> </project>这里有三点必须注意:
一是顶层 pom 自己继承了 Spring Boot 的父 pom,子模块再继承这个顶层 pom。这样 Spring Boot 的依赖管理能力就能通过"爷爷传爸爸,爸爸传儿子"的方式传导到所有子模块,不需要每个子模块都写一遍 Spring Boot 版本。二是 packaging 必须是 pom,否则 Maven 不会把它当聚合工程。三是 modules 里写的是子模块的目录名,不是 artifactId,目录名和 artifactId 最好保持一致,否则非常容易看花眼。
2.3 聚合与继承:Maven 多模块的两个隐藏角色
很多人会混淆聚合(modules)和继承(parent),其实它们是一件事的两个侧面。
继承解决的是"复用配置"。子模块通过 parent 标签指向父工程,就能拿到父工程里的 properties、dependencyManagement、插件配置。这就像父子遗传,爸爸有的一些特征,孩子天生就有,不需要重复声明。
聚合解决的是"批量构建"。你在根目录执行 mvn clean install,Maven 会读 modules 列表,按顺序把每个子模块都构建一遍,不用手动进到每个目录敲命令。这相当于给整个项目加了一个总入口,CI 和本地构建都方便很多。
实际项目中,根 pom 往往同时扮演这两个角色:对外是聚合总入口,对内是公共配置的爸爸。理解这一点,后面很多操作就顺了。
3. 统一版本号:dependencyManagement的正确用法
3.1 用 properties 收拢版本号
版本管理不到位,多模块就会变成"版本地狱":模块 A 用 2.7.0,模块 B 用 2.6.13,表面上都能编译,运行时却因兼容性问题抛一堆莫名其妙的异常。我的习惯是把所有相关版本号写在父 pom 的一个 properties 区块里,集中管理,一目了然:
<properties> <java.version>1.8</java.version> <spring.boot.version>2.6.13</spring.boot.version> <spring.cloud.version>2021.0.5</spring.cloud.version> <mybatis-plus.version>3.5.2</mybatis-plus.version> </properties>以后升级依赖,只改父 pom 的这一处即可。子模块里再也不要出现硬编码的版本号,统一用${mybatis-plus.version}这类引用,这样从根上杜绝各模块各用各版本的情况。配合 IDE 的全局替换,升级依赖变成一次性操作,而不是逐个模块手动改。
3.2 dependencyManagement 和 dependencies 到底差在哪
这是多模块里最容易写拧巴的一个点。两者看上去都是写在父 pom 里声明依赖,但语义完全不同:
- dependencies 是直接生效:写在父 pom 的 dependencies 里,所有子模块都会无条件继承这个依赖。
- dependencyManagement 只是管理版本:它声明"如果子模块引入某个依赖,默认用这个版本",但子模块不主动写这个依赖,那就不会被引入。
用大白话说,dependencies 是"直接塞给你",dependencyManagement 是"告诉你标准价,你按需领取"。
举例,我想让所有子模块都拥有日志能力,就把它放 dependencies;想让各子模块用到 JSON 工具时版本一致,就在 dependencyManagement 里声明,子模块根据自己的需要自主引入。典型做法:
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>${spring.cloud.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>Spring Cloud 官方依赖的版本信息是打包在一个 BOM 里的,所以这里用 import 方式引进来。这样下面任意子模块只要写:
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId> </dependency>版本号会自动匹配统一版本,不用自己填。这个 import 机制是多模块版本管理里最值得掌握的技巧。
3.3 Spring Cloud 与 Spring Boot 版本对应关系
Spring Cloud 有自己独立的大版本号体系,和 Spring Boot 并非一一对应。用错了组合,启动时经常报 NoSuchMethodError 或者加载到不兼容的类。以我常用的组合为例:
| Spring Cloud 版本 | 对应的 Spring Boot 版本 |
|---|---|
| 2021.0.x | 2.6.x |
| 2020.0.x | 2.4.x、2.5.x |
| Hoxton.SR12 | 2.2.x、2.3.x |
| Greenwich | 2.1.x |
建议动手前先确认好这对组合再落到父 pom 里。我的经验是:升级 Spring Boot 大版本时,Spring Cloud 版本几乎必变,不要只升一个,否则启动后各种暗病会慢慢冒出来。
4. 子模块划分与依赖设计:公共模块不该做的事
4.1 子模块 pom 的基本结构
每个子模块的 pom 都很薄,它要做的事只有三件:声明自己的 parent 是谁、声明自己的坐标、声明自己独有的依赖。基础结构如下:
<project> <parent> <groupId>com.example</groupId> <artifactId>springcloud-multi-demo</artifactId> <version>1.0.0-SNAPSHOT</version> </parent> <artifactId>demo-user</artifactId> <dependencies> <dependency> <groupId>com.example</groupId> <artifactId>demo-common</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies> </project>注意两点:子模块里不需要写 groupId,它会从 parent 继承;version 也不需要写,同样从 parent 继承。如果子模块引入了公共模块,而这个公共模块的版本已经在父 pom 的 dependencyManagement 里管辖,那么这里连版本号都能省掉,一个清爽的依赖声明就这样实现了。
4.2 公共模块的设计边界
在 SpringCloud 多模块里,公共模块是所有服务的基础依赖,设计得好就是基础设施,设计得烂就是垃圾堆。
我的几个原则:公共模块尽量只放纯 Java 代码——统一返回值、通用工具类、枚举、异常定义、DTO。它不应该引入 Spring Boot Web 相关的 starter,更不应该放任何业务代码。因为公共模块一旦依赖了 web 的东西,所有引用它的服务都会被强制带入一套 web 框架,哪怕你只是个纯后端任务也会背着这个包袱。
如果公共模块变得很大,说明抽得不够细。比如认证相关的能力,它可以单独成一个"认证依赖"模块,而不是全塞进 common 里让所有服务都背上认证的逻辑。模块划分不是越少越好,而是边界越清晰越好。
4.3 避免循环依赖的几种情况
模块之间的依赖应该是一个有向无环图:依赖方向从具体业务模块指向底层公共模块,而底层公共模块绝不能反过来依赖业务模块。
循环依赖在 Maven 多模块里非常隐蔽。典型场景:demo-user 依赖 demo-order 的某个 DTO,而 demo-order 又依赖 demo-user 的某个工具类。编译时两个模块的坐标已经写进 pom 了,构建时 Maven 会提示循环依赖,解决的办法不是往下硬调,而是往上抽——把两个模块共同需要的东西下沉到 demo-common,让两个业务模块都依赖 common,谁也不依赖谁。
判断一个依赖是否应该存在,我的经验是:如果为了引用一个类而被迫引入整个模块,那十有八九这个类放错位置,它应该在更底层的地方。
4.4 子模块之间的服务调用怎么处理
服务间调用不要直接在 pom 里互相引用整个业务模块,那会让模块边界形同虚设。更稳定的做法是:通过网关转发、通过 HTTP 走提供方暴露的接口,或者引入轻量的声明式客户端。各服务保持物理上的独立,interfaces(接口)层面的共享走公共模块的 DTO。不少团队一上来就互相引模块,最后模块拆了跟没拆一样,所有模块形成一个互相绞在一起的毛线团。
5. 多模块打包与多环境配置:install、repackage和profiles的实战细节
5.1 package 和 install 的区别:多模块构建的核心认知
多模块工程里最典型的报错是"依赖了另一个模块,但本地仓库里却没有这个模块的包"。根因往往是把 mvn package 和 mvn install 搞混了。
mvn package 只是把当前模块打成 jar 放到自己模块的 target 目录下,它不会把这个 jar 装进本地仓库。mvn install 除了打包,还会把工件安装到本地 Maven 仓库,这样其他模块引用它时才能真正拿到。也就是说,多模块工程里,被其他模块依赖的模块必须 install,光 package 是不够的。
所以我的习惯是:在根目录执行mvn clean install -DskipTests,一次性把聚合工程里的所有模块都安装进本地仓库,然后再单独对某个模块做 package 或者启动。如果只改了公共模块,可以单独 install 公共模块,再重新构建依赖它的服务。
5.2 spring-boot-maven-plugin 带来的双重打包问题
SpringBoot 多模块最容易踩的隐藏坑之一,就是可执行 jar 和普通 jar 的区分。spring-boot-maven-plugin 打包时会执行 repackage,把 SpringBoot 应用做成可执行 fat jar,但问题在于:公共模块根本不需要可执行 fat jar,普通模块被打成 fat jar 后,其他模块引用它时反而会出问题。
常见表现是:服务启动时加载到的类来自一个"带 HASH 的嵌套 jar",排查半天都不知道这个版本是从哪来的。
我的处理思路是:只有真正作为启动入口的模块(网关、业务服务)才启用 spring-boot-maven-plugin 的 repackage,公共模块或者普通依赖模块只打包成普通 jar 即可。放在父 pom 的 pluginManagement 里统一声明,子模块按需显式启用,能有效避免每个模块都去 repackage 的混乱局面。
5.3 跳过测试的两种方式差异
多模块构建时,每次全量跑单测会很浪费时间,实际项目中常用到跳过测试的两个参数:
- -DskipTests:跳过测试执行,但会编译测试代码。
- -Dmaven.test.skip=true:跳过测试编译,连测试代码都不编译,速度更快。
CI 环境里一般用 -Dmaven.test.skip=true 提高效率,但本地开发建议保留单测执行一遍,毕竟多模块反射问题往往要从测试里暴露出来。还有一个小经验:如果有模块依赖的公共模块恰好在这个版本里改了测试代码,编译不通过时会卡住全家构建,这时用 -Dmaven.test.skip=true 可以快速绕开,但有代价——测试代码的问题会延迟暴露。
5.4 多环境配置与 profiles 配合
SpringCloud 服务一般至少分 dev、test、prod 三套环境。每套环境除了配置文件外,注册中心地址、配置中心地址、日志级别、数据库连接都不相同。我的做法是保留一套基础配置 application.yml,按环境拆出 application-dev.yml、application-test.yml、application-prod.yml,启动时通过参数指定激活环境。结合统一配置中心时,各环境只保留配置中心的地址,其余业务配置全部外置,多模块下各服务只需关心自身配置即可。
父 pom 里可以用 Maven 的 profiles 把构建时的环境信息传进去,例如:
<profiles> <profile> <id>dev</id> <properties> <activated.env>dev</activated.env> </properties> </profile> <profile> <id>prod</id> <properties> <activated.env>prod</activated.env> </properties> </profile> </profiles>构建时通过-Pdev激活对应 profile,配置文件里的${activated.env}占位符就能拿到环境名。这个组合在很多项目里被验证是高效且直观的,前提是配置文件的命名和加载逻辑要遵循约定,别让环境名在代码里散落。
6. 实测踩坑实录:从"找不到依赖"到"启动扫不到Bean"
这部分是我的真实排障记录,每段都包含了完整的排查链路,希望你能在我的思路里找到参照。
6.1 模块A引不到模块B的类:先查本地仓库,再查坐标
第一次拆多模块时,我建了 demo-user 和 demo-common 两个模块,demo-user 的 pom 里引用 demo-common 后,写代码时 IDE 里一切正常,但命令行编译直接报"找不到符号"。
我当时的排查顺序是:先确认 demo-common 是否已经被 install 到本地仓库。检查后发现我确实只在 IDE 里做了编译,并没有把 demo-common 安装到仓库,于是执行mvn install -pl demo-common之后再编译 demo-user,问题立刻消失。如果你也遇到类似情况,先确认被依赖模块是否存在于本地仓库,这是最容易被忽略的第一环节。
接着第二坑:坐标不一致。我查看仓库里 demo-common 的目录结构时发现 groupId 路径拼不对,因为我的子模块没有显式写 groupId,从父 pom 继承了一个错误的 groupId。排查方式很简单,mvn install 成功之后到本地仓库对应路径下看一眼 jar 存在的目录层级,如果和 pom 里的依赖坐标对不上,要么改坐标,要么改目录。
6.2 启动类扫描不到 Controller:包路径的锅
某个微服务拆完模块后,启动一切正常,但访问接口全部 404。排查后确认 Controller 是存在的,问题出在 Spring Boot 的组件扫描机制。
Spring Boot 的启动类默认只扫描自己所在包及其子包。我把 Controller 写在 demo-common 的一个包下面,启动类的包路径与之无关,组件根本没有被扫描进来。排查链路是:先确认接口类在 class 文件里存在,再检查启动时扫描的包范围,最后用@ComponentScan显式扩展到业务包路径。这类问题有个共性特征:IDE 里能编译、服务能启动、但接口就是 404 或 Bean 缺失。
我的建议是,所有子模块的基础包名统一前缀,例如都用 com.example.demo.common、com.example.demo.user、com.example.demo.order,这样启动类只需要配置一次扫描包前缀,后续新增模块自动纳入扫描范围。如果每个模块的包名千差万别,那就只能靠手动追加扫描路径,极易漏配。
6.3 同一个类被加载两份:可执行 jar 的连带影响
某个服务里出现了两个包名完全相同的类,行为还不一致。起初以为是编译缓存问题,深入排查后发现是公共模块被打造成了可执行 jar,拆开 jar 后发现它的 BOOT-INF/lib 里又套了一层依赖,导致引用方加载到了两份相同的类。
这个问题的根因就是 5.2 里说的「每个模块都启用 spring-boot-maven-plugin 的 repackage」。当时我把 Spring Boot 父 pom 的插件配置全局继承下来,所有模块默认都执行了 repackage,公共模块也不例外。解决方式是:在公共模块里显式禁用 repackage,同时确保只有真正包含启动类的模块才开启这个插件。
排查这一类问题有一个核心技术手段:启动时加-verbose:class或者用日志输出类的加载路径,能直接看到类是从哪个 jar 来的,定位非常高效。纯靠读代码往往很难察觉到加载路径存在两处来源。
6.4 多模块构建顺序不对:凭空多出来的脏数据
Maven 多模块在聚合模式下构建时,模块顺序默认按 modules 里的声明顺序执行,不是按依赖关系自动拓扑排序,至少在早期版本里确实要留意这一点。我遇到过一次问题:demo-order 依赖 demo-user,但 modules 里先写了 demo-order,执行全量构建时 order 模块到本地仓库找不到 user 模块新版本的包,构建失败。
后来我设置 modules 时就遵循依赖顺序,先从 base 模块(common)写起,再写依赖它的上层模块。多数情况下 Maven 能识别依赖关系并自动选择构建顺序,但不要完全依赖它,手动保持合理的顺序能避免很多莫名其妙的"本地仓库是旧版本"问题。
还有个细节:更新被依赖模块后,引用方如果一直找不到新改的方法,多半是本地仓库里的旧 jar 没被覆盖掉,可以配置构建时强制刷新快照:mvn clean install -U。这个参数强制拉取最新的快照版本,是排查"改了代码但执行还是旧行为"的一把利器。
6.5 IDE 加载多模块的小技巧
最后一条经验关于 IDE,多模块工程在 IDE 里打开后,建议做两件事:一是在 Maven 工具窗口里点一次刷新,让 IDE 重新读取模块间的依赖关系;二是确认所有模块的 JDK 编译级别和父 pom 里保持一致,否则某个模块会按局部默认编译级别去编译,出现"我本地没问题,CI 上就报错"的经典尴尬。
如果模块数量很多,第一次加载会比较慢,不要急着打断,让 IDE 完整导入。之后日常开发时只改动单个模块,编译运行效率依然很高。多模块本身不是问题,配合不当才会体验不好。
最后再分享一个我自己的操作习惯:每次改动公共模块我必然先 install 一次,再启动依赖它的服务;每次从 Git 上拉新代码我第一件事是执行mvn clean install -Dmaven.test.skip=true,保证本地仓库和最新代码同步。这套流程跑顺之后,多模块项目的日常开发其实完全可以做到"心中无模块"——构建、启动、部署都顺畅了,模块边界带来的复杂度也就被彻底消化掉了。