恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Spring Boot项目集成实战:A访问B的Controller层的四种主流方案
首页
资讯中心
/
Spring Boot项目集成实战:A访问B的Controller层的四种主流方案
Spring Boot项目集成实战:A访问B的Controller层的四种主流方案
发布时间:2026/10/16 20:48:50
1. 需求拆解一个能访问B的Controller背后藏了什么刚接到这个需求时我第一反应是问了自己一个问题项目A到底是想把B当作代码库的一部分来用还是想通过某种方式远程调用B的服务能力这两种诉求的技术路线完全不同甚至架构思路都是相悖的——但网上很多人的提问把这俩搅在一起导致回复说什么的都有新手很容易被带偏。先把这个需求掰开。标题说的是项目A集成另一个Spring Boot项目B并且可以运行访问B的Controller层。这里的关键动作拆开看有三个A要集成B说明A和B是两个独立的Maven/Gradle工程不是同一个项目的两个Module。运行说明B的服务代码不需要单独启动一个独立进程或者说即便B被打成可执行jar最终跑起来访问时也不该让用户分两次部署。访问B的Controller层说明最终目标是A对外暴露的路径能打到B的HTTP接口上也就是A的端口上要能路由到B的RequestMapping映射。这类需求在实际场景里非常常见。我举几个真实的例子某公司内部有一个核心的下单服务B后来又起了新的网关服务AA想复用B已有的几个查询接口但不想重复实现一遍业务逻辑某团队把报表功能做成独立的Spring Boot工程B现在需要在主管理平台A里直接挂载B的页面接口还有更常见的A是单体老系统改造后的聚合工程B是新拆出来的微服务业务上A要兜底转发一部分请求到B。但问题就在这——不同场景对应的集成方案差别非常大用错方案不仅代码写得别扭上线后运维也是灾难。所以我在这篇文章里不想只丢给你一种能跑通的方案而是把几种主流路线全部拆开讲清楚它们各自的核心原理、适用边界、操作步骤、以及我实际踩过的坑。你根据自己的项目背景对号入座就行。需要特别说明的是下面的代码示例和工程结构都以最常规的Spring Boot 2.x/3.x Maven为例某些细节在Gradle下略有不同我会在对应小节里标注差异点。2. 方案选型的全局视野四种技术路线到底分别解决什么问题在写代码之前先把方案地图铺开。所谓A集成B从底层的连通机制来看逃不出四种模式模式本质对B的要求典型使用场景Maven依赖 组件扫描把B的jar包引入A通过包扫描把B的Controller注册为A的BeanB需要能被打成普通jar或fat jar中剥离后引入A和B部署在一起想要合并对外端口HTTP远程调用Feign / RestTemplate / WebClientA作为客户端调用B暴露的HTTP接口B独立运行独立进程A通过地址访问A和B两个独立服务B单独部署Maven多模块 父工程聚合A、B都作为parent下的module共享依赖和构建运行时仍可独立或合并A/B代码在同一仓库结构重构项目初始阶段就想统一管理多个应用网关/路由层转发A内置路由把请求转发给后端BB独立运行A作为网关入口典型的网关 微服务架构这四种模式并不是互斥的。比如你可以用Maven依赖 组件扫描让B作为A内部的模块同时又用Feign让A去调另一个独立部署的B。关键是你得清楚自己在做哪一种。2.1 先说最常见的误区把B的fat jar直接塞进A很多从网上找答案的同学第一反应是把B打成Spring Boot fat jar可执行jar然后放进A的lib里以为这样A就能启动时自动加载B的Controller。这个思路是错的。Spring Boot的可执行jar结构特殊它使用自定义的类加载器B的Controller类即便被加载进来也不会被A的组件扫描命中——因为A在启动时只会扫描自己包路径下的注解。即便你强行通过Import把B的配置类引入B的fat jar里的BOOT-INF/classes目录也不是常规的classpath结构A的类加载器根本读不到。所以方案一里我对B做的是一个重要变形B不打Spring Boot的可执行jar而是打普通jarSpring Boot工程通过修改pom把打包插件换成普通maven-jar-plugin并且在A里显式扫描B的Controller所在的包路径。这一步是整个方案的灵魂。2.2 再说集成听似高端、实则最简单的做法另一种极端是A和B本来就是两个独立服务你只需要在A里写几个代理方法把HTTP请求转发到B的地址。这类方案实现成本极低不需要改B的打包方式也不需要关心B的Bean在A里能不能被管理。它唯一的代价就是多一层网络开销并且B必须保持在线。很多人纠结这算不算集成——我的理解很直接只要A能通过某种方式调用B的Controller能力并且代码层面上能少改甚至不改B就算集成了。方案二Feign / RestTemplate就是用网络换取解耦的典型做法。2.3 什么时候必须用组件扫描方案什么时候千万别用组件扫描方案只适合两种情况B的代码量不大且B是你们团队内部可改的工程A需要把B的接口能力缝合进自己的Spring容器里统一管理事务、安全校验等。A和B注定部署在同一台服务器上希望对外只暴露A的端口同时希望B的接口路径变成A的路径一部分。但以下情况千万不要用组件扫描B已经线上独立运行并且有自己独立的配置中心、缓存、消息队列等你把它扫进A的容器里等于复制了一份B的运行时环境很容易冲突。B和A的依赖版本差异很大——比如B依赖了一堆老版本的库A用了新版本组件扫描方案会把B的依赖也带进A冲突起来非常痛苦。我所在的一个项目就吃过这个亏A是网关应用B是老的业务服务为了省一台机器资源团队强行用组件扫描把B塞进A结果B里引用的老版本SpEL表达式库和A里的新版本冲突启动时一堆NoSuchMethodError排查了两天才定位到是依赖版本污染。后来退回到远程调用方案机器资源其实也多花不了多少钱。3. 方案一实操把B打成普通jar让A把B的Controller注册进来这套方案的特点是A启动时B的Bean会一并初始化访问路径直接打在A的端口上用户只需要启动A一个进程。3.1 第一步改造B的打包方式B的pom.xml里通常会有这样的Spring Boot插件配置build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build这个插件默认会把B打成可执行fat jar。要让它变成可被其他工程引用的普通jar有两种做法做法一最干净直接移除这个插件。如果你的B本身不需要独立运行比如它已经被A全盘接管那B不需要main方法也不需要可执行打包。把spring-boot-maven-plugin整段注释掉B就会变成标准的maven-jar-plugin产物里头的类、resources都会被正常打进jar。做法二推荐保留B独立运行能力不删插件但引入一个execution手工指定repackage的phase为none或者通过classifier把两个jar分开。plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId executions execution idrepackage/id goals goalrepackage/goal /goals !-- 让fat jar带classifier后缀避免覆盖普通jar -- configuration classifierexec/classifier /configuration /execution /executions /plugin这样执行mvn clean install后仓库里会同时出现 b-0.0.1-SNAPSHOT.jar普通jar给A引用和 b-0.0.1-SNAPSHOT-exec.jar可执行fat jarB还能自己跑。我推荐这个做法因为万一B需要独立部署排障你还有后路。3.2 第二步A里引入B的依赖A的pom.xml加坐标即可dependency groupIdcom.demo/groupId artifactIdb-project/artifactId version0.0.1-SNAPSHOT/version /dependency注意版本号别写死最好用properties统一管理。另外如果你在本地改来改去记得先在B工程里执行mvn clean install把它装进本地仓库不然A引不到。3.3 第三步改包扫描路径让A能发现B的Controller这是最容易出错的一步。假设B的Controller包路径是com.demo.b.controllerA的主类包路径是com.demo.a那么A的启动类上的扫描注解就要扩展SpringBootApplication( scanBasePackages { com.demo.a, com.demo.b } ) public class ApplicationA { public static void main(String[] args) { SpringApplication.run(ApplicationA.class, args); } }注意这一步不是把com.demo.b包下所有东西都扫进来——scanBasePackages是组件扫描的根包路径Spring会递归扫描该包及其子包下的所有Component、Service、Repository、Controller等。所以如果B工程里面的配置类、拦截器也在这个包路径下同样会被加载。如果B的配置有很多只有独立运行才有意义的Bean你可以用ComponentScan配合excludeFilters做排除或者在B的代码里用ConditionalOnProperty控制装配条件。还有一种更克制的方式单独写一个配置类导入B的Controller包Configuration ComponentScan(basePackages com.demo.b.controller) public class BControllerImportConfig { }只导入Controller相关的包其他组件一概不扫。这种做法的好处是精细控制坏处是如果B的Controller里依赖了一堆Service那些Service不在扫描范围内启动时Bean找不到会直接报错。所以实际落地时我一般会把B的业务包路径也带上但用ComponentScan的nameGenerator或过滤规则做限定。3.4 第四步处理B自身的配置类B如果用了Configuration去注册一些Bean你需要确定这些Configuration要不要在A里生效。如果不需要前面扫描包路径时别扫到那些配置类所在的包就行。如果B的配置类分散且必须生效可以用Import(BConfig.class)手写导入。这里要特别提一个坑B里如果有SpringBootApplication或EnableAutoConfiguration相关的注解类绝对不能让它成为A启动扫描的一部分。我见过有人图省事把B的启动类路径加进scanBasePackages结果A启动时报一堆自动配置冲突比如数据源的Url属性缺失、JPA方言识别失败。原因就是B的启动类自带了SpringBootApplication其内部的EnableAutoConfiguration会被A的扫描逻辑当成配置类重复处理。正确做法是彻底不要让B的启动类进入A的扫描范围。3.5 第五步路径冲突与端口设置当B的Controller注册进A的容器后A的DispatcherServlet持有的HandlerMapping会包含B的所有RequestMapping映射。你访问A的端口时路径直接用B的Controller里写的路径即可比如B里有RestController RequestMapping(/api/b) public class BController { GetMapping(/hello) public String hello() { return hello from B; } }那A启动后访问http://localhost:8080/api/b/hello就能拿到返回值。如果B里某个路径和A自己已有的路径恰好重名启动时会报Ambiguous mapping或者某个映射被覆盖的警告。我建议在B的Controller统一加一个前缀用server.servlet.context-path或者通过一个PathPrefixConfig统一调整。不过Spring Boot在Controller层做前缀不像RequestMapping那么方便最简单粗暴的做法就是改B的RequestMapping(/api/b)别让路径重叠。3.6 启动后如何验证B的Bean已生效启动日志里会有大量Controller映射信息可以直接搜B的包路径。或者用一行命令验证curl http://localhost:8080/api/b/hello也可以用actuator的mappings端点curl http://localhost:8080/actuator/mappings | grep -i api/b如果没有用actuator则直接在日志里搜RequestMappingHandlerMapping注册的行。另外要注意A里如果放置了B可能依赖的配置文件比如B使用了独立的application-b.yml你要么把配置key合并进A的配置文件要么通过spring.config.import引入它否则B的Controller虽然注册了运行时读取配置会拿到默认值或报错。4. 方案二实操让B独立运行A通过HTTP远程调用B的Controller这套方案更贴近微服务思想。A和B是两个独立的Spring Boot进程各自有自己的端口、配置、依赖A要调B的接口时不需要加载B的任何业务代码只通过HTTP调用。它的核心优势是解耦彻底不足之处是多了一次网络往返。4.1 为什么这种方案在多数场景下反而是最优解我最开始接触这类集成需求时总觉得把B的代码装进A里才算真集成后来想明白一个问题如果你的B已经做成了独立服务把它强行塞进另一个服务的进程里等于你放弃了边界。B升级时你要重新打包AB的配置变更你要重新发布AB的依赖冲突会直接影响A的运行。这些隐形代价远远大于多部署一个进程的成本。所以只要B能独立维护我通常优先推荐远程调用方案。4.2 用OpenFeign实现一个最省心的远程调用层Feign是目前最推荐的远程调用方式因为它在接口层面声明式定义写起来和本地调用几乎没区别。先给A加依赖dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-openfeign/artifactId /dependency然后在A的启动类加上EnableFeignClientsSpringBootApplication EnableFeignClients(basePackages com.demo.a.client) public class ApplicationA { public static void main(String[] args) { SpringApplication.run(ApplicationA.class, args); } }接着定义一个Feign接口FeignClient(name b-project, url ${b.service.url}) public interface BClient { GetMapping(/api/b/hello) String hello(); PostMapping(/api/b/order) BOrderDTO createOrder(RequestBody CreateOrderRequest request); }然后在配置文件里指定B的地址b: service: url: http://localhost:8081A的业务代码里注入BClient直接方法调用即可RestController RequestMapping(/api/a) public class AController { private final BClient bClient; public AController(BClient bClient) { this.bClient bClient; } GetMapping(/hello-b) public String callB() { return bClient.hello(); } }需要注意Feign默认带的负载均衡逻辑在引入spring-cloud-starter-loadbalancer之前会选择第一个可用实例如果只有单机Burl写死就行不要配置服务发现否则启动会报找不到服务名。4.3 如果不想引Feign用RestTemplate也够用了有的项目很轻不想为了一个转发功能引一整套Spring Cloud生态那直接用RestTemplate也行。在A里定义配置类Configuration public class RestTemplateConfig { Bean public RestTemplate restTemplate() { return new RestTemplate(); } }调用时写String url http://localhost:8081/api/b/hello; ResponseEntityString response restTemplate.getForEntity(url, String.class); String body response.getBody();这里有个小坑如果你一次性要频繁调用B的多个接口RestTemplate每次请求都新建连接性能浪费大。建议给它设置一个连接池用Apache HttpClient或OkHttp做底层实现。示例Bean public RestTemplate restTemplate() { SimpleClientHttpRequestFactory factory new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(3000); factory.setReadTimeout(5000); return new RestTemplate(factory); }如果请求量更大可以去查一下HttpClient连接池的完整配置方式这里不展开。4.4 远程调用方案的兜底策略要不要做降级既然B独立运行它挂掉的时候A应该怎么表现这是很多初始方案里没考虑到的。我建议在A里给Feign接口加fallbackFeignClient(name b-project, url ${b.service.url}, fallback BClientFallback.class) public interface BClient { ... } Component public class BClientFallback implements BClient { Override public String hello() { return B service unavailable; } }然后在配置里开启Feign的降级feign: circuitbreaker: enabled: true这种做法虽然简单直接但要注意它只是降级返回预设值并不代表业务语义的最终一致。如果B挂掉导致数据缺失需要后续补偿措施而不能只吞掉异常。4.5 验证连通性先curl再上代码我每次写远程调用代码之前都会先手动验证两个服务的连通性和参数格式启动B进程用curl访问B的接口确认它本身能工作。确认B的CORS、拦截器、鉴权等中间件没有拦截不必要的请求。再启动A从A的进程内调用BClient观察日志和响应。这套排队方法看着笨却能节约大量排错时间。很多时候大家直接写代码一调就失败到底是B没起来还是参数序列化出错还是路径写错完全分不清。5. 方案三实操用Maven多模块 父工程统一托管A和B这个方法适合项目还在早期或者团队愿意做一个结构调整的场景。它跟前面两种思路的区别在于A和B不再是两个仓库里各自维护的两个工程而是在同一个Maven父工程里以module的形式存在。5.1 多模块的目录结构与父POM配置典型的目录parent-project/ ├── pom.xml ├── module-a/ │ ├── pom.xml │ └── src/ └── module-b/ ├── pom.xml └── src/父pom的核心是声明module和统一的依赖管理groupIdcom.demo/groupId artifactIdparent-project/artifactId version1.0.0/version packagingpom/packaging modules modulemodule-a/module modulemodule-b/module /modules dependencyManagement dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-dependencies/artifactId version2.7.18/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagementmodule-a的pom引用module-b作为依赖就跟前面方案一的操作一样。module-b本身也可以保留为可运行工程。5.2 这个方案比方案一好在哪方案一要求B先打成jar并安装到本地仓库然后A引用这个jar。如果B频繁改动你得不断install很容易出现A引到的还是老版本。多模块方案则是直接把module-b的源码作为A编译期的一环构建时自动使用最新代码不存在版本漂移问题。另外如果A和B之间还有其他共同的基础模块比如common模块、dto模块放在同一个parent下统一管理依赖版本是最方便的。5.3 多模块下的坑模块依赖的循环与启动顺序模块之间要做成单向依赖A依赖B没问题但如果B也依赖A就麻烦了Maven会在构建时发现循环并报错。所以这种方案只适用于一个主应用、若干被集成子应用的单向结构。启动顺序上如果A和B都各自有main方法你可以分别启动如果你希望只启动A一个进程那B的打包方式就参考方案一的普通jar做法并且A的扫描路径要包含B的Controller包路径。也就是说多模块方案和方案一是可以无缝结合的——核心是解决依赖加载和打包方式两个问题。5.4 Gradle里怎么玩类似的事如果你用的是Gradle思路完全一样只是配置语法不同。在settings.gradle里include两个子项目在子项目的build.gradle里配置implementation project(:module-b)。Gradle对多项目构建的原生支持比Maven更顺手依赖变化也会实时反馈到编译类路径上。6. 组内最常见的情况A已经是个庞大的应用B也是但就是不能分开部署还有一类需求我经常碰到就是A和B都很大团队又不希望改动它们的部署拓扑——但要让A能调B。这时候前面的组件扫描方案因为依赖冲突几乎不可行远程调用方案也因为网络环境、鉴权体系复杂而未必顺当。实际业务里这种事通常出现在老旧系统改造期。这种情况下我往往会建议做一层贴面路由在A里做一个通用的转发Controller把所有以某个前缀开头的请求原样或稍加改写后转发到B。这个方案不用引入Feign也不用改B任何代码A对外暴露的路径自动映射到B。举个例子A里写这样一个转发器RestController RequestMapping(/proxy/b/**) public class BProxyController { private final RestTemplate restTemplate; Autowired public BProxyController(RestTemplate restTemplate) { this.restTemplate restTemplate; } RequestMapping(value /proxy/b/**, method { RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE }) public ResponseEntityString proxy(HttpServletRequest request) throws URISyntaxException { String bBaseUrl http://localhost:8081; String path request.getRequestURI().replace(/proxy/b, ); String query request.getQueryString(); String target bBaseUrl path (query ! null ? ? query : ); HttpMethod method HttpMethod.valueOf(request.getMethod()); HttpHeaders headers new HttpHeaders(); // 根据需要选择拷贝请求头避免敏感信息泄露 headers.setContentType(MediaType.APPLICATION_JSON); byte[] body request.getInputStream().readAllBytes(); HttpEntitybyte[] entity new HttpEntity(body, headers); ResponseEntitybyte[] response restTemplate.exchange(target, method, entity, byte[].class); return ResponseEntity.status(response.getStatusCode()) .contentType(response.getHeaders().getContentType()) .body(response.getBody()); } }这种代理转发表面上看是把A当网关用但它承受了网络IO、超时、请求体大小控制等一堆问题。如果你们的A和B都在内网且请求量不大可以这么搞请求量大就上专门的网关组件别自己写。7. 最容易翻车的六个坑与排查链路我在做这系列集成的过程中踩过不少坑每个坑都花了不短的时间去定位。这里整理几个最常见、也最值得记住的。7.1 第一坑NoSuchMethodError / ClassNotFoundException 出现在启动期这种问题基本是依赖冲突它通常发生在使用组件扫描方案时。B的jar把旧版本依赖带进了AA里其他类用到新版本的API运行时抛NoSuchMethodError。排错思路先看堆栈里冲突的类名。在A工程里执行mvn dependency:tree找出该类的来源。找到重复依赖后在A的pom里用exclusions排除B传递进来且与A冲突的依赖。这里有一个比较实用的经验如果你的B工程代码更新比较频繁不让它传递依赖是最省心的。在B的pom里把所有依赖都改成optional或者A引用B时显式声明排除全部传递依赖dependency groupIdcom.demo/groupId artifactIdb-project/artifactId version0.0.1-SNAPSHOT/version exclusions exclusion groupId*/groupId artifactId*/artifactId /exclusion /exclusions /dependency这样做后B如果缺少了运行所需的库启动时自然会暴露出来再根据需要手动补齐依赖。代价是维护量略大好处是冲突面大大缩小。7.2 第二坑组件扫描把B的所有Bean都装进来直接导致A启动失败这类情况的报错通常是NoSuchBeanDefinitionException或DataSource相关配置缺失。排查顺序确认A的启动日志里有没有扫到B的启动类。查看B的SpringBootApplication类路径是否在scanBasePackages范围内。如果B的配置类被扫到用ComponentScan过滤规则排除或者让B把自己的配置类放到启动类包之外的地方。这种问题最坑的地方在于报错本身并不直接指向你的扫描路径有问题而是把锅甩给某个具体的Bean。所以我的习惯是新引入集成场景时先在配置里加一行日志配置把org.springframework.context和org.springframework.boot.autoconfigure的日志级别调成DEBUG启动时直接看扫描了哪些类。定位效率翻倍。7.3 第三坑B的Controller路径在A里访问404如果A启动时日志里没有显示B的Controller被注册那基本就是扫描路径没覆盖到。如果日志里显示注册了但访问还是404那就要看A的server.servlet.context-path和B的路径是否有关系。还有可能是A的RequestMapping拦截器把请求挡了先看有没有自定义HandlerInterceptor或Filter。7.4 第四坑远程调用时Feign报参数序列化错误用Feign传对象时A和B最好共用DTO类。如果不是共用则必须在A和B各写一份相同字段的类并且保持字段名一致。Feign默认使用Jackson序列化字段名不一致会出现未知字段或反序列化失败。解决办法是给Feign配置自定义的ObjectMapper或者直接用Map接收把序列化问题延后到业务层判断。另外还要检查HTTP响应状态码。B返回状态码不是2xx时Feign会抛出FeignException走fallback时必须留意不能只吞异常而不记录日志。7.5 第五坑循环依赖与Bean生命周期的冲突当B的Bean被扫进A容器后如果A里有个全局配置依赖了B的某个Bean且B的Bean依赖了A里的某个Bean就会形成循环依赖。Spring Boot 2.6以后默认禁止循环依赖启动直接报错。解决思路用Lazy注解打破循环或者抽一个中间层消除两个Bean之间的直接引用。我一般不太建议用Lazy去掩盖问题因为循环依赖的代码结构本身就有坏味道。如果时间充裕把耦合的部分提取成独立的Service会更干净。7.6 第六坑A和B都用了同一个内嵌Servlet容器端口被占用如果A和B都是可运行工程你在本机同时启动两个进程时忘了设置不同端口第二个进程会直接报Port already in use。解决办法很简单B的application.yml里设置server.port8081或其他不与A冲突的端口。但这个坑经常出现在我明明改了端口还是报占用的怪异情形。检查一下B的配置是否被某个profile覆盖或者项目里有没有同时存在application.yml和bootstrap.yml而后者优先级更高。8. 不同场景下的最终选型建议看完上面的方案很多人还是会纠结该选哪条路。我把决策条件浓缩成一句话B需不需要独立运行A需不需要实时拿到B的最新代码根据实际业务我做了一张选型速查表项目状态推荐方案理由B和A必须同进程运行B代码内部可改方案一普通jar 组件扫描对外单端口业务上属于合并部署B和A是两个独立服务只是A要调B方案二Feign / RestTemplate解耦最彻底部署互不影响从零开始搭建多应用工程方案三Maven多模块代码统一管理构建统一执行A是巨大的老系统B是新的微服务方案二或代理转发避免老系统依赖污染降低侵入性B只提供几个查询接口A只做展示方案二RestTemplate就够简单直接不需要引入额外框架我再强调一次方案一和方案二不建议混用。一旦你决定把B的Bean扫进A容器就不应该再让A通过HTTP去调B否则会出现B既在进程内又在进程外的荒诞状态事务、缓存、日志链路都会变得混乱。9. 一整套可跑通的Demo代码结构参考如果你需要一个完整的最小工程来验证这套思路我给出一个可以直接复制的结构。假设本地有三个目录parent、module-a、module-b采用Maven多模块整理。parent的pom如下?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 modelVersion4.0.0/modelVersion groupIdcom.demo/groupId artifactIdparent/artifactId version1.0.0/version packagingpom/packaging modules modulemodule-a/module modulemodule-b/module /modules parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent properties java.version11/java.version /properties /projectmodule-b的pomproject modelVersion4.0.0/modelVersion parent groupIdcom.demo/groupId artifactIdparent/artifactId version1.0.0/version /parent artifactIdmodule-b/artifactId /projectmodule-a的pomproject modelVersion4.0.0/modelVersion parent groupIdcom.demo/groupId artifactIdparent/artifactId version1.0.0/version /parent artifactIdmodule-a/artifactId dependencies dependency groupIdcom.demo/groupId artifactIdmodule-b/artifactId version1.0.0/version /dependency /dependencies /projectmodule-b里写一个Controllerpackage com.demo.b.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 BController { GetMapping(/hello) public String hello() { return hello from B module; } }module-a的主类package com.demo.a; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication(scanBasePackages {com.demo.a, com.demo.b}) public class ApplicationA { public static void main(String[] args) { SpringApplication.run(ApplicationA.class, args); } }module-a里写一个对外接口package com.demo.a.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; RestController public class AController { GetMapping(/api/a/test) public String test() { return hello from A; } }在parent目录执行mvn clean install然后只启动module-a的ApplicationA访问http://localhost:8080/api/b/hello应该能拿到B的结果。这套demo我把最容易出错的地方都埋好了——比如module-b的启动类没有放在com.demo.b.controller包外面所以不会被A的扫描误伤module-a的scanBasePackages只扩展了两层足够覆盖A和B的主业务路径。如果你的B工程还包含自己的SpringBootApplication启动类比如路径是com.demo.b.BApplication而A扫描了com.demo.b整个父包那BApplication会作为配置类被扫描到直接触发自动配置的重新加载。这也是我前面强调过的坑务必要注意。10. 结合日志链路与观测手段怎么验证集成成功且可持续很多工程第一次集成成功后就以为完事了但真正上线后遇到问题要回溯时才发现连B的接口到底有没有被A调用到都查不清楚。所以最后这部分聊聊集成后的可观测性。10.1 利用MDC在日志里串起A到B的链路如果用的是方案二远程调用A调用B的接口时最好能传递一个traceId。做法是在A的Feign请求拦截器里把MDC中的traceId放入请求头B的拦截器或Filter里读取并放入自己的MDC。示例Component public class TraceIdFeignInterceptor implements RequestInterceptor { Override public void apply(RequestTemplate template) { String traceId MDC.get(traceId); if (traceId ! null) { template.header(X-Trace-Id, traceId); } } }B那边做一个FilterComponent public class TraceIdFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest (HttpServletRequest) request; String traceId httpRequest.getHeader(X-Trace-Id); if (traceId null || traceId.isEmpty()) { traceId UUID.randomUUID().toString(); } MDC.put(traceId, traceId); try { chain.doFilter(request, response); } finally { MDC.remove(traceId); } } }这套组合在排查分布式问题时很管用。有一次某公司内部反馈订单查询偶发超时大家查了很久没头绪最后就是靠traceId串起整个调用链发现A这边网络正常但B的线程池排队导致响应变慢。没有链路信息这种问题几乎不可能人工定位。10.2 Actuator暴露哪些端点可以帮助验收方案一集成下最值得看的端点是/actuator/mappings和/actuator/beans。mappings会列出所有已注册的HandlerMethod你直接搜索com.demo.b看不到就说明B的Controller没被加载。beans里搜bController确认Bean是否被创建且名为bController。方案二下主要看/actuator/health确认B在线以及/actuator/metrics里HTTP调用的相关指标。10.3 压测时别忽略集成路径的额外开销方案一比方案二少了一层网络开销但并非所有场景都快。B被扫进A后A的启动时间变长且A进程的内存占用会增加。方案二多了一次请求转发如果B在另一台机器上网络延迟可能数毫秒到数十毫秒不等。压测时应该以A的整体响应时间为准不要只测B单机接口的时间因为真实用户只看到A的响应时长。11. 我的选型心路从硬塞代码到尊重部署边界做了这么多类似的集成需求后我个人的体会是真正让工程师心态发生变化的不是掌握了某个API的用法而是学会判断该不该把两个进程合在一起。刚工作那两年我特别执拗于方案一觉得集成嘛B肯定要进A嘛不这么做就不叫集成。后来接手的系统越来越多被依赖冲突和发布流程折磨多了才开始认真评估方案二。现在我的原则简单很多如果B已经是一个独立启动的工程且没有强烈的合并部署需求一律默认走远程调用只有确定必须同进程、必须单端口这种硬要求时才考虑组件扫描。这两种做法没有绝对的高下之分。方案一适合一个应用包含多个模块方案二适合多个应用互相协作。你把项目背景说清楚技术选择本身就很容易定了。最后再说一个容易被忽视的小技巧无论你选哪种方案都要在A的README或代码注释里写一句这里的B是集成进来的来源在X工程更新后记得同步执行构建。这类跨工程协作如果不写清楚后来接手的人大概率会花一个下午在为什么我改了B代码但A里没反应上打转。