接手过两个 Spring Boot 项目要整合的需求你就知道什么叫做“看起来简单做起来全是坑”。我印象很深的一个案例某系统A是主应用另一个服务B单独维护着账号体系和一组业务接口两边都是独立启动的 Spring Boot 工程。后来为了简化部署我们要把B整合进A——注意不是走远程调用而是真正把B的代码打进A里由A统一启动并且能直接访问B的Controller层。如果只是简单地在A的pom里加一个B的依赖再启动A大概率会看到一个接一个的404运气差一点甚至启动就失败。这篇文章就围绕这个需求把三个可落地的集成方案、背后的组件扫描原理、以及我在实际操作里踩过并排查掉的坑一次讲清楚。适合刚接触Spring Boot模块化整合的后端开发也适合被这种整合需求折磨过一阵、想看看有没有更好姿势的工程师。1. 项目A集成项目B之前先把方案选型说清楚我见过不少人在方案还没定的时候就直接动手改pom结果改到一半发现路子不对只能回滚重来。这里先说结论Spring Boot工程互相集成本质上分为三种路子选哪种取决于B还需不需要独立运行。1.1 三种集成方案的特点与适用场景对比集成方式改造成本B还能不能独立运行推荐场景依赖jar包式B打成普通jar交给A引用低需要额外处理才能保留B基本不再独立部署或者可以作为普通库被调用Maven多模块聚合式A、B同属一个父工程A依赖B模块中等B模块可以单独打包运行两个项目在一个团队内长期维护B以后大概率还会继续演进源码并入式把B的代码复制进A最高不再独立运行只允许单工程交付B已经不会再独立迭代第一种子方案其实很适合“我就是要引用B的Controller和Service”这种场景。B可以被改造成一个带Web能力的StarterA通过依赖引入后稍做扫描配置就能访问B的接口。第二种方案则适合B和A处于同一个Maven仓库、同一个多模块工程里的情况开发时直接在同一个IDE里切换模块B的改动能被A实时感知本地调试最顺畅。第三种方案我个人极度不推荐但确实在某些外包交付场景里会遇到我放在后面单独讲。判断自己适合哪种最核心的标准就是B在整合之后还要不要独立启动。如果答案是“要”就必须选择能让B保留可执行jar的方案也就是下面要讲的classifier双打包做法或者干脆走远程调用。如果答案是“不要”那依赖jar包式和多模块聚合式都可以选你团队更顺手的。1.2 为什么最先排除“源码复制”方案可能有人觉得最省事的方式不就是把B的代码全部复制到A里面吗Controller复制过来Service复制过来配置文件也复制过来项目不就立刻能跑了吗我劝你千万冷静。我见过一个真实的“复制粘贴翻车现场”某团队把B的Controller整体拷贝进A代码倒是跑起来了但B的Service层依赖了B工程里一个全局配置类复制的时候漏掉了那个配置类随后引发了连锁的Bean初始化报错。更隐蔽的问题是B的代码里引用了B工程的相对资源路径复制到A之后目录结构变化资源加载直接失败。那次修复花了两天时间最后还是老老实实改回集成方案。源码复制的本质问题是会破坏两边的工程边界。B自己的一套包结构、配置命名、依赖版本在A里未必兼容。就算当时能跑B后续任何一次代码更新A都要手动同步一遍。Git历史也会变得非常混乱评审代码的人根本分不清哪些代码是A原生、哪些是B迁移过来的。所以除非甲方明确要求“必须只交付一个工程其他什么都不能要”否则我不建议走这条路。2. 核心难点为什么引进了B的jarController还是404决定走集成路线之后绝大多数人遇到的第一个大坑就是jar包引入成功了A也能正常启动但访问B的Controller时始终返回404。先别急着怀疑代码写错了这个问题的根源在于Spring Boot默认的组件扫描机制根本没有把B的Controller扫进IoC容器。2.1 组件扫描机制为什么B的Controller不在扫描范围内先解释一下Spring Boot启动时是怎么“发现”Bean的。你写的启动类上通常有一个SpringBootApplication注解它是个复合注解里面包含了ComponentScan。ComponentScan的默认逻辑是从启动类所在的包开始向下扫描所有子包把带Component、Service、Controller这些注解的类注册成Bean。这么说吧A的启动类在com.example.app包下它默认只会扫com.example.app以及它的子包比如com.example.app.controller。如果B的Controller在com.example.serviceb.controller包下这个包和com.example.app之间没有任何从属关系Spring根本不知道要去那里找Controller。这是最最常见的404根因之一。另一个容易被忽略的点是jar包的结构。如果你把B直接用spring-boot-maven-plugin打成可执行jar那么B的class文件会被放到BOOT-INF/classes目录下而不是普通jar的根目录。这种jar被称为fat jar它本来是给java -jar独立启动用的。A在引入这种jar时classloader虽然能加载里面的内容但Spring对它的扫描会很别扭Controller的注解处理经常不会生效表现也是404或者启动时Bean缺失。这个问题我后面实操部分会专门展开这里先记住一个结论被集成的B不能只保留一个fat jar。2.2 解决Controller扫描的两种正确姿势明确了扫描机制之后解决办法就清楚了。你要么主动告诉Spring去扫描B的包要么把B的配置类通过Import明确导入。第一种做法最简单直接在A的启动类上增加扫描范围比如SpringBootApplication(scanBasePackages {com.example.app, com.example.serviceb}) public class ApplicationA { public static void main(String[] args) { SpringApplication.run(ApplicationA.class, args); } }加上scanBasePackages之后com.example.serviceb包下面的Controller、Service、Component都会被扫描到。如果你的A和B统一用了同一个顶层包名比如com.example那直接写scanBasePackages com.example就行一网打尽后续加新模块也不会踩扫描漏掉的坑。第二种做法适合B不想被随意扫描、希望通过显式配置暴露接口的情况。比如B里面有一个专门负责对外提供Bean的配置类package com.example.serviceb.config; Configuration public class ServiceBWebConfig { Bean public ServiceBConfig serviceBConfig() { return new ServiceBConfig(); } }此时A可以通过Import(ServiceBWebConfig.class)把这个配置类导入其作用类似于把B配置类的逻辑合并进A的上下文SpringBootApplication Import(com.example.serviceb.config.ServiceBWebConfig.class) public class ApplicationA { public static void main(String[] args) { SpringApplication.run(ApplicationA.class, args); } }不过Import只会导入你指定的那个类如果B的Controller依赖了B内部的Service这些Service不是通过配置类声明的而是通过包扫描注册的那就仍然需要配合ComponentScan才能全部装进容器。实际项目里我建议优先用scanBasePackages显式写法用在B的配置类要精细化控制时两者可以结合使用。3. 完整实操用Maven多模块把B集成进A三步跑通B的Controller上面讲了原理现在给出一套我实测过多次的完整操作流程。这一节按Maven多模块方案来讲原因是它的工程结构最清晰也最容易排查问题。如果你是要把B打成外部jar给另一个独立工程A使用步骤基本一致只要跳过父工程那一步就行。3.1 第一步工程结构改造成多模块假设原来有两个独立的Spring Boot工程现在把它们放进同一个父目录。改造后的结构大致如下parent-project ├── pom.xml ├── module-a │ ├── pom.xml │ └── src/main/java │ └── com/example/app │ ├── ApplicationA.java │ └── controller/HelloController.java └── module-b ├── pom.xml └── src/main/java └── com/example/serviceb ├── controller/BHelloController.java └── service/BHelloService.java父工程的pom只负责管理公共依赖和模块清单不写业务代码groupIdcom.example/groupId artifactIdparent-project/artifactId version1.0.0/version packagingpom/packaging modules modulemodule-a/module modulemodule-b/module /modules这里把packaging设成pom是Maven聚合工程的固定写法目的是让父工程只作为管理容器不产生可运行的jar包。B的pom保持普通Spring Boot工程配置但要注意打包插件处理的细节这一步非常重要。3.2 第二步B模块打包策略调整避免“fat jar陷阱”如果你的B模块几乎不再独立启动那最简单的方式就是把它当成普通jar。也就是说在B的pom里不要加spring-boot-maven-plugin或者即使加了也要跳过repackage。比如下面这样build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId configuration skiptrue/skip /configuration /plugin /plugins /buildskip设置为true后B打包时不会生成fat jar而是生成普通的jar。这种普通jar里的class在根目录结构下正常排列A引用它时Spring能够顺利扫描到B的Controller和Service。但很多场景下B还需要保留独立运行的能力。比如B团队还在并行迭代时不时要单独起一个B来测试。这种情况就不能简单skip而是要利用classifier把可执行jar和普通jar分开生成。B的pom可以这样配置plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId executions execution goals goalrepackage/goal /goals configuration classifierexec/classifier /configuration /execution /executions /plugin执行mvn package之后B的target目录下会出现两个jar一个是module-b-1.0.0.jar这是普通jar给A做依赖引用的就是它另一个是module-b-1.0.0-exec.jar这个是可执行fat jar留着给B独立部署用。这个做法我强烈推荐等于一套打包流程同时满足两种运行场景谁也不用迁就谁。3.3 第三步A集成B并启动验证A的pom里增加对B的依赖dependency groupIdcom.example/groupId artifactIdmodule-b/artifactId version1.0.0/version /dependency然后在A的启动类上配置包扫描把B的根包加进去。如果A和B的顶层包名一致比如都是com.example可以直接写成SpringBootApplication(scanBasePackages com.example) public class ApplicationA { public static void main(String[] args) { SpringApplication.run(ApplicationA.class, args); } }B里的Controller写法和平时完全一样。假设B的Controller是package com.example.serviceb.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; RestController RequestMapping(/api/b) public class BHelloController { GetMapping(/hello) public String hello() { return hello from B; } }启动A之后直接在浏览器或命令行访问curl http://localhost:8080/api/b/hello只要能返回hello from B就说明B的Controller已经成功跑在A进程里了。如果A本身配置了server.servlet.context-path比如配置成/app那访问地址就要变成http://localhost:8080/app/api/b/hello。这个细节很多人会忘看到404就以为集成失败其实只是前缀没加对。4. 运行阶段的常见问题与排查技巧集成方案跑通一次不代表后面就一路顺风。实际运行过程中我遇到过不少让人挠头的问题。这里整理成速查表再展开讲几个典型的。现象可能原因排查方向访问B的接口404Controller没被扫描到查看启动日志是否有RequestMapping映射记录启动报同一个Bean名称重复A和B存在同名Bean用Primary指定优先加载或用Qualifier精确指定B定义的配置不生效B的配置类没有被注册检查是否需要Import或自动配置注册文件静态资源或页面模板错乱两边有相同的static或templates资源给B的资源加独立子目录或改名接口能访问但返回数据不对依赖版本或序列化配置被A覆盖检查Jackson等公共依赖是否被统一管理4.1 启动A后访问B的接口404遇到404先去看A的启动日志。Spring Boot启动时如果Controller被正确扫描并注册控制台会输出类似这样的映射信息Mapped {[/api/b/hello]} onto public java.lang.String ...。如果日志里完全搜不到B的路径说明B的Controller没有被扫描优先检查scanBasePackages有没有覆盖B的包路径。还有一个隐蔽问题是fat jar。如果你确认扫描包路径没问题但Controller还是没注册去检查B实际引入到A的jar包结构。解压B依赖的jar看看里面class文件是直接在com/example/serviceb/controller下还是在BOOT-INF/classes/com/example/serviceb/controller下。如果是后者就是打包策略出了问题把B的spring-boot-maven-plugin按前面的方式加上classifier重新打包再引入。4.2 端口与上下文路径规划两个原先独立运行的项目都会有各自的端口和上下文路径。比如原来A在8080B在8081B的接口地址是http://localhost:8081/api/b/hello。整合之后A只有一个端口、一个上下文如果B的Controller路径没有变化就会和A共享/api/b/hello这个路径。我处理这类问题时的习惯是把所有被集成的模块的路径统一加一级前缀。比如B的所有Controller都加上/b前缀即RequestMapping(/b/api/...)这样A自己接口继续用/api/...B的接口统一走/b/api/...就算以后B再拆出去独立部署客户端把域名从A改成B的地址路径前缀完全不用变迁移成本非常低。4.3 同名Bean与配置项冲突A和B如果都定义了一个同名Bean比如都叫commonConfigSpring在启动时会直接报错BeanDefinitionStoreException提示名字冲突。解决办法通常是在B的配置类上使用Primary让B的配置在A中使用时优先加载。如果A代码本身确实需要两套配置就要靠Qualifier(xxx)来精确注入指定名称的Bean不能只按类型注入。配置项冲突更隐蔽。A和B都有application.yml但整合后B的yml并不会自动加载因为Spring Boot默认只读取A的配置文件。解决办法是把B的配置项手动迁移到A的application.yml中或者B使用独立的b-application.yml在B的配置类上通过PropertySource指定加载。后者维护起来更清晰适合配置项比较多的场景。4.4 静态资源与模板目录冲突如果是纯API接口整合静态资源冲突一般不会遇到。但现实中有一些B内部会带后台管理页面包含static/css、templates这些目录。当B的jar被A引入后两个工程的静态资源会以classpath上合并的方式共同存在如果存在同名文件后加载的会覆盖先加载的表现出来就是页面样式突然变乱、模板指向错误。处理思路是给每个模块的资源文件单独建子目录。比如B的静态资源放在static/b/下模板放在templates/b/下A的依旧放在根目录这样两者互不干扰。这也再次说明做模块集成时代码包路径、资源路径的“命名空间意识”非常重要。5. 从“能访问”到“用着爽”整合后建议做好的三件事跑通B的Controller只是第一步。既然把B并进了A那B原本那套监控、配置、文档体系也要跟着调整否则后续维护会非常难受。我根据自己的实践整理了三个整合后几乎必做的优化点。5.1 给B的接口统一加前缀并保留说明文档给B的接口统一定前缀不单是为了避免和A的接口路径冲突更是为了给前端和测试人员一个清晰约定。前端不用关心后端到底是几个项目只需要知道/b/api/开头的就是原B系统能力。我一般在整合完成后会让B的Controller基础路径统一从/api/改成/b/api/。如果B原有Controller非常多逐个改注解太费时间可以在B内部定义一个父类路径常量所有Controller继承或者直接用server.servlet.context-path/b方式区分但后者会连A的接口也带上前缀需要权衡。5.2 配置项按模块拆分不要揉成一个巨型yml两个工程合并后最忌讳的是一股脑把B的配置贴到A的application.yml末尾。这样过两个月再看根本分不清哪些配置是A的、哪些是B的。我建议在A的resources目录里按模块建文件比如application.yml只放A的核心配置再建一个application-b.yml放B的配置然后在A启动时用spring.profiles.activeb或者spring.config.importclasspath:application-b.yml把它加载进来。这样B接回去的时候文件直接提走就行不用在A里大海捞针式地找配置。5.3 接口文档和监控也要一并整合B的Controller被A托管后如果B原来接入了Swagger之类的接口文档文档页面也要跟着A的配置走。一个常见问题是B的注解扫描范围如果没包住文档上就看不到B的接口。解决方式跟前面组件扫描思路一样要把B的Controller包加进接口文档的扫描列表里。同时建议在A里开启Spring Boot Actuator至少保留健康检查和接口映射两个端点。我排查404的时候最常用的就是/actuator/mappings它能直接列出现有所有可以访问的路径对比B的Controller路径是否注册成功比看一堆日志高效得多。说回这次整合的核心经验Spring Boot项目互相集成引依赖只是表面功夫真正决定成败的是组件扫描范围和打包策略。只要把scanBasePackages放进启动类把被集成方的fat jar处理成普通jar加可执行jar分开生成再提前规划好路径前缀和配置拆分A访问B的Controller就是水到渠成的事。如果你正在为类似的模块整合头疼按这个思路一步步排查大概率能比我当初省下好几天的加班时间。
阅读完成 · 觉得有帮助?