接手过这样一个需求某项目A是已经上线跑了好久的主业务系统稳定得很突然说要集成另一个单独开发好的Spring Boot项目B而且要求集成之后不用单独部署B服务直接在A里就能访问B的Controller层接口。乍一听这可能让人有点懵明明是两个独立的Spring Boot工程各自有自己的启动类、自己的配置文件、自己的端口怎么合到一起这个场景在真实业务里非常常见比如新做的能力模块B想直接嵌入老系统A、比如公司内部一个通用服务想变成主系统的子模块、再比如几个团队开发完的成果要合并到一个发布单元里。我基于自己的实操经验把整个集成过程、方案选型、踩坑记录全部梳理一遍希望能给遇到同样需求的朋友一个可参考的完整路径。1. 集成需求拆解先想清楚你要合代码还是连服务1.1 两种模式的核心区别拿到项目A集成项目B并且能访问B的Controller这个需求第一步不是去写代码而是先确认一件事这里的集成到底是指哪种模式第一种是构建期合并就是真正把B的代码、依赖、配置全部融入到A的工程里最终只打出一个包、只启动一个进程A的端口上直接挂着B的接口。第二种是运行期调用A和B各自独立部署、各自启动A通过HTTP、RPC这类方式去调B的接口。两者表面上都能访问B的Controller但工程形态、运维方式、故障边界完全不同。结合标题里集成另一个springboot项目这个描述绝大多数情况下要的都是第一种也就是把B作为模块嵌进A合并后就是一个应用、一个进程。判断标准很简单如果你后续希望B的接口和A的接口共用同一个端口、同一个服务名、同一个配置中心那必然需要构建期合并如果业务上允许B独立重启、独立扩容那反而是第二种更合适。1.2 什么情况下适合源码级集成从我接触过的实际案例看下面几类场景基本都适合直接合代码B不是一个独立的业务产品而是A的功能增强包比如某个业务线新做的报表模块、新的通知中心、新的数据导入导出组件。B的部署运维都由A的团队负责公司层面不希望引入第二个服务实例来增加监控和运维成本。B本身没有独立的数据库、没有独立的消息队列只是逻辑上拆出来的一套代码硬要部署成独立服务反而要额外做一套鉴权、网关、注册中心对接。团队对B的代码质量有足够信心且B的依赖和A的依赖重叠度较高合并后不会产生严重冲突。1.3 集成后的边界划分源码级集成之后A和B在物理上已经没有边界了同一个Spring容器里启动同一个JVM里跑。这时候你要重新思考几个问题B的配置项是合并到A的application.yml里还是保留自己的配置文件B的日志是输出到同一个文件还是按模块分目录B的异常是被A的全局异常处理器接管还是自己处理这些边界在动手之前必须想清楚否则代码合到一起之后会出现很多隐性问题比如两个项目各自定义了同名的全局异常处理类最终注册哪个完全不确定只能看加载顺序这种问题排查起来非常头疼。2. 集成前的工程改造把独立的B改造成可嵌入的B2.1 先摸清两个项目的包名和启动类动手改造之前先把A和B的包结构整理清楚。比如A的主包名是com.company.main启动类叫MainApplicationB的主包名可能是com.company.module启动类叫ModuleApplication。这里有个非常关键的点Spring Boot的组件扫描默认只扫描启动类所在包及子包。A的启动类在com.company.main下的时候默认扫描范围就是com.company.main.*那么放在com.company.module下的B的Controller、Service、Repository永远不会被扫描到接口直接404。按理说最省事的方式是让B整体代码挪到A的包路径下但现实中B可能已经开发了很久包名改了涉及大量的import语句和xml配置不现实。所以更通用的做法是保持B的包名不变通过扩展A的扫描范围来覆盖B的包。这也是后面集成实操里的核心动作之一。B的启动类也是个麻烦点。B本身有SpringBootApplication如果合到A里之后还保留这个类而且是可被扫描到的轻则多扫描一遍重则可能出现重复注册或者多个配置源。我一般直接删掉B的启动类或者把它改成普通的Configuration类。如果B的启动类里还配置了DataSource、Redis等Bean把这些配置保留到改造后的配置类里就行不一定要删除。2.2 梳理B的依赖清单这一步非常容易漏。B作为一个独立Spring Boot项目它的pom里会有自己的依赖但你合到A里的时候不能天真地以为把B的pom依赖全部塞进A的pom就完事了。实际上要注意几点第一B如果引入了和A不同版本的同一个依赖比如A用Spring Boot 2.3B用Spring Boot 2.5直接把依赖合并进去之后Maven会依据依赖声明顺序和传递依赖产生版本仲裁最终编译启动可能没问题但运行期会出现NoSuchMethodError、ClassNotFoundException这类诡异异常。第二B依赖的很多starter其实A已经有了这种情况下重复引入无意义反而可能在升级版本时埋坑。第三B独有的依赖要评估版本兼容性尤其是数据库驱动、连接池、Redis客户端这类基础设施相关的库。我的习惯是把B的依赖逐条梳理出来用Maven的dependency:tree看B的完整依赖树然后跟A的依赖树做一次减法只把多出来的部分和A里版本冲突的部分做调整而不是一股脑全部复制过来。这个过程虽然费时间但能避免集成完成后跑不起来的尴尬。2.3 处理B的配置文件和静态资源B是一个独立应用的时候会有自己的application.yml里面可能有server.port、spring.datasource.url、自定义的配置项等等。集成之后除非做多租户、多数据源这类特殊设计否则这些配置要合并到A的配置文件里。合并时有个主次原则以A为主B的配置作为扩展项挂进来。因为最终入口是A端口、应用名、监控上报这些都以A为准。B的端口的配置要直接删掉或注释掉否则不会生效或者让人误解。B的数据库连接、Redis地址如果和A用的同一个环境直接复用A的配置就好不需要在合并文件里重复配置。静态资源这里要注意B如果用了templates、static目录合并到A的时候要检查有没有同名文件。比如两个项目都默认放了一个favicon.ico或者公共的样式文件合到一起之后到底用谁的版本完全取决于资源加载顺序可能出现开发环境正常、打包后页面变样的问题。3. 在A中接入B的完整实操流程3.1 把B的代码放进A的工程在确认完成包改造、依赖梳理、配置合并规划之后开始动手接入。这里有两种做法一种是把B的源码目录直接复制到A的工程里比如A是Maven多模块项目B的源码就作为某个子模块的代码存在另一种是把B打成jar包A通过依赖的方式引入。我实际更推荐的做法是模块化集成A的父pom里加一个module把B作为子模块引进来A依赖B模块。这样做的好处是开发调试方便改了B的代码可以在A的工程里直接热更新不需要反复重新打包B的jar。缺点是对团队工程习惯有要求如果B的代码是另一个团队维护的他们还经常独立改代码模块化集成的耦合度会让双方都觉得别扭。如果只是临时合并或者B的维护方坚持自己独立开发那就把B打成普通的jar包放到私服A的pom里引入。这里有一个容易踩的坑Spring Boot的spring-boot-maven-plugin默认会把项目打成可执行的fat jar这种jar内部的类都在BOOT-INF/classes目录下直接依赖根本引入不到接口启动时ClassNotFound或者Bean创建失败。所以B如果作为依赖包被A引用需要把spring-boot-maven-plugin的配置里加上classifier让B同时生成一个普通jar和一个可执行jarA依赖的是普通jar这个坐标。3.2 扩展组件扫描范围把B的Controller纳入容器这是整个集成过程中最核心、也是报错最集中的环节。A的启动类上直接SpringBootApplication默认扫描A的包。要让B的Controller、Service、Mapper被扫描进来最简单的做法是在A的启动类上增加ComponentScanSpringBootApplication ComponentScan(basePackages { com.company.main, com.company.module }) public class MainApplication { public static void main(String[] args) { SpringApplication.run(MainApplication.class, args); } }前面已经提到当B的包路径在A的默认扫描范围之外时必须靠这个注解把B的包加进来。这里有一个细节要注意如果A和B都用到了自动配置类比如B里有一些带Configuration、且没有被ComponentScan扫到的配置类还需要显式通过Import或者保持它们在扫描范围内否则B的配置类等于没注册接口虽然进来了但依赖的服务没有初始化启动时可能不报错访问时才会NPE这种问题很隐蔽。3.3 合并配置文件与应用级参数组件扫描解决了包的问题接下来要处理配置。找到A的application.yml把B里需要保留的配置项合并进来。比如B原来是这样的配置server: port: 8081 app: module-b: enabled: true timeout: 3000集成后server.port要以A为准B的端口配置直接去掉。B的自定义配置项可以保留前缀可以用原来的也可以用新的前缀关键是A的代码里如果引用了B的某个配置类比如ConfigurationProperties(prefix app.module-b)那配置项的前缀必须和代码里的前缀一致。比较规范的合并做法是在A的配置文件中加一个独立的分区用注释标明以下为集成B模块的配置把所有B的配置集中放一起。这样后续某天要拆掉B模块直接把这个分区清理掉就行维护成本最低。千万不要图省事把B的配置散落在A的配置文件各处后面排查问题时很容易漏。3.4 启动验证Controller可访问配置和扫描都处理完之后在A的工程里启动MainApplication。启动过程中重点看两个东西第一日志里有没有报错特别注意Bean冲突、Autowired失败的异常第二启动完成后看控制台里打印的RequestMapping映射列表确认B的Controller下的接口路径都被注册进来了。比如B原来有个接口是/api/module/query启动日志里应该能看到这个路径的映射记录。然后本地直接浏览器或curl访问验证curl http://localhost:8080/api/module/query不需要加B原来的8081端口因为现在B的接口已经跑在A的端口上了。如果返回正常数据说明Controller已经成功被A托管。如果404优先检查组件扫描是否真的把B的包扫进来了其次检查B的Controller类是不是因为某种原因没有被Spring识别为Controller。4. 集成后必踩的坑Bean冲突、配置复写与扫描失控4.1 Bean名称冲突是最常见的启动失败原因A和B各自独立跑的时候各自容器里Bean名称互不影响但是合到一起后同一个Spring容器里如果有两个相同名称的Bean启动时直接抛BeanDefinitionStoreException。最常见的情况是两个项目都有自己的RedisTemplate、RestTemplate、ObjectMapper这类通用Bean而且方法名一样比如两个配置类里都有这样一个方法Bean public RedisTemplateString, Object redisTemplate() { ... }集成之后两个Bean方法都试图注册名为redisTemplate的Bean启动直接挂掉。解决办法有三个一个是把B里重复定义的通用Bean去掉直接复用A的这也是最推荐的做法因为底层框架组件本质上只需要一份另一个是给B的Bean换名字比如Bean(moduleRedisTemplate)还有一个是确保B的配置类不被扫描到B需要哪些Bean就在A里用Import精确引入。三种方案可以组合使用但总原则是通用组件复用A的B只保留自己特有的组件。4.2 拦截器、过滤器、监听器重复生效如果A和B各自都有WebMvcConfigurer实现或者各自注册了Filter、ServletListener合并后可能会发现同一请求被处理两次、响应头重复、甚至出现执行顺序错乱的问题。特别是B原来为了实现某些功能注册了一个全局的OncePerRequestFilterA里也有一个类似的两个都生效之后请求处理链路会变得混乱。我的处理方式是把A和B的所有拦截器、过滤器统一梳理一遍放到A的配置类里集中注册。B的Filter可以部分去掉因为A的鉴权、日志链路等基础设施本身就是全量服务的重复做没有意义。如果B的Filter实在有特异性也要通过Order显式指定执行顺序避免隐性顺序问题。4.3 扫描范围过大导致启动变慢或误注册加了ComponentScan(basePackages com.company.module)之后B包下的所有类都会被扫描。如果B的包特别大、类特别多、或者包路径下有一些本不该被Spring管理的工具类就会导致启动时多加载很多不相干的Bean启动时间变长是小事更麻烦的是某些工具类因为带了某个注解被误注册导致上下文初始化异常。遇到这种情况建议把粗粒度的包扫描改成细粒度的选择。比如B的Controller统一放在com.company.module.controllerService放在com.company.module.service那你可以在A的配置类里用ComponentScan的includeFilters来精确指定你要纳入管理的类型而不再直接扫描整个包。或者用Import把B的各个配置类手动引入这种方式控制力度最大但对维护者要求也高需要清楚B内部有哪些配置类。4.4 循环依赖与事务边界要提前梳理A和B合并前各自启动没任何问题合并后出现循环依赖这种情况也不少见。原因是A的某个Service依赖了B的Service而B的Service又反向依赖了A的Service独立部署时这种两个进程之间的互相调用是走HTTP的不存在Spring循环依赖问题合并后变成了Bean级别的循环依赖启动时直接报错。遇到这种情况要重新设计依赖关系打破循环比如把公共逻辑抽到一个单独的Service里或者用ApplicationContext里getBean这样懒加载的方式降级处理当然能通过重构解决的尽量重构解决。事务边界也需要特别注意。B的方法上如果有Transactional而且内部又调了A的某个Service方法两个方法在同一个事务里执行还是各自独立事务取决于A的方法是否被代理、事务传播级别怎么设置。集成前这类问题永远不会暴露集成后一旦出现数据一致性问题排查成本非常高。所以集成完成后要做一轮针对涉及数据库写入操作的联调测试重点确认事务行为符合预期。5. 常见问题速查与自检清单5.1 高频报错对照表现象根本原因处理建议启动报BeanDefinitionStoreExceptionBean名称重复A和B定义了同名Bean删除或改名重复Bean优先复用A的通用组件接口404但没有启动报错B的包不在A的组件扫描范围内在A的启动类增加ComponentScan覆盖B的包启动过程中ClassNotFoundExceptionB依赖的某个库版本与A冲突或者B的jar被Spring Boot fat jar影响用dependency:tree排查依赖树统一版本检查B的打包方式启动正常调用接口时NPE或配置值为空B的配置未合并到A或者配置前缀与ConfigurationProperties不匹配核对B的配置项是否完整合并前缀是否一致请求被多个过滤器重复拦截A和B各自注册了功能重叠的Filter统一在A中注册用Order确定顺序上下文初始化报循环依赖A和B的Bean存在双向依赖重构依赖关系抽出公共Service打破循环接口能访问但静态资源404B的static资源未合并或同名文件被覆盖检查A的resources下static目录是否有同名文件冲突5.2 集成后正式发布前建议逐条过一遍自检清单输入mvn dependency:tree确认最终依赖树中Spring Boot相关的版本统一没有重复的starter版本冲突。启动A后完整看一遍启动日志确认B的Controller映射全部注册成功确认B的配置类被加载。遍历B的所有核心接口逐个发测试请求验证返回正常特别包括涉及文件上传、异步处理、定时任务这些非标准接口。检查B的定时任务是否被触发。如果B里有Scheduled的定时任务且A本身也有合入后会同时启动两组定时任务一定要确认任务去重逻辑避免重复执行。检查日志文件。B如果之前单独配置了logback-spring.xml和A合并之后可能只生效一份确认日志输出位置和格式是否符合预期。检查监控上报。如果A和B各自接入了同一套监控上报组件合入后同一个应用会发出两套应用名或实例ID的数据需要去除B的自定义上报。5.3 上线前做一次拆合演练这是我个人强烈推荐的做法。集成完成、联调通过之后花一天时间做一次模拟把B的代码从A的工程里摘除重新让A独立启动跑一遍确认A本身没有被这次集成破坏再把B加回去确认集成后的状态可复现。这一步看起来多余但真实场景里很管用因为很多时候你会改了B的某些代码来迎合集成而这些改动本身可能影响B独立运行。如果你负责的这套代码未来还要支持双形态部署既能独立跑、又能嵌入跑这次演练就更有必要了。另外集成这种动作尽量不要赶在版本发布前一天去做。我踩过一次这样的坑一个模块集成进来后启动时一切正常结果跑了几小时后偶发出现内存缓慢攀升最后定位到是B里的某个缓存组件和A的缓存组件互相抢占了过期线程这种运行时问题不是简单测一遍接口就能发现的。所以只要时间允许集成完成之后留出至少一轮完整的观察窗口让系统在测试环境跑一跑再安排上线计划。做了这么多次Spring Boot项目集成我个人的体会是技术上最难的从来不是写代码而是对两套工程自身规律的把握。每个项目都有自己默认的包结构、配置约定、公共组件定义合并的那一刻不是简单叠加而是两套约定要重新统一。只要把组件扫描、Bean冲突、配置文件这三件大事想清楚剩下的都是一些按步骤走的体力活。这套方法我后来在好几个项目上重复用过不管是一个复杂的平台接入还是小模块整合只要遵循先摸清边界、再统一扫描、最后联调验证这个顺序基本都能平稳落地。