很多人在 Spring 入门进到一定阶段后,都会撞上同一个困惑:打开一个正常的基于 Spring 的工程,内部不是单一文件夹加一个 pom,而是多个 module 并列排开。有些开源项目甚至一个根目录下挂着七八个子模块,第一次看到时你会完全不知道哪个模块先编译、哪个模块负责启动、新来的业务代码到底放进哪个目录。我第一次在 IntelliJ IDEA 里新建 module,是在一个学习用的多商户后台项目里,当时为了把会员模块从主工程里拆出来,问题一个接一个:新模块建好后父 POM 不认它,引用了另一个模块的类却报“程序包不存在”,启动类放错位置导致 Spring 容器扫不到新模块的 Bean。这篇内容只围绕一件事:Spring 学习中新建 module 模块应该怎么操作、为什么要这么操作,以及我在实际操作里积攒下来的排查经验。它适合已经写完过一个完整 Spring Boot 小项目、准备了解多模块工程结构的读者,也适合接到一个多模块项目后想先在本地添加模块的开发者。
1. 建新模块之前,先搞清楚“模块在工程里到底是什么”
1.1 在 IDEA 里,module 和 project 到底谁包含谁
很多人第一关就卡在概念上。在 IntelliJ IDEA 中,你打开一个“项目(Project)”,这个项目下面可以直接存在若干个“模块(Module)”。如果你一直在一个单模块工程里练习,那只是碰巧项目本身只有一个模块。当你执行 File → New → Module 时,新建出来的不是一个独立的新项目,而是在当前打开的工程范围内加入一个新的子模块,它仍然属于当前工程体系。这一步如果不转过来,后面看父工程和子模块的关系时就会一直别扭。
用 Maven 的视角理解会更清晰。Maven 的多模块结构通常由父工程(packaging 为 pom)聚合若干子模块组成。IDEA 左侧树里,父工程和子模块会并列显示;父工程名会用粗体标记,打开它的 pom.xml,能看到一段 modules 列表,里面写着所有被子工程聚合的子模块。很多初学者以为新建 module 就是复制一个目录,但实际上 module 的本质是一个拥有独立 pom.xml、可以被单独编译的最小构建单元。父 POM 负责统一管理版本号、公共依赖和构建顺序,子模块只需要关心自己这个业务边界内的代码。
这里有个很常见的误区:看到开源工程里有好几个 module,就想自己也赶紧拆出三五个模块来“显得结构清晰”。结果往往是模块与模块之间依赖绕成一团,最后光排依赖关系就花掉大量时间。多模块不是目的,让构建和职责更清晰才是目的。
1.2 多模块下,构建顺序其实不是你能手动决定的
单模块项目里,代码都在同一个模块,编译顺序自然是先到先编。一旦有了多模块,情况就变了:Maven 会先读取父 POM 中声明的模块列表,再根据模块之间 pom.xml 声明的依赖关系自动算出构建顺序。比如父工程下有三个模块:common、user-core、user-web。user-core 的 pom 里依赖了 common,user-web 的 pom 里依赖了 user-core,那么 Maven 打包时一定会先编译 common,再编译 user-core,最后编译 user-web。即便你手动先从 user-web 开始打包,Maven 也会强制把它的依赖先构建出来。
这个顺序不需要人工干预,也最好不要手动干预。真正需要你留意的,是父 POM 的 modules 列表和每个子模块的 parent 声明。modules 列表是父工程对外展示的“家庭成员名单”,parent 声明是子模块回家认门的那条路。这两个地方只要有一个不对,整个工程结构就会变得非常奇怪,报错类型也五花八门。我判断一个多模块工程是否健康,通常先看这两处,命中率很高。
1.3 按业务模块切,还是按技术层次切
建新模块之前,要先想清楚用的哪种拆分逻辑。常见的拆分方式有两种:一种是按业务边界切,例如电商项目里拆出商品模块、订单模块、用户模块;另一种是按技术层次切,例如把 entity、mapper、service、controller 各放一个模块。多数成熟项目会优先采用业务边界切分,因为它更贴近真实需求变化:订单模块改订单,不会影响到商品模块的编译。
学习阶段反而建议克制一点。不要一上来就把实体类、工具类、控制器分别各建一个模块,拆得越细,依赖图就越复杂,启动和排错成本也越高。合理起步是拆出一个通用模块(common)和两个业务模块(例如 user、order),这样足够练习模块间的依赖注入、配置扫描和构建同步,也不会把自己绕进 Maven 依赖地狱。
2. 在 IntelliJ IDEA 里新建 Module 的完整操作路径
2.1 开始之前,先做三个环境检查
虽然是几十秒就能完成的动作,但这三个检查能帮你避开后面不少干扰。
第一,确认 JDK 和语言级别一致。在 File → Project Structure → Project 里能看到当前工程的 SDK 和 Language level。新建的子模块默认会继承父工程的设置,但偶尔会因为手工调整或向导默认选项不一致导致编译级别错乱。我的习惯是让整个工程统一用同一个语言级别,同时让 Maven 的 maven-compiler-plugin 也声明相同的 source 和 target,两边对齐,省得出一些“需要 diamond 操作符版本”这种莫名其妙的小报警。
第二,确认 Maven 配置正确。在 Settings → Build, Execution, Deployment → Build Tools → Maven 里检查 Maven home path 和配置文件路径。如果配置没用对,第一次同步时可能会卡在下载依赖上,IDEA 右下角一直转圈,项目树里出现红色下划线,彼时还以为是模块建错了,其实是 Maven 仓库配置根本不对。
第三,确认你当前打开的工程是一个 Maven 工程。根目录下必须有 pom.xml。如果你的项目当初是用 IDEA 的“新建 Spring Initializr 项目”创建的,它本身就是一个带 parent 的 Maven 工程,可以作为多模块的父工程底座,直接在上面加子模块。真正不适合的,是一个纯手工目录或者非 Maven 的普通工程,那种情况下建议先另建一个干净的父工程骨架。
2.2 从 File 菜单走到 Finish 的关键步骤
在 IntelliJ IDEA 中新建模块的标准路径是:菜单栏 File → New → Module...,或者在左侧 Project 窗口的项目名称上右键,选择 New → Module。无论哪种入口,弹出的窗口左边都会出现一列项目类型。
这里有一个特别值得提醒的选项:很多人注意不到左侧会有 Maven、Gradle、Spring Initializr 等好几个选项,而常见的选择错误是选了 Spring Initializr。如果你的目的是在已有工程中增加一个 Maven 子模块,正确做法是选择左侧的 Maven。Spring Initializr 是用于创建独立 Spring Boot 工程的向导,它默认会生成一套完整独立的新工程结构,还会远程拉取初始化模板,在已有父工程里使用经常会生成出多余的嵌套结构,反而弄出一堆不必要的插件配置。
选择 Maven 之后,需要填写三项基础信息:GroupId、ArtifactId 和 Version。GroupId 一般建议与父 POM 保持一致,例如 com.example.demo;ArtifactId 是这个模块真正的身份标识,例如 demo-user,注意不要用大写字母、中文或者带空格的名称;版本号建议保持一致,可以跳过不填,IDEA 会自动继承父工程的版本。弹出的其它字段还有 Module name、Content root 和 Module file location,默认生成即可,如果想自定义,也要注意这几个字段之间的联动,不要只改 Content root 而忘了改 Module name。
最后点 Finish。IDEA 会提示 Maven 项目需要引入,根据版本不同可能会弹“Enable Auto-Import”或“Maven projects need to be imported”。建议选择自动导入,或者稍后在 Maven 面板手动刷新。模块创建完成后,左侧项目树会出现一个新模块,里面有它自己的 pom.xml、src/main/java、src/main/resources 目录。
2.3 模块建完后的第一个验证动作
不要急着写业务代码,先把空模块编译一遍。右侧 Maven 面板中找到父工程,执行 clean,再执行 compile。如果新模块是空的,Maven compile 会直接通过;如果父工程的 Maven 面板里没有出现这个新模块,或者 pom.xml 文件标红,说明模块还没有被父工程正确聚合,接下来就要到第三部分提到的几处配置去查。
这一步的验证价值很大。空模块编译通过,能保证后续写进去的代码不会再被“模块本身是否正常”这种基础问题干扰。一个人为模块配置、依赖关系花费的点常常集中在 pom 配置和扫描配置,而不是业务代码本身。
3. 新模块的 pom.xml:四个地方决定它能不能被正确管理
3.1 父 POM 的 modules 列表,必须补上这个新模块
父 POM 里需要有这样的片段:
<modules> <module>demo-common</module> <module>demo-user</module> <module>demo-order</module> </modules>IDEA 的新建模块向导通常会自动把模块名加进这段列表,但有几个场景不会自动加:比如你手动把一个普通目录转换成了 Maven module,或者把一个外部模块文件夹复制到了当前工程目录。此时就需要自己手动把模块名加进父 POM。
判断方法很直观:Maven 面板中看不到该模块,或者父工程编译时没有将它纳入构建,就说明 modules 列表漏了它。modules 标签里填写的名称,对应的是子模块所在的相对目录名,生产上一般与 ArtifactId 保持一致,但不是必须完全一致,只是保持一致能少浪费一些脑力。
3.2 子模块的 parent 声明,是它认父的依据
每个子模块的 pom.xml 里应该有一段类似这样的声明:
<parent> <groupId>com.example.demo</groupId> <artifactId>demo-parent</artifactId> <version>1.0.0-SNAPSHOT</version> <relativePath>../pom.xml</relativePath> </parent> <artifactId>demo-user</artifactId> <name>demo-user</name>这里有个容易被忽略的字段 relativePath。如果不写,Maven 会先尝试从本地仓库找父 POM,找不到再上网去拉;如果写了 ../pom.xml,Maven 会直接去磁盘相对路径读取父 POM,构建更快,也更不容易受仓库缓存影响。对于标准的多模块工程来说,建议加这个字段并把路径指向真实的父 POM 文件。假设你是从外部拷贝整个模块目录进来,一定要核查 relativePath,因为路径一旦对不同,Maven 会报 Non-resolvable parent POM,单看错误会以为父工程版本写错了,实际上是寻路失败。
3.3 依赖版本统一交给父 POM 的 dependencyManagement
子模块自己可以写依赖版本号,但这正是多模块工程版本失衡的根源。你见过在一个项目里 spring-boot 2.6.1 和 2.7.4 并存的场景吗?很可能就是有人复制粘贴 pom 后又随手改了版本。避免这种问题,最好的做法是父 POM 用 dependencyManagement 统一锁版本。
父 POM可以这样维护内部模块版本:
<dependencyManagement> <dependencies> <dependency> <groupId>com.example.demo</groupId> <artifactId>demo-common</artifactId> <version>${project.version}</version> </dependency> </dependencies> </dependencyManagement>子模块引用时就不用再写版本号:
<dependency> <groupId>com.example.demo</groupId> <artifactId>demo-common</artifactId> </dependency>如果把 Spring Boot 体系纳入进来,通常会让父工程继承 spring-boot-starter-parent,这样 Boot 全家桶的常用版本已经由它统一管理;再自行管理内部模块版本,两层分工明确。注意不要混用两套方式太多:一会子模块自己写版本,一会又靠父 POM;两套逻辑长期并行,依赖升级时非常容易漏掉某处。对于 Spring 学习阶段的工程,维持一套统一机制即可。
主 POM 的情况在细节里有所差异,但核心思路一致:把变化点收敛到父 POM 一处。
3.4 内部模块之间引用要显式声明
模块之间不存在“继承”,只存在依赖。比如 demo-user 想用 demo-common 里的一个统一返回类,就必须在 demo-user 的 pom.xml 中显式添加 demo-common 依赖。这个依赖该不该加上,应根据实际引用关系确认,既不重复加无用依赖,也要避免漏加导致编译报“程序包不存在”。
对于内部依赖的依赖范围,默认用 compile 即可,不需要设置 provided 等特殊 scope。还有一点:如果一个模块对外只暴露 DTO 或工具方法,却被依赖模块牵连了好多 Spring 相关的传递依赖,这说明模块之间的分层已经乱了,优先考虑把无关的类下沉到更底层的公共模块。依赖方向保持单向,才是多模块舒服运行的关键。
4. 让新模块真正“跑起来”,Spring 容器里的接入才是关键
4.1 启动类的扫描范围,决定了它“看得见还是看不见”
很多 Spring Boot 初学者建好 module 后,把 Controller 和 Service 写进新模块的包里,启动主工程访问接口,却发现 404 或者直接提示找不到 Bean。核心原因非常简单:Spring Boot 默认只会扫描启动类所在包及其子包。
如果你的启动类 DemoApplication 在 com.example.demo 包下,新模块类却放在 com.example.user.controller,那么这些类根本不会被容器发现。最常见的解决办法是在启动类上指定扩大扫描范围:
@SpringBootApplication(scanBasePackages = "com.example") public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }这样会让 Spring 扫描 com.example 下所有包,对学习阶段来说最简单。代价是扫描范围变大,容易把无关的候选组件都纳入容器,运行期略有损耗,也增加了同名 Bean 冲突的概率。等你对模块边界有更深理解后,会更愿意用显式 Import 的方式,而不是永远扩大音符范围。
4.2 用配置类和 @Import 做显式注册
当模块一多,靠一个巨大 scanBasePackages 吃遍全工程的做法就不再合适。更可控的做法是:每个模块自己准备一个配置类,模块内的组件只由该配置类负责扫描,然后由主工程通过 @Import 显式引入。
比如 demo-user 模块里放一个 UserModuleConfig:
@Configuration @ComponentScan("com.example.demo.user") public class UserModuleConfig { }主启动类再引用它:
@Import(UserModuleConfig.class) @SpringBootApplication public class DemoApplication { // ... }把这种方式跑通之后,你会明显感觉模块边界清晰了:哪个模块有没有被引入,一眼就能在代码里看出来;不用再猜某个类到底有没有被扫描到。这个习惯往深了走,也贴合 Spring Boot 自动配置的原理:各种 EnableXxx 注解、AutoConfiguration 背后的思路,都是先声明一个配置类,再被框架有选择地引入。
4.3 Mapper 接口不单独扫描,MyBatis 肯定不认账
如果项目用了 MyBatis,新模块里写了 UserMapper 接口和对应 XML 文件,启动后调用时却报 No qualifying bean of type,说明 MapperScan 还没覆盖到这个 mapper 所在的包。
需要在配置类或者启动类上加:
@MapperScan("com.example.demo.user.mapper")更贴近实际工程的做法是,在 UserModuleConfig 这种模块配置类上单独声明自己的 mapper 扫描范围,这样每个模块都能自己控制数据访问层边界,不至于全工程只靠一个 @MapperScan 挂全局包。
还有一个常见的连带问题:XML 文件没有被正确加载。检查一下新模块的 src/main/resources 是否被 IDEA 标记为资源根目录,以及 Maven 打包时 xml 文件是否被包含进来。MyBatis 的 mapper-locations 配置明明写着 classpath:/mappers/**/*.xml,却找不到,多数时候不是路径写错,而是资源目录标记不对,或者模块本身没有被启动模块依赖进 classpath。
4.4 模块间协作,要依赖注入,不要用 new 绕过 Spring
这一点是我在带人做模块话开发时最常纠正的。新模块建好后,跨模块调用另一个模块的 Service,有人图省事直接在 Controller 里 new 一个 Service 出来:
UserService userService = new UserService();表面上能跑,实际上这个自建对象没有被 Spring 托管,它内部依赖的其他 Bean 很可能全部为空。正确的写法是把它声明为 Spring 管理的 Bean,通过构造器注入使用:
@RestController public class OrderController { private final UserService userService; public OrderController(UserService userService) { this.userService = userService; } }多模块场景里,跨模块引用类会越来越频繁,依赖图也会越来越复杂。如果在第一步就选择用 new 逃课,后面就会不断用各种补丁去掩盖实际上的组装错误,最终模块之间边界会变得非常混乱。老老实实全部走 Spring 容器,模块的边界才是真实的依赖边界,而不只是源码目录文件夹。
5. 在实际新建和接入模块后,我反复踩到的报错与排查
这类问题我不能保证你一个不踩,但按照下面的排查链路走,绝大多数能在半小时内定位到根因。
5.1 “程序包不存在”:多数时候依赖没同步或没被聚合
典型场景是 IDEA 里代码能自动补全另一个模块的类,但执行 Maven compile 或启动时,报 package com.example.demo.common does not exist。这里有很坑的一点:IDEA 的代码补全并不等于 Maven 依赖已经建立,它只是索引到了一个类而已。真正的约束在 Maven 的依赖声明里。
排查链路我固定如下。第一步,到需要引用方的 pom.xml 里确认依赖已经加上,并且依赖的是内部模块的 artifactId。第二步,到右侧 Maven 面板看父工程下是否列出了该模块,如果没有,检查父 POM 的 modules 列表。第三步,在父工程上执行 mvn clean install,把内部模块安装到本地仓库,这样其它模块才能解析到。第四步,回到 IDEA,点击 Maven 面板的刷新按钮,让 IDEA 重新读取本地仓库状态。这条链路里,十有七八是前三步中漏了一步。
还有一种比较隐蔽的情况:模块是从别的工程拷贝进来的。IDEA 确实把它显示出来了,包名也正常,但父 POM 的 modules 列表没有注册它,其它模块引用它时就一直报程序包不存在。所以引用别的模块前,务必先确认三件事:模块被父 POM 聚合、模块自身编译通过、依赖方 pom 已声明引用。三件事环环相扣。
5.2 启动时找不到 Bean,九成是扫描范围问题
Spring 的报错很直白:Field userService in com.example... required a bean of type ... that could not be found。遇到这个报错,先别急着怀疑程序写错,按下面顺序排查。
第一,看看组件类上有没有加 @Service、@Component、@Repository 这类注解。第二,看它所在的包是否在启动类的扫描范围之内。第三,如果确认包范围没问题,再看是否有多个同类型候选 Bean 导致 Spring 不知道该注入哪一个。第四,查一下有没有在新模块里手写 new 对象,导致该扫描到的类没有被容器管理。
这里分享一个我自己常用的定位技巧:在启动后打印容器里注册的 Bean 名称。可以在启动类里通过 ApplicationContext 把 getBeanDefinitionNames 输出一下,确认自己的类是否在列表里。这一步能立刻区分两种情况:容器确实没支配这个类,还是 BeanName 生成规则和自己预期不一样。它能帮你从盲猜切换到精确验证。
5.3 模块之间互相引用,容易把依赖图拧成死结
比如 demo-user 依赖 demo-order,demo-order 又依赖 demo-user,Maven 解析时可以没完,Spring 初始化也极可能卡在循环依赖上。三个模块以上时,这种互相引用会越缠越紧,最后你会发现自己改一个模块会牵连三个模块同时编译,每个模块都有理由依赖对方。
这类问题的根因通常不是 Maven 配置,而是模块边界一开始没划好。处理思路不是调配置硬解,而是把两个模块共同使用的基础类下沉到更底层模块,比如拆出一个 demo-common,里面放公共的常量、工具类、统一返回结构,让两个业务模块都只依赖 common,不再互相依赖。依赖方向这件事无论如何强调都不过分:模块和模块之间应该是清晰的单向依赖,形成裸循环依赖时,表面上是构建报错,深层是职责边界模糊。
5.4 resources 资源没生效的两种容易被忽略的表现
新模块里放了 application.yml,启动日志中却没加载,或者放了一个 sql 文件打不进包。第一种可能,IDEA 没有把 src/main/resources 标记为资源根目录,在 Project Structure 的模块设置里可以检查,被正确标记的资源目录会显示绿色图标。第二种可能,是启动模块没有依赖这个新模块,导致这个模块的 classpath 资源根本不会出现在最终运行 classpath 里。这两种情况容易同时存在,所以排查时先看依赖关系,再右键目录看一下标记状态。
现实里还有一种表现比较诡异:IDEA 的 Maven 面板显示模块依赖,但打包运行后缺少 XML 文件。这通常涉及 Maven 的 resource 过滤配置,需要进一步在 pom 的 build/resource 节点处理,学习阶段遇到得比较少。先把目录标记和模块依赖检查完,再往这些深水区走。
6. 从“会新建模块”到形成自己的模块划分思路
6.1 先单模块跑顺,再谈模块化
模块不是越多越好。我曾经见过项目中一共五个模块,其中四个相互依赖,构建顺序明明暗暗,最终谁也不敢动任何一个模块里的公共类。这个局面的根源,是在没有充分理解单模块内部的包结构和职责时,硬拆出来的结果。
比较稳妥的路线是:首先在单模块的 Spring Boot 工程里,把一个完整小功能跑通,包括 entity、Mapper、Service、Controller 和启动类。当你能清楚说出这个项目里哪些类是通用基础、哪些是业务逻辑的时候,再把通用部分抽取成一个新模块。多模块的意义在于隔离变化和复用公共能力,不在于让目录树显得豪华。一个拆了却转不动的模块,比不拆更难受。
6.2 一个适合学习的三模块最小布局
如果只想拿一个足够用的模板来练手,我建议使用下面这个最小布局,而不是去模仿那些大型开源项目动辄十几个模块:
spring-multi-demo ├── pom.xml (父 POM,packaging=pom) ├── demo-common (通用:统一返回、工具类、常量) ├── demo-user (用户业务:Controller、Service、Mapper) └── demo-web (启动模块:application、全局配置)在这个布局中,demo-web 依赖 demo-user 和 demo-common,demo-user 依赖 demo-common,整体没有循环依赖。练习时,代码改动集中在 demo-user,启动入口在 demo-web,通用基础件放 demo-common,方向非常清楚。
这个结构为什么会顺?common 层放的是不依赖具体业务的通用能力;业务层只管具体领域的代码;启动模块只负责组装和运行。这种切法也接近我遇到的大多数后台管理、商城类开源项目的初始骨架,只是真实项目里的模块名会换成 system、goods、member 一类,骨架逻辑大同小异。
6.3 每建一个模块,就做一次“两步走”
我后来给自己定了一个固定习惯:新建模块下来,不立刻写业务代码。第一步,把模块加入父 POM 的 modules 列表,让 Maven 编译通过一次;第二步,写一个最简单的 Service,并在主启动类里尝试注入它,确认从编译期到容器启动全链路都正常。两步都走通了,再开始填充需求代码。
这样做的好处是:模块本身的接入问题是独立的,不会和业务逻辑混在一起排查。如果你正在学 Spring,刚开始使用模块化,我强烈建议按这个节奏来。一次只增加一个变量,排错难度就会低一个量级。
等你把新建模块、POM 配置、扫描机制、依赖注入这些细节串成一条线之后,后面再看 Spring 的 Bean 生命周期、三级缓存和循环依赖原理,就不会觉得只是停留在源码层面的抽象概念,而是能跟具体工程现象对应上的容器机制。模块新建只是形式上的第一步,容器如何认识并管理这些模块里的 Bean,才是 Spring 学习真正要向深处走的地方。