Spring Cloud微服务架构下的Maven多模块工程搭建实践

发布时间:2026/10/14 6:50:20
Spring Cloud微服务架构下的Maven多模块工程搭建实践
做微服务绕不开的一步就是把原来那种一个项目塞所有功能的单体工程拆成结构清晰、边界分明的多模块工程。我见过太多团队在Spring Cloud项目里直接用一堆互相独立的小项目来组织代码结果依赖版本对不上、公共代码重复维护、编译顺序乱成一团。Maven多模块正是解决这些问题的最朴素、也最实用的一套工程方案。这篇笔记基于我实际搭建Spring Cloud多模块工程的经验完整记录从模块划分、父子POM配置、依赖版本管理到业务模块落地、本地多实例调试的全部过程并把我踩过的坑一并列出来供准备搭建微服务工程的同学直接参考。1. 先想清楚为什么要拆成多模块而不是一个项目打天下1.1 单体项目走向微服务的第一步很多人在聊微服务的时候注意力全放在注册中心、网关、熔断这些框架能力上却忽略了一个最基础的问题代码仓库到底怎么组织。以前做单体应用一个Maven工程里按包名区分controller、service、mapper团队小的时候很顺手。可一旦上了Spring Cloud服务被拆成若干个独立的进程你还把所有代码堆在一个Maven工程里就会面临几个很现实的问题第一个问题是公共模块没法复用。比如统一返回结果、异常处理、Feign的通用配置、工具类这些代码多个服务都要用。单体工程里复制粘贴一份就完事但拆成多个独立小项目后复制粘贴就是灾难——改一个字段要同步七八个地方漏一个就是线上事故。第二个问题是依赖版本失控。每个小项目各自引入Spring Boot、Spring Cloud的依赖今天升级这个明天升级那个版本号散落在不同的pom文件里一眼望过去谁也不知道当前项目用的哪套版本组合。等到联调的时候才发现A服务用的Feign版本和B服务不兼容那感觉别提多酸爽。第三个问题是构建顺序没人管。多个独立项目之间有依赖关系比如订单服务依赖公共模块、用户服务依赖公共模块那你每次打包都得手动先构建公共模块再构建具体服务。忘了就先报错多构建一次又浪费时间。Maven多模块工程则天然解决这个问题父工程能统一识别模块依赖关系一条命令从上往下按依赖顺序批量构建顺序完全不用你操心。1.2 多模块划分的几种经典方式我见到过比较常见的划分方式大概有三种具体选哪种要看团队规模和业务复杂度。第一种是最标准的父工程 基础模块 业务服务三层结构。父POM负责统一依赖版本下面的模块分成两类一类是基础模块比如common模块放通用代码另一类是业务服务模块比如system-service、order-service。每个业务服务对应一个可独立启动的Spring Boot应用。这种结构最简单清晰适用于绝大多数中小型团队。第二种是聚合模块 可复用组件结构额外增加一个component模块用来沉淀真正被多个服务复用的业务级组件比如消息发送、文件存储、登录态校验等。这种比common模块的定位更深入适合多个服务之间有较强横切逻辑的场景但对模块划分能力要求更高划不好容易变成一个大杂烩。第三种是按技术分层的结构比如一个模块放mapper一个模块放service接口一个模块放web控制器整体按技术维度切。这种方式在单体应用过渡期见过一些但在Spring Cloud下我其实不太推荐因为你把同一业务的代码切到不同模块里服务边界被打散了到最后绕来绕去还是回到了大泥球。我目前最推荐的还是第一种按业务边界和复用粒度划分同时把能独立部署作为模块的立身之本。父模块只管聚合和版本common模块只管公共能力业务服务只管自己的逻辑。2. 打好地基父工程与子模块的搭建全过程2.1 从IDE创建一个父子工程我以IDEA为例记录整个创建过程因为这是多数团队在用的开发工具。第一步是新建一个普通的Maven工程作为父工程这里有个非常关键的细节新建父工程的时候除了artifactId其他信息正常填创建完成后要把src目录直接删掉。父工程本身不写代码它的存在只是为了聚合子模块和统一管理依赖src目录留着反而碍眼。紧接着修改父pom.xml把packaging标签改成pom。Maven工程有三类打包方式jar代表可执行或被依赖的Java库war代表Web应用pom代表纯聚合用途。父工程把packaging设为pom后Maven才知道这是一个纯粹的聚合工程它不会尝试去编译源码也不会生成可执行文件而是把子模块统一纳管起来。然后在这个父工程上右键新建Maven模块依次创建common、system-service、order-service等子模块。每创建完一个子模块父pom的modules标签里会自动加上对应的module信息。这里我习惯检查一下确保所有子模块的路径书写无误比如modulesystem-service/module表示和父工程平级的目录路径写错的话构建会直接报错。2.2 父POM的关键配置解析父工程的pom是整个多模块工程的地基配置得好后面所有子模块都能受益。我贴一份精简但完整的父pom内容说明关键配置的用途和必须注意的地方。groupIdcom.example/groupId artifactIdmicroservice-parent/artifactId version1.0.0/version packagingpom/packaging modules modulecommon/module modulesystem-service/module moduleorder-service/module /modules properties project.build.sourceEncodingUTF-8/project.build.sourceEncoding maven.compiler.source8/maven.compiler.source maven.compiler.target8/maven.compiler.target spring.boot.version2.7.18/spring.boot.version spring.cloud.version2021.0.8/spring.cloud.version spring.cloud.alibaba.version2021.0.5.0/spring.cloud.alibaba.version /properties dependencyManagement dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-dependencies/artifactId version${spring.boot.version}/version typepom/type scopeimport/scope /dependency dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-dependencies/artifactId version${spring.cloud.version}/version typepom/type scopeimport/scope /dependency dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-alibaba-dependencies/artifactId version${spring.cloud.alibaba.version}/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement这段配置里最值得讲清楚的是dependencyManagement的import方式。Spring Boot和Spring Cloud都发布了自己的BOMBill of Materials一份特殊的POM文件里面罗列了该套框架所有组件对应的官方推荐版本通过import的方式把这个BOM导入就好像引入了一份官方版本清单。有了这份清单子模块里再引入Spring Cloud相关组件时就完全不用写版本号了系统会自动从这份清单里找到匹配的版本。举例来说子模块要引入OpenFeign直接这样写dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-openfeign/artifactId /dependency不用写version这就是dependencyManagement最核心的价值。我见过不少新手把版本号直接写在子模块的依赖里这就完全绕过了父工程的统一管理后续排查版本问题会非常痛苦。尽早养成子模块一律不写版本号的习惯是搭好Maven多模块工程最重要的一件事。3. 核心依赖管理与版本控制3.1 版本统一管理的两种级别Maven的依赖管理要区分两个不同的概念一个是dependencyManagement另一个是dependencies。这俩长得像但行为完全不同我经常用一句大白话解释它们的区别dependencyManagement是账本只登记版本号不真正引入依赖dependencies是领料单只要写出来这个依赖一定会被引入到当前模块。具体到多模块的实践里父工程的dependencyManagement只负责统一登记版本子模块按需在dependencies里声明自己要用的依赖。这种设计的妙处在于版本号全局只维护一份但每个子模块的依赖体积可以保持精简。比如订单服务不需要Redis那它就不引入Redis相关的starter系统服务不需要消息队列那它就不引入消息队列的客户端。既控制了依赖膨胀又保证了版本一致性。还有一种情况需要注意就是Spring Boot的parent。我在上面父pom里用了dependencyManagement导入BOM的方式来管理Spring Boot依赖这意味着子模块并没有继承Spring Boot的parent POM。直接后果是Spring Boot的Maven插件不能自动生效需要在需要打成可执行jar的服务模块里显式声明插件build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build这一步漏掉的话服务模块打包出来就是一个普通jar直接java -jar启动会报没有主清单属性。3.2 四张POM的依赖关系梳理一个标准的多模块工程正常会有至少四张需要打交道的POM。父工程POM、common模块POM、业务服务模块POM以及如果使用了独立的目录再单独拆出来的其他POM。它们的依赖关系一句话总结父工程只管统一版本和聚合common模块被所有业务服务依赖各业务服务模块之间通常情况下不互相依赖如果有跨服务调用通过OpenFeign接口完成而不是直接引入对方业务模块。这里有个我坚持的约定业务服务模块之间坚决不互相依赖。有人图省事让订单服务直接引入用户服务的模块调用用户服务里的Service方法。这在Maven层面能编译通过但会把两个服务的边界彻底打破部署时订单服务会把用户服务的类也打进去微服务拆分了个寂寞。正确做法是把对外暴露的接口抽到单独的API模块或者放进common模块里服务之间只依赖接口运行时通过HTTP调用。版本管理上我建议在父工程的properties标签里统一声明所有关键版本号比如Spring Boot版本、Spring Cloud版本、Spring Cloud Alibaba版本以及Nacos客户端版本。这三个Spring生态的版本组合非常敏感不是随便拼的一定要参照官方公布的版本对应关系来选择。我目前常用的稳定组合就是Spring Boot 2.7.18、Spring Cloud 2021.0.8、Spring Cloud Alibaba 2021.0.5.0、Nacos服务器2.2.3这个组合我已经在多个项目里验证过运行稳定。4. 基础模块与业务服务落地4.1 common模块让公共代码只存一份common模块是多模块工程里最万金油的角色它被所有业务服务依赖所以放进里面的代码必须足够公共否则就会把其他服务不该有的东西全部带到生产环境里去。我会把以下几类内容放进common模块统一的REST返回结果封装类、全局异常处理器、统一的响应码枚举、常用工具类比如日期转换、JSON解析、对象拷贝、OpenFeign的通用配置拦截器、解码器以及跨服务调用用的DTO和Feign接口。公共DTO这块很多人会纠结放哪我建议放在common里因为它是服务间通信的契约集中管理才能避免订单服务改了字段后用户服务还在用旧字段的尴尬。业务服务模块依赖common模块的写法很简单比如system-service的pom里加上dependency groupIdcom.example/groupId artifactIdcommon/artifactId version${project.version}/version /dependency注意这里的版本号用了${project.version}表示继承父工程统一的版本号。这样写比硬编码版本号要安全得多如果父工程版本升级所有子模块的依赖引用会自动跟着变。启动类的扫描范围是我实际踩过的一个大坑。common模块里的包名是com.example.common业务服务的启动类一般写在com.example.system下Spring Boot启动类默认扫描当前包及子包如果不额外指定扫描范围启动服务时common模块里的类根本不会被加载。Feign接口注册不了、通用配置失效、异常处理器不生效问题表现五花八门根子都在包扫描上。解决办法是在启动类上显式声明扫描路径SpringBootApplication(scanBasePackages {com.example.common, com.example.system}) EnableFeignClients(basePackages {com.example.common.api, com.example.system.api})4.2 业务服务的完整配置清单以system-service为例记录一个完整可启动的业务服务模块需要哪些东西。首先是pom.xml里的启动依赖最基础的是Web应用能力引入spring-boot-starter-web然后是注册中心客户端引入spring-cloud-starter-alibaba-nacos-discovery如果要接配置中心再加spring-cloud-starter-alibaba-nacos-config服务调用用OpenFeign引入spring-cloud-starter-openfeign再加上数据库相关依赖和common模块。依赖数量控制在能完成业务的范围内别一股脑全塞进来。然后是application.yml的关键配置。我这里给出一个带Nacos注册中心和配置中心的精简版本server: port: 8101 spring: application: name: system-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 config: server-addr: 127.0.0.1:8848 namespace: dev group: DEFAULT_GROUP file-extension: yaml profiles: active: dev对于Spring Cloud 2021.0.x版本有一个特别容易忽略的坑bootstrap.yml默认失效。旧版本里配置中心的优先加载依赖bootstrap.yml但2021版本之后Spring Cloud默认不再读取它了。解决方式有两种要么在pom里追加spring-cloud-starter-bootstrap依赖让它恢复传统行为要么把配置中心的参数直接写进application.yml里。我个人推荐后者少一个隐式依赖排查起来思路更清晰。服务间的调用我用OpenFeign的方式实现。Feign接口定义放common模块业务服务里通过Autowired注入使用。Feign接口的关键点是要用FeignClient注解标明目标服务名服务名和你在Nacos里注册的spring.application.name保持一致否则路由不到。FeignClient(name system-service, path /api/system) public interface SystemClient { GetMapping(/user/{id}) ResultUserDTO getUserById(PathVariable(id) Long id); }5. 多实例与配置中心的真实使用5.1 本地启动多个服务实例微服务真正和单体工程拉开差距的地方在于一个服务可以水平部署多个实例。本地开发时为了验证负载均衡和Feign调用经常需要同时启动同一个服务的多个实例。这个需求在Maven多模块工程下做起来有几个小技巧值得分享。IDEA里直接右键一个Spring Boot启动类选择Run只能启动一个实例。想启动第二个先去右上角运行配置里复制一份启动配置然后在VM Options里加一个参数覆盖端口比如-Dserver.port8102。同时建议加上-Dspring.cloud.nacos.discovery.instance-enabledtrue确保这个实例正常注册到Nacos。还有一个点容易出问题多个实例都注册到Nacos后服务列表中会看到多个IP地址相同但端口不同的实例默认情况下Nacos会用IP加端口生成实例ID如果本地起了多个实例但端口没设置到位可能出现实例互相覆盖的问题。如果发现服务列表里只有一个实例优先检查是不是两个实例的instanceId冲突。可以在启动参数里手动指定-Dspring.cloud.nacos.discovery.instance-id${spring.application.name}-${server.port}本地多实例配合OpenFeign调用时Feign默认集成了Spring Cloud LoadBalancer的负载均衡能力请求会在多个实例间轮询分发。你可以故意让两个实例的日志打不同的标志比如加上自定义日志前缀一目了然地看到请求是不是被分散到了不同实例上。5.2 配置中心接入与数据隔离Nacos作为配置中心是Spring Cloud Alibaba路径下的标配选择。多模块工程接入配置中心后每个服务会有三层的配置来源本地application.yml、共享的公共配置、该服务专属的配置。从我和团队使用的经验看建议把绝对的公共配置放到Nacos的共享配置里比如日志级别、监控开关、公共中间件地址而把每个服务自己的差异化配置放到专属配置文件里。Nacos里的配置优先级有一个简单规则spring.cloud.nacos.config.extension-configs里的dataId越靠后优先级越高服务名配置文件的优先级高于extension-configs。环境隔离我是通过Nacos的namespace命名空间实现的。每个环境用一个独立的namespace开发环境、测试环境、生产环境的配置完全隔离。配置中心连接时通过namespace的ID区分环境注意namespace字段填的是创建命名空间时生成的ID不是显示名称这一点填写错误是最常见的接不上配置中心的原因。动态配置刷新是我在日常开发中用到比较多的功能。开启方式是引入spring-cloud-starter-alibaba-nacos-config后在配置类上加RefreshScope注解。配置中心里修改配置并发布后加了注解的Bean会自动刷新不需要重启服务。但要注意RefreshScope只对Spring管理的Bean生效如果某个配置值在静态变量或常量类里被引用刷新不会生效这种场景建议改用配置类Get方法获取。6. 搭建过程中最常踩的坑6.1 编译和启动阶段的坑搭建多模块工程最常见的第一道坎就是编译失败。有一个场景我反复遇到改了common模块的代码后业务服务怎么都不生效甚至报找不到类。原因是本地仓库里common模块还是旧版本的jar包业务服务引用的依赖是从本地仓库加载的并不是工作区里最新改的代码。解决思路很简单但很多人不知道原因修改了公共模块代码后执行一次mvn clean install -DskipTests把最新的common模块安装到本地仓库再启动业务服务。批量构建时在父工程目录执行mvn clean install -DskipTestsMaven会按照modules里声明的顺序先构建common模块再构建依赖它的业务服务这个顺序完全自动完成。第二个高发坑是子模块错误地继承了Spring Boot的parent。有些情况下我把Spring Boot parent写在某个子模块的pom里表面看没问题但会导致子模块的打包方式被Spring Boot插件接管多个模块间出现奇怪的依赖冲突。我的做法是父工程统一用dependencyManagement管理版本只有需要打成可执行jar的业务服务模块才显式引入spring-boot-maven-plugin插件。第三个坑是IDEA里模块之间的依赖不及时更新。Maven多模块工程最常用的操作组合是先对父工程执行Reload All Maven Projects再做一次clean install。如果你对改动了接口签名但没重新install其他模块调用的还是旧的接口定义编译期不报错但运行期会抛方法找不到之类的异常。6.2 依赖和版本配置的坑依赖相关的坑里最多的就是在子模块的依赖里手写了版本号且这个版本号还和父工程dependencyManagement里不一致。Maven的依赖仲裁规则是就近原则子模块里直接声明的版本号优先级高于父工程dependencyManagement里的版本号。这样的结果就是你以为大家都在用同一个版本实际上某些模块偷偷用了别的版本经典的版本冲突就是这么来的。排查这种问题推荐在IDEA里打开Maven面板点击Show Dependencies查看依赖树或者直接执行mvn dependency:tree命令。依赖树一旦拉出来每个jar包的实际版本号一目了然是谁把版本号从某个版本改成了另一个版本很快就能定位到。我排查依赖问题时几乎不看报错信息都是直接看依赖树。Spring Cloud Alibaba相关组件的版本和Spring Boot版本的兼容性是最让人头疼的。这里分享一个稳定组合我建议新项目直接用这个基线起步组件推荐版本Spring Boot2.7.18Spring Cloud2021.0.8Spring Cloud Alibaba2021.0.5.0Nacos Server2.2.3这个组合对应的是Spring Cloud Alibaba 2021.0.5.0版本的典型搭配兼容性已经被大量项目验证过。尽量不要自己随意组合大版本比如Spring Boot 3.x搭配Spring Cloud Alibaba 2021版本是跑不起来的两者之间对Jakarta命名空间和Java版本的要求都不一样。补一个启动时常见的场景单机启动Nacos服务端需要执行startup.cmd -m standaloneWindows环境或startup.sh -m standaloneLinux环境不加这个参数Nacos默认以集群模式启动8537端口集群通信相关检查会报错服务注册不上。我在本地开发时经常忘记加这个参数报错信息又不够直接浪费过不少时间。还有一类问题出现在配置中心生效顺序上。服务从Nacos配置中心拉取配置的时机在Spring Boot初始化早期如果配置内容错误导致解析失败服务启动后即使改了配置中心里的文件也不会自动恢复必须重启服务才能重新拉取。所以改配置中心内容后养成交替检查Nacos控制台配置详情和本地服务日志的习惯。最后总结一下我个人在多模块工程落地过程中的体会。Maven多模块本身不是微服务的银弹它解决的是代码组织和依赖管理的问题真正的服务边界划分还需要团队一起定规矩。但一个结构清楚、版本统一、构建顺畅的多模块工程确实能为后续的微服务开发省下大量无谓的联调和排错时间。建立工程时多花半小时把父子POM配好、把版本组合选对后面每个迭代都会感谢这个决定。