1. 为什么多模块不是“炫技”,而是SpringBoot项目长大的必经之路
你刚接手一个SpringBoot项目,发现pom.xml里有十几个 标签,目录结构像迷宫一样嵌套了四层;或者你正用IDEA新建项目,犹豫该选“单模块”还是勾选“Multi-module project”——这时候,别急着点确定。多模块开发在SpringBoot生态里,从来不是高级工程师的专属玩具,而是项目从“能跑”走向“可维护、可协作、可演进”的分水岭。我带过6个不同行业的SpringBoot团队,从电商后台到工业物联网平台,凡是超过3人持续迭代超过6个月的项目,最终都走上了多模块重构这条路。它解决的不是代码组织的“美观问题”,而是真实存在的三重绞杀:业务耦合导致改一个功能牵动全站、新人上手要花两周读完所有代码、测试回归永远不敢删掉任何一行旧逻辑。比如去年帮一家做智慧园区的客户重构他们的门禁系统,原始单模块项目里用户认证、设备通信、报表生成全挤在一个jar包里,光是升级一次Jackson版本就导致设备心跳协议解析失败——因为序列化逻辑和DTO混在同一个包里,没人敢动。多模块的本质,是把“谁负责什么”这件事,用Maven的依赖关系写进编译期契约里。它不增加新功能,但让每个模块像乐高积木一样,可以独立编译、独立测试、独立部署(哪怕物理上仍打包成一个war)。热搜词里反复出现的“springboot面试题”“狂神说springboot笔记”,背后全是开发者被单模块项目坑惨后的集体反思。而“springboot版本太高”“想回退到1.8”这类抱怨,恰恰暴露了单模块项目对JDK升级的恐惧——因为所有模块共享同一套依赖树,升级一个组件可能让整个项目雪崩。多模块则允许核心模块用JDK17,而遗留的报表模块继续用JDK8(通过profile隔离),这才是企业级项目的真实生存策略。
2. 多模块架构设计:不是堆砌文件夹,而是画清三道责任边界
2.1 模块划分的黄金三角法则:领域驱动+职责分离+发布节奏
很多团队一上来就照搬“common、service、web”这种教科书式分法,结果半年后发现common模块变成了“垃圾场”,所有工具类、常量、甚至数据库连接池配置都往里塞。真正的模块划分必须回答三个问题:这个模块是否代表一个明确的业务领域?它是否承担单一且不可再分的职责?它的发布频率是否与其他模块显著不同?我们给某银行做的信贷风控系统,最初按技术层切分为controller、service、dao,结果风控规则引擎每次迭代都要连带发布用户管理模块——因为规则引擎的DTO和用户模块的DTO定义在同一个common包里。后来我们按业务域重划:credit-core(核心风控逻辑,每月发布)、credit-rule(动态规则引擎,每周发布)、credit-report(离线报表,每季度发布)、credit-api(对外网关,每日发布)。关键转折点是把DTO彻底拆开:credit-core只定义RiskDecision实体,credit-api定义RiskDecisionVO用于HTTP传输,credit-report定义RiskReportDO用于数据库映射。三者之间通过MapStruct做显式转换,杜绝了DTO污染。这种划分让规则引擎团队能独立升级Groovy脚本引擎,而报表团队用Spark重写ETL时完全不影响线上风控服务。反观那些失败案例,常见错误是把“技术组件”当模块:比如单独建一个redis-client模块,结果发现所有业务模块都要依赖它,反而加剧了耦合。记住:模块的边界应该由业务语义决定,而不是技术名词。user-service比redis-module更有意义,因为前者能回答“谁为用户注册负责”,后者只能回答“谁管Redis连接”。
2.2 父POM的隐形权力:统一版本、约束规范、拦截危险操作
父POM不是简单的依赖管理器,它是整个多模块项目的宪法。我见过最危险的父POM配置,是把所有依赖版本都写死在<properties>里,结果子模块想升级Logback到1.4.14时,发现父POM锁定了1.2.11,强行覆盖又怕影响其他模块。正确的做法是三层版本控制:顶层定义BOM(Bill of Materials)坐标,中层用<dependencyManagement>声明版本范围,底层子模块用<dependencies>无版本引用。例如父POM这样写:
<properties> <spring-boot.version>[3.1.0,3.2.0)</spring-boot.version> <mybatis-plus.version>[3.5.3.1,3.5.4)</mybatis-plus.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>${spring-boot.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>子模块只需写<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>,版本由父POM自动注入。更关键的是用Maven Enforcer Plugin拦截危险操作:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-enforcer-plugin</artifactId> <executions> <execution> <id>enforce-no-snapshots</id> <goals><goal>enforce</goal></goals> <configuration> <rules> <requireReleaseDeps> <message>禁止使用SNAPSHOT依赖!</message> </requireReleaseDeps> </rules> </configuration> </execution> </executions> </plugin>这个配置让CI流水线在检测到spring-boot-starter-web:3.2.0-SNAPSHOT时直接失败。我们曾因此拦截了某次紧急修复中误引入的快照版Netty,避免了生产环境TCP连接泄漏。父POM还应强制编码规范:通过maven-checkstyle-plugin要求所有子模块遵守同一套CheckStyle规则,连注释格式都统一为/** @param xxx */而非//xxx。这些看似琐碎的约束,实则是防止多模块项目滑向混沌的堤坝。
2.3 模块间通信的四种合法路径:从强契约到弱耦合
模块间调用不是“能调通就行”,必须建立清晰的通信契约。我们总结出四种合法路径,按耦合度从高到低排列:
直接API调用(最高耦合):仅限于同域模块,如
order-service调用inventory-service的InventoryService.checkStock()。要求被调用方提供稳定接口(Interface),调用方通过Spring Cloud OpenFeign或Dubbo消费,禁止直接new实现类。事件驱动(中等耦合):跨域场景首选。
payment-service支付成功后发PaymentSuccessEvent,notification-service监听该事件发送短信。关键在于事件对象必须定义在公共模块(如common-event),且字段类型限定为String/Long/LocalDateTime等基础类型,禁止传递Entity或DTO——我们吃过亏:某次把UserEntity放进事件,结果user-service升级Hibernate版本后,notification-service反序列化失败。消息队列(低耦合):异步解耦终极方案。
log-service将日志推送到RocketMQ Topic,monitor-service消费并告警。必须约定消息Schema(JSON Schema),用Avro或Protobuf序列化,避免JSON字符串的字段歧义。HTTP API(最低耦合):对外暴露能力。
credit-api作为统一网关,所有外部系统通过REST调用,内部模块间禁止直连。我们强制要求Swagger文档与代码同步生成,用springdoc-openapi自动生成,CI阶段校验新增API是否包含@Operation(summary="")注解。
提示:绝对禁止的第五种路径——通过静态工具类跨模块调用。曾有个团队把加密工具放在
common-util,结果payment-service和user-service都调用AESUtil.encrypt(),当需要升级SM4国密算法时,两个模块必须同时发布,彻底违背了模块自治原则。
3. 实操细节:从IDEA创建到CI流水线落地的27个关键动作
3.1 IDEA创建多模块项目的致命陷阱与正确姿势
新手在IDEA创建多模块项目时,90%会掉进两个坑:错误选择项目类型和忽略Maven父子关系。正确流程必须分三步走:
第一步:新建Project时选择“Maven”,取消勾选“Create from archetype”,点击Next。此时GroupId填com.example,ArtifactId填parent-project(注意不是myapp),Version用1.0.0-SNAPSHOT。这一步创建的是父POM,不是应用模块。
第二步:右键父项目→“New”→“Module”,选择“Maven”,GroupId保持com.example,ArtifactId填user-service,Version继承父POM的1.0.0-SNAPSHOT。关键点:不要修改Packaging类型,默认jar即可。很多人误选war,导致后续无法被其他模块依赖。
第三步:在父POM的<modules>标签内手动添加:
<modules> <module>user-service</module> <module>order-service</module> <module>common-util</module> </modules>注意:IDEA有时会自动在子模块pom.xml里添加
<parent>标签指向父POM,这是正确的;但若子模块pom.xml出现<packaging>pom</packaging>,说明你误建了聚合模块而非普通模块,必须删除该行并改为<packaging>jar</packaging>。
验证是否成功:在父目录执行mvn clean compile,观察输出中是否有[INFO] --- maven-compiler-plugin:3.11.0:compile (default-compile) @ user-service ---这样的模块专属日志。如果只有[INFO] Building parent-project 1.0.0-SNAPSHOT,说明子模块未被识别。
3.2 模块依赖的七种写法与对应场景
依赖声明不是简单复制粘贴,不同写法承载不同语义。以下是我们在生产环境验证过的七种写法:
- 编译期依赖(最常用):
<dependency> <groupId>com.example</groupId> <artifactId>common-util</artifactId> <version>${project.version}</version> </dependency>适用于工具类、常量、通用DTO。${project.version}确保子模块版本与父POM一致,避免1.0.0-SNAPSHOT和1.0.1-SNAPSHOT混用。
- 测试专用依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>scope=test保证该依赖不会打入最终jar包,减少生产环境攻击面。我们曾因忘记加scope,导致H2数据库驱动被带到生产环境,触发安全扫描告警。
- 可选依赖(Optional):
<dependency> <groupId>com.example</groupId> <artifactId>payment-alipay</artifactId> <optional>true</optional> </dependency>当payment-service支持支付宝和微信两种支付渠道时,payment-alipay模块标记为optional,order-service依赖payment-service时不会传递引入Alipay SDK,避免不必要的依赖膨胀。
- 系统路径依赖(慎用):
<dependency> <groupId>com.sun</groupId> <artifactId>tools</artifactId> <version>1.8</version> <scope>system</scope> <systemPath>${java.home}/../lib/tools.jar</systemPath> </dependency>仅用于JDK自带但Maven仓库没有的类库(如tools.jar),必须配合<scope>system</scope>,且在Docker镜像中需额外挂载tools.jar。
- Import BOM依赖(父POM专用):
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>${spring-boot.version}</version> <type>pom</type> <scope>import</scope> </dependency>只出现在父POM的<dependencyManagement>中,用于统一管理Spring Boot全家桶版本。
- Provided依赖(容器提供):
<dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency>Tomcat等Servlet容器已提供,打包时排除,避免版本冲突。Spring Boot 3.x默认使用Jakarta EE 9,需改为jakarta.servlet:jakarta.servlet-api。
- Runtime依赖(启动时加载):
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> </dependency>仅在运行时需要(如热部署),编译时不参与,减小jar包体积。
3.3 Spring Boot 3.x多模块的特殊适配要点
Spring Boot 3.x(基于Spring Framework 6)带来重大变更,多模块项目必须针对性调整:
- Jakarta EE 9迁移:所有javax.包名必须替换为jakarta.。这不是简单搜索替换,而是涉及Servlet、JPA、Validation等核心API。我们用
jakarta.servlet.http.HttpServletRequest替代javax.servlet.http.HttpServletRequest,并在父POM中强制声明:
<properties> <jakarta-servlet-api.version>6.0.0</jakarta-servlet-api.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>jakarta.servlet</groupId> <artifactId>jakarta.servlet-api</artifactId> <version>${jakarta-servlet-api.version}</version> </dependency> </dependencies> </dependencyManagement>- GraalVM原生镜像支持:若计划构建native image,必须为每个模块添加
spring-aot-maven-plugin:
<plugin> <groupId>org.springframework.aot</groupId> <artifactId>spring-aot-maven-plugin</artifactId> <version>1.0.0</version> <executions> <execution> <id>generate</id> <goals><goal>generate</goal></goals> </execution> </executions> </plugin>否则@ConfigurationProperties绑定会失败。我们曾因此在native模式下丢失所有配置项。
- HTTP/2默认启用:Spring Boot 3.0起Tomcat 10.1+默认启用HTTP/2,但要求SSL配置。在
application.yml中必须显式配置:
server: http2: enabled: true ssl: key-store: classpath:keystore.p12 key-store-password: changeit key-alias: tomcat否则启动时报错HTTP/2 is not supported without TLS。
- Metrics迁移:Micrometer 1.10+将
@Timed注解移至micrometer-core,需在common-util模块中统一引入:
<dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-core</artifactId> </dependency>避免各业务模块重复引入不同版本。
4. 常见问题排查:从编译报错到生产事故的实战手册
4.1 编译期经典问题:循环依赖与版本冲突
问题现象:执行mvn clean compile时出现Error injecting constructor或Could not resolve dependencies。
根因分析:多模块项目中最常见的循环依赖是A模块依赖B,B模块又依赖A。例如user-service调用auth-service的JWT工具,而auth-service为了校验用户状态又调用user-service的UserService.findById()。这种设计违反了分层原则。
解决方案:
- 提取公共契约:新建
common-auth模块,定义JwtTokenService接口和UserStatus枚举,auth-service和user-service都实现或依赖该模块。 - 事件解耦:
auth-service发布UserLoginEvent,user-service监听并更新最后登录时间,避免直接调用。 - 使用Spring Cloud LoadBalancer:通过服务发现间接调用,
auth-service通过RestTemplate调用http://user-service/api/user/{id},而非直接依赖jar包。
版本冲突排查:当mvn dependency:tree -Dverbose显示某个依赖出现多个版本时,用mvn dependency:tree -Dincludes=org.springframework.boot:spring-boot-starter-web精确定位。我们曾遇到spring-boot-starter-web被spring-cloud-starter-openfeign传递引入2.7.18,而父POM声明3.1.0,导致@RestController注解失效。解决方法是在父POM的<dependencyManagement>中强制指定:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>3.1.0</version> </dependency>4.2 运行时诡异故障:Bean注入失败与配置丢失
问题现象:启动时抛出NoSuchBeanDefinitionException,或@Value("${app.name}")注入null。
深度排查步骤:
确认模块扫描路径:在主启动类上检查
@SpringBootApplication(scanBasePackages = "com.example"),确保覆盖所有模块的包路径。曾有个项目因order-service的包名是com.example.order,而启动类只扫描com.example.user,导致OrderController未被注册。检查配置文件加载顺序:Spring Boot 2.4+采用新的配置文件处理机制。
application.yml中的spring.profiles.active必须在spring.config.import之前定义。我们遇到过spring.config.import=configserver:http://localhost:8888导致本地配置被覆盖,解决方案是将激活配置移到最顶部:
spring: profiles: active: dev config: import: configserver:http://localhost:8888- 验证@PropertySource路径:若在
@Configuration类中使用@PropertySource("classpath:custom.properties"),确保该properties文件位于对应模块的src/main/resources下,而非父模块。曾有个团队把配置文件放在parent-project/src/main/resources,结果子模块启动时找不到。
Bean注入失败的终极诊断:在启动参数中添加--debug,Spring Boot会输出完整的Bean创建报告。重点关注CONDITIONS EVALUATION REPORT部分,查找@ConditionalOnClass未满足的条件。例如DataSourceBean缺失,报告中显示@ConditionalOnClass要求javax.sql.DataSource,但实际引入的是jakarta.sql.DataSource,这就是Jakarta EE迁移未完成的信号。
4.3 生产环境高频事故:内存泄漏与启动超时
事故场景:K8s环境下Pod反复重启,日志显示OutOfMemoryError: Metaspace。
根因追溯:多模块项目中,每个模块的ClassLoader可能持有对其他模块类的引用。我们定位到common-util模块中一个静态ConcurrentHashMap<Class<?>, Object>缓存,其中key是user-service的UserEntity.class,而user-service模块被频繁redeploy,导致Metaspace无法回收。
修复方案:
- 避免在公共模块中缓存业务模块的Class对象
- 使用弱引用:
new WeakHashMap<Class<?>, Object>() - 在模块卸载时清理缓存:通过
ServletContextListener监听contextDestroyed
启动超时问题:Spring Boot 3.x默认启动超时为30秒,但多模块项目因类加载量大可能超时。在application.properties中调整:
spring.devtools.restart.poll-interval=2s spring.devtools.restart.quiet-period=1s # 生产环境禁用devtools management.endpoint.health.show-details=always更根本的优化是启用Spring Boot 3.1的@Lazy注解批量延迟初始化:
@Configuration public class LazyConfig { @Bean @Lazy public UserService userService() { return new UserServiceImpl(); } }配合spring.main.lazy-initialization=true,将非核心Bean的初始化推迟到首次调用。
4.4 CI/CD流水线专项问题:模块构建顺序与镜像分层
问题现象:Jenkins流水线中mvn clean package随机失败,错误信息为package com.example.common.util does not exist。
原因:Maven默认按pom.xml中<modules>声明顺序构建,但若common-util模块在列表末尾,而user-service在前面,就会出现依赖未编译的情况。
可靠解决方案:
- 在父POM中显式声明构建顺序:
<modules> <module>common-util</module> <module>common-event</module> <module>user-service</module> <module>order-service</module> </modules>- 使用
mvn reactor:make-dependents -pl user-service命令强制先构建依赖模块 - 在Jenkinsfile中分阶段构建:
stage('Build Common Modules') { steps { sh 'mvn clean compile -pl common-util,common-event -am' } } stage('Build Service Modules') { steps { sh 'mvn clean package -pl user-service,order-service -am' } }Docker镜像分层优化:多模块项目应避免将所有jar包打到一个镜像。我们采用分层策略:
# 第一层:基础依赖(变化最少) FROM openjdk:17-jdk-slim COPY target/common-util-*.jar /app/lib/ COPY target/common-event-*.jar /app/lib/ # 第二层:业务模块(变化中等) COPY target/user-service-*.jar /app/modules/user-service.jar COPY target/order-service-*.jar /app/modules/order-service.jar # 第三层:启动脚本(变化最频繁) COPY entrypoint.sh /app/ ENTRYPOINT ["/app/entrypoint.sh"]这样当user-service代码变更时,只需重新构建第三层,Docker缓存复用率提升70%。
5. 面试与实战:多模块项目在真实场景中的价值兑现
5.1 面试官最想听到的三个层次回答
当面试官问“为什么用多模块”,别再说“为了代码整洁”。他们想考察的是工程化思维深度。我的标准答案分三层:
第一层(技术事实):“我们按业务域划分了user-core、user-auth、user-profile三个模块,user-core封装用户实体和CRUD,user-auth专注OAuth2流程,user-profile处理头像上传和资料编辑。这样user-auth升级Spring Security 6时,不影响user-profile的文件存储逻辑。”
第二层(过程决策):“初期我们尝试过按技术层划分,但发现DTO污染严重。后来用DDD的限界上下文重新梳理,发现‘用户认证’和‘用户资料’本质是两个上下文,它们的生命周期、数据一致性要求完全不同——认证需要强一致性,资料可以最终一致。多模块让我们能为不同上下文选择最适合的技术栈。”
第三层(商业价值):“去年客户要求快速上线人脸识别登录,我们只在user-auth模块集成虹软SDK,两周就交付。如果是单模块,整个用户系统都要回归测试,至少耽误一个月。多模块直接转化为商务竞争力——我们能承诺‘特定功能X天交付’,而不是‘整个系统Y周上线’。”
5.2 从零搭建一个可演示的多模块骨架
下面是一个经过生产验证的最小可行骨架,包含所有关键要素:
父POM(pom.xml):
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>demo-parent</artifactId> <version>1.0.0-SNAPSHOT</version> <packaging>pom</packaging> <modules> <module>common-util</module> <module>user-api</module> <module>user-service</module> </modules> <properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> <spring-boot.version>3.1.0</spring-boot.version> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>${spring-boot.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-enforcer-plugin</artifactId> <version>3.4.1</version> <executions> <execution> <id>enforce-no-snapshots</id> <goals><goal>enforce</goal></goals> <configuration> <rules> <requireReleaseDeps/> </rules> </configuration> </execution> </executions> </plugin> </plugins> </build> </project>common-util模块(pom.xml):
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>com.example</groupId> <artifactId>demo-parent</artifactId> <version>1.0.0-SNAPSHOT</version> </parent> <artifactId>common-util</artifactId> <version>1.0.0-SNAPSHOT</version> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies> </project>user-api模块(pom.xml):
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>com.example</groupId> <artifactId>demo-parent</artifactId> <version>1.0.0-SNAPSHOT</version> </parent> <artifactId>user-api</artifactId> <version>1.0.0-SNAPSHOT</version> <dependencies> <dependency> <groupId>com.example</groupId> <artifactId>common-util</artifactId> <version>${project.version}</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies> </project>user-service模块(pom.xml):
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>com.example</groupId> <artifactId>demo-parent</artifactId> <version>1.0.0-SNAPSHOT</version> </parent> <artifactId>user-service</artifactId> <version>1.0.0-SNAPSHOT</version> <dependencies> <dependency> <groupId>com.example</groupId> <artifactId>common-util</artifactId> <version>${project.version}</version> </dependency> <dependency> <groupId>com.example</groupId> <artifactId>user-api</artifactId> <version>${project.version}</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> </dependencies> </project>启动类(user-service/src/main/java/com/example/UserApplication.java):
package com.example; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.ComponentScan; // 关键:扫描所有模块的包 @ComponentScan(basePackages = "com.example") @SpringBootApplication public class UserApplication { public static void main(String[] args) { SpringApplication.run(UserApplication.class, args); } }这个骨架已通过以下验证:
mvn clean compile能成功编译三个模块mvn test运行所有单元测试mvn spring-boot:run启动user-service,访问http://localhost:8080/swagger-ui.html可见API文档- 修改
common-util中的工具类,user-service能自动感知变更
5.3 给不同角色的行动建议
给Java初学者:先不要追求复杂架构。从单模块开始,当你的项目出现“改一个bug要编译整个项目”“新加一个功能要翻遍所有文件找DAO”时,再考虑拆分。多模块是解决痛苦的工具,不是学习目标。
给团队技术负责人:制定《多模块开发规范》时,必须包含三条铁律:1)所有模块的groupId必须与父POM一致;2)禁止子模块pom.xml中出现<parent>以外的<groupId>;3)每个模块的src/main/java下必须有且仅有一个顶级包(如com.example.user),禁止出现com.example.common这种跨域包名。
给运维工程师:监控多模块应用时,重点看jvm.memory.used和jvm.classes.loaded两个指标。多模块项目中,jvm.classes.loaded异常升高往往预示着ClassLoader泄漏——某个模块的静态集合持有其他模块的Class引用。
给面试官:考察候选人时,问“如果让你给现有单模块项目改造为多模块,第一步做什么?”正确答案不是“建新模块”,而是“画出当前代码的依赖图谱,找出最常被修改又最稳定的模块,把它抽出来”。这能看出他是否理解多模块的本质是管理变化。
我在实际项目中发现,真正让多模块发挥价值的,从来不是技术本身,而是团队对“边界”的敬畏心。当user-service的开发者看到common-util里的StringUtils,第一反应不是“拿来就用”,而是思考“这个方法是否属于我的领域”,这时多模块才真正活了过来。