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,后来又起了新的网关服务A,A想复用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的Bean | B需要能被打成普通jar(或fat jar中剥离后引入) | A和B部署在一起,想要合并对外端口 |
| HTTP远程调用(Feign / RestTemplate / WebClient) | A作为客户端调用B暴露的HTTP接口 | B独立运行(独立进程),A通过地址访问 | A和B两个独立服务,B单独部署 |
| Maven多模块 + 父工程聚合 | A、B都作为parent下的module,共享依赖和构建,运行时仍可独立或合并 | A/B代码在同一仓库,结构重构 | 项目初始阶段就想统一管理多个应用 |
| 网关/路由层转发 | A内置路由,把请求转发给后端B | B独立运行,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,而是打普通jar(Spring 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> <groupId>org.springframework.boot</groupId> <artifactId>spring-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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <executions> <execution> <id>repackage</id> <goals> <goal>repackage</goal> </goals> <!-- 让fat jar带classifier后缀,避免覆盖普通jar --> <configuration> <classifier>exec</classifier> </configuration> </execution> </executions> </plugin>这样执行mvn clean install后,仓库里会同时出现 b-0.0.1-SNAPSHOT.jar(普通jar,给A引用)和 b-0.0.1-SNAPSHOT-exec.jar(可执行fat jar,B还能自己跑)。我推荐这个做法,因为万一B需要独立部署排障,你还有后路。
3.2 第二步:A里引入B的依赖
A的pom.xml加坐标即可:
<dependency> <groupId>com.demo</groupId> <artifactId>b-project</artifactId> <version>0.0.1-SNAPSHOT</version> </dependency>注意版本号别写死,最好用properties统一管理。另外如果你在本地改来改去,记得先在B工程里执行mvn clean install把它装进本地仓库,不然A引不到。
3.3 第三步:改包扫描路径,让A能发现B的Controller
这是最容易出错的一步。假设B的Controller包路径是com.demo.b.controller,A的主类包路径是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升级时你要重新打包A,B的配置变更你要重新发布A,B的依赖冲突会直接影响A的运行。这些隐形代价远远大于多部署一个进程的成本。所以只要B能独立维护,我通常优先推荐远程调用方案。
4.2 用OpenFeign实现一个最省心的远程调用层
Feign是目前最推荐的远程调用方式,因为它在接口层面声明式定义,写起来和本地调用几乎没区别。先给A加依赖:
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency>然后在A的启动类加上@EnableFeignClients:
@SpringBootApplication @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之前会选择第一个可用实例,如果只有单机B,url写死就行,不要配置服务发现,否则启动会报找不到服务名。
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"; ResponseEntity<String> 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接口加fallback:
@FeignClient(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和统一的依赖管理:
<groupId>com.demo</groupId> <artifactId>parent-project</artifactId> <version>1.0.0</version> <packaging>pom</packaging> <modules> <module>module-a</module> <module>module-b</module> </modules> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>2.7.18</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>module-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 ResponseEntity<String> 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(); HttpEntity<byte[]> entity = new HttpEntity<>(body, headers); ResponseEntity<byte[]> 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把旧版本依赖带进了A,A里其他类用到新版本的API,运行时抛NoSuchMethodError。
排错思路:
- 先看堆栈里冲突的类名。
- 在A工程里执行
mvn dependency:tree找出该类的来源。 - 找到重复依赖后,在A的pom里用
exclusions排除B传递进来且与A冲突的依赖。
这里有一个比较实用的经验:如果你的B工程代码更新比较频繁,不让它传递依赖是最省心的。在B的pom里把所有依赖都改成optional,或者A引用B时显式声明排除全部传递依赖:
<dependency> <groupId>com.demo</groupId> <artifactId>b-project</artifactId> <version>0.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.port=8081(或其他不与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 version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0"> <modelVersion>4.0.0</modelVersion> <groupId>com.demo</groupId> <artifactId>parent</artifactId> <version>1.0.0</version> <packaging>pom</packaging> <modules> <module>module-a</module> <module>module-b</module> </modules> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <properties> <java.version>11</java.version> </properties> </project>module-b的pom:
<project> <modelVersion>4.0.0</modelVersion> <parent> <groupId>com.demo</groupId> <artifactId>parent</artifactId> <version>1.0.0</version> </parent> <artifactId>module-b</artifactId> </project>module-a的pom:
<project> <modelVersion>4.0.0</modelVersion> <parent> <groupId>com.demo</groupId> <artifactId>parent</artifactId> <version>1.0.0</version> </parent> <artifactId>module-a</artifactId> <dependencies> <dependency> <groupId>com.demo</groupId> <artifactId>module-b</artifactId> <version>1.0.0</version> </dependency> </dependencies> </project>module-b里写一个Controller:
package 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那边做一个Filter:
@Component 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里没反应"上打转。